Skip to content

fix(vxrn,one): apply native Fast Refresh to One route components#736

Merged
natew merged 2 commits into
onejs:mainfrom
YevheniiKotyrlo:fix/native-route-fast-refresh
Jul 16, 2026
Merged

fix(vxrn,one): apply native Fast Refresh to One route components#736
natew merged 2 commits into
onejs:mainfrom
YevheniiKotyrlo:fix/native-route-fast-refresh

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

On the Vite-native dev path, editing a source file never updates the running app. A dev build compiles, installs, launches, and renders — but a save to e.g. app/index.tsx doesn't change what's on screen, and a force-stop + relaunch comes back showing the old code. So there's no way to see a change without restarting the whole dev server — the biggest gap in the native dev loop.

Root cause

The server side is correct: on save, the file watcher fires, rolldown recompiles the changed module, and the dev server broadcasts a well-formed { type: 'hmr:update', code } to every /hot client. The failure is entirely client-side, on device — React Refresh runs but doesn't repaint One's route components.

Why React Refresh doesn't repaint them isn't fully pinned, and I don't want to overclaim it. useScreens' ScreenComponent obtains the component via value.loadRoute() and re-wraps it (fromImportforwardRefgetPageExport), which takes the mounted fiber out of the edited module's Refresh family — but the same wrapping runs on web, where leaf routes Fast-Refresh fine. So the native-specific failure more likely also involves vxrn's own React Refresh wiring: the injected outro sets __hmrWS.onmessage and __rolldown_runtime__.setup adds a second message listener, so each patch is eval'd twice — a plausible way to defeat the in-place family swap. That's worth a separate look (see Notes).

