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
55 changes: 55 additions & 0 deletions packages/one/src/router/hmrImport.native.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hmrImport } from './hmrImport.native'

const realRuntime = (globalThis as any).__rolldown_runtime__
afterEach(() => {
;(globalThis as any).__rolldown_runtime__ = realRuntime
vi.restoreAllMocks()
})
const setRuntime = (runtime: unknown) => {
;(globalThis as any).__rolldown_runtime__ = runtime
}

describe('hmrImport (native)', () => {
it('rejects when no runtime is present', async () => {
setRuntime(undefined)
await expect(hmrImport('foo.tsx')).rejects.toThrow(
'HMR import not supported on native'
)
})

it('rejects when loadExports is missing', async () => {
setRuntime({})
await expect(hmrImport('foo.tsx')).rejects.toThrow(
'HMR import not supported on native'
)
})

it('resolves with the runtime exports and strips ./ and / prefixes', async () => {
const exports = { default: () => null }
const loadExports = vi.fn(() => exports)
setRuntime({ loadExports })

await expect(hmrImport('./foo.tsx')).resolves.toBe(exports)
await expect(hmrImport('/foo.tsx')).resolves.toBe(exports)
await expect(hmrImport('foo.tsx')).resolves.toBe(exports)
expect(loadExports).toHaveBeenNthCalledWith(1, 'foo.tsx')
expect(loadExports).toHaveBeenNthCalledWith(2, 'foo.tsx')
expect(loadExports).toHaveBeenNthCalledWith(3, 'foo.tsx')
})

it('rejects when loadExports returns null', async () => {
setRuntime({ loadExports: () => null })
await expect(hmrImport('foo.tsx')).rejects.toThrow('no exports for foo.tsx')
})

it('rejects when loadExports throws', async () => {
const boom = new Error('boom')
setRuntime({
loadExports: () => {
throw boom
},
})
await expect(hmrImport('foo.tsx')).rejects.toBe(boom)
})
})
28 changes: 23 additions & 5 deletions packages/one/src/router/hmrImport.native.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
/**
* Native stub: HMR cache-busting is not supported on native.
* This avoids the dynamic import() syntax that Hermes cannot parse.
* Native HMR re-import.
*
* vxrn's native dev runtime re-registers the edited module into
* `globalThis.__rolldown_runtime__` before One evicts its route cache, so the
* fresh exports can be pulled straight from the runtime — no dynamic `import()`
* (which Hermes cannot parse). Falls back to a rejected promise when the runtime
* isn't present (e.g. production), so `resolve()` keeps the memoized module.
*/
export function hmrImport(_path: string): Promise<any> {
// Return a rejected promise to fall back to normal import
return Promise.reject(new Error('HMR import not supported on native'))
export function hmrImport(path: string): Promise<any> {
const runtime = (globalThis as any).__rolldown_runtime__
if (!runtime || typeof runtime.loadExports !== 'function') {
return Promise.reject(new Error('HMR import not supported on native'))
}
// route paths arrive as `./foo.tsx` / `/foo.tsx`; rolldown ids are bare (`foo.tsx`)
const id = String(path).replace(/^\.\//, '').replace(/^\//, '')
try {
const mod = runtime.loadExports(id)
if (mod == null) {
return Promise.reject(new Error(`no exports for ${id}`))
}
return Promise.resolve(mod)
} catch (error) {
return Promise.reject(error)
}
}
52 changes: 52 additions & 0 deletions packages/one/src/router/routeHmr.native.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

type RouteHmrModule = typeof import('./routeHmr.native')

describe('routeHmr.native', () => {
let routeHmr: RouteHmrModule
const realWindow = (globalThis as any).window

beforeEach(async () => {
// the __VXRN_ON_MODULE_UPDATED__ registration is dev-gated and runs at module
// load, so stub NODE_ENV then re-import to exercise it
vi.stubEnv('NODE_ENV', 'development')
vi.resetModules()
routeHmr = await import('./routeHmr.native')
})

afterEach(() => {
vi.unstubAllEnvs()
globalThis.__VXRN_ON_MODULE_UPDATED__ = undefined
;(globalThis as any).window = realWindow
})

it('registers globalThis.__VXRN_ON_MODULE_UPDATED__ in development', () => {
expect(typeof globalThis.__VXRN_ON_MODULE_UPDATED__).toBe('function')
})

it('bumps the epoch and notifies subscribers, and stops after unsubscribe', () => {
const before = routeHmr.getRouteHmrEpoch()
const listener = vi.fn()
const unsubscribe = routeHmr.subscribeRouteHmr(listener)

globalThis.__VXRN_ON_MODULE_UPDATED__!('app/index.tsx')
expect(routeHmr.getRouteHmrEpoch()).toBe(before + 1)
expect(listener).toHaveBeenCalledTimes(1)

unsubscribe()
globalThis.__VXRN_ON_MODULE_UPDATED__!('app/index.tsx')
expect(listener).toHaveBeenCalledTimes(1)
})

it('evicts the route cache for the updated file when window.__oneRouteCache is present', () => {
const clearFile = vi.fn()
;(globalThis as any).window = { __oneRouteCache: { clearFile } }
globalThis.__VXRN_ON_MODULE_UPDATED__!('app/_layout.tsx')
expect(clearFile).toHaveBeenCalledWith('app/_layout.tsx')
})

it('does not throw when window / route cache is absent', () => {
;(globalThis as any).window = undefined
expect(() => globalThis.__VXRN_ON_MODULE_UPDATED__!('x.tsx')).not.toThrow()
})
})
45 changes: 45 additions & 0 deletions packages/one/src/router/routeHmr.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Native Fast Refresh for One routes.
//
// On native, RN's React Refresh can't repaint One's route components: useScreens'
// `ScreenComponent` obtains them via `value.loadRoute()` and re-wraps them
// (`fromImport` -> `forwardRef` -> `getPageExport`), so the mounted fiber isn't in
// the edited module's Refresh family. vxrn's native HMR runtime surfaces each
// committed module id to `globalThis.__VXRN_ON_MODULE_UPDATED__`; we evict One's
// route cache (bumping `useViteRoutes`' `hmrVersion` so `resolve()` re-imports the
// edited module) and bump a subscribable epoch so subscribed `ScreenComponent`s
// re-render and re-run `loadRoute()`. Web uses `routeHmr.ts` (a no-op store; the
// web path repaints via the `one-hmr-update` event, see useScreens' web block).
//
// (The web path shows RN's re-wrapping isn't the whole story — web leaf routes
// Fast-Refresh fine through the same wrapper — so the native-specific failure most
// likely lives in vxrn's own React Refresh wiring; this bypasses it for routes.)

declare global {
// vxrn's native HMR runtime invokes this (when defined) with each committed
// module id, so a framework can react to a hot update.
var __VXRN_ON_MODULE_UPDATED__: ((moduleId: string) => void) | undefined
}

let routeHmrEpoch = 0
const routeHmrListeners = new Set<() => void>()

export const subscribeRouteHmr = (onStoreChange: () => void) => {
routeHmrListeners.add(onStoreChange)
return () => {
routeHmrListeners.delete(onStoreChange)
}
}

export const getRouteHmrEpoch = () => routeHmrEpoch

if (process.env.NODE_ENV === 'development' && typeof globalThis !== 'undefined') {
globalThis.__VXRN_ON_MODULE_UPDATED__ = (id: string) => {
try {
if (typeof window !== 'undefined' && (window as any).__oneRouteCache) {
;(window as any).__oneRouteCache.clearFile(id)
}
} catch {}
routeHmrEpoch++
routeHmrListeners.forEach((listener) => listener())
}
}
8 changes: 8 additions & 0 deletions packages/one/src/router/routeHmr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Web counterpart of routeHmr.native.ts. Web route Fast Refresh happens through
// the `one-hmr-update` event (see useScreens' web block), so this is a no-op store
// — its exports exist only so useScreens can import `./routeHmr` platform-agnostically.
export const subscribeRouteHmr = (_onStoreChange: () => void): (() => void) => {
return () => {}
}

export const getRouteHmrEpoch = () => 0
12 changes: 12 additions & 0 deletions packages/one/src/router/useScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { SpaShellContext } from './SpaShellContext'
import { NamedSlot } from '../views/Navigator'
import { sortRoutesWithInitial } from './sortRoutes'
import { getRouteHmrEpoch, subscribeRouteHmr } from './routeHmr'
import { getClientMatchesSnapshot } from '../useMatches'

// `@react-navigation/core` does not expose the Screen or Group components directly, so we have to
Expand Down Expand Up @@ -395,6 +396,17 @@ export function getQualifiedRouteComponent(value: RouteNode) {
}, [])
}

