From a95bc507b86f77b2e303a3c5b6b8dc28f7c62588 Mon Sep 17 00:00:00 2001 From: Steve McNiven-Scott Date: Wed, 9 Sep 2026 19:26:16 -0400 Subject: [PATCH] fix(vite): await the client strategy before /ns/rt $navigateTo reports the navigator missing Fixes #11422 --- packages/vite/hmr/client/strategy-loader.ts | 11 +++ packages/vite/hmr/server/ns-rt-bridge.spec.ts | 82 ++++++++++++++++++- packages/vite/hmr/server/ns-rt-bridge.ts | 7 +- .../vite/hmr/server/vite-plugin-path.spec.ts | 23 ++++++ packages/vite/hmr/server/vite-plugin.ts | 12 +++ packages/vite/hmr/shared/ns-globals.ts | 2 + 6 files changed, 134 insertions(+), 3 deletions(-) diff --git a/packages/vite/hmr/client/strategy-loader.ts b/packages/vite/hmr/client/strategy-loader.ts index b61d25bc9d..415026269b 100644 --- a/packages/vite/hmr/client/strategy-loader.ts +++ b/packages/vite/hmr/client/strategy-loader.ts @@ -137,6 +137,17 @@ export const CLIENT_STRATEGY_READY: Promise = }) : Promise.resolve(); +// Settle the bootstrap's deferred readiness, or publish ours directly. +try { + const g = getGlobalScope(); + const settle = g.__NS_CLIENT_STRATEGY_RESOLVE__; + if (typeof settle === 'function') { + CLIENT_STRATEGY_READY.then(settle, settle); + } else { + g.__NS_CLIENT_STRATEGY_READY__ = CLIENT_STRATEGY_READY; + } +} catch {} + /** Undefined until `CLIENT_STRATEGY_READY` resolves (or when the flavor ships no client strategy). */ export function getClientStrategy(): FrameworkClientStrategy | undefined { return CLIENT_STRATEGY; diff --git a/packages/vite/hmr/server/ns-rt-bridge.spec.ts b/packages/vite/hmr/server/ns-rt-bridge.spec.ts index 7828567e4c..9cebd65b16 100644 --- a/packages/vite/hmr/server/ns-rt-bridge.spec.ts +++ b/packages/vite/hmr/server/ns-rt-bridge.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { buildNsRtBridgeModule, discoverNsvBridgeExports } from './ns-rt-bridge.js'; @@ -110,6 +110,86 @@ describe('/ns/rt bridge builder', () => { expect(code).not.toContain('with.dot'); }); + // Regression: root-mount navigation raced the strategy's dynamic import. + describe('$navigateTo waits for the client strategy before declaring the navigator missing', () => { + // Evaluates the served text, not a re-implementation. + function loadNavigateTo(g: Record) { + const code = buildNsRtBridgeModule({ rtVer: '0', requireGuardSnippet: '', vendorExports: [] }); + const pick = (re: RegExp) => { + const m = re.exec(code); + if (!m) throw new Error(`bridge text lost: ${re}`); + return m[0]; + }; + const navigateTo = pick(/^export const \$navigateTo = .*$/m).replace(/^export const /, 'const '); + const helpers = pick(/^function __navigateNow\(a\).*$/m) + '\n' + pick(/^function __navigatorMissing\(\).*$/m); + const factory = new Function('g', `${helpers}\nconst __ns_core_bridge = null; const __cached_vm = {}; const __ensure = () => ({});\n${navigateTo}\nreturn $navigateTo;`); + return factory(g) as (...a: any[]) => any; + } + const quiet = () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + return () => spy.mockRestore(); + }; + + it('calls the navigator synchronously when it is already installed', () => { + const calls: any[] = []; + const g = { Frame: {}, __nsNavigateUsingApp: (...a: any[]) => (calls.push(a), 'page') }; + expect(loadNavigateTo(g)({ name: 'Home' }, { props: { a: 1 } })).toBe('page'); + expect(calls).toEqual([[{ name: 'Home' }, { props: { a: 1 } }]]); + }); + + it('waits for __NS_CLIENT_STRATEGY_READY__ and then navigates when the strategy installs the navigator late', async () => { + const calls: any[] = []; + const g: Record = { Frame: {} }; + let installed!: () => void; + g.__NS_CLIENT_STRATEGY_READY__ = new Promise((resolve) => { + installed = () => { + g.__nsNavigateUsingApp = (...a: any[]) => (calls.push(a), 'page'); + resolve(); + }; + }); + const pending = loadNavigateTo(g)({ name: 'Home' }); + expect(typeof pending.then).toBe('function'); + expect(calls).toEqual([]); + installed(); + await expect(pending).resolves.toBe('page'); + expect(calls).toEqual([[{ name: 'Home' }]]); + }); + + it('rejects only after the strategy has settled without installing a navigator', async () => { + const restore = quiet(); + try { + const g = { Frame: {}, __NS_CLIENT_STRATEGY_READY__: Promise.resolve() }; + await expect(loadNavigateTo(g)({ name: 'Home' })).rejects.toThrow('app navigator missing'); + } finally { + restore(); + } + }); + + it('still throws synchronously when there is no readiness promise to wait for', () => { + const restore = quiet(); + try { + expect(() => loadNavigateTo({ Frame: {} })({ name: 'Home' })).toThrow('app navigator missing'); + } finally { + restore(); + } + }); + + it('surfaces navigator errors unchanged', () => { + const restore = quiet(); + try { + const g = { + Frame: {}, + __nsNavigateUsingApp: () => { + throw new Error('boom'); + }, + }; + expect(() => loadNavigateTo(g)({ name: 'Home' })).toThrow('boom'); + } finally { + restore(); + } + }); + }); + it('discoverNsvBridgeExports returns an empty set when nativescript-vue is not resolvable from the project root', () => { // No baseline fallback: discovery is the single source of truth. Pointing // at an empty directory simulates a misconfigured project, which the diff --git a/packages/vite/hmr/server/ns-rt-bridge.ts b/packages/vite/hmr/server/ns-rt-bridge.ts index 8646c25ceb..45b26ac5ba 100644 --- a/packages/vite/hmr/server/ns-rt-bridge.ts +++ b/packages/vite/hmr/server/ns-rt-bridge.ts @@ -8,7 +8,7 @@ const NSV_SHIM_OVERRIDES: ReadonlySet = new Set(['$navigateTo', '$naviga // Bridge-internal identifiers that would clash with the emitted preamble if // the vendor package happens to publish a colliding name. -const RESERVED_BRIDGE_LOCALS: ReadonlySet = new Set(['__realm', '__cached_rt', '__cached_vm', '__ensure', '__get', 'default']); +const RESERVED_BRIDGE_LOCALS: ReadonlySet = new Set(['__realm', '__cached_rt', '__cached_vm', '__ensure', '__get', '__navigateNow', '__navigatorMissing', 'default']); const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; @@ -120,7 +120,10 @@ export function buildNsRtBridgeModule(options: NsRtBridgeOptions): string { // These run through `globalThis.__nsNavigateUsingApp` etc. instead of // the vendor's native navigation, so HMR can re-route navigation // targets after module updates. - `export const $navigateTo = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); try { if (!(g && g.Frame)) { const ns = (__ns_core_bridge && (__ns_core_bridge.__esModule && __ns_core_bridge.default ? __ns_core_bridge.default : (__ns_core_bridge.default || __ns_core_bridge))) || __ns_core_bridge || {}; if (ns) { if (!g.Frame && ns.Frame) g.Frame = ns.Frame; if (!g.Page && ns.Page) g.Page = ns.Page; if (!g.Application && (ns.Application||ns.app||ns.application)) g.Application = (ns.Application||ns.app||ns.application); } } } catch {} try { const hmrRealm = (g && g.__NS_HMR_REALM__) || 'unknown'; const hasTop = !!(g && g.Frame && g.Frame.topmost && g.Frame.topmost()); const top = hasTop ? g.Frame.topmost() : null; const ctor = top && top.constructor && top.constructor.name; } catch {} if (g && typeof g.__nsNavigateUsingApp === 'function') { try { return g.__nsNavigateUsingApp(...a); } catch (e) { console.error('[ns-rt] $navigateTo app navigator error', e); throw e; } } console.error('[ns-rt] $navigateTo unavailable: app navigator missing'); throw new Error('$navigateTo unavailable: app navigator missing'); } ;\n` + + `export const $navigateTo = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); try { if (!(g && g.Frame)) { const ns = (__ns_core_bridge && (__ns_core_bridge.__esModule && __ns_core_bridge.default ? __ns_core_bridge.default : (__ns_core_bridge.default || __ns_core_bridge))) || __ns_core_bridge || {}; if (ns) { if (!g.Frame && ns.Frame) g.Frame = ns.Frame; if (!g.Page && ns.Page) g.Page = ns.Page; if (!g.Application && (ns.Application||ns.app||ns.application)) g.Application = (ns.Application||ns.app||ns.application); } } } catch {} try { const hmrRealm = (g && g.__NS_HMR_REALM__) || 'unknown'; const hasTop = !!(g && g.Frame && g.Frame.topmost && g.Frame.topmost()); const top = hasTop ? g.Frame.topmost() : null; const ctor = top && top.constructor && top.constructor.name; } catch {} if (g && typeof g.__nsNavigateUsingApp === 'function') { return __navigateNow(a); } const ready = g && g.__NS_CLIENT_STRATEGY_READY__; if (ready && typeof ready.then === 'function') { return ready.then(() => { if (g && typeof g.__nsNavigateUsingApp === 'function') return __navigateNow(a); return __navigatorMissing(); }); } return __navigatorMissing(); } ;\n` + + // Await the client strategy before declaring the navigator missing. + `function __navigateNow(a) { try { return g.__nsNavigateUsingApp(...a); } catch (e) { console.error('[ns-rt] $navigateTo app navigator error', e); throw e; } }\n` + + `function __navigatorMissing() { console.error('[ns-rt] $navigateTo unavailable: app navigator missing'); throw new Error('$navigateTo unavailable: app navigator missing'); }\n` + `export const $navigateBack = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); const impl = (vm && (vm.$navigateBack || (vm.default && vm.default.$navigateBack))) || (rt && (rt.$navigateBack || (rt.runtimeHelpers && rt.runtimeHelpers.navigateBack))); let res; try { const via = (impl && (impl === (vm && vm.$navigateBack) || impl === (vm && vm.default && vm.default.$navigateBack))) ? 'vm' : (impl ? 'rt' : 'none'); } catch {} try { if (typeof impl === 'function') res = impl(...a); } catch {} try { const top = (g && g.Frame && g.Frame.topmost && g.Frame.topmost()); if (!res && top && top.canGoBack && top.canGoBack()) { res = top.goBack(); } } catch {} try { const hook = g && (g.__NS_HMR_ON_NAVIGATE_BACK || g.__NS_HMR_ON_BACK || g.__nsAttemptBackRemount); if (typeof hook === 'function') hook(); } catch {} return res; }\n` + `export const $showModal = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); const impl = (vm && (vm.$showModal || (vm.default && vm.default.$showModal))) || (rt && (rt.$showModal || (rt.runtimeHelpers && rt.runtimeHelpers.showModal))); try { if (typeof impl === 'function') return impl(...a); } catch (e) { } return undefined; }\n` + // Vite client polyfill — see the comment in websocket.ts for full rationale. diff --git a/packages/vite/hmr/server/vite-plugin-path.spec.ts b/packages/vite/hmr/server/vite-plugin-path.spec.ts index dd9e5e89f6..c782833703 100644 --- a/packages/vite/hmr/server/vite-plugin-path.spec.ts +++ b/packages/vite/hmr/server/vite-plugin-path.spec.ts @@ -200,4 +200,27 @@ describe('createNsDevClientBootstrapCode', () => { expect(code).not.toContain('10.0.2.2'); expect(code).not.toContain('orderedHosts'); }); + + it('publishes a deferred client-strategy readiness promise before the app entry can navigate', () => { + // The full client (and its strategy) loads only after boot-complete, + // which flips after the app entry evaluates. The wrapper evaluates + // before the entry, so it owns the promise `/ns/rt` awaits. + const code = createNsDevClientBootstrapCode({ + wsUrl: 'ws://127.0.0.1:5173/__ns_dev__/ws', + origin: 'http://127.0.0.1:5173', + clientImport: '/ns/m/node_modules/@nativescript/vite/hmr/client/index.js', + }); + const deferredAt = code.indexOf('globalThis.__NS_CLIENT_STRATEGY_READY__ = new Promise'); + // The first `__nsBrowserRuntimeConnectSocket();` in the emitted code sits + // inside the reconnect timer's function body, not the boot call site — + // take the last occurrence, which is the top-level boot call. + const socketAt = code.lastIndexOf('__nsBrowserRuntimeConnectSocket();'); + expect(deferredAt).toBeGreaterThan(-1); + expect(deferredAt).toBeLessThan(socketAt); + expect(code).toContain('globalThis.__NS_CLIENT_STRATEGY_RESOLVE__ = resolve'); + // A failed full-client start must settle it, never leave callers hanging. + expect(code).toContain('globalThis.__NS_CLIENT_STRATEGY_RESOLVE__?.()'); + // So must an entry import failure observed by the boot poller. + expect(code).toContain('else if (globalThis.__NS_ENTRY_ERROR__)'); + }); }); diff --git a/packages/vite/hmr/server/vite-plugin.ts b/packages/vite/hmr/server/vite-plugin.ts index 4dd65221a2..b598229b92 100644 --- a/packages/vite/hmr/server/vite-plugin.ts +++ b/packages/vite/hmr/server/vite-plugin.ts @@ -551,6 +551,8 @@ async function __nsBrowserRuntimeEnsureFullClientStarted() { }) .catch((error) => { globalThis.__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__ = false; + // Settle readiness so pending navigations fail instead of hanging. + try { globalThis.__NS_CLIENT_STRATEGY_RESOLVE__?.(); } catch {} console.error('[ns-browser-runtime-client] failed to start full NativeScript HMR client', __NS_BROWSER_RUNTIME_CLIENT_IMPORT__, error); throw error; }); @@ -564,6 +566,12 @@ __nsBrowserRuntimeEnsureVendorBootstrap(); if (!globalThis.__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__) { globalThis.__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__ = true; globalThis.__NS_HTTP_ORIGIN__ = __NS_BROWSER_RUNTIME_ORIGIN__; + // Deferred strategy readiness; the full client settles it later. + if (!globalThis.__NS_CLIENT_STRATEGY_READY__) { + globalThis.__NS_CLIENT_STRATEGY_READY__ = new Promise((resolve) => { + globalThis.__NS_CLIENT_STRATEGY_RESOLVE__ = resolve; + }); + } __nsBrowserRuntimeConnectSocket(); const __nsBrowserRuntimeBootWaitStartedAt = Date.now(); const __nsBrowserRuntimeWaitForBoot = () => { @@ -573,6 +581,10 @@ if (!globalThis.__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__) { } void __nsBrowserRuntimeReplaySeededCss(); void __nsBrowserRuntimeEnsureFullClientStarted(); + } else if (globalThis.__NS_ENTRY_ERROR__) { + // Boot failed; settle readiness so pending navigations reject. + try { globalThis.__NS_CLIENT_STRATEGY_RESOLVE__?.(); } catch {} + setTimeout(__nsBrowserRuntimeWaitForBoot, 100); } else { if (!__nsBrowserRuntimeBootWaitWarningIssued && Date.now() - __nsBrowserRuntimeBootWaitStartedAt >= 10000) { __nsBrowserRuntimeBootWaitWarningIssued = true; diff --git a/packages/vite/hmr/shared/ns-globals.ts b/packages/vite/hmr/shared/ns-globals.ts index bde65b06dc..a1f30ec296 100644 --- a/packages/vite/hmr/shared/ns-globals.ts +++ b/packages/vite/hmr/shared/ns-globals.ts @@ -108,6 +108,8 @@ declare global { var __NS_HMR_WORKER_TRACKING_INSTALLED__: boolean | undefined; var __NS_UPDATE_ANGULAR_APP_OPTIONS__: any; var __nsNavigateUsingApp: any; + var __NS_CLIENT_STRATEGY_READY__: Promise | undefined; + var __NS_CLIENT_STRATEGY_RESOLVE__: (() => void) | undefined; var __nsRequire: any; var __nsVendorRequire: any; var __nsVendorRegistry: any;