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.
102102const 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