| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 02b10f9 commit 86320ee
14 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,5 +1,6 @@ | |||
| 1 | 1 | import { getCore } from './utils.js'; | |
| 2 | 2 | import { getGlobalScope } from '../shared/runtime/global-scope.js'; | |
| 3 | + import { restyleRootAndModals } from '../shared/runtime/restyle-roots.js'; | ||
| 3 | 4 | ||
| 4 | 5 | const VERBOSE = !!getGlobalScope().__NS_ENV_VERBOSE__; | |
| 5 | 6 | ||
@@ -79,16 +80,10 @@ export function applyCssText(cssText: string, tag: string = APP_CSS_TAG): void { | |||
| 79 | 80 | if (VERBOSE) console.info('[ns-hmr] CSS applied via addCss (additive fallback)'); | |
| 80 | 81 | } | |
| 81 | 82 | // NS caches computed styles — re-trigger styling on the root | |
| 82 | - // (propagates to descendants via `eachDescendant`). | ||
| 83 | + // (propagates to descendants via `eachDescendant`) and on every | ||
| 84 | + // presented modal, which is a root of its own. | ||
| 83 | 85 | try { | |
| 84 | - const rootView = Application?.getRootView?.(); | ||
| 85 | - if (rootView && typeof rootView._onCssStateChange === 'function') { | ||
| 86 | - rootView._onCssStateChange(); | ||
| 87 | - } else if (rootView) { | ||
| 88 | - const cls = rootView.className || ''; | ||
| 89 | - rootView.className = cls + ' '; | ||
| 90 | - rootView.className = cls; | ||
| 91 | - } | ||
| 86 | + restyleRootAndModals(Application?.getRootView?.()); | ||
| 92 | 87 | } catch {} | |
| 93 | 88 | } catch (e) { | |
| 94 | 89 | console.warn('[ns-hmr] CSS apply failed:', e?.message || String(e)); | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,4 +1,5 @@ | |||
| 1 | 1 | import { getGlobalScope } from './shared/runtime/global-scope.js'; | |
| 2 | + import { restyleRootAndModals } from './shared/runtime/restyle-roots.js'; | ||
| 2 | 3 | import { markDevBootComplete } from './shared/runtime/boot-complete.js'; | |
| 3 | 4 | import { readNsRuntimeDevHostApi } from './shared/runtime/browser-runtime-contract.js'; | |
| 4 | 5 | import { installWorkerConstructorTracking } from './shared/runtime/worker-tracking.js'; | |
@@ -156,16 +157,7 @@ export function installHttpCoreCssSupport(coreModule: any, verbose?: boolean): H | |||
| 156 | 157 | const seenRoots = new Set<any>(); | |
| 157 | 158 | for (const candidate of candidates) { | |
| 158 | 159 | try { | |
| 159 | - const rootView = candidate?.getRootView?.(); | ||
| 160 | - if (!rootView || seenRoots.has(rootView)) continue; | ||
| 161 | - seenRoots.add(rootView); | ||
| 162 | - if (typeof rootView._onCssStateChange === 'function') { | ||
| 163 | - rootView._onCssStateChange(); | ||
| 164 | - } else { | ||
| 165 | - const cls = rootView.className || ''; | ||
| 166 | - rootView.className = cls + ' '; | ||
| 167 | - rootView.className = cls; | ||
| 168 | - } | ||
| 160 | + restyleRootAndModals(candidate?.getRootView?.(), seenRoots); | ||
| 169 | 161 | } catch {} | |
| 170 | 162 | } | |
| 171 | 163 | }; | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,4 +1,5 @@ | |||
| 1 | 1 | import { getCore, getCurrentApp, graph, invalidateModulesByUrls, normalizeSpec, resolveHmrHttpOrigin, safeDynImport, safeReadDefault, setCurrentApp } from '../../../client/utils.js'; | |
| 2 | + import { APPLIED_IN_PLACE } from './vue-sfc-update-overlay.js'; | ||
| 2 | 3 | import { getGlobalScope } from '../../../shared/runtime/global-scope.js'; | |
| 3 | 4 | import { resolveVendorModule } from '../../../shared/runtime/vendor-resolve.js'; | |
| 4 | 5 | import { findSfcAncestors } from './dep-propagation.js'; | |
@@ -517,27 +518,43 @@ function openHmrReplaceNavWindow(): () => void { | |||
| 517 | 518 | ||
| 518 | 519 | /** | |
| 519 | 520 | * In-place Vue HMR. The assembled SFC stamps a stable `comp.__hmrId`, so Vue's | |
| 520 | - * registerHMR has a record for every mounted instance. Calling the real | ||
| 521 | - * `__VUE_HMR_RUNTIME__.reload(id, newComp)` re-renders just those instances in | ||
| 522 | - * place — preserving the App.vue shell (drawer, nav, router), current route, and | ||
| 523 | - * scroll — instead of a whole-tree resetRootView. Returns false (→ resetRoot | ||
| 524 | - * fallback) when the real runtime is absent (stub/prod build), the component has | ||
| 525 | - * no id, or reload throws. A reload that matches no live instances is a no-op | ||
| 526 | - * (component not currently displayed); the caller treats that as handled. | ||
| 521 | + * registerHMR has a record for every mounted instance, and the runtime can | ||
| 522 | + * patch exactly those instances instead of replacing the whole root view. | ||
| 523 | + * | ||
| 524 | + * Which runtime entry point matters, and it is not a performance question. | ||
| 525 | + * `reload` re-instantiates; for an instance with no parent Vue routes that to | ||
| 526 | + * `appContext.reload()`, which tears down and rebuilds the hosted view. Every | ||
| 527 | + * page pushed with `$navigateTo` and every sheet shown with `$showModal` is | ||
| 528 | + * such a root, so a `reload` there costs a re-navigation at best and a | ||
| 529 | + * dismissed sheet at worst. `rerender` swaps the render function on each live | ||
| 530 | + * instance and updates it where it stands — parented or not. | ||
| 531 | + * | ||
| 532 | + * So when the server reports the save left every script block untouched | ||
| 533 | + * (`rerenderOnly`), take `rerender`: the edit lands inside pushed pages and | ||
| 534 | + * presented modals with nothing remounted. Anything that could have changed | ||
| 535 | + * behavior still needs `reload`. | ||
| 536 | + * | ||
| 537 | + * Returns false (→ resetRoot fallback) when the real runtime is absent | ||
| 538 | + * (stub/prod build), the component has no id, or the call throws. An update | ||
| 539 | + * that matches no live instances is a no-op (component not currently | ||
| 540 | + * displayed); the caller treats that as handled. | ||
| 527 | 541 | */ | |
| 528 | - function tryInPlaceVueReload(comp: any): boolean { | ||
| 542 | + function tryInPlaceVueReload(comp: any, rerenderOnly = false): boolean { | ||
| 529 | 543 | try { | |
| 530 | 544 | const rt: any = (getGlobalScope() as any).__VUE_HMR_RUNTIME__; | |
| 531 | 545 | const id = comp && comp.__hmrId; | |
| 532 | - if (!rt || typeof rt.reload !== 'function' || !id) return false; | ||
| 546 | + if (!rt || !id) return false; | ||
| 547 | + const canRerender = rerenderOnly && typeof rt.rerender === 'function' && typeof comp.render === 'function'; | ||
| 548 | + if (!canRerender && typeof rt.reload !== 'function') return false; | ||
| 533 | 549 | // Vue queues the remount on its scheduler (microtask flush) — the | |
| 534 | 550 | // Frame navigation happens inside that flush, not synchronously in | |
| 535 | 551 | // reload(). Hold the replace-navigation window through the flush: | |
| 536 | 552 | // nextTick resolves after the job queue drains; the timeout is a | |
| 537 | 553 | // safety net when nextTick is unavailable. | |
| 538 | 554 | const closeWindow = openHmrReplaceNavWindow(); | |
| 539 | 555 | try { | |
| 540 | - rt.reload(id, comp); | ||
| 556 | + if (canRerender) rt.rerender(id, comp.render); | ||
| 557 | + else rt.reload(id, comp); | ||
| 541 | 558 | } catch (e) { | |
| 542 | 559 | closeWindow(); | |
| 543 | 560 | throw e; | |
@@ -549,10 +566,10 @@ function tryInPlaceVueReload(comp: any): boolean { | |||
| 549 | 566 | } | |
| 550 | 567 | } catch {} | |
| 551 | 568 | setTimeout(closeWindow, 500); | |
| 552 | - if (__NS_ENV_VERBOSE__) console.log('[hmr][vue] in-place reload', id); | ||
| 569 | + if (__NS_ENV_VERBOSE__) console.log(`[hmr][vue] in-place ${canRerender ? 'rerender' : 'reload'}`, id); | ||
| 553 | 570 | return true; | |
| 554 | 571 | } catch (e) { | |
| 555 | - if (__NS_ENV_VERBOSE__) console.warn('[hmr][vue] in-place reload failed; resetRoot fallback', e); | ||
| 572 | + if (__NS_ENV_VERBOSE__) console.warn('[hmr][vue] in-place update failed; resetRoot fallback', e); | ||
| 556 | 573 | return false; | |
| 557 | 574 | } | |
| 558 | 575 | } | |
@@ -576,8 +593,8 @@ export async function handleVueSfcRegistryUpdate(msg: any, graphVersion: number) | |||
| 576 | 593 | // Preferred path: patch the changed component's mounted instances in | |
| 577 | 594 | // place (App.vue shell, route, scroll all survive). Returning null tells | |
| 578 | 595 | // the overlay no resetRootView is needed. | |
| 579 | - if (tryInPlaceVueReload(comp)) { | ||
| 580 | - return null; | ||
| 596 | + if (tryInPlaceVueReload(comp, msg?.rerenderOnly === true)) { | ||
| 597 | + return APPLIED_IN_PLACE; | ||
| 581 | 598 | } | |
| 582 | 599 | // Fallback (real Vue HMR runtime unavailable): a child SFC with required | |
| 583 | 600 | // props cannot be a standalone root — mounting it bare crashes (missing | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,4 +1,5 @@ | |||
| 1 | - import { describe, expect, it } from 'vitest'; | ||
| 1 | + import { describe, expect, it, vi } from 'vitest'; | ||
| 2 | + import { installNavigatedPageHmrReload } from './navigate-app'; | ||
| 2 | 3 | import { readFileSync } from 'fs'; | |
| 3 | 4 | import path from 'path'; | |
| 4 | 5 | import { fileURLToPath } from 'url'; | |
@@ -52,3 +53,102 @@ describe('__nsNavigateUsingApp prop forwarding', () => { | |||
| 52 | 53 | expect(navigateSrc).toMatch(/AppFactory\(normalizeComponent\(comp,/); | |
| 53 | 54 | }); | |
| 54 | 55 | }); | |
| 56 | + | ||
| 57 | + describe('installNavigatedPageHmrReload', () => { | ||
| 58 | + function makeFrame(currentPage: any) { | ||
| 59 | + const onceHandlers: Record<string, Array<() => void>> = {}; | ||
| 60 | + return { | ||
| 61 | + currentPage, | ||
| 62 | + replacePage: vi.fn(), | ||
| 63 | + once: vi.fn((event: string, cb: () => void) => { | ||
| 64 | + (onceHandlers[event] ||= []).push(cb); | ||
| 65 | + }), | ||
| 66 | + fire(event: string) { | ||
| 67 | + const list = onceHandlers[event] || []; | ||
| 68 | + onceHandlers[event] = []; | ||
| 69 | + list.forEach((cb) => cb()); | ||
| 70 | + }, | ||
| 71 | + }; | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + function makePage(frame: any) { | ||
| 75 | + const onceHandlers: Array<() => void> = []; | ||
| 76 | + return { | ||
| 77 | + frame, | ||
| 78 | + once: vi.fn((_event: string, cb: () => void) => { | ||
| 79 | + onceHandlers.push(cb); | ||
| 80 | + }), | ||
| 81 | + fireNavigatedTo() { | ||
| 82 | + const list = onceHandlers.splice(0); | ||
| 83 | + list.forEach((cb) => cb()); | ||
| 84 | + }, | ||
| 85 | + }; | ||
| 86 | + } | ||
| 87 | + | ||
| 88 | + it("overwrites the app context's reload (Vue's DEV default renders into the detached NSVRoot)", () => { | ||
| 89 | + const ctx: Record<string, any> = { reload: () => 'vue-default' }; | ||
| 90 | + const app = { _context: ctx, unmount: vi.fn() }; | ||
| 91 | + const page = makePage(makeFrame(null)); | ||
| 92 | + expect(installNavigatedPageHmrReload({ app, page, rebuild: () => ({}) })).toBe(true); | ||
| 93 | + expect(typeof ctx.reload).toBe('function'); | ||
| 94 | + expect(ctx.reload()).not.toBe('vue-default'); | ||
| 95 | + }); | ||
| 96 | + | ||
| 97 | + it('replaces the current entry with a rebuilt page and releases the old app after the swap', () => { | ||
| 98 | + const ctx: Record<string, any> = {}; | ||
| 99 | + const app = { _context: ctx, unmount: vi.fn() }; | ||
| 100 | + const frame = makeFrame(null); | ||
| 101 | + const page = makePage(frame); | ||
| 102 | + frame.currentPage = page; | ||
| 103 | + const freshPage = { fresh: true }; | ||
| 104 | + installNavigatedPageHmrReload({ app, page, rebuild: () => freshPage }); | ||
| 105 | + | ||
| 106 | + ctx.reload(); | ||
| 107 | + | ||
| 108 | + expect(frame.replacePage).toHaveBeenCalledTimes(1); | ||
| 109 | + const entry = frame.replacePage.mock.calls[0][0]; | ||
| 110 | + expect(entry.animated).toBe(false); | ||
| 111 | + expect(entry.create()).toBe(freshPage); | ||
| 112 | + // old app released only once the fresh page is actually in place | ||
| 113 | + expect(app.unmount).not.toHaveBeenCalled(); | ||
| 114 | + frame.fire('navigatedTo'); | ||
| 115 | + expect(app.unmount).toHaveBeenCalledTimes(1); | ||
| 116 | + }); | ||
| 117 | + | ||
| 118 | + it('stands down for a page with no frame (already replaced or disposed)', () => { | ||
| 119 | + const ctx: Record<string, any> = {}; | ||
| 120 | + const app = { _context: ctx, unmount: vi.fn() }; | ||
| 121 | + const rebuild = vi.fn(); | ||
| 122 | + const page = makePage(null); | ||
| 123 | + installNavigatedPageHmrReload({ app, page, rebuild }); | ||
| 124 | + | ||
| 125 | + ctx.reload(); | ||
| 126 | + | ||
| 127 | + expect(rebuild).not.toHaveBeenCalled(); | ||
| 128 | + expect(page.once).not.toHaveBeenCalled(); | ||
| 129 | + }); | ||
| 130 | + | ||
| 131 | + it('defers a backstack page to its next navigatedTo, coalescing repeated reloads', () => { | ||
| 132 | + const ctx: Record<string, any> = {}; | ||
| 133 | + const app = { _context: ctx, unmount: vi.fn() }; | ||
| 134 | + const frame = makeFrame({ someOtherPage: true }); | ||
| 135 | + const page = makePage(frame); | ||
| 136 | + const freshPage = { fresh: true }; | ||
| 137 | + installNavigatedPageHmrReload({ app, page, rebuild: () => freshPage }); | ||
| 138 | + | ||
| 139 | + ctx.reload(); | ||
| 140 | + ctx.reload(); | ||
| 141 | + ctx.reload(); | ||
| 142 | + expect(page.once).toHaveBeenCalledTimes(1); | ||
| 143 | + expect(frame.replacePage).not.toHaveBeenCalled(); | ||
| 144 | + | ||
| 145 | + // the user comes back to the page | ||
| 146 | + frame.currentPage = page; | ||
| 147 | + page.fireNavigatedTo(); | ||
| 148 | + expect(frame.replacePage).toHaveBeenCalledTimes(1); | ||
| 149 | + }); | ||
| 150 | + | ||
| 151 | + it('refuses an app with no context', () => { | ||
| 152 | + expect(installNavigatedPageHmrReload({ app: {} as any, page: {}, rebuild: () => ({}) })).toBe(false); | ||
| 153 | + }); | ||
| 154 | + }); | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -54,6 +54,73 @@ export function normalizeComponent(input: any, nameHint?: string): any { | |||
| 54 | 54 | return input; | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | + /** The slice of a mounted Vue app the navigated-page reload needs. */ | ||
| 58 | + export interface NavigatedPageHmrReloadHandle { | ||
| 59 | + app: { unmount?: () => void; _context?: Record<string, any> }; | ||
| 60 | + /** The NativeScript Page hosting the mounted component. */ | ||
| 61 | + page: any; | ||
| 62 | + /** Builds a fresh page from the current component definition (and installs a new reload on it). */ | ||
| 63 | + rebuild: () => any; | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + /** | ||
| 67 | + * Install `appContext.reload` on a page mounted by `__nsNavigateUsingApp`. | ||
| 68 | + * | ||
| 69 | + * Vue routes an HMR `reload` of a parentless instance — which every navigated | ||
| 70 | + * page mounted through this backend is — to `instance.appContext.reload()`. | ||
| 71 | + * Vue's own DEV default for that hook re-renders into the app's root container, | ||
| 72 | + * but here that container is the detached `NSVRoot` the page was mounted from, | ||
| 73 | + * so the on-screen page never changes. The stock nativescript-vue `$navigateTo` | ||
| 74 | + * installs its `reloadPage` for exactly this reason; this is that contract for | ||
| 75 | + * the HMR navigation backend. | ||
| 76 | + * | ||
| 77 | + * The installed hook rebuilds the destination from the (HMR-mutated) component | ||
| 78 | + * and `replacePage`s it into the frame, so the current entry is swapped in | ||
| 79 | + * place and the backstack is untouched. A page that is not currently shown | ||
| 80 | + * defers to its next `navigatedTo`; a page with no frame at all was already | ||
| 81 | + * replaced or disposed, so its instance is stale and the hook stands down. | ||
| 82 | + */ | ||
| 83 | + export function installNavigatedPageHmrReload({ app, page, rebuild }: NavigatedPageHmrReloadHandle): boolean { | ||
| 84 | + const ctx = app && app._context; | ||
| 85 | + if (!ctx || !page) return false; | ||
| 86 | + let pendingReturn = false; | ||
| 87 | + ctx.reload = () => { | ||
| 88 | + try { | ||
| 89 | + const frame = page.frame; | ||
| 90 | + if (!frame) return; | ||
| 91 | + if (frame.currentPage !== page) { | ||
| 92 | + if (pendingReturn) return; | ||
| 93 | + pendingReturn = true; | ||
| 94 | + try { | ||
| 95 | + page.once('navigatedTo', () => { | ||
| 96 | + pendingReturn = false; | ||
| 97 | + try { | ||
| 98 | + ctx.reload(); | ||
| 99 | + } catch {} | ||
| 100 | + }); | ||
| 101 | + } catch { | ||
| 102 | + pendingReturn = false; | ||
| 103 | + } | ||
| 104 | + return; | ||
| 105 | + } | ||
| 106 | + frame.replacePage({ create: () => rebuild(), animated: false } as any); | ||
| 107 | + try { | ||
| 108 | + // Release the replaced app once the fresh page is in, so its | ||
| 109 | + // instance leaves Vue's HMR registry — otherwise every later | ||
| 110 | + // reload also runs against the stale instance. | ||
| 111 | + frame.once('navigatedTo', () => { | ||
| 112 | + try { | ||
| 113 | + app.unmount?.(); | ||
| 114 | + } catch {} | ||
| 115 | + }); | ||
| 116 | + } catch {} | ||
| 117 | + } catch (e) { | ||
| 118 | + console.warn('[app-nav] HMR reload of navigated page failed', e); | ||
| 119 | + } | ||
| 120 | + }; | ||
| 121 | + return true; | ||
| 122 | + } | ||
| 123 | + | ||
| 57 | 124 | // Deterministic navigation using the current Vue app instance rather than vendor-held rootApp. | |
| 58 | 125 | function __nsNavigateUsingApp(comp: any, opts: any = {}) { | |
| 59 | 126 | const g = getGlobalScope(); | |
@@ -112,19 +179,20 @@ function __nsNavigateUsingApp(comp: any, opts: any = {}) { | |||
| 112 | 179 | if (!nativeView) throw new Error('navigation mount did not yield a nativeView'); | |
| 113 | 180 | const P = getCore('Page'); | |
| 114 | 181 | const ctorName = String(nativeView?.constructor?.name || '').replace(/^_+/, ''); | |
| 115 | - if (ctorName === 'Page' || /^Page(\$\d+)?$/.test(ctorName)) { | ||
| 116 | - return nativeView; | ||
| 117 | - } | ||
| 118 | - if (typeof P === 'function') { | ||
| 182 | + let page = nativeView; | ||
| 183 | + if (!(ctorName === 'Page' || /^Page(\$\d+)?$/.test(ctorName)) && typeof P === 'function') { | ||
| 119 | 184 | const pg = new (P as any)(); | |
| 120 | 185 | (pg as any).content = nativeView; | |
| 121 | 186 | // Hide default ActionBar for wrapped views to avoid double bars | |
| 122 | 187 | try { | |
| 123 | 188 | (pg as any).actionBarHidden = true; | |
| 124 | 189 | } catch {} | |
| 125 | - return pg; | ||
| 190 | + page = pg; | ||
| 126 | 191 | } | |
| 127 | - return nativeView; // fallback | ||
| 192 | + try { | ||
| 193 | + installNavigatedPageHmrReload({ app, page, rebuild: buildTarget }); | ||
| 194 | + } catch {} | ||
| 195 | + return page; | ||
| 128 | 196 | }; | |
| 129 | 197 | let frame = opts && (opts as any).frame ? (opts as any).frame : getRootFrame(); | |
| 130 | 198 | if (!frame) { | |
| Back | FazBrowse Home | New Git URL |
0 commit comments