@nativescript/vite 8.0.0-alpha.5 → 8.0.0-alpha.7

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.
Files changed (78) hide show
  1. package/configuration/base.js +8 -8
  2. package/configuration/base.js.map +1 -1
  3. package/helpers/global-defines.d.ts +49 -0
  4. package/helpers/global-defines.js +75 -0
  5. package/helpers/global-defines.js.map +1 -1
  6. package/helpers/logging.d.ts +1 -0
  7. package/helpers/logging.js +36 -3
  8. package/helpers/logging.js.map +1 -1
  9. package/helpers/main-entry.js +5 -6
  10. package/helpers/main-entry.js.map +1 -1
  11. package/helpers/ns-core-url.d.ts +5 -6
  12. package/helpers/ns-core-url.js +5 -6
  13. package/helpers/ns-core-url.js.map +1 -1
  14. package/hmr/client/css-handler.js +2 -1
  15. package/hmr/client/css-handler.js.map +1 -1
  16. package/hmr/client/hmr-pending-overlay.d.ts +27 -0
  17. package/hmr/client/hmr-pending-overlay.js +50 -0
  18. package/hmr/client/hmr-pending-overlay.js.map +1 -0
  19. package/hmr/client/index.js +73 -1
  20. package/hmr/client/index.js.map +1 -1
  21. package/hmr/client/utils.d.ts +5 -0
  22. package/hmr/client/utils.js +155 -15
  23. package/hmr/client/utils.js.map +1 -1
  24. package/hmr/entry-runtime.js +95 -31
  25. package/hmr/entry-runtime.js.map +1 -1
  26. package/hmr/frameworks/angular/client/index.d.ts +1 -0
  27. package/hmr/frameworks/angular/client/index.js +416 -11
  28. package/hmr/frameworks/angular/client/index.js.map +1 -1
  29. package/hmr/server/core-sanitize.js +8 -8
  30. package/hmr/server/core-sanitize.js.map +1 -1
  31. package/hmr/server/import-map.js +3 -4
  32. package/hmr/server/import-map.js.map +1 -1
  33. package/hmr/server/ns-core-cjs-shape.d.ts +2 -4
  34. package/hmr/server/ns-core-cjs-shape.js +3 -5
  35. package/hmr/server/ns-core-cjs-shape.js.map +1 -1
  36. package/hmr/server/perf-instrumentation.d.ts +114 -0
  37. package/hmr/server/perf-instrumentation.js +195 -0
  38. package/hmr/server/perf-instrumentation.js.map +1 -0
  39. package/hmr/server/shared-transform-request.js +12 -5
  40. package/hmr/server/shared-transform-request.js.map +1 -1
  41. package/hmr/server/vite-plugin.js +3 -1
  42. package/hmr/server/vite-plugin.js.map +1 -1
  43. package/hmr/server/websocket-angular-hot-update.d.ts +16 -0
  44. package/hmr/server/websocket-angular-hot-update.js +161 -1
  45. package/hmr/server/websocket-angular-hot-update.js.map +1 -1
  46. package/hmr/server/websocket-core-bridge.js +11 -16
  47. package/hmr/server/websocket-core-bridge.js.map +1 -1
  48. package/hmr/server/websocket-graph-upsert.d.ts +15 -0
  49. package/hmr/server/websocket-graph-upsert.js +20 -0
  50. package/hmr/server/websocket-graph-upsert.js.map +1 -1
  51. package/hmr/server/websocket-hmr-pending.d.ts +43 -0
  52. package/hmr/server/websocket-hmr-pending.js +55 -0
  53. package/hmr/server/websocket-hmr-pending.js.map +1 -0
  54. package/hmr/server/websocket-ns-m-finalize.js +1 -1
  55. package/hmr/server/websocket-ns-m-finalize.js.map +1 -1
  56. package/hmr/server/websocket-ns-m-paths.d.ts +1 -1
  57. package/hmr/server/websocket-ns-m-paths.js +58 -13
  58. package/hmr/server/websocket-ns-m-paths.js.map +1 -1
  59. package/hmr/server/websocket-ns-m-request.js +1 -16
  60. package/hmr/server/websocket-ns-m-request.js.map +1 -1
  61. package/hmr/server/websocket-runtime-compat.js +3 -2
  62. package/hmr/server/websocket-runtime-compat.js.map +1 -1
  63. package/hmr/server/websocket-served-module-helpers.js +43 -18
  64. package/hmr/server/websocket-served-module-helpers.js.map +1 -1
  65. package/hmr/server/websocket-vue-sfc.js +3 -6
  66. package/hmr/server/websocket-vue-sfc.js.map +1 -1
  67. package/hmr/server/websocket.d.ts +4 -4
  68. package/hmr/server/websocket.js +670 -214
  69. package/hmr/server/websocket.js.map +1 -1
  70. package/hmr/shared/runtime/boot-timeline.d.ts +17 -0
  71. package/hmr/shared/runtime/boot-timeline.js +51 -0
  72. package/hmr/shared/runtime/boot-timeline.js.map +1 -0
  73. package/hmr/shared/runtime/dev-overlay.d.ts +49 -2
  74. package/hmr/shared/runtime/dev-overlay.js +585 -12
  75. package/hmr/shared/runtime/dev-overlay.js.map +1 -1
  76. package/hmr/shared/runtime/session-bootstrap.js +52 -0
  77. package/hmr/shared/runtime/session-bootstrap.js.map +1 -1
  78. package/package.json +1 -1
@@ -35,17 +35,25 @@ import { getCliFlags } from '../../helpers/cli-flags.js';
35
35
  import { normalizeCoreSub as normalizeCoreSubCanonical } from '../../helpers/ns-core-url.js';
36
36
  import { isRuntimeGraphExcludedPath, matchesRuntimeGraphModuleId, normalizeRuntimeGraphPath, shouldIncludeRuntimeGraphFile, shouldSkipRuntimeGraphDirectoryName } from './runtime-graph-filter.js';
37
37
  import { resolveAngularCoreHmrImportSource, rewriteAngularEntryRegisterOnly } from './websocket-angular-entry.js';
38
- import { canonicalizeTransformRequestCacheKey, collectAngularHotUpdateRoots, collectAngularTransformCacheInvalidationUrls, collectAngularTransitiveImportersForInvalidation, collectGraphUpdateModulesForHotUpdate, normalizeHotReloadMatchPath, shouldInvalidateAngularTransitiveImporters, shouldSuppressDefaultViteHotUpdate, shouldSuppressViteFullReloadPayload } from './websocket-angular-hot-update.js';
39
- import { classifyGraphUpsert, shouldBroadcastGraphUpsertDelta } from './websocket-graph-upsert.js';
38
+ import { angularSourceHasSemanticDecorator, canonicalizeTransformRequestCacheKey, collectAngularEvictionUrls, collectAngularHotUpdateRoots, collectAngularTransformCacheInvalidationUrls, collectAngularTransitiveImportersForInvalidation, collectGraphUpdateModulesForHotUpdate, normalizeHotReloadMatchPath, shouldInvalidateAngularTransitiveImporters, shouldSuppressDefaultViteHotUpdate, shouldSuppressViteFullReloadPayload } from './websocket-angular-hot-update.js';
39
+ import { classifyGraphUpsert, shouldBroadcastGraphUpsertDelta, shouldBumpGraphVersion } from './websocket-graph-upsert.js';
40
+ import { classifyBootRoute, classifyHmrUpdateKind, createColdBootRequestCounter, formatHmrUpdateSummary, formatPopulateInitialGraphSummary, formatServerStartupBanner } from './perf-instrumentation.js';
41
+ import { createHmrPendingMessage } from './websocket-hmr-pending.js';
40
42
  import { extractVitePrebundleId, filterExistingNodeModulesTransformCandidates, getBlockedDeviceNodeModulesReason, getFlattenedManifestMap, isCoreGlobalsReference, isEsmFrameworkPackageSpecifier, isLikelyNativeScriptPluginSpecifier, isLikelyNativeScriptRuntimePluginSpecifier, isNativeScriptCoreModule, isNativeScriptPluginModule, normalizeNativeScriptCoreSpecifier, normalizeNodeModulesSpecifier, resolveCandidateFilePath, resolveInternalRuntimePluginBareSpecifier, resolveNodeModulesPackageBoundary, resolveVendorFromCandidate, resolveVendorRouting, shouldPreserveBareRuntimePluginSubpathImport, stripDecoratedServePrefixes, tryReadRawExplicitJavaScriptModule, viteDepsPathToBareSpecifier, } from './websocket-module-specifiers.js';
41
43
  import { ensureNativeScriptModuleBindings, getProcessCodeResolvedSpecifierOverrides } from './websocket-module-bindings.js';
42
44
  import { collectStaticExportNamesFromFile, collectStaticExportOriginsFromFile, ensureVersionedCoreImports, extractDirectExportedNames, normalizeCoreExportOriginsForRuntime, parseCoreBridgeRequest, resolveRuntimeCoreModulePath } from './websocket-core-bridge.js';
43
45
  import { createSharedTransformRequestRunner } from './shared-transform-request.js';
46
+ import { formatNsMHmrServeTag, getNumericServeVersionTag, rewriteNsMImportPathForHmr } from './websocket-ns-m-paths.js';
47
+ import { ensureDynamicHmrImportHelper } from './websocket-served-module-helpers.js';
44
48
  export { ensureNativeScriptModuleBindings, getProcessCodeResolvedSpecifierOverrides } from './websocket-module-bindings.js';
45
49
  export { stripDecoratedServePrefixes, tryReadRawExplicitJavaScriptModule } from './websocket-module-specifiers.js';
46
50
  export { collectStaticExportNamesFromFile, collectStaticExportOriginsFromFile, ensureVersionedCoreImports, normalizeCoreExportOriginsForRuntime, parseCoreBridgeRequest } from './websocket-core-bridge.js';
47
51
  export { rewriteAngularEntryRegisterOnly } from './websocket-angular-entry.js';
