You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
useColorScheme resubscribes on every render; listeners removed mid-dispatch miss prefers-color-scheme changes #2848
react-native-web version: 0.21.2 (bug present since the hook's introduction)
Summary
useColorScheme's subscription effect has no dependency array:
// dist/exports/useColorScheme/index.jsReact.useEffect(()=>{functionlistener(appearance){setColorScheme(appearance.colorScheme);}var_Appearance$addChange=Appearance.addChangeListener(listener),remove=_Appearance$addChange.remove;returnremove;});// ← no dep array: tears down + re-adds the listener on EVERY render
Because the listener is removed and re-added on every render, a prefers-color-scheme change can strand hook instances permanently:
The MediaQueryList dispatches the change to its listener list.
The first useColorScheme instance in a component receives it and setStates; the component re-renders.
The re-render runs the no-dep effect's cleanup for the component's otheruseColorScheme instances, removing their listeners mid-dispatch.
Per DOM semantics, listeners removed during dispatch never receive the in-flight event — so those instances keep the stale scheme until the next change (which then strands a different subset).
Any component (or hook composition) that ends up with more than one useColorScheme instance in its render tree hits this. In our app (Surf, a large RN-web product), toggling the OS theme left large, varying portions of the page on the previous palette until a reload, in a way that looked random because it depended on listener registration order.
Empirical confirmation
We instrumented window.matchMedia (counting registrations and per-listener dispatch) and drove prefers-color-scheme flips via Playwright's page.emulateMedia:
Unpatched: 39 listeners registered on the single MediaQueryList; only 11 fired on a flip (a stride pattern — exactly the sibling instances of the components whose first listener fired were skipped); 46 mid-dispatch removals. A probe component rendering useColorScheme() twice showed dark and light simultaneously.
With the fix below:39/39 fired, zero mid-dispatch churn, every consumer updated.
Fix
Add the missing dependency array so the subscription survives re-renders (the listener closure only captures the stable setColorScheme):
Independently reproduced this via a completely different real-world code path -- worth noting since it confirms the mechanism isn't specific to any one consumer of useColorScheme().
I hit this as a stale background color on @expo/ui's web BottomSheet (built on vaul), which calls useColorScheme() internally to pick its own background. Reopening the sheet after toggling prefers-color-scheme while it was closed would sometimes show the pre-toggle color, while ordinary React content inside the sheet (also driven by a separate useColorScheme()-backed theme context in my app) re-themed correctly on the same reopen -- i.e. exactly the "some subset of useColorScheme() instances get stranded, others don't" pattern described here, not a full app-wide failure to update.
I bisected it down with a minimal repro (https://github.com/timheilman/expo-ui-bottomsheet-theme-repro) and found the second, independent useColorScheme() subscriber was the necessary ingredient -- a single subscriber alone never reproduced it, matching the mid-dispatch-removal mechanism described above exactly.
Applied the same [] fix locally via pnpm patch and it resolves it completely.
I opened #2853 with the one-line effect dependency fix and a regression test that verifies rerenders do not remove/re-add the Appearance subscription. The test failed before the fix with 2 registrations after one rerender and passes afterward. Full Flow, format, lint, DOM/Node tests, react-native-web build, and a two-subscriber Chromium runtime check all passed.
Activity
timheilman commentedon Aug 27, 2026
Independently reproduced this via a completely different real-world code path -- worth noting since it confirms the mechanism isn't specific to any one consumer of
useColorScheme().I hit this as a stale background color on
@expo/ui's webBottomSheet(built onvaul), which callsuseColorScheme()internally to pick its own background. Reopening the sheet after togglingprefers-color-schemewhile it was closed would sometimes show the pre-toggle color, while ordinary React content inside the sheet (also driven by a separateuseColorScheme()-backed theme context in my app) re-themed correctly on the same reopen -- i.e. exactly the "some subset ofuseColorScheme()instances get stranded, others don't" pattern described here, not a full app-wide failure to update.I bisected it down with a minimal repro (https://github.com/timheilman/expo-ui-bottomsheet-theme-repro) and found the second, independent
useColorScheme()subscriber was the necessary ingredient -- a single subscriber alone never reproduced it, matching the mid-dispatch-removal mechanism described above exactly.Applied the same
[]fix locally viapnpm patchand it resolves it completely.– also drafted by Claude (AI)
Finalize as react-native-web useColorScheme repro (not @expo/ui)
huytdps13400 commentedon Aug 30, 2026
I opened #2853 with the one-line effect dependency fix and a regression test that verifies rerenders do not remove/re-add the Appearance subscription. The test failed before the fix with 2 registrations after one rerender and passes afterward. Full Flow, format, lint, DOM/Node tests, react-native-web build, and a two-subscriber Chromium runtime check all passed.