Skip to content

Commit 0be657f

Browse files
fix(vite): await the client strategy before /ns/rt $navigateTo reports the navigator missing (#11424)
closes #11422 [skip ci]
1 parent 9388630 commit 0be657f

6 files changed

Lines changed: 134 additions & 3 deletions

File tree

packages/vite/hmr/client/strategy-loader.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ export const CLIENT_STRATEGY_READY: Promise<void> =
137137
})
138138
: Promise.resolve();
139139

140+
// Settle the bootstrap's deferred readiness, or publish ours directly.
141+
try {
142+
const g = getGlobalScope();
143+
const settle = g.__NS_CLIENT_STRATEGY_RESOLVE__;
144+
if (typeof settle === 'function') {
145+
CLIENT_STRATEGY_READY.then(settle, settle);
146+
} else {
147+
g.__NS_CLIENT_STRATEGY_READY__ = CLIENT_STRATEGY_READY;
148+
}
149+
} catch {}
150+
140151
/** Undefined until `CLIENT_STRATEGY_READY` resolves (or when the flavor ships no client strategy). */
141152
export function getClientStrategy(): FrameworkClientStrategy | undefined {
142153
return CLIENT_STRATEGY;

packages/vite/hmr/server/ns-rt-bridge.spec.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { describe, expect, it, vi } from 'vitest';
22

33
import { buildNsRtBridgeModule, discoverNsvBridgeExports } from './ns-rt-bridge.js';
44

@@ -110,6 +110,86 @@ describe('/ns/rt bridge builder', () => {
110110
expect(code).not.toContain('with.dot');
111111
});
112112

113+
// Regression: root-mount navigation raced the strategy's dynamic import.
114+
describe('$navigateTo waits for the client strategy before declaring the navigator missing', () => {
115+
// Evaluates the served text, not a re-implementation.
116+
function loadNavigateTo(g: Record<string, any>) {
117+
const code = buildNsRtBridgeModule({ rtVer: '0', requireGuardSnippet: '', vendorExports: [] });
118+
const pick = (re: RegExp) => {
119+
const m = re.exec(code);
120+
if (!m) throw new Error(`bridge text lost: ${re}`);
121+
return m[0];
122+
};
123+
const navigateTo = pick(/^export const \$navigateTo = .*$/m).replace(/^export const /, 'const ');
124+
const helpers = pick(/^function __navigateNow\(a\).*$/m) + '\n' + pick(/^function __navigatorMissing\(\).*$/m);
125+
const factory = new Function('g', `${helpers}\nconst __ns_core_bridge = null; const __cached_vm = {}; const __ensure = () => ({});\n${navigateTo}\nreturn $navigateTo;`);
126+
return factory(g) as (...a: any[]) => any;
127+
}
128+
const quiet = () => {
129+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
130+
return () => spy.mockRestore();
131+
};
132+
133+
it('calls the navigator synchronously when it is already installed', () => {
134+
const calls: any[] = [];
135+
const g = { Frame: {}, __nsNavigateUsingApp: (...a: any[]) => (calls.push(a), 'page') };
136+
expect(loadNavigateTo(g)({ name: 'Home' }, { props: { a: 1 } })).toBe('page');
137+
expect(calls).toEqual([[{ name: 'Home' }, { props: { a: 1 } }]]);
138+
});
139+
140+
it('waits for __NS_CLIENT_STRATEGY_READY__ and then navigates when the strategy installs the navigator late', async () => {
141+
const calls: any[] = [];
142+
const g: Record<string, any> = { Frame: {} };
143+
let installed!: () => void;
144+
g.__NS_CLIENT_STRATEGY_READY__ = new Promise<void>((resolve) => {
145+
installed = () => {
146+
g.__nsNavigateUsingApp = (...a: any[]) => (calls.push(a), 'page');
147+
resolve();
148+
};
149+
});
150+
const pending = loadNavigateTo(g)({ name: 'Home' });
151+
expect(typeof pending.then).toBe('function');
152+
expect(calls).toEqual([]);
153+
installed();
154+
await expect(pending).resolves.toBe('page');
155+
expect(calls).toEqual([[{ name: 'Home' }]]);
156+
});
157+
158+
it('rejects only after the strategy has settled without installing a navigator', async () => {
159+
const restore = quiet();
160+
try {
161+
const g = { Frame: {}, __NS_CLIENT_STRATEGY_READY__: Promise.resolve() };
162+
await expect(loadNavigateTo(g)({ name: 'Home' })).rejects.toThrow('app navigator missing');
163+
} finally {
164+
restore();
165+
}
166+
});
167+
168+
it('still throws synchronously when there is no readiness promise to wait for', () => {
169+
const restore = quiet();
170+
try {
171+
expect(() => loadNavigateTo({ Frame: {} })({ name: 'Home' })).toThrow('app navigator missing');
172+
} finally {
173+
restore();
174+
}
175+
});
176+
177+
it('surfaces navigator errors unchanged', () => {
178+
const restore = quiet();
179+
try {
180+
const g = {
181+
Frame: {},
182+
__nsNavigateUsingApp: () => {
183+
throw new Error('boom');
184+
},
185+
};
186+
expect(() => loadNavigateTo(g)({ name: 'Home' })).toThrow('boom');
187+
} finally {
188+
restore();
189+
}
190+
});
191+
});
192+
113193
it('discoverNsvBridgeExports returns an empty set when nativescript-vue is not resolvable from the project root', () => {
114194
// No baseline fallback: discovery is the single source of truth. Pointing
115195
// at an empty directory simulates a misconfigured project, which the

packages/vite/hmr/server/ns-rt-bridge.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const NSV_SHIM_OVERRIDES: ReadonlySet<string> = new Set(['$navigateTo', '$naviga
88

99
// Bridge-internal identifiers that would clash with the emitted preamble if
1010
// the vendor package happens to publish a colliding name.
11-
const RESERVED_BRIDGE_LOCALS: ReadonlySet<string> = new Set(['__realm', '__cached_rt', '__cached_vm', '__ensure', '__get', 'default']);
11+
const RESERVED_BRIDGE_LOCALS: ReadonlySet<string> = new Set(['__realm', '__cached_rt', '__cached_vm', '__ensure', '__get', '__navigateNow', '__navigatorMissing', 'default']);
1212

1313
const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
1414

@@ -120,7 +120,10 @@ export function buildNsRtBridgeModule(options: NsRtBridgeOptions): string {
120120
// These run through `globalThis.__nsNavigateUsingApp` etc. instead of
121121
// the vendor's native navigation, so HMR can re-route navigation
122122
// targets after module updates.
123-
`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` +
123+
`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` +
124+
// Await the client strategy before declaring the navigator missing.
125+
`function __navigateNow(a) { try { return g.__nsNavigateUsingApp(...a); } catch (e) { console.error('[ns-rt] $navigateTo app navigator error', e); throw e; } }\n` +
126+
`function __navigatorMissing() { console.error('[ns-rt] $navigateTo unavailable: app navigator missing'); throw new Error('$navigateTo unavailable: app navigator missing'); }\n` +
124127
`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` +
125128
`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` +
126129
// Vite client polyfill — see the comment in websocket.ts for full rationale.

packages/vite/hmr/server/vite-plugin-path.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,27 @@ describe('createNsDevClientBootstrapCode', () => {
200200
expect(code).not.toContain('10.0.2.2');
201201
expect(code).not.toContain('orderedHosts');
202202
});
203+
204+
it('publishes a deferred client-strategy readiness promise before the app entry can navigate', () => {
205+
// The full client (and its strategy) loads only after boot-complete,
206+
// which flips after the app entry evaluates. The wrapper evaluates
207+
// before the entry, so it owns the promise `/ns/rt` awaits.
208+
const code = createNsDevClientBootstrapCode({
209+
wsUrl: 'ws://127.0.0.1:5173/__ns_dev__/ws',
210+
origin: 'http://127.0.0.1:5173',
211+
clientImport: '/ns/m/node_modules/@nativescript/vite/hmr/client/index.js',
212+
});
213+
const deferredAt = code.indexOf('globalThis.__NS_CLIENT_STRATEGY_READY__ = new Promise');
214+
// The first `__nsBrowserRuntimeConnectSocket();` in the emitted code sits
215+
// inside the reconnect timer's function body, not the boot call site —
216+
// take the last occurrence, which is the top-level boot call.
217+
const socketAt = code.lastIndexOf('__nsBrowserRuntimeConnectSocket();');
218+
expect(deferredAt).toBeGreaterThan(-1);
219+
expect(deferredAt).toBeLessThan(socketAt);
220+
expect(code).toContain('globalThis.__NS_CLIENT_STRATEGY_RESOLVE__ = resolve');
221+
// A failed full-client start must settle it, never leave callers hanging.
222+
expect(code).toContain('globalThis.__NS_CLIENT_STRATEGY_RESOLVE__?.()');
223+
// So must an entry import failure observed by the boot poller.
224+
expect(code).toContain('else if (globalThis.__NS_ENTRY_ERROR__)');
225+
});
203226
});

packages/vite/hmr/server/vite-plugin.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,8 @@ async function __nsBrowserRuntimeEnsureFullClientStarted() {
551551
})
552552
.catch((error) => {
553553
globalThis.__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__ = false;
554+
// Settle readiness so pending navigations fail instead of hanging.
555+
try { globalThis.__NS_CLIENT_STRATEGY_RESOLVE__?.(); } catch {}
554556
console.error('[ns-browser-runtime-client] failed to start full NativeScript HMR client', __NS_BROWSER_RUNTIME_CLIENT_IMPORT__, error);
555557
throw error;
556558
});
@@ -564,6 +566,12 @@ __nsBrowserRuntimeEnsureVendorBootstrap();
564566
if (!globalThis.__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__) {
565567
globalThis.__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__ = true;
566568
globalThis.__NS_HTTP_ORIGIN__ = __NS_BROWSER_RUNTIME_ORIGIN__;
569+
// Deferred strategy readiness; the full client settles it later.
570+
if (!globalThis.__NS_CLIENT_STRATEGY_READY__) {
571+
globalThis.__NS_CLIENT_STRATEGY_READY__ = new Promise((resolve) => {
572+
globalThis.__NS_CLIENT_STRATEGY_RESOLVE__ = resolve;
573+
});
574+
}
567575
__nsBrowserRuntimeConnectSocket();
568576
const __nsBrowserRuntimeBootWaitStartedAt = Date.now();
569577
const __nsBrowserRuntimeWaitForBoot = () => {
@@ -573,6 +581,10 @@ if (!globalThis.__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__) {
573581
}
574582
void __nsBrowserRuntimeReplaySeededCss();
575583
void __nsBrowserRuntimeEnsureFullClientStarted();
584+
} else if (globalThis.__NS_ENTRY_ERROR__) {
585+
// Boot failed; settle readiness so pending navigations reject.
586+
try { globalThis.__NS_CLIENT_STRATEGY_RESOLVE__?.(); } catch {}
587+
setTimeout(__nsBrowserRuntimeWaitForBoot, 100);
576588
} else {
577589
if (!__nsBrowserRuntimeBootWaitWarningIssued && Date.now() - __nsBrowserRuntimeBootWaitStartedAt >= 10000) {
578590
__nsBrowserRuntimeBootWaitWarningIssued = true;

packages/vite/hmr/shared/ns-globals.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ declare global {
108108
var __NS_HMR_WORKER_TRACKING_INSTALLED__: boolean | undefined;
109109
var __NS_UPDATE_ANGULAR_APP_OPTIONS__: any;
110110
var __nsNavigateUsingApp: any;
111+
var __NS_CLIENT_STRATEGY_READY__: Promise<void> | undefined;
112+
var __NS_CLIENT_STRATEGY_RESOLVE__: (() => void) | undefined;
111113
var __nsRequire: any;
112114
var __nsVendorRequire: any;
113115
var __nsVendorRegistry: any;

0 commit comments

Comments
 (0)