Skip to content

fix: style refcounting bugs and render-phase leak under React 18+/19#218

Merged
dazlious merged 4 commits into
kriasoft:mainfrom
dazlious:fix/css-refcounting-and-render-phase-leaks
Jun 5, 2026
Merged

fix: style refcounting bugs and render-phase leak under React 18+/19#218
dazlious merged 4 commits into
kriasoft:mainfrom
dazlious:fix/css-refcounting-and-render-phase-leaks

Conversation

@dazlious

@dazlious dazlious commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #217. Fixes three reference-counting/lifecycle bugs found while reviewing the style insertion code, plus small docs/build cleanups.

withStyles: render-phase side effect leaked styles (React 18+/19)

insertCss was called in the constructor. React 18+/19 may invoke the constructor without ever mounting the instance (StrictMode double-invocation, discarded concurrent renders), incrementing the style reference count with no matching decrement — orphaned <style> elements accumulate in long-lived SPAs. Client-side insertion now happens in componentDidMount (still before paint, so no FOUC); the render-phase call is kept on the server only, where it is required for critical CSS collection.

The new StrictMode regression test fails against the previous implementation.

insertCss: refcount corruption

  • The returned remove function is now idempotent — calling it twice decremented references held by other components.
  • Refcount keys are deleted at zero so counts can never go negative; a negative (truthy) count made the next insert of the same id a silent no-op.
  • replace: true (used by HMR) no longer resets the refcount to 1 while multiple components still reference the style — the first unmount after a hot update removed styles still in use.

useStyles: stale closure

The layout effect closed over the context value with an empty dependency array; insertCss is now a dependency.

Misc

  • Prototype-less objects for the refcount/dupe maps; dead IE8/IE9 fallbacks removed
  • README: webpack 5 + css-loader esModule: false requirements, getCss serialization caveat, v6 migration notes
  • build: removed no-op corejs/useBuiltIns preset-env options
  • test: fixed a no-op toBeNull assertion

Test plan

  • node tools/test — 14/14 (4 new regression tests)
  • node tools/lint — clean
  • node tools/build — all dist bundles compile

🤖 Generated with Claude Code

dazlious added 4 commits June 5, 2026 17:02
- make the returned remove function idempotent so double invocation
  cannot steal references held by other components
- delete refcount keys at zero so counts can never go negative, which
  silently swallowed the next insert of the same id
- preserve the reference count on replace (HMR) instead of resetting it
  to 1, which removed styles still in use on the next unmount
- use prototype-less objects for the refcount/dupe maps
- drop dead IE8/IE9 fallbacks (styleSheet.cssText, btoa guard)
Calling insertCss in the constructor is a render-phase side effect:
React 18+/19 may invoke the constructor without ever mounting the
instance (StrictMode double-invocation, discarded concurrent renders),
incrementing the style reference count with no matching decrement.
Orphaned <style> elements then accumulate in long-lived SPAs.

Client-side insertion now happens in componentDidMount (still before
paint, so no FOUC); the render-phase call is kept on the server only,
where it is required for critical CSS collection.
The layout effect closed over the context value with an empty dependency
array, so a changed StyleContext provider value would never re-insert
styles and cleanup would remove the wrong ones.
…l options

- README: webpack 5 and css-loader esModule:false requirements, getCss
  serialization caveat, v6 migration notes (no static hoisting, no
  prop-types)
- build: remove corejs/useBuiltIns from preset-env; without a core-js
  entry import they had no effect
@dazlious dazlious merged commit e3ae10f into kriasoft:main Jun 5, 2026
3 checks passed
@dazlious dazlious deleted the fix/css-refcounting-and-render-phase-leaks branch June 5, 2026 15:09
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