From 4961b40aca426787b32902822a469cc1d20c51c9 Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 24 Jul 2026 10:54:22 -0700 Subject: [PATCH 1/9] fix(bundler): support Tailwind prefix imports --- .../src/bundler/artifacts/css/themes.ts | 66 +++++++------------ .../native/bundler/tailwind-prefix.test.ts | 58 ++++++++++++++++ 2 files changed, 80 insertions(+), 44 deletions(-) create mode 100644 packages/uniwind/tests/native/bundler/tailwind-prefix.test.ts diff --git a/packages/uniwind/src/bundler/artifacts/css/themes.ts b/packages/uniwind/src/bundler/artifacts/css/themes.ts index da0ef3e2..3c4ec092 100644 --- a/packages/uniwind/src/bundler/artifacts/css/themes.ts +++ b/packages/uniwind/src/bundler/artifacts/css/themes.ts @@ -2,7 +2,6 @@ import { Logger } from '@/bundler/logger' import { compile } from '@tailwindcss/node' import fs from 'fs' import { transform } from 'lightningcss' -import type { ImportDependency } from 'lightningcss' import path from 'path' const readFileSafe = (filePath: string) => { @@ -20,21 +19,38 @@ const isExcludedDependency = (url: string) => url.includes('node_modules/uniwind'), ].some(Boolean) +const removeImportsForAnalysis = (css: string) => css.replace(/@import(?:[^;"']+|"[^"]*"|'[^']*')+;/g, '') + export const generateCSSForThemes = async (themes: Array, input: string) => { // css generation const themesVariables = Object.fromEntries(themes.map(theme => [theme, new Set()])) + const inputPath = path.resolve(input) + const cssPaths = new Set([inputPath]) + const inputCSS = readFileSafe(inputPath) + + if (inputCSS !== null) { + await compile(inputCSS, { + base: path.dirname(inputPath), + onDependency: dependency => { + if (!isExcludedDependency(dependency)) { + cssPaths.add(dependency) + } + }, + }) + } - const findVariantsRec = async (cssPath: string) => { + for (const cssPath of cssPaths) { const css = readFileSafe(cssPath) if (css === null) { - return + continue } - const { dependencies } = transform({ - code: Buffer.from(css), + transform({ + // Tailwind owns import resolution, including prefix(...). Lightning + // CSS only inspects import-free source for Uniwind theme metadata. + code: Buffer.from(removeImportsForAnalysis(css)), filename: 'uniwind.css', - analyzeDependencies: true, visitor: { Rule: rule => { if (rule.type === 'unknown' && rule.value.name === 'variant') { @@ -59,46 +75,8 @@ export const generateCSSForThemes = async (themes: Array, input: string) }, }, }) - - if (!Array.isArray(dependencies)) { - return - } - - const importUrls = new Set() - const importsCSS = dependencies - .filter((dependency): dependency is ImportDependency => { - if (dependency.type !== 'import') { - return false - } - - if (dependency.url.startsWith('.')) { - importUrls.add(path.resolve(path.dirname(cssPath), dependency.url)) - - return false - } - - return !isExcludedDependency(dependency.url) - }) - .map(dependency => `@import "${dependency.url}";`).join('\n') - - await compile(importsCSS, { - base: path.resolve(path.dirname(cssPath)), - onDependency: dependency => { - if (isExcludedDependency(dependency)) { - return - } - - importUrls.add(dependency) - }, - }) - - for (const filePath of importUrls) { - await findVariantsRec(filePath) - } } - await findVariantsRec(input) - // Check if all themes have the same variables let hasErrors = false as boolean const hasVariables = Object.values(themesVariables).some(variables => variables.size > 0) diff --git a/packages/uniwind/tests/native/bundler/tailwind-prefix.test.ts b/packages/uniwind/tests/native/bundler/tailwind-prefix.test.ts new file mode 100644 index 00000000..bef4dc4f --- /dev/null +++ b/packages/uniwind/tests/native/bundler/tailwind-prefix.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import path from 'path' +import { generateCSSForThemes } from '../../../src/bundler/artifacts/css/themes' +import { UniwindBundlerConfig } from '../../../src/bundler/config' +import { compileCSS } from '../../../src/bundler/css-compiler' +import { Platform } from '../../../src/common/consts' + +describe('Tailwind prefix support', () => { + test('accepts prefix import modifiers during artifact generation', async () => { + const directory = mkdtempSync(path.join(process.cwd(), '.tmp-prefix-artifacts-')) + const cssPath = path.join(directory, 'remote.css') + + writeFileSync( + cssPath, + [ + '@layer theme, base, components, utilities;', + '@import "tailwindcss/theme.css" layer(theme) prefix(rma);', + '@import "tailwindcss/utilities.css" layer(utilities) prefix(rma);', + '@import "uniwind";', + ].join('\n'), + ) + + try { + await expect(generateCSSForThemes(['light', 'dark'], cssPath)).resolves.toContain('@custom-variant light') + } finally { + rmSync(directory, { force: true, recursive: true }) + } + }) + + test('processes Tailwind prefixes before Lightning CSS', async () => { + const directory = mkdtempSync(path.join(process.cwd(), '.tmp-prefix-compile-')) + const cssPath = path.join(directory, 'remote.css') + + writeFileSync( + cssPath, + [ + '@import "tailwindcss" prefix(rma);', + '@source inline("rma:flex rma:bg-red-500");', + ].join('\n'), + ) + + try { + const config = UniwindBundlerConfig.fromMetroConfig( + { + cssEntryFile: path.relative(process.cwd(), cssPath), + }, + Platform.Web, + ) + const webCSS = await compileCSS(config) + + expect(webCSS).toContain('.rma\\:flex') + expect(webCSS).toContain('.rma\\:bg-red-500') + expect(webCSS).not.toContain('prefix(rma)') + } finally { + rmSync(directory, { force: true, recursive: true }) + } + }) +}) From bd000721d6d00ad27d57e9373701689c1aced32b Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 24 Jul 2026 12:09:46 -0700 Subject: [PATCH 2/9] fix(web): refresh styles when stylesheets change --- packages/uniwind/src/core/web/cssListener.ts | 20 +++++++++- .../tests/web/core/css-listener.test.ts | 37 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 packages/uniwind/tests/web/core/css-listener.test.ts diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index 68bde329..167cb2d3 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -6,7 +6,7 @@ class CSSListenerBuilder { private classNameMediaQueryListeners = new Map() private listeners = new Map>() private registeredRulesMediaQueries = new Map() - private processedStyleSheets = new WeakSet() + private processedStyleSheets = new Set() private pendingInitialization: number | undefined = undefined constructor() { @@ -101,17 +101,28 @@ class CSSListenerBuilder { private pruneStaleRules() { const activeSheets = new Set(Array.from(document.styleSheets)) + let styleSheetsChanged = false + + for (const sheet of this.processedStyleSheets) { + if (!activeSheets.has(sheet)) { + this.processedStyleSheets.delete(sheet) + styleSheetsChanged = true + } + } for (const rule of this.activeRules) { if (!rule.parentStyleSheet || !activeSheets.has(rule.parentStyleSheet)) { this.activeRules.delete(rule) + styleSheetsChanged = true } } + + return styleSheetsChanged } private initialize() { this.pendingInitialization = undefined - this.pruneStaleRules() + let styleSheetsChanged = this.pruneStaleRules() for (const sheet of Array.from(document.styleSheets)) { // Skip already processed stylesheets @@ -135,9 +146,14 @@ class CSSListenerBuilder { // Mark as processed after successful cssRules access this.processedStyleSheets.add(sheet) + styleSheetsChanged = true this.addMediaQueriesDeep(rules) } + + if (styleSheetsChanged) { + UniwindListener.notify([StyleDependency.Variables]) + } } private isStyleRule(rule: CSSRule): rule is CSSStyleRule { diff --git a/packages/uniwind/tests/web/core/css-listener.test.ts b/packages/uniwind/tests/web/core/css-listener.test.ts new file mode 100644 index 00000000..1292984b --- /dev/null +++ b/packages/uniwind/tests/web/core/css-listener.test.ts @@ -0,0 +1,37 @@ +import { waitFor } from '@testing-library/react' +import { CSSListener } from '../../../src/core/web' + +describe('CSSListener', () => { + test('notifies class subscribers when a stylesheet loads, unloads, and reloads', async () => { + const listener = jest.fn() + const dispose = CSSListener.subscribeToClassName('remote-class', listener) + + const style = document.createElement('style') + style.textContent = '.remote-class { background-color: rgb(250, 204, 21); }' + document.head.appendChild(style) + + await waitFor(() => { + expect(Array.from(CSSListener.activeRules).some(rule => rule.selectorText === '.remote-class')).toBe(true) + expect(listener).toHaveBeenCalled() + }) + + listener.mockClear() + style.remove() + + await waitFor(() => { + expect(Array.from(CSSListener.activeRules).some(rule => rule.selectorText === '.remote-class')).toBe(false) + expect(listener).toHaveBeenCalled() + }) + + listener.mockClear() + document.head.appendChild(style) + + await waitFor(() => { + expect(Array.from(CSSListener.activeRules).some(rule => rule.selectorText === '.remote-class')).toBe(true) + expect(listener).toHaveBeenCalled() + }) + + dispose() + style.remove() + }) +}) From f4048716409f06dc7c7d7fa621accbf59b4312d5 Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 24 Jul 2026 13:00:20 -0700 Subject: [PATCH 3/9] fix(web): retain deferred media subscriptions --- packages/uniwind/src/core/web/cssListener.ts | 40 ++++++++-------- .../tests/web/core/css-listener.test.ts | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index 167cb2d3..26e0e35e 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -3,8 +3,7 @@ import { UniwindListener } from '../listener' class CSSListenerBuilder { activeRules = new Set() - private classNameMediaQueryListeners = new Map() - private listeners = new Map>() + private classNameListeners = new Map>() private registeredRulesMediaQueries = new Map() private processedStyleSheets = new Set() private pendingInitialization: number | undefined = undefined @@ -50,17 +49,18 @@ class CSSListenerBuilder { subscribeToClassName(classNames: string, listener: VoidFunction) { const disposables = [] as Array - classNames.split(' ').forEach(className => { - const mediaQuery = this.classNameMediaQueryListeners.get(className) + classNames.split(' ').filter(Boolean).forEach(className => { + const listeners = this.classNameListeners.get(className) ?? new Set() - if (!mediaQuery) { - return () => {} - } - - const listeners = this.listeners.get(mediaQuery) + listeners.add(listener) + this.classNameListeners.set(className, listeners) + disposables.push(() => { + listeners.delete(listener) - listeners?.add(listener) - disposables.push(() => listeners?.delete(listener)) + if (listeners.size === 0) { + this.classNameListeners.delete(className) + } + }) }) const disposeThemeListener = UniwindListener.subscribe(listener, [StyleDependency.Theme, StyleDependency.Variables]) @@ -168,6 +168,10 @@ class CSSListenerBuilder { return rule.constructor.name === 'CSSSupportsRule' } + private hasNestedRules(rule: CSSRule): rule is CSSRule & { cssRules: CSSRuleList } { + return 'cssRules' in rule + } + private collectParentMediaQueries(rule: CSSRule, acc = [] as Array): Array { const { parentRule } = rule @@ -210,7 +214,7 @@ class CSSListenerBuilder { continue } - if ('cssRules' in rule && rule.cssRules instanceof CSSRuleList) { + if (this.hasNestedRules(rule)) { this.addMediaQueriesDeep(rule.cssRules) continue @@ -225,11 +229,11 @@ class CSSListenerBuilder { const cachedMediaQueryList = this.registeredRulesMediaQueries.get(rules) if (cachedMediaQueryList) { - this.classNameMediaQueryListeners.set(parsedClassName, cachedMediaQueryList) this.toggleRule(cachedMediaQueryList, rule) cachedMediaQueryList.addEventListener('change', () => { this.toggleRule(cachedMediaQueryList, rule) + this.notifyClassName(parsedClassName) }) return @@ -239,17 +243,17 @@ class CSSListenerBuilder { this.toggleRule(mediaQueryList, rule) this.registeredRulesMediaQueries.set(rules, mediaQueryList) - this.listeners.set(mediaQueryList, new Set()) - this.classNameMediaQueryListeners.set(parsedClassName, mediaQueryList) mediaQueryList.addEventListener('change', () => { - this.listeners.get(mediaQueryList)!.forEach(listener => { - listener() - }) this.toggleRule(mediaQueryList, rule) + this.notifyClassName(parsedClassName) }) } + private notifyClassName(className: string) { + this.classNameListeners.get(className)?.forEach(listener => listener()) + } + private isRuleLive(rule: CSSStyleRule) { const sheet = rule.parentStyleSheet return sheet !== null && Array.from(document.styleSheets).includes(sheet) diff --git a/packages/uniwind/tests/web/core/css-listener.test.ts b/packages/uniwind/tests/web/core/css-listener.test.ts index 1292984b..ec716b9e 100644 --- a/packages/uniwind/tests/web/core/css-listener.test.ts +++ b/packages/uniwind/tests/web/core/css-listener.test.ts @@ -34,4 +34,50 @@ describe('CSSListener', () => { dispose() style.remove() }) + + test('retains media-query subscriptions for classes loaded later', async () => { + const originalMatchMedia = window.matchMedia + const mediaListeners = new Set() + const mediaQueryList = { + addEventListener: (_: string, listener: EventListener) => mediaListeners.add(listener), + dispatchEvent: () => true, + matches: false, + media: '(min-width: 600px)', + onchange: null, + removeEventListener: (_: string, listener: EventListener) => mediaListeners.delete(listener), + } + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: jest.fn(() => mediaQueryList), + }) + + const listener = jest.fn() + const dispose = CSSListener.subscribeToClassName('remote-responsive', listener) + const style = document.createElement('style') + + try { + style.textContent = '@media (min-width: 600px) { .remote-responsive { background-color: blue; } }' + document.head.appendChild(style) + + await waitFor(() => { + expect(window.matchMedia).toHaveBeenCalledWith('(min-width: 600px)') + expect(listener).toHaveBeenCalled() + }) + + listener.mockClear() + mediaQueryList.matches = true + mediaListeners.forEach(mediaListener => mediaListener(new Event('change'))) + + expect(Array.from(CSSListener.activeRules).some(rule => rule.selectorText === '.remote-responsive')).toBe(true) + expect(listener).toHaveBeenCalled() + } finally { + dispose() + style.remove() + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: originalMatchMedia, + }) + } + }) }) From 0e57d3a2cad4834440d2b675a9beb2f0a4e39a2b Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 24 Jul 2026 13:10:18 -0700 Subject: [PATCH 4/9] fix(web): normalize escaped responsive classes --- packages/uniwind/src/core/web/cssListener.ts | 2 +- packages/uniwind/tests/web/core/css-listener.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index 26e0e35e..5816cd91 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -225,7 +225,7 @@ class CSSListenerBuilder { private addMediaQuery(mediaQueries: Array, rule: CSSStyleRule) { const className = rule.selectorText const rules = mediaQueries.map(mediaQuery => mediaQuery.conditionText).sort().join(' and ') - const parsedClassName = className.replace('.', '').replace('\\', '') + const parsedClassName = className.replace('.', '').replaceAll('\\', '') const cachedMediaQueryList = this.registeredRulesMediaQueries.get(rules) if (cachedMediaQueryList) { diff --git a/packages/uniwind/tests/web/core/css-listener.test.ts b/packages/uniwind/tests/web/core/css-listener.test.ts index ec716b9e..0264998f 100644 --- a/packages/uniwind/tests/web/core/css-listener.test.ts +++ b/packages/uniwind/tests/web/core/css-listener.test.ts @@ -53,11 +53,11 @@ describe('CSSListener', () => { }) const listener = jest.fn() - const dispose = CSSListener.subscribeToClassName('remote-responsive', listener) + const dispose = CSSListener.subscribeToClassName('rma:md:bg-blue-500', listener) const style = document.createElement('style') try { - style.textContent = '@media (min-width: 600px) { .remote-responsive { background-color: blue; } }' + style.textContent = '@media (min-width: 600px) { .rma\\:md\\:bg-blue-500 { background-color: blue; } }' document.head.appendChild(style) await waitFor(() => { @@ -69,7 +69,7 @@ describe('CSSListener', () => { mediaQueryList.matches = true mediaListeners.forEach(mediaListener => mediaListener(new Event('change'))) - expect(Array.from(CSSListener.activeRules).some(rule => rule.selectorText === '.remote-responsive')).toBe(true) + expect(Array.from(CSSListener.activeRules).some(rule => rule.selectorText === '.rma\\:md\\:bg-blue-500')).toBe(true) expect(listener).toHaveBeenCalled() } finally { dispose() From adae8346ceef4c5aceee1ab7d6ddc769c44ed19f Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 24 Jul 2026 12:10:25 -0700 Subject: [PATCH 5/9] feat(native): merge federated style registrations --- packages/uniwind/src/common/consts.ts | 1 + .../uniwind/src/core/config/config.native.ts | 17 +- packages/uniwind/src/core/listener.ts | 1 + packages/uniwind/src/core/native/store.ts | 144 +++++++++++- .../native/core/federated-styles.test.ts | 221 ++++++++++++++++++ 5 files changed, 373 insertions(+), 11 deletions(-) create mode 100644 packages/uniwind/tests/native/core/federated-styles.test.ts diff --git a/packages/uniwind/src/common/consts.ts b/packages/uniwind/src/common/consts.ts index cb1c113e..42848442 100644 --- a/packages/uniwind/src/common/consts.ts +++ b/packages/uniwind/src/common/consts.ts @@ -21,6 +21,7 @@ export enum StyleDependency { Rtl = 7, AdaptiveThemes = 8, Variables = 9, + Stylesheet = 10, } export const enum Orientation { diff --git a/packages/uniwind/src/core/config/config.native.ts b/packages/uniwind/src/core/config/config.native.ts index 917e1d40..3f25282b 100644 --- a/packages/uniwind/src/core/config/config.native.ts +++ b/packages/uniwind/src/core/config/config.native.ts @@ -1,10 +1,11 @@ import { formatHex, formatHex8, parse } from 'culori' import type { Insets } from 'react-native' import { StyleDependency } from '../../common/consts' +import { arrayEquals } from '../../common/utils' import { UniwindListener } from '../listener' import { Logger } from '../logger' import { UniwindStore } from '../native' -import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../types' +import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName, Vars } from '../types' import { UniwindConfigBuilder as UniwindConfigBuilderBase } from './config.common' class UniwindConfigBuilder extends UniwindConfigBuilderBase { @@ -13,6 +14,8 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { } updateCSSVariables(theme: ThemeName, variables: CSSVariables) { + const runtimeVars = {} as Vars + Object.entries(variables).forEach(([varName, varValue]) => { if (!varName.startsWith('--') && __DEV__) { Logger.error(`CSS variable name must start with "--", instead got: ${varName}`) @@ -36,10 +39,10 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { return varValue } - UniwindStore.vars[theme] ??= {} - UniwindStore.vars[theme][varName] = getValue + runtimeVars[varName] = getValue }) + UniwindStore.updateCSSVariables(theme, runtimeVars) UniwindListener.notify([StyleDependency.Variables]) } @@ -56,6 +59,14 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { UniwindStore.reinit(generateStyleSheetCallback, themes) } + protected __mergeStyles(id: string, generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array) { + if (!arrayEquals(themes, this._themes)) { + throw new Error(`Uniwind: Federated styles '${id}' must use the host themes.`) + } + + return UniwindStore.merge(id, generateStyleSheetCallback, themes) + } + protected onThemeChange() { UniwindStore.runtime.currentThemeName = this.currentTheme } diff --git a/packages/uniwind/src/core/listener.ts b/packages/uniwind/src/core/listener.ts index cd9794e6..fe963bd6 100644 --- a/packages/uniwind/src/core/listener.ts +++ b/packages/uniwind/src/core/listener.ts @@ -15,6 +15,7 @@ class UniwindListenerBuilder { [StyleDependency.Rtl]: new Set<() => void>(), [StyleDependency.AdaptiveThemes]: new Set<() => void>(), [StyleDependency.Variables]: new Set<() => void>(), + [StyleDependency.Stylesheet]: new Set<() => void>(), } notify(dependencies: Array) { diff --git a/packages/uniwind/src/core/native/store.ts b/packages/uniwind/src/core/native/store.ts index e2c676aa..f1a88578 100644 --- a/packages/uniwind/src/core/native/store.ts +++ b/packages/uniwind/src/core/native/store.ts @@ -1,6 +1,7 @@ import { Dimensions, Platform } from 'react-native' import { Orientation, Platform as UniwindPlatform, StyleDependency, UNIWIND_PLATFORM_VARIABLES, UNIWIND_THEME_VARIABLES } from '../../common/consts' import { UniwindListener } from '../listener' +import { Logger } from '../logger' import type { ComponentState, GenerateStyleSheetsCallback, RNStyle, Style, StyleSheets, ThemeName, UniwindContextType, Var, Vars } from '../types' import { parseBoxShadow, parseFontVariant, parseTextShadowMutation, parseTransformsMutation, resolveGradient } from './parsers' import { UniwindRuntime } from './runtime' @@ -11,6 +12,12 @@ type StylesResult = { dependencySum: number } +type StyleRegistration = { + config: ReturnType + owner: string + themes: Array +} + const emptyState: StylesResult = { styles: {}, dependencies: [], dependencySum: 0 } class UniwindStoreBuilder { @@ -18,6 +25,9 @@ class UniwindStoreBuilder { vars = {} as Record private stylesheet = {} as StyleSheets private cache = {} as Record> + private baseRegistration: StyleRegistration | null = null + private remoteRegistrations = new Map() + private runtimeVariableOverrides = {} as Record getStyles( className: string | undefined, @@ -59,8 +69,124 @@ class UniwindStoreBuilder { } reinit = (generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array) => { - const config = generateStyleSheetCallback(this.runtime) - const { scopedVars, stylesheet, vars } = config + this.runtimeVariableOverrides = {} + this.baseRegistration = { + config: generateStyleSheetCallback(this.runtime), + owner: 'host', + themes, + } + this.rebuild() + + if (__DEV__ || this.remoteRegistrations.size > 0) { + UniwindListener.notifyAll() + } + } + + updateCSSVariables = (theme: ThemeName, variables: Vars) => { + this.runtimeVariableOverrides[theme] ??= {} + Object.assign(this.runtimeVariableOverrides[theme], variables) + this.vars[theme] ??= {} + Object.assign(this.vars[theme], variables) + } + + merge = (id: string, generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array) => { + const registration = { + config: generateStyleSheetCallback(this.runtime), + owner: id, + themes, + } + + this.remoteRegistrations.set(id, registration) + this.rebuild() + UniwindListener.notify([StyleDependency.Stylesheet, StyleDependency.Variables]) + + return () => { + if (this.remoteRegistrations.get(id) !== registration) { + return + } + + this.remoteRegistrations.delete(id) + this.rebuild() + UniwindListener.notify([StyleDependency.Stylesheet, StyleDependency.Variables]) + } + } + + private rebuild() { + const registrations = [ + ...(this.baseRegistration ? [this.baseRegistration] : []), + ...this.remoteRegistrations.values(), + ] + const themes = this.baseRegistration?.themes ?? registrations[0]?.themes ?? [] + const stylesheet = {} as StyleSheets + const vars = {} as Vars + const scopedVars = {} as Partial> + const classNameOwners = new Map() + const variableOwners = new Map() + + for (const registration of registrations) { + for (const [className, styles] of Object.entries(registration.config.stylesheet)) { + const existingOwner = classNameOwners.get(className) + + if (existingOwner !== undefined) { + if (__DEV__) { + Logger.warn( + `Federated class "${className}" from "${registration.owner}" was dropped because it is already registered by "${existingOwner}". Prefix remote class names to avoid conflicts.`, + ) + } + + continue + } + + classNameOwners.set(className, registration.owner) + stylesheet[className] = styles + } + + const registrationVariableNames = new Set([ + ...Reflect.ownKeys(registration.config.vars), + ...Object.values(registration.config.scopedVars).flatMap(value => value ? Reflect.ownKeys(value) : []), + ]) + const acceptedVariableNames = new Set() + + for (const variableName of registrationVariableNames) { + const existingOwner = variableOwners.get(variableName) + + if (existingOwner !== undefined) { + if (__DEV__) { + Logger.warn( + `Federated CSS variable "${ + String(variableName) + }" from "${registration.owner}" was dropped because it is already registered by "${existingOwner}". Prefix remote CSS variables to avoid conflicts.`, + ) + } + + continue + } + + variableOwners.set(variableName, registration.owner) + acceptedVariableNames.add(variableName) + } + + for (const variableName of acceptedVariableNames) { + if (Object.hasOwn(registration.config.vars, variableName)) { + vars[variableName] = registration.config.vars[variableName]! + } + } + + for (const [scope, registrationVars] of Object.entries(registration.config.scopedVars)) { + if (!registrationVars) { + continue + } + + const targetVars = scopedVars[scope] ??= {} + + for (const variableName of acceptedVariableNames) { + if (Object.hasOwn(registrationVars, variableName)) { + targetVars[variableName] = registrationVars[variableName]! + } + } + } + } + const platform = this.getCurrentPlatform() const commonPlatform = platform.includes('tv') ? UniwindPlatform.TV : UniwindPlatform.Native const commonPlatformVars = scopedVars[`${UNIWIND_PLATFORM_VARIABLES}${commonPlatform}`] @@ -83,13 +209,15 @@ class UniwindStoreBuilder { Object.assign(clonedVars, themeVars) } + const runtimeVariableOverrides = this.runtimeVariableOverrides[theme] + + if (runtimeVariableOverrides) { + Object.assign(clonedVars, runtimeVariableOverrides) + } + return [theme, clonedVars] })) this.cache = Object.fromEntries(themes.map(theme => [theme, new Map()])) - - if (__DEV__) { - UniwindListener.notifyAll() - } } private resolveStyles( @@ -104,8 +232,8 @@ class UniwindStoreBuilder { let vars = this.vars[theme]! const originalVars = vars let hasDataAttributes = false - const dependencies = new Set() - let dependencySum = 0 + const dependencies = new Set([StyleDependency.Stylesheet]) + let dependencySum = 1 << StyleDependency.Stylesheet const bestBreakpoints = new Map() const isScopedTheme = uniwindContext.scopedTheme !== null const classNameTokens = classNames.includes('\n') diff --git a/packages/uniwind/tests/native/core/federated-styles.test.ts b/packages/uniwind/tests/native/core/federated-styles.test.ts new file mode 100644 index 00000000..a06a2b49 --- /dev/null +++ b/packages/uniwind/tests/native/core/federated-styles.test.ts @@ -0,0 +1,221 @@ +import { StyleDependency } from '../../../src/common/consts' +import { UniwindListener } from '../../../src/core/listener' +import { Logger } from '../../../src/core/logger' +import { UniwindStore } from '../../../src/core/native' +import type { GenerateStyleSheetsCallback, Style, ThemeName, UniwindContextType, Vars } from '../../../src/core/types' + +const context = { + rtl: null, + scopedTheme: null, +} satisfies UniwindContextType + +const createStyle = (className: string, value: string | ((vars: Vars) => string)): Style => ({ + active: null, + className, + complexity: 0, + dataAttributes: null, + dependencies: null, + disabled: null, + entries: [ + [ + 'backgroundColor', + typeof value === 'string' ? () => value : value, + ], + ], + focus: null, + importantProperties: [], + index: 0, + maxWidth: Number.POSITIVE_INFINITY, + minWidth: 0, + native: true, + orientation: null, + rtl: null, + theme: null, +}) + +const createRegistration = ( + styles: Record string)>, + variables: Record = {}, +): GenerateStyleSheetsCallback => +() => ({ + scopedVars: {}, + stylesheet: Object.fromEntries( + Object.entries(styles).map(([className, value]) => [ + className, + [createStyle(className, value)], + ]), + ), + vars: Object.fromEntries( + Object.entries(variables).map(([name, value]) => [ + name, + () => value, + ]), + ), +}) + +const getBackgroundColor = (className: string, theme: ThemeName = 'light') => { + UniwindStore.runtime.currentThemeName = theme + + return UniwindStore.getStyles(className, undefined, undefined, context).styles.backgroundColor +} + +describe('federated native styles', () => { + const disposers: Array = [] + + beforeEach(() => { + UniwindStore.reinit( + createRegistration( + { + 'host-conflict': '#16a34a', + 'host-only': '#16a34a', + 'host-variable': vars => vars['--shared-color'](vars) as string, + }, + { + '--shared-color': '#16a34a', + }, + ), + ['light', 'dark'], + ) + }) + + afterEach(() => { + disposers.splice(0).forEach(dispose => dispose()) + }) + + test('adds explicitly prefixed remote styles without replacing the host', () => { + disposers.push(UniwindStore.merge( + 'remote-a', + createRegistration( + { + 'rma:conflict': '#facc15', + 'rma:only': '#facc15', + 'rma:variable': vars => vars['--rma-shared-color'](vars) as string, + }, + { + '--rma-shared-color': '#facc15', + }, + ), + ['light', 'dark'], + )) + + expect(getBackgroundColor('host-only')).toBe('#16a34a') + expect(getBackgroundColor('host-conflict')).toBe('#16a34a') + expect(getBackgroundColor('host-variable')).toBe('#16a34a') + expect(getBackgroundColor('rma:only')).toBe('#facc15') + expect(getBackgroundColor('rma:conflict')).toBe('#facc15') + expect(getBackgroundColor('rma:variable')).toBe('#facc15') + }) + + test('drops accidental class and variable overrides', () => { + const warn = jest.spyOn(Logger, 'warn').mockImplementation() + + disposers.push(UniwindStore.merge( + 'remote-a', + createRegistration( + { + 'host-conflict': '#facc15', + 'rma:host-variable': vars => vars['--shared-color'](vars) as string, + }, + { + '--shared-color': '#facc15', + }, + ), + ['light', 'dark'], + )) + + expect(getBackgroundColor('host-conflict')).toBe('#16a34a') + expect(getBackgroundColor('host-variable')).toBe('#16a34a') + expect(getBackgroundColor('rma:host-variable')).toBe('#16a34a') + expect(warn).toHaveBeenNthCalledWith( + 1, + 'Federated class "host-conflict" from "remote-a" was dropped because it is already registered by "host". Prefix remote class names to avoid conflicts.', + ) + expect(warn).toHaveBeenNthCalledWith( + 2, + 'Federated CSS variable "--shared-color" from "remote-a" was dropped because it is already registered by "host". Prefix remote CSS variables to avoid conflicts.', + ) + expect(warn).toHaveBeenCalledTimes(2) + + warn.mockRestore() + }) + + test('replaces only the registration with the same owner ID', () => { + const staleDispose = UniwindStore.merge( + 'remote-a', + createRegistration({ 'rma:only': '#facc15' }), + ['light', 'dark'], + ) + const currentDispose = UniwindStore.merge( + 'remote-a', + createRegistration({ 'rma:only': '#2563eb' }), + ['light', 'dark'], + ) + disposers.push(staleDispose, currentDispose) + + staleDispose() + + expect(getBackgroundColor('host-only')).toBe('#16a34a') + expect(getBackgroundColor('rma:only')).toBe('#2563eb') + }) + + test('disposes one remote without changing the host', () => { + const dispose = UniwindStore.merge( + 'remote-a', + createRegistration({ 'rma:only': '#facc15' }), + ['light', 'dark'], + ) + disposers.push(dispose) + + dispose() + + expect(getBackgroundColor('host-only')).toBe('#16a34a') + expect(getBackgroundColor('rma:only')).toBeUndefined() + }) + + test('preserves runtime variable overrides across remote changes', () => { + UniwindStore.updateCSSVariables('light', { + '--shared-color': () => '#ea580c', + }) + const dispose = UniwindStore.merge( + 'remote-a', + createRegistration({ 'rma:only': '#facc15' }), + ['light', 'dark'], + ) + disposers.push(dispose) + + expect(getBackgroundColor('host-variable')).toBe('#ea580c') + + dispose() + + expect(getBackgroundColor('host-variable')).toBe('#ea580c') + }) + + test('notifies static and missing class subscribers when registrations change', () => { + const listener = jest.fn() + const variableListener = jest.fn() + const staticStyle = UniwindStore.getStyles('host-only', undefined, undefined, context) + const missingStyle = UniwindStore.getStyles('rma:only', undefined, undefined, context) + const disposeStaticListener = UniwindListener.subscribe(listener, staticStyle.dependencies) + const disposeMissingListener = UniwindListener.subscribe(listener, missingStyle.dependencies) + const disposeVariableListener = UniwindListener.subscribe(variableListener, [StyleDependency.Variables]) + disposers.push(disposeStaticListener, disposeMissingListener, disposeVariableListener) + + expect(staticStyle.dependencies).toContain(StyleDependency.Stylesheet) + expect(missingStyle.dependencies).toContain(StyleDependency.Stylesheet) + + const disposeRemote = UniwindStore.merge( + 'remote-a', + createRegistration({ 'rma:only': '#facc15' }), + ['light', 'dark'], + ) + disposers.push(disposeRemote) + + expect(listener).toHaveBeenCalledTimes(2) + expect(variableListener).toHaveBeenCalledTimes(1) + + disposeRemote() + + expect(listener).toHaveBeenCalledTimes(4) + expect(variableListener).toHaveBeenCalledTimes(2) + }) +}) From 70fa2e627e49b59609344300c970d1ae1ab46d94 Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 24 Jul 2026 12:11:14 -0700 Subject: [PATCH 6/9] feat(metro): emit federated remote style deltas --- .../src/bundler/adapters/metro/index.d.ts | 4 ++ .../src/bundler/adapters/metro/metro.ts | 4 +- .../src/bundler/adapters/metro/transformer.ts | 8 +++ packages/uniwind/src/bundler/config.ts | 8 +++ .../bundler/css-compiler/compileNativeCSS.ts | 9 ++- packages/uniwind/src/bundler/types.ts | 6 ++ .../native/bundler/federated-css.test.ts | 56 +++++++++++++++++++ 7 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 packages/uniwind/tests/native/bundler/federated-css.test.ts diff --git a/packages/uniwind/src/bundler/adapters/metro/index.d.ts b/packages/uniwind/src/bundler/adapters/metro/index.d.ts index 1784bafe..71b8c0d1 100644 --- a/packages/uniwind/src/bundler/adapters/metro/index.d.ts +++ b/packages/uniwind/src/bundler/adapters/metro/index.d.ts @@ -8,6 +8,10 @@ type UniwindConfig = { cssEntryFile: string extraThemes?: Array dtsFile?: string + federation?: { + role: 'remote' + id: string + } polyfills?: Polyfills debug?: boolean isTV?: boolean diff --git a/packages/uniwind/src/bundler/adapters/metro/metro.ts b/packages/uniwind/src/bundler/adapters/metro/metro.ts index cfb06cff..6fc7762d 100644 --- a/packages/uniwind/src/bundler/adapters/metro/metro.ts +++ b/packages/uniwind/src/bundler/adapters/metro/metro.ts @@ -1,5 +1,5 @@ import { UniwindBundlerConfig } from '@/bundler/config' -import type { UniwindConfig } from '@/bundler/types' +import type { UniwindMetroConfig } from '@/bundler/types' import { Platform } from '@/common/consts' import type { MetroConfig } from 'metro-config' import type { CustomResolver } from 'metro-resolver' @@ -23,7 +23,7 @@ const isExpoMetroConfig = (config: MetroConfig) => { export const withUniwindConfig = ( config: T, - uniwindConfig: UniwindConfig, + uniwindConfig: UniwindMetroConfig, ): T => { const bundlerConfig = UniwindBundlerConfig.fromMetroConfig(uniwindConfig) const pinnedUniwindOrigin = join(config.projectRoot ?? process.cwd(), 'package.json') diff --git a/packages/uniwind/src/bundler/adapters/metro/transformer.ts b/packages/uniwind/src/bundler/adapters/metro/transformer.ts index 08059b0f..9c4d3b76 100644 --- a/packages/uniwind/src/bundler/adapters/metro/transformer.ts +++ b/packages/uniwind/src/bundler/adapters/metro/transformer.ts @@ -73,6 +73,14 @@ export const transform = async ( data = Buffer.from( isWeb ? virtualCode + : bundlerConfig.federation + ? [ + `const { Uniwind } = require('uniwind');`, + `const dispose = Uniwind.__mergeStyles(${ + JSON.stringify(bundlerConfig.federation.id) + }, rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});`, + `if (module.hot) { module.hot.dispose(dispose); }`, + ].join('') : [ `const { Uniwind } = require('uniwind');`, `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});`, diff --git a/packages/uniwind/src/bundler/config.ts b/packages/uniwind/src/bundler/config.ts index f448b405..811e0fdb 100644 --- a/packages/uniwind/src/bundler/config.ts +++ b/packages/uniwind/src/bundler/config.ts @@ -38,6 +38,10 @@ export class UniwindBundlerConfig { ) } + if (config.federation && config.federation.id.trim() === '') { + throw new Error('Uniwind: federation.id must be a non-empty stable remote identifier.') + } + return new UniwindBundlerConfig(config, getPlatform()) } @@ -79,6 +83,10 @@ export class UniwindBundlerConfig { return this.config.polyfills } + get federation() { + return this.config.federation + } + get stringifiedThemes() { return `[${this.themes.map((theme) => `'${theme}'`).join(', ')}]` } diff --git a/packages/uniwind/src/bundler/css-compiler/compileNativeCSS.ts b/packages/uniwind/src/bundler/css-compiler/compileNativeCSS.ts index 404b4ff3..3d5bac8a 100644 --- a/packages/uniwind/src/bundler/css-compiler/compileNativeCSS.ts +++ b/packages/uniwind/src/bundler/css-compiler/compileNativeCSS.ts @@ -6,6 +6,11 @@ export const compileNativeCSS = (bundlerConfig: UniwindBundlerConfig, tailwindCS Processor.transform(tailwindCSS) + // Federated remotes resolve Uniwind's runtime globals from the host. + if (bundlerConfig.federation) { + delete Processor.vars['--uniwind-em'] + } + const stylesheet = serializeJSObject( addMetaToStylesTemplate(Processor, bundlerConfig.platform), (key, value) => `"${key}": ${value}`, @@ -24,7 +29,9 @@ export const compileNativeCSS = (bundlerConfig: UniwindBundlerConfig, tailwindCS const serializedScopedVars = Object.entries(scopedVars) .map(([scopedVarsName, scopedVars]) => `"${scopedVarsName}": ({ ${scopedVars} }),`) .join('') - const currentColorVar = `currentColor: () => rt.colorScheme === 'dark' ? '#ffffff' : '#000000',` + const currentColorVar = bundlerConfig.federation + ? '' + : `currentColor: () => rt.colorScheme === 'dark' ? '#ffffff' : '#000000',` return [ '({', diff --git a/packages/uniwind/src/bundler/types.ts b/packages/uniwind/src/bundler/types.ts index 5f7d831a..23ad8d2c 100644 --- a/packages/uniwind/src/bundler/types.ts +++ b/packages/uniwind/src/bundler/types.ts @@ -1,3 +1,8 @@ +export type UniwindFederationConfig = { + role: 'remote' + id: string +} + export type UniwindConfig = { cssEntryFile: string extraThemes?: Array @@ -9,6 +14,7 @@ export type Polyfills = { } export type UniwindMetroConfig = UniwindConfig & { + federation?: UniwindFederationConfig polyfills?: Polyfills debug?: boolean isExpoProject?: boolean diff --git a/packages/uniwind/tests/native/bundler/federated-css.test.ts b/packages/uniwind/tests/native/bundler/federated-css.test.ts new file mode 100644 index 00000000..260ce80a --- /dev/null +++ b/packages/uniwind/tests/native/bundler/federated-css.test.ts @@ -0,0 +1,56 @@ +import { UniwindBundlerConfig } from '../../../src/bundler/config' +import { compileNativeCSS } from '../../../src/bundler/css-compiler/compileNativeCSS' +import { Platform } from '../../../src/common/consts' +import { Logger } from '../../../src/core/logger' +import { UniwindStore } from '../../../src/core/native' +import type { GenerateStyleSheetsCallback, UniwindContextType } from '../../../src/core/types' + +const context = { + rtl: null, + scopedTheme: null, +} satisfies UniwindContextType + +const compileRegistration = (css: string, federated: boolean): GenerateStyleSheetsCallback => { + const config = UniwindBundlerConfig.fromMetroConfig( + { + cssEntryFile: './unused.css', + ...(federated + ? { + federation: { + role: 'remote' as const, + id: 'remote-a', + }, + } + : {}), + }, + Platform.iOS, + ) + const virtualCode = compileNativeCSS(config, css) + + return rt => { + // oxlint-disable-next-line no-eval + return eval(`(${virtualCode})`) + } +} + +describe('federated native CSS', () => { + test('resolves host-owned runtime globals without remote conflicts', () => { + const warn = jest.spyOn(Logger, 'warn').mockImplementation() + + UniwindStore.reinit(compileRegistration('', false), ['light', 'dark']) + const dispose = UniwindStore.merge( + 'remote-a', + compileRegistration('.runtime-globals { color: currentColor; width: 1em; }', true), + ['light', 'dark'], + ) + + expect(UniwindStore.getStyles('runtime-globals', undefined, undefined, context).styles).toMatchObject({ + color: '#000000', + width: 16, + }) + expect(warn).not.toHaveBeenCalled() + + dispose() + warn.mockRestore() + }) +}) From 911849a4ffd6f5429db22cf72a4612ea54781c6b Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 24 Jul 2026 12:11:47 -0700 Subject: [PATCH 7/9] docs: describe federated style contract --- CONTEXT.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index bdcfe76b..65e19f7f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -63,6 +63,7 @@ Dependency policy: peer dependency floors are support contracts. Raising support Native runtime: - Build output injects a generated stylesheet callback into `Uniwind.__reinit(...)`. +- Federated remote build output registers an owner-keyed style delta instead. - `UniwindStore` holds generated style records, theme variables, scoped variables, runtime state, and per-theme caches. - `UniwindStore.getStyles(className, props, state, context)` resolves classes into React Native style objects. - Cache keys include class names, component state, whether theme is scoped, and explicit layout direction context. @@ -75,7 +76,7 @@ Web runtime: - Web keeps styles in CSS and passes `{ $$css: true, tailwind: className }` through RNW style arrays. - `getWebStyles` uses a hidden DOM element to compute style values when a JS value is needed, such as color extraction or `useResolveClassNames`. -- `CSSListener` tracks active CSS rules and media queries, then notifies subscribers when class-dependent media rules change. +- `CSSListener` tracks active CSS rules and media queries, then notifies subscribers when stylesheets load or class-dependent media rules change. - `ScopedTheme` renders a `div` with the theme class and `display: contents` on web. - `LayoutDirection` renders a contents-style wrapper with `direction`/`dir` semantics so RTL/LTR variants can be scoped to a subtree. - Dynamic CSS variable updates are written into a generated `#uniwind-dynamic-styles` style element. @@ -96,6 +97,7 @@ Configuration shape: - `cssEntryFile`: required CSS entry path, resolved from `process.cwd()`. - `extraThemes`: optional named themes added to default `light` and `dark`. - `dtsFile`: optional generated declaration file path, default `uniwind-types.d.ts`. +- Metro-only `federation`: optional native remote build contract with a stable owner ID. - Metro-only `polyfills.rem`: custom rem base, default `16`. - Metro-only `debug` and `isTV` flags exist in types. @@ -115,6 +117,7 @@ Metro integration: - Metro transformer handles the configured CSS entry file specially. - Metro transformer worker selection is lazy, cached per Expo/non-Expo config type, and follows Expo transformer paths or Expo-specific config markers. - Native platform CSS transforms into a JS module that calls `Uniwind.__reinit(...)`. +- Federated remote native CSS transforms into an owner-keyed merge registration. - Web platform CSS transforms into CSS plus web runtime setup. - Resolver swaps React Native component imports to Uniwind-aware implementations where needed. @@ -163,6 +166,11 @@ Web components: - Web wrappers map `className` to RNW CSS style markers through `toRNWClassName`. - Web wrappers pass generated `dataSet` so data attribute variants can match. +## Federated Style Contract + +- The host owns the base/global CSS. Remotes emit only explicitly prefixed deltas. +- Native deltas merge by owner; existing keys win, same-owner registration replaces, and non-federated `__reinit` behavior is unchanged. + `withUniwind`: - Auto mode maps `className`-style props to matching RN style props and color class props to color props. From 7b4f9f8c619ab6d41096defcc7e7c16899ac87ca Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 24 Jul 2026 12:14:46 -0700 Subject: [PATCH 8/9] feat: add module federation style isolation demo --- apps/module-federation/.gitignore | 9 + apps/module-federation/README.md | 141 ++++++ apps/module-federation/babel.config.js | 17 + .../expo-federation-async-require.js | 75 +++ apps/module-federation/host/app.json | 16 + apps/module-federation/host/babel.config.js | 1 + apps/module-federation/host/global.css | 16 + apps/module-federation/host/global.d.ts | 1 + apps/module-federation/host/index.js | 15 + apps/module-federation/host/metro.config.js | 14 + apps/module-federation/host/package.json | 28 ++ apps/module-federation/host/src/App.tsx | 429 ++++++++++++++++++ apps/module-federation/host/src/Fallback.tsx | 25 + apps/module-federation/host/src/remotes.d.ts | 13 + apps/module-federation/host/tsconfig.json | 4 + .../module-federation/host/uniwind-types.d.ts | 10 + apps/module-federation/metro.shared.js | 189 ++++++++ apps/module-federation/package.json | 11 + apps/module-federation/remote-a/app.json | 15 + .../remote-a/babel.config.js | 1 + apps/module-federation/remote-a/global.d.ts | 1 + apps/module-federation/remote-a/index.js | 5 + .../remote-a/metro.config.js | 14 + apps/module-federation/remote-a/package.json | 28 ++ apps/module-federation/remote-a/remote-a.css | 19 + .../remote-a/src/RemotePanel.tsx | 123 +++++ apps/module-federation/remote-a/tsconfig.json | 4 + .../remote-a/uniwind-types.d.ts | 10 + apps/module-federation/remote-b/app.json | 15 + .../remote-b/babel.config.js | 1 + apps/module-federation/remote-b/global.d.ts | 1 + apps/module-federation/remote-b/index.js | 5 + .../remote-b/metro.config.js | 14 + apps/module-federation/remote-b/package.json | 28 ++ apps/module-federation/remote-b/remote-b.css | 19 + .../remote-b/src/RemotePanel.tsx | 123 +++++ apps/module-federation/remote-b/tsconfig.json | 4 + .../remote-b/uniwind-types.d.ts | 10 + apps/module-federation/remote-hmr-disabled.js | 2 + apps/module-federation/start.mjs | 221 +++++++++ apps/module-federation/stop.mjs | 48 ++ apps/module-federation/tsconfig.base.json | 7 + apps/module-federation/verify-web.mjs | 142 ++++++ bun.lock | 114 +++++ package.json | 5 +- 45 files changed, 1992 insertions(+), 1 deletion(-) create mode 100644 apps/module-federation/.gitignore create mode 100644 apps/module-federation/README.md create mode 100644 apps/module-federation/babel.config.js create mode 100644 apps/module-federation/expo-federation-async-require.js create mode 100644 apps/module-federation/host/app.json create mode 100644 apps/module-federation/host/babel.config.js create mode 100644 apps/module-federation/host/global.css create mode 100644 apps/module-federation/host/global.d.ts create mode 100644 apps/module-federation/host/index.js create mode 100644 apps/module-federation/host/metro.config.js create mode 100644 apps/module-federation/host/package.json create mode 100644 apps/module-federation/host/src/App.tsx create mode 100644 apps/module-federation/host/src/Fallback.tsx create mode 100644 apps/module-federation/host/src/remotes.d.ts create mode 100644 apps/module-federation/host/tsconfig.json create mode 100644 apps/module-federation/host/uniwind-types.d.ts create mode 100644 apps/module-federation/metro.shared.js create mode 100644 apps/module-federation/package.json create mode 100644 apps/module-federation/remote-a/app.json create mode 100644 apps/module-federation/remote-a/babel.config.js create mode 100644 apps/module-federation/remote-a/global.d.ts create mode 100644 apps/module-federation/remote-a/index.js create mode 100644 apps/module-federation/remote-a/metro.config.js create mode 100644 apps/module-federation/remote-a/package.json create mode 100644 apps/module-federation/remote-a/remote-a.css create mode 100644 apps/module-federation/remote-a/src/RemotePanel.tsx create mode 100644 apps/module-federation/remote-a/tsconfig.json create mode 100644 apps/module-federation/remote-a/uniwind-types.d.ts create mode 100644 apps/module-federation/remote-b/app.json create mode 100644 apps/module-federation/remote-b/babel.config.js create mode 100644 apps/module-federation/remote-b/global.d.ts create mode 100644 apps/module-federation/remote-b/index.js create mode 100644 apps/module-federation/remote-b/metro.config.js create mode 100644 apps/module-federation/remote-b/package.json create mode 100644 apps/module-federation/remote-b/remote-b.css create mode 100644 apps/module-federation/remote-b/src/RemotePanel.tsx create mode 100644 apps/module-federation/remote-b/tsconfig.json create mode 100644 apps/module-federation/remote-b/uniwind-types.d.ts create mode 100644 apps/module-federation/remote-hmr-disabled.js create mode 100644 apps/module-federation/start.mjs create mode 100644 apps/module-federation/stop.mjs create mode 100644 apps/module-federation/tsconfig.base.json create mode 100644 apps/module-federation/verify-web.mjs diff --git a/apps/module-federation/.gitignore b/apps/module-federation/.gitignore new file mode 100644 index 00000000..cb82a9a7 --- /dev/null +++ b/apps/module-federation/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.expo/ +dist/ +web-build/ +expo-env.d.ts +ios/ +android/ +*.tsbuildinfo +.servers.pid diff --git a/apps/module-federation/README.md b/apps/module-federation/README.md new file mode 100644 index 00000000..bcf3ec17 --- /dev/null +++ b/apps/module-federation/README.md @@ -0,0 +1,141 @@ +# Module Federation style isolation demo + +This example runs one host and two independently compiled remotes. `react`, +`react-native`, and the exact `uniwind` package root are shared as Module +Federation singletons. It demonstrates Strategy A: + +- The host owns the only full Tailwind entry and Preflight in `host/global.css`. +- Remote A emits a CSS delta with the explicit `rma` prefix and registers its + native delta under the MF container name `remoteA`. +- Remote B emits a CSS delta with the explicit `rmb` prefix and registers its + native delta under the MF container name `remoteB`. + +There are three separate signals in each panel: + +- An owner-only class proves that all three registrations remain installed. +- Each build uses the same semantic `mf-conflict` utility name. Remote selectors + become `rma:mf-conflict` and `rmb:mf-conflict`, so they cannot collide. +- Each build defines `--color-mf-shared`. Tailwind emits remote variables as + `--rma-color-mf-shared` and `--rmb-color-mf-shared`. + +The diagnostic UI uses inline styles except for the colored signal bars. This +keeps the controls independent from the behavior under test. Each signal prints +its declared value and the value currently resolved by Uniwind, so verification +does not depend on distinguishing colors visually. + +## Run + +From the repository root: + +```sh +bun install +bun run --cwd apps/module-federation web +``` + +With the web servers running, a separate terminal can execute the headed +browser assertions used to verify both load orders: + +```sh +bun run --cwd apps/module-federation verify:web +``` + +The verifier uses the repository's existing Playwright installation; the demo +does not add Playwright as a dependency. If Chromium is not installed locally, +run `bunx playwright install chromium` once. + +For the iOS simulator: + +```sh +bun run --cwd apps/module-federation ios +``` + +The iOS command requires Xcode and CocoaPods. It generates the ignored +`host/ios` directory when needed, builds a local debug app, and installs it in +the simulator. It does not use Expo Go. + +Both commands start three Metro servers: + +| Project | Port | +| --- | --- | +| Host | 8081 | +| Remote A | 8082 | +| Remote B | 8083 | + +Stop all three servers with: + +```sh +bun run --cwd apps/module-federation stop +``` + +The remote URLs use `localhost`, so the native example targets the iOS +simulator rather than a physical device. + +Expo is the React Native and Metro base for this example. The native owner +merge and web prefix isolation are not Expo-specific; the compatibility changes +needed to load the three graphs are described below. + +## Strategy A integration + +The remotes deliberately write their prefixes in both CSS and source +`className` values. There is no `StyleNamespace`, runtime class mapping, or +shared-class contract in this strategy. + +`metro.shared.js` derives Uniwind's native owner ID from the existing Module +Federation `name`. The host keeps the normal `__reinit` path. Remote CSS modules +emit `__mergeStyles(remoteName, ...)`, so each remote replaces only its own +registration during HMR and disposes only its own registration. + +On web, Tailwind prefixing isolates generated selectors and theme variables. +The remote entries import only `tailwindcss/theme.css`, +`tailwindcss/utilities.css`, and `uniwind`; they do not import Preflight. + +## MF/Expo compatibility changes + +The following integration code exists only to get three independent Metro +graphs into one runtime: + +| Change | Why it is needed here | Production meaning | +| --- | --- | --- | +| Share `react`, `react-native`, and the exact `uniwind` root as versioned singletons; remotes use `import: false` | React and React Native cannot be duplicated safely, and native style deltas must reach the host's Uniwind store. Uniwind resolver-generated component subpaths are separate modules and are not made singletons by this entry. | Production needs an explicit sharing contract for the Uniwind root and public subpaths so all wrappers use one runtime/store graph. | +| Apply `withModuleFederation` before `withUniwindConfig`, then explicitly route generated `.mf-metro` imports through MF's resolver | Both wrappers own `resolveRequest`. Without explicit composition, Uniwind can rewrite MF runtime/provider imports and the remote container fails to initialize. | Production Metro configuration needs equivalent resolver composition until the integrations compose automatically. | +| Resolve package-internal Uniwind self-imports with the base resolver | Letting MF turn imports originating inside the Uniwind package back into its shared proxy can create provider cycles or route web-injected modules through the wrong resolver. | This origin-path exception is a temporary interoperability shim; the integrations need a defined resolver delegation contract. | +| Import `mf:init-host` explicitly and reinstall `mf:async-require` after importing Expo | Expo's web virtual entry bypasses MF's generated host-entry stub, and Expo installs its own split-bundle loader during startup. Explicit ordering makes startup deterministic on web and iOS. | An Expo-based production host needs equivalent startup ordering until MF handles Expo entries directly. | +| Capture Metro's active `__r` as `__r` in `getRunModuleStatement` | MF emits prefixed require calls, but its experimental runtime patch does not reach Expo 57's prepended Metro runtime. Without the alias, evaluated remote modules cannot execute. | Demo-only Expo 57/MF bridge. It preserves each prefixed require function but does not restore unprefixed global Metro state after evaluating a remote; production needs an upstream graph-safe runtime integration. | +| Replace `mf:async-require` with `expo-federation-async-require.js` | MF's adapter wraps a prefixed `__loadBundleAsync` that is not initialized in this Expo 57 startup path, causing `loadBundleAsync is not a function`. The local adapter captures its graph prefix at module initialization, then fetches/evaluates bundles and populates that graph's shared/remote registries. | This implementation is a demo compatibility bridge, not a proposed production loader. A production app should use an upstream-supported loader/cache implementation with the same graph isolation and registry semantics. | +| Resolve `mf:remote-hmr` to a no-op | MF imports this module only from generated remote entries. Its implementation uses a native React Native deep import on web, and cross-registering remote entry points against the host graph caused Metro to resolve nonexistent host modules such as `./remoteA`. | Development-only. Production bundles have no HMR; a development environment needs graph-aware remote HMR before this can be re-enabled. This resolver rule does not change the host entry's HMR configuration. | +| Add CORS headers on all three Metro servers | Web manifests and bundles are fetched across ports 8081-8083. | Required only when production remotes are served from different origins and those origins do not already provide suitable CORS headers. | +| Alias `culori` to `culori/require` in Babel | This is the same Expo/Uniwind compatibility used by `apps/expo-example`; it selects Culori's bundled CommonJS entry for Metro. | Not specific to Module Federation. Keep only while the chosen Metro setup needs it. | +| Build a local iOS host instead of opening Expo Go | Expo Go's shell failed to reconnect reliably to the three-server setup even after all bundles compiled. The local app is also representative of how a native MF host is shipped. | Production always uses an app-owned native binary. The generated debug project is ignored here because Expo config can regenerate it. | + +The audit removed two earlier experiments: remote URLs are no longer rewritten +with `hot=false`, and no custom rule disables host HMR. Both load orders work +without those overrides. Full `--no-dev` mode was also rejected because it +prevents the local development runtime from loading normally. + +## Verify + +1. Confirm all three host signals resolve to `#16a34a`. +2. Load Remote A, wait for it to render, then load Remote B. +3. Confirm Remote A stays `#facc15`, Remote B stays `#2563eb`, and the host + stays `#16a34a`. +4. Use `Reload runtime`. +5. Load Remote B, wait for it to render, then load Remote A. +6. Confirm the same values remain stable. + +A full reload is required between scenarios because loaded JavaScript modules +and web stylesheets remain registered for the lifetime of the runtime. + +Expected on web and native: all nine signals retain their declared values in +both load orders. Web selectors and variables are unique by prefix. Native +styles and variables are merged by owner, with the host registration taking +precedence over accidental unprefixed conflicts. + +## Remaining upstream work + +1. Define composable resolver delegation between Uniwind, Module Federation, + Expo, and other Metro wrappers. +2. Share the Uniwind root and resolver-generated public subpaths as one + runtime/store graph. +3. Provide an Expo-compatible MF entry/split loader and graph-safe prefixed + Metro runtime so the local async loader and require bridge are unnecessary. +4. Make remote HMR graph-aware and platform-safe. diff --git a/apps/module-federation/babel.config.js b/apps/module-federation/babel.config.js new file mode 100644 index 00000000..4aebb915 --- /dev/null +++ b/apps/module-federation/babel.config.js @@ -0,0 +1,17 @@ +module.exports = function(api) { + api.cache(true) + + return { + presets: ['babel-preset-expo'], + plugins: [ + [ + 'module-resolver', + { + alias: { + 'culori': 'culori/require', + }, + }, + ], + ], + } +} diff --git a/apps/module-federation/expo-federation-async-require.js b/apps/module-federation/expo-federation-async-require.js new file mode 100644 index 00000000..cd982eb9 --- /dev/null +++ b/apps/module-federation/expo-federation-async-require.js @@ -0,0 +1,75 @@ +const cache = new Map() +const metroGlobalPrefix = __METRO_GLOBAL_PREFIX__ + +const isUrl = value => /^https?:\/\//.test(value) + +const getPublicPath = origin => origin?.split('/').slice(0, -1).join('/') + +const getBundleId = (bundlePath, publicPath) => { + let value = bundlePath + + if (isUrl(value)) { + value = value.replace(publicPath, '') + } + + return value + .replace(/^\/+/, '') + .split('?')[0] + .replaceAll('\\', '/') + .replace('.bundle', '') +} + +const loadBundle = async url => { + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Failed to load ${url}: ${response.status} ${response.statusText}`) + } + + const code = await response.text() + globalThis.eval(code) +} + +globalThis[`${metroGlobalPrefix}__loadBundleAsync`] = async bundlePath => { + const scope = globalThis.__FEDERATION__?.__NATIVE__?.[metroGlobalPrefix] + + if (!scope) { + throw new Error( + `Missing Module Federation scope for "${metroGlobalPrefix}" while loading "${bundlePath}"`, + ) + } + + const publicPath = getPublicPath(scope.origin) + + if (!isUrl(bundlePath) && !publicPath) { + throw new Error( + `Missing Module Federation origin for "${metroGlobalPrefix}" while loading relative bundle "${bundlePath}"`, + ) + } + + const url = isUrl(bundlePath) + ? bundlePath + : new URL(bundlePath, `${publicPath}/`).toString() + + let pending = cache.get(url) + + if (!pending) { + pending = loadBundle(url).catch(error => { + cache.delete(url) + throw error + }) + cache.set(url, pending) + } + + await pending + + const bundleId = getBundleId(url, publicPath) + const shared = scope.deps.shared[bundleId] ?? [] + const remotes = scope.deps.remotes[bundleId] ?? [] + const registry = require('mf:remote-module-registry') + + await Promise.all([ + ...shared.map(registry.loadSharedToRegistry), + ...remotes.map(registry.loadRemoteToRegistry), + ]) +} diff --git a/apps/module-federation/host/app.json b/apps/module-federation/host/app.json new file mode 100644 index 00000000..c89d007f --- /dev/null +++ b/apps/module-federation/host/app.json @@ -0,0 +1,16 @@ +{ + "expo": { + "name": "Uniwind Module Federation Host", + "slug": "uniwind-module-federation-host", + "version": "1.0.0", + "orientation": "default", + "userInterfaceStyle": "light", + "ios": { + "bundleIdentifier": "dev.uniwind.modulefederation", + "supportsTablet": true + }, + "web": { + "bundler": "metro" + } + } +} diff --git a/apps/module-federation/host/babel.config.js b/apps/module-federation/host/babel.config.js new file mode 100644 index 00000000..a8367a08 --- /dev/null +++ b/apps/module-federation/host/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../babel.config') diff --git a/apps/module-federation/host/global.css b/apps/module-federation/host/global.css new file mode 100644 index 00000000..cbc7f38b --- /dev/null +++ b/apps/module-federation/host/global.css @@ -0,0 +1,16 @@ +@import 'tailwindcss'; +@import 'uniwind'; + +@source inline("mf-host-only mf-conflict bg-mf-shared"); + +@theme { + --color-mf-shared: #16a34a; +} + +@utility mf-host-only { + background-color: #16a34a; +} + +@utility mf-conflict { + background-color: #16a34a; +} diff --git a/apps/module-federation/host/global.d.ts b/apps/module-federation/host/global.d.ts new file mode 100644 index 00000000..5894ae0b --- /dev/null +++ b/apps/module-federation/host/global.d.ts @@ -0,0 +1 @@ +declare module '*.css' diff --git a/apps/module-federation/host/index.js b/apps/module-federation/host/index.js new file mode 100644 index 00000000..6b20df9f --- /dev/null +++ b/apps/module-federation/host/index.js @@ -0,0 +1,15 @@ +import 'mf:init-host' + +import { withAsyncStartup } from '@module-federation/metro/bootstrap' +import { registerRootComponent } from 'expo' + +// Expo installs its generic split loader while importing registerRootComponent. +// Install MF's loader afterwards so remote bundles use the federated registry. +require('mf:async-require') + +registerRootComponent( + withAsyncStartup( + () => require('./src/App'), + () => require('./src/Fallback'), + )(), +) diff --git a/apps/module-federation/host/metro.config.js b/apps/module-federation/host/metro.config.js new file mode 100644 index 00000000..66193a97 --- /dev/null +++ b/apps/module-federation/host/metro.config.js @@ -0,0 +1,14 @@ +const { createMetroConfig } = require('../metro.shared') + +module.exports = createMetroConfig({ + cssEntryFile: 'global.css', + projectRoot: __dirname, + federation: { + name: 'uniwindHost', + remotes: { + remoteA: 'remoteA@http://localhost:8082/mf-manifest.json', + remoteB: 'remoteB@http://localhost:8083/mf-manifest.json', + }, + shareStrategy: 'loaded-first', + }, +}) diff --git a/apps/module-federation/host/package.json b/apps/module-federation/host/package.json new file mode 100644 index 00000000..88270c27 --- /dev/null +++ b/apps/module-federation/host/package.json @@ -0,0 +1,28 @@ +{ + "name": "uniwind-module-federation-host", + "version": "1.0.0", + "private": true, + "main": "index.js", + "scripts": { + "check:typescript": "tsc --noEmit", + "start": "expo start" + }, + "dependencies": { + "@expo/metro-runtime": "57.0.2", + "@module-federation/metro": "catalog:", + "@module-federation/runtime": "catalog:", + "expo": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-native": "catalog:", + "react-native-web": "catalog:", + "tailwindcss": "catalog:", + "uniwind": "workspace:*" + }, + "devDependencies": { + "@babel/core": "7.29.0", + "@types/react": "catalog:", + "babel-plugin-module-resolver": "5.0.3", + "typescript": "catalog:" + } +} diff --git a/apps/module-federation/host/src/App.tsx b/apps/module-federation/host/src/App.tsx new file mode 100644 index 00000000..c34da436 --- /dev/null +++ b/apps/module-federation/host/src/App.tsx @@ -0,0 +1,429 @@ +import '../global.css' + +import { Component, lazy, Suspense, useEffect, useState } from 'react' +import { + ActivityIndicator, + DevSettings, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native' +import { useResolveClassNames, withUniwind } from 'uniwind' + +const RemoteA = lazy(() => import('remoteA/Panel')) +const RemoteB = lazy(() => import('remoteB/Panel')) +const StyledView = withUniwind(View) + +type RemoteId = 'A' | 'B' + +type RemoteErrorBoundaryProps = { + children: React.ReactNode + name: string +} + +type RemoteErrorBoundaryState = { + error: Error | null +} + +class RemoteErrorBoundary extends Component { + state: RemoteErrorBoundaryState = { + error: null, + } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + render() { + if (this.state.error) { + return ( + + {this.props.name} failed to load + {this.state.error.message} + Use Reload runtime before trying again. + + ) + } + + return this.props.children + } +} + +type ReadyBoundaryProps = { + children: React.ReactNode + id: RemoteId + onReady: (id: RemoteId) => void +} + +function ReadyBoundary({ children, id, onReady }: ReadyBoundaryProps) { + useEffect(() => { + onReady(id) + }, [id, onReady]) + + return children +} + +type SignalProps = { + className: string + label: string + testID: string +} + +function formatObservedColor(value: unknown) { + return typeof value === 'string' && value !== '' + ? value + : 'not registered' +} + +function Signal({ className, label, testID }: SignalProps) { + const { backgroundColor } = useResolveClassNames(className) + + return ( + + {label} + + Observed now: {formatObservedColor(backgroundColor)} + + + + + + ) +} + +function App() { + const [requested, setRequested] = useState>([]) + const [loaded, setLoaded] = useState>([]) + const isLoading = requested.length !== loaded.length + + const requestRemote = (id: RemoteId) => { + if (requested.includes(id) || isLoading) { + return + } + + setRequested(current => [...current, id]) + } + + const markLoaded = (id: RemoteId) => { + setLoaded(current => current.includes(id) ? current : [...current, id]) + } + + const reloadRuntime = () => { + if (Platform.OS === 'web') { + globalThis.location.reload() + return + } + + DevSettings.reload() + } + + return ( + + + UNIWIND / MODULE FEDERATION + Three bundles, stable styles + + The host owns global CSS. Each remote contributes a prefixed delta on web and an owner-keyed merge on native. Load both orders; + every signal must keep its declared color. + + + + + requestRemote('A')} + style={({ pressed }) => [ + styles.loadButton, + styles.remoteAButton, + (requested.includes('A') || isLoading) && styles.disabledButton, + pressed && styles.pressedButton, + ]} + > + Load Remote A + + requestRemote('B')} + style={({ pressed }) => [ + styles.loadButton, + styles.remoteBButton, + (requested.includes('B') || isLoading) && styles.disabledButton, + pressed && styles.pressedButton, + ]} + > + Load Remote B + + [ + styles.reloadButton, + pressed && styles.pressedButton, + ]} + > + Reload runtime + + + + + Load order: {loaded.length === 0 ? 'host only' : `Host -> ${loaded.join(' -> ')}`} + + + + + + + Host + Owns global.css and declares green (#16a34a) + + + + + + + + {requested.includes('A') && ( + + }> + + + + + + )} + + {requested.includes('B') && ( + + }> + + + + + + )} + + + What this proves + + Remote source uses explicit rma: and rmb: classes, including prefixed theme variables. Web selectors cannot collide. Native + registrations merge under remoteA and remoteB instead of replacing the host. All nine bars remain stable in either load order. + + + + ) +} + +function LoadingRemote({ name }: { name: string }) { + return ( + + + Loading {name}... + + ) +} + +const styles = StyleSheet.create({ + page: { + flexGrow: 1, + backgroundColor: '#f4f1e8', + paddingHorizontal: 24, + paddingVertical: 48, + gap: 18, + }, + header: { + maxWidth: 760, + gap: 8, + }, + eyebrow: { + color: '#5b5a54', + fontSize: 12, + fontWeight: '700', + letterSpacing: 2, + }, + title: { + color: '#151513', + fontSize: 42, + fontWeight: '800', + letterSpacing: -1.5, + }, + intro: { + color: '#4b4a45', + fontSize: 16, + lineHeight: 24, + maxWidth: 680, + }, + controls: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + loadButton: { + borderRadius: 6, + paddingHorizontal: 18, + paddingVertical: 12, + }, + remoteAButton: { + backgroundColor: '#facc15', + }, + remoteBButton: { + backgroundColor: '#1d4ed8', + }, + reloadButton: { + borderColor: '#272722', + borderRadius: 6, + borderWidth: 1, + paddingHorizontal: 18, + paddingVertical: 11, + }, + buttonText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '700', + }, + remoteAButtonText: { + color: '#422006', + }, + reloadButtonText: { + color: '#272722', + fontSize: 14, + fontWeight: '700', + }, + disabledButton: { + opacity: 0.35, + }, + pressedButton: { + opacity: 0.7, + }, + order: { + color: '#272722', + fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }), + fontSize: 13, + }, + panel: { + backgroundColor: '#fffdf6', + borderColor: '#d6d0bf', + borderRadius: 8, + borderWidth: 1, + gap: 14, + maxWidth: 760, + padding: 18, + }, + panelHeading: { + alignItems: 'center', + flexDirection: 'row', + gap: 10, + }, + ownerDot: { + borderRadius: 10, + height: 12, + width: 12, + }, + hostDot: { + backgroundColor: '#16a34a', + }, + panelTitle: { + color: '#151513', + fontSize: 19, + fontWeight: '800', + }, + panelMeta: { + color: '#706e65', + fontSize: 12, + }, + signalRow: { + gap: 6, + }, + signalLabel: { + color: '#3d3c37', + fontSize: 13, + fontWeight: '600', + }, + observedLabel: { + color: '#111827', + fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }), + fontSize: 12, + fontWeight: '700', + }, + signalTrack: { + backgroundColor: '#e5e1d6', + borderColor: '#c9c3b4', + borderRadius: 4, + borderWidth: 1, + height: 34, + overflow: 'hidden', + }, + signalBar: { + height: '100%', + width: '100%', + }, + loading: { + alignItems: 'center', + backgroundColor: '#fffdf6', + borderColor: '#d6d0bf', + borderRadius: 8, + borderWidth: 1, + flexDirection: 'row', + gap: 10, + maxWidth: 760, + padding: 18, + }, + loadingText: { + color: '#3d3c37', + fontSize: 14, + }, + errorPanel: { + backgroundColor: '#fff7ed', + borderColor: '#c2410c', + borderRadius: 8, + borderWidth: 1, + gap: 8, + maxWidth: 760, + padding: 18, + }, + errorTitle: { + color: '#7c2d12', + fontSize: 16, + fontWeight: '800', + }, + errorText: { + color: '#9a3412', + fontSize: 13, + lineHeight: 19, + }, + explanation: { + backgroundColor: '#22221e', + borderRadius: 8, + gap: 8, + maxWidth: 760, + padding: 18, + }, + explanationTitle: { + color: '#f7f2e5', + fontSize: 15, + fontWeight: '800', + }, + explanationText: { + color: '#cbc5b7', + fontSize: 14, + lineHeight: 21, + }, +}) + +export default App diff --git a/apps/module-federation/host/src/Fallback.tsx b/apps/module-federation/host/src/Fallback.tsx new file mode 100644 index 00000000..706fe16d --- /dev/null +++ b/apps/module-federation/host/src/Fallback.tsx @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' + +export default function Fallback() { + return ( + + + Initializing Module Federation... + + ) +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + backgroundColor: '#f4f1e8', + flex: 1, + gap: 12, + justifyContent: 'center', + }, + text: { + color: '#272722', + fontSize: 14, + fontWeight: '600', + }, +}) diff --git a/apps/module-federation/host/src/remotes.d.ts b/apps/module-federation/host/src/remotes.d.ts new file mode 100644 index 00000000..1ae16889 --- /dev/null +++ b/apps/module-federation/host/src/remotes.d.ts @@ -0,0 +1,13 @@ +declare module 'remoteA/Panel' { + import type { ComponentType } from 'react' + + const RemotePanel: ComponentType + export default RemotePanel +} + +declare module 'remoteB/Panel' { + import type { ComponentType } from 'react' + + const RemotePanel: ComponentType + export default RemotePanel +} diff --git a/apps/module-federation/host/tsconfig.json b/apps/module-federation/host/tsconfig.json new file mode 100644 index 00000000..840ae607 --- /dev/null +++ b/apps/module-federation/host/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src", "global.d.ts"] +} diff --git a/apps/module-federation/host/uniwind-types.d.ts b/apps/module-federation/host/uniwind-types.d.ts new file mode 100644 index 00000000..cc099419 --- /dev/null +++ b/apps/module-federation/host/uniwind-types.d.ts @@ -0,0 +1,10 @@ +// NOTE: This file is generated by uniwind and it should not be edited manually. +/// + +declare module 'uniwind' { + export interface UniwindConfig { + themes: readonly ['light', 'dark'] + } +} + +export {} diff --git a/apps/module-federation/metro.shared.js b/apps/module-federation/metro.shared.js new file mode 100644 index 00000000..64f79b51 --- /dev/null +++ b/apps/module-federation/metro.shared.js @@ -0,0 +1,189 @@ +const { getDefaultConfig } = require('expo/metro-config') +const { withModuleFederation } = require('@module-federation/metro') +const { withUniwindConfig } = require('uniwind/metro') +const path = require('path') + +const REACT_VERSION = require('react/package.json').version +const REACT_NATIVE_VERSION = require('react-native/package.json').version +const UNIWIND_VERSION = require('uniwind/package.json').version + +const withRuntimeRequireBridge = (config, federationName) => { + const getRunModuleStatement = config.serializer.getRunModuleStatement + + return { + ...config, + serializer: { + ...config.serializer, + getRunModuleStatement: moduleId => + [ + `globalThis[${JSON.stringify(`${federationName}__r`)}] ??= globalThis.__r;`, + getRunModuleStatement(moduleId), + ].join('\n'), + }, + } +} + +const withCrossOriginRequests = config => { + const enhanceMiddleware = config.server.enhanceMiddleware + + return { + ...config, + server: { + ...config.server, + enhanceMiddleware: (middleware, metroServer) => { + const enhancedMiddleware = enhanceMiddleware?.(middleware, metroServer) ?? middleware + + return (request, response, next) => { + response.setHeader('Access-Control-Allow-Origin', '*') + response.setHeader('Access-Control-Allow-Headers', '*') + + if (request.method === 'OPTIONS') { + response.statusCode = 204 + response.end() + return + } + + return enhancedMiddleware(request, response, next) + } + }, + }, + } +} + +const withFederationRuntimeResolver = (uniwindConfig, federatedConfig, baseConfig, projectRoot) => { + const baseResolver = baseConfig.resolver.resolveRequest + const federationResolver = federatedConfig.resolver.resolveRequest + const uniwindResolver = uniwindConfig.resolver.resolveRequest + const federationRuntimeRoot = `${path.join(projectRoot, 'node_modules/.mf-metro')}${path.sep}` + const uniwindRoot = `${path.dirname(require.resolve('uniwind/package.json'))}${path.sep}` + const asyncRequire = path.resolve(__dirname, 'expo-federation-async-require.js') + const disabledHmr = path.resolve(__dirname, 'remote-hmr-disabled.js') + const resolveWithBaseConfig = (context, moduleName, platform) => { + if (baseResolver) { + return baseResolver(context, moduleName, platform) + } + + return context.resolveRequest(context, moduleName, platform) + } + + return { + ...uniwindConfig, + resolver: { + ...uniwindConfig.resolver, + resolveRequest: (context, moduleName, platform) => { + // Expo 57 does not initialize the prefixed loader that MF wraps, + // causing `loadBundleAsync is not a function`. Use an adapter + // that evaluates the bundle and updates MF's module registries. + if ( + moduleName === 'mf:async-require' + ) { + return { + type: 'sourceFile', + filePath: asyncRequire, + } + } + + // MF imports this virtual module only from generated remote + // entries, not from the host entry. Its implementation also + // imports a native-only client on web. + if ( + moduleName === 'mf:remote-hmr' + ) { + return { + type: 'sourceFile', + filePath: disabledHmr, + } + } + + if ( + context.originModulePath.startsWith(uniwindRoot) + && (moduleName === 'uniwind' || moduleName.startsWith('uniwind/')) + ) { + return resolveWithBaseConfig(context, moduleName, platform) + } + + if (context.originModulePath.startsWith(federationRuntimeRoot)) { + return federationResolver(context, moduleName, platform) + } + + return uniwindResolver(context, moduleName, platform) + }, + }, + } +} + +const getSharedDependencies = role => ({ + react: { + singleton: true, + eager: role === 'host', + ...(role === 'remote' ? { import: false } : {}), + requiredVersion: REACT_VERSION, + version: REACT_VERSION, + }, + 'react-native': { + singleton: true, + eager: role === 'host', + ...(role === 'remote' ? { import: false } : {}), + requiredVersion: REACT_NATIVE_VERSION, + version: REACT_NATIVE_VERSION, + }, + uniwind: { + singleton: true, + eager: role === 'host', + ...(role === 'remote' ? { import: false } : {}), + requiredVersion: UNIWIND_VERSION, + version: UNIWIND_VERSION, + }, +}) + +const createMetroConfig = ({ + cssEntryFile, + projectRoot, + federation, +}) => { + const isRemote = Boolean(federation.exposes) + const workspaceRoot = path.resolve(projectRoot, '../../..') + const config = getDefaultConfig(projectRoot) + + config.watchFolders = [workspaceRoot] + config.resolver.nodeModulesPaths = [ + path.join(projectRoot, 'node_modules'), + path.join(workspaceRoot, 'node_modules'), + ] + + const federatedConfig = withModuleFederation( + config, + { + ...federation, + shared: getSharedDependencies(federation.exposes ? 'remote' : 'host'), + }, + ) + + // MF's runtime Babel patch does not currently reach Expo 57's prepended + // Metro runtime. Capture the active runtime under the prefix its serializer + // emits so this reproduction can focus on Uniwind's global style state. + const corsConfig = withCrossOriginRequests(federatedConfig) + const bridgedConfig = withRuntimeRequireBridge(corsConfig, federation.name) + + // Uniwind must remain the outer wrapper so its resolver delegates to the + // Module Federation additions rather than replacing them. + const uniwindConfig = withUniwindConfig(bridgedConfig, { + cssEntryFile, + // MF's container name is the stable owner used to replace or dispose + // this remote's native style delta without reinitializing the host. + ...(isRemote + ? { + federation: { + role: 'remote', + id: federation.name, + }, + } + : {}), + }) + + return withFederationRuntimeResolver(uniwindConfig, bridgedConfig, config, projectRoot) +} + +module.exports = { + createMetroConfig, +} diff --git a/apps/module-federation/package.json b/apps/module-federation/package.json new file mode 100644 index 00000000..5071e36c --- /dev/null +++ b/apps/module-federation/package.json @@ -0,0 +1,11 @@ +{ + "name": "uniwind-module-federation-example", + "version": "1.0.0", + "private": true, + "scripts": { + "ios": "bun ./start.mjs ios", + "stop": "bun ./stop.mjs", + "verify:web": "node ./verify-web.mjs", + "web": "bun ./start.mjs web" + } +} diff --git a/apps/module-federation/remote-a/app.json b/apps/module-federation/remote-a/app.json new file mode 100644 index 00000000..3567bdc1 --- /dev/null +++ b/apps/module-federation/remote-a/app.json @@ -0,0 +1,15 @@ +{ + "expo": { + "name": "Uniwind Module Federation Remote A", + "slug": "uniwind-module-federation-remote-a", + "version": "1.0.0", + "orientation": "default", + "userInterfaceStyle": "light", + "ios": { + "supportsTablet": true + }, + "web": { + "bundler": "metro" + } + } +} diff --git a/apps/module-federation/remote-a/babel.config.js b/apps/module-federation/remote-a/babel.config.js new file mode 100644 index 00000000..a8367a08 --- /dev/null +++ b/apps/module-federation/remote-a/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../babel.config') diff --git a/apps/module-federation/remote-a/global.d.ts b/apps/module-federation/remote-a/global.d.ts new file mode 100644 index 00000000..5894ae0b --- /dev/null +++ b/apps/module-federation/remote-a/global.d.ts @@ -0,0 +1 @@ +declare module '*.css' diff --git a/apps/module-federation/remote-a/index.js b/apps/module-federation/remote-a/index.js new file mode 100644 index 00000000..ff19c077 --- /dev/null +++ b/apps/module-federation/remote-a/index.js @@ -0,0 +1,5 @@ +import { AppRegistry } from 'react-native' + +import RemotePanel from './src/RemotePanel' + +AppRegistry.registerComponent('main', () => RemotePanel) diff --git a/apps/module-federation/remote-a/metro.config.js b/apps/module-federation/remote-a/metro.config.js new file mode 100644 index 00000000..ab882ea5 --- /dev/null +++ b/apps/module-federation/remote-a/metro.config.js @@ -0,0 +1,14 @@ +const { createMetroConfig } = require('../metro.shared') + +module.exports = createMetroConfig({ + cssEntryFile: 'remote-a.css', + projectRoot: __dirname, + federation: { + name: 'remoteA', + filename: 'remoteA.bundle', + exposes: { + './Panel': './src/RemotePanel.tsx', + }, + shareStrategy: 'version-first', + }, +}) diff --git a/apps/module-federation/remote-a/package.json b/apps/module-federation/remote-a/package.json new file mode 100644 index 00000000..71d7f167 --- /dev/null +++ b/apps/module-federation/remote-a/package.json @@ -0,0 +1,28 @@ +{ + "name": "uniwind-module-federation-remote-a", + "version": "1.0.0", + "private": true, + "main": "index.js", + "scripts": { + "check:typescript": "tsc --noEmit", + "start": "expo start" + }, + "dependencies": { + "@expo/metro-runtime": "57.0.2", + "@module-federation/metro": "catalog:", + "@module-federation/runtime": "catalog:", + "expo": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-native": "catalog:", + "react-native-web": "catalog:", + "tailwindcss": "catalog:", + "uniwind": "workspace:*" + }, + "devDependencies": { + "@babel/core": "7.29.0", + "@types/react": "catalog:", + "babel-plugin-module-resolver": "5.0.3", + "typescript": "catalog:" + } +} diff --git a/apps/module-federation/remote-a/remote-a.css b/apps/module-federation/remote-a/remote-a.css new file mode 100644 index 00000000..200a3931 --- /dev/null +++ b/apps/module-federation/remote-a/remote-a.css @@ -0,0 +1,19 @@ +@layer theme, base, components, utilities; + +@import 'tailwindcss/theme.css' layer(theme) prefix(rma); +@import 'tailwindcss/utilities.css' layer(utilities) prefix(rma); +@import 'uniwind'; + +@source inline("rma:mf-remote-a-only rma:mf-conflict rma:bg-mf-shared"); + +@theme { + --color-mf-shared: #facc15; +} + +@utility mf-remote-a-only { + background-color: #facc15; +} + +@utility mf-conflict { + background-color: #facc15; +} diff --git a/apps/module-federation/remote-a/src/RemotePanel.tsx b/apps/module-federation/remote-a/src/RemotePanel.tsx new file mode 100644 index 00000000..0c6dd564 --- /dev/null +++ b/apps/module-federation/remote-a/src/RemotePanel.tsx @@ -0,0 +1,123 @@ +import '../remote-a.css' + +import { Platform, StyleSheet, Text, View } from 'react-native' +import { useResolveClassNames, withUniwind } from 'uniwind' + +const StyledView = withUniwind(View) + +function formatObservedColor(value: unknown) { + return typeof value === 'string' && value !== '' + ? value + : 'not registered' +} + +function Signal({ className, label, testID }: { className: string; label: string; testID: string }) { + const { backgroundColor } = useResolveClassNames(className) + + return ( + + {label} + + Observed now: {formatObservedColor(backgroundColor)} + + + + + + ) +} + +export default function RemotePanel() { + return ( + + + + + Remote A + Prefix rma / yellow (#facc15) / port 8082 + + + remoteA/Panel + + + + + ) +} + +const styles = StyleSheet.create({ + panel: { + backgroundColor: '#fefce8', + borderColor: '#fde047', + borderRadius: 8, + borderWidth: 1, + gap: 14, + maxWidth: 760, + padding: 18, + }, + panelHeading: { + alignItems: 'center', + flexDirection: 'row', + gap: 10, + }, + ownerDot: { + borderRadius: 10, + height: 12, + width: 12, + }, + remoteDot: { + backgroundColor: '#facc15', + }, + panelTitle: { + color: '#422006', + fontSize: 19, + fontWeight: '800', + }, + panelMeta: { + color: '#854d0e', + fontSize: 12, + }, + moduleId: { + color: '#713f12', + fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }), + fontSize: 12, + }, + signalRow: { + gap: 6, + }, + signalLabel: { + color: '#713f12', + fontSize: 13, + fontWeight: '600', + }, + observedLabel: { + color: '#422006', + fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }), + fontSize: 12, + fontWeight: '700', + }, + signalTrack: { + backgroundColor: '#fef9c3', + borderColor: '#fde047', + borderRadius: 4, + borderWidth: 1, + height: 34, + overflow: 'hidden', + }, + signalBar: { + height: '100%', + width: '100%', + }, +}) diff --git a/apps/module-federation/remote-a/tsconfig.json b/apps/module-federation/remote-a/tsconfig.json new file mode 100644 index 00000000..840ae607 --- /dev/null +++ b/apps/module-federation/remote-a/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src", "global.d.ts"] +} diff --git a/apps/module-federation/remote-a/uniwind-types.d.ts b/apps/module-federation/remote-a/uniwind-types.d.ts new file mode 100644 index 00000000..cc099419 --- /dev/null +++ b/apps/module-federation/remote-a/uniwind-types.d.ts @@ -0,0 +1,10 @@ +// NOTE: This file is generated by uniwind and it should not be edited manually. +/// + +declare module 'uniwind' { + export interface UniwindConfig { + themes: readonly ['light', 'dark'] + } +} + +export {} diff --git a/apps/module-federation/remote-b/app.json b/apps/module-federation/remote-b/app.json new file mode 100644 index 00000000..93f216db --- /dev/null +++ b/apps/module-federation/remote-b/app.json @@ -0,0 +1,15 @@ +{ + "expo": { + "name": "Uniwind Module Federation Remote B", + "slug": "uniwind-module-federation-remote-b", + "version": "1.0.0", + "orientation": "default", + "userInterfaceStyle": "light", + "ios": { + "supportsTablet": true + }, + "web": { + "bundler": "metro" + } + } +} diff --git a/apps/module-federation/remote-b/babel.config.js b/apps/module-federation/remote-b/babel.config.js new file mode 100644 index 00000000..a8367a08 --- /dev/null +++ b/apps/module-federation/remote-b/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../babel.config') diff --git a/apps/module-federation/remote-b/global.d.ts b/apps/module-federation/remote-b/global.d.ts new file mode 100644 index 00000000..5894ae0b --- /dev/null +++ b/apps/module-federation/remote-b/global.d.ts @@ -0,0 +1 @@ +declare module '*.css' diff --git a/apps/module-federation/remote-b/index.js b/apps/module-federation/remote-b/index.js new file mode 100644 index 00000000..ff19c077 --- /dev/null +++ b/apps/module-federation/remote-b/index.js @@ -0,0 +1,5 @@ +import { AppRegistry } from 'react-native' + +import RemotePanel from './src/RemotePanel' + +AppRegistry.registerComponent('main', () => RemotePanel) diff --git a/apps/module-federation/remote-b/metro.config.js b/apps/module-federation/remote-b/metro.config.js new file mode 100644 index 00000000..8b3beb3b --- /dev/null +++ b/apps/module-federation/remote-b/metro.config.js @@ -0,0 +1,14 @@ +const { createMetroConfig } = require('../metro.shared') + +module.exports = createMetroConfig({ + cssEntryFile: 'remote-b.css', + projectRoot: __dirname, + federation: { + name: 'remoteB', + filename: 'remoteB.bundle', + exposes: { + './Panel': './src/RemotePanel.tsx', + }, + shareStrategy: 'version-first', + }, +}) diff --git a/apps/module-federation/remote-b/package.json b/apps/module-federation/remote-b/package.json new file mode 100644 index 00000000..d96da9d1 --- /dev/null +++ b/apps/module-federation/remote-b/package.json @@ -0,0 +1,28 @@ +{ + "name": "uniwind-module-federation-remote-b", + "version": "1.0.0", + "private": true, + "main": "index.js", + "scripts": { + "check:typescript": "tsc --noEmit", + "start": "expo start" + }, + "dependencies": { + "@expo/metro-runtime": "57.0.2", + "@module-federation/metro": "catalog:", + "@module-federation/runtime": "catalog:", + "expo": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-native": "catalog:", + "react-native-web": "catalog:", + "tailwindcss": "catalog:", + "uniwind": "workspace:*" + }, + "devDependencies": { + "@babel/core": "7.29.0", + "@types/react": "catalog:", + "babel-plugin-module-resolver": "5.0.3", + "typescript": "catalog:" + } +} diff --git a/apps/module-federation/remote-b/remote-b.css b/apps/module-federation/remote-b/remote-b.css new file mode 100644 index 00000000..bd09ce0f --- /dev/null +++ b/apps/module-federation/remote-b/remote-b.css @@ -0,0 +1,19 @@ +@layer theme, base, components, utilities; + +@import 'tailwindcss/theme.css' layer(theme) prefix(rmb); +@import 'tailwindcss/utilities.css' layer(utilities) prefix(rmb); +@import 'uniwind'; + +@source inline("rmb:mf-remote-b-only rmb:mf-conflict rmb:bg-mf-shared"); + +@theme { + --color-mf-shared: #2563eb; +} + +@utility mf-remote-b-only { + background-color: #2563eb; +} + +@utility mf-conflict { + background-color: #2563eb; +} diff --git a/apps/module-federation/remote-b/src/RemotePanel.tsx b/apps/module-federation/remote-b/src/RemotePanel.tsx new file mode 100644 index 00000000..7c6f9d92 --- /dev/null +++ b/apps/module-federation/remote-b/src/RemotePanel.tsx @@ -0,0 +1,123 @@ +import '../remote-b.css' + +import { Platform, StyleSheet, Text, View } from 'react-native' +import { useResolveClassNames, withUniwind } from 'uniwind' + +const StyledView = withUniwind(View) + +function formatObservedColor(value: unknown) { + return typeof value === 'string' && value !== '' + ? value + : 'not registered' +} + +function Signal({ className, label, testID }: { className: string; label: string; testID: string }) { + const { backgroundColor } = useResolveClassNames(className) + + return ( + + {label} + + Observed now: {formatObservedColor(backgroundColor)} + + + + + + ) +} + +export default function RemotePanel() { + return ( + + + + + Remote B + Prefix rmb / blue (#2563eb) / port 8083 + + + remoteB/Panel + + + + + ) +} + +const styles = StyleSheet.create({ + panel: { + backgroundColor: '#eff6ff', + borderColor: '#93c5fd', + borderRadius: 8, + borderWidth: 1, + gap: 14, + maxWidth: 760, + padding: 18, + }, + panelHeading: { + alignItems: 'center', + flexDirection: 'row', + gap: 10, + }, + ownerDot: { + borderRadius: 10, + height: 12, + width: 12, + }, + remoteDot: { + backgroundColor: '#2563eb', + }, + panelTitle: { + color: '#172554', + fontSize: 19, + fontWeight: '800', + }, + panelMeta: { + color: '#1e40af', + fontSize: 12, + }, + moduleId: { + color: '#1e3a8a', + fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }), + fontSize: 12, + }, + signalRow: { + gap: 6, + }, + signalLabel: { + color: '#1e3a8a', + fontSize: 13, + fontWeight: '600', + }, + observedLabel: { + color: '#172554', + fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }), + fontSize: 12, + fontWeight: '700', + }, + signalTrack: { + backgroundColor: '#dbeafe', + borderColor: '#93c5fd', + borderRadius: 4, + borderWidth: 1, + height: 34, + overflow: 'hidden', + }, + signalBar: { + height: '100%', + width: '100%', + }, +}) diff --git a/apps/module-federation/remote-b/tsconfig.json b/apps/module-federation/remote-b/tsconfig.json new file mode 100644 index 00000000..840ae607 --- /dev/null +++ b/apps/module-federation/remote-b/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src", "global.d.ts"] +} diff --git a/apps/module-federation/remote-b/uniwind-types.d.ts b/apps/module-federation/remote-b/uniwind-types.d.ts new file mode 100644 index 00000000..cc099419 --- /dev/null +++ b/apps/module-federation/remote-b/uniwind-types.d.ts @@ -0,0 +1,10 @@ +// NOTE: This file is generated by uniwind and it should not be edited manually. +/// + +declare module 'uniwind' { + export interface UniwindConfig { + themes: readonly ['light', 'dark'] + } +} + +export {} diff --git a/apps/module-federation/remote-hmr-disabled.js b/apps/module-federation/remote-hmr-disabled.js new file mode 100644 index 00000000..c3e0d66c --- /dev/null +++ b/apps/module-federation/remote-hmr-disabled.js @@ -0,0 +1,2 @@ +// A remote bundle belongs to its own Metro graph, not the host HMR graph. +export function setup() {} diff --git a/apps/module-federation/start.mjs b/apps/module-federation/start.mjs new file mode 100644 index 00000000..02094d42 --- /dev/null +++ b/apps/module-federation/start.mjs @@ -0,0 +1,221 @@ +import { spawn, spawnSync } from 'node:child_process' +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const platform = process.argv[2] +const expoCli = fileURLToPath(new URL('../../node_modules/expo/bin/cli', import.meta.url)) +const pidFile = fileURLToPath(new URL('./.servers.pid', import.meta.url)) +const hostArgs = platform === 'ios' ? ['--lan'] : ['--localhost'] +const commonArgs = ['start', '--clear', ...hostArgs] + +if (platform !== 'ios' && platform !== 'web') { + console.error('Usage: bun start.mjs ') + process.exit(1) +} + +const getManagedLauncher = () => { + let activePid + + try { + activePid = Number.parseInt(readFileSync(pidFile, 'utf8'), 10) + process.kill(activePid, 0) + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ESRCH') { + return null + } + + throw error + } + + const processInfo = spawnSync('ps', ['-p', String(activePid), '-o', 'command='], { + encoding: 'utf8', + }) + + if (processInfo.error) { + throw processInfo.error + } + + return processInfo.status === 0 && processInfo.stdout.includes('start.mjs') + ? activePid + : null +} + +const activePid = getManagedLauncher() + +if (activePid) { + console.error(`[module-federation] Servers are already managed by PID ${activePid}`) + process.exit(1) +} + +try { + rmSync(pidFile) +} catch (error) { + if (error?.code !== 'ENOENT') { + throw error + } +} + +writeFileSync(pidFile, `${process.pid}\n`) + +const projects = [ + { + name: 'Remote A', + cwd: new URL('./remote-a', import.meta.url), + args: [...commonArgs, '--port', '8082'], + }, + { + name: 'Remote B', + cwd: new URL('./remote-b', import.meta.url), + args: [...commonArgs, '--port', '8083'], + }, + { + name: 'Host', + cwd: new URL('./host', import.meta.url), + args: [ + ...commonArgs, + '--port', + '8081', + ...(platform === 'web' ? ['--web'] : ['--dev-client']), + ], + }, +] + +const children = [] +const settledChildren = new Set() +let stopping = false + +const cleanupPidFile = () => { + try { + if (Number.parseInt(readFileSync(pidFile, 'utf8'), 10) === process.pid) { + rmSync(pidFile) + } + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error + } + } +} + +const exitWhenChildrenStop = () => { + if ( + stopping + && children.every(child => settledChildren.has(child)) + ) { + process.exit(process.exitCode ?? 0) + } +} + +const stopAll = signal => { + if (stopping) { + exitWhenChildrenStop() + return + } + + stopping = true + + for (const child of children) { + if (!child.pid) { + continue + } + + try { + process.kill(child.pid, signal) + } catch (error) { + if (error?.code !== 'ESRCH') { + throw error + } + } + } +} + +const startChild = (name, args, cwd, env = process.env) => { + console.log(`[module-federation] Starting ${name}`) + + const child = spawn('node', [expoCli, ...args], { + cwd, + env, + stdio: 'inherit', + }) + + children.push(child) + + child.on('error', error => { + settledChildren.add(child) + + if (!stopping) { + console.error(`[module-federation] ${name} failed to start: ${error.message}`) + process.exitCode = 1 + stopAll('SIGTERM') + } + + exitWhenChildrenStop() + }) + + child.on('exit', code => { + settledChildren.add(child) + + if (!stopping && code !== 0) { + console.error(`[module-federation] ${name} exited with code ${code}`) + process.exitCode = code ?? 1 + stopAll('SIGTERM') + } + + exitWhenChildrenStop() + }) + + return child +} + +for (const project of projects) { + startChild(project.name, project.args, project.cwd) +} + +const waitForUrl = async (name, url) => { + const deadline = Date.now() + 60_000 + + while (Date.now() < deadline) { + try { + const response = await fetch(url) + + if (response.ok) { + console.log(`[module-federation] ${name} is ready`) + return + } + } catch { + // Metro has not opened its listener yet. + } + + await new Promise(resolve => setTimeout(resolve, 250)) + } + + throw new Error(`Timed out waiting for ${name} at ${url}`) +} + +if (platform === 'ios') { + try { + await Promise.all([ + waitForUrl('Host', 'http://localhost:8081/status'), + waitForUrl('Remote A manifest', 'http://localhost:8082/mf-manifest.json'), + waitForUrl('Remote B manifest', 'http://localhost:8083/mf-manifest.json'), + ]) + + startChild( + 'local iOS host', + ['run:ios', '--no-bundler'], + new URL('./host', import.meta.url), + { + ...process.env, + RCT_METRO_PORT: '8081', + }, + ) + } catch (error) { + console.error(`[module-federation] iOS startup failed: ${error.message}`) + process.exitCode = 1 + stopAll('SIGTERM') + } +} + +process.on('SIGINT', () => stopAll('SIGTERM')) +process.on('SIGTERM', () => stopAll('SIGTERM')) +process.on('exit', cleanupPidFile) diff --git a/apps/module-federation/stop.mjs b/apps/module-federation/stop.mjs new file mode 100644 index 00000000..c1fe3b28 --- /dev/null +++ b/apps/module-federation/stop.mjs @@ -0,0 +1,48 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync, rmSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const pidFile = fileURLToPath(new URL('./.servers.pid', import.meta.url)) +let pid + +try { + pid = Number.parseInt(readFileSync(pidFile, 'utf8'), 10) +} catch (error) { + if (error?.code === 'ENOENT') { + console.log('[module-federation] No managed servers are running') + process.exit(0) + } + + throw error +} + +const processInfo = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf8', +}) + +if (processInfo.error) { + throw processInfo.error +} + +if ( + processInfo.status !== 0 + || !processInfo.stdout.includes('start.mjs') +) { + rmSync(pidFile) + console.log('[module-federation] Removed a stale server PID file') + process.exit(0) +} + +const result = spawnSync('kill', ['-TERM', String(pid)], { + stdio: 'inherit', +}) + +if (result.error) { + throw result.error +} + +if (result.status !== 0) { + process.exit(result.status ?? 1) +} + +console.log(`[module-federation] Stopped servers managed by PID ${pid}`) diff --git a/apps/module-federation/tsconfig.base.json b/apps/module-federation/tsconfig.base.json new file mode 100644 index 00000000..5dde46ed --- /dev/null +++ b/apps/module-federation/tsconfig.base.json @@ -0,0 +1,7 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "types": ["uniwind/types"] + } +} diff --git a/apps/module-federation/verify-web.mjs b/apps/module-federation/verify-web.mjs new file mode 100644 index 00000000..a7771ea2 --- /dev/null +++ b/apps/module-federation/verify-web.mjs @@ -0,0 +1,142 @@ +// @ts-nocheck + +import { chromium } from 'playwright' + +const browser = await chromium.launch({ headless: false, slowMo: 250 }) +const page = await browser.newPage({ viewport: { height: 1000, width: 1280 } }) +const browserErrors = [] + +page.on('pageerror', error => browserErrors.push(error.message)) +page.on('console', message => { + if (message.type() === 'error') { + browserErrors.push(message.text()) + } +}) + +const signalIds = [ + 'host-only', + 'host-conflict', + 'host-variable', + 'remote-a-only', + 'remote-a-conflict', + 'remote-a-variable', + 'remote-b-only', + 'remote-b-conflict', + 'remote-b-variable', +] + +const readColors = async () => { + const colors = {} + + for (const id of signalIds) { + const element = page.getByTestId(id) + + if (await element.count()) { + colors[id] = await element.evaluate(node => getComputedStyle(node).backgroundColor) + } + } + + return colors +} + +const readObservedValues = async () => { + const values = {} + + for (const id of signalIds) { + const element = page.getByTestId(`${id}-observed`) + + if (await element.count()) { + values[id] = (await element.textContent())?.replace('Observed now: ', '') + } + } + + return values +} + +const assertValues = (actual, expected, label) => { + for (const [id, value] of Object.entries(expected)) { + if (actual[id] !== value) { + throw new Error(`${label} ${id}: expected ${value}, received ${actual[id]}`) + } + } +} + +const waitForObservedValues = async expected => { + const deadline = Date.now() + 10_000 + let values = {} + + while (Date.now() < deadline) { + values = await readObservedValues() + + if (Object.entries(expected).every(([id, value]) => values[id] === value)) { + return values + } + + await page.waitForTimeout(100) + } + + assertValues(values, expected, 'observed label') +} + +const expectedRgb = { + 'host-only': 'rgb(22, 163, 74)', + 'host-conflict': 'rgb(22, 163, 74)', + 'host-variable': 'rgb(22, 163, 74)', + 'remote-a-only': 'rgb(250, 204, 21)', + 'remote-a-conflict': 'rgb(250, 204, 21)', + 'remote-a-variable': 'rgb(250, 204, 21)', + 'remote-b-only': 'rgb(37, 99, 235)', + 'remote-b-conflict': 'rgb(37, 99, 235)', + 'remote-b-variable': 'rgb(37, 99, 235)', +} + +const expectedHex = { + 'host-only': '#16a34a', + 'host-conflict': '#16a34a', + 'host-variable': '#16a34a', + 'remote-a-only': '#facc15', + 'remote-a-conflict': '#facc15', + 'remote-a-variable': '#facc15', + 'remote-b-only': '#2563eb', + 'remote-b-conflict': '#2563eb', + 'remote-b-variable': '#2563eb', +} + +const assertVisibleSignals = async () => { + const colors = await readColors() + const visibleIds = signalIds.filter(id => id in colors) + const visibleRgb = Object.fromEntries(visibleIds.map(id => [id, expectedRgb[id]])) + const visibleHex = Object.fromEntries(visibleIds.map(id => [id, expectedHex[id]])) + + assertValues(colors, visibleRgb, 'computed color') + await waitForObservedValues(visibleHex) +} + +const runOrder = async (first, second) => { + await page.getByRole('button', { name: `Load Remote ${first}` }).click() + await page.getByText(`Load order: Host -> ${first}`, { exact: true }).waitFor() + await assertVisibleSignals() + + await page.getByRole('button', { name: `Load Remote ${second}` }).click() + await page.getByText(`Load order: Host -> ${first} -> ${second}`, { exact: true }).waitFor() + await assertVisibleSignals() +} + +try { + await page.goto('http://localhost:8081/', { waitUntil: 'networkidle' }) + await assertVisibleSignals() + await runOrder('A', 'B') + + await page.getByRole('button', { name: 'Reload runtime' }).click() + await page.getByText('Load order: host only', { exact: true }).waitFor() + await assertVisibleSignals() + await runOrder('B', 'A') + + if (browserErrors.length) { + throw new Error(`Browser errors:\n${browserErrors.join('\n')}`) + } + + console.log('PASS: host and prefixed remote classes and variables remain stable in both load orders') +} finally { + await browser.close() +} diff --git a/bun.lock b/bun.lock index 34be6017..869d2e17 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,76 @@ "typescript": "catalog:", }, }, + "apps/module-federation": { + "name": "uniwind-module-federation-example", + "version": "1.0.0", + }, + "apps/module-federation/host": { + "name": "uniwind-module-federation-host", + "version": "1.0.0", + "dependencies": { + "@expo/metro-runtime": "57.0.2", + "@module-federation/metro": "catalog:", + "@module-federation/runtime": "catalog:", + "expo": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-native": "catalog:", + "react-native-web": "catalog:", + "tailwindcss": "catalog:", + "uniwind": "workspace:*", + }, + "devDependencies": { + "@babel/core": "7.29.0", + "@types/react": "catalog:", + "babel-plugin-module-resolver": "5.0.3", + "typescript": "catalog:", + }, + }, + "apps/module-federation/remote-a": { + "name": "uniwind-module-federation-remote-a", + "version": "1.0.0", + "dependencies": { + "@expo/metro-runtime": "57.0.2", + "@module-federation/metro": "catalog:", + "@module-federation/runtime": "catalog:", + "expo": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-native": "catalog:", + "react-native-web": "catalog:", + "tailwindcss": "catalog:", + "uniwind": "workspace:*", + }, + "devDependencies": { + "@babel/core": "7.29.0", + "@types/react": "catalog:", + "babel-plugin-module-resolver": "5.0.3", + "typescript": "catalog:", + }, + }, + "apps/module-federation/remote-b": { + "name": "uniwind-module-federation-remote-b", + "version": "1.0.0", + "dependencies": { + "@expo/metro-runtime": "57.0.2", + "@module-federation/metro": "catalog:", + "@module-federation/runtime": "catalog:", + "expo": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-native": "catalog:", + "react-native-web": "catalog:", + "tailwindcss": "catalog:", + "uniwind": "workspace:*", + }, + "devDependencies": { + "@babel/core": "7.29.0", + "@types/react": "catalog:", + "babel-plugin-module-resolver": "5.0.3", + "typescript": "catalog:", + }, + }, "apps/vite-example": { "name": "vite-example", "version": "0.0.0", @@ -139,6 +209,8 @@ "@tailwindcss/oxide", ], "catalog": { + "@module-federation/metro": "2.8.0", + "@module-federation/runtime": "2.8.0", "@types/bun": "1.3.14", "@types/react": "19.2.15", "@types/react-dom": "19.2.3", @@ -644,6 +716,22 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@module-federation/dts-plugin": ["@module-federation/dts-plugin@2.8.0", "", { "dependencies": { "@module-federation/error-codes": "2.8.0", "@module-federation/managers": "2.8.0", "@module-federation/sdk": "2.8.0", "@module-federation/third-party-dts-extractor": "2.8.0", "adm-zip": "0.5.10", "isomorphic-ws": "5.0.0", "undici": "7.28.0", "ws": "8.21.0" }, "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vue-tsc": ">=1.0.24" }, "optionalPeers": ["vue-tsc"] }, "sha512-defjq4jOWMEfeejezPWLP5sc8kw0O6FqTT7/E5rbZPEVyjB1A0U3ynhW6GDE5/6hk9/TzdbWS+fBNi4MqUOY6Q=="], + + "@module-federation/error-codes": ["@module-federation/error-codes@2.8.0", "", {}, "sha512-Gaog9904EmxYOQV0hli3XQ7jXeFaADfh5bnBtTCtbZ37Qd/Sz9kQfd+gYQRyIj7RGmkv9DPiN/SsmrTMrTymKw=="], + + "@module-federation/managers": ["@module-federation/managers@2.8.0", "", { "dependencies": { "@module-federation/sdk": "2.8.0" } }, "sha512-SnVBCwmi962WGg6hLFElxZUCnrRJdR6glE2ZKPBY/iK07AHUN2ZxuaBCBsVzyws+xLZGHZxBHmVstijTh8dSUA=="], + + "@module-federation/metro": ["@module-federation/metro@2.8.0", "", { "dependencies": { "@expo/metro-runtime": "^5.0.4", "@module-federation/dts-plugin": "2.8.0", "@module-federation/runtime": "2.8.0", "@module-federation/sdk": "2.8.0" }, "peerDependencies": { "@babel/types": "^7.25.0", "metro": "^0.82.1", "metro-config": "^0.82.1", "metro-file-map": "^0.82.1", "metro-resolver": "^0.82.1", "metro-source-map": "^0.82.1", "react": ">=19.0.0", "react-native": ">=0.79.0" }, "optionalPeers": ["react", "react-native"] }, "sha512-yTCXgYWpaId3PlkzKLSS8lLVLO8Km9Kra/hnh4m1hbi7/Z0AXkd9PQWbpKdPHb545468RRMDUjFDLsVbYVc0eg=="], + + "@module-federation/runtime": ["@module-federation/runtime@2.8.0", "", { "dependencies": { "@module-federation/error-codes": "2.8.0", "@module-federation/runtime-core": "2.8.0", "@module-federation/sdk": "2.8.0" } }, "sha512-cGtUBQ1/TVy7KrXy6xPgy3FEmOGyIYkBA2T4iGH3ZH5PNPPTmqN9jF2AfneTSOj0RtBr7Pxq3CUt81E/UCvK1A=="], + + "@module-federation/runtime-core": ["@module-federation/runtime-core@2.8.0", "", { "dependencies": { "@module-federation/error-codes": "2.8.0", "@module-federation/sdk": "2.8.0" } }, "sha512-Tf98+epGGiPSHqmQHuXa2uXZMMvjGf1IqJDR1/FpXfmobv5ECN0mGZCjUHGNSyxvoDyXKIkKwJu7IwEoh0ouQA=="], + + "@module-federation/sdk": ["@module-federation/sdk@2.8.0", "", {}, "sha512-yBP+9+0Z8nlvKEXAZS3AsQVy7bFbZf8eMivGk4q4ZdwG3TsLMlsPjb1dQb2i7gcAG6ux9y2LWLkj/0LVk74cnQ=="], + + "@module-federation/third-party-dts-extractor": ["@module-federation/third-party-dts-extractor@2.8.0", "", {}, "sha512-nAMlr74OKIylkfRwlunOhytQbmsgb3gCqdXWnPQhG+ZtqWXGELLfMT4a1Q1ht3cS+sRp9OONpK75YlPnIjQqX0dBDtA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], @@ -1048,6 +1136,8 @@ "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + "adm-zip": ["adm-zip@0.5.10", "", {}, "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ=="], + "agent-base": ["agent-base@8.0.0", "", {}, "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg=="], "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], @@ -1640,6 +1730,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], + "issue-parser": ["issue-parser@7.0.1", "", { "dependencies": { "lodash.capitalize": "^4.2.1", "lodash.escaperegexp": "^4.1.2", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.uniqby": "^4.7.0" } }, "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -2372,6 +2464,14 @@ "uniwind-expo-example": ["uniwind-expo-example@workspace:apps/expo-example"], + "uniwind-module-federation-example": ["uniwind-module-federation-example@workspace:apps/module-federation"], + + "uniwind-module-federation-host": ["uniwind-module-federation-host@workspace:apps/module-federation/host"], + + "uniwind-module-federation-remote-a": ["uniwind-module-federation-remote-a@workspace:apps/module-federation/remote-a"], + + "uniwind-module-federation-remote-b": ["uniwind-module-federation-remote-b@workspace:apps/module-federation/remote-b"], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "untyped": ["untyped@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "defu": "^6.1.4", "jiti": "^2.4.2", "knitwork": "^1.2.0", "scule": "^1.3.0" }, "bin": { "untyped": "dist/cli.mjs" } }, "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g=="], @@ -2730,6 +2830,20 @@ "@jest/types/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "@module-federation/dts-plugin/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "@module-federation/dts-plugin/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "@module-federation/metro/@expo/metro-runtime": ["@expo/metro-runtime@5.0.5", "", { "peerDependencies": { "react-native": "*" } }, "sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A=="], + + "@module-federation/metro/metro-config": ["metro-config@0.85.0", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.85.0", "metro-cache": "0.85.0", "metro-core": "0.85.0", "metro-runtime": "0.85.0", "yaml": "^2.6.1" } }, "sha512-d15/vq0aAysKnQPmTZMiQfIdO/tDecJ+h91KiBV39/w+WshaSSZPbv6IJzam2FGZH8n1IcCm+nnMByGKF9mmxw=="], + + "@module-federation/metro/metro-source-map": ["metro-source-map@0.85.0", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.85.0", "nullthrows": "^1.1.1", "ob1": "0.85.0", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-Z9JszTtKB146QCcxSDwxS2yGNZCoCyDdsF/dqyhPevUhLxdH3bi8gIPW4Ays3QePZpkqr5bweBdY0ncpLGNkpQ=="], + + "@module-federation/metro/metro-config/metro-runtime": ["metro-runtime@0.85.0", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-heTCsR7YVQ54OmAvZoNPkVHET8+OiwC9ohgbE2tVnN/IcXj4WS9sQJ6UI/zb7bhLptu8yyj5kQnztSr/uCcqfw=="], + + "@module-federation/metro/metro-source-map/ob1": ["ob1@0.85.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-l0Mi4dPo6MXUfkoTOr1d/LJ/0RnXou6aW1Dj+z7PGD9+JocrvDJnsFU8i+SxHoSp2Rrr1ZTuHhc/B0bxjWWDzg=="], + "@react-native-community/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@react-native-community/cli-doctor/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], diff --git a/package.json b/package.json index 5f79bd84..ec31d0b4 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,16 @@ "workspaces": { "packages": [ "packages/*", - "apps/*" + "apps/*", + "apps/module-federation/*" ], "catalog": { "typescript": "6.0.3", "@types/bun": "1.3.14", "@types/react": "19.2.15", "@types/react-dom": "19.2.3", + "@module-federation/metro": "2.8.0", + "@module-federation/runtime": "2.8.0", "react": "19.2.3", "react-dom": "19.2.3", "expo": "57.0.1", From 5490abe2770a9beb2b5fbae64b7af5b71dcd4854 Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 24 Jul 2026 14:25:25 -0700 Subject: [PATCH 9/9] test: cover federated CSS variable APIs --- .../native/core/federated-styles.test.ts | 54 +++++++++++++++++++ .../tests/web/core/css-listener.test.ts | 31 ++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/uniwind/tests/native/core/federated-styles.test.ts b/packages/uniwind/tests/native/core/federated-styles.test.ts index a06a2b49..98279c0f 100644 --- a/packages/uniwind/tests/native/core/federated-styles.test.ts +++ b/packages/uniwind/tests/native/core/federated-styles.test.ts @@ -1,3 +1,5 @@ +import { act, renderHook } from '@testing-library/react-native' +import { Uniwind, useCSSVariable } from '../../../src' import { StyleDependency } from '../../../src/common/consts' import { UniwindListener } from '../../../src/core/listener' import { Logger } from '../../../src/core/logger' @@ -190,6 +192,58 @@ describe('federated native styles', () => { expect(getBackgroundColor('host-variable')).toBe('#ea580c') }) + test('updates public CSS variable APIs when remote registrations change', () => { + const variableName = '--rma-shared-color' + const warn = jest.spyOn(Logger, 'warn').mockImplementation() + const { result } = renderHook(() => useCSSVariable(variableName)) + let staleDispose = () => {} + + expect(Uniwind.getCSSVariable(variableName)).toBeUndefined() + expect(result.current).toBeUndefined() + + act(() => { + staleDispose = UniwindStore.merge( + 'remote-a', + createRegistration({}, { [variableName]: '#facc15' }), + ['light', 'dark'], + ) + }) + disposers.push(staleDispose) + + try { + expect(Uniwind.getCSSVariable('--shared-color')).toBe('#16a34a') + expect(Uniwind.getCSSVariable(variableName)).toBe('#facc15') + expect(result.current).toBe('#facc15') + + let currentDispose = () => {} + + act(() => { + currentDispose = UniwindStore.merge( + 'remote-a', + createRegistration({}, { [variableName]: '#2563eb' }), + ['light', 'dark'], + ) + }) + disposers.push(currentDispose) + + expect(Uniwind.getCSSVariable(variableName)).toBe('#2563eb') + expect(result.current).toBe('#2563eb') + + act(() => staleDispose()) + + expect(Uniwind.getCSSVariable(variableName)).toBe('#2563eb') + expect(result.current).toBe('#2563eb') + + act(() => currentDispose()) + + expect(Uniwind.getCSSVariable('--shared-color')).toBe('#16a34a') + expect(Uniwind.getCSSVariable(variableName)).toBeUndefined() + expect(result.current).toBeUndefined() + } finally { + warn.mockRestore() + } + }) + test('notifies static and missing class subscribers when registrations change', () => { const listener = jest.fn() const variableListener = jest.fn() diff --git a/packages/uniwind/tests/web/core/css-listener.test.ts b/packages/uniwind/tests/web/core/css-listener.test.ts index 0264998f..a8bcd0b4 100644 --- a/packages/uniwind/tests/web/core/css-listener.test.ts +++ b/packages/uniwind/tests/web/core/css-listener.test.ts @@ -1,4 +1,5 @@ -import { waitFor } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' +import { Uniwind, useCSSVariable } from '../../../src' import { CSSListener } from '../../../src/core/web' describe('CSSListener', () => { @@ -80,4 +81,32 @@ describe('CSSListener', () => { }) } }) + + test('updates public CSS variable APIs as a remote stylesheet loads and unloads', async () => { + const variableName = '--rma-color-mf-shared' + const style = document.createElement('style') + const { result } = renderHook(() => useCSSVariable(variableName)) + + expect(Uniwind.getCSSVariable(variableName)).toBe('') + expect(result.current).toBe('') + + try { + style.textContent = `.light, .light * { ${variableName}: #facc15; }` + act(() => document.head.appendChild(style)) + + await waitFor(() => { + expect(Uniwind.getCSSVariable(variableName)).toBe('#facc15') + expect(result.current).toBe('#facc15') + }) + + act(() => style.remove()) + + await waitFor(() => { + expect(Uniwind.getCSSVariable(variableName)).toBe('') + expect(result.current).toBe('') + }) + } finally { + style.remove() + } + }) })