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

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 (57) hide show
  1. package/helpers/global-defines.d.ts +55 -0
  2. package/helpers/global-defines.js +81 -0
  3. package/helpers/global-defines.js.map +1 -1
  4. package/helpers/logging.d.ts +1 -0
  5. package/helpers/logging.js +36 -3
  6. package/helpers/logging.js.map +1 -1
  7. package/hmr/client/hmr-pending-overlay.d.ts +27 -0
  8. package/hmr/client/hmr-pending-overlay.js +50 -0
  9. package/hmr/client/hmr-pending-overlay.js.map +1 -0
  10. package/hmr/client/index.js +72 -1
  11. package/hmr/client/index.js.map +1 -1
  12. package/hmr/client/utils.d.ts +5 -0
  13. package/hmr/client/utils.js +153 -15
  14. package/hmr/client/utils.js.map +1 -1
  15. package/hmr/entry-runtime.js +95 -31
  16. package/hmr/entry-runtime.js.map +1 -1
  17. package/hmr/frameworks/angular/client/index.d.ts +1 -0
  18. package/hmr/frameworks/angular/client/index.js +424 -11
  19. package/hmr/frameworks/angular/client/index.js.map +1 -1
  20. package/hmr/server/perf-instrumentation.d.ts +118 -0
  21. package/hmr/server/perf-instrumentation.js +198 -0
  22. package/hmr/server/perf-instrumentation.js.map +1 -0
  23. package/hmr/server/shared-transform-request.js +12 -5
  24. package/hmr/server/shared-transform-request.js.map +1 -1
  25. package/hmr/server/websocket-angular-hot-update.d.ts +16 -0
  26. package/hmr/server/websocket-angular-hot-update.js +163 -1
  27. package/hmr/server/websocket-angular-hot-update.js.map +1 -1
  28. package/hmr/server/websocket-graph-upsert.d.ts +15 -0
  29. package/hmr/server/websocket-graph-upsert.js +20 -0
  30. package/hmr/server/websocket-graph-upsert.js.map +1 -1
  31. package/hmr/server/websocket-hmr-pending.d.ts +43 -0
  32. package/hmr/server/websocket-hmr-pending.js +55 -0
  33. package/hmr/server/websocket-hmr-pending.js.map +1 -0
  34. package/hmr/server/websocket-ns-m-finalize.js +1 -1
  35. package/hmr/server/websocket-ns-m-finalize.js.map +1 -1
  36. package/hmr/server/websocket-ns-m-paths.d.ts +1 -1
  37. package/hmr/server/websocket-ns-m-paths.js +59 -13
  38. package/hmr/server/websocket-ns-m-paths.js.map +1 -1
  39. package/hmr/server/websocket-ns-m-request.js +1 -16
  40. package/hmr/server/websocket-ns-m-request.js.map +1 -1
  41. package/hmr/server/websocket-runtime-compat.js.map +1 -1
  42. package/hmr/server/websocket-served-module-helpers.js +42 -18
  43. package/hmr/server/websocket-served-module-helpers.js.map +1 -1
  44. package/hmr/server/websocket-vue-sfc.js +3 -6
  45. package/hmr/server/websocket-vue-sfc.js.map +1 -1
  46. package/hmr/server/websocket.d.ts +4 -4
  47. package/hmr/server/websocket.js +614 -177
  48. package/hmr/server/websocket.js.map +1 -1
  49. package/hmr/shared/runtime/boot-timeline.d.ts +17 -0
  50. package/hmr/shared/runtime/boot-timeline.js +54 -0
  51. package/hmr/shared/runtime/boot-timeline.js.map +1 -0
  52. package/hmr/shared/runtime/dev-overlay.d.ts +49 -2
  53. package/hmr/shared/runtime/dev-overlay.js +587 -12
  54. package/hmr/shared/runtime/dev-overlay.js.map +1 -1
  55. package/hmr/shared/runtime/session-bootstrap.js +49 -0
  56. package/hmr/shared/runtime/session-bootstrap.js.map +1 -1
  57. 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,34 @@ 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
+ // alpha.59 — Stable URL + Explicit Invalidation:
84
+ // Memoized resolver for the project bootstrap entry as a posix
85
+ // project-relative path (e.g. `/src/main.ts`). This mirrors the resolution
86
+ // the cold-boot wrapper performs (`getPackageJson().main` →
87
+ // project-relative under `/<APP_ROOT_DIR>/`) so the eviction set for HMR
88
+ // always lines up with the URL the runtime actually re-imports. Resolved
89
+ // at first call and cached: `package.json` is read at startup and never
90
+ // changes during a dev session, so it's safe to memoize.
91
+ let __ns_bootstrap_entry_rel_cached = null;
92
+ function getBootstrapEntryRelPath() {
93
+ if (__ns_bootstrap_entry_rel_cached)
94
+ return __ns_bootstrap_entry_rel_cached;
95
+ let entry = DEFAULT_MAIN_ENTRY_VIRTUAL;
96
+ try {
97
+ const pkg = getPackageJson();
98
+ const main = (pkg && pkg.main) || DEFAULT_MAIN_ENTRY;
99
+ const abs = getProjectFilePath(main).replace(/\\/g, '/');
100
+ const marker = `/${APP_ROOT_DIR}/`;
101
+ const idx = abs.indexOf(marker);
102
+ entry = idx >= 0 ? abs.substring(idx) : DEFAULT_MAIN_ENTRY_VIRTUAL;
103
+ }
104
+ catch { }
105
+ if (!entry.startsWith('/')) {
106
+ entry = '/' + entry;
107
+ }
108
+ __ns_bootstrap_entry_rel_cached = entry;
109
+ return entry;
110
+ }
75
111
  const STRATEGY_REGISTRY = new Map([
76
112
  ['vue', vueServerStrategy],
77
113
  ['angular', angularServerStrategy],
@@ -318,44 +354,12 @@ function ensureGuardPlainDynamicImports(code, origin) {
318
354
  return code;
319
355
  }
320
356
  }
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) {
357
+ // alpha.59 — `ensureDynamicHmrImportHelper` was previously duplicated
358
+ // here. Single source of truth now lives in
359
+ // `./websocket-served-module-helpers.js`. See that file for the
360
+ // architectural rationale and the current (much smaller) helper
361
+ // implementation.
362
+ async function expandStarExports(code, server, projectRoot, verbose, sharedTransformer) {
359
363
  const STAR_RE = /^[ \t]*(export\s+\*\s+from\s+["'])([^"']+)(["'];?)[ \t]*$/gm;
360
364
  let match;
361
365
  const replacements = [];
@@ -367,25 +371,41 @@ async function expandStarExports(code, server, projectRoot, verbose) {
367
371
  }
368
372
  if (!replacements.length)
369
373
  return code;
370
- for (const rep of replacements) {
374
+ // Pull target URLs through the shared runner when it's available so each
375
+ // node_modules path shares the 60s TTL cache with the main /ns/m pipeline
376
+ // and respects the global concurrency gate. Fan them out in parallel —
377
+ // this block used to be a serial `for await` loop, which dominated cold
378
+ // boot on apps with dozens of star-re-exports.
379
+ const transformer = sharedTransformer ?? ((url) => server.transformRequest(url));
380
+ const resolved = await Promise.all(replacements.map(async (rep) => {
371
381
  try {
372
382
  let vitePath = rep.url.replace(/^https?:\/\/[^/]+/, '');
373
383
  vitePath = vitePath.replace(/^\/ns\/m\//, '/');
374
384
  vitePath = vitePath.replace(/^\/__ns_boot__\/[^/]+/, '');
375
385
  vitePath = vitePath.replace(/\/__ns_hmr__\/[^/]+/, '');
376
- const result = await server.transformRequest(vitePath);
386
+ const result = await transformer(vitePath);
377
387
  if (!result?.code)
378
- continue;
388
+ return null;
379
389
  const names = extractExportedNames(result.code);
380
390
  if (!names.length)
381
- continue;
382
- const explicit = `export { ${names.join(', ')} } from ${JSON.stringify(rep.url)};`;
383
- code = code.replace(rep.full, explicit);
391
+ return null;
384
392
  if (verbose) {
385
- console.log(`[ns/m] expanded export* -> ${names.length} names from ${vitePath}`);
393
+ try {
394
+ console.log(`[ns/m] expanded export* -> ${names.length} names from ${vitePath}`);
395
+ }
396
+ catch { }
386
397
  }
398
+ return { rep, names };
387
399
  }
388
- catch { }
400
+ catch {
401
+ return null;
402
+ }
403
+ }));
404
+ for (const entry of resolved) {
405
+ if (!entry)
406
+ continue;
407
+ const explicit = `export { ${entry.names.join(', ')} } from ${JSON.stringify(entry.rep.url)};`;
408
+ code = code.replace(entry.rep.full, explicit);
389
409
  }
390
410
  return code;
391
411
  }
@@ -894,52 +914,14 @@ function toNodeModulesHttpModuleId(importPath) {
894
914
  }
895
915
  return `/ns/m/node_modules/${nodeModulesSpecifier}`;
896
916
  }
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
- }
917
+ // alpha.59 — `rewriteNsMImportPathForHmr` and `getNumericServeVersionTag`
918
+ // previously lived here as duplicates of the implementations in
919
+ // `./websocket-ns-m-paths.js`. The path rewriter is part of the
920
+ // "Stable URL + Explicit Invalidation" architecture (see
921
+ // HMR_STABLE_URL_INVALIDATION_PLAN.md) and must be a single source of
922
+ // truth so the canonicalization rules can't drift between the two files.
923
+ // They are imported above and re-exported below for tests / external
924
+ // callers that historically reached them through this module.
943
925
  function normalizeAbsoluteFilesystemImport(spec, importerPath, projectRoot) {
944
926
  if (!spec || typeof spec !== 'string') {
945
927
  return null;
@@ -2331,10 +2313,21 @@ function createHmrWebSocketPlugin(opts) {
2331
2313
  let registrySent = false;
2332
2314
  let vendorBootstrapDone = false;
2333
2315
  let pluginRoot;
2334
- let graphVersion = 0;
2316
+ // graphVersion starts at 1 so the very first /ns/m response uses a stable
2317
+ // `v1` URL tag (see dynamic-import helper at lines 398-432). Keeping it
2318
+ // stable during cold boot prevents double-loads when the graph fills up
2319
+ // lazily as modules are served.
2320
+ let graphVersion = 1;
2335
2321
  // Transactional HMR batches: map graphVersion -> ordered list of changed ids for that version
2336
2322
  const txnBatches = new Map();
2337
2323
  const graph = new Map();
2324
+ // Tracks the background initial-graph population so handleHotUpdate can
2325
+ // await completion before computing delta roots for the first HMR event.
2326
+ let graphInitialPopulationPromise = null;
2327
+ // Cold-boot /ns/m request counter — populated the first time a /ns/m
2328
+ // request arrives, finalized when the request window goes idle.
2329
+ // See Shared across requests so a single counter spans the whole cold boot.
2330
+ let coldBootCounter = null;
2338
2331
  function rememberAngularReloadSuppression(root, file, ttlMs = 3000) {
2339
2332
  const absPath = normalizeHotReloadMatchPath(file);
2340
2333
  const relPath = normalizeHotReloadMatchPath(file, root);
@@ -2506,12 +2499,17 @@ function createHmrWebSocketPlugin(opts) {
2506
2499
  const classification = classifyGraphUpsert(existing, hash, normDeps);
2507
2500
  if (classification === 'unchanged')
2508
2501
  return existing;
2509
- graphVersion++;
2502
+ // Version bumps are only meaningful for live edits — serve-time graph
2503
+ // warm-ups and the initial bulk walk should leave graphVersion stable.
2504
+ const bumpVersion = shouldBumpGraphVersion(classification, options?.bumpVersion !== false);
2505
+ if (bumpVersion) {
2506
+ graphVersion++;
2507
+ }
2510
2508
  const gm = { id, deps: normDeps, hash };
2511
2509
  graph.set(id, gm);
2512
2510
  if (verbose) {
2513
2511
  try {
2514
- console.log('[hmr-ws][graph] upsert', { id, deps: normDeps, hash, graphVersion, classification });
2512
+ console.log('[hmr-ws][graph] upsert', { id, deps: normDeps, hash, graphVersion, classification, bumpVersion });
2515
2513
  console.log('[hmr-ws][graph] size', graph.size);
2516
2514
  }
2517
2515
  catch { }
@@ -2539,6 +2537,8 @@ function createHmrWebSocketPlugin(opts) {
2539
2537
  async function populateInitialGraph(server) {
2540
2538
  if (graph.size)
2541
2539
  return; // already populated
2540
+ const tStart = Date.now();
2541
+ const versionAtStart = graphVersion;
2542
2542
  const root = server.config.root || process.cwd();
2543
2543
  // Avoid direct require in ESM build: lazily obtain fs & path via createRequire or dynamic import
2544
2544
  let fs;
@@ -2554,6 +2554,18 @@ function createHmrWebSocketPlugin(opts) {
2554
2554
  fs = await import('fs');
2555
2555
  pathMod = await import('path');
2556
2556
  }
2557
+ // Route every bulk transform through `sharedTransformRequest` when it's
2558
+ // already been wired up — this way the background walk shares the 60s
2559
+ // TTL cache with live /ns/m requests, so the device sees cached results
2560
+ // for any file the walker already visited. The fallback keeps the
2561
+ // walker working during server tests where the shared runner isn't
2562
+ // constructed yet.
2563
+ const bulkTransform = (rel) => {
2564
+ if (sharedTransformRequest) {
2565
+ return sharedTransformRequest(rel);
2566
+ }
2567
+ return server.transformRequest(rel);
2568
+ };
2557
2569
  async function walk(dir) {
2558
2570
  for (const name of fs.readdirSync(dir)) {
2559
2571
  if (name === 'node_modules' || name.startsWith('.') || shouldSkipRuntimeGraphDirectoryName(name))
@@ -2568,7 +2580,7 @@ function createHmrWebSocketPlugin(opts) {
2568
2580
  const rel = '/' + pathMod.relative(root, full).split(pathMod.sep).join('/');
2569
2581
  // Transform via Vite to gather deps (ignore failures)
2570
2582
  try {
2571
- const transformed = await server.transformRequest(rel);
2583
+ const transformed = await bulkTransform(rel);
2572
2584
  const code = transformed?.code || '';
2573
2585
  const deps = [];
2574
2586
  // fallback to import relationships via moduleGraph
@@ -2579,7 +2591,10 @@ function createHmrWebSocketPlugin(opts) {
2579
2591
  deps.push(m.id.split('?')[0]);
2580
2592
  }
2581
2593
  }
2582
- upsertGraphModule(rel, code, deps);
2594
+ // bumpVersion: false — the initial walk is a bulk load, not a live
2595
+ // edit. Keeping graphVersion stable during cold boot avoids double
2596
+ // cache-key drift described in Track 1.3 of the HMR plan.
2597
+ upsertGraphModule(rel, code, deps, { bumpVersion: false });
2583
2598
  }
2584
2599
  catch { }
2585
2600
  }
@@ -2592,6 +2607,36 @@ function createHmrWebSocketPlugin(opts) {
2592
2607
  await walk(pathMod.join(root, 'src'));
2593
2608
  }
2594
2609
  catch { }
2610
+ // Diagnostic summary: Always-on so slow
2611
+ // cold-boot walks surface even when verbose is off. A `bumpedVersion=no`
2612
+ // result is the happy path (Track 1.3); `yes` indicates a regression.
2613
+ try {
2614
+ console.info(formatPopulateInitialGraphSummary({
2615
+ moduleCount: graph.size,
2616
+ durationMs: Date.now() - tStart,
2617
+ graphVersion,
2618
+ bumpedVersion: graphVersion !== versionAtStart,
2619
+ }));
2620
+ }
2621
+ catch { }
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 (Track 1.4 in HMR_CORE_REALM_DETERMINISTIC_PLAN.md). We cap
2673
+ // at 8 by default to match typical dev machines and respect Vite's
2674
+ // internal worker pool limits. Override via the 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 that
2678
+ // unchanged neighbours of an edited file don't re-run through the
2679
+ // Angular/TypeScript/Vite transform pipeline. The HMR flow
2680
+ // explicitly invalidates affected URLs, so a longer TTL is safe.
2681
+ // See Track 2.2 in HMR_CORE_REALM_DETERMINISTIC_PLAN.md.
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,120 @@ 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 so
2695
+ // anyone investigating perf can immediately see which build is live
2696
+ // and what knobs are active. See HMR_CORE_REALM_DETERMINISTIC_PLAN.md
2697
+ // ("Track 1 + 2 — diagnostic instrumentation").
2698
+ try {
2699
+ let pkgVersion = 'unknown';
2700
+ try {
2701
+ const req = createRequire(import.meta.url);
2702
+ const pkg = req('@nativescript/vite/package.json');
2703
+ if (pkg && typeof pkg.version === 'string')
2704
+ pkgVersion = pkg.version;
2705
+ }
2706
+ catch {
2707
+ // `@nativescript/vite/package.json` is not always exported; fall
2708
+ // back to reading the file from disk next to this module.
2709
+ try {
2710
+ const here = new URL(import.meta.url).pathname;
2711
+ const pkgPath = path.resolve(path.dirname(here), '..', '..', 'package.json');
2712
+ if (existsSync(pkgPath)) {
2713
+ const parsed = JSON.parse(readFileSync(pkgPath, 'utf-8'));
2714
+ if (parsed && typeof parsed.version === 'string')
2715
+ pkgVersion = parsed.version;
2716
+ }
2717
+ }
2718
+ catch { }
2719
+ }
2720
+ console.info(formatServerStartupBanner({
2721
+ version: pkgVersion,
2722
+ transformConcurrency,
2723
+ transformCacheMs,
2724
+ lazyInitialGraph: true,
2725
+ graphVersion,
2726
+ }));
2727
+ }
2728
+ catch { }
2729
+ // Always-on cold-boot request trace. Runs in front of every other
2730
+ // middleware so it catches all NS dev routes (/ns/m/*, /ns/rt/*,
2731
+ // /ns/core/*, /__ns_boot__/*, etc.) with a single hook. Closes
2732
+ // itself after an idle window so HMR edits don't get rolled into
2733
+ // the cold-boot numbers. The idle window is generous by default
2734
+ // (5s) because V8's HTTP ESM resolver pauses between dep levels
2735
+ // while parsing — a too-tight window was closing after the first
2736
+ // wave and under-reporting boot by 100x. Override via
2737
+ // NS_VITE_HMR_BOOT_TRACE_IDLE_MS when profiling something tricky.
2738
+ // See HMR_CORE_REALM_DETERMINISTIC_PLAN.md ("Track 1 + 2 — round
2739
+ // three, 2026-04").
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
+ log: (line) => {
2750
+ try {
2751
+ console.info(line);
2752
+ }
2753
+ catch { }
2754
+ },
2755
+ });
2756
+ }
2757
+ }
2758
+ catch { }
2759
+ server.middlewares.use((req, res, next) => {
2760
+ try {
2761
+ const urlObj = new URL(req.url || '', 'http://localhost');
2762
+ const route = classifyBootRoute(urlObj.pathname);
2763
+ if (route === 'other')
2764
+ return next();
2765
+ if (!coldBootCounter)
2766
+ return next();
2767
+ const handle = coldBootCounter.record(urlObj.pathname);
2768
+ const finishOnce = () => {
2769
+ try {
2770
+ handle.finish();
2771
+ }
2772
+ catch { }
2773
+ };
2774
+ try {
2775
+ res.once('finish', finishOnce);
2776
+ res.once('close', finishOnce);
2777
+ }
2778
+ catch { }
2779
+ }
2780
+ catch { }
2781
+ next();
2782
+ });
2783
+ // Give `populateInitialGraph` a head start. Previously this only
2784
+ // kicked off on the first /ns/m hit, which meant populate was
2785
+ // competing with the device for the same 8 transform slots
2786
+ // throughout the first 4-5 seconds of cold boot. Starting at
2787
+ // `configureServer` time gives populate the full app build/launch
2788
+ // window (typically 2-3s on simulator) as a head start, so more
2789
+ // of its work lands before the device even connects. Disable via
2790
+ // NS_VITE_HMR_DISABLE_POPULATE=1 when profiling whether populate
2791
+ // is helping or hurting a specific app. See
2792
+ // HMR_CORE_REALM_DETERMINISTIC_PLAN.md ("Track 1 + 2 — round
2793
+ // three").
2794
+ try {
2795
+ const disablePopulate = process.env.NS_VITE_HMR_DISABLE_POPULATE === '1' || process.env.NS_VITE_HMR_DISABLE_POPULATE === 'true';
2796
+ if (disablePopulate) {
2797
+ if (verbose)
2798
+ console.info('[hmr-ws][populate] disabled via NS_VITE_HMR_DISABLE_POPULATE');
2799
+ // Short-circuit: mark as resolved so /ns/m never schedules it and
2800
+ // HMR still works (handleHotUpdate just has no pre-warmed graph).
2801
+ graphInitialPopulationPromise = Promise.resolve();
2802
+ }
2803
+ else {
2804
+ ensureInitialGraphPopulationStarted(server);
2805
+ }
2806
+ }
2807
+ catch { }
2639
2808
  // Attempt early vendor manifest bootstrap once per server.
2640
2809
  if (!vendorBootstrapDone) {
2641
2810
  vendorBootstrapDone = true;
@@ -2910,25 +3079,23 @@ function createHmrWebSocketPlugin(opts) {
2910
3079
  const urlObj = new URL(req.url || '', 'http://localhost');
2911
3080
  if (!urlObj.pathname.startsWith('/ns/m'))
2912
3081
  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
- }
3082
+ // Previously we awaited `populateInitialGraph(server)` here so
3083
+ // graphVersion would be non-zero for the first /ns/m request.
3084
+ // That gave deterministic URL tags but blocked the cold boot on a
3085
+ // full src/ tree walk (hundreds of transformRequest calls, 3-6s).
3086
+ //
3087
+ // Track 1.3: graphVersion now starts at 1 and stays stable during
3088
+ // cold boot (see `upsertGraphModule`'s bumpVersion option and the
3089
+ // inline comment at the graphVersion declaration). We kick off the
3090
+ // initial population in the background so it doesn't block the first
3091
+ // response. `handleHotUpdate` awaits the same promise so the first
3092
+ // HMR event still sees a fully populated graph.
3093
+ ensureInitialGraphPopulationStarted(server);
3094
+ // Cold-boot counter is now hooked via the leading boot-trace
3095
+ // middleware (see `configureServer` — it records the request
3096
+ // and tracks finish() via res.on('close'/'finish')). This
3097
+ // handler used to record here but that missed the
3098
+ // round-trip timing and didn't track per-route breakdowns.
2932
3099
  res.setHeader('Access-Control-Allow-Origin', '*');
2933
3100
  res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
2934
3101
  // Disable caching for dev ESM endpoints to avoid device-side stale module reuse
@@ -3207,7 +3374,9 @@ function createHmrWebSocketPlugin(opts) {
3207
3374
  }
3208
3375
  catch { }
3209
3376
  }
3210
- upsertGraphModule(id, code, deps);
3377
+ // Serve-time warm-up: no live edit happened, so don't bump
3378
+ // graphVersion. See Track 1.3 in HMR_CORE_REALM_DETERMINISTIC_PLAN.md.
3379
+ upsertGraphModule(id, code, deps, { bumpVersion: false });
3211
3380
  }
3212
3381
  }
3213
3382
  }
@@ -3357,7 +3526,7 @@ export const piniaSymbol = p.piniaSymbol;
3357
3526
  // misses re-exported names). By expanding to `export { a, b } from "url"`,
3358
3527
  // the engine sees explicit named exports and resolves them correctly.
3359
3528
  try {
3360
- code = await expandStarExports(code, server, server.config?.root || process.cwd(), verbose);
3529
+ code = await expandStarExports(code, server, server.config?.root || process.cwd(), verbose, sharedTransformRequest);
3361
3530
  }
3362
3531
  catch (e) {
3363
3532
  if (verbose)
@@ -3430,45 +3599,62 @@ export const piniaSymbol = p.piniaSymbol;
3430
3599
  }
3431
3600
  }
3432
3601
  catch { }
3602
+ // alpha.59 — `/ns/rt` and `/ns/core` URL versioning.
3603
+ //
3604
+ // Pre-alpha.59 these URLs were emitted as `/ns/rt/<ver>`
3605
+ // and `/ns/core/<ver>` so V8's HTTP module cache would see
3606
+ // a fresh URL on every save. The runtime canonicalizer
3607
+ // (HMRSupport.mm `CanonicalizeHttpUrlKey`) collapses these
3608
+ // version segments to the bare `/ns/rt` and `/ns/core`
3609
+ // keys before lookup, so V8 actually saw a single cache
3610
+ // entry — but the server was doing extra work to inject a
3611
+ // version segment that the runtime then immediately
3612
+ // stripped. Now that alpha.59 has explicit eviction (and
3613
+ // these bridge endpoints don't change at HMR time
3614
+ // anyway), the version segment is purely vestigial.
3615
+ //
3616
+ // Rather than rip the helpers out (which would touch
3617
+ // every ensureVersionedImports caller and risk bumping
3618
+ // older runtimes), we keep them but pass `verNum=0`. The
3619
+ // helpers still normalize URL shape (strip the absolute
3620
+ // origin prefix when present) but emit a stable
3621
+ // `/ns/rt/0` / `/ns/core/0` URL — which collapses to
3622
+ // `/ns/rt` / `/ns/core` in the runtime.
3433
3623
  try {
3434
- const verNum = getNumericServeVersionTag(forcedVer, Number(graphVersion || 0));
3624
+ const verNum = 0;
3435
3625
  code = ensureVersionedRtImports(code, getServerOrigin(server), verNum);
3436
3626
  code = ACTIVE_STRATEGY.ensureVersionedImports(code, getServerOrigin(server), verNum);
3437
3627
  code = ensureVersionedCoreImports(code, getServerOrigin(server), verNum);
3438
3628
  }
3439
3629
  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.
3630
+ // alpha.59 `/ns/m` URL finalize step.
3631
+ //
3632
+ // `rewriteNsMImportPathForHmr` (Phase 3a) is now a
3633
+ // canonicalizer: it strips legacy `__ns_hmr__/<tag>/`
3634
+ // segments and adds `__ns_boot__/b1/` only for boot-tagged
3635
+ // requests. The `ver` parameter is preserved on the
3636
+ // signature for API compatibility but is ignored for app
3637
+ // modules (cache busting is driven by
3638
+ // `__nsInvalidateModules`, not URL versioning). We pass
3639
+ // `'v0'` as a stable placeholder — the canonicalizer
3640
+ // emits the same URL regardless of this value, but a
3641
+ // constant placeholder makes the contract explicit.
3642
+ //
3643
+ // SFC URLs (line below, `/ns/sfc/${verTag}/...`) still
3644
+ // embed a version because the Vue SFC pathway does not
3645
+ // yet have an eviction protocol. The runtime canonicalizer
3646
+ // does NOT strip `/ns/sfc/<ver>/`, so Vue users still see
3647
+ // per-save SFC re-fetches — that's a known follow-up
3648
+ // (HMR_STABLE_URL_INVALIDATION_PLAN.md "Vue Follow-up").
3443
3649
  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)}`;
3650
+ const verTag = (() => {
3651
+ const numeric = getNumericServeVersionTag(forcedVer, Number(graphVersion || 0));
3652
+ return numeric > 0 ? `v${numeric}` : 'v0';
3469
3653
  })();
3470
3654
  const origin = getServerOrigin(server);
3471
- const rewritePath = (p) => rewriteNsMImportPathForHmr(p, ver, bootTaggedRequest);
3655
+ const rewritePath = (p) => rewriteNsMImportPathForHmr(p, 'v0', bootTaggedRequest);
3656
+ // /ns/m URL forms — all collapse to canonical stable
3657
+ // URLs via the Phase 3a rewriter.
3472
3658
  // 1) Static imports: import ... from "/ns/m/..."
3473
3659
  code = code.replace(/(from\s*["'])(\/ns\/m\/[^"'?]+)(["'])/g, (_m, a, p, b) => `${a}${rewritePath(p)}${b}`);
3474
3660
  // 2) Side-effect imports: import "/ns/m/..."
@@ -3479,14 +3665,14 @@ export const piniaSymbol = p.piniaSymbol;
3479
3665
  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
3666
  // 5) __ns_import(new URL('/ns/m/...', import.meta.url).href)
3481
3667
  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__/..."
3668
+ // 6) Force absolute HTTP for new URL('/ns/m/...', import.meta.url).href → canonical stable URL.
3483
3669
  try {
3484
3670
  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
3671
  }
3486
3672
  catch { }
3487
- // 7) Also fix SFC new URL('/ns/sfc/...', import.meta.url).href "${origin}/ns/sfc/<ver>/..."
3673
+ // 7) SFC URLs (Vue) — still versioned. See header comment.
3488
3674
  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}`)}`);
3675
+ 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
3676
  }
3491
3677
  catch { }
3492
3678
  }
@@ -5868,6 +6054,103 @@ export const piniaSymbol = p.piniaSymbol;
5868
6054
  if (isRuntimeGraphExcludedPath(file)) {
5869
6055
  return;
5870
6056
  }
6057
+ // Always-on update timing — see HMR_CORE_REALM_DETERMINISTIC_PLAN.md
6058
+ // ("Track 2 — round one, 2026-04: HMR update metrics"). Captures
6059
+ // the four phases (await, framework, broadcast, total) plus
6060
+ // invalidated module count and recipient count. Emitted at the
6061
+ // end of this function via `emitHmrUpdateSummary()`. Single line,
6062
+ // always-on so a 6-second `.ts` save is immediately visible
6063
+ // without flipping verbose.
6064
+ const updateRoot = server.config.root || process.cwd();
6065
+ const updateRel = (() => {
6066
+ try {
6067
+ return '/' + path.posix.normalize(path.relative(updateRoot, file)).split(path.sep).join('/');
6068
+ }
6069
+ catch {
6070
+ return file;
6071
+ }
6072
+ })();
6073
+ const updateMetrics = {
6074
+ file: updateRel,
6075
+ kind: classifyHmrUpdateKind(file),
6076
+ t0: Date.now(),
6077
+ tAfterAwait: 0,
6078
+ tAfterFramework: 0,
6079
+ tEnd: 0,
6080
+ invalidated: 0,
6081
+ recipients: 0,
6082
+ // Round-eight diagnostic — populated by the angular branch when
6083
+ // the changed file is `.ts`, otherwise remains undefined and is
6084
+ // omitted from the summary line entirely. See
6085
+ // HMR_CORE_REALM_DETERMINISTIC_PLAN.md ("Round-eight — surface
6086
+ // narrowing decision").
6087
+ narrowed: undefined,
6088
+ emitted: false,
6089
+ };
6090
+ // alpha.62 follow-up — broadcast a "pending" notification at
6091
+ // the very start of handleHotUpdate so the client can show
6092
+ // the HMR-applying overlay BEFORE we spend time on graph
6093
+ // updates / transforms / dependency analysis (typically
6094
+ // 7–200ms on a warm cache). Without this, the overlay only
6095
+ // appears at `ns:angular-update` broadcast time and the
6096
+ // user perceives a "delayed" reaction to their save.
6097
+ //
6098
+ // Fire-and-forget: a failed pending broadcast must never
6099
+ // hold up the actual update. The client treats receipt of
6100
+ // `ns:angular-update` (or `ns:css-updates`) as authoritative;
6101
+ // the pending message is purely a UX hint.
6102
+ try {
6103
+ const pendingPayload = JSON.stringify(createHmrPendingMessage({
6104
+ origin: getServerOrigin(server),
6105
+ path: updateMetrics.file,
6106
+ kind: updateMetrics.kind,
6107
+ timestamp: updateMetrics.t0,
6108
+ }));
6109
+ wss.clients.forEach((client) => {
6110
+ if (isSocketClientOpen(client)) {
6111
+ try {
6112
+ client.send(pendingPayload);
6113
+ }
6114
+ catch { }
6115
+ }
6116
+ });
6117
+ }
6118
+ catch { }
6119
+ const emitHmrUpdateSummary = () => {
6120
+ if (updateMetrics.emitted)
6121
+ return;
6122
+ updateMetrics.emitted = true;
6123
+ updateMetrics.tEnd = Date.now();
6124
+ try {
6125
+ const awaitMs = (updateMetrics.tAfterAwait || updateMetrics.t0) - updateMetrics.t0;
6126
+ const frameworkMs = (updateMetrics.tAfterFramework || updateMetrics.tAfterAwait || updateMetrics.t0) - (updateMetrics.tAfterAwait || updateMetrics.t0);
6127
+ const broadcastMs = updateMetrics.tEnd - (updateMetrics.tAfterFramework || updateMetrics.tAfterAwait || updateMetrics.t0);
6128
+ const totalMs = updateMetrics.tEnd - updateMetrics.t0;
6129
+ console.info(formatHmrUpdateSummary({
6130
+ file: updateMetrics.file,
6131
+ kind: updateMetrics.kind,
6132
+ awaitMs,
6133
+ frameworkMs,
6134
+ broadcastMs,
6135
+ totalMs,
6136
+ invalidated: updateMetrics.invalidated,
6137
+ recipients: updateMetrics.recipients,
6138
+ narrowed: updateMetrics.narrowed,
6139
+ }));
6140
+ }
6141
+ catch { }
6142
+ };
6143
+ // Track 1.3: the first /ns/m request kicks off populateInitialGraph
6144
+ // in the background. If an HMR update races in before that walk
6145
+ // completes, we'd lose transitive-importer data. Await completion
6146
+ // here so the delta computation below always sees a populated graph.
6147
+ if (graphInitialPopulationPromise) {
6148
+ try {
6149
+ await graphInitialPopulationPromise;
6150
+ }
6151
+ catch { }
6152
+ }
6153
+ updateMetrics.tAfterAwait = Date.now();
5871
6154
  // Graph update for this file change (wrapped to avoid aborting rest of handler)
5872
6155
  try {
5873
6156
  const skipAngularHtmlGraphUpdate = ACTIVE_STRATEGY.flavor === 'angular' && /\.(html|htm)$/i.test(file);
@@ -5917,6 +6200,7 @@ export const piniaSymbol = p.piniaSymbol;
5917
6200
  console.log(`[hmr-ws] Hot update for: ${file}`);
5918
6201
  // Handle CSS updates
5919
6202
  if (file.endsWith('.css')) {
6203
+ updateMetrics.tAfterFramework = Date.now();
5920
6204
  try {
5921
6205
  let rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
5922
6206
  const origin = getServerOrigin(server);
@@ -5935,12 +6219,14 @@ export const piniaSymbol = p.piniaSymbol;
5935
6219
  wss.clients.forEach((client) => {
5936
6220
  if (isSocketClientOpen(client)) {
5937
6221
  client.send(JSON.stringify(msg));
6222
+ updateMetrics.recipients += 1;
5938
6223
  }
5939
6224
  });
5940
6225
  }
5941
6226
  catch (error) {
5942
6227
  console.warn('[hmr-ws] CSS update failed:', error);
5943
6228
  }
6229
+ emitHmrUpdateSummary();
5944
6230
  return;
5945
6231
  }
5946
6232
  // Framework-specific hot update handling
@@ -5956,6 +6242,7 @@ export const piniaSymbol = p.piniaSymbol;
5956
6242
  });
5957
6243
  if (!(isHtml || isTs))
5958
6244
  return;
6245
+ updateMetrics.invalidated += angularHotUpdateRoots.length;
5959
6246
  if (angularHotUpdateRoots.length) {
5960
6247
  for (const mod of angularHotUpdateRoots) {
5961
6248
  try {
@@ -5972,13 +6259,80 @@ export const piniaSymbol = p.piniaSymbol;
5972
6259
  }
5973
6260
  }
5974
6261
  const angularTransitiveInvalidationRoots = (angularHotUpdateRoots.length ? angularHotUpdateRoots : ctx.modules);
5975
- if (shouldInvalidateAngularTransitiveImporters({ flavor: ACTIVE_STRATEGY.flavor, file })) {
6262
+ // Round-six narrowing: read the source for `.ts/.tsx/.js/.jsx`
6263
+ // edits so `shouldInvalidateAngularTransitiveImporters` can
6264
+ // distinguish leaf modules (constants/utils) from real
6265
+ // Angular files. If `ctx.read()` throws (file deleted, race
6266
+ // against the watcher), `angularChangedSource` stays
6267
+ // undefined and we fall back to the conservative
6268
+ // pre-round-six "always invalidate transitively" behavior.
6269
+ let angularChangedSource;
6270
+ if (isTs) {
5976
6271
  try {
5977
- const transitiveImporters = collectAngularTransitiveImportersForInvalidation({
5978
- modules: angularTransitiveInvalidationRoots,
5979
- isExcluded: (id) => id.includes('/node_modules/'),
5980
- maxDepth: 16,
5981
- });
6272
+ angularChangedSource = await ctx.read();
6273
+ }
6274
+ catch {
6275
+ angularChangedSource = undefined;
6276
+ }
6277
+ }
6278
+ const angularNeedsTransitive = shouldInvalidateAngularTransitiveImporters({
6279
+ flavor: ACTIVE_STRATEGY.flavor,
6280
+ file,
6281
+ source: angularChangedSource,
6282
+ });
6283
+ // Round-eight diagnostic. We surface the narrowing decision on
6284
+ // every `.ts` Angular hot update (HTML routes always invalidate
6285
+ // transitively today and aren't subject to Round-Seven, so we
6286
+ // leave them as `undefined` — the field is omitted from the
6287
+ // summary line). The boolean is the inverse of
6288
+ // `angularNeedsTransitive` because "needs transitive" is the
6289
+ // pre-Round-Seven (broad) behavior.
6290
+ if (isTs) {
6291
+ updateMetrics.narrowed = !angularNeedsTransitive;
6292
+ }
6293
+ // alpha.59 — Stable URL + Explicit Invalidation:
6294
+ //
6295
+ // Compute the transitive importer closure ONCE here and reuse it
6296
+ // for (a) `server.moduleGraph.invalidateModule` (so Vite's
6297
+ // transform pipeline re-runs on next request), (b) the shared
6298
+ // transform-request cache, and (c) the runtime eviction set we
6299
+ // broadcast in `ns:angular-update`. Pre-alpha.59 code computed
6300
+ // this list twice in adjacent try blocks; consolidating it
6301
+ // removes a redundant graph walk and guarantees the three
6302
+ // consumers see the exact same set of importers (otherwise a
6303
+ // late module-graph mutation between calls could leave an
6304
+ // asymmetric narrowed/broad mix).
6305
+ //
6306
+ // alpha.59.1 — separate Vite-transform narrowing from runtime
6307
+ // eviction. `angularNeedsTransitive` answers the question "does
6308
+ // the changed file's symbol shape change such that importers
6309
+ // must be re-transformed by Vite?". The runtime, however, has
6310
+ // a stricter requirement: ESM live bindings only refresh if
6311
+ // the importing module re-evaluates inside V8. A constants
6312
+ // file with no Angular decorator does NOT need a Vite
6313
+ // re-transform of its importers (their compiled JS is
6314
+ // identical), but its importers still hold stale bindings to
6315
+ // the OLD constants Module record. After eviction + re-import
6316
+ // of `main.ts`, V8 sees the cached importers, returns them
6317
+ // unchanged, and they continue to read the OLD values. The
6318
+ // user-visible symptom: HMR completes successfully, logs are
6319
+ // clean, but the simulator does not reflect the change.
6320
+ //
6321
+ // The fix: ALWAYS compute the transitive importer closure for
6322
+ // runtime eviction. Only skip Vite's `moduleGraph.invalidate`
6323
+ // + transform-cache purge when `angularNeedsTransitive` is
6324
+ // false — those are the genuine narrowing wins (saves
6325
+ // re-transform work on the server). The eviction set always
6326
+ // includes importers so V8 re-fetches and re-binds them.
6327
+ let transitiveImporters = [];
6328
+ try {
6329
+ transitiveImporters = collectAngularTransitiveImportersForInvalidation({
6330
+ modules: angularTransitiveInvalidationRoots,
6331
+ isExcluded: (id) => id.includes('/node_modules/'),
6332
+ maxDepth: 16,
6333
+ });
6334
+ if (angularNeedsTransitive) {
6335
+ updateMetrics.invalidated += transitiveImporters.length;
5982
6336
  for (const mod of transitiveImporters) {
5983
6337
  try {
5984
6338
  server.moduleGraph.invalidateModule(mod);
@@ -5993,24 +6347,40 @@ export const piniaSymbol = p.piniaSymbol;
5993
6347
  console.log('[hmr-ws][angular] invalidated transitive importers:', transitiveImporters.length);
5994
6348
  }
5995
6349
  }
5996
- catch (error) {
5997
- if (verbose)
5998
- console.warn('[hmr-ws][angular] transitive importer collection failed', error);
6350
+ else if (isTs && typeof angularChangedSource === 'string') {
6351
+ // Round-eight: was previously gated on `verbose`. Surfacing this
6352
+ // log unconditionally lets the user immediately confirm whether
6353
+ // Round-Seven's narrowing fired for a given `.ts` edit (the
6354
+ // summary line below still emits `narrowed=yes`/`no`, but
6355
+ // having both makes the decision easier to spot in noisy logs
6356
+ // and lets the user diff scenarios without flipping
6357
+ // `NS_HMR_VERBOSE=true`).
6358
+ //
6359
+ // alpha.59.1 — narrowing now means "skip Vite re-transform"
6360
+ // (the importers still get evicted from the V8 module
6361
+ // registry so live bindings refresh). The log call-out is
6362
+ // preserved but extended with the importer count to make the
6363
+ // distinction visible.
6364
+ 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
6365
  }
6000
6366
  }
6367
+ catch (error) {
6368
+ if (verbose)
6369
+ console.warn('[hmr-ws][angular] transitive importer collection failed', error);
6370
+ }
6001
6371
  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
- : [];
6372
+ // alpha.59.1 purge shared transform cache for the changed
6373
+ // file + hot-update roots unconditionally (their transform
6374
+ // output IS different now). Transitive importers are only
6375
+ // purged when narrowing decides their output may have
6376
+ // changed; otherwise their cached transforms are still
6377
+ // valid (compiled JS is identical even though the runtime
6378
+ // must re-evaluate them to refresh ESM bindings).
6009
6379
  const transformCacheInvalidationUrls = new Set(collectAngularTransformCacheInvalidationUrls({
6010
6380
  file,
6011
6381
  isTs,
6012
6382
  hotUpdateRoots: angularHotUpdateRoots,
6013
- transitiveImporters,
6383
+ transitiveImporters: angularNeedsTransitive ? transitiveImporters : [],
6014
6384
  projectRoot: server.config.root || process.cwd(),
6015
6385
  }));
6016
6386
  if (transformCacheInvalidationUrls.size) {
@@ -6024,17 +6394,74 @@ export const piniaSymbol = p.piniaSymbol;
6024
6394
  if (verbose)
6025
6395
  console.warn('[hmr-ws][angular] shared transform cache purge failed', error);
6026
6396
  }
6397
+ updateMetrics.tAfterFramework = Date.now();
6027
6398
  try {
6028
6399
  const root = server.config.root || process.cwd();
6029
6400
  const rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
6030
6401
  rememberAngularReloadSuppression(root, file);
6031
6402
  const origin = getServerOrigin(server);
6403
+ const bootstrapEntryRel = getBootstrapEntryRelPath();
6404
+ // alpha.59 — Stable URL + Explicit Invalidation:
6405
+ //
6406
+ // `evictPaths` is the canonical list of `/ns/m/<rel>` URLs
6407
+ // the runtime must drop from `g_moduleRegistry` before
6408
+ // re-importing `importerEntry`. Pre-alpha.59 the server
6409
+ // signaled invalidation by bumping a global `graphVersion`
6410
+ // counter and embedding it in every URL — but V8 keys the
6411
+ // module registry by full URL, so a v1 → v2 bump
6412
+ // effectively flushed the entire dependency graph from
6413
+ // the cache and forced the runtime to re-fetch + re-eval
6414
+ // every transitively-imported module on each save (~3s
6415
+ // HMR cycles, dominated by Vite's single-threaded transform
6416
+ // pipeline). The new model:
6417
+ //
6418
+ // 1. URLs are stable: `/ns/m/<rel>` everywhere, no `vN`.
6419
+ // 2. The server walks the inverse-dependency closure and
6420
+ // sends only the modules that actually need to be
6421
+ // re-evaluated (typically O(1) for component edits,
6422
+ // or the changed file + entry for narrowed edits).
6423
+ // 3. The client calls `__nsInvalidateModules(evictPaths)`
6424
+ // and re-imports `importerEntry`, which causes V8 to
6425
+ // refetch ONLY those modules. Everything else stays
6426
+ // hot in the registry.
6427
+ //
6428
+ // Invariants enforced by `collectAngularEvictionUrls`:
6429
+ // - Always includes the changed file (so the new source
6430
+ // is fetched).
6431
+ // - Always includes `importerEntry` (so re-import
6432
+ // re-evaluates).
6433
+ // - Excludes node_modules (vendor packages are stable).
6434
+ // - Excludes virtual / runtime-graph-excluded ids.
6435
+ // - Origin-prefixed: `http://host:port/ns/m/<rel>`.
6436
+ let evictPaths = [];
6437
+ try {
6438
+ evictPaths = collectAngularEvictionUrls({
6439
+ file,
6440
+ hotUpdateRoots: angularHotUpdateRoots,
6441
+ transitiveImporters,
6442
+ projectRoot: root,
6443
+ origin,
6444
+ bootstrapEntry: bootstrapEntryRel,
6445
+ });
6446
+ }
6447
+ catch (error) {
6448
+ if (verbose)
6449
+ console.warn('[hmr-ws][angular] eviction set computation failed', error);
6450
+ }
6451
+ if (verbose) {
6452
+ console.log('[hmr-ws][angular] eviction set', {
6453
+ count: evictPaths.length,
6454
+ importerEntry: bootstrapEntryRel,
6455
+ });
6456
+ }
6032
6457
  const msg = {
6033
6458
  type: 'ns:angular-update',
6034
6459
  origin,
6035
6460
  path: rel,
6036
6461
  version: graphVersion,
6037
6462
  timestamp: Date.now(),
6463
+ evictPaths,
6464
+ importerEntry: bootstrapEntryRel,
6038
6465
  };
6039
6466
  if (verbose) {
6040
6467
  console.log('[hmr-ws][angular] broadcasting update', Array.from(wss.clients || []).map((client) => ({
@@ -6046,12 +6473,14 @@ export const piniaSymbol = p.piniaSymbol;
6046
6473
  wss.clients.forEach((client) => {
6047
6474
  if (isSocketClientOpen(client)) {
6048
6475
  client.send(JSON.stringify(msg));
6476
+ updateMetrics.recipients += 1;
6049
6477
  }
6050
6478
  });
6051
6479
  }
6052
6480
  catch (error) {
6053
6481
  console.warn('[hmr-ws][angular] update failed:', error);
6054
6482
  }
6483
+ emitHmrUpdateSummary();
6055
6484
  if (shouldSuppressDefaultViteHotUpdate({ flavor: ACTIVE_STRATEGY.flavor, file })) {
6056
6485
  return [];
6057
6486
  }
@@ -6059,6 +6488,7 @@ export const piniaSymbol = p.piniaSymbol;
6059
6488
  }
6060
6489
  // TypeScript flavor: emit generic graph delta for app XML/TS/style changes
6061
6490
  if (ACTIVE_STRATEGY.flavor === 'typescript') {
6491
+ updateMetrics.tAfterFramework = Date.now();
6062
6492
  try {
6063
6493
  const rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
6064
6494
  if (verbose)
@@ -6072,6 +6502,7 @@ export const piniaSymbol = p.piniaSymbol;
6072
6502
  if (verbose)
6073
6503
  console.warn('[hmr-ws][ts] failed to emit delta for', file, e);
6074
6504
  }
6505
+ emitHmrUpdateSummary();
6075
6506
  return;
6076
6507
  }
6077
6508
  // Solid flavor: emit graph delta for app TSX/TS/JSX file changes.
@@ -6085,6 +6516,7 @@ export const piniaSymbol = p.piniaSymbol;
6085
6516
  const isSolidFile = /\.(tsx?|jsx?)$/i.test(file);
6086
6517
  if (!isSolidFile)
6087
6518
  return;
6519
+ updateMetrics.tAfterFramework = Date.now();
6088
6520
  try {
6089
6521
  const rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
6090
6522
  if (verbose)
@@ -6109,6 +6541,7 @@ export const piniaSymbol = p.piniaSymbol;
6109
6541
  if (verbose)
6110
6542
  console.warn('[hmr-ws][solid] failed to handle hot update for', file, e);
6111
6543
  }
6544
+ emitHmrUpdateSummary();
6112
6545
  return;
6113
6546
  }
6114
6547
  // Handle .vue file updates
@@ -6394,6 +6827,10 @@ if (typeof __VUE_HMR_RUNTIME__ === 'undefined') {
6394
6827
  console.warn('[hmr-ws] HMR update failed:', error);
6395
6828
  console.error(error);
6396
6829
  }
6830
+ // Vue path emits update summary at the end of the function so
6831
+ // every framework branch gets exactly one log line. Idempotent
6832
+ // — if any branch already emitted, this is a no-op.
6833
+ emitHmrUpdateSummary();
6397
6834
  // CRITICAL: Return empty array to prevent Vite's default HMR
6398
6835
  return [];
6399
6836
  },