Regardless of that root cause, One already ships a route-cache-refresh fallback for exactly the cases React Refresh can't handle (window.__oneRouteCache / hmrVersion / hmrImport) — the web path drives it via the one-hmr-update event. On native three links to drive that same fallback were missing, each independently fatal:

  1. No trigger. vxrn's rolldown dev runtime commits each HMR patch in applyUpdates(boundaries) (inlined in createNativeDevEngine.ts's runtime source) but never surfaced the updated module ids to any framework hook. (The web one-hmr-update event has no equivalent on the native /hot transport — and the native prelude stubs dispatchEvent to a no-op, so it can't.)
  2. No re-render. ScreenComponent never subscribed to a route-hot signal, so nothing re-invoked loadRoute().
  3. Re-import was a stub. hmrImport.native.ts returned Promise.reject(...), so even after cache eviction + re-render, resolve()'s hmrImport(...).catch(() => routesSync[id]()) fell back to the memoized original module → the edit never took effect.

Fix

A three-link chain that drives One's existing route-cache fallback on native, with no web changes:

  1. vxrn — a typed hot-update extension point. applyUpdates now surfaces each committed module id to globalThis.__VXRN_ON_MODULE_UPDATED__(moduleId) — a generic vxrn hook declared next to the existing __VXRN_CUSTOM_HMR_HANDLER__ (guarded; a no-op if no framework registered one). Not One-specific.
  2. one — a dedicated routeHmr.native.ts store. It registers __VXRN_ON_MODULE_UPDATED__ to evict the route cache (window.__oneRouteCache.clearFile, which bumps useViteRoutes' hmrVersion so resolve() re-imports) and bump a subscribable epoch; ScreenComponent subscribes via useSyncExternalStore (dev + native only) so it re-renders and re-runs loadRoute(). A web no-op routeHmr.ts keeps the import platform-agnostic. (This replaces an inline store + import-time side effect that had been sitting in useScreens.tsx, matching the existing hmrImport.ts/.native.ts split.)
  3. onehmrImport.native.ts. Implement the native re-import against vxrn's live rolldown runtime (__rolldown_runtime__.loadExports, which the patch just re-registered), replacing the rejecting stub. Still no dynamic import() (Hermes can't parse it); falls back to a rejected promise when the runtime is absent (e.g. production).

The one-side re-render/re-import is TAMAGUI_TARGET === 'native'-gated, so the web path is untouched (it keeps using the one-hmr-update event).

Verified chain (on device)

edit app/index.tsx
  → applyUpdates([['app/index.tsx', …]])
  → globalThis.__VXRN_ON_MODULE_UPDATED__('app/index.tsx')
  → window.__oneRouteCache.clearFile(...) evicts './index.tsx' + hmrVersion → 1
  → epoch bump → ScreenComponent re-renders
  → value.loadRoute() → resolve() (hmrVersion > 0) → hmrImport('app/index.tsx')
  → __rolldown_runtime__.loadExports('app/index.tsx') → { default: <fresh HomePage> }
  → repaint

Test plan

  • Unit tests (packages/one): hmrImport.native — resolves with the runtime exports, strips ./ and / prefixes, and rejects when the runtime / loadExports / exports are absent or throw; routeHmr.native — registers the hook in dev, bumps the epoch + notifies subscribers (and stops after unsubscribe), and evicts window.__oneRouteCache.clearFile for the updated file (no-op-safe when absent).
  • On device (Android emulator, Hermes, new arch): editing a route file updates the screen live, without a reload (before: no update at all); scripts/e2e-check.sh native-hot-reload FAIL → PASS. This revision (extracting the store + renaming the hook) is behavior-identical to that validated fix, but it touches the HMR runtime, so an on-device re-smoke is worthwhile.

Notes

  • Cross-platform — no Windows-specific logic; the module ids on the wire are always forward-slash.
  • Suspected deeper defect (separate issue): the two competing /hot message handlers eval'ing each patch twice (above) are the more likely reason native React Refresh can't repaint routes in place. Worth investigating on its own — if killing the double-eval restores native RR for routes, both platforms could drop the manual dance. This fix drives One's existing fallback and is independent of that outcome.
  • Out of scope (follow-up): the full getBundle() is memoized per dev-start, so a plain relaunch still serves the dev-start bundle; the incremental path fixed here is what live hot-reload needs.

On the Vite-native dev path, editing a source file never updated the running
app. React Refresh runs on device but can't repaint One's route components:
useScreens re-wraps loadRoute()'s export (fromImport -> forwardRef -> getPageExport),
so the mounted fiber isn't in the edited module's Refresh family. One ships a
route-cache-refresh fallback, but on native three links were missing:

1. vxrn's rolldown runtime commits each HMR patch in applyUpdates() but never
   surfaced the updated module ids to a framework hook (the web one:route-update
   event has no equivalent on the native /hot transport).
2. ScreenComponent never subscribed to a route-hot signal, so nothing re-ran
   loadRoute().
3. hmrImport.native was a rejecting stub, so resolve() fell back to the memoized
   original module.

Fix the whole chain:
- vxrn applyUpdates surfaces each committed boundary id to
  globalThis.__oneRouteHotUpdate (guarded; a no-op if nothing registered it).
- useScreens registers that hook (evict the route cache via
  window.__oneRouteCache.clearFile, which bumps hmrVersion so resolve() re-imports,
  + bump a subscribable epoch) and subscribes ScreenComponent via
  useSyncExternalStore so it re-renders and re-runs loadRoute().
- hmrImport.native re-imports from the live rolldown runtime
  (__rolldown_runtime__.loadExports), replacing the rejecting stub. No dynamic
  import() (Hermes can't parse it); falls back to reject when the runtime is absent.

All one-side changes are gated to TAMAGUI_TARGET === 'native', so the web path
(one-hmr-update) is untouched.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft July 15, 2026 16:42
…date hook

Move One's native route-HMR store out of useScreens.tsx into a dedicated
routeHmr.native.ts (with a web no-op routeHmr.ts), matching the existing
hmrImport.ts/.native.ts platform split -- removes the import-time global side
effect from useScreens and makes the store unit-testable.

Replace the ad-hoc globalThis.__oneRouteHotUpdate with a typed vxrn extension
point, globalThis.__VXRN_ON_MODULE_UPDATED__, declared next to
__VXRN_CUSTOM_HMR_HANDLER__: vxrn's applyUpdates surfaces each committed module id
to it, and any framework (here One) registers a handler. Behavior unchanged. Adds
unit tests for hmrImport.native (loadExports) and the routeHmr.native store.
@natew
natew marked this pull request as ready for review July 16, 2026 00:57
@natew
natew merged commit 1cad586 into onejs:main Jul 16, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants