Native Fast Refresh is broken: re-evaluating the compiled CSS module forces a full reload on every HMR update
Summary
On native, every save triggers a full app reload instead of Fast Refresh. The cause is uniwind's compiled CSS module: it's marked skipCache, so it is re-evaluated on (essentially) every HMR delta, and re-evaluating it runs Uniwind.__reinit(...), which synchronously reaches React Native's Refresh.performFullRefresh() → DevSettings.reload(). This happens even for edits that don't change any styles (e.g. editing a string inside a component), so it's a redundant full reload on every save.
Environment
uniwind@1.10.0
- Expo SDK 56 / React Native 0.85 / Hermes, iOS
- Metro with
unstable_enablePackageExports: true (uniwind resolves to its react-native export condition, i.e. the src/ build)
- Yarn workspaces monorepo (shared UI lib + app);
cssEntryFile points at a global.css in the shared package
Steps to reproduce
- Set up uniwind on a native app (
withUniwindConfig, cssEntryFile).
expo start --clear, open the app, ensure Fast Refresh is enabled.
- Edit a visible string in any mounted component and save.
Expected: the component Fast-Refreshes in place, state preserved.
Actual: the whole app does a full reload (module graph re-initializes; component/navigation state is lost).
Root cause (device stack trace)
The full reload originates from evaluating the compiled CSS module during the HMR update:
DevSettings.reload (react-native)
Refresh.performFullRefresh (react-native/Libraries/Core/setUpReactRefresh.js:26)
forEach (native)
forEach (native)
eval (…/hapn-app/src/ui/global.bundle:1:4) ← the compiled CSS module
inject (metro-runtime/src/modules/HMRClient.js:9)
injectUpdate (metro-runtime/src/modules/HMRClient.js:14)
_ws.onmessage (metro-runtime/src/modules/HMRClient.js:51)
Tracing it:
- The metro transformer emits the native CSS module as
const { Uniwind } = require('uniwind'); Uniwind.__reinit(rt => <css>, <themes>); and sets output[0].data.css.skipCache = true (dist/metro/transformer.cjs). Because it's skipCache, the module is re-transformed/re-sent in HMR deltas rather than served from cache — so it's re-evaluated on saves that have nothing to do with CSS.
- Re-evaluating it calls
Uniwind.__reinit → (native) UniwindStore.reinit(...) → UniwindListener.notifyAll() (the two nested forEach frames above are notifyAll iterating listener sets).
- One of the notified listeners synchronously reaches
Refresh.performFullRefresh(), which is RN's "give up and full-reload" hook (setUpReactRefresh.js → DevSettings.reload()).
So a CSS re-init that only needs to re-apply styles ends up forcing a full reload, even though uniwind's own listener rerenders already re-apply the styles to mounted components (state-preservingly).
Impact
Fast Refresh is effectively unusable on native for any project where the CSS module ends up in HMR deltas — every save is a cold reload with full state loss. It's easy to miss in a minimal app (narrow content globs → the sheet rarely regenerates), but with broader @source scanning (e.g. a shared UI package) the module is in most deltas and the reload is constant.
Suggested fix
Any of these would resolve it:
- Don't full-reload on re-init. When
__reinit needs to propagate a new stylesheet, rely on the state-preserving listener rerenders (which already run) instead of anything that reaches performFullRefresh. If a refresh is truly needed, prefer Refresh.performReactRefresh() (state-preserving) over performFullRefresh().
- Diff before notifying. Skip the re-init/notify entirely when the newly generated stylesheet is byte-identical to the previous one (the common case for edits that don't touch classes), so unrelated saves don't re-init at all.
- Make the CSS module a self-accepting HMR boundary (
module.hot.accept()), so re-evaluating it doesn't propagate to a full reload.
Current workaround
We patch RN's DevSettings.reload to skip reloads whose call stack originates from the CSS global.bundle (i.e. the uniwind re-init path), which restores Fast Refresh for both component and global.css edits while leaving all other reloads intact. It works, but it's a fragile local patch — we'd much rather this be fixed upstream.
Native Fast Refresh is broken: re-evaluating the compiled CSS module forces a full reload on every HMR update
Summary
On native, every save triggers a full app reload instead of Fast Refresh. The cause is uniwind's compiled CSS module: it's marked
skipCache, so it is re-evaluated on (essentially) every HMR delta, and re-evaluating it runsUniwind.__reinit(...), which synchronously reaches React Native'sRefresh.performFullRefresh()→DevSettings.reload(). This happens even for edits that don't change any styles (e.g. editing a string inside a component), so it's a redundant full reload on every save.Environment
uniwind@1.10.0unstable_enablePackageExports: true(uniwind resolves to itsreact-nativeexport condition, i.e. thesrc/build)cssEntryFilepoints at aglobal.cssin the shared packageSteps to reproduce
withUniwindConfig,cssEntryFile).expo start --clear, open the app, ensure Fast Refresh is enabled.Expected: the component Fast-Refreshes in place, state preserved.
Actual: the whole app does a full reload (module graph re-initializes; component/navigation state is lost).
Root cause (device stack trace)
The full reload originates from evaluating the compiled CSS module during the HMR update:
Tracing it:
const { Uniwind } = require('uniwind'); Uniwind.__reinit(rt => <css>, <themes>);and setsoutput[0].data.css.skipCache = true(dist/metro/transformer.cjs). Because it'sskipCache, the module is re-transformed/re-sent in HMR deltas rather than served from cache — so it's re-evaluated on saves that have nothing to do with CSS.Uniwind.__reinit→ (native)UniwindStore.reinit(...)→UniwindListener.notifyAll()(the two nestedforEachframes above arenotifyAlliterating listener sets).Refresh.performFullRefresh(), which is RN's "give up and full-reload" hook (setUpReactRefresh.js→DevSettings.reload()).So a CSS re-init that only needs to re-apply styles ends up forcing a full reload, even though uniwind's own listener rerenders already re-apply the styles to mounted components (state-preservingly).
Impact
Fast Refresh is effectively unusable on native for any project where the CSS module ends up in HMR deltas — every save is a cold reload with full state loss. It's easy to miss in a minimal app (narrow content globs → the sheet rarely regenerates), but with broader
@sourcescanning (e.g. a shared UI package) the module is in most deltas and the reload is constant.Suggested fix
Any of these would resolve it:
__reinitneeds to propagate a new stylesheet, rely on the state-preserving listener rerenders (which already run) instead of anything that reachesperformFullRefresh. If a refresh is truly needed, preferRefresh.performReactRefresh()(state-preserving) overperformFullRefresh().module.hot.accept()), so re-evaluating it doesn't propagate to a full reload.Current workaround
We patch RN's
DevSettings.reloadto skip reloads whose call stack originates from the CSSglobal.bundle(i.e. the uniwind re-init path), which restores Fast Refresh for both component andglobal.cssedits while leaving all other reloads intact. It works, but it's a fragile local patch — we'd much rather this be fixed upstream.