ember-vite-hmr 2.3.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/hmr.js CHANGED
@@ -9,235 +9,198 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import path from 'path';
11
11
  import { fileURLToPath } from 'url';
12
- import { parseSync } from '@babel/core';
13
- import traverseModule from '@babel/traverse';
14
- const traverse = traverseModule.default || traverseModule;
15
12
  import { readFile } from 'fs/promises';
13
+ import { hmrImportMetadataCache } from './babel-plugin.js';
16
14
  const __filename = fileURLToPath(import.meta.url);
17
15
  const __dirname = path.dirname(__filename);
18
- // Above this many distinct named blocks, fall back to forwarding all of them
19
- // unconditionally rather than generating an exponential number of branches
20
- // (see generateInvocations below for why branching is needed at all).
21
- const MAX_COMBINATORIAL_YIELDS = 8;
22
- function blockTag(y) {
23
- return `<:${y} as |a b c d e f g h i j k l|>{{yield a b c d e f g h i j k l to='${y}'}}</:${y}>`;
16
+ // Embroider's resolver registry. It's the binding the app entry's HMR hook
17
+ // mutates, so we use it to locate that entry. Embroider keeps this as an
18
+ // internal literal (not a public export), so we mirror the string here.
19
+ const compatModulesSpecifier = '@embroider/virtual/compat-modules';
20
+ export const hmrRuntimeId = 'virtual:ember-vite-hmr-runtime';
21
+ const resolvedRuntimeId = '\0' + hmrRuntimeId;
22
+ // This runs in the browser.
23
+ // It has no imports on purpose: a virtual module has no location on disk,
24
+ // so Embroider's resolver has nothing to resolve `@glimmer/tracking` against.
25
+ // The app modules that call `define` can import `tracked` normally and pass
26
+ // it in.
27
+ //
28
+ // `byValue` lets an old reference to a component class find its current
29
+ // registry entry, so modules holding stale bindings still serve the newest
30
+ // version.
31
+ //
32
+ // `consumed` lets a self-accepting module that nobody rendered through
33
+ // fall back to `invalidate()` rather than silently doing nothing.
34
+ //
35
+ // `tracked` is called as a function (not a decorator syntax), so the runtime
36
+ // doesn't depend on the app's Babel decorator config.
37
+ const runtimeSource = `
38
+ const entries = new WeakMap();
39
+
40
+ // Per-class FIFO queues of live component instances, used to pair old instances
41
+ // (being destroyed by an HMR swap) with the new instances that replace them.
42
+ // Keyed by the NEW class (the replacement). Cleared at update() time so that
43
+ // instances created by normal user-flow navigation before the swap are never
44
+ // in the queue — only instances created after the swap callback fires.
45
+ const liveInstanceQueues = new WeakMap();
46
+
47
+ function isRef(value) {
48
+ return value !== null && (typeof value === 'object' || typeof value === 'function');
24
49
  }
25
- function invocation(selected) {
26
- if (!selected.length) {
27
- return `<this.curried @__hot__={{this.hot}} ...attributes />`;
28
- }
29
- return `<this.curried @__hot__={{this.hot}} ...attributes>${selected.map(blockTag).join('\n')}</this.curried>`;
50
+
51
+ function makeEntry(tracked, value) {
52
+ class Entry {}
53
+ const desc = tracked(Entry.prototype, 'current', { configurable: true, enumerable: true, writable: true, initializer: null });
54
+ Object.defineProperty(Entry.prototype, 'current', desc);
55
+ const entry = new Entry();
56
+ entry.current = value;
57
+ entry.consumed = false;
58
+ return entry;
30
59
  }
31
- // Glimmer determines a target component's `(has-block "y")` purely from
32
- // whether a `<:y>` named block tag is structurally present on the
33
- // invocation, not from whether that block's content renders anything, and
34
- // named block tags cannot be nested inside `{{#if}}` (a "named block nested
35
- // in a normal block" compile error). So a per-block `{{#if (has-block 'y')}}
36
- // <:y>...</:y>{{/if}}` inside a single invocation can't work — instead this
37
- // recursively branches on `(has-block y)` for each known named block to
38
- // build one fully static invocation per combination of blocks the caller
39
- // actually passed, so blocks not passed are truly absent rather than merely
40
- // empty (see #531).
41
- function generateInvocations(remaining, selected) {
42
- if (!remaining.length) {
43
- return invocation(selected);
44
- }
45
- const [y, ...rest] = remaining;
46
- return `{{#if (has-block '${y}')}}${generateInvocations(rest, [...selected, y])}{{else}}${generateInvocations(rest, selected)}{{/if}}`;
60
+
61
+ // Register value for the first time. No-op if already registered.
62
+ export function register(value, tracked) {
63
+ if (!isRef(value)) return;
64
+ if (!entries.has(value)) {
65
+ entries.set(value, makeEntry(tracked, value));
66
+ }
47
67
  }
48
- function generateContent(yields) {
49
- if (!yields.includes('default')) {
50
- yields.push('default');
51
- }
52
- if (yields.length > MAX_COMBINATORIAL_YIELDS) {
53
- let all = yields.map((y) => `(has-block '${y}')`).join(' ');
54
- return `
55
- {{#if (notAny ${all})}}
56
- <this.curried @__hot__={{this.hot}} ...attributes />
57
- {{else}}
58
- ${invocation(yields)}
59
- {{/if}}
60
- `;
68
+
69
+ // Re-point an existing entry from oldValue to newValue.
70
+ // Also registers newValue under the same entry so future current() calls
71
+ // on newValue resolve to whatever is current at that point.
72
+ //
73
+ // Clearing liveInstanceQueues[newValue] here is the key to correctness for
74
+ // syncState across user-flow create/destroy cycles. Any instances of newValue
75
+ // created and destroyed by normal navigation *before* this swap happened are
76
+ // stale. By resetting the queue at swap time, only instances created *after*
77
+ // this update() call (the replacement instances Glimmer is about to create)
78
+ // are eligible to receive state from the old instances being torn down.
79
+ export function update(oldValue, newValue) {
80
+ if (!isRef(oldValue)) return;
81
+ const entry = entries.get(oldValue);
82
+ if (entry) {
83
+ entry.current = newValue;
84
+ if (isRef(newValue)) {
85
+ entries.set(newValue, entry);
86
+ // Clear the queue for newValue so only instances created after this
87
+ // swap are paired with old instances being torn down.
88
+ liveInstanceQueues.delete(newValue);
61
89
  }
62
- return generateInvocations(yields, []);
90
+ // oldValue is being retired — it will never be the target of a future
91
+ // dequeueInstance() call, so any queued instances for it are now stale.
92
+ liveInstanceQueues.delete(oldValue);
93
+ }
63
94
  }
64
- const getHotComponent = (imp, specifier, yields) => `
65
- import { ${specifier} as TargetComponent } from "${imp}";
66
- import Component from "@glimmer/component";
67
- import { tracked } from "@glimmer/tracking";
68
- import { createComputeRef } from "@glimmer/reference";
69
- import { curry } from '@glimmer/runtime';
70
- import { registerDestructor } from '@ember/destroyable';
71
- import { getInternalComponentManager, setInternalComponentManager } from '@glimmer/manager';
72
95
 
73
- function notAny(...yields) {
74
- return !yields.some((y) => !!y);
96
+ export function current(value) {
97
+ if (!isRef(value)) return value;
98
+ const entry = entries.get(value);
99
+ if (!entry) return value;
100
+ entry.consumed = true;
101
+ return entry.current;
75
102
  }
76
103
 
77
- const hotCallbacks = new Set();
78
-
79
- if (import.meta.hot) {
80
- import.meta.hot.accept('${imp}', (module) => {
81
- for (const callback of hotCallbacks) {
82
- callback(module);
83
- }
84
- });
104
+ export function used(value) {
105
+ if (!isRef(value)) return false;
106
+ return Boolean(entries.get(value)?.consumed);
85
107
  }
86
108
 
87
- // Stack of CapturedArguments (see @glimmer/interfaces), pushed by the shadow
88
- // component manager below right before it constructs a HotComponent instance,
89
- // and peeked (not popped) here in the constructor. It is always non-empty at
90
- // this point because HotComponent is only ever created through that manager.
91
- const capturedArgsStack = [];
109
+ // Returns true if value has been registered with the HMR runtime.
110
+ // Used by setup-hmr-manager.ts to skip non-HMR components when building
111
+ // the liveInstances queue so stale entries don't corrupt later HMR swaps.
112
+ export function isHmrClass(value) {
113
+ return isRef(value) && entries.has(value);
114
+ }
92
115
 
93
- export default class HotComponent extends Component {
94
- @tracked curried;
95
- hot = {};
96
- constructor(owner, args) {
97
- super(owner, args);
98
- const capturedArgs = capturedArgsStack[capturedArgsStack.length - 1];
99
- const named = {};
100
- const positional = [];
101
- for (const name of Object.keys(args)) {
102
- // Forward the caller's original, updatable reference (e.g. a path like
103
- // "this.value") instead of wrapping the reified value in a fresh
104
- // read-only compute ref. A read-only ref fails Ember's "You can only
105
- // pass a path to mut" check, breaking {{mut @arg}} in wrapped components.
106
- named[name] = capturedArgs?.named[name] ?? createComputeRef(() => args[name]);
107
- }
108
- const CurriedComponent = 0;
109
- this.curried = curry(CurriedComponent, TargetComponent, owner, { positional, named});
110
- if (import.meta.hot) {
111
- const callback = (module) => {
112
- this.curried = curry(CurriedComponent, module.default, owner, { positional, named});
113
- };
114
- hotCallbacks.add(callback);
115
- registerDestructor(this, () => {
116
- hotCallbacks.delete(callback);
117
- });
118
- }
116
+ // Called from setup-hmr-manager.ts's create() hook for each new HMR-registered
117
+ // component instance. Appends to the per-class queue in DOM (creation) order.
118
+ export function enqueueInstance(klass, instance) {
119
+ if (!isRef(klass)) return;
120
+ const q = liveInstanceQueues.get(klass);
121
+ if (q) {
122
+ q.push(instance);
123
+ } else {
124
+ liveInstanceQueues.set(klass, [instance]);
119
125
  }
126
+ }
120
127
 
121
- <template>
122
- ${generateContent(yields)}
123
- </template>
128
+ // Called from setup-hmr-manager.ts's willDestroy hook. Returns and removes the
129
+ // oldest queued instance for klass (FIFO = DOM order), or null if none.
130
+ export function dequeueInstance(klass) {
131
+ if (!isRef(klass)) return null;
132
+ const q = liveInstanceQueues.get(klass);
133
+ if (!q || q.length === 0) return null;
134
+ const instance = q.shift();
135
+ if (q.length === 0) liveInstanceQueues.delete(klass);
136
+ return instance ?? null;
124
137
  }
125
138
 
126
- // HotComponent's own args (the reified values passed to its constructor
127
- // above) only expose values, not the underlying VM references, so the raw
128
- // references have to be captured one level down, in the internal component
129
- // manager's create() hook, before Ember reifies them into that value-only
130
- // proxy. Shadowing the manager (rather than patching the shared default
131
- // manager's create()) scopes the capture to HotComponent only; getManager()
132
- // walks the prototype chain and finds this before reaching @glimmer/component's
133
- // default manager.
134
- const defaultManager = getInternalComponentManager(Component);
135
- const shadowManager = Object.create(defaultManager);
136
- shadowManager.create = function (owner, definition, vmArgs) {
137
- capturedArgsStack.push(vmArgs.capture());
138
- try {
139
- return defaultManager.create(owner, definition, vmArgs);
140
- } finally {
141
- capturedArgsStack.pop();
142
- }
143
- };
144
- // Glimmer's debug render tree normally adds one 'component' node per
145
- // invoked component instance (see VM_GET_COMPONENT_SELF_OP). Returning no
146
- // nodes here suppresses that node for HotComponent itself, so only the
147
- // real, curried target component (invoked in HotComponent's own template)
148
- // shows up in ember-inspector's component tree - this is the same hook
149
- // Ember core uses internally to keep its own {{outlet}}/{{mount}} wrapper
150
- // machinery out of (or relabeled in) that tree (see OutletComponentManager
151
- // and MountManager in ember-source).
152
- shadowManager.getDebugCustomRenderTree = function () {
153
- return [];
154
- };
155
- setInternalComponentManager(shadowManager, HotComponent);
139
+ // Called from setup-hmr-manager.ts's willDestroy hook when no HMR swap is in
140
+ // progress (normal navigation destroy). Removes this specific instance from the
141
+ // queue so destroyed instances don't accumulate indefinitely when a timer
142
+ // mounts/unmounts the same component repeatedly between saves.
143
+ export function removeInstance(klass, instance) {
144
+ if (!isRef(klass)) return;
145
+ const q = liveInstanceQueues.get(klass);
146
+ if (!q) return;
147
+ const idx = q.indexOf(instance);
148
+ if (idx !== -1) q.splice(idx, 1);
149
+ if (q.length === 0) liveInstanceQueues.delete(klass);
150
+ }
151
+
152
+ // Expose helpers on the global so setup-hmr-manager.ts's synchronous
153
+ // initialize() can call them without a dynamic import.
154
+ globalThis.__ember_vite_hmr = { current, isHmrClass, enqueueInstance, dequeueInstance, removeInstance };
156
155
  `;
157
- const cachedYields = {};
158
- function getYieldsFromFile(filename, content, noCache) {
159
- if (cachedYields[filename] && !noCache) {
160
- return cachedYields[filename];
161
- }
162
- // very basic, todo: make this use AST
163
- const matches = content.matchAll(/to=['"\\]+(\w+)['"\\]+/g);
164
- const yields = new Set([...matches].map((m) => m === null || m === void 0 ? void 0 : m[1]).filter((m) => !!m));
165
- if (noCache) {
166
- return {
167
- yields,
168
- modules: [],
169
- };
170
- }
171
- cachedYields[filename] = {
172
- yields,
173
- modules: [],
156
+ // `enforce: 'pre'` makes this run before Embroider's resolver, which would
157
+ // otherwise try to resolve the virtual id as a package and fail.
158
+ // `apply: 'serve'` keeps it out of production builds entirely.
159
+ export function hmrRuntime() {
160
+ return {
161
+ name: 'ember-vite-hmr-runtime',
162
+ enforce: 'pre',
163
+ apply: 'serve',
164
+ resolveId(source) {
165
+ if (source === hmrRuntimeId) {
166
+ return resolvedRuntimeId;
167
+ }
168
+ },
169
+ load(id) {
170
+ if (id === resolvedRuntimeId) {
171
+ return runtimeSource;
172
+ }
173
+ },
174
174
  };
175
- return cachedYields[filename];
176
- }
177
- function difference(a, b) {
178
- const diff = [];
179
- for (const bElement of b) {
180
- if (!a.has(bElement)) {
181
- diff.push(bElement);
182
- }
183
- }
184
- return diff;
185
175
  }
186
176
  // Helper function to normalize paths consistently across platforms
187
177
  function normalizePath(inputPath) {
188
- // Always convert backslashes to forward slashes
189
178
  return inputPath.replace(/\\/g, '/');
190
179
  }
191
- const virtualPrefix = '/ember-vite-hmr/virtual/component:';
192
- // Embroider's resolver registry. It's the binding the app entry's HMR hook
193
- // mutates, so we use it to locate that entry. Embroider keeps this as an
194
- // internal literal (not a public export), so we mirror the string here.
195
- const compatModulesSpecifier = '@embroider/virtual/compat-modules';
196
180
  export function hmr(enableViteHmrForModes = ['development']) {
197
181
  let base = '/';
182
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
198
183
  let server;
199
- return {
184
+ const mainPlugin = {
200
185
  name: 'hmr-plugin',
201
186
  enforce: 'post',
202
187
  config(config, env) {
203
188
  var _a;
204
- // The per-component hot wrapper (see getHotComponent) imports the first
205
- // four of these, but it is generated/served on demand, so it is never
206
- // part of the static module graph Vite's dependency scanner crawls on
207
- // the first pass. `@ember/component/template-only` has the same
208
- // problem for a different reason: it's injected by the template
209
- // compiler into a template-only component's compiled output, which the
210
- // scanner (source-only) never sees either. Without pre-declaring them,
211
- // the browser requests them on boot, Vite discovers "new" deps and
212
- // triggers a re-optimize + full page reload.
213
- //
214
- // We must use the Embroider-rewritten `ember-source/...` subpaths: the
215
- // bare `@glimmer/reference` etc. specifiers the wrapper writes cannot be
216
- // resolved by optimizeDeps.include. (`@glimmer/component` and
217
- // `@glimmer/tracking` are omitted on purpose — normal app code already
218
- // pulls them into the scan.)
219
189
  if (!enableViteHmrForModes.includes(env.mode)) {
220
190
  return;
221
191
  }
222
192
  // With `optimizeDeps.noDiscovery: true`, Vite never scans the app's own
223
- // source, so nothing else pulls these same glimmer subpaths into the
224
- // optimizer. Forcing them into `include` then creates a second,
225
- // separately pre-bundled copy of the glimmer VM alongside the
226
- // unoptimized one the rest of the (unscanned) app actually uses,
227
- // leading to "The global context for Glimmer VM was not set" (#554).
228
- // There's nothing useful this hook can pre-declare in that mode, so
229
- // skip it entirely rather than fight the user's own dep-optimization
230
- // config.
193
+ // source, so nothing else pulls these glimmer subpaths into the optimizer.
194
+ // Forcing them into `include` then creates a second, separately pre-bundled
195
+ // copy of the glimmer VM alongside the unoptimized one the rest of the
196
+ // (unscanned) app actually uses, leading to "The global context for Glimmer
197
+ // VM was not set" (#554). Skip entirely in that mode.
231
198
  if ((_a = config.optimizeDeps) === null || _a === void 0 ? void 0 : _a.noDiscovery) {
232
199
  return;
233
200
  }
234
201
  return {
235
202
  optimizeDeps: {
236
203
  include: [
237
- 'ember-source/@glimmer/reference/index.js',
238
- 'ember-source/@glimmer/runtime/index.js',
239
- 'ember-source/@ember/destroyable/index.js',
240
- 'ember-source/@glimmer/manager/index.js',
241
204
  'ember-source/@ember/component/template-only.js',
242
205
  ],
243
206
  },
@@ -259,22 +222,6 @@ export function hmr(enableViteHmrForModes = ['development']) {
259
222
  if (id.includes('@ember-vite-hmr/setup-ember-hmr.js')) {
260
223
  return id;
261
224
  }
262
- if (importer === null || importer === void 0 ? void 0 : importer.startsWith(virtualPrefix)) {
263
- const newImporter = path.join(process.cwd(), 'package.json');
264
- importer = newImporter;
265
- return this.resolve(id, importer, meta);
266
- }
267
- if (id.startsWith(virtualPrefix)) {
268
- let [imp, specifier] = id
269
- .split('?')[0]
270
- .slice(virtualPrefix.length)
271
- .split('::');
272
- if (imp === null || imp === void 0 ? void 0 : imp.startsWith('.')) {
273
- const r = yield this.resolve(imp, importer);
274
- return id.replace(`${imp}::${specifier}`, `${r.id}::${specifier}`);
275
- }
276
- return id;
277
- }
278
225
  if (id === '/ember-vite-hmr/services/vite-hot-reload') {
279
226
  return this.resolve('ember-vite-hmr/services/vite-hot-reload', path.resolve(process.cwd(), 'package.json'), meta);
280
227
  }
@@ -285,56 +232,6 @@ export function hmr(enableViteHmrForModes = ['development']) {
285
232
  if (id.includes('@ember-vite-hmr/setup-ember-hmr.js')) {
286
233
  return yield readFile(path.resolve(__dirname, '..', 'setup-ember-hmr.js'), 'utf8');
287
234
  }
288
- if (id.startsWith(virtualPrefix)) {
289
- if (!server) {
290
- // During build, server is not available
291
- return null;
292
- }
293
- let [imp, specifier] = id
294
- .split('?')[0]
295
- .slice(virtualPrefix.length, -'.gts'.length)
296
- .split('::');
297
- imp = imp.replace('embroider_virtual', '@embroider/virtual');
298
- let filename = imp;
299
- if (filename.includes('__vpc__')) {
300
- filename = filename.split('__vpc__')[0];
301
- }
302
- const res = yield server.transformRequest(filename);
303
- const content = res === null || res === void 0 ? void 0 : res.code;
304
- // For a classic component with a separately resolved template (a colocated
305
- // `.hbs` file next to a backing class, or a template-only component), the
306
- // compiled backing module only *imports* its template (e.g. `import TEMPLATE
307
- // from "./foo.hbs?import"`); it doesn't inline it. Any {{yield ... to="..."}}
308
- // usage lives in that template file, so it has to be fetched and scanned
309
- // separately, otherwise named blocks other than "default" are never detected
310
- // and get silently dropped by the generated hot-reload wrapper.
311
- let yieldSourceFilename = filename;
312
- let yieldSourceContent = content;
313
- const templateImportMatch = content === null || content === void 0 ? void 0 : content.match(/\bfrom\s*['"]([^'"]+\.hbs(?:\?[^'"]*)?)['"]/);
314
- if (templateImportMatch) {
315
- // The compiled backing class embeds the template import as a
316
- // browser-facing URL (i.e. prefixed with the configured vite
317
- // `base`), since that's what gets sent to the client. But
318
- // `server.transformRequest` is an internal API keyed by root-
319
- // relative ids, so the base has to be stripped back off before
320
- // reusing that specifier here, or this 404s under a non-root base.
321
- let templateSpecifier = templateImportMatch[1];
322
- if (base !== '/' && templateSpecifier.startsWith(base)) {
323
- templateSpecifier = `/${templateSpecifier.slice(base.length)}`;
324
- }
325
- const templateRes = yield server.transformRequest(templateSpecifier);
326
- yieldSourceFilename = templateSpecifier;
327
- yieldSourceContent = templateRes === null || templateRes === void 0 ? void 0 : templateRes.code;
328
- }
329
- const resId = yield this.resolve(yieldSourceFilename, path.resolve(process.cwd(), 'package.json'));
330
- // Strip any query string so the cache key matches the plain file path that
331
- // `hotUpdate` below receives when that template file is edited.
332
- const cacheKey = resId.id.split('?')[0];
333
- const cached = getYieldsFromFile(cacheKey, yieldSourceContent !== null && yieldSourceContent !== void 0 ? yieldSourceContent : '');
334
- const yields = cached.yields;
335
- cached.modules.push(id);
336
- return getHotComponent(imp, specifier, [...yields].filter((y) => !!y));
337
- }
338
235
  });
339
236
  },
340
237
  transformIndexHtml(html) {
@@ -353,49 +250,69 @@ export function hmr(enableViteHmrForModes = ['development']) {
353
250
  ];
354
251
  },
355
252
  handleHotUpdate(ctx) {
356
- if (!ctx.file.split('?')[0].endsWith('.hbs')) {
357
- return ctx.modules;
358
- }
253
+ var _a, _b;
254
+ const file = ctx.file.split('?')[0];
359
255
  const otherModules = [];
360
- const pairedModule = ctx.modules.find((m) => [...m.importers].find((i) => i.id.startsWith('embroider_virtual') &&
361
- i.id.endsWith('-embroider-pair-component')));
362
- if (pairedModule) {
363
- const pairComponent = [...pairedModule.importers].find((i) => i.id.startsWith('embroider_virtual') &&
364
- i.id.endsWith('-embroider-pair-component'));
365
- if (pairComponent) {
366
- const componentModule = [...pairComponent.clientImportedModules].find((cim) => cim.id.split('?')[0].match(/\/component\.(js|ts|gjs|gts)/));
367
- if (componentModule) {
368
- otherModules.push(componentModule);
256
+ // ── .hbs pair-component wiring (unchanged) ───────────────────────────
257
+ if (file.endsWith('.hbs')) {
258
+ const pairedModule = ctx.modules.find((m) => [...m.importers].find((i) => i.id.startsWith('embroider_virtual') &&
259
+ i.id.endsWith('-embroider-pair-component')));
260
+ if (pairedModule) {
261
+ const pairComponent = [...pairedModule.importers].find((i) => i.id.startsWith('embroider_virtual') &&
262
+ i.id.endsWith('-embroider-pair-component'));
263
+ if (pairComponent) {
264
+ const componentModule = [...pairComponent.clientImportedModules].find((cim) => cim.id.split('?')[0].match(/\/component\.(js|ts|gjs|gts)/));
265
+ if (componentModule) {
266
+ otherModules.push(componentModule);
267
+ }
369
268
  }
370
269
  }
270
+ return [...ctx.modules, ...otherModules];
371
271
  }
372
- return [...ctx.modules, ...otherModules];
373
- },
374
- hotUpdate(options) {
375
- return __awaiter(this, void 0, void 0, function* () {
376
- if (options.type === 'update' || options.type === 'create') {
377
- const id = options.file;
378
- const source = (yield readFile(id)).toString();
379
- if (cachedYields[id] &&
380
- difference(cachedYields[id].yields, getYieldsFromFile(id, source, true).yields).length) {
381
- for (const y of getYieldsFromFile(id, source).yields) {
382
- cachedYields[id].yields.add(y);
383
- }
384
- const modules = cachedYields[id].modules;
385
- delete cachedYields[id];
386
- for (const module of modules) {
387
- server.moduleGraph.onFileChange(module);
388
- let m = server.moduleGraph.getModuleById(module);
389
- if (m) {
390
- yield server.reloadModule(m);
391
- }
272
+ // ── subclass propagation ──────────────────────────────────────────────
273
+ // When a component file (e.g. block-base.gts) self-accepts, Vite stops
274
+ // propagation before any subclass module (e.g. block-child.ts extends
275
+ // BlockBase) can be notified. The subclass's importer (e.g. equipment.gts)
276
+ // therefore never fires its accept(block-child, cb) callback, so
277
+ // template__imports__.BlockChild keeps pointing at the old class with
278
+ // the old template.
279
+ //
280
+ // Fix: for every component file that changed, walk its direct importers
281
+ // in Vite's module graph. Any importer that is itself a component file
282
+ // under /components/ is a potential subclass — add it to the update set
283
+ // so Vite re-evaluates it too. That re-evaluation runs its own
284
+ // self-accept callback (update(OldChild, NewChild)) and causes the
285
+ // parent template's accept(block-child, cb) to fire, swapping in the new
286
+ // subclass and its newly-inherited template.
287
+ // Subclass propagation only applies to files under /components/ — route
288
+ // templates (.gts under /templates/) import components but are not
289
+ // subclasses of them. Including templates would force Vite to re-evaluate
290
+ // equipment.gts when resource-holder.gts changes, breaking the existing
291
+ // accept(dep, cb) wiring that already handles that case correctly.
292
+ const isComponentFile = (p) => !p.includes('node_modules') &&
293
+ !p.includes('/-components/') &&
294
+ p.includes('/components/') &&
295
+ (p.endsWith('.gjs') ||
296
+ p.endsWith('.gts') ||
297
+ p.endsWith('.ts') ||
298
+ p.endsWith('.js'));
299
+ if (isComponentFile(file)) {
300
+ for (const mod of ctx.modules) {
301
+ for (const importer of mod.importers) {
302
+ const importerId = normalizePath((_b = (_a = importer.id) === null || _a === void 0 ? void 0 : _a.split('?')[0]) !== null && _b !== void 0 ? _b : '');
303
+ if (isComponentFile(importerId) &&
304
+ !ctx.modules.some((m) => m.id === importer.id) &&
305
+ !otherModules.includes(importer)) {
306
+ otherModules.push(importer);
392
307
  }
393
308
  }
394
309
  }
395
- });
310
+ }
311
+ return [...ctx.modules, ...otherModules];
396
312
  },
397
313
  transform(source, id) {
398
314
  return __awaiter(this, void 0, void 0, function* () {
315
+ var _a, _b;
399
316
  if (process.env.EMBER_VITE_HMR_ENABLED !== 'true') {
400
317
  return source;
401
318
  }
@@ -424,142 +341,116 @@ export function hmr(enableViteHmrForModes = ['development']) {
424
341
  });
425
342
  }`;
426
343
  }
427
- if (resourcePath.includes('ember-vite-hmr/virtual/components')) {
428
- return source;
429
- }
430
344
  if (resourcePath.includes('node_modules')) {
431
345
  return source;
432
346
  }
433
- // Add hot reload statements for tracked imports (only non-node_modules)
434
- // Use babel visitor to extract __hmr_import_metadata__
435
- const result = parseSync(source, {
436
- filename: resourcePath,
437
- ast: true,
438
- code: false,
439
- configFile: false,
440
- babelrc: false,
441
- plugins: [
442
- ['@babel/plugin-syntax-typescript', { isTSX: true }],
443
- ['@babel/plugin-proposal-decorators', { version: '2022-03' }],
444
- ],
445
- });
446
- if (result) {
447
- let importVar = null;
448
- let bindings = [];
449
- const importStatements = [];
450
- // First pass: Extract metadata
451
- traverse(result, {
452
- ExportNamedDeclaration(path) {
453
- var _a, _b, _c;
454
- const declaration = path.node.declaration;
455
- // Check if this is: export const __hmr_import_metadata__ = {...}
456
- if ((declaration === null || declaration === void 0 ? void 0 : declaration.type) === 'VariableDeclaration' &&
457
- ((_c = (_b = (_a = declaration.declarations) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.id) === null || _c === void 0 ? void 0 : _c.name) ===
458
- '__hmr_import_metadata__') {
459
- const init = declaration.declarations[0].init;
460
- if ((init === null || init === void 0 ? void 0 : init.type) === 'ObjectExpression') {
461
- // Extract importVar and bindings from the object
462
- for (const prop of init.properties) {
463
- if (prop.type === 'ObjectProperty' &&
464
- prop.key.type === 'Identifier') {
465
- if (prop.key.name === 'importVar' &&
466
- prop.value.type === 'StringLiteral') {
467
- importVar = prop.value.value;
468
- }
469
- else if (prop.key.name === 'bindings' &&
470
- prop.value.type === 'ArrayExpression') {
471
- bindings = prop.value.elements
472
- .filter((el) => (el === null || el === void 0 ? void 0 : el.type) === 'StringLiteral')
473
- .map((el) => el.value);
474
- }
475
- }
476
- }
477
- }
478
- }
479
- },
480
- });
481
- // Second pass: Find matching imports (only if we have bindings to match)
347
+ // Add hot reload statements for tracked imports.
348
+ //
349
+ // lib/babel-plugin.ts's hotReplaceAst already computes this exact
350
+ // metadata (importVar/bindings/importStatements) while babel processes
351
+ // this same file earlier in the pipeline, and shares it via
352
+ // hmrImportMetadataCache keyed by filename -- so the common case skips
353
+ // re-parsing and re-traversing this file from scratch. Only fall back
354
+ // to recovering it the slow way (parsing the already-babel-transformed
355
+ // source and pulling the __hmr_import_metadata__ export back out of
356
+ // it) when there's no cache entry, e.g. because this file's babel pass
357
+ // didn't go through hotReplaceAst in this process, or a concurrent
358
+ // babel pass for another file raced the cache write out (see that
359
+ // cache's own doc comment).
360
+ let importVar = null;
361
+ let bindings = [];
362
+ let importStatements = [];
363
+ const cached = hmrImportMetadataCache.get(resourcePath);
364
+ if (cached) {
365
+ importVar = cached.importVar;
366
+ bindings = cached.bindings;
367
+ importStatements = cached.importStatements;
368
+ }
369
+ else {
370
+ // Fall back to extracting metadata from the __hmr_import_metadata__
371
+ // export the babel plugin emits into the source.
372
+ const metaMatch = source.match(/export const __hmr_import_metadata__ = (\{[\s\S]*?\});/);
373
+ if (metaMatch) {
374
+ try {
375
+ // eslint-disable-next-line no-new-func
376
+ const meta = new Function(`return ${metaMatch[1]}`)();
377
+ importVar = meta.importVar;
378
+ bindings = (_a = meta.bindings) !== null && _a !== void 0 ? _a : [];
379
+ }
380
+ catch (_c) {
381
+ // unparseable metadata — skip
382
+ }
383
+ }
384
+ // Recover importStatements by scanning the source for each binding
482
385
  if (importVar && bindings.length > 0) {
483
- traverse(result, {
484
- ImportDeclaration(path) {
485
- const importSource = path.node.source.value;
486
- for (const specifier of path.node.specifiers) {
487
- const local = specifier.local.name;
488
- if (bindings.includes(local)) {
489
- let specifierName = 'default';
490
- if (specifier.type === 'ImportDefaultSpecifier') {
491
- specifierName = 'default';
492
- }
493
- else if (specifier.type === 'ImportSpecifier') {
494
- // For named imports, use the imported name
495
- // Handle both Identifier and StringLiteral types
496
- const imported = specifier.imported;
497
- specifierName =
498
- imported.type === 'Identifier'
499
- ? imported.name
500
- : imported.value;
501
- }
502
- else if (specifier.type === 'ImportNamespaceSpecifier') {
503
- specifierName = '*';
504
- }
505
- importStatements.push({
506
- local,
507
- source: importSource,
508
- specifier: specifierName,
509
- });
510
- }
386
+ for (const binding of bindings) {
387
+ const importMatch = source.match(new RegExp(`import\\s+(?:(${binding})|(\\{[^}]*\\b${binding}\\b[^}]*\\})|\\*\\s+as\\s+(${binding}))\\s+from\\s+['"]([^'"]+)['"]`));
388
+ if (importMatch) {
389
+ const src = importMatch[4];
390
+ let specifier = 'default';
391
+ if (importMatch[2]) {
392
+ // named import — find alias or name
393
+ const namedMatch = importMatch[2].match(new RegExp(`(\\w+)\\s+as\\s+${binding}|${binding}`));
394
+ specifier = (_b = namedMatch === null || namedMatch === void 0 ? void 0 : namedMatch[1]) !== null && _b !== void 0 ? _b : binding;
511
395
  }
512
- },
513
- });
514
- }
515
- // Process metadata if we found importVar (even with empty bindings)
516
- if (importVar) {
517
- // Generate hot reload code for each import (only if we have bindings)
518
- const hotReloadStatements = [];
519
- for (const imp of importStatements) {
520
- // Resolve the import to check if it's from node_modules
521
- const resolved = yield this.resolve(imp.source, resourcePath, {});
522
- if ((resolved === null || resolved === void 0 ? void 0 : resolved.id) &&
523
- normalizePath(resolved.id).includes('node_modules')) {
524
- // Skip node_modules imports
525
- continue;
396
+ else if (importMatch[3]) {
397
+ specifier = '*';
398
+ }
399
+ importStatements.push({ local: binding, source: src, specifier });
526
400
  }
527
- const sourceId = imp.source.replace(/@embroider\/virtual/g, 'embroider_virtual');
528
- const virtualPath = `/ember-vite-hmr/virtual/component:${sourceId}::${imp.specifier}.gjs`;
529
- hotReloadStatements.push(`
530
- (async () => {
531
- const GlimmerComponent = (await import('@glimmer/component')).default;
532
- const { hasInternalComponentManager } = await import('@glimmer/manager');
533
- // Class-based Glimmer components are functions, detected the cheap way
534
- // via prototype chain. Template-only components (no backing class, e.g.
535
- // a bare \`<template>\` export) are plain objects instead, so they have to
536
- // be recognized by checking for a registered component manager - that
537
- // also keeps helpers/modifiers (which use separate manager registries)
538
- // out of this branch.
539
- const isComponent = typeof ${imp.local} === 'function'
540
- ? ${imp.local}.prototype instanceof GlimmerComponent
541
- : typeof ${imp.local} === 'object' && ${imp.local} !== null && hasInternalComponentManager(${imp.local});
542
- if (isComponent) {
543
- const c = await import('${virtualPath}');
544
- ${importVar}.${imp.local} = c.default;
545
- import.meta.hot.accept('${virtualPath}', (c) => {
546
- ${importVar}.${imp.local} = c['${imp.specifier}'];
547
- });
548
- import.meta.hot.accept('${imp.source}');
549
- }
550
- })();`);
551
401
  }
552
- // Always remove the metadata export
553
- source = source.replace(/export const __hmr_import_metadata__[^;]+;/, '');
554
- // Add HMR code if we have any statements OR if we have bindings (even if all were skipped)
555
- if (hotReloadStatements.length > 0 || bindings.length > 0) {
556
- const hotReloadCode = `
557
- if (import.meta.hot) {
558
- ${hotReloadStatements.join('\n')}
559
- }`;
560
- source = source + hotReloadCode;
402
+ }
403
+ }
404
+ // Process metadata if we found importVar (even with empty bindings)
405
+ if (importVar) {
406
+ const hotReloadStatements = [];
407
+ for (const imp of importStatements) {
408
+ // Resolve the import to check if it's from node_modules
409
+ const resolved = yield this.resolve(imp.source, resourcePath, {});
410
+ if ((resolved === null || resolved === void 0 ? void 0 : resolved.id) &&
411
+ normalizePath(resolved.id).includes('node_modules')) {
412
+ continue;
561
413
  }
414
+ // Each imported binding is already a local variable in scope thanks
415
+ // to its import statement. Register it by value on first load so
416
+ // `current(binding)` (called by the template__imports__ getter) has
417
+ // an entry to read from. When the dep module reloads, update() sets
418
+ // entry.current = newVal — the getter re-reads it, invalidating only
419
+ // the scope that contains the getter call, not the parent scope.
420
+ // No assignment to template__imports__.X needed: the getter handles it.
421
+ hotReloadStatements.push(`
422
+ (async () => {
423
+ const { register: ember_vite_hmr_register, update: ember_vite_hmr_update, used: ember_vite_hmr_used } = await import(${JSON.stringify(hmrRuntimeId)});
424
+ const { tracked: ember_vite_hmr_tracked } = await import('@glimmer/tracking');
425
+ ember_vite_hmr_register(${imp.local}, ember_vite_hmr_tracked);
426
+ import.meta.hot.accept(${JSON.stringify(imp.source)}, (m) => {
427
+ if (m) {
428
+ const newVal = m[${JSON.stringify(imp.specifier === 'default' ? 'default' : imp.specifier)}];
429
+ ember_vite_hmr_update(${imp.local}, newVal);
430
+ } else if (!ember_vite_hmr_used(${imp.local})) {
431
+ import.meta.hot.invalidate('nothing rendered ${imp.local} through the HMR runtime');
432
+ }
433
+ });
434
+ })();`);
562
435
  }
436
+ // Always remove the metadata export
437
+ source = source.replace(/export const __hmr_import_metadata__[^;]+;/, '');
438
+ // Do NOT add a self-accept in the importVar block.
439
+ //
440
+ // When module A imports module B, and B's template__imports__ class is in
441
+ // A's scope, A must be notified when B changes (so A's accept callback can
442
+ // swap in the new B class via template__imports__.B = newB). If B were to
443
+ // self-accept, Vite would stop propagation before A's callback fires — A's
444
+ // template__imports__ would never be updated, and Glimmer would never
445
+ // re-render. The same applies to route templates: they rely on Ember's
446
+ // canAcceptNew mechanism (added below), which self-accept would bypass.
447
+ //
448
+ // Components without importVar (no template imports) get a self-accept in
449
+ // the section below, after the supportedPaths check.
450
+ const hotReloadCode = hotReloadStatements.length > 0
451
+ ? `\nif (import.meta.hot) {\n${hotReloadStatements.join('\n')}\n}`
452
+ : '';
453
+ source = source + hotReloadCode;
563
454
  }
564
455
  const supportedPaths = ['routers', 'controllers', 'routes', 'templates'];
565
456
  const supportedFileNames = [
@@ -575,6 +466,56 @@ ${hotReloadStatements.join('\n')}
575
466
  }
576
467
  if (!supportedPaths.some((s) => resourcePath.includes(`/${s}/`)) &&
577
468
  !supportedFileNames.some((s) => resourcePath.endsWith(s))) {
469
+ // Component files (including template-less .ts/.js ones that just extend
470
+ // another component) that have no cross-component imports need a
471
+ // self-accept boundary so edits to them don't propagate up to a full
472
+ // page reload. Restrict plain .ts/.js to paths under /components/ to
473
+ // avoid accidentally self-accepting services, utilities, adapters, etc.
474
+ const isComponentFile = resourcePath.endsWith('.gjs') ||
475
+ resourcePath.endsWith('.gts') ||
476
+ ((resourcePath.endsWith('.ts') || resourcePath.endsWith('.js')) &&
477
+ resourcePath.includes('/components/'));
478
+ if (isComponentFile && !importVar) {
479
+ // Self-accept with a callback that updates the tracked-cell registry
480
+ // so Glimmer re-renders and swaps in the new class. This handles
481
+ // edits to the component's own class (new method, changed @tracked
482
+ // property, etc.) — without it, a bare accept() would silently
483
+ // discard the new class and leave the running instance on the old one.
484
+ //
485
+ // Only do this when there is no importVar (no template imports of
486
+ // other components). When importVar is present, the component's own
487
+ // importer already has an accept(dep, cb) that fires when this file
488
+ // changes — adding a self-accept here would stop Vite's propagation
489
+ // before that callback can fire, breaking the re-render. The same
490
+ // applies to re-export barrels: a self-accept on my-button.ts stops
491
+ // Vite walking up to index.ts's importer, silencing the accept
492
+ // callback that updates template__imports__.
493
+ //
494
+ // The babel plugin rewrites `export default <expr>` into
495
+ // `const __hmr_default__ = <expr>; export { __hmr_default__ as default }`
496
+ // giving us a stable local variable to register by value.
497
+ // hot.data.default carries the old value across self-accept
498
+ // re-evaluations so update() can find the existing entry.
499
+ return `${source}
500
+ if (import.meta.hot) {
501
+ (async () => {
502
+ const { register: ember_vite_hmr_register, update: ember_vite_hmr_update, used: ember_vite_hmr_used } = await import(${JSON.stringify(hmrRuntimeId)});
503
+ const { tracked: ember_vite_hmr_tracked } = await import('@glimmer/tracking');
504
+ const _hmr_prev = import.meta.hot.data.default;
505
+ import.meta.hot.data.default = __hmr_default__;
506
+ if (_hmr_prev) {
507
+ ember_vite_hmr_update(_hmr_prev, __hmr_default__);
508
+ }
509
+ ember_vite_hmr_register(__hmr_default__, ember_vite_hmr_tracked);
510
+ import.meta.hot.accept((m) => {
511
+ if (m && !ember_vite_hmr_used(import.meta.hot.data.default)) {
512
+ import.meta.hot.invalidate('nothing rendered this component through the HMR runtime');
513
+ }
514
+ });
515
+ })();
516
+ }
517
+ `;
518
+ }
578
519
  return source;
579
520
  }
580
521
  if (supportedPaths.includes('templates') &&
@@ -596,4 +537,5 @@ ${hotReloadStatements.join('\n')}
596
537
  });
597
538
  },
598
539
  };
540
+ return [hmrRuntime(), mainPlugin];
599
541
  }