Skip to content

Commit 29ef514

Browse files
committed
fix(vite): give HMR-navigated Vue pages the root app's registrations and fresh reloads
Pages reached through the /ns/rt `$navigateTo` bridge are mounted by `__nsNavigateUsingApp`, which builds a fresh Vue app per page and copied only `provides` from the root app. Global registrations made in app.ts (`app.component()`, `app.use(plugin)`) were missing, so a plugin component such as CollectionView resolved as a plain element and its scoped slot was invoked with no arguments ("Cannot destructure property 'item' of 'undefined'"). The root app was also never recorded — the bridge passed `createApp` straight through — so a first navigation had nothing to inherit from. Full-reload HMR edits never appeared on those pages either: Vue's `createApp` clones a non-function root component and HMR mutates that clone (`instance.type`), while the reload hook rebuilt the page from the original object, swapping in stale code without any error. Render-only edits worked, which hid it. The bridge now emits `createApp` as a recording wrapper (`__NS_VUE_ROOT_APP__`), the navigator inherits components, directives, mixins and globalProperties from the root context (keeping the page app's own) and rebuilds from the mounted instance's type. `CollectionView` leaves NS_NATIVE_TAGS: the assembler compiles templates with that set as `isCustomElement`, and the Vue compiler rejects a scoped slot on a custom element, which made every Home.vue compile fall back to the Vite template variant. [skip ci]
1 parent edec20a commit 29ef514

7 files changed

Lines changed: 203 additions & 19 deletions

File tree

packages/vite/hmr/frameworks/vue/client/navigate-app.spec.ts

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, expect, it, vi } from 'vitest';
2-
import { installNavigatedPageHmrReload } from './navigate-app';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { inheritAppContext, installNavigatedPageHmrReload, installVueNavigateUsingApp } from './navigate-app';
33
import { readFileSync } from 'fs';
44
import path from 'path';
55
import { fileURLToPath } from 'url';
@@ -42,15 +42,119 @@ describe('__nsNavigateUsingApp prop forwarding', () => {
4242
expect(callMatch).toBeTruthy();
4343
const argList = callMatch![1];
4444
// Two top-level arguments: component, props
45-
expect(argList).toContain('normalizeComponent(comp,');
45+
expect(argList).toContain('normalizeComponent(target,');
4646
expect(argList).toMatch(/,\s*opts\s*&&\s*\(opts\s*as\s*any\)\.props\s*$/);
4747
});
4848