48
- export { canonicalizeTransformRequestCacheKey, collectAngularHotUpdateRoots, collectAngularTransformCacheInvalidationUrls, collectAngularTransitiveImportersForInvalidation, collectGraphUpdateModulesForHotUpdate, createSharedTransformRequestRunner, normalizeHotReloadMatchPath, shouldInvalidateAngularTransitiveImporters, shouldSuppressDefaultViteHotUpdate, shouldSuppressViteFullReloadPayload, classifyGraphUpsert, shouldBroadcastGraphUpsertDelta };
52
+ // Re-export the canonical URL rewriter from `websocket-ns-m-paths.js` so the
53
+ // existing test suites (which import from `./websocket.js`) keep working
54
+ // without churn while the implementation lives in a focused module.
55
+ export { formatNsMHmrServeTag, rewriteNsMImportPathForHmr } from './websocket-ns-m-paths.js';
56
+ export { angularSourceHasSemanticDecorator, canonicalizeTransformRequestCacheKey, collectAngularEvictionUrls, collectAngularHotUpdateRoots, collectAngularTransformCacheInvalidationUrls, collectAngularTransitiveImportersForInvalidation, collectGraphUpdateModulesForHotUpdate, createSharedTransformRequestRunner, normalizeHotReloadMatchPath, shouldInvalidateAngularTransitiveImporters, shouldSuppressDefaultViteHotUpdate, shouldSuppressViteFullReloadPayload, classifyGraphUpsert, shouldBroadcastGraphUpsertDelta, shouldBumpGraphVersion };
49
57
  const pluginTransformTypescript = (() => {
50
58
  const requireFromHere = createRequire(import.meta.url);
51
59
  const loaded = requireFromHere('@babel/plugin-transform-typescript');
@@ -72,6 +80,33 @@ const APP_VIRTUAL_PREFIX = getProjectAppVirtualPath();
72
80
  const APP_VIRTUAL_WITH_SLASH = `${APP_VIRTUAL_PREFIX}/`;
73
81
  const DEFAULT_MAIN_ENTRY = getProjectAppRelativePath('app.ts');
74
82
  const DEFAULT_MAIN_ENTRY_VIRTUAL = getProjectAppVirtualPath('app.ts');
83
+ // Memoized resolver for the project bootstrap entry as a posix
84
+ // project-relative path (e.g. `/src/main.ts`). This mirrors the
85
+ // resolution the cold-boot wrapper performs (`getPackageJson().main` →
86
+ // project-relative under `/<APP_ROOT_DIR>/`) so the eviction set for
87
+ // HMR always lines up with the URL the runtime actually re-imports.
88
+ // Resolved at first call and cached: `package.json` is read at startup
89
+ // and never changes during a dev session, so it's safe to memoize.
90
+ let __ns_bootstrap_entry_rel_cached = null;
91
+ function getBootstrapEntryRelPath() {
92
+ if (__ns_bootstrap_entry_rel_cached)
93
+ return __ns_bootstrap_entry_rel_cached;
94
+ let entry = DEFAULT_MAIN_ENTRY_VIRTUAL;
95
+ try {
96
+ const pkg = getPackageJson();
97
+ const main = (pkg && pkg.main) || DEFAULT_MAIN_ENTRY;
98
+ const abs = getProjectFilePath(main).replace(/\\/g, '/');
99
+ const marker = `/${APP_ROOT_DIR}/`;
100
+ const idx = abs.indexOf(marker);
101
+ entry = idx >= 0 ? abs.substring(idx) : DEFAULT_MAIN_ENTRY_VIRTUAL;
102
+ }
103
+ catch { }
104
+ if (!entry.startsWith('/')) {
105
+ entry = '/' + entry;
106
+ }
107
+ __ns_bootstrap_entry_rel_cached = entry;
108
+ return entry;
109
+ }
75
110
  const STRATEGY_REGISTRY = new Map([
76
111
  ['vue', vueServerStrategy],
77
112
  ['angular', angularServerStrategy],
@@ -318,44 +353,10 @@ function ensureGuardPlainDynamicImports(code, origin) {
318
353
  return code;
319
354
  }
320
355
  }
321
- function ensureDynamicHmrImportHelper(code) {
322
- try {
323
- if (!code.includes('__nsDynamicHmrImport('))
324
- return code;
325
- if (code.includes('const __nsDynamicHmrImport ='))
326
- return code;
327
- const helper = 'const __nsDynamicHmrImport = (spec) => {\n' +
328
- " const __nsm = '/ns' + '/m';\n" +
329
- " const __nsBootPrefix = typeof import.meta !== 'undefined' && import.meta && typeof import.meta.url === 'string' && import.meta.url.includes('/__ns_boot__/b1/') ? '/__ns_boot__/b1' : '';\n" +
330
- " const __nsImporterTagMatch = typeof import.meta !== 'undefined' && import.meta && typeof import.meta.url === 'string' ? import.meta.url.match(/\\/__ns_hmr__\\/([^/]+)\\//) : null;\n" +
331
- " const __nsImporterTag = __nsImporterTagMatch && __nsImporterTagMatch[1] ? decodeURIComponent(__nsImporterTagMatch[1]) : '';\n" +
332
- " try { if (!spec || spec === '@') { return import(new URL(__nsm + '/__invalid_at__.mjs', import.meta.url).href); } } catch {}\n" +
333
- ' try {\n' +
334
- " if (typeof spec === 'string' && spec.startsWith(__nsm + '/')) {\n" +
335
- ' const g = globalThis;\n' +
336
- " const graphVersion = typeof g.__NS_HMR_GRAPH_VERSION__ === 'number' ? g.__NS_HMR_GRAPH_VERSION__ : 0;\n" +
337
- " const nonce = typeof g.__NS_HMR_IMPORT_NONCE__ === 'number' ? g.__NS_HMR_IMPORT_NONCE__ : 0;\n" +
338
- " const __nsActiveBootPrefix = graphVersion || nonce ? '' : __nsBootPrefix;\n" +
339
- " if (spec.includes('/__ns_hmr__/')) {\n" +
340
- " const __preservedSpec = !nonce && __nsBootPrefix && spec.startsWith(__nsm + '/__ns_hmr__/') && !spec.includes('/node_modules/') ? __nsm + __nsBootPrefix + spec.slice(__nsm.length) : spec;\n" +
341
- ' return import(new URL(__preservedSpec, import.meta.url).href);\n' +
342
- ' }\n' +
343
- " if (spec.startsWith(__nsm + '/node_modules/')) { return import(new URL(spec, import.meta.url).href); }\n" +
344
- " const tag = nonce ? `n${nonce}` : (graphVersion ? `v${graphVersion}` : (__nsImporterTag || 'live'));\n" +
345
- " const nextPath = __nsm + __nsActiveBootPrefix + '/__ns_hmr__/' + encodeURIComponent(tag) + spec.slice(__nsm.length);\n" +
346
- " const origin = typeof g.__NS_HTTP_ORIGIN__ === 'string' && /^https?:\\/\\//.test(g.__NS_HTTP_ORIGIN__) ? g.__NS_HTTP_ORIGIN__ : '';\n" +
347
- ' return import(origin ? origin + nextPath : new URL(nextPath, import.meta.url).href);\n' +
348
- ' }\n' +
349
- ' } catch {}\n' +
350
- ' return import(spec);\n' +
351
- '};\n';
352
- return helper + code;
353
- }
354
- catch {
355
- return code;
356
- }
357
- }
358
- async function expandStarExports(code, server, projectRoot, verbose) {
356
+ // `ensureDynamicHmrImportHelper` lives in
357
+ // `./websocket-served-module-helpers.js`. See that file for the
358
+ // architectural rationale and the current helper implementation.
359
+ async function expandStarExports(code, server, projectRoot, verbose, sharedTransformer) {
359
360
  const STAR_RE = /^[ \t]*(export\s+\*\s+from\s+["'])([^"']+)(["'];?)[ \t]*$/gm;
360
361
  let match;
361
362
  const replacements = [];
@@ -367,25 +368,41 @@ async function expandStarExports(code, server, projectRoot, verbose) {
367
368
  }
368
369
  if (!replacements.length)
369
370
  return code;
370
- for (const rep of replacements) {
371
+ // Pull target URLs through the shared runner when it's available so each
372
+ // node_modules path shares the 60s TTL cache with the main /ns/m pipeline
373
+ // and respects the global concurrency gate. Fan them out in parallel —
374
+ // this block used to be a serial `for await` loop, which dominated cold
375
+ // boot on apps with dozens of star-re-exports.
376
+ const transformer = sharedTransformer ?? ((url) => server.transformRequest(url));
377
+ const resolved = await Promise.all(replacements.map(async (rep) => {
371
378
  try {
372
379
  let vitePath = rep.url.replace(/^https?:\/\/[^/]+/, '');
373
380
  vitePath = vitePath.replace(/^\/ns\/m\//, '/');
374
381
  vitePath = vitePath.replace(/^\/__ns_boot__\/[^/]+/, '');
375
382
  vitePath = vitePath.replace(/\/__ns_hmr__\/[^/]+/, '');
376
- const result = await server.transformRequest(vitePath);
383
+ const result = await transformer(vitePath);
377
384
  if (!result?.code)
378
- continue;
385
+ return null;
379
386
  const names = extractExportedNames(result.code);
380
387
  if (!names.length)
381
- continue;
382
- const explicit = `export { ${names.join(', ')} } from ${JSON.stringify(rep.url)};`;
383
- code = code.replace(rep.full, explicit);
388
+ return null;
384
389
  if (verbose) {
385
- console.log(`[ns/m] expanded export* -> ${names.length} names from ${vitePath}`);
390
+ try {
391
+ console.log(`[ns/m] expanded export* -> ${names.length} names from ${vitePath}`);
392
+ }
393
+ catch { }
386
394
  }
395
+ return { rep, names };
387
396
  }
388
- catch { }
397
+ catch {
398
+ return null;
399
+ }
400
+ }));
401
+ for (const entry of resolved) {
402
+ if (!entry)
403
+ continue;
404
+ const explicit = `export { ${entry.names.join(', ')} } from ${JSON.stringify(entry.rep.url)};`;
405
+ code = code.replace(entry.rep.full, explicit);
389
406
  }
390
407
  return code;
391
408
  }
@@ -894,52 +911,13 @@ function toNodeModulesHttpModuleId(importPath) {
894
911
  }
895
912
  return `/ns/m/node_modules/${nodeModulesSpecifier}`;
896
913
  }
897
- export function rewriteNsMImportPathForHmr(p, ver, bootTaggedRequest) {
898
- const toHmrServeTag = (value) => {
899
- const raw = String(value ?? '').trim();
900
- if (!raw) {
901
- return 'v0';
902
- }
903
- if (raw === 'live' || /^n\d+$/i.test(raw) || /^v[^/]+$/i.test(raw)) {
904
- return raw;
905
- }
906
- if (/^\d+$/.test(raw)) {
907
- return `v${raw}`;
908
- }
909
- return raw;
910
- };
911
- if (!p || !p.startsWith('/ns/m/')) {
912
- return p;
913
- }
914
- const canonicalNodeModulesPath = p.replace(/^\/ns\/m\/__ns_boot__\/b1\/__ns_hmr__\/[^/]+\/node_modules\//, '/ns/m/node_modules/').replace(/^\/ns\/m\/__ns_hmr__\/[^/]+\/node_modules\//, '/ns/m/node_modules/');
915
- if (canonicalNodeModulesPath.startsWith('/ns/m/node_modules/')) {
916
- return canonicalNodeModulesPath;
917
- }
918
- if (canonicalNodeModulesPath.startsWith('/ns/m/__ns_boot__/')) {
919
- return canonicalNodeModulesPath;
920
- }
921
- if (canonicalNodeModulesPath.startsWith('/ns/m/__ns_hmr__/')) {
922
- return bootTaggedRequest ? `/ns/m/__ns_boot__/b1${canonicalNodeModulesPath.slice('/ns/m'.length)}` : canonicalNodeModulesPath;
923
- }
924
- const tag = toHmrServeTag(ver);
925
- const hmrPrefix = `/ns/m/__ns_hmr__/${tag}`;
926
- const bootHmrPrefix = `/ns/m/__ns_boot__/b1/__ns_hmr__/${tag}`;
927
- return (bootTaggedRequest ? bootHmrPrefix : hmrPrefix) + canonicalNodeModulesPath.slice('/ns/m'.length);
928
- }
929
- function getNumericServeVersionTag(tag, fallback) {
930
- const raw = String(tag || '').trim();
931
- if (!raw) {
932
- return fallback;
933
- }
934
- const versionMatch = raw.match(/^v(\d+)$/);
935
- if (versionMatch?.[1]) {
936
- return Number(versionMatch[1]);
937
- }
938
- if (/^\d+$/.test(raw)) {
939
- return Number(raw);
940
- }
941
- return fallback;
942
- }
914
+ // `rewriteNsMImportPathForHmr` and `getNumericServeVersionTag` live in
915
+ // `./websocket-ns-m-paths.js`. The path rewriter is part of the
916
+ // "Stable URL + Explicit Invalidation" architecture and must be a
917
+ // single source of truth so the canonicalization rules can't drift
918
+ // between modules. They are imported above and re-exported below for
919
+ // tests / external callers that historically reached them through this
920
+ // module.
943
921
  function normalizeAbsoluteFilesystemImport(spec, importerPath, projectRoot) {
944
922
  if (!spec || typeof spec !== 'string') {
945
923
  return null;
@@ -2331,10 +2309,21 @@ function createHmrWebSocketPlugin(opts) {
2331
2309
  let registrySent = false;
2332
2310
  let vendorBootstrapDone = false;
2333
2311
  let pluginRoot;
2334
- let graphVersion = 0;
2312
+ // graphVersion starts at 1 so the very first /ns/m response uses a stable
2313
+ // `v1` URL tag (see dynamic-import helper at lines 398-432). Keeping it
2314
+ // stable during cold boot prevents double-loads when the graph fills up
2315
+ // lazily as modules are served.
2316
+ let graphVersion = 1;
2335
2317
  // Transactional HMR batches: map graphVersion -> ordered list of changed ids for that version
2336
2318
  const txnBatches = new Map();
2337
2319
  const graph = new Map();
2320
+ // Tracks the background initial-graph population so handleHotUpdate can
2321
+ // await completion before computing delta roots for the first HMR event.
2322
+ let graphInitialPopulationPromise = null;
2323
+ // Cold-boot /ns/m request counter — populated the first time a /ns/m
2324
+ // request arrives, finalized when the request window goes idle.
2325
+ // See Shared across requests so a single counter spans the whole cold boot.
2326
+ let coldBootCounter = null;
2338
2327
  function rememberAngularReloadSuppression(root, file, ttlMs = 3000) {
2339
2328
  const absPath = normalizeHotReloadMatchPath(file);
2340
2329
  const relPath = normalizeHotReloadMatchPath(file, root);
@@ -2506,12 +2495,17 @@ function createHmrWebSocketPlugin(opts) {
2506
2495
  const classification = classifyGraphUpsert(existing, hash, normDeps);
2507
2496
  if (classification === 'unchanged')
2508
2497
  return existing;
2509
- graphVersion++;
2498
+ // Version bumps are only meaningful for live edits — serve-time graph
2499
+ // warm-ups and the initial bulk walk should leave graphVersion stable.
2500
+ const bumpVersion = shouldBumpGraphVersion(classification, options?.bumpVersion !== false);
2501
+ if (bumpVersion) {
2502
+ graphVersion++;
2503
+ }
2510
2504
  const gm = { id, deps: normDeps, hash };
2511
2505
  graph.set(id, gm);
2512
2506
  if (verbose) {
2513
2507
  try {
2514
- console.log('[hmr-ws][graph] upsert', { id, deps: normDeps, hash, graphVersion, classification });
2508
+ console.log('[hmr-ws][graph] upsert', { id, deps: normDeps, hash, graphVersion, classification, bumpVersion });
2515
2509
  console.log('[hmr-ws][graph] size', graph.size);
2516
2510
  }
2517
2511
  catch { }
@@ -2539,6 +2533,8 @@ function createHmrWebSocketPlugin(opts) {
2539
2533
  async function populateInitialGraph(server) {
2540
2534
  if (graph.size)
2541
2535
  return; // already populated
2536
+ const tStart = Date.now();
2537
+ const versionAtStart = graphVersion;
2542
2538
  const root = server.config.root || process.cwd();
2543
2539
  // Avoid direct require in ESM build: lazily obtain fs & path via createRequire or dynamic import
2544
2540
  let fs;
@@ -2554,6 +2550,18 @@ function createHmrWebSocketPlugin(opts) {
2554
2550
  fs = await import('fs');
2555
2551
  pathMod = await import('path');
2556
2552
  }
2553
+ // Route every bulk transform through `sharedTransformRequest` when it's
2554
+ // already been wired up — this way the background walk shares the 60s
2555
+ // TTL cache with live /ns/m requests, so the device sees cached results
2556
+ // for any file the walker already visited. The fallback keeps the
2557
+ // walker working during server tests where the shared runner isn't
2558
+ // constructed yet.
2559
+ const bulkTransform = (rel) => {
2560
+ if (sharedTransformRequest) {
2561
+ return sharedTransformRequest(rel);
2562
+ }
2563
+ return server.transformRequest(rel);
2564
+ };
2557
2565
  async function walk(dir) {
2558
2566
  for (const name of fs.readdirSync(dir)) {
2559
2567
  if (name === 'node_modules' || name.startsWith('.') || shouldSkipRuntimeGraphDirectoryName(name))
@@ -2568,7 +2576,7 @@ function createHmrWebSocketPlugin(opts) {
2568
2576
  const rel = '/' + pathMod.relative(root, full).split(pathMod.sep).join('/');
2569
2577
  // Transform via Vite to gather deps (ignore failures)
2570
2578
  try {
2571
- const transformed = await server.transformRequest(rel);
2579
+ const transformed = await bulkTransform(rel);
2572
2580
  const code = transformed?.code || '';
2573
2581
  const deps = [];
2574
2582
  // fallback to import relationships via moduleGraph
@@ -2579,7 +2587,10 @@ function createHmrWebSocketPlugin(opts) {
2579
2587
  deps.push(m.id.split('?')[0]);
2580
2588
  }
2581
2589
  }
2582
- upsertGraphModule(rel, code, deps);
2590
+ // bumpVersion: false — the initial walk is a bulk load, not a live
2591
+ // edit. Keeping graphVersion stable during cold boot avoids double
2592
+ // cache-key drift.
2593
+ upsertGraphModule(rel, code, deps, { bumpVersion: false });
2583
2594
  }
2584
2595
  catch { }
2585
2596
  }
@@ -2592,6 +2603,40 @@ function createHmrWebSocketPlugin(opts) {
2592
2603
  await walk(pathMod.join(root, 'src'));
2593
2604
  }
2594
2605
  catch { }
2606
+ // Diagnostic summary. Gated behind the verbose flag so the
2607
+ // dev console stays quiet on a normal save. Flip
2608
+ // NS_VITE_VERBOSE=1 to surface slow cold-boot walks; a
2609
+ // `bumpedVersion=no` result is the happy path, `yes`
2610
+ // indicates a regression.
2611
+ if (verbose) {
2612
+ try {
2613
+ console.info(formatPopulateInitialGraphSummary({
2614
+ moduleCount: graph.size,
2615
+ durationMs: Date.now() - tStart,
2616
+ graphVersion,
2617
+ bumpedVersion: graphVersion !== versionAtStart,
2618
+ }));
2619
+ }
2620
+ catch { }
2621
+ }
2622
+ }
2623
+ // Kick off `populateInitialGraph` in the background (non-awaited) so /ns/m
2624
+ // responses are never blocked on a full tree walk. Returns the shared
2625
+ // promise so hot-update code paths can await completion before computing
2626
+ // delta roots for the first HMR event.
2627
+ function ensureInitialGraphPopulationStarted(server) {
2628
+ if (graphInitialPopulationPromise) {
2629
+ return graphInitialPopulationPromise;
2630
+ }
2631
+ if (graph.size) {
2632
+ graphInitialPopulationPromise = Promise.resolve();
2633
+ return graphInitialPopulationPromise;
2634
+ }
2635
+ graphInitialPopulationPromise = populateInitialGraph(server).catch((error) => {
2636
+ if (verbose)
2637
+ console.warn('[hmr-ws][graph] background initial population failed', error);
2638
+ });
2639
+ return graphInitialPopulationPromise;
2595
2640
  }
2596
2641
  return {
2597
2642
  name: 'nativescript-hmr-websocket',
@@ -2620,12 +2665,22 @@ function createHmrWebSocketPlugin(opts) {
2620
2665
  return originalSend(payload, ...rest);
2621
2666
  });
2622
2667
  }
2623
- // Default to serialized transform execution for deterministic HTTP HMR startup.
2624
- // Higher fan-out can be re-enabled explicitly via NS_VITE_HMR_TRANSFORM_CONCURRENCY.
2625
- const configuredTransformConcurrency = Number.parseInt(process.env.NS_VITE_HMR_TRANSFORM_CONCURRENCY || '1', 10);
2626
- const transformConcurrency = Number.isFinite(configuredTransformConcurrency) && configuredTransformConcurrency > 0 ? configuredTransformConcurrency : 1;
2627
- const configuredTransformCacheMs = Number.parseInt(process.env.NS_VITE_HMR_TRANSFORM_CACHE_MS || '15000', 10);
2628
- const transformCacheMs = Number.isFinite(configuredTransformCacheMs) && configuredTransformCacheMs >= 0 ? configuredTransformCacheMs : 15000;
2668
+ // Transform concurrency. Historically we defaulted to 1 to avoid
2669
+ // race conditions during HTTP HMR startup, but the shared runner
2670
+ // already has per-URL coalescing and an async-cached result map,
2671
+ // so higher fan-out is safe and dramatically reduces cold-boot
2672
+ // time. We cap at 8 by default to match typical dev machines and
2673
+ // respect Vite's internal worker pool limits. Override via the
2674
+ // `NS_VITE_HMR_TRANSFORM_CONCURRENCY` env var when needed.
2675
+ const configuredTransformConcurrency = Number.parseInt(process.env.NS_VITE_HMR_TRANSFORM_CONCURRENCY || '', 10);
2676
+ const transformConcurrency = Number.isFinite(configuredTransformConcurrency) && configuredTransformConcurrency > 0 ? configuredTransformConcurrency : 8;
2677
+ // Keep transformed code cached for longer across HMR updates so
2678
+ // that unchanged neighbours of an edited file don't re-run
2679
+ // through the Angular/TypeScript/Vite transform pipeline. The
2680
+ // HMR flow explicitly invalidates affected URLs, so a longer TTL
2681
+ // is safe. Override with `NS_VITE_HMR_TRANSFORM_CACHE_MS`.
2682
+ const configuredTransformCacheMs = Number.parseInt(process.env.NS_VITE_HMR_TRANSFORM_CACHE_MS || '', 10);
2683
+ const transformCacheMs = Number.isFinite(configuredTransformCacheMs) && configuredTransformCacheMs >= 0 ? configuredTransformCacheMs : 60000;
2629
2684
  sharedTransformRequest = createSharedTransformRequestRunner((url) => server.transformRequest(url), (url, timeoutMs) => {
2630
2685
  try {
2631
2686
  console.warn('[ns:m] slow transformRequest for', url, '(>' + timeoutMs + 'ms)');
@@ -2636,6 +2691,124 @@ function createHmrWebSocketPlugin(opts) {
2636
2691
  resultCacheTtlMs: transformCacheMs,
2637
2692
  getResultCacheKey: (url) => canonicalizeTransformRequestCacheKey(url, pluginRoot || process.cwd()),
2638
2693
  });
2694
+ // Always-on startup banner — prints once per dev server process
2695
+ // so anyone investigating perf can immediately see which build
2696
+ // is live and what knobs are active.
2697
+ try {
2698
+ let pkgVersion = 'unknown';
2699
+ try {
2700
+ const req = createRequire(import.meta.url);
2701
+ const pkg = req('@nativescript/vite/package.json');
2702
+ if (pkg && typeof pkg.version === 'string')
2703
+ pkgVersion = pkg.version;
2704
+ }
2705
+ catch {
2706
+ // `@nativescript/vite/package.json` is not always exported; fall
2707
+ // back to reading the file from disk next to this module.
2708
+ try {
2709
+ const here = new URL(import.meta.url).pathname;
2710
+ const pkgPath = path.resolve(path.dirname(here), '..', '..', 'package.json');
2711
+ if (existsSync(pkgPath)) {
2712
+ const parsed = JSON.parse(readFileSync(pkgPath, 'utf-8'));
2713
+ if (parsed && typeof parsed.version === 'string')
2714
+ pkgVersion = parsed.version;
2715
+ }
2716
+ }
2717
+ catch { }
2718
+ }
2719
+ if (verbose) {
2720
+ console.info(formatServerStartupBanner({
2721
+ version: pkgVersion,
2722
+ transformConcurrency,
2723
+ transformCacheMs,
2724
+ lazyInitialGraph: true,
2725
+ graphVersion,
2726
+ }));
2727
+ }
2728
+ }
2729
+ catch { }
2730
+ // Always-on cold-boot request trace. Runs in front of every
2731
+ // other middleware so it catches all NS dev routes (/ns/m/*,
2732
+ // /ns/rt/*, /ns/core/*, /__ns_boot__/*, etc.) with a single
2733
+ // hook. Closes itself after an idle window so HMR edits don't
2734
+ // get rolled into the cold-boot numbers. The idle window is
2735
+ // generous by default (5s) because V8's HTTP ESM resolver
2736
+ // pauses between dep levels while parsing — a too-tight window
2737
+ // was closing after the first wave and under-reporting boot by
2738
+ // 100x. Override via `NS_VITE_HMR_BOOT_TRACE_IDLE_MS` when
2739
+ // profiling something tricky.
2740
+ try {
2741
+ const configuredIdleMs = Number.parseInt(process.env.NS_VITE_HMR_BOOT_TRACE_IDLE_MS || '', 10);
2742
+ const idleWindowMs = Number.isFinite(configuredIdleMs) && configuredIdleMs > 0 ? configuredIdleMs : 5000;
2743
+ const configuredSummaryEvery = Number.parseInt(process.env.NS_VITE_HMR_BOOT_TRACE_PROGRESS_EVERY || '', 10);
2744
+ const summaryEvery = Number.isFinite(configuredSummaryEvery) && configuredSummaryEvery >= 0 ? configuredSummaryEvery : 25;
2745
+ if (!coldBootCounter) {
2746
+ coldBootCounter = createColdBootRequestCounter({
2747
+ summaryEvery,
2748
+ idleWindowMs,
2749
+ // Gated on the verbose flag so cold-boot progress and
2750
+ // the final window-closed summary stay quiet by
2751
+ // default. Flip NS_VITE_VERBOSE=1 to surface them.
2752
+ log: (line) => {
2753
+ if (!verbose)
2754
+ return;
2755
+ try {
2756
+ console.info(line);
2757
+ }
2758
+ catch { }
2759
+ },
2760
+ });
2761
+ }
2762
+ }
2763
+ catch { }
2764
+ server.middlewares.use((req, res, next) => {
2765
+ try {
2766
+ const urlObj = new URL(req.url || '', 'http://localhost');
2767
+ const route = classifyBootRoute(urlObj.pathname);
2768
+ if (route === 'other')
2769
+ return next();
2770
+ if (!coldBootCounter)
2771
+ return next();
2772
+ const handle = coldBootCounter.record(urlObj.pathname);
2773
+ const finishOnce = () => {
2774
+ try {
2775
+ handle.finish();
2776
+ }
2777
+ catch { }
2778
+ };
2779
+ try {
2780
+ res.once('finish', finishOnce);
2781
+ res.once('close', finishOnce);
2782
+ }
2783
+ catch { }
2784
+ }
2785
+ catch { }
2786
+ next();
2787
+ });
2788
+ // Give `populateInitialGraph` a head start. Previously this only
2789
+ // kicked off on the first /ns/m hit, which meant populate was
2790
+ // competing with the device for the same 8 transform slots
2791
+ // throughout the first 4-5 seconds of cold boot. Starting at
2792
+ // `configureServer` time gives populate the full app
2793
+ // build/launch window (typically 2-3s on simulator) as a head
2794
+ // start, so more of its work lands before the device even
2795
+ // connects. Disable via `NS_VITE_HMR_DISABLE_POPULATE=1` when
2796
+ // profiling whether populate is helping or hurting a specific
2797
+ // app.
2798
+ try {
2799
+ const disablePopulate = process.env.NS_VITE_HMR_DISABLE_POPULATE === '1' || process.env.NS_VITE_HMR_DISABLE_POPULATE === 'true';
2800
+ if (disablePopulate) {
2801
+ if (verbose)
2802
+ console.info('[hmr-ws][populate] disabled via NS_VITE_HMR_DISABLE_POPULATE');
2803
+ // Short-circuit: mark as resolved so /ns/m never schedules it and
2804
+ // HMR still works (handleHotUpdate just has no pre-warmed graph).
2805
+ graphInitialPopulationPromise = Promise.resolve();
2806
+ }
2807
+ else {
2808
+ ensureInitialGraphPopulationStarted(server);
2809
+ }
2810
+ }
2811
+ catch { }
2639
2812
  // Attempt early vendor manifest bootstrap once per server.
2640
2813
  if (!vendorBootstrapDone) {
2641
2814
  vendorBootstrapDone = true;
@@ -2910,25 +3083,23 @@ function createHmrWebSocketPlugin(opts) {
2910
3083
  const urlObj = new URL(req.url || '', 'http://localhost');
2911
3084
  if (!urlObj.pathname.startsWith('/ns/m'))
2912
3085
  return next();
2913
- // Populate the initial graph on first /ns/m request so graphVersion is
2914
- // non-zero and stable before we rewrite any child import paths. Without
2915
- // this the first dyn-imports (e.g. main.ts routed tab components) are
2916
- // served with graphVersion=0, which makes the 'live' v{N} substitution
2917
- // below no-op; later dyn-imports (after the HMR websocket client connects
2918
- // and populateInitialGraph runs inside 'connection') arrive when
2919
- // graphVersion has jumped to some N>0, so their child URLs land at /v{N}/
2920
- // while the early tree still references /live/ — two distinct iOS HTTP
2921
- // ESM cache entries for the same file, two Angular class identities,
2922
- // NG0912 selector collisions.
2923
- if (graph.size === 0) {
2924
- try {
2925
- await populateInitialGraph(server);
2926
- }
2927
- catch (e) {
2928
- if (verbose)
2929
- console.warn('[hmr-ws][graph] lazy initial population failed', e);
2930
- }
2931
- }
3086
+ // Previously we awaited `populateInitialGraph(server)` here so
3087
+ // graphVersion would be non-zero for the first /ns/m request.
3088
+ // That gave deterministic URL tags but blocked the cold boot on a
3089
+ // full src/ tree walk (hundreds of transformRequest calls, 3-6s).
3090
+ //
3091
+ // graphVersion now starts at 1 and stays stable during cold boot
3092
+ // (see `upsertGraphModule`'s bumpVersion option and the inline
3093
+ // comment at the graphVersion declaration). We kick off the
3094
+ // initial population in the background so it doesn't block the
3095
+ // first response. `handleHotUpdate` awaits the same promise so
3096
+ // the first HMR event still sees a fully populated graph.
3097
+ ensureInitialGraphPopulationStarted(server);
3098
+ // Cold-boot counter is now hooked via the leading boot-trace
3099
+ // middleware (see `configureServer` — it records the request
3100
+ // and tracks finish() via res.on('close'/'finish')). This
3101
+ // handler used to record here but that missed the
3102
+ // round-trip timing and didn't track per-route breakdowns.
2932
3103
  res.setHeader('Access-Control-Allow-Origin', '*');
2933
3104
  res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
2934
3105
  // Disable caching for dev ESM endpoints to avoid device-side stale module reuse
@@ -3140,7 +3311,9 @@ function createHmrWebSocketPlugin(opts) {
3140
3311
  if (transformed?.code && ACTIVE_STRATEGY?.flavor === 'solid' && (resolvedCandidate || spec || '').includes('@solid-refresh')) {
3141
3312
  const PATCH_SENTINEL = '/* __ns_solid_refresh_patched__ */';
3142
3313
  const alreadyPatched = transformed.code.includes(PATCH_SENTINEL);
3143
- console.log('[hmr-ws][solid] @solid-refresh patch check:', { spec: resolvedCandidate || spec, alreadyPatched, codeLen: transformed.code.length });
3314
+ if (verbose) {
3315
+ console.log('[hmr-ws][solid] @solid-refresh patch check:', { spec: resolvedCandidate || spec, alreadyPatched, codeLen: transformed.code.length });
3316
+ }
3144
3317
  if (!alreadyPatched) {
3145
3318
  let patchedCode = transformed.code;
3146
3319
  // Patch 1: Bypass shouldWarnAndDecline() — the vendor-bundled solid-js
@@ -3149,7 +3322,9 @@ function createHmrWebSocketPlugin(opts) {
3149
3322
  const declineCheck = 'function shouldWarnAndDecline() {';
3150
3323
  if (patchedCode.includes(declineCheck)) {
3151
3324
  patchedCode = patchedCode.replace(declineCheck, `${PATCH_SENTINEL}\nfunction shouldWarnAndDecline() { return false; /* NS HMR: always allow refresh */ }\nfunction __original_shouldWarnAndDecline() {`);
3152
- console.log('[hmr-ws][solid] bypassed shouldWarnAndDecline() for NativeScript HMR');
3325
+ if (verbose) {
3326
+ console.log('[hmr-ws][solid] bypassed shouldWarnAndDecline() for NativeScript HMR');
3327
+ }
3153
3328
  }
3154
3329
  // Patch 2: Force createMemo path in createProxy.
3155
3330
  // Without the 'development' condition, $DEVCOMP is not set on components,
@@ -3160,10 +3335,17 @@ function createHmrWebSocketPlugin(opts) {
3160
3335
  const proxyCondition = 'if (!s || $DEVCOMP in s) {';
3161
3336
  if (patchedCode.includes(proxyCondition)) {
3162
3337
  patchedCode = patchedCode.replace(proxyCondition, 'if (true) { /* NS HMR: always use createMemo for reactive HMR updates */');
3163
- console.log('[hmr-ws][solid] forced createMemo path in createProxy for NativeScript HMR');
3338
+ if (verbose) {
3339
+ console.log('[hmr-ws][solid] forced createMemo path in createProxy for NativeScript HMR');
3340
+ }
3164
3341
  }
3165
3342
  // Patch 3: Inline patchRegistry call so updates apply immediately
3166
3343
  // on module re-evaluation (accept callbacks are not invoked by the HMR client).
3344
+ // The injected `console.log` helpers run inside the user's runtime
3345
+ // when @solid-refresh re-evaluates a module, so they are a runtime
3346
+ // concern (stripped if the user disables the patch). Keeping them
3347
+ // behind the patch sentinel rather than the dev-server `verbose`
3348
+ // flag is intentional — the patch only runs when Solid HMR fires.
3167
3349
  const marker = 'hot.data[SOLID_REFRESH] = hot.data[SOLID_REFRESH] || registry;';
3168
3350
  if (patchedCode.includes(marker)) {
3169
3351
  const patchCode = [
@@ -3177,7 +3359,9 @@ function createHmrWebSocketPlugin(opts) {
3177
3359
  `}`,
3178
3360
  ].join('\n ');
3179
3361
  patchedCode = patchedCode.replace(marker, `${patchCode}\n ${marker}`);
3180
- console.log('[hmr-ws][solid] added inline patchRegistry for NativeScript HMR');
3362
+ if (verbose) {
3363
+ console.log('[hmr-ws][solid] added inline patchRegistry for NativeScript HMR');
3364
+ }
3181
3365
  }
3182
3366
  // Work on a copy to avoid mutating Vite's cached TransformResult
3183
3367
  transformed = { ...transformed, code: patchedCode };
@@ -3207,7 +3391,9 @@ function createHmrWebSocketPlugin(opts) {
3207
3391
  }
3208
3392
  catch { }
3209
3393
  }
3210
- upsertGraphModule(id, code, deps);
3394
+ // Serve-time warm-up: no live edit happened, so don't bump
3395
+ // graphVersion.
3396
+ upsertGraphModule(id, code, deps, { bumpVersion: false });
3211
3397
  }
3212
3398
  }
3213
3399
  }
@@ -3357,7 +3543,7 @@ export const piniaSymbol = p.piniaSymbol;
3357
3543
  // misses re-exported names). By expanding to `export { a, b } from "url"`,
3358
3544
  // the engine sees explicit named exports and resolves them correctly.
3359
3545
  try {
3360
- code = await expandStarExports(code, server, server.config?.root || process.cwd(), verbose);
3546
+ code = await expandStarExports(code, server, server.config?.root || process.cwd(), verbose, sharedTransformRequest);
3361
3547
  }
3362
3548
  catch (e) {
3363
3549
  if (verbose)
@@ -3430,45 +3616,63 @@ export const piniaSymbol = p.piniaSymbol;
3430
3616
  }
3431
3617
  }
3432
3618
  catch { }
3619
+ // `/ns/rt` and `/ns/core` URL versioning.
3620
+ //
3621
+ // Older versions of the server emitted `/ns/rt/<ver>` and
3622
+ // `/ns/core/<ver>` so V8's HTTP module cache would see a
3623
+ // fresh URL on every save. The runtime canonicalizer
3624
+ // (`CanonicalizeHttpUrlKey` in HMRSupport.mm) collapses
3625
+ // these version segments to the bare `/ns/rt` and
3626
+ // `/ns/core` keys before lookup, so V8 actually saw a
3627
+ // single cache entry — but the server was doing extra
3628
+ // work to inject a version segment that the runtime then
3629
+ // immediately stripped. Now that the runtime supports
3630
+ // explicit eviction (and these bridge endpoints don't
3631
+ // change at HMR time anyway), the version segment is
3632
+ // purely vestigial.
3633
+ //
3634
+ // Rather than rip the helpers out (which would touch
3635
+ // every ensureVersionedImports caller and risk bumping
3636
+ // older runtimes), we keep them but pass `verNum=0`. The
3637
+ // helpers still normalize URL shape (strip the absolute
3638
+ // origin prefix when present) but emit a stable
3639
+ // `/ns/rt/0` / `/ns/core/0` URL — which collapses to
3640
+ // `/ns/rt` / `/ns/core` in the runtime.
3433
3641
  try {
3434
- const verNum = getNumericServeVersionTag(forcedVer, Number(graphVersion || 0));
3642
+ const verNum = 0;
3435
3643
  code = ensureVersionedRtImports(code, getServerOrigin(server), verNum);
3436
3644
  code = ACTIVE_STRATEGY.ensureVersionedImports(code, getServerOrigin(server), verNum);
3437
3645
  code = ensureVersionedCoreImports(code, getServerOrigin(server), verNum);
3438
3646
  }
3439
3647
  catch { }
3440
- // Finalize: stamp all internal /ns/m imports with PATH-based cache busting.
3441
- // IMPORTANT: use path prefix (not ?v= query) because the iOS HTTP ESM loader
3442
- // strips query params when computing module cache keys, so ?v= doesn't bust the V8 cache.
3648
+ // `/ns/m` URL finalize step.
3649
+ //
3650
+ // `rewriteNsMImportPathForHmr` is a canonicalizer: it
3651
+ // strips legacy `__ns_hmr__/<tag>/` segments and adds
3652
+ // `__ns_boot__/b1/` only for boot-tagged requests. The
3653
+ // `ver` parameter is preserved on the signature for API
3654
+ // compatibility but is ignored for app modules (cache
3655
+ // busting is driven by `__nsInvalidateModules`, not URL
3656
+ // versioning). We pass `'v0'` as a stable placeholder —
3657
+ // the canonicalizer emits the same URL regardless of
3658
+ // this value, but a constant placeholder makes the
3659
+ // contract explicit.
3660
+ //
3661
+ // SFC URLs (line below, `/ns/sfc/${verTag}/...`) still
3662
+ // embed a version because the Vue SFC pathway does not
3663
+ // yet have an eviction protocol. The runtime
3664
+ // canonicalizer does NOT strip `/ns/sfc/<ver>/`, so Vue
3665
+ // users still see per-save SFC re-fetches — that's a
3666
+ // known follow-up.
3443
3667
  try {
3444
- const ver = (() => {
3445
- const raw = String(forcedVer || '').trim();
3446
- const gv = Number(graphVersion || 0);
3447
- // 'live' is the client dynamic-import helper's fallback when
3448
- // globalThis.__NS_HMR_GRAPH_VERSION__ has not yet been set (i.e. before the
3449
- // full HMR client takes over). If we propagate 'live' into child import
3450
- // URLs, a file loaded through a 'live'-tagged parent ends up at
3451
- // /ns/m/__ns_hmr__/live/... in the iOS V8 module cache while a later
3452
- // dyn-import at v${N} loads the same file at /ns/m/__ns_hmr__/v${N}/...
3453
- // — two distinct cache entries, two module realms, two Angular class
3454
- // identities per @Component, and NG0912 selector collisions. Replace
3455
- // 'live' with the current graph version so every child resolves to one
3456
- // canonical URL regardless of the parent's request tag.
3457
- if (raw === 'live' && gv > 0) {
3458
- return `v${gv}`;
3459
- }
3460
- if (raw) {
3461
- if (raw === 'live' || /^n\d+$/i.test(raw) || /^v[^/]+$/i.test(raw)) {
3462
- return raw;
3463
- }
3464
- if (/^\d+$/.test(raw)) {
3465
- return `v${raw}`;
3466
- }
3467
- }
3468
- return `v${String(graphVersion || 0)}`;
3668
+ const verTag = (() => {
3669
+ const numeric = getNumericServeVersionTag(forcedVer, Number(graphVersion || 0));
3670
+ return numeric > 0 ? `v${numeric}` : 'v0';
3469
3671
  })();
3470
3672
  const origin = getServerOrigin(server);
3471
- const rewritePath = (p) => rewriteNsMImportPathForHmr(p, ver, bootTaggedRequest);
3673
+ const rewritePath = (p) => rewriteNsMImportPathForHmr(p, 'v0', bootTaggedRequest);
3674
+ // /ns/m URL forms — all collapse to canonical stable
3675
+ // URLs via the Phase 3a rewriter.
3472
3676
  // 1) Static imports: import ... from "/ns/m/..."
3473
3677
  code = code.replace(/(from\s*["'])(\/ns\/m\/[^"'?]+)(["'])/g, (_m, a, p, b) => `${a}${rewritePath(p)}${b}`);
3474
3678
  // 2) Side-effect imports: import "/ns/m/..."
@@ -3479,14 +3683,14 @@ export const piniaSymbol = p.piniaSymbol;
3479
3683
  code = code.replace(/(new\s+URL\(\s*["'])(\/ns\/m\/[^"'?]+)(["']\s*,\s*import\.meta\.url\s*\))/g, (_m, a, p, b) => `${a}${rewritePath(p)}${b}`);
3480
3684
  // 5) __ns_import(new URL('/ns/m/...', import.meta.url).href)
3481
3685
  code = code.replace(/(new\s+URL\(\s*["'])(\/ns\/m\/[^"'?]+)(["']\s*,\s*import\.meta\.url\s*\)\.href)/g, (_m, a, p, b) => `${a}${rewritePath(p)}${b}`);
3482
- // 6) Force absolute HTTP for new URL('/ns/m/...', import.meta.url).href → "${origin}/ns/m/__ns_hmr__/..."
3686
+ // 6) Force absolute HTTP for new URL('/ns/m/...', import.meta.url).href → canonical stable URL.
3483
3687
  try {
3484
3688
  code = code.replace(/new\s+URL\(\s*["'](\/ns\/m\/[^"'?]+)(?:\?[^"']*)?["']\s*,\s*import\.meta\.url\s*\)\.href/g, (_m, p1) => `${JSON.stringify(`${origin}${rewritePath(p1)}`)}`);
3485
3689
  }
3486
3690
  catch { }
3487
- // 7) Also fix SFC new URL('/ns/sfc/...', import.meta.url).href "${origin}/ns/sfc/<ver>/..."
3691
+ // 7) SFC URLs (Vue) — still versioned. See header comment.
3488
3692
  try {
3489
- code = code.replace(/new\s+URL\(\s*["']\/ns\/sfc(\/[^"'?]+)(?:\?[^"']*)?["']\s*,\s*import\.meta\.url\s*\)\.href/g, (_m, p1) => `${JSON.stringify(`${origin}/ns/sfc/${ver}${p1}`)}`);
3693
+ code = code.replace(/new\s+URL\(\s*["']\/ns\/sfc(\/[^"'?]+)(?:\?[^"']*)?["']\s*,\s*import\.meta\.url\s*\)\.href/g, (_m, p1) => `${JSON.stringify(`${origin}/ns/sfc/${verTag}${p1}`)}`);
3490
3694
  }
3491
3695
  catch { }
3492
3696
  }
@@ -3603,10 +3807,12 @@ export const piniaSymbol = p.piniaSymbol;
3603
3807
  // by the serving pipeline. Only warn, don't fatally block the importer.
3604
3808
  const hasCjsPattern = /\bmodule\s*\.\s*exports\b/.test(targetCode) || /\bexports\s*\.\s*\w/.test(targetCode);
3605
3809
  if (hasCjsPattern) {
3606
- try {
3607
- console.warn(`[ns:m][link-check] CJS module without export default: ${u.pathname} (will be CJS-wrapped at serve time)`);
3810
+ if (verbose) {
3811
+ try {
3812
+ console.warn(`[ns:m][link-check] CJS module without export default: ${u.pathname} (will be CJS-wrapped at serve time)`);
3813
+ }
3814
+ catch { }
3608
3815
  }
3609
- catch { }
3610
3816
  continue;
3611
3817
  }
3612
3818
  const msg = `[link-check] Missing default export in ${u.pathname}${u.search} (imported by ${resolvedCandidate || spec})`;
@@ -3620,10 +3826,12 @@ export const piniaSymbol = p.piniaSymbol;
3620
3826
  }
3621
3827
  }
3622
3828
  catch (eLC) {
3623
- try {
3624
- console.warn('[ns:m][link-check] failed', eLC?.message || eLC);
3829
+ if (verbose) {
3830
+ try {
3831
+ console.warn('[ns:m][link-check] failed', eLC?.message || eLC);
3832
+ }
3833
+ catch { }
3625
3834
  }
3626
- catch { }
3627
3835
  }
3628
3836
  res.statusCode = 200;
3629
3837
  res.end(code);
@@ -3665,8 +3873,10 @@ export const piniaSymbol = p.piniaSymbol;
3665
3873
  `let __cached_rt = null;\n` +
3666
3874
  `let __cached_vm = null;\n` +
3667
3875
  `const __RT_REALM_TAG = (globalThis.__NS_RT_REALM__ ||= Math.random().toString(36).slice(2));\n` +
3668
- // Unconditional one-shot evaluation marker to confirm bridge is executed on device
3669
- `try { if (!(globalThis.__NS_RT_ONCE__ && globalThis.__NS_RT_ONCE__.eval)) { (globalThis.__NS_RT_ONCE__ ||= {}).eval = true; console.log('[ns-rt] evaluated', { rtRealm: __RT_REALM_TAG }); } } catch {}\n` +
3876
+ // One-shot evaluation marker to confirm the bridge is executed on
3877
+ // device. Gated on __NS_ENV_VERBOSE__ so it stays silent unless
3878
+ // the developer opts in via NS_VITE_VERBOSE / VITE_DEBUG_LOGS.
3879
+ `try { if (!(globalThis.__NS_RT_ONCE__ && globalThis.__NS_RT_ONCE__.eval)) { (globalThis.__NS_RT_ONCE__ ||= {}).eval = true; if (globalThis.__NS_ENV_VERBOSE__) console.log('[ns-rt] evaluated', { rtRealm: __RT_REALM_TAG }); } } catch {}\n` +
3670
3880
  `function __ensure(){\n` +
3671
3881
  ` if (__cached_rt) return __cached_rt;\n` +
3672
3882
  ` let vm = null;\n` +
@@ -3846,15 +4056,15 @@ export const piniaSymbol = p.piniaSymbol;
3846
4056
  });
3847
4057
  // 2.6) ESM bridge for @nativescript/core: GET /ns/core[/<ver>][?p=sub/path]
3848
4058
  //
3849
- // Since bundle.mjs no longer bundles @nativescript/core (see
3850
- // HMR_CORE_REALM_DETERMINISTIC_PLAN.md external in the rolldown
3851
- // config under HMR), this endpoint is the ONE place core is
3852
- // evaluated. Every consumer — bundle.mjs's own `@nativescript/core*`
3853
- // imports (resolved to full HTTP URLs in the entry virtual module),
3854
- // externalized vendor packages, HTTP-served app modules — all end
3855
- // up here. No more proxy bridge, no enumeration, no namespace
3856
- // detection, no prototype-polluted maps. We just serve Vite's
3857
- // authoritative transformed module content.
4059
+ // Since bundle.mjs no longer bundles @nativescript/core (it is
4060
+ // declared external in the rolldown config under HMR), this
4061
+ // endpoint is the ONE place core is evaluated. Every consumer —
4062
+ // bundle.mjs's own `@nativescript/core*` imports (resolved to
4063
+ // full HTTP URLs in the entry virtual module), externalized
4064
+ // vendor packages, HTTP-served app modules — all end up here.
4065
+ // No more proxy bridge, no enumeration, no namespace detection,
4066
+ // no prototype-polluted maps. We just serve Vite's authoritative
4067
+ // transformed module content.
3858
4068
  //
3859
4069
  // iOS caches by URL path, so each unique URL is evaluated exactly
3860
4070
  // once per app lifetime. Every class identity is shared, every
@@ -3961,16 +4171,15 @@ export const piniaSymbol = p.piniaSymbol;
3961
4171
  `const __TEST__ = false;`,
3962
4172
  ].join('\n');
3963
4173
  // Boot-time instrumentation + module self-registration.
3964
- // See HMR_CORE_REALM_DETERMINISTIC_PLAN.md:
3965
- // - Invariant A (URL canonicalization): the same
3966
- // logical module must always resolve to byte-
3967
- // identical URLs across every emitter. The /ns/core
3968
- // handler records the first URL seen for each
3969
- // canonical sub (or '' for main) in
3970
- // `globalThis.__NS_CORE_FIRST_URL__` and fails hard
3971
- // on mismatch so drift in any emitter surfaces
4174
+ //
4175
+ // - URL canonicalization: the same logical module must
4176
+ // always resolve to byte-identical URLs across every
4177
+ // emitter. The /ns/core handler records the first URL
4178
+ // seen for each canonical sub (or '' for main) in
4179
+ // `globalThis.__NS_CORE_FIRST_URL__` and fails hard on
4180
+ // mismatch so drift in any emitter surfaces
3972
4181
  // immediately, before the realm splits.
3973
- // - Invariant C (boot-order): CommonJS
4182
+ // - CJS/ESM boot order: CommonJS
3974
4183
  // `require('@nativescript/core/...')` calls from
3975
4184
  // vendor install() hooks must resolve to the SAME
3976
4185
  // ESM namespace that ran this side-effect preamble.
@@ -3999,13 +4208,13 @@ export const piniaSymbol = p.piniaSymbol;
3999
4208
  ` const __nsUrl = ${JSON.stringify(canonicalUrl)};`,
4000
4209
  ` __nsSeen.push(__nsUrl);`,
4001
4210
  ` if (typeof __nsFirst[__nsKey] === 'string' && __nsFirst[__nsKey] !== __nsUrl) {`,
4002
- ` throw new Error('[ns-core] URL drift for sub=' + __nsKey + ': first=' + __nsFirst[__nsKey] + ' now=' + __nsUrl + ' (see HMR_CORE_REALM_DETERMINISTIC_PLAN.md Invariant A)');`,
4211
+ ` throw new Error('[ns-core] URL drift for sub=' + __nsKey + ': first=' + __nsFirst[__nsKey] + ' now=' + __nsUrl);`,
4003
4212
  ` }`,
4004
4213
  ` if (!__nsFirst[__nsKey]) __nsFirst[__nsKey] = __nsUrl;`,
4005
4214
  ` globalThis.__NS_CORE_EVAL_COUNT__ = (globalThis.__NS_CORE_EVAL_COUNT__ || 0) + 1;`,
4006
4215
  `} } catch (e) { try { console.warn('[ns-core] instrumentation failed:', (e && e.message) || e); } catch {} }`,
4007
4216
  ].join('\n');
4008
- // Invariant D (CJS/ESM interop shape) — REGISTRATION side.
4217
+ // CJS/ESM interop shape — REGISTRATION side.
4009
4218
  //
4010
4219
  // The actual shape installer runs earlier in the module
4011
4220
  // body (between preamble and selfImport; see
@@ -4021,9 +4230,6 @@ export const piniaSymbol = p.piniaSymbol;
4021
4230
  // registration — the shape function is identity-preserving
4022
4231
  // via WeakMap — gives a stable, shared, CJS-compatible
4023
4232
  // view without copying on every require.
4024
- //
4025
- // See HMR_CORE_REALM_DETERMINISTIC_PLAN.md § "Invariant D"
4026
- // for the full rationale.
4027
4233
  const registrationFooter = [
4028
4234
  `try { if (typeof globalThis !== 'undefined') {`,
4029
4235
  ` const __nsReg = globalThis.__NS_CORE_MODULES__ || (globalThis.__NS_CORE_MODULES__ = Object.create(null));`,
@@ -4135,7 +4341,8 @@ export const piniaSymbol = p.piniaSymbol;
4135
4341
  content = 'export default async function start(){ console.error("[/ns/entry-rt] not found"); }\n';
4136
4342
  }
4137
4343
  }
4138
- console.log('[hmr-http] /ns/entry-rt serving', content.length, 'bytes');
4344
+ if (verbose)
4345
+ console.log('[hmr-http] /ns/entry-rt serving', content.length, 'bytes');
4139
4346
  res.statusCode = 200;
4140
4347
  res.end(content);
4141
4348
  }
@@ -5868,6 +6075,101 @@ export const piniaSymbol = p.piniaSymbol;
5868
6075
  if (isRuntimeGraphExcludedPath(file)) {
5869
6076
  return;
5870
6077
  }
6078
+ // Always-on update timing. Captures the four phases (await,
6079
+ // framework, broadcast, total) plus invalidated module count
6080
+ // and recipient count. Emitted at the end of this function via
6081
+ // `emitHmrUpdateSummary()`. Single line, always-on so a
6082
+ // 6-second `.ts` save is immediately visible without flipping
6083
+ // verbose.
6084
+ const updateRoot = server.config.root || process.cwd();
6085
+ const updateRel = (() => {
6086
+ try {
6087
+ return '/' + path.posix.normalize(path.relative(updateRoot, file)).split(path.sep).join('/');
6088
+ }
6089
+ catch {
6090
+ return file;
6091
+ }
6092
+ })();
6093
+ const updateMetrics = {
6094
+ file: updateRel,
6095
+ kind: classifyHmrUpdateKind(file),
6096
+ t0: Date.now(),
6097
+ tAfterAwait: 0,
6098
+ tAfterFramework: 0,
6099
+ tEnd: 0,
6100
+ invalidated: 0,
6101
+ recipients: 0,
6102
+ // Narrowing diagnostic — populated by the angular branch when
6103
+ // the changed file is `.ts`, otherwise remains undefined and is
6104
+ // omitted from the summary line entirely.
6105
+ narrowed: undefined,
6106
+ emitted: false,
6107
+ };
6108
+ // Broadcast a "pending" notification at the very start of
6109
+ // handleHotUpdate so the client can show the HMR-applying
6110
+ // overlay BEFORE we spend time on graph updates / transforms /
6111
+ // dependency analysis (typically 7–200ms on a warm cache).
6112
+ // Without this, the overlay only appears at `ns:angular-update`
6113
+ // broadcast time and the user perceives a "delayed" reaction
6114
+ // to their save.
6115
+ //
6116
+ // Fire-and-forget: a failed pending broadcast must never
6117
+ // hold up the actual update. The client treats receipt of
6118
+ // `ns:angular-update` (or `ns:css-updates`) as authoritative;
6119
+ // the pending message is purely a UX hint.
6120
+ try {
6121
+ const pendingPayload = JSON.stringify(createHmrPendingMessage({
6122
+ origin: getServerOrigin(server),
6123
+ path: updateMetrics.file,
6124
+ kind: updateMetrics.kind,
6125
+ timestamp: updateMetrics.t0,
6126
+ }));
6127
+ wss.clients.forEach((client) => {
6128
+ if (isSocketClientOpen(client)) {
6129
+ try {
6130
+ client.send(pendingPayload);
6131
+ }
6132
+ catch { }
6133
+ }
6134
+ });
6135
+ }
6136
+ catch { }
6137
+ const emitHmrUpdateSummary = () => {
6138
+ if (updateMetrics.emitted)
6139
+ return;
6140
+ updateMetrics.emitted = true;
6141
+ updateMetrics.tEnd = Date.now();
6142
+ try {
6143
+ const awaitMs = (updateMetrics.tAfterAwait || updateMetrics.t0) - updateMetrics.t0;
6144
+ const frameworkMs = (updateMetrics.tAfterFramework || updateMetrics.tAfterAwait || updateMetrics.t0) - (updateMetrics.tAfterAwait || updateMetrics.t0);
6145
+ const broadcastMs = updateMetrics.tEnd - (updateMetrics.tAfterFramework || updateMetrics.tAfterAwait || updateMetrics.t0);
6146
+ const totalMs = updateMetrics.tEnd - updateMetrics.t0;
6147
+ console.info(formatHmrUpdateSummary({
6148
+ file: updateMetrics.file,
6149
+ kind: updateMetrics.kind,
6150
+ awaitMs,
6151
+ frameworkMs,
6152
+ broadcastMs,
6153
+ totalMs,
6154
+ invalidated: updateMetrics.invalidated,
6155
+ recipients: updateMetrics.recipients,
6156
+ narrowed: updateMetrics.narrowed,
6157
+ }));
6158
+ }
6159
+ catch { }
6160
+ };
6161
+ // The first /ns/m request kicks off populateInitialGraph in the
6162
+ // background. If an HMR update races in before that walk
6163
+ // completes, we'd lose transitive-importer data. Await
6164
+ // completion here so the delta computation below always sees a
6165
+ // populated graph.
6166
+ if (graphInitialPopulationPromise) {
6167
+ try {
6168
+ await graphInitialPopulationPromise;
6169
+ }
6170
+ catch { }
6171
+ }
6172
+ updateMetrics.tAfterAwait = Date.now();
5871
6173
  // Graph update for this file change (wrapped to avoid aborting rest of handler)
5872
6174
  try {
5873
6175
  const skipAngularHtmlGraphUpdate = ACTIVE_STRATEGY.flavor === 'angular' && /\.(html|htm)$/i.test(file);
@@ -5917,6 +6219,7 @@ export const piniaSymbol = p.piniaSymbol;
5917
6219
  console.log(`[hmr-ws] Hot update for: ${file}`);
5918
6220
  // Handle CSS updates
5919
6221
  if (file.endsWith('.css')) {
6222
+ updateMetrics.tAfterFramework = Date.now();
5920
6223
  try {
5921
6224
  let rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
5922
6225
  const origin = getServerOrigin(server);
@@ -5935,12 +6238,14 @@ export const piniaSymbol = p.piniaSymbol;
5935
6238
  wss.clients.forEach((client) => {
5936
6239
  if (isSocketClientOpen(client)) {
5937
6240
  client.send(JSON.stringify(msg));
6241
+ updateMetrics.recipients += 1;
5938
6242
  }
5939
6243
  });
5940
6244
  }
5941
6245
  catch (error) {
5942
6246
  console.warn('[hmr-ws] CSS update failed:', error);
5943
6247
  }
6248
+ emitHmrUpdateSummary();
5944
6249
  return;
5945
6250
  }
5946
6251
  // Framework-specific hot update handling
@@ -5956,6 +6261,7 @@ export const piniaSymbol = p.piniaSymbol;
5956
6261
  });
5957
6262
  if (!(isHtml || isTs))
5958
6263
  return;
6264
+ updateMetrics.invalidated += angularHotUpdateRoots.length;
5959
6265
  if (angularHotUpdateRoots.length) {
5960
6266
  for (const mod of angularHotUpdateRoots) {
5961
6267
  try {
@@ -5972,13 +6278,80 @@ export const piniaSymbol = p.piniaSymbol;
5972
6278
  }
5973
6279
  }
5974
6280
  const angularTransitiveInvalidationRoots = (angularHotUpdateRoots.length ? angularHotUpdateRoots : ctx.modules);
5975
- if (shouldInvalidateAngularTransitiveImporters({ flavor: ACTIVE_STRATEGY.flavor, file })) {
6281
+ // Read the source for `.ts/.tsx/.js/.jsx` edits so
6282
+ // `shouldInvalidateAngularTransitiveImporters` can
6283
+ // distinguish leaf modules (constants/utils) from real
6284
+ // Angular files. If `ctx.read()` throws (file deleted, race
6285
+ // against the watcher), `angularChangedSource` stays
6286
+ // undefined and we fall back to the conservative "always
6287
+ // invalidate transitively" behavior.
6288
+ let angularChangedSource;
6289
+ if (isTs) {
5976
6290
  try {
5977
- const transitiveImporters = collectAngularTransitiveImportersForInvalidation({
5978
- modules: angularTransitiveInvalidationRoots,
5979
- isExcluded: (id) => id.includes('/node_modules/'),
5980
- maxDepth: 16,
5981
- });
6291
+ angularChangedSource = await ctx.read();
6292
+ }
6293
+ catch {
6294
+ angularChangedSource = undefined;
6295
+ }
6296
+ }
6297
+ const angularNeedsTransitive = shouldInvalidateAngularTransitiveImporters({
6298
+ flavor: ACTIVE_STRATEGY.flavor,
6299
+ file,
6300
+ source: angularChangedSource,
6301
+ });
6302
+ // Surface the narrowing decision on every `.ts` Angular hot
6303
+ // update (HTML routes always invalidate transitively and
6304
+ // aren't subject to narrowing, so we leave them as
6305
+ // `undefined` — the field is omitted from the summary line).
6306
+ // The boolean is the inverse of `angularNeedsTransitive`
6307
+ // because "needs transitive" is the broad (un-narrowed)
6308
+ // behavior.
6309
+ if (isTs) {
6310
+ updateMetrics.narrowed = !angularNeedsTransitive;
6311
+ }
6312
+ // Stable URL + Explicit Invalidation:
6313
+ //
6314
+ // Compute the transitive importer closure ONCE here and reuse
6315
+ // it for (a) `server.moduleGraph.invalidateModule` (so Vite's
6316
+ // transform pipeline re-runs on next request), (b) the shared
6317
+ // transform-request cache, and (c) the runtime eviction set
6318
+ // we broadcast in `ns:angular-update`. Consolidating this
6319
+ // removes a redundant graph walk and guarantees the three
6320
+ // consumers see the exact same set of importers (otherwise a
6321
+ // late module-graph mutation between calls could leave an
6322
+ // asymmetric narrowed/broad mix).
6323
+ //
6324
+ // We separate Vite-transform narrowing from runtime eviction:
6325
+ // `angularNeedsTransitive` answers the question "does the
6326
+ // changed file's symbol shape change such that importers
6327
+ // must be re-transformed by Vite?". The runtime, however,
6328
+ // has a stricter requirement: ESM live bindings only refresh
6329
+ // if the importing module re-evaluates inside V8. A
6330
+ // constants file with no Angular decorator does NOT need a
6331
+ // Vite re-transform of its importers (their compiled JS is
6332
+ // identical), but its importers still hold stale bindings to
6333
+ // the OLD constants Module record. After eviction + re-import
6334
+ // of `main.ts`, V8 sees the cached importers, returns them
6335
+ // unchanged, and they continue to read the OLD values. The
6336
+ // user-visible symptom: HMR completes successfully, logs are
6337
+ // clean, but the simulator does not reflect the change.
6338
+ //
6339
+ // The fix: ALWAYS compute the transitive importer closure
6340
+ // for runtime eviction. Only skip Vite's
6341
+ // `moduleGraph.invalidate` + transform-cache purge when
6342
+ // `angularNeedsTransitive` is false — those are the genuine
6343
+ // narrowing wins (saves re-transform work on the server).
6344
+ // The eviction set always includes importers so V8 re-fetches
6345
+ // and re-binds them.
6346
+ let transitiveImporters = [];
6347
+ try {
6348
+ transitiveImporters = collectAngularTransitiveImportersForInvalidation({
6349
+ modules: angularTransitiveInvalidationRoots,
6350
+ isExcluded: (id) => id.includes('/node_modules/'),
6351
+ maxDepth: 16,
6352
+ });
6353
+ if (angularNeedsTransitive) {
6354
+ updateMetrics.invalidated += transitiveImporters.length;
5982
6355
  for (const mod of transitiveImporters) {
5983
6356
  try {
5984
6357
  server.moduleGraph.invalidateModule(mod);
@@ -5993,24 +6366,39 @@ export const piniaSymbol = p.piniaSymbol;
5993
6366
  console.log('[hmr-ws][angular] invalidated transitive importers:', transitiveImporters.length);
5994
6367
  }
5995
6368
  }
5996
- catch (error) {
5997
- if (verbose)
5998
- console.warn('[hmr-ws][angular] transitive importer collection failed', error);
6369
+ else if (isTs && typeof angularChangedSource === 'string') {
6370
+ // Surfacing this log unconditionally lets the user
6371
+ // immediately confirm whether narrowing fired for a
6372
+ // given `.ts` edit (the summary line below still
6373
+ // emits `narrowed=yes`/`no`, but having both makes
6374
+ // the decision easier to spot in noisy logs and lets
6375
+ // the user diff scenarios without flipping
6376
+ // `NS_HMR_VERBOSE=true`).
6377
+ //
6378
+ // Narrowing means "skip Vite re-transform" (the
6379
+ // importers still get evicted from the V8 module
6380
+ // registry so live bindings refresh). The importer
6381
+ // count is appended so the distinction is visible.
6382
+ console.log(`[hmr-ws][angular] narrowed transitive invalidation (no @Component/@Directive/@Pipe/@Injectable/@NgModule): ${updateRel} — Vite transform skipped, runtime eviction includes ${transitiveImporters.length} importer(s)`);
5999
6383
  }
6000
6384
  }
6385
+ catch (error) {
6386
+ if (verbose)
6387
+ console.warn('[hmr-ws][angular] transitive importer collection failed', error);
6388
+ }
6001
6389
  try {
6002
- const transitiveImporters = shouldInvalidateAngularTransitiveImporters({ flavor: ACTIVE_STRATEGY.flavor, file })
6003
- ? collectAngularTransitiveImportersForInvalidation({
6004
- modules: angularTransitiveInvalidationRoots,
6005
- isExcluded: (id) => id.includes('/node_modules/'),
6006
- maxDepth: 16,
6007
- })
6008
- : [];
6390
+ // Purge shared transform cache for the changed file +
6391
+ // hot-update roots unconditionally (their transform
6392
+ // output IS different now). Transitive importers are
6393
+ // only purged when narrowing decides their output may
6394
+ // have changed; otherwise their cached transforms are
6395
+ // still valid (compiled JS is identical even though the
6396
+ // runtime must re-evaluate them to refresh ESM bindings).
6009
6397
  const transformCacheInvalidationUrls = new Set(collectAngularTransformCacheInvalidationUrls({
6010
6398
  file,
6011
6399
  isTs,
6012
6400
  hotUpdateRoots: angularHotUpdateRoots,
6013
- transitiveImporters,
6401
+ transitiveImporters: angularNeedsTransitive ? transitiveImporters : [],
6014
6402
  projectRoot: server.config.root || process.cwd(),
6015
6403
  }));
6016
6404
  if (transformCacheInvalidationUrls.size) {
@@ -6024,17 +6412,74 @@ export const piniaSymbol = p.piniaSymbol;
6024
6412
  if (verbose)
6025
6413
  console.warn('[hmr-ws][angular] shared transform cache purge failed', error);
6026
6414
  }
6415
+ updateMetrics.tAfterFramework = Date.now();
6027
6416
  try {
6028
6417
  const root = server.config.root || process.cwd();
6029
6418
  const rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
6030
6419
  rememberAngularReloadSuppression(root, file);
6031
6420
  const origin = getServerOrigin(server);
6421
+ const bootstrapEntryRel = getBootstrapEntryRelPath();
6422
+ // Stable URL + Explicit Invalidation:
6423
+ //
6424
+ // `evictPaths` is the canonical list of `/ns/m/<rel>` URLs
6425
+ // the runtime must drop from `g_moduleRegistry` before
6426
+ // re-importing `importerEntry`. Older versions of the
6427
+ // server signaled invalidation by bumping a global
6428
+ // `graphVersion` counter and embedding it in every URL —
6429
+ // but V8 keys the module registry by full URL, so a v1 →
6430
+ // v2 bump effectively flushed the entire dependency
6431
+ // graph from the cache and forced the runtime to
6432
+ // re-fetch + re-eval every transitively-imported module
6433
+ // on each save (~3s HMR cycles, dominated by Vite's
6434
+ // single-threaded transform pipeline). The new model:
6435
+ //
6436
+ // 1. URLs are stable: `/ns/m/<rel>` everywhere, no `vN`.
6437
+ // 2. The server walks the inverse-dependency closure and
6438
+ // sends only the modules that actually need to be
6439
+ // re-evaluated (typically O(1) for component edits,
6440
+ // or the changed file + entry for narrowed edits).
6441
+ // 3. The client calls `__nsInvalidateModules(evictPaths)`
6442
+ // and re-imports `importerEntry`, which causes V8 to
6443
+ // refetch ONLY those modules. Everything else stays
6444
+ // hot in the registry.
6445
+ //
6446
+ // Invariants enforced by `collectAngularEvictionUrls`:
6447
+ // - Always includes the changed file (so the new source
6448
+ // is fetched).
6449
+ // - Always includes `importerEntry` (so re-import
6450
+ // re-evaluates).
6451
+ // - Excludes node_modules (vendor packages are stable).
6452
+ // - Excludes virtual / runtime-graph-excluded ids.
6453
+ // - Origin-prefixed: `http://host:port/ns/m/<rel>`.
6454
+ let evictPaths = [];
6455
+ try {
6456
+ evictPaths = collectAngularEvictionUrls({
6457
+ file,
6458
+ hotUpdateRoots: angularHotUpdateRoots,
6459
+ transitiveImporters,
6460
+ projectRoot: root,
6461
+ origin,
6462
+ bootstrapEntry: bootstrapEntryRel,
6463
+ });
6464
+ }
6465
+ catch (error) {
6466
+ if (verbose)
6467
+ console.warn('[hmr-ws][angular] eviction set computation failed', error);
6468
+ }
6469
+ if (verbose) {
6470
+ console.log('[hmr-ws][angular] eviction set', {
6471
+ count: evictPaths.length,
6472
+ importerEntry: bootstrapEntryRel,
6473
+ });
6474
+ }
6032
6475
  const msg = {
6033
6476
  type: 'ns:angular-update',
6034
6477
  origin,
6035
6478
  path: rel,
6036
6479
  version: graphVersion,
6037
6480
  timestamp: Date.now(),
6481
+ evictPaths,
6482
+ importerEntry: bootstrapEntryRel,
6038
6483
  };
6039
6484
  if (verbose) {
6040
6485
  console.log('[hmr-ws][angular] broadcasting update', Array.from(wss.clients || []).map((client) => ({
@@ -6046,12 +6491,14 @@ export const piniaSymbol = p.piniaSymbol;
6046
6491
  wss.clients.forEach((client) => {
6047
6492
  if (isSocketClientOpen(client)) {
6048
6493
  client.send(JSON.stringify(msg));
6494
+ updateMetrics.recipients += 1;
6049
6495
  }
6050
6496
  });
6051
6497
  }
6052
6498
  catch (error) {
6053
6499
  console.warn('[hmr-ws][angular] update failed:', error);
6054
6500
  }
6501
+ emitHmrUpdateSummary();
6055
6502
  if (shouldSuppressDefaultViteHotUpdate({ flavor: ACTIVE_STRATEGY.flavor, file })) {
6056
6503
  return [];
6057
6504
  }
@@ -6059,6 +6506,7 @@ export const piniaSymbol = p.piniaSymbol;
6059
6506
  }
6060
6507
  // TypeScript flavor: emit generic graph delta for app XML/TS/style changes
6061
6508
  if (ACTIVE_STRATEGY.flavor === 'typescript') {
6509
+ updateMetrics.tAfterFramework = Date.now();
6062
6510
  try {
6063
6511
  const rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
6064
6512
  if (verbose)
@@ -6072,6 +6520,7 @@ export const piniaSymbol = p.piniaSymbol;
6072
6520
  if (verbose)
6073
6521
  console.warn('[hmr-ws][ts] failed to emit delta for', file, e);
6074
6522
  }
6523
+ emitHmrUpdateSummary();
6075
6524
  return;
6076
6525
  }
6077
6526
  // Solid flavor: emit graph delta for app TSX/TS/JSX file changes.
@@ -6085,6 +6534,7 @@ export const piniaSymbol = p.piniaSymbol;
6085
6534
  const isSolidFile = /\.(tsx?|jsx?)$/i.test(file);
6086
6535
  if (!isSolidFile)
6087
6536
  return;
6537
+ updateMetrics.tAfterFramework = Date.now();
6088
6538
  try {
6089
6539
  const rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
6090
6540
  if (verbose)
@@ -6109,6 +6559,7 @@ export const piniaSymbol = p.piniaSymbol;
6109
6559
  if (verbose)
6110
6560
  console.warn('[hmr-ws][solid] failed to handle hot update for', file, e);
6111
6561
  }
6562
+ emitHmrUpdateSummary();
6112
6563
  return;
6113
6564
  }
6114
6565
  // Handle .vue file updates
@@ -6117,7 +6568,8 @@ export const piniaSymbol = p.piniaSymbol;
6117
6568
  console.log('[hmr-ws] Not a .vue file, skipping');
6118
6569
  return;
6119
6570
  }
6120
- console.log('[hmr-ws] Processing .vue file update...');
6571
+ if (verbose)
6572
+ console.log('[hmr-ws] Processing .vue file update...');
6121
6573
  try {
6122
6574
  const root = server.config.root || process.cwd();
6123
6575
  let rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
@@ -6394,6 +6846,10 @@ if (typeof __VUE_HMR_RUNTIME__ === 'undefined') {
6394
6846
  console.warn('[hmr-ws] HMR update failed:', error);
6395
6847
  console.error(error);
6396
6848
  }
6849
+ // Vue path emits update summary at the end of the function so
6850
+ // every framework branch gets exactly one log line. Idempotent
6851
+ // — if any branch already emitted, this is a no-op.
6852
+ emitHmrUpdateSummary();
6397
6853
  // CRITICAL: Return empty array to prevent Vite's default HMR
6398
6854
  return [];
6399
6855
  },