Skip to content

Web: exports conditions bundle both dist/module and dist/common — two Uniwind singletons, setTheme never reaches the root class writer #622

Description

@focux

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

  1. 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)
  2. npm ci --legacy-peer-deps
  3. npm run verify → prints dist/common refs: 28, Uniwind singleton copies: 2
  4. npm run verify:fixed (workaround) → dist/common refs: 0, singleton copies: 1
  5. 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 〰

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions