Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions apps/module-federation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
.expo/
dist/
web-build/
expo-env.d.ts
ios/
android/
*.tsbuildinfo
.servers.pid
141 changes: 141 additions & 0 deletions apps/module-federation/README.md
Original file line number Diff line number Diff line change
@@ -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 `<federationName>__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.
17 changes: 17 additions & 0 deletions apps/module-federation/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module.exports = function(api) {
api.cache(true)

return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
alias: {
'culori': 'culori/require',
},
},
],
],
}
}
75 changes: 75 additions & 0 deletions apps/module-federation/expo-federation-async-require.js
Original file line number Diff line number Diff line change
@@ -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),
])
}
16 changes: 16 additions & 0 deletions apps/module-federation/host/app.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
1 change: 1 addition & 0 deletions apps/module-federation/host/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../babel.config')
16 changes: 16 additions & 0 deletions apps/module-federation/host/global.css
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions apps/module-federation/host/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module '*.css'
15 changes: 15 additions & 0 deletions apps/module-federation/host/index.js
Original file line number Diff line number Diff line change
@@ -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'),
)(),
)
14 changes: 14 additions & 0 deletions apps/module-federation/host/metro.config.js
Original file line number Diff line number Diff line change
@@ -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',
},
})
28 changes: 28 additions & 0 deletions apps/module-federation/host/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}
Loading