diff --git a/packages/vxrn/src/runtime/native-prelude.ts b/packages/vxrn/src/runtime/native-prelude.ts index 5337fd34a..c5d7f08d8 100644 --- a/packages/vxrn/src/runtime/native-prelude.ts +++ b/packages/vxrn/src/runtime/native-prelude.ts @@ -75,20 +75,6 @@ if (typeof globalThis.URLSearchParams === 'undefined') { }; } -// suppress HMRClient.setup() error from native side -// native calls HMRClient.setup() during bundle load but we don't use Metro HMR -// this intercept catches the error and prevents the red screen -var __origErrorHandler = globalThis.ErrorUtils && globalThis.ErrorUtils.getGlobalHandler && globalThis.ErrorUtils.getGlobalHandler(); -if (globalThis.ErrorUtils && globalThis.ErrorUtils.setGlobalHandler) { - globalThis.ErrorUtils.setGlobalHandler(function(error, isFatal) { - if (error && error.message && error.message.indexOf('HMRClient') !== -1) { - // suppress HMRClient errors silently - return; - } - if (__origErrorHandler) __origErrorHandler(error, isFatal); - }); -} - // ensure console exists before any library prelude touches it in release bundles globalThis.console = globalThis.console || {}; var console = globalThis.console; diff --git a/packages/vxrn/src/utils/createNativeDevEngine.test.ts b/packages/vxrn/src/utils/createNativeDevEngine.test.ts index 08e6227a0..6bf7252f9 100644 --- a/packages/vxrn/src/utils/createNativeDevEngine.test.ts +++ b/packages/vxrn/src/utils/createNativeDevEngine.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { getNativeTransformConfig, + hmrClientNoopPlugin, wrapNativeBundleModuleScope, } from './createNativeDevEngine' @@ -66,3 +67,55 @@ describe('wrapNativeBundleModuleScope', () => { expect(wrapNativeBundleModuleScope(input)).toBe(input) }) }) + +describe('hmrClientNoopPlugin', () => { + const plugin = hmrClientNoopPlugin() + const resolveId = plugin.resolveId as unknown as (source: string) => any + const load = plugin.load as unknown as (id: string) => any + const VIRTUAL_ID = '\0vxrn-hmr-client-noop' + + it.each([ + 'react-native/Libraries/Utilities/HMRClient', + '../Utilities/HMRClient', + '../../Utilities/HMRClient.js', + // native Windows ids use backslashes + '..\\Utilities\\HMRClient.js', + '../Utilities/HMRClient.ts', + '../Utilities/HMRClient.tsx', + '../Utilities/HMRClient.cjs', + ])('aliases RN HMRClient specifier %j to the no-op virtual module', (source) => { + expect(resolveId(source)).toEqual({ id: VIRTUAL_ID, external: false }) + }) + + it.each([ + // trailing letters (no boundary) must not match + 'react-native/Libraries/Utilities/HMRClientRegistry', + // Utilities must sit at a path boundary + 'some/MyUtilities/HMRClient', + // unrelated RN modules + 'react-native/Libraries/Core/setUpDeveloperTools', + 'react', + ])('does not touch unrelated specifier %j', (source) => { + expect(resolveId(source)).toBeUndefined() + }) + + it('loads a no-op module exposing every HMRClient method RN calls', () => { + const result = load(VIRTUAL_ID) + expect(result?.moduleType).toBe('js') + for (const method of [ + 'setup', + 'enable', + 'disable', + 'registerBundle', + 'log', + 'isEnabled', + ]) { + expect(result!.code).toContain(method) + } + expect(result!.code).toContain('export default HMRClient') + }) + + it('does not load unrelated ids', () => { + expect(load('\0some-other-virtual')).toBeUndefined() + }) +}) diff --git a/packages/vxrn/src/utils/createNativeDevEngine.ts b/packages/vxrn/src/utils/createNativeDevEngine.ts index 21fcdcb72..c9ad01553 100644 --- a/packages/vxrn/src/utils/createNativeDevEngine.ts +++ b/packages/vxrn/src/utils/createNativeDevEngine.ts @@ -191,6 +191,10 @@ function getNativePlugins( serverFileExclusionPlugin(), // guard server-only / client-only / web-only / native-only imports environmentGuardPlugin(), + // alias RN's Metro HMR client to a no-op — vxrn drives HMR itself (the + // rolldown-runtime WebSocket); RN's client otherwise opens a /hot socket and + // red-boxes "unknown-message [object Object]" on every edit (new arch) + hmrClientNoopPlugin(), // stub CSS imports — native doesn't support CSS and rolldown removed CSS bundling cssStubPlugin(), // handle import.meta.glob (used by One's route system) @@ -470,14 +474,6 @@ try { // skipped by the per-file SWC plugin) so old Hermes can parse them code = await downlevelClassFieldsInBundle(code) - // register a no-op HMRClient so RN's native side doesn't error when calling HMRClient.setup() - // our actual HMR is handled via the outro WebSocket connection - const hmrClientStub = `registerCallableModule("HMRClient",{setup:function(){},enable:function(){},disable:function(){},registerBundle:function(){},log:function(){}})` - code = code.replace( - /registerCallableModule\s*\(\s*["']AppRegistry["']/, - (match) => hmrClientStub + ',' + match - ) - // wrap module code in a function scope so top-level `var`s (e.g. RN // fetch.js's `Headers`/`Request`) don't leak as non-configurable // globals and break RN's polyfillGlobal (dev-only redbox). see fn doc. @@ -792,6 +788,45 @@ function environmentGuardPlugin(): Plugin { } } +/** + * Alias react-native's Metro HMR client (`Libraries/Utilities/HMRClient`) to a + * no-op module. + * + * vxrn drives Fast Refresh itself over the rolldown-runtime WebSocket and never + * speaks Metro's `/hot` protocol. On the new architecture, react-native + * `registerCallableModule('HMRClient', require('./HMRClient'))`s its real client + * eagerly at startup — before vxrn's late override runs — and `emplace` keeps + * that first registration. RN's client then opens a `MetroHMRClient` socket that + * receives vxrn's `hmr:*` frames it can't parse and red-boxes + * `unknown-message [object Object]` on every edit. + * + * Neutralizing the module at its source means RN registers *this* no-op as the + * one-and-only `HMRClient` (working WITH `emplace`, so it's arch-agnostic) and + * the stray socket is never opened. The class-shaped surface + * (`setup`/`enable`/`disable`/`registerBundle`/`log`/`isEnabled`) mirrors the + * methods RN calls on it. + */ +export function hmrClientNoopPlugin(): Plugin { + // Match RN's HMRClient by module path, tolerating either separator (native + // Windows ids use `\`) and an optional js/ts extension. + const RN_HMR_CLIENT_RE = /(^|[\\/])Utilities[\\/]HMRClient(\.[cm]?[jt]sx?)?$/ + return { + name: 'vxrn:hmr-client-noop', + resolveId(source) { + if (RN_HMR_CLIENT_RE.test(source)) + return { id: '\0vxrn-hmr-client-noop', external: false } + }, + load(id) { + if (id === '\0vxrn-hmr-client-noop') { + return { + code: `const HMRClient = { setup() {}, enable() {}, disable() {}, registerBundle() {}, log() {}, isEnabled() { return false } }\nexport default HMRClient`, + moduleType: 'js' as any, + } + } + }, + } +} + /** * Stub CSS imports for native builds. * Native doesn't support CSS and rolldown removed CSS bundling support.