@nativescript/vite 8.0.0-alpha.10 → 8.0.0-alpha.12

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.
@@ -2701,6 +2701,74 @@ function createHmrWebSocketPlugin(opts) {
2701
2701
  if (!wsAny.__NS_ANGULAR_FULL_RELOAD_FILTER_INSTALLED__) {
2702
2702
  const originalSend = server.ws.send.bind(server.ws);
2703
2703
  wsAny.__NS_ANGULAR_FULL_RELOAD_FILTER_INSTALLED__ = true;
2704
+ // Bridge Vite's stock WS broadcasts (`server.ws.send(...)`)
2705
+ // to our `/ns-hmr` WebSocket. Vite v8 keeps two completely
2706
+ // separate `WebSocketServer` instances: its own (default
2707
+ // path `/`, accepting `vite-hmr`/`vite-ping` protocols) and
2708
+ // ours (`/ns-hmr`, where the iOS device actually connects).
2709
+ // Plugin-emitted events like Analog's
2710
+ // `server.ws.send('angular:component-update', { id, ts })`
2711
+ // flow through Vite's `normalizedHotChannel.send` →
2712
+ // `wss.clients.forEach`, but those `wss.clients` are
2713
+ // EMPTY in NativeScript dev — the device never speaks the
2714
+ // `vite-hmr` protocol nor connects to `/`. Without a
2715
+ // bridge, every plugin-emitted custom event is logged on
2716
+ // the server (e.g. `(client) hmr update <html>`) but
2717
+ // silently dropped before reaching the device. Symptom:
2718
+ // the iOS HMR-applying overlay sticks at 5%
2719
+ // ("Preparing update") forever because Angular's compiled
2720
+ // `import.meta.hot.on('angular:component-update', cb)`
2721
+ // listeners never fire. We mirror the payload onto our
2722
+ // `/ns-hmr` clients here so the existing custom-event
2723
+ // dispatcher in `hmr/client/index.ts` (which forwards to
2724
+ // `__NS_DISPATCH_HOT_EVENT__`) actually runs.
2725
+ const bridgeToNsHmrClients = (payload, args) => {
2726
+ try {
2727
+ let normalized;
2728
+ if (typeof args[0] === 'string') {
2729
+ normalized = { type: 'custom', event: args[0], data: args[1] };
2730
+ }
2731
+ else {
2732
+ normalized = payload;
2733
+ }
2734
+ if (!normalized)
2735
+ return;
2736
+ // Vite's stock `update` payload includes per-module
2737
+ // HMR boundary info that our device-side client
2738
+ // has no handler for (we drive HMR via our own
2739
+ // `ns:angular-update`/`ns:hmr-delta`/`ns:css-updates`
2740
+ // messages). Forwarding it would just look like
2741
+ // noise to the client. Custom events
2742
+ // (`type: 'custom'`) — including
2743
+ // `angular:component-update` and Analog's
2744
+ // CSS-direct/inline `update` shorthand — DO need
2745
+ // to reach the device, since they drive the
2746
+ // in-place `ɵɵreplaceMetadata` template-swap path.
2747
+ // Filter the relay to those.
2748
+ if (normalized.type !== 'custom')
2749
+ return;
2750
+ const stringified = JSON.stringify(normalized);
2751
+ let recipients = 0;
2752
+ wss?.clients.forEach((client) => {
2753
+ try {
2754
+ if (client && client.readyState === 1) {
2755
+ client.send(stringified);
2756
+ recipients++;
2757
+ }
2758
+ }
2759
+ catch { }
2760
+ });
2761
+ if (verbose) {
2762
+ const event = normalized?.event;
2763
+ console.log(`[hmr-ws][bridge] forwarded ${normalized.type}${event ? `:${event}` : ''} payload to ${recipients} /ns-hmr client(s)`);
2764
+ }
2765
+ }
2766
+ catch (err) {
2767
+ if (verbose) {
2768
+ console.warn('[hmr-ws][bridge] failed to forward payload to /ns-hmr clients', err);
2769
+ }
2770
+ }
2771
+ };
2704
2772
  server.ws.send = ((payload, ...rest) => {
2705
2773
  pruneAngularReloadSuppressions();
2706
2774
  if (shouldSuppressViteFullReloadPayload({
@@ -2713,6 +2781,7 @@ function createHmrWebSocketPlugin(opts) {
2713
2781
  }
2714
2782
  return;
2715
2783
  }
2784
+ bridgeToNsHmrClients(payload, [payload, ...rest]);
2716
2785
  return originalSend(payload, ...rest);
2717
2786
  });
2718
2787
  }
@@ -3126,6 +3195,84 @@ function createHmrWebSocketPlugin(opts) {
3126
3195
  const urlObj = new URL(req.url || '', 'http://localhost');
3127
3196
  if (!urlObj.pathname.startsWith('/ns/m'))
3128
3197
  return next();
3198
+ // Delegate AnalogJS Angular component live-reload endpoints.
3199
+ //
3200
+ // Angular 21's `ɵɵgetReplaceMetadataURL` (in @angular/core
3201
+ // _debug_node-chunk.mjs) builds the metadata-replacement URL as
3202
+ // `new URL('./@ng/component?c=<id>&t=<ts>', import.meta.url).href`.
3203
+ // Because `import.meta.url` for a NS-served module is
3204
+ // `http://host:port/ns/m/<project-relative>/component.ts`, the
3205
+ // resolved metadata URL ends up *nested* under the component's
3206
+ // directory: `/ns/m/<dir>/@ng/component?c=...&t=...`.
3207
+ //
3208
+ // AnalogJS's `liveReloadPlugin` registers a middleware that matches
3209
+ // `/@ng/component` anywhere in `req.url` and returns either an empty
3210
+ // module body (no HMR update available) or the metadata-replacement
3211
+ // code (after a save invalidates the file). Without this delegation
3212
+ // the NS `/ns/m/` middleware would treat the path as a file lookup,
3213
+ // fail to resolve `@ng/component` against disk, and respond with
3214
+ // 404 — which surfaces as `HTTP fetch/compile failed` at the
3215
+ // component's own `_HmrLoad(Date.now())` call on initial boot and
3216
+ // blocks Angular component bootstrapping.
3217
+ //
3218
+ // Calling `next()` here lets AnalogJS's middleware (or any other
3219
+ // middleware later in the chain) handle the request. Analog's
3220
+ // middleware reads only the `?c=` query string and is pathname-
3221
+ // agnostic, so we don't need to rewrite `req.url` for it to work.
3222
+ //
3223
+ // HOWEVER: AnalogJS responds with an EMPTY body (`res.end('')`)
3224
+ // for non-invalidated component IDs (initial boot, before any
3225
+ // file save). The iOS HTTP ESM loader's
3226
+ // `LoadHttpModuleForUrl` (ModuleInternalCallbacks.mm) treats an
3227
+ // empty body as a fetch failure (`body.empty() → reject`), even
3228
+ // when the HTTP status is 200 OK. That bubbles up as
3229
+ // `HTTP fetch/compile failed` at the device's `__ns_import(...)`
3230
+ // inside each component's `_HmrLoad(Date.now())` and crashes
3231
+ // Angular's component bootstrap. To make Analog's empty
3232
+ // "no-update" response acceptable to the iOS loader, we wrap
3233
+ // `res.write` / `res.end` and substitute a minimal valid ESM
3234
+ // module body (`export {}`) when downstream writes nothing.
3235
+ // Non-empty bodies (real HMR update payloads after a save)
3236
+ // pass through unchanged.
3237
+ if (urlObj.pathname.includes('/@ng/component')) {
3238
+ const chunks = [];
3239
+ const origWrite = res.write.bind(res);
3240
+ const origEnd = res.end.bind(res);
3241
+ let ended = false;
3242
+ const captureChunk = (chunk) => {
3243
+ if (chunk == null)
3244
+ return;
3245
+ if (typeof chunk === 'string') {
3246
+ chunks.push(chunk);
3247
+ }
3248
+ else if (Buffer.isBuffer(chunk)) {
3249
+ chunks.push(chunk.toString('utf8'));
3250
+ }
3251
+ else {
3252
+ chunks.push(String(chunk));
3253
+ }
3254
+ };
3255
+ res.write = function (chunk, ..._args) {
3256
+ captureChunk(chunk);
3257
+ return true;
3258
+ };
3259
+ res.end = function (chunk, ..._args) {
3260
+ if (ended)
3261
+ return true;
3262
+ ended = true;
3263
+ captureChunk(chunk);
3264
+ let body = chunks.join('');
3265
+ if (body.length === 0) {
3266
+ body = '// [ns:m] empty Angular component metadata — substituted with valid empty module to satisfy iOS HTTP loader (rejects empty bodies)\nexport {};\n';
3267
+ }
3268
+ try {
3269
+ res.setHeader('Content-Length', Buffer.byteLength(body, 'utf8'));
3270
+ }
3271
+ catch { }
3272
+ return origEnd(body);
3273
+ };
3274
+ return next();
3275
+ }
3129
3276
  // Previously we awaited `populateInitialGraph(server)` here so
3130
3277
  // graphVersion would be non-zero for the first /ns/m request.
3131
3278
  // That gave deterministic URL tags but blocked the cold boot on a
@@ -3389,28 +3536,87 @@ function createHmrWebSocketPlugin(opts) {
3389
3536
  console.log('[hmr-ws][solid] forced createMemo path in createProxy for NativeScript HMR');
3390
3537
  }
3391
3538
  }
3539
+ // Patch 2b (NS HMR escape hatch): expose `source` on the proxy
3540
+ // itself via a non-enumerable, well-known symbol-ish key. This lets
3541
+ // our HMR remount path bypass solid-refresh's proxy chain and call
3542
+ // the freshly-patched underlying Home function directly. solid-refresh's
3543
+ // proxy wraps everything in nested createMemo's, which under
3544
+ // universal-renderer + nested-context (TanStack RouterContextProvider)
3545
+ // causes accumulating zombie memos that all subscribe to M_initial.source
3546
+ // — the visible symptom is "every other save applies".
3547
+ //
3548
+ // With this hatch we can do `proxy.__$$ns_resolve()` to obtain the
3549
+ // current underlying component (e.g. M_initial.proxy after first save,
3550
+ // or the actual Home function on the deepest hop) and then call
3551
+ // untrack(() => extract until we reach the actual function), then mount
3552
+ // against THAT function — no HMRComp memo proxy chain at the page level.
3553
+ // NS HMR escape hatch: stash `source` on the HMRComp function
3554
+ // itself AND short-circuit the Proxy's `get` handler so that
3555
+ // `proxy.__$ns_resolveSource` returns our exposed function.
3556
+ // Without the get-handler short-circuit, accessing the property
3557
+ // on the Proxy goes through `return source()[property]` — which
3558
+ // asks the *current source value* (which may itself be another
3559
+ // solid-refresh proxy or the underlying user function) for the
3560
+ // property. The user function doesn't have `__$ns_resolveSource`,
3561
+ // and a chained proxy would re-enter its own get handler. Either
3562
+ // way we end up with `undefined` at the page-level remount and
3563
+ // can't unwrap.
3564
+ //
3565
+ // NOTE: `$$` in String.prototype.replace replacement is treated
3566
+ // as a literal `$`. We use a single `$` to avoid that footgun.
3567
+ // Match only the unique opening fragment to avoid getting tripped up
3568
+ // by whitespace differences after the AST normalizer ran.
3569
+ const newProxyMarker = `if (property === 'location' || property === 'name') {`;
3570
+ if (patchedCode.includes(newProxyMarker)) {
3571
+ // 1. Inject `__$ns_resolveSource` as a property on the HMRComp
3572
+ // function itself (so its closure captures `source`).
3573
+ // CRITICAL: assign at module-eval time (right after HMRComp
3574
+ // is defined / before `return new Proxy(...)`), NOT inside
3575
+ // the HMRComp body — the body only runs when the proxy is
3576
+ // called, so before first call the property is undefined
3577
+ // and the page-level remount unwrap finds nothing.
3578
+ const setupMarker = `setComponentProperty(HMRComp, 'name', refreshName);`;
3579
+ patchedCode = patchedCode.replace(setupMarker, `HMRComp.__$ns_resolveSource = function() { return source(); }; ${setupMarker}`);
3580
+ // 2. Make the Proxy `get` handler short-circuit our property
3581
+ // so callers can do `proxy.__$ns_resolveSource()` without
3582
+ // going through `source()[property]` (which would unwrap one
3583
+ // hop early or reach the user function which doesn't have it).
3584
+ patchedCode = patchedCode.replace(newProxyMarker, `if (property === '__$ns_resolveSource') { return HMRComp.__$ns_resolveSource; } ${newProxyMarker}`);
3585
+ if (verbose) {
3586
+ console.log('[hmr-ws][solid] exposed __$ns_resolveSource on createProxy for NS HMR escape hatch');
3587
+ }
3588
+ }
3392
3589
  // Patch 3: Inline patchRegistry call so updates apply immediately
3393
3590
  // on module re-evaluation (accept callbacks are not invoked by the HMR client).
3394
- // The injected `console.log` helpers run inside the user's runtime
3395
- // when @solid-refresh re-evaluates a module, so they are a runtime
3396
- // concern (stripped if the user disables the patch). Keeping them
3397
- // behind the patch sentinel rather than the dev-server `verbose`
3398
- // flag is intentional the patch only runs when Solid HMR fires.
3591
+ //
3592
+ // The injected diagnostic logs are gated on
3593
+ // `globalThis.__NS_ENV_VERBOSE__` so they're silent in
3594
+ // normal use but resurface immediately when the user
3595
+ // re-runs with verbose logging enabled. The flag is
3596
+ // seeded by `mainEntryPlugin` from the same `verbose`
3597
+ // option that drives this server-side log gating.
3598
+ // Without these the visible HMR signal is just "did it
3599
+ // apply" — with them, devs can answer "did `hot.data`
3600
+ // persist", "did `patchRegistry` actually swap the
3601
+ // proxy's signal source", and "did the registry
3602
+ // component count change" without reaching for an
3603
+ // inspector.
3399
3604
  const marker = 'hot.data[SOLID_REFRESH] = hot.data[SOLID_REFRESH] || registry;';
3400
3605
  if (patchedCode.includes(marker)) {
3401
3606
  const patchCode = [
3402
- `console.log('[solid-refresh][$$refreshESM] hot.data keys=', hot.data ? Object.keys(hot.data) : 'no-data', 'has=', !!(hot.data && hot.data[SOLID_REFRESH]));`,
3607
+ `var __nsRefreshVerbose = (typeof globalThis !== 'undefined') && !!globalThis.__NS_ENV_VERBOSE__;`,
3608
+ `if (__nsRefreshVerbose) console.log('[ns-hmr][solid-refresh][$$refreshESM] hot.data has SOLID_REFRESH=', !!(hot.data && hot.data[SOLID_REFRESH]), 'registry components=', registry.components ? registry.components.size : 0);`,
3403
3609
  `if (hot.data[SOLID_REFRESH]) {`,
3404
- ` console.log('[solid-refresh][$$refreshESM] patching: oldComponents=', hot.data[SOLID_REFRESH].components ? hot.data[SOLID_REFRESH].components.size : 0, 'newComponents=', registry.components ? registry.components.size : 0);`,
3405
- ` var _shouldInvalidate = patchRegistry(hot.data[SOLID_REFRESH], registry);`,
3406
- ` console.log('[solid-refresh][$$refreshESM] patchRegistry result: shouldInvalidate=', _shouldInvalidate);`,
3610
+ ` var __nsOldComponents = hot.data[SOLID_REFRESH].components ? hot.data[SOLID_REFRESH].components.size : 0;`,
3611
+ ` var __nsShouldInvalidate = patchRegistry(hot.data[SOLID_REFRESH], registry);`,
3612
+ ` if (__nsRefreshVerbose) console.log('[ns-hmr][solid-refresh][$$refreshESM] patched: oldComponents=', __nsOldComponents, 'newComponents=', registry.components ? registry.components.size : 0, 'shouldInvalidate=', __nsShouldInvalidate);`,
3407
3613
  `} else {`,
3408
- ` console.log('[solid-refresh][$$refreshESM] first load — creating registry, components=', registry.components ? registry.components.size : 0);`,
3614
+ ` if (__nsRefreshVerbose) console.log('[ns-hmr][solid-refresh][$$refreshESM] first load — no prior registry to patch');`,
3409
3615
  `}`,
3410
3616
  ].join('\n ');
3411
3617
  patchedCode = patchedCode.replace(marker, `${patchCode}\n ${marker}`);
3412
3618
  if (verbose) {
3413
- console.log('[hmr-ws][solid] added inline patchRegistry for NativeScript HMR');
3619
+ console.log('[hmr-ws][solid] added inline patchRegistry (with diagnostics) for NativeScript HMR');
3414
3620
  }
3415
3621
  }
3416
3622
  // Work on a copy to avoid mutating Vite's cached TransformResult
@@ -4009,6 +4215,40 @@ export const piniaSymbol = p.piniaSymbol;
4009
4215
  `export const $navigateTo = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); try { if (!(g && g.Frame)) { const ns = (__ns_core_bridge && (__ns_core_bridge.__esModule && __ns_core_bridge.default ? __ns_core_bridge.default : (__ns_core_bridge.default || __ns_core_bridge))) || __ns_core_bridge || {}; if (ns) { if (!g.Frame && ns.Frame) g.Frame = ns.Frame; if (!g.Page && ns.Page) g.Page = ns.Page; if (!g.Application && (ns.Application||ns.app||ns.application)) g.Application = (ns.Application||ns.app||ns.application); } } } catch {} try { const hmrRealm = (g && g.__NS_HMR_REALM__) || 'unknown'; const hasTop = !!(g && g.Frame && g.Frame.topmost && g.Frame.topmost()); const top = hasTop ? g.Frame.topmost() : null; const ctor = top && top.constructor && top.constructor.name; } catch {} if (g && typeof g.__nsNavigateUsingApp === 'function') { try { return g.__nsNavigateUsingApp(...a); } catch (e) { console.error('[ns-rt] $navigateTo app navigator error', e); throw e; } } console.error('[ns-rt] $navigateTo unavailable: app navigator missing'); throw new Error('$navigateTo unavailable: app navigator missing'); } ;\n` +
4010
4216
  `export const $navigateBack = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); const impl = (vm && (vm.$navigateBack || (vm.default && vm.default.$navigateBack))) || (rt && (rt.$navigateBack || (rt.runtimeHelpers && rt.runtimeHelpers.navigateBack))); let res; try { const via = (impl && (impl === (vm && vm.$navigateBack) || impl === (vm && vm.default && vm.default.$navigateBack))) ? 'vm' : (impl ? 'rt' : 'none'); } catch {} try { if (typeof impl === 'function') res = impl(...a); } catch {} try { const top = (g && g.Frame && g.Frame.topmost && g.Frame.topmost()); if (!res && top && top.canGoBack && top.canGoBack()) { res = top.goBack(); } } catch {} try { const hook = g && (g.__NS_HMR_ON_NAVIGATE_BACK || g.__NS_HMR_ON_BACK || g.__nsAttemptBackRemount); if (typeof hook === 'function') hook(); } catch {} return res; }\n` +
4011
4217
  `export const $showModal = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); const impl = (vm && (vm.$showModal || (vm.default && vm.default.$showModal))) || (rt && (rt.$showModal || (rt.runtimeHelpers && rt.runtimeHelpers.showModal))); try { if (typeof impl === 'function') return impl(...a); } catch (e) { } return undefined; }\n` +
4218
+ // Vite client helpers re-exported through the runtime bridge.
4219
+ //
4220
+ // Vite's `vite:import-analysis` plugin rewrites unresolvable dynamic
4221
+ // imports (where the URL is not a static string literal) as
4222
+ // `__vite__injectQuery(<expr>, 'import')` and prepends an import
4223
+ // from `/@vite/client`. The /* @vite-ignore */ comment only
4224
+ // suppresses the warning, not the rewrite — Vite gates the rewrite
4225
+ // on `urlIsStringRE`, not `hasViteIgnoreRE`.
4226
+ //
4227
+ // In NativeScript dev, the AST normalizer (packages/vite/hmr/helpers/
4228
+ // ast-normalizer.ts) correctly strips the /@vite/client import (the
4229
+ // browser-only client module is not loadable on-device), then sees
4230
+ // the unbound `__vite__injectQuery` identifier and synthesizes
4231
+ // `const { vite__injectQuery: __vite__injectQuery } = __ns_rt_ns_1`
4232
+ // from this bridge. Without this export the destructure binds to
4233
+ // undefined and Angular 21's component HMR loader (and any other
4234
+ // caller of dynamic-import-with-non-literal-URL) fails with
4235
+ // `__vite__injectQuery is not a function` at module evaluation.
4236
+ //
4237
+ // This polyfill mirrors Vite 8's `__vite__injectQuery` in
4238
+ // node_modules/vite/dist/node/chunks/node.js — for relative or
4239
+ // absolute-path URLs it appends `?<queryToInject>` (preserving
4240
+ // existing search/hash); for already-absolute URLs (http(s):, etc.)
4241
+ // it returns the URL unchanged. Angular's `ɵɵgetReplaceMetadataURL`
4242
+ // returns absolute HTTP URLs, so this acts as a passthrough at
4243
+ // runtime, matching Vite's web behavior.
4244
+ `export const vite__injectQuery = (url, queryToInject) => {\n` +
4245
+ ` if (typeof url !== 'string') return url;\n` +
4246
+ ` if (url[0] !== '.' && url[0] !== '/') return url;\n` +
4247
+ ` const pathname = url.replace(/[?#].*$/, '');\n` +
4248
+ ` let search = '', hash = '';\n` +
4249
+ ` try { const u = new URL(url, 'http://vite.dev'); search = u.search || ''; hash = u.hash || ''; } catch {}\n` +
4250
+ ` return pathname + '?' + queryToInject + (search ? '&' + search.slice(1) : '') + (hash || '');\n` +
4251
+ `};\n` +
4012
4252
  `export default {\n` +
4013
4253
  ` defineComponent, resolveComponent, createVNode, createTextVNode, createCommentVNode,\n` +
4014
4254
  ` Fragment, Teleport, Transition, TransitionGroup, KeepAlive, Suspense, withCtx, openBlock,\n` +
@@ -4018,7 +4258,8 @@ export const piniaSymbol = p.piniaSymbol;
4018
4258
  ` isVNode, cloneVNode, isRef, ref, shallowRef, unref, computed, reactive, readonly, isReactive, isReadonly, toRaw, markRaw, shallowReactive, shallowReadonly,\n` +
4019
4259
  ` watch, watchEffect, watchPostEffect, watchSyncEffect, onBeforeMount, onMounted, onBeforeUpdate, onUpdated,\n` +
4020
4260
  ` onBeforeUnmount, onUnmounted, onActivated, onDeactivated, onErrorCaptured, onRenderTracked, onRenderTriggered, nextTick, h, provide, inject, vShow, createApp, registerElement,\n` +
4021
- ` $navigateTo, $navigateBack, $showModal\n` +
4261
+ ` $navigateTo, $navigateBack, $showModal,\n` +
4262
+ ` vite__injectQuery\n` +
4022
4263
  `};\n`;
4023
4264
  // Prepend guard and ship (harmless, keeps diagnostics consistent)
4024
4265
  code = REQUIRE_GUARD_SNIPPET + code;
@@ -6179,7 +6420,21 @@ export const piniaSymbol = p.piniaSymbol;
6179
6420
  const code = transformed?.code || '';
6180
6421
  upsertGraphModule((mod.id || '').replace(/\?.*$/, ''), code, deps, {
6181
6422
  emitDeltaOnInsert: true,
6182
- broadcastDelta: ACTIVE_STRATEGY.flavor !== 'angular',
6423
+ // Defer the delta broadcast until AFTER the framework
6424
+ // hot-update handler has had a chance to invalidate the
6425
+ // shared transform-request cache + Vite's moduleGraph
6426
+ // for the changed file and its transitive importers.
6427
+ // Otherwise the client races: it receives the delta
6428
+ // (eviction + re-import via tagged URL) before the
6429
+ // server has purged its caches, and the re-import is
6430
+ // served from cache → V8 evaluates the previous save's
6431
+ // transformed code → patchRegistry runs against an
6432
+ // unchanged source → the visible page is "one save
6433
+ // behind". Angular has always taken this path; Solid
6434
+ // needs the same contract because Solid HMR depends
6435
+ // on the client re-fetching the just-changed module
6436
+ // to drive `solid-refresh.patchRegistry`.
6437
+ broadcastDelta: ACTIVE_STRATEGY.flavor !== 'angular' && ACTIVE_STRATEGY.flavor !== 'solid',
6183
6438
  });
6184
6439
  }
6185
6440
  catch (error) {
@@ -6241,6 +6496,118 @@ export const piniaSymbol = p.piniaSymbol;
6241
6496
  // For Angular, react to component TS or external template HTML changes under /src
6242
6497
  const isHtml = file.endsWith('.html');
6243
6498
  const isTs = file.endsWith('.ts');
6499
+ // Web-style template HMR opt-in: when the user enables Angular's
6500
+ // `liveReload` (Analog's flag, mirrored from `--hmr` in
6501
+ // `configuration/angular.ts`), `.html` edits are owned by
6502
+ // Analog's `handleHotUpdate` which sends
6503
+ // `server.ws.send('angular:component-update', { id, timestamp })`.
6504
+ // The runtime listener registered in each compiled component
6505
+ // `.mjs` then dynamic-imports `/@ng/component?c=<id>&t=<ts>` and
6506
+ // calls `ɵɵreplaceMetadata` on the live class — swapping the
6507
+ // template definition AND walking live `LView`s to recreate
6508
+ // matching views in-place. NO Angular reboot, NO route navigation.
6509
+ //
6510
+ // The NS reboot path (`ns:angular-update` → `__reboot_ng_modules__`)
6511
+ // must be SKIPPED for HTML edits when this is on; otherwise both
6512
+ // fire, the reboot wins, and we lose the in-place swap. The
6513
+ // reboot path stays intact for `.ts` edits — those genuinely
6514
+ // change module-level code (services, route configs, NgModule
6515
+ // providers) that Angular's `ɵɵreplaceMetadata` can't reach.
6516
+ //
6517
+ // We detect "live reload mode is on" by checking that the
6518
+ // `analogjs-live-reload-plugin` registered itself with the
6519
+ // dev server. That plugin only exists when `liveReload: true`
6520
+ // was passed to `angular()` in `configuration/angular.ts`,
6521
+ // which gates on `hmrActive`. So this check is a clean
6522
+ // boolean: true iff the in-place pipeline is wired up.
6523
+ const angularLiveReloadActive = (server.config?.plugins ?? []).some((plugin) => plugin?.name === 'analogjs-live-reload-plugin');
6524
+ if (isHtml && angularLiveReloadActive) {
6525
+ updateMetrics.tAfterFramework = Date.now();
6526
+ if (verbose) {
6527
+ const rel = '/' +
6528
+ path.posix
6529
+ .normalize(path.relative(server.config.root || process.cwd(), file))
6530
+ .split(path.sep)
6531
+ .join('/');
6532
+ console.info(`[ns-hmr-diag][server] HTML edit handed off to Analog component-update path; skipping ns:angular-update broadcast (file=${rel})`);
6533
+ }
6534
+ // Re-query the moduleGraph for this file AFTER awaiting
6535
+ // `graphInitialPopulationPromise` (done at the top of
6536
+ // `handleHotUpdate`) and return the freshly-discovered
6537
+ // modules so they propagate to Analog's `handleHotUpdate`
6538
+ // in the same chain.
6539
+ //
6540
+ // Vite v8 builds the initial `mixedHmrContext.modules`
6541
+ // from `mixedModuleGraph.getModulesByFile(file)` BEFORE
6542
+ // any plugin runs. On the very first save after a cold
6543
+ // dev-server start, the moduleGraph for the changed
6544
+ // `.html` template has not yet been populated — that
6545
+ // population happens lazily via `populateInitialGraph`
6546
+ // → `transformRequest` → Analog's `transform` hook →
6547
+ // `addWatchFile(htmlFile)` → `vite:import-analysis`
6548
+ // consumes `_addedImports` and finally calls
6549
+ // `moduleGraph.updateModuleInfo` which registers the
6550
+ // `html → component.ts` importer relationship in
6551
+ // `fileToModulesMap`. All of that work races against the
6552
+ // file-watcher event for the `.html` edit, and the
6553
+ // watcher event almost always wins — so `ctx.modules`
6554
+ // arrives as `[]` even though the component is fully
6555
+ // compiled and ready to receive an in-place template
6556
+ // swap.
6557
+ //
6558
+ // Returning `undefined` here would propagate that empty
6559
+ // `ctx.modules` to the next plugin (Analog's handler),
6560
+ // which iterates with `ctx.modules.forEach(mod => mod
6561
+ // .importers.forEach(imp => …))` — a no-op when
6562
+ // `ctx.modules` is empty. Analog never broadcasts
6563
+ // `angular:component-update`, never marks anything
6564
+ // self-accepting, and Vite falls back to a `full-reload`
6565
+ // payload that the device runtime cannot honor (NS apps
6566
+ // don't have a browser-style page reload). The
6567
+ // user-visible symptom is exactly the "first save logs
6568
+ // `(client) page reload` and the simulator gets stuck
6569
+ // on the HMR-applying overlay forever" failure we hit
6570
+ // before this re-query was added.
6571
+ //
6572
+ // Since we already `await graphInitialPopulationPromise`
6573
+ // at the top of this function, by this point the
6574
+ // moduleGraph IS populated (every component file in
6575
+ // `src/` has been transformed and `addWatchFile` has
6576
+ // been consumed by `import-analysis`). A fresh
6577
+ // `getModulesByFile(file)` call now returns the template
6578
+ // module with the importing component's module in
6579
+ // `.importers`. Returning that array overwrites
6580
+ // `mixedHmrContext.modules` so Analog's handler — which
6581
+ // runs RIGHT AFTER us in the same chain — sees the
6582
+ // populated importer graph, identifies the component
6583
+ // class via `classNames.get(imp.id)`, and broadcasts
6584
+ // `angular:component-update` for `ɵɵreplaceMetadata`.
6585
+ //
6586
+ // We still skip the reboot path (`ns:angular-update`)
6587
+ // for HTML edits — control never reaches the
6588
+ // reboot-broadcast block below because of the `return`
6589
+ // here. The default-Vite-full-reload suppression is now
6590
+ // Analog's responsibility: it marks the changed module
6591
+ // self-accepting, which tells Vite the update is
6592
+ // handled and prevents the fallback.
6593
+ let resolvedModules = ctx.modules;
6594
+ try {
6595
+ const fresh = server.moduleGraph?.getModulesByFile?.(file);
6596
+ if (fresh && fresh.size > 0) {
6597
+ resolvedModules = [...fresh];
6598
+ if (verbose) {
6599
+ console.info(`[ns-hmr-diag][server] re-queried modules after graph population: count=${resolvedModules.length} (was ${ctx.modules?.length ?? 0})`);
6600
+ }
6601
+ }
6602
+ }
6603
+ catch (refetchErr) {
6604
+ if (verbose) {
6605
+ console.warn('[ns-hmr-diag][server] failed to re-query moduleGraph for html update', refetchErr);
6606
+ }
6607
+ }
6608
+ emitHmrUpdateSummary();
6609
+ return resolvedModules;
6610
+ }
6244
6611
  const angularHotUpdateRoots = collectAngularHotUpdateRoots({
6245
6612
  file,
6246
6613
  modules: ctx.modules,
@@ -6563,6 +6930,179 @@ export const piniaSymbol = p.piniaSymbol;
6563
6930
  const gm = graph.get(normalizedId);
6564
6931
  console.log('[hmr-ws][solid] delta module', { id: gm?.id, hash: gm?.hash });
6565
6932
  }
6933
+ // Purge the shared transform-request cache AND Vite's own
6934
+ // moduleGraph transformResult cache for the changed file
6935
+ // AND every transitive importer.
6936
+ //
6937
+ // Why this matters for Solid HMR specifically:
6938
+ // - The HMR client evicts V8's module cache for the
6939
+ // canonical /ns/m/<path> URL and re-imports the module.
6940
+ // - The dev server resolves /ns/m/* by calling
6941
+ // `sharedTransformRequest(...)`, which has a 60s TTL on
6942
+ // transform results to amortize cost across HMR
6943
+ // cycles. The shared cache wraps `server.transformRequest`,
6944
+ // which itself caches the compiled output on each
6945
+ // `ModuleNode.transformResult`. Both layers must be
6946
+ // invalidated, or the re-import resolves to whatever
6947
+ // the previous save populated.
6948
+ // - Without invalidation at *both* layers, the second
6949
+ // save of a file within the cache window returns the
6950
+ // FIRST save's transform — V8 evaluates stale code,
6951
+ // `solid-refresh.patchRegistry` runs against an
6952
+ // unchanged source body, and the visible page picks
6953
+ // up the previous save's edit instead of the current
6954
+ // one (the "one-save-behind" symptom users reported).
6955
+ //
6956
+ // Critically, transitive importers must also be invalidated
6957
+ // because TanStack file-based routing (and similar frameworks)
6958
+ // use route files that statically import their components.
6959
+ // When `home.tsx` changes, `routes/index.tsx`'s transform
6960
+ // output references the imported home module identity. Even
6961
+ // though the route file's source bytes did not change, its
6962
+ // *resolved* import target has — and its cached transform
6963
+ // might still encode the previous resolution. Forcing a
6964
+ // fresh transform of the importer guarantees the route
6965
+ // file's `import Home from ...` re-resolves against the
6966
+ // freshly evaluated home module on V8 side.
6967
+ //
6968
+ // The Angular path performs the equivalent purge via
6969
+ // `collectAngularTransformCacheInvalidationUrls` /
6970
+ // `sharedTransformRequest.invalidateMany`. We replicate
6971
+ // that contract for Solid here. The transitive walk is
6972
+ // bounded the same way (max depth 16, node_modules /
6973
+ // virtual ids excluded) so vendor packages stay hot.
6974
+ try {
6975
+ const projectRoot = server.config.root || process.cwd();
6976
+ const cacheInvalidationUrls = new Set();
6977
+ const addCacheKey = (rawId) => {
6978
+ const id = String(rawId || '');
6979
+ if (!id)
6980
+ return;
6981
+ const cacheKey = canonicalizeTransformRequestCacheKey(id, projectRoot);
6982
+ cacheInvalidationUrls.add(cacheKey);
6983
+ const noQuery = cacheKey.replace(/\?.*$/, '');
6984
+ const stripped = noQuery.replace(/\.(?:[mc]?[jt]sx?)$/i, '');
6985
+ if (stripped !== noQuery) {
6986
+ cacheInvalidationUrls.add(stripped);
6987
+ }
6988
+ };
6989
+ addCacheKey(file);
6990
+ const rootModules = server.moduleGraph.getModulesByFile?.(file);
6991
+ const transitiveImporters = collectAngularTransitiveImportersForInvalidation({
6992
+ modules: rootModules ? Array.from(rootModules) : [],
6993
+ isExcluded: (id) => id.includes('/node_modules/') || isRuntimeGraphExcludedPath(id),
6994
+ maxDepth: 16,
6995
+ });
6996
+ // Invalidate Vite's moduleGraph for the changed file +
6997
+ // every transitive importer so `server.transformRequest`
6998
+ // re-runs the transform pipeline instead of returning
6999
+ // the cached `ModuleNode.transformResult`. We call
7000
+ // `onFileChange` (Vite's authoritative file-changed
7001
+ // signal — walks all module variants including `?v=`,
7002
+ // `?import`, `?t=`) AND per-module `invalidateModule`
7003
+ // for transitive importers (which onFileChange
7004
+ // doesn't reach).
7005
+ try {
7006
+ server.moduleGraph.onFileChange(file);
7007
+ }
7008
+ catch { }
7009
+ if (rootModules) {
7010
+ for (const mod of rootModules) {
7011
+ try {
7012
+ server.moduleGraph.invalidateModule(mod);
7013
+ }
7014
+ catch { }
7015
+ }
7016
+ }
7017
+ for (const mod of transitiveImporters) {
7018
+ addCacheKey(mod?.id);
7019
+ try {
7020
+ server.moduleGraph.invalidateModule(mod);
7021
+ }
7022
+ catch { }
7023
+ }
7024
+ if (cacheInvalidationUrls.size && sharedTransformRequest) {
7025
+ sharedTransformRequest.invalidateMany(cacheInvalidationUrls);
7026
+ if (verbose) {
7027
+ console.log('[hmr-ws][solid] purged shared transform cache entries:', cacheInvalidationUrls.size, 'transitiveImporters=', transitiveImporters.length);
7028
+ }
7029
+ }
7030
+ // Sledgehammer: nuke EVERY entry in sharedTransformRequest's
7031
+ // result cache. The targeted `invalidateMany` above only
7032
+ // clears keys we know about. The `/ns/m/` handler iterates
7033
+ // a long list of candidate extensions (`.ts`, `.js`, `.tsx`,
7034
+ // `.jsx`, `.mjs`, `.mts`, `.cts`, `.vue`, `index.*`) and
7035
+ // EACH candidate is a separate cache key. If a previous
7036
+ // serve populated cache for `/src/components/home.js` (via
7037
+ // extension fallback that resolves to `home.tsx`), our
7038
+ // targeted invalidate misses it and iOS HITs the stale
7039
+ // entry — serving the previous save's transformed code.
7040
+ try {
7041
+ sharedTransformRequest.clear();
7042
+ }
7043
+ catch { }
7044
+ }
7045
+ catch (e) {
7046
+ if (verbose)
7047
+ console.warn('[hmr-ws][solid] transform cache invalidation failed', e);
7048
+ }
7049
+ // Re-run the transform AFTER all caches are invalidated, then
7050
+ // re-upsert the graph so the broadcast hash matches the freshly-
7051
+ // transformed content. The common upsert block above ran
7052
+ // `server.transformRequest` BEFORE invalidation — at that
7053
+ // moment Vite's auto-invalidate hadn't fired yet (it runs after
7054
+ // `plugin.handleHotUpdate`), so the result it cached was the
7055
+ // previous save's. Without this re-transform, the broadcast
7056
+ // carries a stale hash and iOS evaluates the previous save's
7057
+ // bytes ("one save behind").
7058
+ //
7059
+ // We pre-populate the cache for every extension variant Vite's
7060
+ // /ns/m/ handler might try, so the first request from iOS hits
7061
+ // fresh data regardless of which candidate it resolves first.
7062
+ try {
7063
+ const ext = file.match(/\.(?:[mc]?[jt]sx?)$/i)?.[0] || '';
7064
+ const baseSpec = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
7065
+ const baseNoExt = ext ? baseSpec.replace(/\.(?:[mc]?[jt]sx?)$/i, '') : baseSpec;
7066
+ const candidates = Array.from(new Set([baseSpec, baseNoExt, baseNoExt + '.ts', baseNoExt + '.tsx', baseNoExt + '.js', baseNoExt + '.jsx', baseNoExt + '.mjs', baseNoExt + '.mts', baseNoExt + '.cts', file]));
7067
+ let freshCode = '';
7068
+ for (const cand of candidates) {
7069
+ try {
7070
+ const fresh = await sharedTransformRequest(cand, 30000);
7071
+ if (fresh?.code && !freshCode)
7072
+ freshCode = fresh.code;
7073
+ }
7074
+ catch { }
7075
+ }
7076
+ if (freshCode) {
7077
+ const existingGm = graph.get(normalizedId);
7078
+ const existingDeps = existingGm?.deps || [];
7079
+ upsertGraphModule(normalizedId, freshCode, existingDeps, {
7080
+ broadcastDelta: false,
7081
+ });
7082
+ }
7083
+ }
7084
+ catch (e) {
7085
+ if (verbose)
7086
+ console.warn('[hmr-ws][solid] post-invalidation re-transform failed', e);
7087
+ }
7088
+ // Broadcast the (now-fresh) delta. Suppressing this in the
7089
+ // common upsert block (`broadcastDelta: ACTIVE_STRATEGY.flavor
7090
+ // !== 'solid'`) and emitting it here ensures the client's
7091
+ // eviction + re-import doesn't race the server's cache
7092
+ // invalidation.
7093
+ try {
7094
+ const gm = graph.get(normalizedId);
7095
+ if (gm) {
7096
+ emitDelta([gm], []);
7097
+ if (verbose) {
7098
+ console.log('[hmr-ws][solid] broadcast delta after cache invalidation', { id: gm.id, hash: gm.hash });
7099
+ }
7100
+ }
7101
+ }
7102
+ catch (e) {
7103
+ if (verbose)
7104
+ console.warn('[hmr-ws][solid] post-invalidation broadcast failed', e);
7105
+ }
6566
7106
  }
6567
7107
  catch (e) {
6568
7108
  if (verbose)