diff --git a/.nx/version-plans/version-plan-1767023508983.md b/.nx/version-plans/version-plan-1767023508983.md new file mode 100644 index 0000000..b498fc6 --- /dev/null +++ b/.nx/version-plans/version-plan-1767023508983.md @@ -0,0 +1,5 @@ +--- +default: prerelease +--- + +The module mocking system has been rewritten to improve compatibility with different versions of React Native. Instead of fully overwriting Metro's module system, the new implementation surgically redirects responsibility for imports to Harness, allowing for better integration with various React Native versions while maintaining the same mocking capabilities. The module mocking API has been slightly modified as part of this rewrite. diff --git a/apps/playground/src/__tests__/mocking/modules.harness.ts b/apps/playground/src/__tests__/mocking/modules.harness.ts index b51b0a7..1f8fb27 100644 --- a/apps/playground/src/__tests__/mocking/modules.harness.ts +++ b/apps/playground/src/__tests__/mocking/modules.harness.ts @@ -7,14 +7,18 @@ import { unmock, requireActual, fn, - clearMocks, resetModules, } from 'react-native-harness'; describe('Module mocking', () => { afterEach(() => { - // Clean up mocks after each test - clearMocks(); + resetModules(); + }); + + it('should not interfere with modules that are not mocked', () => { + const moduleA = require('react-native'); + const moduleB = require('react-native'); + expect(moduleA === moduleB).toBe(true); }); it('should completely mock a module and return mock implementation', () => { @@ -133,21 +137,4 @@ describe('Module mocking', () => { const newNow = require('react-native').now; expect(newNow).not.toBe(oldNow); }); - - it('should unmock all modules when clearMocks is called', () => { - // Mock a module - const mockFactory = () => ({ mockProperty: 'mocked' }); - mock('react-native', mockFactory); - - // Verify it's mocked - const module = require('react-native'); - expect(module.mockProperty).toBe('mocked'); - - // Unmock all modules - clearMocks(); - - // Verify it's back to actual - const actualModule = require('react-native'); - expect(actualModule).not.toHaveProperty('mockProperty'); - }); }); diff --git a/packages/metro/src/moduleSystem.ts b/packages/metro/src/moduleSystem.ts deleted file mode 100644 index 4daf4c5..0000000 --- a/packages/metro/src/moduleSystem.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { CouldNotPatchModuleSystemError } from './errors'; - -const optionalResolve = (path: string, from: string): string | null => { - try { - return require.resolve(path, { paths: [from] }); - } catch { - return null; - } -}; - -const getMetroConfigPath = (): string => { - const expoConfigPath = optionalResolve('@expo/metro-config', process.cwd()); - - if (expoConfigPath) { - return expoConfigPath; - } - - const reactNativeMetroConfigPath = optionalResolve( - '@react-native/metro-config', - process.cwd() - ); - - if (reactNativeMetroConfigPath) { - return reactNativeMetroConfigPath; - } - - throw new CouldNotPatchModuleSystemError(); -}; - -const getMetroDefaultsPath = (): string => { - const metroConfigPath = getMetroConfigPath(); - - const preExportsDefaults = optionalResolve( - 'metro-config/src/defaults/defaults', - metroConfigPath - ); - - if (preExportsDefaults) { - return preExportsDefaults; - } - - const privateDefaults = optionalResolve( - 'metro-config/private/defaults/defaults', - metroConfigPath - ); - - if (privateDefaults) { - return privateDefaults; - } - - throw new CouldNotPatchModuleSystemError(); -}; - -export const patchModuleSystem = (): void => { - const metroConfigPath = getMetroDefaultsPath(); - const metroConfig = require(metroConfigPath); - - metroConfig.moduleSystem = require.resolve( - '@react-native-harness/runtime/moduleSystem' - ); -}; diff --git a/packages/metro/src/withRnHarness.ts b/packages/metro/src/withRnHarness.ts index 10195e7..d7c68f7 100644 --- a/packages/metro/src/withRnHarness.ts +++ b/packages/metro/src/withRnHarness.ts @@ -1,6 +1,5 @@ import type { MetroConfig } from 'metro-config'; import { getConfig } from '@react-native-harness/config'; -import { patchModuleSystem } from './moduleSystem'; import { getHarnessResolver } from './resolver'; import { getHarnessManifest } from './manifest'; import { getHarnessBabelTransformerPath } from './babel-transformer'; @@ -25,8 +24,6 @@ export const withRnHarness = ( const metroConfig = await config; const { config: harnessConfig } = await getConfig(process.cwd()); - patchModuleSystem(); - const harnessResolver = getHarnessResolver(metroConfig, harnessConfig); const harnessManifest = getHarnessManifest(harnessConfig); const harnessBabelTransformerPath = @@ -40,6 +37,9 @@ export const withRnHarness = ( getPolyfills: (...args) => [ ...(metroConfig.serializer?.getPolyfills?.(...args) ?? []), harnessManifest, + require.resolve( + '@react-native-harness/runtime/polyfills/harness-module-system' + ), ], isThirdPartyModule({ path: modulePath }) { const isThirdPartyByDefault = diff --git a/packages/runtime/assets/harness-module-system.js b/packages/runtime/assets/harness-module-system.js new file mode 100644 index 0000000..153b706 --- /dev/null +++ b/packages/runtime/assets/harness-module-system.js @@ -0,0 +1,73 @@ +// @ts-nocheck +/* eslint-disable */ + +// This file is a polyfill that monkey-patches the Metro module system +// to allow capturing nested require calls. + +(function (globalObject) { + const myRequire = function (id) { + return globalObject.__r(id); + }; + + const myImportDefault = function (id) { + return globalObject.__r.importDefault(id); + }; + + const myImportAll = function (id) { + return globalObject.__r.importAll(id); + }; + + // Monkey-patch define + const originalDefine = globalObject.__d; + globalObject.__d = function (factory, moduleId, dependencyMap) { + const wrappedFactory = function (...args) { + // Standard Metro with import support (7 arguments) + // args: global, require, importDefault, importAll, module, exports, dependencyMap + const global = args[0]; + const moduleObject = args[4]; + const exports = args[5]; + const depMap = args[6]; + + return factory( + global, + myRequire, + myImportDefault, + myImportAll, + moduleObject, + exports, + depMap + ); + }; + + // Call the original define with the wrapped factory + return originalDefine.call(this, wrappedFactory, moduleId, dependencyMap); + }; + + globalObject.__resetModule = function (moduleId) { + const module = globalObject.__r.getModules().get(moduleId); + + if (!module) { + return; + } + + module.hasError = false; + module.error = undefined; + module.isInitialized = false; + }; + + globalObject.__resetModules = function () { + const modules = globalObject.__r.getModules(); + + modules.forEach(function (mod, moduleId) { + globalObject.__resetModule(moduleId); + }); + }; +})( + typeof globalThis !== 'undefined' + ? globalThis + : typeof global !== 'undefined' + ? global + : typeof window !== 'undefined' + ? window + : this +); diff --git a/packages/runtime/assets/moduleSystem.flow.js b/packages/runtime/assets/moduleSystem.flow.js deleted file mode 100644 index ff9f74f..0000000 --- a/packages/runtime/assets/moduleSystem.flow.js +++ /dev/null @@ -1,1082 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - * @format - * @oncall react_native - * @polyfill - */ - -'use strict'; - -/* eslint-disable no-bitwise */ - -declare var __DEV__: boolean; -declare var __METRO_GLOBAL_PREFIX__: string; - -// A simpler $ArrayLike. Not iterable and doesn't have a `length`. -// This is compatible with actual arrays as well as with objects that look like -// {0: 'value', 1: '...'} -type ArrayIndexable = interface { - +[indexer: number]: T, -}; -type DependencyMap = $ReadOnly< - ArrayIndexable & { - paths?: { [id: ModuleID]: string }, - } ->; -type InverseDependencyMap = { [key: ModuleID]: Array, ... }; -type Exports = any; -type FactoryFn = ( - global: Object, - require: RequireFn, - metroImportDefault: RequireFn, - metroImportAll: RequireFn, - moduleObject: { exports: { ... }, ... }, - exports: { ... }, - dependencyMap: ?DependencyMap -) => void; -type HotModuleReloadingCallback = () => void; -type HotModuleReloadingData = { - _acceptCallback: ?HotModuleReloadingCallback, - _disposeCallback: ?HotModuleReloadingCallback, - _didAccept: boolean, - accept: (callback?: HotModuleReloadingCallback) => void, - dispose: (callback?: HotModuleReloadingCallback) => void, -}; -type ModuleID = number; -type Module = { - id?: ModuleID, - exports: Exports, - hot?: HotModuleReloadingData, - ... -}; -type ModuleDefinition = { - dependencyMap: ?DependencyMap, - error?: any, - factory: FactoryFn, - hasError: boolean, - hot?: HotModuleReloadingData, - importedAll: any, - importedDefault: any, - isInitialized: boolean, - path?: string, - publicModule: Module, - verboseName?: string, -}; -type ModuleList = Map; -export type RequireFn = (id: ModuleID | VerboseModuleNameForDev) => Exports; -export type DefineFn = ( - factory: FactoryFn, - moduleId: number, - dependencyMap?: DependencyMap, - verboseName?: string, - inverseDependencies?: InverseDependencyMap -) => void; - -type VerboseModuleNameForDev = string; -type ModuleDefiner = (moduleId: ModuleID) => void; - -global.__r = (metroRequire: RequireFn); -global[`${__METRO_GLOBAL_PREFIX__}__d`] = (define: DefineFn); -global.__c = clear; -global.__registerSegment = registerSegment; -global.__resetAllModules = resetAllModules; -global.__clearModule = clearModule; - -var modules = clear(); - -function resetAllModules() { - modules.forEach((mod) => { - // Mutating existing module doesn't work for some reason - modules.set(mod.id, { ...mod, isInitialized: false }); - }); -} - -function clearModule(moduleId: ModuleID) { - if (!modules.has(moduleId)) { - return; - } - - modules.delete(moduleId); -} - -// Don't use a Symbol here, it would pull in an extra polyfill with all sorts of -// additional stuff (e.g. Array.from). -const EMPTY = {}; -const CYCLE_DETECTED = {}; -const { hasOwnProperty } = {}; - -if (__DEV__) { - global.$RefreshReg$ = global.$RefreshReg$ ?? (() => {}); - global.$RefreshSig$ = global.$RefreshSig$ ?? (() => (type) => type); -} - -function clear(): ModuleList { - modules = new Map(); - - // We return modules here so that we can assign an initial value to modules - // when defining it. Otherwise, we would have to do "let modules = null", - // which will force us to add "nullthrows" everywhere. - return modules; -} - -if (__DEV__) { - var verboseNamesToModuleIds: Map = new Map(); - var getModuleIdForVerboseName = (verboseName: string): number => { - const moduleId = verboseNamesToModuleIds.get(verboseName); - if (moduleId == null) { - throw new Error(`Unknown named module: "${verboseName}"`); - } - return moduleId; - }; - var initializingModuleIds: Array = []; -} - -function define( - factory: FactoryFn, - moduleId: number, - dependencyMap?: DependencyMap -): void { - if (modules.has(moduleId)) { - if (__DEV__) { - // (We take `inverseDependencies` from `arguments` to avoid an unused - // named parameter in `define` in production. - const inverseDependencies = arguments[4]; - - // If the module has already been defined and the define method has been - // called with inverseDependencies, we can hot reload it. - if (inverseDependencies) { - global.__accept(moduleId, factory, dependencyMap, inverseDependencies); - } - } - - // prevent repeated calls to `global.nativeRequire` to overwrite modules - // that are already loaded - return; - } - - const mod: ModuleDefinition = { - dependencyMap, - factory, - hasError: false, - importedAll: EMPTY, - importedDefault: EMPTY, - isInitialized: false, - publicModule: { exports: {} }, - }; - - modules.set(moduleId, mod); - - if (__DEV__) { - // HMR - mod.hot = createHotReloadingObject(); - - // DEBUGGABLE MODULES NAMES - // we take `verboseName` from `arguments` to avoid an unused named parameter - // in `define` in production. - const verboseName: string | void = arguments[3]; - if (verboseName) { - mod.verboseName = verboseName; - verboseNamesToModuleIds.set(verboseName, moduleId); - } - } -} - -function metroRequire( - moduleId: ModuleID | VerboseModuleNameForDev | null, - maybeNameForDev?: string -): Exports { - // Unresolved optional dependencies are nulls in dependency maps - // eslint-disable-next-line lint/strictly-null - if (moduleId === null) { - if (__DEV__ && typeof maybeNameForDev === 'string') { - throw new Error("Cannot find module '" + maybeNameForDev + "'"); - } - throw new Error('Cannot find module'); - } - - if (__DEV__ && typeof moduleId === 'string') { - const verboseName = moduleId; - moduleId = getModuleIdForVerboseName(verboseName); - console.warn( - `Requiring module "${verboseName}" by name is only supported for ` + - 'debugging purposes and will BREAK IN PRODUCTION!' - ); - } - - //$FlowFixMe: at this point we know that moduleId is a number - const moduleIdReallyIsNumber: number = moduleId; - - if (__DEV__) { - const initializingIndex = initializingModuleIds.indexOf( - moduleIdReallyIsNumber - ); - if (initializingIndex !== -1) { - const cycle = initializingModuleIds - .slice(initializingIndex) - .map((id: number) => modules.get(id)?.verboseName ?? '[unknown]'); - if (shouldPrintRequireCycle(cycle)) { - cycle.push(cycle[0]); // We want to print A -> B -> A: - console.warn( - `Require cycle: ${cycle.join(' -> ')}\n\n` + - 'Require cycles are allowed, but can result in uninitialized values. ' + - 'Consider refactoring to remove the need for a cycle.' - ); - } - } - } - - const module = modules.get(moduleIdReallyIsNumber); - - return module && module.isInitialized - ? module.publicModule.exports - : guardedLoadModule(moduleIdReallyIsNumber, module); -} - -// We print require cycles unless they match a pattern in the -// `requireCycleIgnorePatterns` configuration. -function shouldPrintRequireCycle(modules: $ReadOnlyArray): boolean { - const regExps = - global[__METRO_GLOBAL_PREFIX__ + '__requireCycleIgnorePatterns']; - if (!Array.isArray(regExps)) { - return true; - } - - const isIgnored = (module: ?string) => - module != null && regExps.some((regExp) => regExp.test(module)); - - // Print the cycle unless any part of it is ignored - return modules.every((module) => !isIgnored(module)); -} - -function metroImportDefault( - moduleId: ModuleID | VerboseModuleNameForDev -): any | Exports { - if (__DEV__ && typeof moduleId === 'string') { - const verboseName = moduleId; - moduleId = getModuleIdForVerboseName(verboseName); - } - - //$FlowFixMe: at this point we know that moduleId is a number - const moduleIdReallyIsNumber: number = moduleId; - - const maybeInitializedModule = modules.get(moduleIdReallyIsNumber); - - if ( - maybeInitializedModule && - maybeInitializedModule.importedDefault !== EMPTY - ) { - return maybeInitializedModule.importedDefault; - } - - const exports: Exports = global.__r(moduleIdReallyIsNumber); - const importedDefault: any | Exports = - exports && exports.__esModule ? exports.default : exports; - - // $FlowFixMe[incompatible-type] The `metroRequire` call above would have thrown if modules[id] was null - const initializedModule: ModuleDefinition = modules.get( - moduleIdReallyIsNumber - ); - return (initializedModule.importedDefault = importedDefault); -} -metroRequire.importDefault = metroImportDefault; - -function metroImportAll( - moduleId: ModuleID | VerboseModuleNameForDev | number -): any | Exports | { [string]: any } { - if (__DEV__ && typeof moduleId === 'string') { - const verboseName = moduleId; - moduleId = getModuleIdForVerboseName(verboseName); - } - - //$FlowFixMe: at this point we know that moduleId is a number - const moduleIdReallyIsNumber: number = moduleId; - - const maybeInitializedModule = modules.get(moduleIdReallyIsNumber); - - if (maybeInitializedModule && maybeInitializedModule.importedAll !== EMPTY) { - return maybeInitializedModule.importedAll; - } - - const exports: Exports = global.__r(moduleIdReallyIsNumber); - let importedAll: Exports | { [string]: any }; - - if (exports && exports.__esModule) { - importedAll = exports; - } else { - importedAll = ({}: { [string]: any }); - - // Refrain from using Object.assign, it has to work in ES3 environments. - if (exports) { - for (const key: string in exports) { - if (hasOwnProperty.call(exports, key)) { - importedAll[key] = exports[key]; - } - } - } - - importedAll.default = exports; - } - - // $FlowFixMe[incompatible-type] The `metroRequire` call above would have thrown if modules[id] was null - const initializedModule: ModuleDefinition = modules.get( - moduleIdReallyIsNumber - ); - return (initializedModule.importedAll = importedAll); -} -metroRequire.importAll = metroImportAll; - -// The `require.context()` syntax is never executed in the runtime because it is converted -// to `require()` in `metro/src/ModuleGraph/worker/collectDependencies.js` after collecting -// dependencies. If the feature flag is not enabled then the conversion never takes place and this error is thrown (development only). -metroRequire.context = function fallbackRequireContext() { - if (__DEV__) { - throw new Error( - 'The experimental Metro feature `require.context` is not enabled in your project.\nThis can be enabled by setting the `transformer.unstable_allowRequireContext` property to `true` in your Metro configuration.' - ); - } - throw new Error( - 'The experimental Metro feature `require.context` is not enabled in your project.' - ); -}; - -// `require.resolveWeak()` is a compile-time primitive (see collectDependencies.js) -metroRequire.resolveWeak = function fallbackRequireResolveWeak() { - if (__DEV__) { - throw new Error( - 'require.resolveWeak cannot be called dynamically. Ensure you are using the same version of `metro` and `metro-runtime`.' - ); - } - throw new Error('require.resolveWeak cannot be called dynamically.'); -}; - -let inGuard = false; -function guardedLoadModule( - moduleId: ModuleID, - module: ?ModuleDefinition -): Exports { - if (!inGuard && global.ErrorUtils) { - inGuard = true; - let returnValue; - try { - returnValue = loadModuleImplementation(moduleId, module); - } catch (e) { - // TODO: (moti) T48204692 Type this use of ErrorUtils. - global.ErrorUtils.reportFatalError(e); - } - inGuard = false; - return returnValue; - } else { - return loadModuleImplementation(moduleId, module); - } -} - -const ID_MASK_SHIFT = 16; -const LOCAL_ID_MASK = ~0 >>> ID_MASK_SHIFT; - -function unpackModuleId(moduleId: ModuleID): { - localId: number, - segmentId: number, - ... -} { - const segmentId = moduleId >>> ID_MASK_SHIFT; - const localId = moduleId & LOCAL_ID_MASK; - return { segmentId, localId }; -} -metroRequire.unpackModuleId = unpackModuleId; - -function packModuleId(value: { - localId: number, - segmentId: number, - ... -}): ModuleID { - return (value.segmentId << ID_MASK_SHIFT) + value.localId; -} -metroRequire.packModuleId = packModuleId; - -const moduleDefinersBySegmentID: Array = []; -const definingSegmentByModuleID: Map = new Map(); - -function registerSegment( - segmentId: number, - moduleDefiner: ModuleDefiner, - moduleIds: ?$ReadOnlyArray -): void { - moduleDefinersBySegmentID[segmentId] = moduleDefiner; - if (__DEV__) { - if (segmentId === 0 && moduleIds) { - throw new Error( - 'registerSegment: Expected moduleIds to be null for main segment' - ); - } - if (segmentId !== 0 && !moduleIds) { - throw new Error( - 'registerSegment: Expected moduleIds to be passed for segment #' + - segmentId - ); - } - } - if (moduleIds) { - moduleIds.forEach((moduleId) => { - if (!modules.has(moduleId) && !definingSegmentByModuleID.has(moduleId)) { - definingSegmentByModuleID.set(moduleId, segmentId); - } - }); - } -} - -function loadModuleImplementation( - moduleId: ModuleID, - module: ?ModuleDefinition -): Exports { - if (!module && moduleDefinersBySegmentID.length > 0) { - const segmentId = definingSegmentByModuleID.get(moduleId) ?? 0; - const definer = moduleDefinersBySegmentID[segmentId]; - if (definer != null) { - definer(moduleId); - module = modules.get(moduleId); - definingSegmentByModuleID.delete(moduleId); - } - } - - const nativeRequire = global.nativeRequire; - if (!module && nativeRequire) { - const { segmentId, localId } = unpackModuleId(moduleId); - nativeRequire(localId, segmentId); - module = modules.get(moduleId); - } - - if (!module) { - throw unknownModuleError(moduleId); - } - - if (module.hasError) { - throw module.error; - } - - if (__DEV__) { - var Systrace = requireSystrace(); - var Refresh = requireRefresh(); - } - - // We must optimistically mark module as initialized before running the - // factory to keep any require cycles inside the factory from causing an - // infinite require loop. - module.isInitialized = true; - - const { factory, dependencyMap } = module; - if (__DEV__) { - initializingModuleIds.push(moduleId); - } - try { - if (__DEV__) { - // $FlowFixMe: we know that __DEV__ is const and `Systrace` exists - Systrace.beginEvent('JS_require_' + (module.verboseName || moduleId)); - } - - const moduleObject: Module = module.publicModule; - - if (__DEV__) { - moduleObject.hot = module.hot; - - var prevRefreshReg = global.$RefreshReg$; - var prevRefreshSig = global.$RefreshSig$; - if (Refresh != null) { - const RefreshRuntime = Refresh; - global.$RefreshReg$ = (type, id) => { - // prefix the id with global prefix to enable multiple HMR clients - const prefixedModuleId = - __METRO_GLOBAL_PREFIX__ + ' ' + moduleId + ' ' + id; - RefreshRuntime.register(type, prefixedModuleId); - }; - global.$RefreshSig$ = - RefreshRuntime.createSignatureFunctionForTransform; - } - } - moduleObject.id = moduleId; - - // keep args in sync with with defineModuleCode in - // metro/src/Resolver/index.js - // and metro/src/ModuleGraph/worker.js - const capturedRequire = (...args) => global.__r(...args); - Object.assign(capturedRequire, global.__r); - - factory( - global, - capturedRequire, - capturedRequire.importDefault, - capturedRequire.importAll, - moduleObject, - moduleObject.exports, - dependencyMap - ); - - // avoid removing factory in DEV mode as it breaks HMR - if (!__DEV__) { - // $FlowFixMe: This is only sound because we never access `factory` again - module.factory = undefined; - module.dependencyMap = undefined; - } - - if (__DEV__) { - // $FlowFixMe: we know that __DEV__ is const and `Systrace` exists - Systrace.endEvent(); - - if (Refresh != null) { - // prefix the id with global prefix to enable multiple HMR clients - const prefixedModuleId = __METRO_GLOBAL_PREFIX__ + ' ' + moduleId; - registerExportsForReactRefresh( - Refresh, - moduleObject.exports, - prefixedModuleId - ); - } - } - - return moduleObject.exports; - } catch (e) { - module.hasError = true; - module.error = e; - module.isInitialized = false; - module.publicModule.exports = undefined; - throw e; - } finally { - if (__DEV__) { - if (initializingModuleIds.pop() !== moduleId) { - throw new Error( - 'initializingModuleIds is corrupt; something is terribly wrong' - ); - } - global.$RefreshReg$ = prevRefreshReg; - global.$RefreshSig$ = prevRefreshSig; - } - } -} - -function unknownModuleError(id: ModuleID): Error { - let message = 'Requiring unknown module "' + id + '".'; - if (__DEV__) { - message += - ' If you are sure the module exists, try restarting Metro. ' + - 'You may also want to run `yarn` or `npm install`.'; - } - return Error(message); -} - -if (__DEV__) { - // $FlowFixMe[prop-missing] - metroRequire.Systrace = { - beginEvent: (): void => {}, - endEvent: (): void => {}, - }; - // $FlowFixMe[prop-missing] - metroRequire.getModules = (): ModuleList => { - return modules; - }; - - // HOT MODULE RELOADING - var createHotReloadingObject = function () { - const hot: HotModuleReloadingData = { - _acceptCallback: null, - _disposeCallback: null, - _didAccept: false, - accept: (callback?: HotModuleReloadingCallback): void => { - hot._didAccept = true; - hot._acceptCallback = callback; - }, - dispose: (callback?: HotModuleReloadingCallback): void => { - hot._disposeCallback = callback; - }, - }; - return hot; - }; - - let reactRefreshTimeout: null | TimeoutID = null; - - const metroHotUpdateModule = function ( - id: ModuleID, - factory: FactoryFn, - dependencyMap: DependencyMap, - inverseDependencies: InverseDependencyMap - ) { - const mod = modules.get(id); - if (!mod) { - /* $FlowFixMe[constant-condition] Error discovered during Constant - * Condition roll out. See https://fburl.com/workplace/1v97vimq. */ - if (factory) { - // New modules are going to be handled by the define() method. - return; - } - throw unknownModuleError(id); - } - - if (!mod.hasError && !mod.isInitialized) { - // The module hasn't actually been executed yet, - // so we can always safely replace it. - mod.factory = factory; - mod.dependencyMap = dependencyMap; - return; - } - - const Refresh = requireRefresh(); - const refreshBoundaryIDs = new Set(); - - // In this loop, we will traverse the dependency tree upwards from the - // changed module. Updates "bubble" up to the closest accepted parent. - // - // If we reach the module root and nothing along the way accepted the update, - // we know hot reload is going to fail. In that case we return false. - // - // The main purpose of this loop is to figure out whether it's safe to apply - // a hot update. It is only safe when the update was accepted somewhere - // along the way upwards for each of its parent dependency module chains. - // - // We perform a topological sort because we may discover the same - // module more than once in the list of things to re-execute, and - // we want to execute modules before modules that depend on them. - // - // If we didn't have this check, we'd risk re-evaluating modules that - // have side effects and lead to confusing and meaningless crashes. - - let didBailOut = false; - let updatedModuleIDs; - try { - updatedModuleIDs = topologicalSort( - [id], // Start with the changed module and go upwards - (pendingID) => { - const pendingModule = modules.get(pendingID); - if (pendingModule == null) { - // Nothing to do. - return []; - } - const pendingHot = pendingModule.hot; - if (pendingHot == null) { - throw new Error( - '[Refresh] Expected module.hot to always exist in DEV.' - ); - } - // A module can be accepted manually from within itself. - let canAccept = pendingHot._didAccept; - if (!canAccept && Refresh != null) { - // Or React Refresh may mark it accepted based on exports. - const isBoundary = isReactRefreshBoundary( - Refresh, - pendingModule.publicModule.exports - ); - if (isBoundary) { - canAccept = true; - refreshBoundaryIDs.add(pendingID); - } - } - if (canAccept) { - // Don't look at parents. - return []; - } - // If we bubble through the roof, there is no way to do a hot update. - // Bail out altogether. This is the failure case. - const parentIDs = inverseDependencies[pendingID]; - if (parentIDs.length === 0) { - // Reload the app because the hot reload can't succeed. - // This should work both on web and React Native. - performFullRefresh('No root boundary', { - source: mod, - failed: pendingModule, - }); - didBailOut = true; - return []; - } - // This module can't handle the update but maybe all its parents can? - // Put them all in the queue to run the same set of checks. - return parentIDs; - }, - () => didBailOut // Should we stop? - ).reverse(); - } catch (e) { - if (e === CYCLE_DETECTED) { - performFullRefresh('Dependency cycle', { - source: mod, - }); - return; - } - throw e; - } - - if (didBailOut) { - return; - } - - // If we reached here, it is likely that hot reload will be successful. - // Run the actual factories. - const seenModuleIDs = new Set(); - for (let i = 0; i < updatedModuleIDs.length; i++) { - const updatedID = updatedModuleIDs[i]; - if (seenModuleIDs.has(updatedID)) { - continue; - } - seenModuleIDs.add(updatedID); - - const updatedMod = modules.get(updatedID); - if (updatedMod == null) { - throw new Error('[Refresh] Expected to find the updated module.'); - } - const prevExports = updatedMod.publicModule.exports; - const didError = runUpdatedModule( - updatedID, - updatedID === id ? factory : undefined, - updatedID === id ? dependencyMap : undefined - ); - const nextExports = updatedMod.publicModule.exports; - - if (didError) { - // The user was shown a redbox about module initialization. - // There's nothing for us to do here until it's fixed. - return; - } - - if (refreshBoundaryIDs.has(updatedID)) { - // Since we just executed the code for it, it's possible - // that the new exports make it ineligible for being a boundary. - const isNoLongerABoundary = !isReactRefreshBoundary( - Refresh, - nextExports - ); - // It can also become ineligible if its exports are incompatible - // with the previous exports. - // For example, if you add/remove/change exports, we'll want - // to re-execute the importing modules, and force those components - // to re-render. Similarly, if you convert a class component - // to a function, we want to invalidate the boundary. - const didInvalidate = shouldInvalidateReactRefreshBoundary( - Refresh, - prevExports, - nextExports - ); - if (isNoLongerABoundary || didInvalidate) { - // We'll be conservative. The only case in which we won't do a full - // reload is if all parent modules are also refresh boundaries. - // In that case we'll add them to the current queue. - const parentIDs = inverseDependencies[updatedID]; - if (parentIDs.length === 0) { - // Looks like we bubbled to the root. Can't recover from that. - performFullRefresh( - isNoLongerABoundary - ? 'No longer a boundary' - : 'Invalidated boundary', - { - source: mod, - failed: updatedMod, - } - ); - return; - } - // Schedule all parent refresh boundaries to re-run in this loop. - for (let j = 0; j < parentIDs.length; j++) { - const parentID = parentIDs[j]; - const parentMod = modules.get(parentID); - if (parentMod == null) { - throw new Error('[Refresh] Expected to find parent module.'); - } - const canAcceptParent = isReactRefreshBoundary( - Refresh, - parentMod.publicModule.exports - ); - if (canAcceptParent) { - // All parents will have to re-run too. - refreshBoundaryIDs.add(parentID); - updatedModuleIDs.push(parentID); - } else { - performFullRefresh('Invalidated boundary', { - source: mod, - failed: parentMod, - }); - return; - } - } - } - } - } - - if (Refresh != null) { - // Debounce a little in case there are multiple updates queued up. - // This is also useful because __accept may be called multiple times. - if (reactRefreshTimeout == null) { - reactRefreshTimeout = setTimeout(() => { - reactRefreshTimeout = null; - // Update React components. - Refresh.performReactRefresh(); - }, 30); - } - } - }; - - const topologicalSort = function ( - roots: Array, - getEdges: (T) => Array, - earlyStop: (T) => boolean - ): Array { - const result = []; - const visited = new Set(); - const stack = new Set(); - function traverseDependentNodes(node: T): void { - if (stack.has(node)) { - throw CYCLE_DETECTED; - } - if (visited.has(node)) { - return; - } - visited.add(node); - stack.add(node); - const dependentNodes = getEdges(node); - if (earlyStop(node)) { - stack.delete(node); - return; - } - dependentNodes.forEach((dependent) => { - traverseDependentNodes(dependent); - }); - stack.delete(node); - result.push(node); - } - roots.forEach((root) => { - traverseDependentNodes(root); - }); - return result; - }; - - const runUpdatedModule = function ( - id: ModuleID, - factory?: FactoryFn, - dependencyMap?: DependencyMap - ): boolean { - const mod = modules.get(id); - if (mod == null) { - throw new Error('[Refresh] Expected to find the module.'); - } - - const { hot } = mod; - if (!hot) { - throw new Error('[Refresh] Expected module.hot to always exist in DEV.'); - } - - if (hot._disposeCallback) { - try { - hot._disposeCallback(); - } catch (error) { - console.error( - `Error while calling dispose handler for module ${id}: `, - error - ); - } - } - - if (factory) { - mod.factory = factory; - } - if (dependencyMap) { - mod.dependencyMap = dependencyMap; - } - mod.hasError = false; - mod.error = undefined; - mod.importedAll = EMPTY; - mod.importedDefault = EMPTY; - mod.isInitialized = false; - const prevExports = mod.publicModule.exports; - mod.publicModule.exports = {}; - hot._didAccept = false; - hot._acceptCallback = null; - hot._disposeCallback = null; - global.__r(id); - - if (mod.hasError) { - // This error has already been reported via a redbox. - // We know it's likely a typo or some mistake that was just introduced. - // Our goal now is to keep the rest of the application working so that by - // the time user fixes the error, the app isn't completely destroyed - // underneath the redbox. So we'll revert the module object to the last - // successful export and stop propagating this update. - mod.hasError = false; - mod.isInitialized = true; - mod.error = null; - mod.publicModule.exports = prevExports; - // We errored. Stop the update. - return true; - } - - if (hot._acceptCallback) { - try { - hot._acceptCallback(); - } catch (error) { - console.error( - `Error while calling accept handler for module ${id}: `, - error - ); - } - } - // No error. - return false; - }; - - const performFullRefresh = ( - reason: string, - modules: $ReadOnly<{ - source?: ModuleDefinition, - failed?: ModuleDefinition, - }> - ) => { - /* global window */ - if ( - typeof window !== 'undefined' && - window.location != null && - // $FlowFixMe[method-unbinding] - typeof window.location.reload === 'function' - ) { - window.location.reload(); - } else { - const Refresh = requireRefresh(); - if (Refresh != null) { - const sourceName = modules.source?.verboseName ?? 'unknown'; - const failedName = modules.failed?.verboseName ?? 'unknown'; - Refresh.performFullRefresh( - `Fast Refresh - ${reason} <${sourceName}> <${failedName}>` - ); - } else { - console.warn('Could not reload the application after an edit.'); - } - } - }; - - // Modules that only export components become React Refresh boundaries. - var isReactRefreshBoundary = function ( - Refresh: any, - moduleExports: Exports - ): boolean { - if (Refresh.isLikelyComponentType(moduleExports)) { - return true; - } - if (moduleExports == null || typeof moduleExports !== 'object') { - // Exit if we can't iterate over exports. - return false; - } - let hasExports = false; - let areAllExportsComponents = true; - for (const key in moduleExports) { - hasExports = true; - if (key === '__esModule') { - continue; - } - const desc = Object.getOwnPropertyDescriptor(moduleExports, key); - if (desc && desc.get) { - // Don't invoke getters as they may have side effects. - return false; - } - const exportValue = moduleExports[key]; - if (!Refresh.isLikelyComponentType(exportValue)) { - areAllExportsComponents = false; - } - } - return hasExports && areAllExportsComponents; - }; - - var shouldInvalidateReactRefreshBoundary = ( - Refresh: any, - prevExports: Exports, - nextExports: Exports - ) => { - const prevSignature = getRefreshBoundarySignature(Refresh, prevExports); - const nextSignature = getRefreshBoundarySignature(Refresh, nextExports); - if (prevSignature.length !== nextSignature.length) { - return true; - } - for (let i = 0; i < nextSignature.length; i++) { - if (prevSignature[i] !== nextSignature[i]) { - return true; - } - } - return false; - }; - - // When this signature changes, it's unsafe to stop at this refresh boundary. - var getRefreshBoundarySignature = ( - Refresh: any, - moduleExports: Exports - ): Array => { - const signature = []; - signature.push(Refresh.getFamilyByType(moduleExports)); - if (moduleExports == null || typeof moduleExports !== 'object') { - // Exit if we can't iterate over exports. - // (This is important for legacy environments.) - return signature; - } - for (const key in moduleExports) { - if (key === '__esModule') { - continue; - } - const desc = Object.getOwnPropertyDescriptor(moduleExports, key); - if (desc && desc.get) { - continue; - } - const exportValue = moduleExports[key]; - signature.push(key); - signature.push(Refresh.getFamilyByType(exportValue)); - } - return signature; - }; - - var registerExportsForReactRefresh = ( - Refresh: any, - moduleExports: Exports, - moduleID: string - ) => { - Refresh.register(moduleExports, moduleID + ' %exports%'); - if (moduleExports == null || typeof moduleExports !== 'object') { - // Exit if we can't iterate over exports. - // (This is important for legacy environments.) - return; - } - for (const key in moduleExports) { - const desc = Object.getOwnPropertyDescriptor(moduleExports, key); - if (desc && desc.get) { - // Don't invoke getters as they may have side effects. - continue; - } - const exportValue = moduleExports[key]; - const typeID = moduleID + ' %exports% ' + key; - Refresh.register(exportValue, typeID); - } - }; - - global.__accept = metroHotUpdateModule; -} - -if (__DEV__) { - // The metro require polyfill can not have module dependencies. - // The Systrace and ReactRefresh dependencies are, therefore, made publicly - // available. Ideally, the dependency would be inversed in a way that - // Systrace / ReactRefresh could integrate into Metro rather than - // having to make them publicly available. - - var requireSystrace = function requireSystrace() { - return ( - // $FlowFixMe[prop-missing] - global[__METRO_GLOBAL_PREFIX__ + '__SYSTRACE'] || metroRequire.Systrace - ); - }; - - var requireRefresh = function requireRefresh() { - // __METRO_GLOBAL_PREFIX__ and global.__METRO_GLOBAL_PREFIX__ differ from - // each other when multiple module systems are used - e.g, in the context - // of Module Federation, the first one would refer to the local prefix - // defined at the top of the bundle, while the other always refers to the - // one coming from the Host - return ( - global[__METRO_GLOBAL_PREFIX__ + '__ReactRefresh'] || - global[global.__METRO_GLOBAL_PREFIX__ + '__ReactRefresh'] || - // $FlowFixMe[prop-missing] - metroRequire.Refresh - ); - }; -} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 262a530..6a314b0 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -13,7 +13,10 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, - "./moduleSystem": "./assets/moduleSystem.flow.js", + "./polyfills/harness-module-system": { + "import": "./assets/harness-module-system.js", + "default": "./assets/harness-module-system.js" + }, "./entry-point": { "development": "./src/entry-point.ts", "types": "./dist/entry-point.d.ts", diff --git a/packages/runtime/src/bundler/evaluate.ts b/packages/runtime/src/bundler/evaluate.ts index 2d14e20..2546a35 100644 --- a/packages/runtime/src/bundler/evaluate.ts +++ b/packages/runtime/src/bundler/evaluate.ts @@ -18,7 +18,7 @@ export const evaluateModule = (moduleJs: string, modulePath: string): void => { const moduleId = Number(__rParam); // This is important as if module was already initialized, it would not be re-initialized - global.__clearModule(moduleId); + global.__resetModule(moduleId); // eslint-disable-next-line no-eval eval(moduleJs); diff --git a/packages/runtime/src/mocker/index.ts b/packages/runtime/src/mocker/index.ts index cfa6f11..1ec2439 100644 --- a/packages/runtime/src/mocker/index.ts +++ b/packages/runtime/src/mocker/index.ts @@ -1,7 +1,6 @@ export { mock, requireActual, - clearMocks, unmock, resetModules, } from './registry.js'; diff --git a/packages/runtime/src/mocker/metro-require.d.ts b/packages/runtime/src/mocker/metro-require.d.ts index 1d6f160..d07a573 100644 --- a/packages/runtime/src/mocker/metro-require.d.ts +++ b/packages/runtime/src/mocker/metro-require.d.ts @@ -3,5 +3,5 @@ import type { Require } from './types.js'; declare global { var __r: Require; var __resetAllModules: () => void; - var __clearModule: (moduleId: number) => void; + var __resetModule: (moduleId: number) => void; } diff --git a/packages/runtime/src/mocker/registry.ts b/packages/runtime/src/mocker/registry.ts index a533f1f..27fb56d 100644 --- a/packages/runtime/src/mocker/registry.ts +++ b/packages/runtime/src/mocker/registry.ts @@ -1,32 +1,28 @@ import { ModuleFactory, ModuleId, Require } from './types.js'; +const modulesCache = new Map(); const mockRegistry = new Map(); -const mockCache = new Map(); const originalRequire = global.__r; export const mock = (moduleId: string, factory: ModuleFactory): void => { - mockCache.delete(moduleId as unknown as ModuleId); + modulesCache.delete(moduleId as unknown as ModuleId); mockRegistry.set(moduleId as unknown as ModuleId, factory); }; -export const clearMocks = (): void => { - mockRegistry.clear(); - mockCache.clear(); +const isModuleMocked = (moduleId: number): boolean => { + return mockRegistry.has(moduleId); }; const getMockImplementation = (moduleId: number): unknown | null => { - if (mockCache.has(moduleId)) { - return mockCache.get(moduleId); - } - const factory = mockRegistry.get(moduleId); + if (!factory) { return null; } const implementation = factory(); - mockCache.set(moduleId, implementation); + modulesCache.set(moduleId, implementation); return implementation; }; @@ -37,25 +33,32 @@ export const requireActual = (moduleId: string): T => export const unmock = (moduleId: string) => { mockRegistry.delete(moduleId as unknown as ModuleId); - mockCache.delete(moduleId as unknown as ModuleId); + modulesCache.delete(moduleId as unknown as ModuleId); }; export const resetModules = (): void => { - mockCache.clear(); - - // Reset Metro's module cache - global.__resetAllModules(); + modulesCache.clear(); + mockRegistry.clear(); }; const mockRequire = (moduleId: string) => { // babel plugin will transform 'moduleId' to a number - const mockedModule = getMockImplementation(moduleId as unknown as ModuleId); + const moduleIdNumber = moduleId as unknown as ModuleId; + const cachedModule = modulesCache.get(moduleIdNumber); + + if (cachedModule) { + return cachedModule; + } - if (mockedModule) { + if (isModuleMocked(moduleIdNumber)) { + const mockedModule = getMockImplementation(moduleIdNumber); + modulesCache.set(moduleIdNumber, mockedModule); return mockedModule; } - return originalRequire(moduleId as unknown as ModuleId); + const originalModule = originalRequire(moduleIdNumber); + modulesCache.set(moduleIdNumber, originalModule); + return originalModule; }; Object.setPrototypeOf(mockRequire, Object.getPrototypeOf(originalRequire)); diff --git a/website/src/docs/api/_meta.json b/website/src/docs/api/_meta.json index 92f4382..2b05398 100644 --- a/website/src/docs/api/_meta.json +++ b/website/src/docs/api/_meta.json @@ -14,6 +14,11 @@ "name": "mocking-and-spying", "label": "Mocking & Spying" }, + { + "type": "file", + "name": "module-mocking", + "label": "Module Mocking" + }, { "type": "file", "name": "rendering-components", diff --git a/website/src/docs/api/mocking-and-spying.md b/website/src/docs/api/mocking-and-spying.md index 57de7c7..5c7363b 100644 --- a/website/src/docs/api/mocking-and-spying.md +++ b/website/src/docs/api/mocking-and-spying.md @@ -197,4 +197,6 @@ Harness provides the complete Vitest spy and mock API including: - **Mock Functions**: `fn()`, mock implementations, return values - **Spying**: `spyOn()`, method restoration - **Mock Management**: `clearAllMocks()`, `resetAllMocks()`, `restoreAllMocks()` -- **Spy Assertions**: All `toHaveBeenCalled*` and `toHaveReturned*` matchers \ No newline at end of file +- **Spy Assertions**: All `toHaveBeenCalled*` and `toHaveReturned*` matchers + +For module mocking capabilities, see the [Module Mocking](/docs/api/module-mocking) documentation. \ No newline at end of file diff --git a/website/src/docs/api/module-mocking.md b/website/src/docs/api/module-mocking.md new file mode 100644 index 0000000..e434d39 --- /dev/null +++ b/website/src/docs/api/module-mocking.md @@ -0,0 +1,164 @@ +# Module Mocking + +Harness provides powerful module mocking capabilities that allow you to replace entire modules or parts of modules with mock implementations. This is particularly useful for testing React Native code that depends on native modules or third-party libraries. + +## mock() + +Mock a module by providing a factory function that returns the mock implementation. + +```typescript +import { describe, test, expect, mock, fn } from 'react-native-harness' + +describe('module mocking', () => { + test('complete module mock', () => { + const mockFactory = () => ({ + formatString: fn().mockReturnValue('mocked string'), + calculateSum: fn().mockImplementation( + (a: number, b: number) => a + b + 1000 + ), + constants: { + VERSION: '999.0.0', + DEBUG: true, + }, + }) + + mock('react-native', mockFactory) + + const mockedModule = require('react-native') + + expect(mockedModule.formatString()).toBe('mocked string') + expect(mockedModule.calculateSum(5, 10)).toBe(1015) + expect(mockedModule.constants.VERSION).toBe('999.0.0') + expect(mockedModule.formatString).toHaveBeenCalledTimes(1) + }) +}) +``` + +## requireActual() + +Get the actual (unmocked) implementation of a module. This is useful for partial mocking where you want to preserve some exports while replacing others. + +```typescript +import { describe, test, expect, mock, requireActual, fn } from 'react-native-harness' + +describe('partial module mocking', () => { + test('mock only Platform while keeping other exports', () => { + const mockFactory = () => { + // Get the actual react-native module + const actualRN = requireActual('react-native') + + // Copy without invoking getters to avoid triggering lazy initialization + const proto = Object.getPrototypeOf(actualRN) + const descriptors = Object.getOwnPropertyDescriptors(actualRN) + + const mockedRN = Object.create(proto, descriptors) + const mockedPlatform = { + OS: 'mockOS', + Version: 999, + select: fn().mockImplementation((options: Record) => { + return options.mockOS || options.default + }), + isPad: false, + isTesting: true, + } + + Object.defineProperty(mockedRN, 'Platform', { + get() { + return mockedPlatform + }, + }) + + return mockedRN + } + + mock('react-native', mockFactory) + + const mockedRN = require('react-native') + + // Verify Platform is mocked + expect(mockedRN.Platform.OS).toBe('mockOS') + expect(mockedRN.Platform.Version).toBe(999) + + // Verify other React Native exports are preserved + expect(mockedRN).toHaveProperty('View') + expect(mockedRN).toHaveProperty('Text') + expect(mockedRN).toHaveProperty('StyleSheet') + }) +}) +``` + +## unmock() + +Remove a mock for a specific module, restoring it to its original implementation. + +```typescript +import { describe, test, expect, mock, unmock } from 'react-native-harness' + +describe('unmocking modules', () => { + test('unmock a previously mocked module', () => { + // Mock a module + const mockFactory = () => ({ mockProperty: 'mocked' }) + mock('react-native', mockFactory) + + // Verify it's mocked + let module = require('react-native') + expect(module.mockProperty).toBe('mocked') + + // Unmock it + unmock('react-native') + + // Verify it's back to actual + module = require('react-native') + expect(module).not.toHaveProperty('mockProperty') + expect(module).toHaveProperty('Platform') // Should have actual RN properties + }) +}) +``` + +## resetModules() + +Clear all module mocks and the module cache. This is useful in `afterEach` hooks to ensure tests don't interfere with each other. + +```typescript +import { describe, test, expect, mock, resetModules, afterEach } from 'react-native-harness' + +describe('module reset', () => { + afterEach(() => { + resetModules() + }) + + test('reinitialize module after reset', () => { + const mockFactory = () => ({ now: Math.random() }) + + mock('react-native', mockFactory) + + // Verify mock is active + const oldNow = require('react-native').now + + // Reset all modules + resetModules() + + // Require again, should reinitialize the module + const newNow = require('react-native').now + expect(newNow).not.toBe(oldNow) + }) +}) +``` + +## Best Practices + +1. **Always reset modules in `afterEach`**: Use `resetModules()` in your test cleanup to prevent mocks from leaking between tests. + +2. **Use `requireActual` for partial mocks**: When you only need to mock specific exports, use `requireActual()` to preserve the rest of the module. + +3. **Factory functions are called lazily**: The factory function is only called when the module is first required, not when `mock()` is called. + +4. **Module caching**: Modules are cached after first require. Use `resetModules()` if you need to reinitialize a mocked module. + +## API Reference + +- **`mock(moduleId: string, factory: () => unknown): void`** - Mock a module with a factory function +- **`unmock(moduleId: string): void`** - Remove a mock for a specific module +- **`requireActual(moduleId: string): T`** - Get the actual (unmocked) implementation of a module +- **`resetModules(): void`** - Clear all module mocks and the module cache +