4949
it('still calls normalizeComponent so non-defineComponent inputs resolve correctly', () => {
5050
// Regression guard: a prior refactor passed `comp` directly to AppFactory
5151
// which broke <script setup> destinations. The normalizeComponent wrap
5252
// must stay in place.
53-
expect(navigateSrc).toMatch(/AppFactory\(normalizeComponent\(comp,/);
53+
expect(navigateSrc).toMatch(/AppFactory\(normalizeComponent\(target,/);
54+
});
55+
});
56+
57+
describe('inheritAppContext', () => {
58+
it('fills in components, directives, mixins and globalProperties the page app lacks, never overriding its own', () => {
59+
const Widget = { name: 'Widget' };
60+
const Own = { name: 'Own' };
61+
const mixin = { created() {} };
62+
const focus = {};
63+
const base = { components: { Widget, Own: { name: 'RootOwn' } }, directives: { focus }, mixins: [mixin], config: { globalProperties: { $http: 'http', $navigateTo: 'root-nav' } } };
64+
const ctx: any = { components: { Own }, directives: {}, mixins: [mixin], config: { globalProperties: { $navigateTo: 'page-nav' } } };
65+
inheritAppContext(ctx, base);
66+
expect(ctx.components.Widget).toBe(Widget);
67+
expect(ctx.components.Own).toBe(Own);
68+
expect(ctx.directives.focus).toBe(focus);
69+
expect(ctx.mixins).toEqual([mixin]);
70+
expect(ctx.config.globalProperties).toEqual({ $navigateTo: 'page-nav', $http: 'http' });
71+
});
72+
73+
it('tolerates a missing side', () => {
74+
expect(() => inheritAppContext(null, { components: {} })).not.toThrow();
75+
expect(() => inheritAppContext({}, null)).not.toThrow();
76+
});
77+
});
78+
79+
/**
80+
* Drives the installed navigator with a Vue-shaped factory: like Vue's
81+
* createApp, it clones a non-function root component, and the mounted root
82+
* instance's `type` is that clone — the object Vue's HMR `reload` mutates.
83+
*/
84+
describe('__nsNavigateUsingApp page apps', () => {
85+
const g: any = globalThis;
86+
const apps: any[] = [];
87+
88+
function installFakeVue() {
89+
apps.length = 0;
90+
g.NSVRoot = class NSVRoot {};
91+
g.createApp = vi.fn((rootComponent: any, rootProps?: any) => {
92+
const type = typeof rootComponent === 'function' ? rootComponent : { ...rootComponent };
93+
const _context: any = { app: null, config: { globalProperties: { $navigateTo: 'page-nav' } }, mixins: [], components: {}, directives: {}, provides: {} };
94+
const app: any = {
95+
_context,
96+
rootProps,
97+
mount: vi.fn(() => ({ $el: { nativeView: { constructor: { name: 'Page' } } }, $: { type } })),
98+
unmount: vi.fn(),
99+
};
100+
_context.app = app;
101+
apps.push(app);
102+
return app;
103+
});
104+
}
105+
106+
function makeFrame() {
107+
const frame: any = {
108+
currentPage: null,
109+
replacePage: vi.fn(),
110+
once: vi.fn(),
111+
navigate: vi.fn((entry: any) => {
112+
const page = entry.create();
113+
page.frame = frame;
114+
frame.currentPage = page;
115+
}),
116+
};
117+
return frame;
118+
}
119+
120+
afterEach(() => {
121+
delete g.createApp;
122+
delete g.NSVRoot;
123+
delete g.__NS_VUE_ROOT_APP__;
124+
});
125+
126+
it('inherits the root app recorded by the bridge when no app has navigated yet', () => {
127+
installFakeVue();
128+
const Widget = { name: 'Widget' };
129+
g.__NS_VUE_ROOT_APP__ = { _context: { components: { Widget }, directives: {}, mixins: [], provides: {}, config: { globalProperties: { $http: 'http' } } } };
130+
installVueNavigateUsingApp();
131+
g.__nsNavigateUsingApp({ name: 'Home', render: () => 'v1' }, { frame: makeFrame() });
132+
expect(apps).toHaveLength(1);
133+
expect(apps[0]._context.components.Widget).toBe(Widget);
134+
expect(apps[0]._context.config.globalProperties.$http).toBe('http');
135+
expect(apps[0]._context.config.globalProperties.$navigateTo).toBe('page-nav');
136+
});
137+
138+
it('rebuilds a hot-reloaded page from the mounted type Vue mutated, not from the original component', () => {
139+
installFakeVue();
140+
installVueNavigateUsingApp();
141+
const frame = makeFrame();
142+
const Home = { name: 'Home', render: () => 'v1' };
143+
g.__nsNavigateUsingApp(Home, { frame });
144+
const pageApp = apps[0];
145+
const mountedType = pageApp.mount.mock.results[0].value.$.type;
146+
expect(mountedType).not.toBe(Home);
147+
148+
// Vue's HMR reload mutates instance.type in place; the original is untouched.
149+
mountedType.render = () => 'v2';
150+
pageApp._context.reload();
151+
152+
expect(frame.replacePage).toHaveBeenCalledTimes(1);
153+
frame.replacePage.mock.calls[0][0].create();
154+
expect(apps).toHaveLength(2);
155+
const rebuiltFrom = g.createApp.mock.calls[1][0];
156+
expect(rebuiltFrom.render()).toBe('v2');
157+
expect(Home.render()).toBe('v1');
54158
});
55159
});
56160

packages/vite/hmr/frameworks/vue/client/navigate-app.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,34 @@ export function installNavigatedPageHmrReload({ app, page, rebuild }: NavigatedP
121121
return true;
122122
}
123123

124+
/**
125+
* Copy the root app's global registrations onto a page app, keeping whatever the
126+
* page app already registered (nativescript-vue's own plugins, pinia).
127+
* @param ctx The page app's `_context`.
128+
* @param base The root app's `_context`.
129+
*/
130+
export function inheritAppContext(ctx: any, base: any): void {
131+
if (!ctx || !base) return;
132+
for (const key of ['components', 'directives'] as const) {
133+
const src = base[key] || {};
134+
const dst = (ctx[key] ||= {});
135+
for (const k of Object.keys(src)) {
136+
if (!Object.prototype.hasOwnProperty.call(dst, k)) dst[k] = src[k];
137+
}
138+
}
139+
if (Array.isArray(base.mixins)) {
140+
const dst: any[] = (ctx.mixins ||= []);
141+
for (const m of base.mixins) if (!dst.includes(m)) dst.push(m);
142+
}
143+
const srcGp = base.config && base.config.globalProperties;
144+
if (srcGp && ctx.config) {
145+
const dstGp = (ctx.config.globalProperties ||= {});
146+
for (const k of Object.keys(srcGp)) {
147+
if (!(k in dstGp)) dstGp[k] = srcGp[k];
148+
}
149+
}
150+
}
151+
124152
// Deterministic navigation using the current Vue app instance rather than vendor-held rootApp.
125153
function __nsNavigateUsingApp(comp: any, opts: any = {}) {
126154
const g = getGlobalScope();
@@ -143,16 +171,17 @@ function __nsNavigateUsingApp(comp: any, opts: any = {}) {
143171
} catch {}
144172
// Build a fresh Page each time the factory is invoked to avoid reusing a Page instance
145173
// across fragment recreations (Android) or multiple frame attachments.
146-
const buildTarget = () => {
147-
const existingApp = getCurrentApp();
174+
const buildTarget = (target: any = comp) => {
175+
// Boot-time apps are only known via the bridge's createApp recording.
176+
const existingApp = getCurrentApp() || (g as any).__NS_VUE_ROOT_APP__ || null;
148177
const baseProvides = (existingApp && existingApp._context && existingApp._context.provides) || {};
149178
// Forward `opts.props` as Vue's rootProps so `$navigateTo(Comp, { props: { … } })`
150179
// reaches the destination component. nativescript-vue's stock `$navigateTo`
151180
// does the same via `createNativeView(target, options?.props, …)` →
152181
// `renderer.createApp(component, props)`. Dropping props here would surface
153182
// at the destination as `[Vue warn]: Missing required prop` and any
154183
// required-prop component would render with `undefined` bindings.
155-
const app = AppFactory(normalizeComponent(comp, comp && (comp.__name || comp.name)), opts && (opts as any).props);
184+
const app = AppFactory(normalizeComponent(target, target && (target.__name || target.name)), opts && (opts as any).props);
156185
ensurePiniaOnApp(app);
157186
try {
158187
const rh: any = resolveVendorModule('nativescript-vue/dist/runtimeHelpers');
@@ -171,9 +200,14 @@ function __nsNavigateUsingApp(comp: any, opts: any = {}) {
171200
});
172201
}
173202
} catch {}
203+
try {
204+
inheritAppContext(app?._context, existingApp && existingApp._context);
205+
} catch {}
174206
const root = new RootCtor();
175207
const vm = typeof (app as any).runWithContext === 'function' ? (app as any).runWithContext(() => (app as any).mount(root) as any) : ((app as any).mount(root) as any);
176208
setCurrentApp(app);
209+
// HMR mutates Vue's clone of the root component, so rebuild from that.
210+
const mountedType = (vm && vm.$ && vm.$.type) || target;
177211
const el = vm?.$el;
178212
const nativeView = el?.nativeView;
179213
if (!nativeView) throw new Error('navigation mount did not yield a nativeView');
@@ -190,7 +224,7 @@ function __nsNavigateUsingApp(comp: any, opts: any = {}) {
190224
page = pg;
191225
}
192226
try {
193-
installNavigatedPageHmrReload({ app, page, rebuild: buildTarget });
227+
installNavigatedPageHmrReload({ app, page, rebuild: () => buildTarget(mountedType) });
194228
} catch {}
195229
return page;
196230
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { compileTemplate } from '../frameworks/vue/server/sfc-route-shared.js';
4+
import { isNativeTag } from './compiler.js';
5+
6+
describe('NS_NATIVE_TAGS', () => {
7+
it('treats core views as custom elements', () => {
8+
expect(isNativeTag('Label')).toBe(true);
9+
expect(isNativeTag('GridLayout')).toBe(true);
10+
});
11+
12+
it('leaves Vue component wrappers that take slot templates to resolveComponent', () => {
13+
expect(isNativeTag('CollectionView')).toBe(false);
14+
});
15+
16+
// The HMR assembler compiles SFC templates with this predicate; a scoped slot on
17+
// a tag it calls an element makes the compiler throw and the SFC falls back to a
18+
// stale or synthesized render.
19+
it('compiles a CollectionView scoped slot with the assembler predicate', () => {
20+
const source = `<GridLayout><CollectionView :items="items"><template #default="{ item, index }"><Label :text="item.name" /></template></CollectionView></GridLayout>`;
21+
const result = compileTemplate({ source, id: 'home', filename: '/app/components/Home.vue', isProd: false, ssr: false, compilerOptions: { isCustomElement: isNativeTag } });
22+
expect(result.errors).toEqual([]);
23+
expect(result.code).toContain('resolveComponent("CollectionView")');
24+
});
25+
});

packages/vite/hmr/server/compiler.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Tags compiled as custom elements. A tag that a Vue integration registers as a
2+
// COMPONENT with slot templates (e.g. @nativescript-community/ui-collectionview/vue3's
3+
// CollectionView) must not be listed: the compiler rejects v-slot on an element.
14
export const NS_NATIVE_TAGS = new Set<string>([
25
// Core containers/layouts
36
'Page',
@@ -19,7 +22,6 @@ export const NS_NATIVE_TAGS = new Set<string>([
1922
'Image',
2023
'Img',
2124
'ListView',
22-
'CollectionView',
2325
'ScrollView',
2426
'WebView',
2527
'Switch',
@@ -52,7 +54,6 @@ export const NS_NATIVE_TAGS = new Set<string>([
5254
'SegmentedBar',
5355
'SegmentedBarItem',
5456
'RadListView',
55-
'CollectionViewGridLayout',
5657
'StackLayoutBase',
5758
'FlexboxLayoutBase',
5859
'GridLayoutBase',

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,25 @@ describe('/ns/rt bridge builder', () => {
9090
expect(code).not.toMatch(/export const \$navigateBack = \(__ensure\(\)\.\$navigateBack\);/);
9191
expect(code).not.toMatch(/export const \$showModal = \(__ensure\(\)\.\$showModal\);/);
9292
expect(code).not.toMatch(/export const vite__injectQuery = \(__ensure\(\)\.vite__injectQuery\);/);
93+
expect(code).not.toMatch(/export const createApp = \(__ensure\(\)\.createApp\);/);
9394
// But the HMR-routed shims ARE present (their bodies reference __nsNavigateUsingApp).
9495
expect(code).toContain('__nsNavigateUsingApp');
95-
// And ordinary exports are still emitted from the same input.
96-
expect(code).toContain('export const createApp = (__ensure().createApp);');
96+
});
97+
98+
// The navigator builds a fresh Vue app per page and has to inherit the root
99+
// app's registrations (app.component / app.use). Nothing else observes the
100+
// app created by app code, so the bridge's createApp records it.
101+
it('emits createApp as a recording wrapper that publishes the root app for the HMR navigator', () => {
102+
const code = buildNsRtBridgeModule({ rtVer: '0', requireGuardSnippet: '', vendorExports: ['createApp', 'ref'] });
103+
const line = code.split('\n').find((l) => l.startsWith('export const createApp = '));
104+
expect(line).toBeTruthy();
105+
expect(line).toContain('__ensure().createApp(...a)');
106+
expect(line).toContain('g.__NS_VUE_ROOT_APP__ = app');
107+
expect(line).toMatch(/return app;/);
108+
// Ordinary exports keep the constant-binding shape and the default listing carries both.
109+
expect(code).toContain('export const ref = (__ensure().ref);');
110+
expect(code).toMatch(/export default \{[^}]*\bcreateApp\b[^}]*\};/);
111+
expect(code).toMatch(/export default \{[^}]*\bref\b[^}]*\};/);
97112
});
98113

99114
it('filters non-identifier names (e.g. property strings with hyphens) from the auto-emitted exports', () => {

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { enumeratePackageExports } from '../helpers/package-exports.js';
44
// must not emit a plain passthrough for these names or the override would be
55
// shadowed and navigation would silently fall back to the vendor's native
66
// version (which doesn't know about the HMR app navigator).
7-
const NSV_SHIM_OVERRIDES: ReadonlySet<string> = new Set(['$navigateTo', '$navigateBack', '$showModal', 'vite__injectQuery']);
7+
const NSV_SHIM_OVERRIDES: ReadonlySet<string> = new Set(['createApp', '$navigateTo', '$navigateBack', '$showModal', 'vite__injectQuery']);
88

99
// Bridge-internal identifiers that would clash with the emitted preamble if
1010
// the vendor package happens to publish a colliding name.
@@ -45,11 +45,12 @@ export interface NsRtBridgeOptions {
4545
* `__nsVendorRegistry`), and the bridge resolves the same `nativescript-vue`
4646
* record everyone else uses.
4747
*
48-
* HMR-specific shims (`$navigateTo`, `$navigateBack`, `$showModal`) and the
49-
* Vite client polyfill (`vite__injectQuery`) are emitted as overrides that
50-
* replace the would-be passthrough — those exports route through the HMR
51-
* navigator instead of the vendor's native version, so the bridge must
52-
* provide the override, not the discovered original.
48+
* HMR-specific shims (`$navigateTo`, `$navigateBack`, `$showModal`), the
49+
* root-app recording `createApp`, and the Vite client polyfill
50+
* (`vite__injectQuery`) are emitted as overrides that replace the would-be
51+
* passthrough — those exports route through the HMR navigator (or feed it)
52+
* instead of the vendor's native version, so the bridge must provide the
53+
* override, not the discovered original.
5354
*/
5455
export function buildNsRtBridgeModule(options: NsRtBridgeOptions): string {
5556
// Sort for stable output — useful for diffing the served bridge across requests.
@@ -62,7 +63,7 @@ export function buildNsRtBridgeModule(options: NsRtBridgeOptions): string {
6263
const passthroughNames = Array.from(passthrough).sort();
6364

6465
const passthroughExports = passthroughNames.map((n) => `export const ${n} = (__ensure().${n});`).join('\n');
65-
const defaultListing = passthroughNames.concat(['$navigateTo', '$navigateBack', '$showModal', 'vite__injectQuery']).join(', ');
66+
const defaultListing = passthroughNames.concat(['createApp', '$navigateTo', '$navigateBack', '$showModal', 'vite__injectQuery']).join(', ');
6667

6768
const code =
6869
`// [ns-rt][v2.4] NativeScript-Vue runtime bridge (module-scoped cache, no globals)\n` +
@@ -124,6 +125,9 @@ export function buildNsRtBridgeModule(options: NsRtBridgeOptions): string {
124125
// Await the client strategy before declaring the navigator missing.
125126
`function __navigateNow(a) { try { return g.__nsNavigateUsingApp(...a); } catch (e) { console.error('[ns-rt] $navigateTo app navigator error', e); throw e; } }\n` +
126127
`function __navigatorMissing() { console.error('[ns-rt] $navigateTo unavailable: app navigator missing'); throw new Error('$navigateTo unavailable: app navigator missing'); }\n` +
128+
// The app's registrations (app.component/use) live on this instance; the
129+
// HMR navigator copies them onto every page app it builds.
130+
`export const createApp = (...a) => { const app = __ensure().createApp(...a); try { g.__NS_VUE_ROOT_APP__ = app; } catch {} return app; };\n` +
127131
`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` +
128132
`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` +
129133
// Vite client polyfill — see the comment in websocket.ts for full rationale.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ 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_VUE_ROOT_APP__: any;
111112
var __NS_CLIENT_STRATEGY_READY__: Promise<void> | undefined;
112113
var __NS_CLIENT_STRATEGY_RESOLVE__: (() => void) | undefined;
113114
var __nsRequire: any;

0 commit comments

Comments
 (0)