// native Fast Refresh: subscribe to the route-hot epoch (the routeHmr.native
// store bumps it when vxrn reports a route module update) so this component
// re-renders and re-runs loadRoute() to pick up the edited module's exports
if (
process.env.NODE_ENV === 'development' &&
process.env.TAMAGUI_TARGET === 'native'
) {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useSyncExternalStore(subscribeRouteHmr, getRouteHmrEpoch, getRouteHmrEpoch)
}

// in spa-shell mode, only SSG/SSR layouts render on the server.
// SPA layouts and leaf pages get a placeholder, swapped for real
// content after hydration.
Expand Down
4 changes: 4 additions & 0 deletions packages/vxrn/src/runtime/hmr-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ declare global {
var __VXRN_CUSTOM_HMR_HANDLER__:
| ((socket: WebSocket, message: HMRCustomMessage) => void)
| undefined
// called (when a framework defines it) with each committed module id from
// applyUpdates, so a framework can react to a native hot update — e.g. One's
// routeHmr.native.ts evicts its route cache and re-renders the route
var __VXRN_ON_MODULE_UPDATED__: ((moduleId: string) => void) | undefined
}

// DO NOT EDIT THIS CLASS NAME - rolldown references it
Expand Down
8 changes: 8 additions & 0 deletions packages/vxrn/src/utils/createNativeDevEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,14 @@ class ReactNativeDevRuntime extends BaseDevRuntime {
ctx.acceptCallbacks[j].fn(this.modules[moduleId].exports);
}
}
// surface each committed module id to the framework hot-update hook (if one
// is registered). RN's React Refresh can't repaint frameworks that re-wrap
// route components away from the edited module's Refresh family (e.g. One),
// and the web route-update event has no equivalent on the native /hot
// socket, so this generic vxrn global is the bridge.
try {
if (globalThis.__VXRN_ON_MODULE_UPDATED__ && moduleId) globalThis.__VXRN_ON_MODULE_UPDATED__(moduleId);
} catch (e) {}
}
}

Expand Down
Loading