fix(vxrn,one): apply native Fast Refresh to One route components#736
Merged
Conversation
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
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
marked this pull request as ready for review
July 16, 2026 00:57
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsxdoesn'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/hotclient. 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'ScreenComponentobtains the component viavalue.loadRoute()and re-wraps it (fromImport→forwardRef→getPageExport), 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.onmessageand__rolldown_runtime__.setupadds a secondmessagelistener, 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 theone-hmr-updateevent. On native three links to drive that same fallback were missing, each independently fatal:applyUpdates(boundaries)(inlined increateNativeDevEngine.ts's runtime source) but never surfaced the updated module ids to any framework hook. (The webone-hmr-updateevent has no equivalent on the native/hottransport — and the native prelude stubsdispatchEventto a no-op, so it can't.)ScreenComponentnever subscribed to a route-hot signal, so nothing re-invokedloadRoute().hmrImport.native.tsreturnedPromise.reject(...), so even after cache eviction + re-render,resolve()'shmrImport(...).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:
vxrn— a typed hot-update extension point.applyUpdatesnow surfaces each committed module id toglobalThis.__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.one— a dedicatedrouteHmr.native.tsstore. It registers__VXRN_ON_MODULE_UPDATED__to evict the route cache (window.__oneRouteCache.clearFile, which bumpsuseViteRoutes'hmrVersionsoresolve()re-imports) and bump a subscribable epoch;ScreenComponentsubscribes viauseSyncExternalStore(dev + native only) so it re-renders and re-runsloadRoute(). A web no-oprouteHmr.tskeeps the import platform-agnostic. (This replaces an inline store + import-time side effect that had been sitting inuseScreens.tsx, matching the existinghmrImport.ts/.native.tssplit.)one—hmrImport.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 dynamicimport()(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 isTAMAGUI_TARGET === 'native'-gated, so the web path is untouched (it keeps using theone-hmr-updateevent).Verified chain (on device)
Test plan
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 evictswindow.__oneRouteCache.clearFilefor the updated file (no-op-safe when absent).scripts/e2e-check.shnative-hot-reloadFAIL → 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
/hotmessage 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.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.