diff --git a/packages/lib/__tests__/compilerRuntime.spec.ts b/packages/lib/__tests__/compilerRuntime.spec.ts new file mode 100644 index 00000000..627caaca --- /dev/null +++ b/packages/lib/__tests__/compilerRuntime.spec.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' + +/** + * These functions are defined in remote-production.ts and used to patch + * compiler-runtime chunks at build time. We replicate them here to test + * in isolation without a full Rollup build. + */ + +// --- Replicated from remote-production.ts --- + +const findImportSharedExportName = (code: string): string | null => { + const unminifiedExport = /export\s*\{[^}]*\bas\s+importShared\b[^}]*\}/ + if (unminifiedExport.test(code)) { + return 'importShared' + } + + const asyncFnRe = /async\s+function\s+(\w+)\s*\(\s*(\w+)/g + let fnMatch: RegExpExecArray | null + + while ((fnMatch = asyncFnRe.exec(code)) !== null) { + const window = code.substring( + fnMatch.index, + Math.min(fnMatch.index + 300, code.length) + ) + if (window.includes('moduleCache') || window.includes('Promise')) { + const internalName = fnMatch[1]! + + const exportRe = new RegExp( + `export\\s*\\{[^}]*\\b${internalName}\\s+as\\s+(\\w+)` + ) + const exportMatch = exportRe.exec(code) + if (exportMatch) { + return exportMatch[1]! + } + + const directExportRe = new RegExp( + `export\\s*\\{[^}]*\\b${internalName}\\b` + ) + if (directExportRe.test(code)) { + return internalName + } + } + } + + return null +} + +const computeRelativePath = (from: string, to: string): string => { + const fromParts = from.split('/') + const toParts = to.split('/') + + fromParts.pop() + + let common = 0 + while ( + common < fromParts.length && + common < toParts.length && + fromParts[common] === toParts[common] + ) { + common++ + } + + const ups = fromParts.length - common + const remaining = toParts.slice(common) + const prefix = ups > 0 ? '../'.repeat(ups) : './' + + return prefix + remaining.join('/') +} + +const patchCompilerRuntime = ( + code: string, + federationImportFile: string, + runtimeFile: string, + importSharedName: string +): string => { + if (!code.includes('useMemoCache') || !code.includes('export{')) { + return code + } + + const relPath = computeRelativePath(runtimeFile, federationImportFile) + + return [ + `import{${importSharedName} as __s}from"${relPath}";`, + `var __react=await __s("react");`, + `var __internals=__react.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;`, + `var __obj={c:function(n){return __internals.H.useMemoCache(n)}};`, + `export{__obj as c};` + ].join('') +} + +// --- Tests --- + +describe('findImportSharedExportName', () => { + it('should detect unminified importShared export', () => { + const code = `async function importShared(name, shareScope) { + return moduleCache[name] ? new Promise((r) => r(moduleCache[name])) : null; + } + export{importShared, getSharedFromRuntime as importSharedRuntime}` + + expect(findImportSharedExportName(code)).toBe('importShared') + }) + + it('should detect minified importShared export (renamed)', () => { + const code = `async function xe(e,t="default"){return moduleCache[e]?new Promise((r)=>r(moduleCache[e])):null}export{xe as i,ye as j}` + + expect(findImportSharedExportName(code)).toBe('i') + }) + + it('should detect direct export (not renamed)', () => { + const code = `async function myImport(n,s="default"){return moduleCache[n]?new Promise((r)=>r(moduleCache[n])):null}export{myImport}` + + expect(findImportSharedExportName(code)).toBe('myImport') + }) + + it('should return null when no importShared-like function found', () => { + const code = `function hello(){return "world"}export{hello}` + + expect(findImportSharedExportName(code)).toBeNull() + }) +}) + +describe('computeRelativePath', () => { + it('should compute same-directory path', () => { + expect( + computeRelativePath( + 'assets/compiler-runtime-abc.js', + 'assets/__federation_fn_import-xyz.js' + ) + ).toBe('./__federation_fn_import-xyz.js') + }) + + it('should compute parent-directory path', () => { + expect( + computeRelativePath( + 'assets/chunks/compiler-runtime-abc.js', + 'assets/__federation_fn_import-xyz.js' + ) + ).toBe('../__federation_fn_import-xyz.js') + }) + + it('should compute path for files at root level', () => { + expect( + computeRelativePath( + 'compiler-runtime-abc.js', + '__federation_fn_import-xyz.js' + ) + ).toBe('./__federation_fn_import-xyz.js') + }) +}) + +describe('patchCompilerRuntime', () => { + const SAMPLE_CHUNK = `import{r as R}from"./index-XYZ.js";var i={exports:{}},o={};var u;function m(){if(u)return o;u=1;var r=R().__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;return o.c=function(e){return r.H.useMemoCache(e)},o}var s=m();export{s as c};` + + it('should rewrite compiler-runtime chunk to use importShared', () => { + const patched = patchCompilerRuntime( + SAMPLE_CHUNK, + 'assets/__federation_fn_import-xyz.js', + 'assets/compiler-runtime-abc.js', + 'importShared' + ) + + expect(patched).toContain('import{importShared as __s}') + expect(patched).toContain('await __s("react")') + expect(patched).toContain( + '__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE' + ) + expect(patched).toContain('useMemoCache') + expect(patched).toContain('export{__obj as c}') + // Should NOT contain the original direct import + expect(patched).not.toContain('./index-XYZ.js') + }) + + it('should use correct relative path to federation import chunk', () => { + const patched = patchCompilerRuntime( + SAMPLE_CHUNK, + 'assets/__federation_fn_import-xyz.js', + 'assets/compiler-runtime-abc.js', + 'i' + ) + + expect(patched).toContain( + 'import{i as __s}from"./__federation_fn_import-xyz.js"' + ) + }) + + it('should not patch code without useMemoCache', () => { + const code = `import{r as R}from"./index.js";export{R as react};` + const result = patchCompilerRuntime( + code, + 'assets/__federation_fn_import.js', + 'assets/some-chunk.js', + 'importShared' + ) + + expect(result).toBe(code) + }) + + it('should not patch code without export statement', () => { + const code = `var r = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; r.H.useMemoCache(1);` + const result = patchCompilerRuntime( + code, + 'assets/__federation_fn_import.js', + 'assets/some-chunk.js', + 'importShared' + ) + + expect(result).toBe(code) + }) +}) diff --git a/packages/lib/__tests__/flattenModule.spec.ts b/packages/lib/__tests__/flattenModule.spec.ts new file mode 100644 index 00000000..be9915a8 --- /dev/null +++ b/packages/lib/__tests__/flattenModule.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' + +/** + * flattenModule is a runtime template (federation_fn_import.js) that gets + * injected into the build output. We replicate the logic here to unit-test + * the Proxy behaviour without requiring a full build. + */ + +// Simulates the moduleCache used by the runtime +const moduleCache: Record = {} + +// This is the patched flattenModule (Proxy-based) +const flattenModule = (module: any, name: string) => { + if (typeof module.default === 'function') { + Object.keys(module).forEach((key) => { + if (key !== 'default') { + module.default[key] = module[key] + } + }) + moduleCache[name] = module.default + return module.default + } + if (module.default) { + const originalModule = module + module = new Proxy(module.default, { + get(target, prop) { + if (prop !== 'default' && prop in originalModule) + return originalModule[prop] + return target[prop] + }, + has(target, prop) { + return prop in originalModule || prop in target + }, + ownKeys(target) { + const keys = new Set([ + ...Reflect.ownKeys(target), + ...Reflect.ownKeys(originalModule) + ]) + keys.delete('default') + return [...keys] + } + }) + } + moduleCache[name] = module + return module +} + +describe('flattenModule — Proxy preserves live bindings', () => { + it('should preserve mutable state on default export (React hooks dispatcher pattern)', () => { + // Simulates React's module structure: __CLIENT_INTERNALS...H is null at + // load time and only set during render. + const internals = { H: null as (() => void) | null } + const reactModule = { + // eslint-disable-next-line @typescript-eslint/no-empty-function + default: { __INTERNALS: internals, createElement: () => {} }, + __esModule: true + } + + const result = flattenModule(reactModule, 'react-mutable') + + // At load time, H is null + expect(result.__INTERNALS.H).toBeNull() + + // Simulate render — React sets H at runtime + internals.H = () => 'dispatcher active' + + // The Proxy should reflect the live value, NOT a snapshot + expect(result.__INTERNALS.H).not.toBeNull() + expect(result.__INTERNALS.H!()).toBe('dispatcher active') + }) + + it('should expose named exports alongside default export properties', () => { + const module = { + default: { defaultProp: 'from-default' }, + namedExport: 'from-named', + anotherExport: 42 + } + + const result = flattenModule(module, 'mixed-exports') + + expect(result.defaultProp).toBe('from-default') + expect(result.namedExport).toBe('from-named') + expect(result.anotherExport).toBe(42) + }) + + it('should prioritise named exports over default export properties', () => { + const module = { + default: { shared: 'from-default' }, + shared: 'from-named' + } + + const result = flattenModule(module, 'priority-test') + + // Named export takes precedence (prop in originalModule check) + expect(result.shared).toBe('from-named') + }) + + it('should not expose "default" key in ownKeys', () => { + const module = { + default: { a: 1 }, + b: 2 + } + + const result = flattenModule(module, 'no-default-key') + const keys = Reflect.ownKeys(result) + + expect(keys).not.toContain('default') + expect(keys).toContain('a') + expect(keys).toContain('b') + }) + + it('should handle function default exports by copying named exports onto it', () => { + const fn = () => 'hello' + const module = { + default: fn, + helper: 'util' + } + + const result = flattenModule(module, 'fn-default') + + expect(typeof result).toBe('function') + expect(result()).toBe('hello') + expect(result.helper).toBe('util') + }) + + it('should not cause infinite recursion (regression: Proxy traps calling themselves)', () => { + const module = { + default: { value: 'test' }, + extra: true + } + + // This would stack overflow if `module` was used instead of + // `originalModule` in the Proxy traps + expect(() => { + const result = flattenModule(module, 'recursion-guard') + // Trigger has trap + 'value' in result + 'extra' in result + 'nonexistent' in result + // Trigger ownKeys trap + Object.keys(result) + Reflect.ownKeys(result) + }).not.toThrow() + }) + + it('should return module as-is when there is no default export', () => { + const module = { a: 1, b: 2 } + + const result = flattenModule(module, 'no-default') + + expect(result).toBe(module) + expect(result.a).toBe(1) + expect(result.b).toBe(2) + }) +}) diff --git a/packages/lib/src/prod/remote-production.ts b/packages/lib/src/prod/remote-production.ts index 3476286b..7aec510f 100644 --- a/packages/lib/src/prod/remote-production.ts +++ b/packages/lib/src/prod/remote-production.ts @@ -541,6 +541,58 @@ export function prodRemotePlugin( }, generateBundle(options, bundle) { + // --------------------------------------------------------------- + // Patch: compiler-runtime chunk (remote builds only) + // + // react/compiler-runtime is bundled as a separate chunk that + // imports React directly (./index-*.js) instead of going through + // importShared("react"). In federated mode the host provides + // React via the share scope — the local chunk is a DIFFERENT + // instance where the hooks dispatcher is never initialised. + // + // Fix: Rewrite the compiler-runtime chunk to obtain React via + // importShared("react") (top-level await), ensuring it uses the + // same React instance as the rest of the federation. + // --------------------------------------------------------------- + if (builderInfo.isRemote) { + let federationImportFileName: string | null = null + let importSharedExportName: string | null = null + + // First pass: find the federation import chunk + for (const fileName in bundle) { + const chunk = bundle[fileName] + if (chunk.type !== 'chunk') continue + if (fileName.includes('__federation_fn_import')) { + federationImportFileName = fileName + importSharedExportName = findImportSharedExportName(chunk.code) + } + } + + // Second pass: patch compiler-runtime chunks + if (federationImportFileName && importSharedExportName) { + for (const fileName in bundle) { + const chunk = bundle[fileName] + if (chunk.type !== 'chunk') continue + if ( + fileName.includes('compiler-runtime') && + chunk.code.includes( + '__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE' + ) + ) { + const patched = patchCompilerRuntime( + chunk.code, + federationImportFileName, + fileName, + importSharedExportName + ) + if (patched !== chunk.code) { + chunk.code = patched + } + } + } + } + } + const preloadSharedReg = parsedOptions.prodShared .filter((shareInfo) => shareInfo[1].modulePreload) .map( @@ -634,4 +686,102 @@ export function prodRemotePlugin( } } +// --------------------------------------------------------------------------- +// compiler-runtime patch helpers +// --------------------------------------------------------------------------- + +/** + * Rewrites the compiler-runtime chunk to obtain React through + * importShared("react") instead of a direct bundled import. + */ +function patchCompilerRuntime( + code: string, + federationImportFile: string, + runtimeFile: string, + importSharedName: string +): string { + if (!code.includes('useMemoCache') || !code.includes('export{')) { + return code + } + + const relPath = computeRelativePath(runtimeFile, federationImportFile) + + return [ + `import{${importSharedName} as __s}from"${relPath}";`, + `var __react=await __s("react");`, + `var __internals=__react.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;`, + `var __obj={c:function(n){return __internals.H.useMemoCache(n)}};`, + `export{__obj as c};` + ].join('') +} + +/** + * Finds the exported name for importShared in the federation import chunk. + * Handles both unminified (`export{... as importShared}`) and minified forms. + */ +function findImportSharedExportName(code: string): string | null { + // Unminified: export{... as importShared ...} + const unminifiedExport = /export\s*\{[^}]*\bas\s+importShared\b[^}]*\}/ + if (unminifiedExport.test(code)) { + return 'importShared' + } + + // Minified: find the async function that accesses moduleCache/Promise, + // then look up its export alias. + const asyncFnRe = /async\s+function\s+(\w+)\s*\(\s*(\w+)/g + let fnMatch: RegExpExecArray | null + + while ((fnMatch = asyncFnRe.exec(code)) !== null) { + const window = code.substring( + fnMatch.index, + Math.min(fnMatch.index + 300, code.length) + ) + if (window.includes('moduleCache') || window.includes('Promise')) { + const internalName = fnMatch[1]! + + const exportRe = new RegExp( + `export\\s*\\{[^}]*\\b${internalName}\\s+as\\s+(\\w+)` + ) + const exportMatch = exportRe.exec(code) + if (exportMatch) { + return exportMatch[1]! + } + + const directExportRe = new RegExp( + `export\\s*\\{[^}]*\\b${internalName}\\b` + ) + if (directExportRe.test(code)) { + return internalName + } + } + } + + return null +} + +/** + * Computes the relative import path between two bundle file names. + */ +function computeRelativePath(from: string, to: string): string { + const fromParts = from.split('/') + const toParts = to.split('/') + + fromParts.pop() + + let common = 0 + while ( + common < fromParts.length && + common < toParts.length && + fromParts[common] === toParts[common] + ) { + common++ + } + + const ups = fromParts.length - common + const remaining = toParts.slice(common) + const prefix = ups > 0 ? '../'.repeat(ups) : './' + + return prefix + remaining.join('/') +} + export { sharedFileName2Prop }