Skip to content

fix(metro): patch every loaded Metro graph for uncached modules - #721

Draft
dlebedynskyi wants to merge 1 commit into
uni-stack:mainfrom
alexa-endpoints:fix/metro-patch-every-graph
Draft

dlebedynskyi wants to merge 1 commit into
uni-stack:mainfrom
alexa-endpoints:fix/metro-patch-every-graph

Conversation

@dlebedynskyi

Copy link
Copy Markdown
Contributor

I ran into this in a monorepo where uniwind is a workspace package and apps/bare runs on the React Native CLI. After #676 I imported a token stylesheet from global.css and edited a token. In the Expo app the change hot reloaded, but in apps/bare it never reached the app. Metro sent an HMR update, but the update only carried the imported stylesheet's empty module, and the CSS entry was never transformed again.

uniwind patches Metro's Graph in two places. patchMetroGraphToSupportUncachedModules makes it transform the CSS entry again on every change, and patchMetroGraphToIncludeCssInLazyGraphs (#646) adds the entry to lazy development graphs. Both patch the Graph that require('metro/private/DeltaBundler/Graph') returns from uniwind's location. That isn't always the Metro running the server. A fresh install of main (9fc9b40) has these copies:

copy version used by
node_modules/metro 0.85.0 packages/uniwind's dev dependency, and what uniwind resolves
node_modules/@react-native/community-cli-plugin/node_modules/metro 0.84.4 react-native start in apps/bare
node_modules/@expo/metro/node_modules/metro 0.84.5 expo start in apps/expo-example

I preloaded a script into each dev server. It lists the metro/src/DeltaBundler/Graph.js modules that are loaded and shows whether each has uniwind's patches:

apps/bare, react-native start
  @react-native/community-cli-plugin/node_modules/metro   traverseDependencies patched=false   lazy CSS entry=false
  metro                                                   traverseDependencies patched=true    lazy CSS entry=true
apps/expo-example, expo start
  @expo/metro/node_modules/metro                          traverseDependencies patched=true    lazy CSS entry=false
  metro                                                   traverseDependencies patched=true    lazy CSS entry=true

Neither server builds its graphs from the root copy. uniwind loads that one to patch it. Expo's own getDefaultConfig patches traverseDependencies on the @expo/metro copy. uniwind doesn't.

