Skip to content

Commit f6c6f14

Browse files
authored
fix(unplugin): v5 more hmr fixes (#6764)
* fix(unplugin): more hmr fixes * chore: format
1 parent 0017754 commit f6c6f14

3 files changed

Lines changed: 73 additions & 46 deletions

File tree

packages/core/src/runtime/hmr-component.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { addHostEventListeners, forceUpdate, getHostRef } from 'virtual:platform
44

55
import { CMP_FLAGS, HOST_FLAGS } from '../utils/constants';
66
import { initializeComponent } from './initialize-component';
7-
import { getScopeId, registerStyle } from './styles';
7+
import { attachStyles, getScopeId, registerStyle } from './styles';
88

99
/**
1010
* Kick off hot-module-replacement for a component. In order to replace the
@@ -102,31 +102,44 @@ const hmrStandalone = async (
102102
}
103103

104104
// Re-register updated styles so live instances pick up new CSS.
105-
// For constructable stylesheets, replaceSync updates all adopted sheets
106-
// immediately; for traditional <style> tags, forceUpdate triggers addStyle.
105+
// Shadow components with constructable stylesheets: replaceSync updates
106+
// all adopted sheets immediately. Scoped/none components: registerStyle
107+
// updates the styles map, but attachStyles must be called explicitly
108+
// because updateComponent only calls it on isInitialLoad.
107109
const styleDesc = Object.getOwnPropertyDescriptor(NewClass, 'style');
108110
if (styleDesc) {
109111
Object.defineProperty(ctor, 'style', styleDesc);
110112
const newStyle = (NewClass as any).style;
111113
if (newStyle) {
112114
const scopeId = getScopeId(cmpMeta);
113115
const isShadow = !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation);
116+
console.log('[stencil-hmr] registerStyle', {
117+
tag: cmpMeta.$tagName$,
118+
scopeId,
119+
isShadow,
120+
cssLen: newStyle.length,
121+
});
114122
registerStyle(scopeId, newStyle, isShadow);
115123
}
116124
}
117125
}
118126

127+
const isShadow = !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation);
128+
119129
// Force a re-render on all live instances
120130
const instances = document.querySelectorAll(cmpMeta.$tagName$);
121131
instances.forEach((el) => {
122-
if (BUILD.hostListener) {
123-
const hostRef = getHostRef(el as any);
124-
if (hostRef?.$rmListeners$) {
125-
hostRef.$rmListeners$.map((rmListener) => rmListener());
126-
hostRef.$rmListeners$ = undefined;
127-
// Re-attach listeners with new handler references
128-
addHostEventListeners(el as any, hostRef, cmpMeta.$listeners$);
129-
}
132+
const hostRef = getHostRef(el as any);
133+
if (BUILD.hostListener && hostRef?.$rmListeners$) {
134+
hostRef.$rmListeners$.map((rmListener) => rmListener());
135+
hostRef.$rmListeners$ = undefined;
136+
addHostEventListeners(el as any, hostRef, cmpMeta.$listeners$);
137+
}
138+
// For scoped/none components, attachStyles must be called explicitly -
139+
// updateComponent only calls it when isInitialLoad is true.
140+
if (!isShadow && hostRef) {
141+
console.log('[stencil-hmr] calling attachStyles for', cmpMeta.$tagName$);
142+
attachStyles(hostRef);
130143
}
131144
forceUpdate(el);
132145
});

packages/unplugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"name": "@stencil/unplugin",
33
"version": "5.0.0-alpha.17",
44
"description": "Universal bundler plugin for Stencil components (Vite, Rollup, Webpack, esbuild)",
5-
"license": "MIT",
65
"homepage": "https://stenciljs.com/",
6+
"license": "MIT",
77
"author": "StencilJs Contributors",
88
"repository": {
99
"type": "git",

packages/unplugin/src/plugin.ts

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,25 @@
44
* Wires together four concerns into a single plugin that works across Vite,
55
* Rollup, webpack, rspack, and bun:
66
*
7-
* 1. **Component transform** `transform` drives `transpileSync` on every
7+
* 1. **Component transform** - `transform` drives `transpileSync` on every
88
* `.tsx`/`.ts` file that carries Stencil decorators, producing
99
* `customelement` output (self-registering class + `defineCustomElement`).
1010
*
11-
* 2. **CSS virtual modules** Stencil emits `./foo.css?tag=my-cmp&…`
11+
* 2. **CSS virtual modules** - Stencil emits `./foo.css?tag=my-cmp&…`
1212
* imports. `resolveId` rewrites these to `\0stencil-css:` virtual IDs;
1313
* `load` reads the real file, runs it through any installed preprocessors,
1414
* and returns `export default () => "…css…"`.
1515
*
16-
* 3. **Base-class inheritance** when a component extends a class that does
16+
* 3. **Base-class inheritance** - when a component extends a class that does
1717
* not itself extend `HTMLElement`, the custom-element registration breaks.
1818
* The plugin intercepts every file the compiler resolves via `resolveImport`
1919
* (during `transpileSync`), pre-transforms it with `transformAsBaseClass: true`
2020
* so it gets `extends HTMLElement`, and caches the result. `resolveId` then
2121
* redirects imports of those files to `\0stencil-base:` virtual modules so
22-
* the bundler always sees the injected version regardless of module-graph
22+
* the bundler always sees the injected version - regardless of module-graph
2323
* processing order.
2424
*
25-
* 4. **HMR** Vite receives a custom `stencil:hmr` WebSocket event when a
25+
* 4. **HMR** - Vite receives a custom `stencil:hmr` WebSocket event when a
2626
* component file changes; other bundlers use the `module.hot` re-execution
2727
* pattern. CSS changes are covered automatically by `addWatchFile`.
2828
*/
@@ -97,7 +97,7 @@ async function scanDocs(filter: (id: string) => boolean): Promise<void> {
9797
);
9898
}
9999

