Skip to content

fix(vxrn): alias RN's Metro HMR client to a no-op on native dev (new arch)#734

Merged
natew merged 2 commits into
onejs:mainfrom
YevheniiKotyrlo:fix/vxrn-hmrclient-noop-alias
Jul 16, 2026
Merged

fix(vxrn): alias RN's Metro HMR client to a no-op on native dev (new arch)#734
natew merged 2 commits into
onejs:mainfrom
YevheniiKotyrlo:fix/vxrn-hmrclient-noop-alias

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

On a native dev build (bundler: 'vite'), the new architecture / bridgeless, every source edit pops a red LogBox:

unknown-message [object Object]

It's non-fatal (the app keeps rendering and vxrn's own Fast Refresh still runs), but it fires on every change and papers over the dev experience. It's React Native's built-in Metro HMR client choking on vxrn's hmr:* WebSocket protocol — a client vxrn believes it has disabled, but hasn't on the new arch.

Root cause — the no-op stub loses an emplace race

vxrn drives Fast Refresh itself over the rolldown-runtime WebSocket and never speaks Metro's /hot protocol. To keep RN's Metro HMRClient out of the way, it splices a no-op registerCallableModule("HMRClient", …) into the bundle right before registerCallableModule("AppRegistry", …). On the new arch that registration is silently dropped:

  1. RN registers the real HMRClient first. Libraries/Core/setUpBatchedBridge.js does registerCallableModule('HMRClient', () => require('../Utilities/HMRClient').default), which runs during setUpBatchedBridgebefore the AppRegistry registration the stub is spliced in front of.
  2. On the new arch, registration routes to a C++ map that does not overwrite. registerCallableModuleglobal.RN$registerCallableModuleReactInstance.cpp callableModules_.emplace(...). std::unordered_map::emplace is a no-op when the key already exists — first registration wins. (On the old arch, MessageQueue assigns this._lazyCallableModules[name] = … — last-wins — which is why the splice worked there.)
  3. So the late stub is discarded and RN's real HMRClient.setup() survives. setup() unconditionally opens a MetroHMRClient socket to /hot (the enable/disable/isEnabled flags only gate whether refreshes are applied, not whether the socket opens).
  4. vxrn broadcasts hmr:* to every /hot client, including the Metro one. metro-runtime's WebSocketHMRClient can't parse hmr:*, hits its default branch, and RN's handler formats `${data.type} ${data.message}` = "unknown-message [object Object]" and throws it → the red LogBox.

Fix

Alias react-native/…/Utilities/HMRClient to a no-op module via a resolver plugin (hmrClientNoopPlugin), instead of the emplace-order-dependent bundle-regex splice. RN's own require('../Utilities/HMRClient') then resolves to the no-op, so RN registers the no-op as the first-and-only HMRClient — working with emplace, arch-agnostically. MetroHMRClient is never constructed (and tree-shakes out of the bundle), so it never connects to /hot and the error cannot occur. vxrn's real HMR path (the rolldown-runtime WebSocket) is untouched.

This also lets two now-dead workarounds go, both of which only ever chased the symptom:

  • The bundle-regex splice (createNativeDevEngine.ts) — this is the registration that loses the emplace race, i.e. the root cause itself. Redundant once RN registers the no-op directly.
  • A global ErrorUtils handler in native-prelude.ts that swallowed any error whose message contained 'HMRClient'. The thrown text is unknown-message [object Object] — it contains no 'HMRClient', so this never caught this error; all it did was risk hiding unrelated errors that happen to mention HMRClient. With the alias, no HMRClient error is thrown at all.

Why alias the module (vs. the alternatives)

  • Separate socket paths (move vxrn HMR off /hot) would stop the cross-talk, but leaves RN's real HMRClient instantiated and an idle /hot socket open — it routes around the Metro client instead of removing it, and stays fragile to any future RN/metro that expects a /hot handshake.
  • Server-side filtering can distinguish the clients (vxrn's client sends hmr:* on connect; Metro's sends register-entrypoints) — so it's not impossible — but it's racy (a broadcast before the first frame leaks) and still symptom-level: the Metro client stays alive.
  • Aliasing the module removes the Metro client at its source — it never instantiates, never opens the socket, arch-agnostically. The no-op is interface-complete for RN's actual call sites (setup/enable/disable/registerBundle/log), verified against RN's whole HMRClient import graph (setUpBatchedBridge, loadBundleFromServer, setUpDeveloperTools).

Test plan

  • On device (Android emulator, Hermes, new arch): the unknown-message [object Object] LogBox that appeared on every edit is gone; the app still renders and vxrn's own Fast Refresh still applies.
  • Served bundle inspection: with the alias, the unknown-message string count goes 2 → 0 and new MetroHMRClient → 0 (MetroHMRClient is no longer reachable and tree-shakes out — bundle ~21KB smaller).
  • Reproduced the source: broadcasting a hmr:* frame to a metro WebSocketHMRClient yields exactly {type:'unknown-message', message: …}"unknown-message [object Object]".
  • Unit test (createNativeDevEngine.test.ts): hmrClientNoopPlugin aliases every RN HMRClient specifier (both separators, optional .js/.ts/.cjs ext) to the virtual no-op and leaves near-misses alone (HMRClientRegistry, MyUtilities/HMRClient, setUpDeveloperTools); the loaded module exposes every method RN calls.

Notes

  • New-arch / bridgeless specific — the old arch's last-wins MessageQueue path made the original splice work, which is likely why this regressed silently as the ecosystem moved to bridgeless.
  • Cross-platform — the fix is a resolver alias with no OS-specific logic (the path regex tolerates either separator so native Windows module ids match too).
  • Independent of the "Fast Refresh isn't applied on native" issue (different defect on the same /hot socket): this one is purely why a red error appears.
  • Adjacent cleanup worth flagging: vxrn ships a full drop-in HMR client at runtime/hmr-client.ts that is now dead (nothing imports it) and incompatible with the current outro-driven runtime (its hmr:update handler is a no-op) — a separate deletion, not part of this PR.

On the new architecture, RN registers its real HMRClient via
registerCallableModule during setUpBatchedBridge — before vxrn's
bundle-splice stub runs — and the underlying std::unordered_map::emplace
keeps the first registration. So RN's HMRClient.setup() survives, opens a
MetroHMRClient to /hot, and red-boxes "unknown-message [object Object]" on
every edit (metro's client can't parse vxrn's hmr:* frames).

Alias react-native's Utilities/HMRClient to a no-op module at resolve time
so RN registers the no-op as the first-and-only HMRClient — working with
emplace, arch-agnostically. MetroHMRClient is never constructed (and
tree-shakes out of the bundle), so the socket never opens and the error
cannot occur. vxrn's own HMR (the rolldown-runtime WebSocket) is untouched.

Also removes two now-dead workarounds that only chased the symptom: the
emplace-losing bundle-splice stub, and a global ErrorUtils handler that
matched 'HMRClient' (the thrown text is "unknown-message [object Object]",
so it never caught this error).
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft July 15, 2026 16:42
Cover resolveId (aliases RN Utilities/HMRClient across both separators and
optional js/ts/cjs extension; leaves near-misses like HMRClientRegistry alone)
and load (no-op module exposing every method RN calls). Export the plugin
factory so the test drives it directly.
@natew
natew marked this pull request as ready for review July 16, 2026 00:57
@natew
natew merged commit 15f319d into onejs:main Jul 16, 2026
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