Then I checked HMR in apps/bare. I added src/hmr-tokens.css, which defines one @theme token (--color-hmr-token: #c1c1c1) and has @source inline("bg-hmr-token"). I imported it from src/global.css, started react-native start, fetched the iOS dev bundle, and registered an HMR client for it. As #676 intends, the entry requires the token file:

src/global.css -> ["src/hmr-tokens.css", "../../packages/uniwind/uniwind.css"]

(#720 removes the second one.) Then I changed the token to #d2d2d2:

apps/bare on main apps/bare with this PR apps/expo-example, main and this PR
HMR updates in the 30 s after the edit 1 1 1
modules in the update src/hmr-tokens.css src/hmr-tokens.css, src/global.css hmr-tokens.css, global.css
update carries the new value no yes yes
rebuilds in the next 15 s 0 0 0

On main the update's only module is src/hmr-tokens.css. Plain Metro builds it as an empty module (function (global, ...) {}), so nothing in the app changes. The problem isn't limited to imported stylesheets. I also added a class used nowhere else (bg-[#e3e3e3]) to src/App.tsx. On main the update contains only src/App.tsx, so the new class gets no styles. With this PR it contains src/App.tsx and src/global.css, and carries the new color.

Cause

Metro only transforms the files that changed. The native CSS entry is marked skipCache, and patchMetroGraphToSupportUncachedModules makes Graph.prototype.traverseDependencies add such modules to every traversal. That's how Tailwind recompiles the entry whenever any file changes. But this patch and the lazy entry patch are both applied to the Graph class of the Metro that resolves from uniwind, which is the root 0.85.0 here.

react-native start runs @react-native/community-cli-plugin, which depends on metro ^0.84.3. The root has 0.85.0, packages/uniwind's dev dependency (see CONTEXT.md), so the install nests 0.84.4 for the plugin. That copy's Graph is a separate class, and nothing patches it. So in apps/bare a change re-transforms only the changed file, never the entry.

Expo is different because getDefaultConfig from @expo/metro-config applies the same traverseDependencies patch to @expo/metro's graph. @expo/metro pins metro 0.84.5 exactly, so it runs its own copy here too. That copy misses only uniwind's lazy CSS entry patch from #646.

Fix

A new getMetroGraphs returns the Graph from the Metro uniwind resolves, as before. It also returns the Graph export of every metro/src/DeltaBundler/Graph.js module already in require.cache, and both patches loop over that set. The existing markers (__patched, __uniwindLazyCssEntryPatched) still stop a class from being wrapped twice. Expo marks its own traverseDependencies patch with __patched as well, so uniwind leaves that one alone, as it already does when Expo and uniwind share a Metro copy.

This relies on the CLI loading its graph before it evaluates metro.config.js. The React Native CLI does: in the probe, its Graph module shows up unpatched first and gets patched once the config runs. Expo's getDefaultConfig requires its graph inside the config, before withUniwindConfig is called. I also added a sentence about this to the Metro section of CONTEXT.md.

Testing

  • tests/native/bundler/metro-patches.test.ts writes a stand-in node_modules/metro/src/DeltaBundler/Graph.js to a temporary directory. It requires it before applying the patches, the way a CLI loads its own copy. Then it checks three things on that class:

    • traverseDependencies adds the uncached CSS entry to the paths and bumps its unstable_transformResultKey, but leaves cached modules alone.
    • Lazy development graphs get the CSS entry and non-lazy ones don't.
    • Patching again doesn't wrap the method twice.

    On main the first two fail, because the paths and entry points come back without the CSS entry. With the fix all three pass.

  • I repeated the probe with the fix. Every loaded graph has both patches, in both apps.

  • I repeated the live runs above with the fix:

    • In apps/bare, the token edit and the class edit each gave exactly one HMR update. It included the entry and carried the new value.
    • Nothing rebuilt in the 30 s after start or in the 15 s after the edit window, and uniwind.css wasn't written.
    • apps/expo-example behaves as before: one rebuild per edit, carrying the new value, then nothing.
  • I ran the CI steps locally: install, build, type checks, lint, format, circular check, and bun run test (native 206, web 48, e2e 9, types). I also exported apps/expo-example for iOS, Android and web, built apps/vite-example, and bundled apps/bare.

Relation to #720

For its two-server run, #720 used a second Expo app instead of apps/bare, because Metro doesn't re-run the bare entry on file changes. That's this bug. Once the bare graph is patched, apps/bare behaves like the Expo apps in that scenario, so without #720 running both examples loops.

I ran apps/expo-example and apps/bare together with only this PR. Both entries still required packages/uniwind/uniwind.css. In the 30 s after start the artifact was written 451 times, and the servers rebuilt 244 and 293 times. Expo's transform also failed now and then with Cannot use @variant with unknown variant: premium, when it read the light/dark artifact bare had just written. With #720's transformer change applied on top, the same run was quiet. The artifact was written twice at startup, nothing rebuilt in the 30 s after start or in the final 15 s, and each token edit rebuilt only the edited server, once, with the new value. The two changes don't touch the same code, but in this repo the examples only run together cleanly with both.

Who's affected

This affects native development with Metro when the CLI runs a different Metro copy than the metro that resolves from where uniwind is installed. uniwind takes metro as a peer dependency, so it uses whichever copy it can see, normally the hoisted one. When the copies differ, the CSS entry isn't transformed again after other files change. New classes in components and edits to stylesheets imported by the entry (#676) don't reach the app.

A second copy appears when the package manager can't dedupe the CLI's metro with the hoisted one:

  • React Native CLI. @react-native/community-cli-plugin depends on metro ^0.84.3 (React Native 0.86). Any other metro version in the install can take the hoisted spot, and the CLI's copy gets nested. That includes a direct metro dependency (like packages/uniwind's 0.85.0 here) and another workspace in a monorepo on a different React Native or Metro version. This is the case where hot reload breaks.
  • Expo. @expo/metro pins an exact metro (0.84.5 in @expo/metro 56.0.2, which Expo SDK 57 uses). If the hoisted metro is any other version, as here with 0.85.0, Expo runs a nested copy. Native hot reload still works because of Expo's own patch. Only the lazy CSS entry patch from fix: web lazy components not hot reloading css #646 (web lazy components) was missing. I didn't run a web lazy-component check for that case. The unit test covers the patch itself.

Installs with a single Metro copy aren't affected. That's the usual single-app project, where the CLI and uniwind resolve the same hoisted metro. Production bundles (react-native bundle, expo export) and Vite aren't affected either.

The uncached-module and lazy CSS entry patches only reached the Metro copy
uniwind resolves. The CLI that runs the server can bring its own: React
Native CLI's community plugin runs a Metro 0.84.4 copy in this workspace,
beside the root 0.85.0, and `@expo/metro` runs its pinned 0.84.5 whenever
the install doesn't hoist that version. Those graphs were never patched.
Under React Native CLI the CSS entry wasn't re-transformed after other
modules changed: a class added to a component got no styles, and a
token-only edit to a stylesheet imported by the entry (tracked since uni-stack#676)
hot-reloaded an empty module. Expo's getDefaultConfig patches its own graph
for uncached modules, so a separate `@expo/metro` copy only missed the lazy
CSS entry patch.

Patch every Metro graph module already loaded as well; both CLIs load it
before they evaluate the config.
@coderabbitai

coderabbitai Bot commented Oct 9, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant