Skip to content

Native Fast Refresh is broken: re-evaluating the compiled CSS module forces a full reload on every HMR update #620

Description

@awish310

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

  1. Set up uniwind on a native app (withUniwindConfig, cssEntryFile).
  2. expo start --clear, open the app, ensure Fast Refresh is enabled.
  3. 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:

  1. 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.
  2. Re-evaluating it calls Uniwind.__reinit → (native) UniwindStore.reinit(...)UniwindListener.notifyAll() (the two nested forEach frames above are notifyAll iterating listener sets).
  3. One of the notified listeners synchronously reaches Refresh.performFullRefresh(), which is RN's "give up and full-reload" hook (setUpReactRefresh.jsDevSettings.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:

  1. 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().
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    invalidThis doesn't seem right

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions