ember-vite-hmr 2.4.0 → 2.7.1

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,428 +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';
16
13
  import { hmrImportMetadataCache } from './babel-plugin.js';
17
14
  const __filename = fileURLToPath(import.meta.url);
18
15
  const __dirname = path.dirname(__filename);
19
- // Above this many distinct named blocks, fall back to forwarding all of them
20
- // unconditionally rather than generating an exponential number of branches
21
- // (see generateInvocations below for why branching is needed at all).
22
- const MAX_COMBINATORIAL_YIELDS = 8;
23
- // Safety cap on how many `extends` links resolveYieldSource will walk while
24
- // looking for an inherited template (see its own doc comment) - real
25
- // component hierarchies never get remotely this deep, this only guards
26
- // against a pathological/cyclic chain.
27
- const MAX_EXTENDS_DEPTH = 20;
28
- function blockTag(y) {
29
- 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}>`;
30
- }
31
- function invocation(selected) {
32
- if (!selected.length) {
33
- return `<this.curried @__hot__={{this.hot}} ...attributes />`;
34
- }
35
- return `<this.curried @__hot__={{this.hot}} ...attributes>${selected.map(blockTag).join('\n')}</this.curried>`;
36
- }
37
- // Glimmer determines a target component's `(has-block "y")` purely from
38
- // whether a `<:y>` named block tag is structurally present on the
39
- // invocation, not from whether that block's content renders anything, and
40
- // named block tags cannot be nested inside `{{#if}}` (a "named block nested
41
- // in a normal block" compile error). So a per-block `{{#if (has-block 'y')}}
42
- // <:y>...</:y>{{/if}}` inside a single invocation can't work — instead this
43
- // recursively branches on `(has-block y)` for each known named block to
44
- // build one fully static invocation per combination of blocks the caller
45
- // actually passed, so blocks not passed are truly absent rather than merely
46
- // empty (see #531).
47
- function generateInvocations(remaining, selected) {
48
- if (!remaining.length) {
49
- return invocation(selected);
50
- }
51
- const [y, ...rest] = remaining;
52
- return `{{#if (has-block '${y}')}}${generateInvocations(rest, [...selected, y])}{{else}}${generateInvocations(rest, selected)}{{/if}}`;
53
- }
54
- function generateContent(yields) {
55
- if (!yields.includes('default')) {
56
- yields.push('default');
57
- }
58
- if (yields.length > MAX_COMBINATORIAL_YIELDS) {
59
- let all = yields.map((y) => `(has-block '${y}')`).join(' ');
60
- return `
61
- {{#if (notAny ${all})}}
62
- <this.curried @__hot__={{this.hot}} ...attributes />
63
- {{else}}
64
- ${invocation(yields)}
65
- {{/if}}
66
- `;
67
- }
68
- return generateInvocations(yields, []);
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');
69
49
  }
70
- const getHotComponent = (imp, specifier, yields) => `
71
- import { ${specifier} as TargetComponent } from "${imp}";
72
- import Component from "@glimmer/component";
73
- import { tracked } from "@glimmer/tracking";
74
- import { createComputeRef } from "@glimmer/reference";
75
- import { curry } from '@glimmer/runtime';
76
- import { registerDestructor } from '@ember/destroyable';
77
- import { getInternalComponentManager, setInternalComponentManager } from '@glimmer/manager';
78
50
 
79
- function notAny(...yields) {
80
- return !yields.some((y) => !!y);
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;
81
59
  }
82
60
 
83
- const hotCallbacks = new Set();
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
+ }
67
+ }
84
68
 
85
- if (import.meta.hot) {
86
- import.meta.hot.accept('${imp}', (module) => {
87
- import.meta.hot.data.latestModule = module;
88
- for (const callback of hotCallbacks) {
89
- callback(module);
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);
90
89
  }
91
- });
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
+ }
92
94
  }
93
95
 
94
- // Stack of CapturedArguments (see @glimmer/interfaces), pushed by the shadow
95
- // component manager below right before it constructs a HotComponent instance,
96
- // and peeked (not popped) here in the constructor. It is always non-empty at
97
- // this point because HotComponent is only ever created through that manager.
98
- const capturedArgsStack = [];
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;
102
+ }
99
103
 
100
- export default class HotComponent extends Component {
101
- @tracked curried;
102
- hot = {};
103
- constructor(owner, args) {
104
- super(owner, args);
105
- const capturedArgs = capturedArgsStack[capturedArgsStack.length - 1];
106
- const named = {};
107
- const positional = [];
108
- for (const name of Object.keys(args)) {
109
- // Forward the caller's original, updatable reference (e.g. a path like
110
- // "this.value") instead of wrapping the reified value in a fresh
111
- // read-only compute ref. A read-only ref fails Ember's "You can only
112
- // pass a path to mut" check, breaking {{mut @arg}} in wrapped components.
113
- named[name] = capturedArgs?.named[name] ?? createComputeRef(() => args[name]);
114
- }
115
- const CurriedComponent = 0;
116
- // After an accepted update this module is not re-evaluated, so the static
117
- // TargetComponent binding still points at the pre-update module. Instances
118
- // created after the update must curry the latest accepted class instead.
119
- const Target = import.meta.hot?.data.latestModule?.default ?? TargetComponent;
120
- this.curried = curry(CurriedComponent, Target, owner, { positional, named});
121
- if (import.meta.hot) {
122
- const callback = (module) => {
123
- this.curried = curry(CurriedComponent, module.default, owner, { positional, named});
124
- };
125
- hotCallbacks.add(callback);
126
- registerDestructor(this, () => {
127
- hotCallbacks.delete(callback);
128
- });
129
- }
130
- }
104
+ export function used(value) {
105
+ if (!isRef(value)) return false;
106
+ return Boolean(entries.get(value)?.consumed);
107
+ }
131
108
 
132
- <template>
133
- ${generateContent(yields)}
134
- </template>
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);
135
114
  }
136
115
 
137
- // HotComponent's own args (the reified values passed to its constructor
138
- // above) only expose values, not the underlying VM references, so the raw
139
- // references have to be captured one level down, in the internal component
140
- // manager's create() hook, before Ember reifies them into that value-only
141
- // proxy. Shadowing the manager (rather than patching the shared default
142
- // manager's create()) scopes the capture to HotComponent only; getManager()
143
- // walks the prototype chain and finds this before reaching @glimmer/component's
144
- // default manager.
145
- const defaultManager = getInternalComponentManager(Component);
146
- const shadowManager = Object.create(defaultManager);
147
- shadowManager.create = function (owner, definition, vmArgs) {
148
- capturedArgsStack.push(vmArgs.capture());
149
- try {
150
- return defaultManager.create(owner, definition, vmArgs);
151
- } finally {
152
- capturedArgsStack.pop();
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]);
153
125
  }
154
- };
155
- // Glimmer's debug render tree normally adds one 'component' node per
156
- // invoked component instance (see VM_GET_COMPONENT_SELF_OP). Returning no
157
- // nodes here suppresses that node for HotComponent itself, so only the
158
- // real, curried target component (invoked in HotComponent's own template)
159
- // shows up in ember-inspector's component tree - this is the same hook
160
- // Ember core uses internally to keep its own {{outlet}}/{{mount}} wrapper
161
- // machinery out of (or relabeled in) that tree (see OutletComponentManager
162
- // and MountManager in ember-source).
163
- shadowManager.getDebugCustomRenderTree = function () {
164
- return [];
165
- };
166
- setInternalComponentManager(shadowManager, HotComponent);
167
- `;
168
- const cachedYields = {};
169
- function getYieldsFromFile(filename, content, noCache) {
170
- if (cachedYields[filename] && !noCache) {
171
- return cachedYields[filename];
172
- }
173
- // very basic, todo: make this use AST
174
- const matches = content.matchAll(/to=['"\\]+(\w+)['"\\]+/g);
175
- const yields = new Set([...matches].map((m) => m === null || m === void 0 ? void 0 : m[1]).filter((m) => !!m));
176
- if (noCache) {
177
- return {
178
- yields,
179
- modules: [],
180
- };
181
- }
182
- cachedYields[filename] = {
183
- yields,
184
- modules: [],
185
- };
186
- return cachedYields[filename];
187
- }
188
- function difference(a, b) {
189
- const diff = [];
190
- for (const bElement of b) {
191
- if (!a.has(bElement)) {
192
- diff.push(bElement);
193
- }
194
- }
195
- return diff;
196
126
  }
197
- // Helper function to normalize paths consistently across platforms
198
- function normalizePath(inputPath) {
199
- // Always convert backslashes to forward slashes
200
- return inputPath.replace(/\\/g, '/');
127
+
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;
201
137
  }
202
- // The compiled backing class embeds an import specifier as a browser-facing
203
- // URL (i.e. prefixed with the configured vite `base`), since that's what
204
- // gets sent to the client. But `server.transformRequest` is an internal API
205
- // keyed by root-relative ids, so the base has to be stripped back off before
206
- // reusing that specifier, or a lookup 404s under a non-root base.
207
- function stripBase(specifier, base) {
208
- if (base !== '/' && specifier.startsWith(base)) {
209
- return `/${specifier.slice(base.length)}`;
210
- }
211
- return specifier;
138
+
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);
212
150
  }
213
- // A component's compiled module only calls `setComponentTemplate` on itself
214
- // when it owns a template directly (an inline `<template>`); it's absent
215
- // both for a classic component whose template lives in a separately
216
- // resolved colocated `.hbs` (see the `.hbs` import check this feeds into)
217
- // and for a component with no template of its own at all, which inherits
218
- // whatever's registered on its nearest ancestor class instead (Glimmer's
219
- // `getComponentTemplate`/`setComponentTemplate`, vendored in ember-source's
220
- // `@glimmer/manager`, resolve a component's template by walking
221
- // `Object.getPrototypeOf` up the prototype chain - the same mechanism
222
- // `getInternalComponentManager` uses, see the `shadowManager` comment above).
223
- function findSuperclassImportSource(content) {
224
- let result;
225
- try {
226
- result = parseSync(content, {
227
- filename: 'hmr-extends-check.js',
228
- ast: true,
229
- code: false,
230
- configFile: false,
231
- babelrc: false,
232
- plugins: [
233
- ['@babel/plugin-syntax-typescript', { isTSX: true }],
234
- ['@babel/plugin-proposal-decorators', { version: '2022-03' }],
235
- ],
236
- });
237
- }
238
- catch (_a) {
239
- return null;
240
- }
241
- if (!result) {
242
- return null;
243
- }
244
- // Prefer the default-exported class specifically (that's always the
245
- // component itself) over "the first class with a superclass in the file",
246
- // since a file can declare other, unrelated classes above it (a local
247
- // helper class, etc.) that would otherwise be matched instead.
248
- let superName = null;
249
- traverse(result, {
250
- ExportDefaultDeclaration(path) {
251
- const declaration = path.node.declaration;
252
- let classNode = declaration;
253
- if (declaration.type === 'Identifier' && declaration.name) {
254
- const binding = path.scope.getBinding(declaration.name);
255
- if ((binding === null || binding === void 0 ? void 0 : binding.path.isClassDeclaration()) ||
256
- (binding === null || binding === void 0 ? void 0 : binding.path.isClassExpression())) {
257
- classNode = binding.path.node;
258
- }
259
- }
260
- const superClass = classNode.superClass;
261
- if ((superClass === null || superClass === void 0 ? void 0 : superClass.type) === 'Identifier' && superClass.name) {
262
- superName = superClass.name;
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 };
155
+ `;
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;
263
167
  }
264
168
  },
265
- });
266
- if (!superName) {
267
- // Fall back to the first class with a superclass anywhere in the file
268
- // (e.g. a non-default-exported component, or an export form the check
269
- // above doesn't specifically handle).
270
- traverse(result, {
271
- 'ClassDeclaration|ClassExpression'(path) {
272
- if (superName) {
273
- return;
274
- }
275
- const superClass = path.node.superClass;
276
- if ((superClass === null || superClass === void 0 ? void 0 : superClass.type) === 'Identifier' && superClass.name) {
277
- superName = superClass.name;
278
- }
279
- },
280
- });
281
- }
282
- if (!superName) {
283
- return null;
284
- }
285
- let source = null;
286
- traverse(result, {
287
- ImportDeclaration(path) {
288
- if (source) {
289
- return;
290
- }
291
- if (path.node.specifiers.some((s) => s.local.name === superName)) {
292
- source = path.node.source.value;
169
+ load(id) {
170
+ if (id === resolvedRuntimeId) {
171
+ return runtimeSource;
293
172
  }
294
173
  },
295
- });
296
- return source;
297
- }
298
- // If `content` (already run through `server.transformRequest`) registers a
299
- // template on itself - either inline, or via a colocated `.hbs` import -
300
- // returns that template's own source. Returns null when this file has no
301
- // template of its own to scan, meaning any named blocks it uses have to be
302
- // found on an ancestor class instead (see resolveYieldSource).
303
- function resolveOwnTemplateSource(server, base, filename, content) {
304
- return __awaiter(this, void 0, void 0, function* () {
305
- var _a;
306
- // For a classic component with a separately resolved template (a colocated
307
- // `.hbs` file next to a backing class, or a template-only component), the
308
- // compiled backing module only *imports* its template (e.g. `import TEMPLATE
309
- // from "./foo.hbs?import"`); it doesn't inline it. Any {{yield ... to="..."}}
310
- // usage lives in that template file, so it has to be fetched and scanned
311
- // separately, otherwise named blocks other than "default" are never detected
312
- // and get silently dropped by the generated hot-reload wrapper.
313
- const templateImportMatch = content.match(/\bfrom\s*['"]([^'"]+\.hbs(?:\?[^'"]*)?)['"]/);
314
- if (templateImportMatch) {
315
- const templateSpecifier = stripBase(templateImportMatch[1], base);
316
- const templateRes = yield server.transformRequest(templateSpecifier);
317
- return { filename: templateSpecifier, content: (_a = templateRes === null || templateRes === void 0 ? void 0 : templateRes.code) !== null && _a !== void 0 ? _a : '' };
318
- }
319
- if (content.includes('setComponentTemplate(')) {
320
- return { filename, content };
321
- }
322
- return null;
323
- });
174
+ };
324
175
  }
325
- // Determines which file's source should be scanned for named-block
326
- // (`{{yield ... to="..."}}`) usage for the component at `filename`/`content`.
327
- // Usually that's the component's own file (or its colocated `.hbs`, handled
328
- // by resolveOwnTemplateSource). But a component with no template of its own
329
- // renders whatever template is registered on its nearest ancestor class (see
330
- // resolveOwnTemplateSource's doc comment) - previously this only ever
331
- // scanned the child's own (template-less) file, silently dropping every
332
- // named block other than "default" for any component subclassed without its
333
- // own template. This walks the `extends` chain, resolving each ancestor's
334
- // import the same way Vite already resolved it in the already-transformed
335
- // source, until one with its own template is found.
336
- function resolveYieldSource(server, base, filename, content) {
337
- return __awaiter(this, void 0, void 0, function* () {
338
- const own = yield resolveOwnTemplateSource(server, base, filename, content);
339
- if (own) {
340
- return own;
341
- }
342
- const seen = new Set([filename]);
343
- let currentContent = content;
344
- for (let i = 0; i < MAX_EXTENDS_DEPTH; i++) {
345
- const superSource = findSuperclassImportSource(currentContent);
346
- if (!superSource) {
347
- break;
348
- }
349
- const superSpecifier = stripBase(superSource, base);
350
- // Framework/addon base classes live in node_modules and are never
351
- // hot-tracked by this plugin (see the node_modules skip in `transform`);
352
- // stop there rather than walking into vendored internals.
353
- if (seen.has(superSpecifier) || superSpecifier.includes('node_modules')) {
354
- break;
355
- }
356
- seen.add(superSpecifier);
357
- let superContent;
358
- try {
359
- // Unlike the `.hbs` specifier resolveOwnTemplateSource requests
360
- // (always something Vite itself already resolved for a file we
361
- // successfully transformed), this specifier can be anything a
362
- // superclass import resolved to, including forms
363
- // `transformRequest` can't handle. Failing to resolve an ancestor's
364
- // template is the same "no named blocks found" outcome as before
365
- // this fix existed - it must never fail the whole component load.
366
- const superRes = yield server.transformRequest(superSpecifier);
367
- superContent = superRes === null || superRes === void 0 ? void 0 : superRes.code;
368
- }
369
- catch (_a) {
370
- break;
371
- }
372
- if (!superContent) {
373
- break;
374
- }
375
- const superOwn = yield resolveOwnTemplateSource(server, base, superSpecifier, superContent);
376
- if (superOwn) {
377
- return superOwn;
378
- }
379
- currentContent = superContent;
380
- }
381
- return { filename, content };
382
- });
176
+ // Helper function to normalize paths consistently across platforms
177
+ function normalizePath(inputPath) {
178
+ return inputPath.replace(/\\/g, '/');
383
179
  }
384
- const virtualPrefix = '/ember-vite-hmr/virtual/component:';
385
- // Embroider's resolver registry. It's the binding the app entry's HMR hook
386
- // mutates, so we use it to locate that entry. Embroider keeps this as an
387
- // internal literal (not a public export), so we mirror the string here.
388
- const compatModulesSpecifier = '@embroider/virtual/compat-modules';
389
180
  export function hmr(enableViteHmrForModes = ['development']) {
390
181
  let base = '/';
182
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
391
183
  let server;
392
- return {
184
+ const mainPlugin = {
393
185
  name: 'hmr-plugin',
394
186
  enforce: 'post',
395
187
  config(config, env) {
396
188
  var _a;
397
- // The per-component hot wrapper (see getHotComponent) imports the first
398
- // four of these, but it is generated/served on demand, so it is never
399
- // part of the static module graph Vite's dependency scanner crawls on
400
- // the first pass. `@ember/component/template-only` has the same
401
- // problem for a different reason: it's injected by the template
402
- // compiler into a template-only component's compiled output, which the
403
- // scanner (source-only) never sees either. Without pre-declaring them,
404
- // the browser requests them on boot, Vite discovers "new" deps and
405
- // triggers a re-optimize + full page reload.
406
- //
407
- // We must use the Embroider-rewritten `ember-source/...` subpaths: the
408
- // bare `@glimmer/reference` etc. specifiers the wrapper writes cannot be
409
- // resolved by optimizeDeps.include. (`@glimmer/component` and
410
- // `@glimmer/tracking` are omitted on purpose — normal app code already
411
- // pulls them into the scan.)
412
189
  if (!enableViteHmrForModes.includes(env.mode)) {
413
190
  return;
414
191
  }
415
192
  // With `optimizeDeps.noDiscovery: true`, Vite never scans the app's own
416
- // source, so nothing else pulls these same glimmer subpaths into the
417
- // optimizer. Forcing them into `include` then creates a second,
418
- // separately pre-bundled copy of the glimmer VM alongside the
419
- // unoptimized one the rest of the (unscanned) app actually uses,
420
- // leading to "The global context for Glimmer VM was not set" (#554).
421
- // There's nothing useful this hook can pre-declare in that mode, so
422
- // skip it entirely rather than fight the user's own dep-optimization
423
- // 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.
424
198
  if ((_a = config.optimizeDeps) === null || _a === void 0 ? void 0 : _a.noDiscovery) {
425
199
  return;
426
200
  }
427
201
  return {
428
202
  optimizeDeps: {
429
203
  include: [
430
- 'ember-source/@glimmer/reference/index.js',
431
- 'ember-source/@glimmer/runtime/index.js',
432
- 'ember-source/@ember/destroyable/index.js',
433
- 'ember-source/@glimmer/manager/index.js',
434
204
  'ember-source/@ember/component/template-only.js',
435
205
  ],
436
206
  },
@@ -452,22 +222,6 @@ export function hmr(enableViteHmrForModes = ['development']) {
452
222
  if (id.includes('@ember-vite-hmr/setup-ember-hmr.js')) {
453
223
  return id;
454
224
  }
455
- if (importer === null || importer === void 0 ? void 0 : importer.startsWith(virtualPrefix)) {
456
- const newImporter = path.join(process.cwd(), 'package.json');
457
- importer = newImporter;
458
- return this.resolve(id, importer, meta);
459
- }
460
- if (id.startsWith(virtualPrefix)) {
461
- let [imp, specifier] = id
462
- .split('?')[0]
463
- .slice(virtualPrefix.length)
464
- .split('::');
465
- if (imp === null || imp === void 0 ? void 0 : imp.startsWith('.')) {
466
- const r = yield this.resolve(imp, importer);
467
- return id.replace(`${imp}::${specifier}`, `${r.id}::${specifier}`);
468
- }
469
- return id;
470
- }
471
225
  if (id === '/ember-vite-hmr/services/vite-hot-reload') {
472
226
  return this.resolve('ember-vite-hmr/services/vite-hot-reload', path.resolve(process.cwd(), 'package.json'), meta);
473
227
  }
@@ -478,32 +232,6 @@ export function hmr(enableViteHmrForModes = ['development']) {
478
232
  if (id.includes('@ember-vite-hmr/setup-ember-hmr.js')) {
479
233
  return yield readFile(path.resolve(__dirname, '..', 'setup-ember-hmr.js'), 'utf8');
480
234
  }
481
- if (id.startsWith(virtualPrefix)) {
482
- if (!server) {
483
- // During build, server is not available
484
- return null;
485
- }
486
- let [imp, specifier] = id
487
- .split('?')[0]
488
- .slice(virtualPrefix.length, -'.gts'.length)
489
- .split('::');
490
- imp = imp.replace('embroider_virtual', '@embroider/virtual');
491
- let filename = imp;
492
- if (filename.includes('__vpc__')) {
493
- filename = filename.split('__vpc__')[0];
494
- }
495
- const res = yield server.transformRequest(filename);
496
- const content = res === null || res === void 0 ? void 0 : res.code;
497
- const { filename: yieldSourceFilename, content: yieldSourceContent } = yield resolveYieldSource(server, base, filename, content !== null && content !== void 0 ? content : '');
498
- const resId = yield this.resolve(yieldSourceFilename, path.resolve(process.cwd(), 'package.json'));
499
- // Strip any query string so the cache key matches the plain file path that
500
- // `hotUpdate` below receives when that template file is edited.
501
- const cacheKey = resId.id.split('?')[0];
502
- const cached = getYieldsFromFile(cacheKey, yieldSourceContent !== null && yieldSourceContent !== void 0 ? yieldSourceContent : '');
503
- const yields = cached.yields;
504
- cached.modules.push(id);
505
- return getHotComponent(imp, specifier, [...yields].filter((y) => !!y));
506
- }
507
235
  });
508
236
  },
509
237
  transformIndexHtml(html) {
@@ -522,49 +250,69 @@ export function hmr(enableViteHmrForModes = ['development']) {
522
250
  ];
523
251
  },
524
252
  handleHotUpdate(ctx) {
525
- if (!ctx.file.split('?')[0].endsWith('.hbs')) {
526
- return ctx.modules;
527
- }
253
+ var _a, _b;
254
+ const file = ctx.file.split('?')[0];
528
255
  const otherModules = [];
529
- const pairedModule = ctx.modules.find((m) => [...m.importers].find((i) => i.id.startsWith('embroider_virtual') &&
530
- i.id.endsWith('-embroider-pair-component')));
531
- if (pairedModule) {
532
- const pairComponent = [...pairedModule.importers].find((i) => i.id.startsWith('embroider_virtual') &&
533
- i.id.endsWith('-embroider-pair-component'));
534
- if (pairComponent) {
535
- const componentModule = [...pairComponent.clientImportedModules].find((cim) => cim.id.split('?')[0].match(/\/component\.(js|ts|gjs|gts)/));
536
- if (componentModule) {
537
- 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
+ }
538
268
  }
539
269
  }
270
+ return [...ctx.modules, ...otherModules];
540
271
  }
541
- return [...ctx.modules, ...otherModules];
542
- },
543
- hotUpdate(options) {
544
- return __awaiter(this, void 0, void 0, function* () {
545
- if (options.type === 'update' || options.type === 'create') {
546
- const id = options.file;
547
- const source = (yield readFile(id)).toString();
548
- if (cachedYields[id] &&
549
- difference(cachedYields[id].yields, getYieldsFromFile(id, source, true).yields).length) {
550
- for (const y of getYieldsFromFile(id, source).yields) {
551
- cachedYields[id].yields.add(y);
552
- }
553
- const modules = cachedYields[id].modules;
554
- delete cachedYields[id];
555
- for (const module of modules) {
556
- server.moduleGraph.onFileChange(module);
557
- let m = server.moduleGraph.getModuleById(module);
558
- if (m) {
559
- yield server.reloadModule(m);
560
- }
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);
561
307
  }
562
308
  }
563
309
  }
564
- });
310
+ }
311
+ return [...ctx.modules, ...otherModules];
565
312
  },
566
313
  transform(source, id) {
567
314
  return __awaiter(this, void 0, void 0, function* () {
315
+ var _a, _b;
568
316
  if (process.env.EMBER_VITE_HMR_ENABLED !== 'true') {
569
317
  return source;
570
318
  }
@@ -593,9 +341,6 @@ export function hmr(enableViteHmrForModes = ['development']) {
593
341
  });
594
342
  }`;
595
343
  }
596
- if (resourcePath.includes('ember-vite-hmr/virtual/components')) {
597
- return source;
598
- }
599
344
  if (resourcePath.includes('node_modules')) {
600
345
  return source;
601
346
  }
@@ -622,133 +367,90 @@ export function hmr(enableViteHmrForModes = ['development']) {
622
367
  importStatements = cached.importStatements;
623
368
  }
624
369
  else {
625
- const result = parseSync(source, {
626
- filename: resourcePath,
627
- ast: true,
628
- code: false,
629
- configFile: false,
630
- babelrc: false,
631
- plugins: [
632
- ['@babel/plugin-syntax-typescript', { isTSX: true }],
633
- ['@babel/plugin-proposal-decorators', { version: '2022-03' }],
634
- ],
635
- });
636
- if (result) {
637
- // First pass: Extract metadata
638
- traverse(result, {
639
- ExportNamedDeclaration(path) {
640
- var _a, _b, _c;
641
- const declaration = path.node.declaration;
642
- // Check if this is: export const __hmr_import_metadata__ = {...}
643
- if ((declaration === null || declaration === void 0 ? void 0 : declaration.type) === 'VariableDeclaration' &&
644
- ((_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) ===
645
- '__hmr_import_metadata__') {
646
- const init = declaration.declarations[0].init;
647
- if ((init === null || init === void 0 ? void 0 : init.type) === 'ObjectExpression') {
648
- // Extract importVar and bindings from the object
649
- for (const prop of init.properties) {
650
- if (prop.type === 'ObjectProperty' &&
651
- prop.key.type === 'Identifier') {
652
- if (prop.key.name === 'importVar' &&
653
- prop.value.type === 'StringLiteral') {
654
- importVar = prop.value.value;
655
- }
656
- else if (prop.key.name === 'bindings' &&
657
- prop.value.type === 'ArrayExpression') {
658
- bindings = prop.value.elements
659
- .filter((el) => (el === null || el === void 0 ? void 0 : el.type) ===
660
- 'StringLiteral')
661
- .map((el) => el.value);
662
- }
663
- }
664
- }
665
- }
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
385
+ if (importVar && bindings.length > 0) {
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;
666
395
  }
667
- },
668
- });
669
- // Second pass: Find matching imports (only if we have bindings to match)
670
- if (importVar && bindings.length > 0) {
671
- traverse(result, {
672
- ImportDeclaration(path) {
673
- const importSource = path.node.source.value;
674
- for (const specifier of path.node.specifiers) {
675
- const local = specifier.local.name;
676
- if (bindings.includes(local)) {
677
- let specifierName = 'default';
678
- if (specifier.type === 'ImportDefaultSpecifier') {
679
- specifierName = 'default';
680
- }
681
- else if (specifier.type === 'ImportSpecifier') {
682
- // For named imports, use the imported name
683
- // Handle both Identifier and StringLiteral types
684
- const imported = specifier.imported;
685
- specifierName =
686
- imported.type === 'Identifier'
687
- ? imported.name
688
- : imported.value;
689
- }
690
- else if (specifier.type === 'ImportNamespaceSpecifier') {
691
- specifierName = '*';
692
- }
693
- importStatements.push({
694
- local,
695
- source: importSource,
696
- specifier: specifierName,
697
- });
698
- }
699
- }
700
- },
701
- });
396
+ else if (importMatch[3]) {
397
+ specifier = '*';
398
+ }
399
+ importStatements.push({ local: binding, source: src, specifier });
400
+ }
702
401
  }
703
402
  }
704
403
  }
705
404
  // Process metadata if we found importVar (even with empty bindings)
706
405
  if (importVar) {
707
- // Generate hot reload code for each import (only if we have bindings)
708
406
  const hotReloadStatements = [];
709
407
  for (const imp of importStatements) {
710
408
  // Resolve the import to check if it's from node_modules
711
409
  const resolved = yield this.resolve(imp.source, resourcePath, {});
712
410
  if ((resolved === null || resolved === void 0 ? void 0 : resolved.id) &&
713
411
  normalizePath(resolved.id).includes('node_modules')) {
714
- // Skip node_modules imports
715
412
  continue;
716
413
  }
717
- const sourceId = imp.source.replace(/@embroider\/virtual/g, 'embroider_virtual');
718
- const virtualPath = `/ember-vite-hmr/virtual/component:${sourceId}::${imp.specifier}.gjs`;
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.
719
421
  hotReloadStatements.push(`
720
422
  (async () => {
721
- const GlimmerComponent = (await import('@glimmer/component')).default;
722
- const { hasInternalComponentManager } = await import('@glimmer/manager');
723
- // Class-based Glimmer components are functions, detected the cheap way
724
- // via prototype chain. Template-only components (no backing class, e.g.
725
- // a bare \`<template>\` export) are plain objects instead, so they have to
726
- // be recognized by checking for a registered component manager - that
727
- // also keeps helpers/modifiers (which use separate manager registries)
728
- // out of this branch.
729
- const isComponent = typeof ${imp.local} === 'function'
730
- ? ${imp.local}.prototype instanceof GlimmerComponent
731
- : typeof ${imp.local} === 'object' && ${imp.local} !== null && hasInternalComponentManager(${imp.local});
732
- if (isComponent) {
733
- const c = await import('${virtualPath}');
734
- ${importVar}.${imp.local} = c.default;
735
- import.meta.hot.accept('${virtualPath}', (c) => {
736
- ${importVar}.${imp.local} = c['${imp.specifier}'];
737
- });
738
- import.meta.hot.accept('${imp.source}');
739
- }
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
+ });
740
434
  })();`);
741
435
  }
742
436
  // Always remove the metadata export
743
437
  source = source.replace(/export const __hmr_import_metadata__[^;]+;/, '');
744
- // Add HMR code if we have any statements OR if we have bindings (even if all were skipped)
745
- if (hotReloadStatements.length > 0 || bindings.length > 0) {
746
- const hotReloadCode = `
747
- if (import.meta.hot) {
748
- ${hotReloadStatements.join('\n')}
749
- }`;
750
- source = source + hotReloadCode;
751
- }
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;
752
454
  }
753
455
  const supportedPaths = ['routers', 'controllers', 'routes', 'templates'];
754
456
  const supportedFileNames = [
@@ -764,6 +466,56 @@ ${hotReloadStatements.join('\n')}
764
466
  }
765
467
  if (!supportedPaths.some((s) => resourcePath.includes(`/${s}/`)) &&
766
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
+ }
767
519
  return source;
768
520
  }
769
521
  if (supportedPaths.includes('templates') &&
@@ -785,4 +537,5 @@ ${hotReloadStatements.join('\n')}
785
537
  });
786
538
  },
787
539
  };
540
+ return [hmrRuntime(), mainPlugin];
788
541
  }