100-
// Null-byte prefix marks a virtual module Rollup/Vite convention that
100+
// Null-byte prefix marks a virtual module - Rollup/Vite convention that
101101
// prevents the ID from being treated as a real filesystem path.
102102
const VIRTUAL_BASE_PREFIX = '\0stencil-base:';
103103

@@ -128,18 +128,26 @@ export const unpluginStencil = createUnplugin(
128128
// Vite HMR handler to send targeted `stencil:hmr` events.
129129
const fileToTagName = new Map<string, string>();
130130

131-
// Maps CSS dependency paths (including Sass @use/@import) → tag name so
132-
// changes to imported partials also trigger HMR for the component.
133-
const cssFileToTagName = new Map<string, string>();
131+
// Maps CSS file paths → tag names that use them. A shared CSS file can be
132+
// used by multiple components, so this is a Set per file path.
133+
const cssFileToTagNames = new Map<string, Set<string>>();
134134

135-
// Maps real CSS file path → virtual CSS module ID (\0stencil-css:...) so
136-
// handleHotUpdate can locate and invalidate the right module in the graph.
137-
const cssRealToVirtualId = new Map<string, string>();
135+
// Maps real CSS file path → virtual CSS module IDs (\0stencil-css:...).
136+
// Multiple components may share the same CSS file, each with their own
137+
// virtual module ID (different tag/encapsulation query params).
138+
const cssRealToVirtualIds = new Map<string, Set<string>>();
139+
140+
function trackCssFile(realPath: string, tag: string, virtualId: string) {
141+
if (!cssFileToTagNames.has(realPath)) cssFileToTagNames.set(realPath, new Set());
142+
cssFileToTagNames.get(realPath)!.add(tag);
143+
if (!cssRealToVirtualIds.has(realPath)) cssRealToVirtualIds.set(realPath, new Set());
144+
cssRealToVirtualIds.get(realPath)!.add(virtualId);
145+
}
138146

139147
// absPath → HTMLElement-injected JS. Populated during `transform` when
140148
// `transpileSync`'s resolveImport callback discovers a base-class file.
141149
// Rollup guarantees that resolveId for imports in a module's *output* fires
142-
// after the transform that produced them so by the time the bundler asks
150+
// after the transform that produced them - so by the time the bundler asks
143151
// to resolve a base-class import, this map is already populated.
144152
const baseClassRegistry = new Map<string, string>();
145153

@@ -174,7 +182,7 @@ export const unpluginStencil = createUnplugin(
174182

175183
if (baseClassRegistry.size > 0) {
176184
// Imports inside a virtual base-class module use the virtual ID as
177-
// importer strip the prefix so resolveSpecifier works against the
185+
// importer - strip the prefix so resolveSpecifier works against the
178186
// real path on disk. This matters for multi-level inheritance chains.
179187

180188
const realImporter = importer.startsWith(VIRTUAL_BASE_PREFIX)
@@ -234,16 +242,14 @@ export const unpluginStencil = createUnplugin(
234242
const tag = qIdx !== -1 ? new URLSearchParams(id.slice(qIdx + 1)).get('tag') : null;
235243
if (realPath) {
236244
this.addWatchFile(realPath);
237-
if (tag) cssFileToTagName.set(realPath, tag);
238-
cssRealToVirtualId.set(realPath, id);
245+
if (tag) trackCssFile(realPath, tag, id);
239246
}
240247
const cssResult = await loadStencilCss(id, isDev);
241248
if (cssResult) {
242249
if (tag) {
243250
for (const dep of cssResult.deps) {
244251
this.addWatchFile(dep);
245-
cssFileToTagName.set(dep, tag);
246-
cssRealToVirtualId.set(dep, id);
252+
trackCssFile(dep, tag, id);
247253
}
248254
}
249255
return { code: cssResult.code, map: null };
@@ -280,27 +286,33 @@ export const unpluginStencil = createUnplugin(
280286
};
281287
};
282288
}) {
283-
const tagName = fileToTagName.get(file) ?? cssFileToTagName.get(file);
284-
if (!tagName) return;
285-
// Invalidate the virtual CSS module (\0stencil-css:...) so Vite
286-
// rewrites its import URL with ?t=timestamp when serving the
287-
// re-fetched TSX — prevents the browser's ESM cache serving stale CSS.
288-
const virtualId = cssRealToVirtualId.get(file);
289-
if (virtualId) {
290-
const virtualMod =
291-
server.moduleGraph.getModuleById?.(virtualId) ??
292-
server.moduleGraph.idToModuleMap?.get(virtualId);
293-
if (virtualMod)
294-
server.moduleGraph.invalidateModule(virtualMod, new Set(), Date.now(), true);
289+
// Collect all tag names affected by this file change
290+
const tsxTag = fileToTagName.get(file);
291+
const cssTagNames = cssFileToTagNames.get(file);
292+
const tagNames = new Set<string>(cssTagNames);
293+
if (tsxTag) tagNames.add(tsxTag);
294+
if (tagNames.size === 0) return;
295+
296+
// Invalidate every virtual CSS module that depends on this file so
297+
// Vite adds ?t=timestamp when re-serving the TSX - busts browser cache.
298+
const virtualIds = cssRealToVirtualIds.get(file);
299+
if (virtualIds) {
300+
for (const virtualId of virtualIds) {
301+
const virtualMod =
302+
server.moduleGraph.getModuleById?.(virtualId) ??
303+
server.moduleGraph.idToModuleMap?.get(virtualId);
304+
if (virtualMod)
305+
server.moduleGraph.invalidateModule(virtualMod, new Set(), Date.now(), true);
306+
}
295307
}
296308
// Update the docs registry and notify the client only when the CEM
297309
// actually changed (new/renamed prop, type update, JSDoc edit, etc.).
298310
// Pure implementation changes leave the CEM identical and fall through
299311
// to normal stencil:hmr so HMR is not disrupted.
300-
if (options.docs && fileToTagName.has(file)) {
312+
if (options.docs && tsxTag) {
301313
let cemChanged = false;
302314
try {
303-
const prevSnapshot = JSON.stringify(docsRegistry.get(tagName));
315+
const prevSnapshot = JSON.stringify(docsRegistry.get(tsxTag));
304316
const code = readFileSync(file, 'utf-8');
305317
const result = await transpile(code, { file, componentExport: 'customelement' });
306318
for (const item of result.data ?? []) {
@@ -309,7 +321,7 @@ export const unpluginStencil = createUnplugin(
309321
resolveImportedTypes(component, file);
310322
docsRegistry.set(item.tagName, component);
311323
}
312-
cemChanged = JSON.stringify(docsRegistry.get(tagName)) !== prevSnapshot;
324+
cemChanged = JSON.stringify(docsRegistry.get(tsxTag)) !== prevSnapshot;
313325
} catch {
314326
// stale docs are acceptable on transpile error
315327
}
@@ -322,7 +334,9 @@ export const unpluginStencil = createUnplugin(
322334
server.ws.send({ type: 'custom', event: 'stencil:docs-update' });
323335
}
324336
}
325-
server.ws.send({ type: 'custom', event: 'stencil:hmr', data: { tagName } });
337+
for (const tagName of tagNames) {
338+
server.ws.send({ type: 'custom', event: 'stencil:hmr', data: { tagName } });
339+
}
326340
return [];
327341
},
328342
},

0 commit comments

Comments
 (0)