Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/vite/hmr/client/strategy-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ export const CLIENT_STRATEGY_READY: Promise<void> =
})
: 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;
Expand Down
82 changes: 81 additions & 1 deletion packages/vite/hmr/server/ns-rt-bridge.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<string, any>) {
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<string, any> = { Frame: {} };
let installed!: () => void;
g.__NS_CLIENT_STRATEGY_READY__ = new Promise<void>((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
Expand Down
7 changes: 5 additions & 2 deletions packages/vite/hmr/server/ns-rt-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const NSV_SHIM_OVERRIDES: ReadonlySet<string> = 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<string> = new Set(['__realm', '__cached_rt', '__cached_vm', '__ensure', '__get', 'default']);
const RESERVED_BRIDGE_LOCALS: ReadonlySet<string> = new Set(['__realm', '__cached_rt', '__cached_vm', '__ensure', '__get', '__navigateNow', '__navigatorMissing', 'default']);

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

Expand Down Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions packages/vite/hmr/server/vite-plugin-path.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__)');
});
});
12 changes: 12 additions & 0 deletions packages/vite/hmr/server/vite-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -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 = () => {
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/vite/hmr/shared/ns-globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | undefined;
var __NS_CLIENT_STRATEGY_RESOLVE__: (() => void) | undefined;
var __nsRequire: any;
var __nsVendorRequire: any;
var __nsVendorRegistry: any;
Expand Down
Loading