What happened?
On web, Metro's package-exports resolution can put both uniwind builds in one bundle — dist/module (ESM, "import" condition) and dist/common (CJS, "default" condition) — which means two Uniwind singletons with independent theme state.
The fork happens because the metro resolver rewrites react-native(-web) component resolutions to uniwind/components/* while passing the original resolution context through, so each rewritten edge inherits the importer's module semantics:
- app source:
import { Uniwind } from "uniwind" → "import" → dist/module
- ordinary third-party libs (
react-native-screens/*.web.js, react-native-safe-area-context/*.web.js) → require semantics → "default" → dist/common
The CJS copy includes components/web/rnw.ts — the code that owns the theme class on <html> — so Uniwind.setTheme() from app code updates an instance the root class writer never listens to.
Visible symptom (demo screen included in the repro): press light in the app (an explicit Uniwind.setTheme("light")), then flip the OS color scheme to dark (or emulate prefers-color-scheme in DevTools → Rendering):
after choosing light: html class: light | useUniwind(): light | hasAdaptiveThemes: false
after OS switches to dark: html class: dark | useUniwind(): light | hasAdaptiveThemes: false
The page visibly flips to dark against the user's explicit choice — the CJS instance is still adaptive, so its rnw.ts stamps the OS theme onto <html> while useUniwind() keeps reporting light.
Bundle evidence (repro, verified on 1.7.0 and 1.10.1): 55 uniwind/dist/module refs + 28 uniwind/dist/common refs, and the UniwindConfigBuilder marker string trying to setTheme appears twice. Single package, plain npm install, one hoisted uniwind copy — the split is purely the exports condition. Native is unaffected ("react-native" condition → single ./src tree).
Workaround — forcing the "import" condition for every uniwind specifier before withUniwindConfig (included in the repro behind UNIWIND_ESM_FIX=1; with it: 0 dist/common refs, one singleton, OS switch no longer overrides the user's choice):
const baseResolveRequest = config.resolver.resolveRequest;
config.resolver.resolveRequest = (context, moduleName, platform) => {
const resolveRequest = baseResolveRequest ?? context.resolveRequest;
const isUniwindModule =
moduleName === "uniwind" || moduleName.startsWith("uniwind/");
return resolveRequest(
isUniwindModule ? { ...context, isESMImport: true } : context,
moduleName,
platform,
);
};
Maybe withUniwindConfig could do this normalization itself (it already owns resolveRequest).
Steps to Reproduce
git clone https://github.com/focux/uniwind-dual-runtime-repro && cd uniwind-dual-runtime-repro (base: create-expo-app -e with-router-uniwind + uniwind@1.10.1 + web deps)
npm ci --legacy-peer-deps
npm run verify → prints dist/common refs: 28, Uniwind singleton copies: 2
npm run verify:fixed (workaround) → dist/common refs: 0, singleton copies: 1
- Interactive:
npm run web, press light, emulate prefers-color-scheme: dark → page turns dark, useUniwind() still says light
Snack or Repository Link
https://github.com/focux/uniwind-dual-runtime-repro
Uniwind version
1.10.1
React Native Version
0.86.0
Platforms
Web
Expo
Yes
Additional information 〰
What happened?
On web, Metro's package-exports resolution can put both uniwind builds in one bundle —
dist/module(ESM,"import"condition) anddist/common(CJS,"default"condition) — which means twoUniwindsingletons with independent theme state.The fork happens because the metro resolver rewrites react-native(-web) component resolutions to
uniwind/components/*while passing the original resolution context through, so each rewritten edge inherits the importer's module semantics:import { Uniwind } from "uniwind"→"import"→dist/modulereact-native-screens/*.web.js,react-native-safe-area-context/*.web.js) → require semantics →"default"→dist/commonThe CJS copy includes
components/web/rnw.ts— the code that owns the theme class on<html>— soUniwind.setTheme()from app code updates an instance the root class writer never listens to.Visible symptom (demo screen included in the repro): press light in the app (an explicit
Uniwind.setTheme("light")), then flip the OS color scheme to dark (or emulateprefers-color-schemein DevTools → Rendering):The page visibly flips to dark against the user's explicit choice — the CJS instance is still adaptive, so its
rnw.tsstamps the OS theme onto<html>whileuseUniwind()keeps reportinglight.Bundle evidence (repro, verified on 1.7.0 and 1.10.1): 55
uniwind/dist/modulerefs + 28uniwind/dist/commonrefs, and theUniwindConfigBuildermarker stringtrying to setThemeappears twice. Single package, plain npm install, one hoisted uniwind copy — the split is purely the exports condition. Native is unaffected ("react-native"condition → single./srctree).Workaround — forcing the
"import"condition for every uniwind specifier beforewithUniwindConfig(included in the repro behindUNIWIND_ESM_FIX=1; with it: 0dist/commonrefs, one singleton, OS switch no longer overrides the user's choice):Maybe
withUniwindConfigcould do this normalization itself (it already ownsresolveRequest).Steps to Reproduce
git clone https://github.com/focux/uniwind-dual-runtime-repro && cd uniwind-dual-runtime-repro(base:create-expo-app -e with-router-uniwind+uniwind@1.10.1+ web deps)npm ci --legacy-peer-depsnpm run verify→ printsdist/common refs: 28,Uniwind singleton copies: 2npm run verify:fixed(workaround) →dist/common refs: 0,singleton copies: 1npm run web, press light, emulateprefers-color-scheme: dark→ page turns dark,useUniwind()still sayslightSnack or Repository Link
https://github.com/focux/uniwind-dual-runtime-repro
Uniwind version
1.10.1
React Native Version
0.86.0
Platforms
Web
Expo
Yes
Additional information 〰