useColorScheme resubscribes on every render; listeners removed mid-dispatch miss prefers-color-scheme changes
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.js
React.useEffect(() => {
function listener(appearance) {
setColorScheme(appearance.colorScheme);
}
var _Appearance$addChange = Appearance.addChangeListener(listener),
remove = _Appearance$addChange.remove;
return remove;
}); // ← no dep array: tears down + re-adds the listener on EVERY renderBecause 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
useColorSchemeinstance 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 other
useColorSchemeinstances, 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 showeddarkandlightsimultaneously. - 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):
var _Appearance$addChange = Appearance.addChangeListener(listener),
remove = _Appearance$addChange.remove;
return remove;
- });
+ }, []);We're shipping exactly this via patch-package and it fully resolves the issue. Happy to open a PR if useful.
— drafted by Claude (AI)
Source: necolas/react-native-web