fix: avoid double rebuildGroup on every dynamic createGlobalStyle render (#5730)#5732
Conversation
🦋 Changeset detectedLatest commit: 899f59f The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
974ec67 to
ba857ed
Compare
…der (#5730) In 6.4.0, dynamic createGlobalStyle components paid the cost of removeStyles → rebuildGroup twice per render: once from the render effect's cleanup callback, once from the new render. Because rebuildGroup clears and reinserts every mounted instance's CSS via CSSOM, this dominated CPU on apps with frequent parent re-renders. Split the single useLayoutEffect into two: one keyed on render deps that calls renderStyles (and lets the inline rulesEqual fast-path skip CSSOM work when CSS is unchanged), and a cleanup-only effect keyed on [instance, sheet, globalStyle] whose teardown fires on unmount, sheet swap, or HMR globalStyle swap -- not on every prop change. Side effect: multiple mounted instances of the same createGlobalStyle now emit CSS in mount order, restoring pre-6.4 cascade behavior.
…espace) Three small wins on paths exercised by every render: - hyphenateStyleName: early-return for CSS var names (--*) and switch per-character branch to charCodeAt comparisons. - objToCssArray: add an internal accumulator form (objToCssArrayInto) that writes directly into flatten's result array, removing the temp array allocation + spread per nesting level. Exported wrapper retained for external callers. - stylis.recursivelySetNamespace: hoist prefix/commaReplace out of the per-rule loop and allocate a fresh props array instead of mutating rule.props in place -- stylis.compile can alias the same props array between a top-level rule and its @media-nested copy, so mutation would corrupt the sibling. Also adds src/utils/charCodes.ts so per-char constants have one canonical home.
- FAQ and sandbox README: drop mentions of React 19's precedence/ href hoisting -- styled-components no longer uses them for global styles (they break post-unmount cleanup), so the narrative was misleading. - tips-and-tricks: update the size-prop example to use transient props ($small) since plain DOM-bound props were removed from the documented happy path years ago; update the forwardRef link to react.dev from the legacy reactjs.org URL. - CONTRIBUTING: use neutral "AI coding assistant" phrasing instead of naming a specific vendor.
On every push to main (or manual dispatch), if there are pending changesets the workflow calculates a snapshot version (<next-version>-prerelease-<datetime>) via changeset version --snapshot, publishes every non-private package to the test dist-tag, and creates a git tag + GitHub prerelease per published package. Uses npm OIDC trusted publishing (id-token: write). Snapshot config in .changeset/config.json uses useCalculatedVersion: true so bump sizes (patch/minor/major) come from the changeset itself.
3c552a7 to
247d6fc
Compare
Syncs the canonical error-map entries with the test snapshot wording from d38c553 so the generated error map still matches what the Keyframes test expects to be thrown.
There was a problem hiding this comment.
Pull request overview
Fixes the createGlobalStyle performance regression reported in #5730 by preventing unnecessary teardown/rebuild work on unrelated parent re-renders, while also updating related tests/docs and adding prerelease publishing automation.
Changes:
- Adjust
createGlobalStyleclient lifecycle to avoid redundant group rebuild work on every render when the emitted CSS is unchanged; add regression tests. - Refactor a few hot-path utilities (e.g. object-to-CSS flattening accumulator, shared char-code constants) and update related snapshots.
- Update docs/error strings to use the bare
styled-components.comdomain and add snapshot/prerelease publishing workflow/config.
Reviewed changes
Copilot reviewed 31 out of 32 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/errors.md | Sync legacy error markdown content/URLs with canonical package errors. |
| packages/styled-components/src/utils/test/flatten.test.tsx | Update inline snapshot URL to match new docs domain. |
| packages/styled-components/src/utils/stylis.ts | Reuse shared char-code constants; adjust namespace recursion behavior. |
| packages/styled-components/src/utils/hyphenateStyleName.ts | Use shared char-code constants and fast charCodeAt comparisons. |
| packages/styled-components/src/utils/flatten.ts | Reduce allocations in object-to-CSS flattening via accumulator helper. |
| packages/styled-components/src/utils/charCodes.ts | Add shared char-code constants used by hot paths. |
| packages/styled-components/src/models/test/Keyframes.test.ts | Update inline snapshot for revised keyframes error text/URL. |
| packages/styled-components/src/models/ComponentStyle.ts | Update component-selector warning URL to bare domain. |
| packages/styled-components/src/constructors/test/createGlobalStyle.test.tsx | Add regression tests ensuring no rebuild thrash on unrelated re-renders; clarify ordering expectations. |
| packages/styled-components/src/constructors/createGlobalStyle.ts | Split effects so cleanup runs on unmount/swap, not every render; enables unchanged-CSS fast path. |
| packages/styled-components/src/base.ts | Update React Native warning URL to bare domain. |
| packages/styled-components/docs/typescript-support.md | Update moved-doc link to bare domain. |
| packages/styled-components/docs/tips-and-tricks.md | Update example to use transient props and refresh React forwardRef link. |
| packages/styled-components/docs/theming.md | Update moved-doc link to bare domain. |
| packages/styled-components/docs/tagged-template-literals.md | Update moved-doc link to bare domain. |
| packages/styled-components/docs/security.md | Update moved-doc link to bare domain. |
| packages/styled-components/docs/react-native.md | Update moved-doc link to bare domain. |
| packages/styled-components/docs/flow-support.md | Update moved-doc link to bare domain. |
| packages/styled-components/docs/faq.md | Remove/adjust RSC/SSR explanations to focus on user-observable behavior. |
| packages/styled-components/docs/existing-css.md | Update moved-doc link to bare domain. |
| packages/styled-components/docs/css-we-support.md | Update moved-doc link to bare domain. |
| packages/styled-components/docs/README.md | Update docs root link to bare domain. |
| packages/styled-components/README.md | Update homepage link to bare domain. |
| packages/sandbox/README.md | Update RSC/SSR guidance text and remove implementation-detail bullets. |
| CONTRIBUTING.md | Avoid naming a specific AI provider in contributor guidance. |
| AGENTS.md | Update repo mandates/rules (benchmarking guidance, PR/changelog wording constraints, link conventions, global-style lifecycle notes). |
| .gitignore | Ignore node-compile-cache. |
| .github/workflows/prerelease.yml | Add prerelease publish workflow for snapshot publishing on main. |
| .github/ISSUE_TEMPLATE.md | Update docs link to bare domain. |
| .changeset/fix-global-style-rebuild-thrash.md | Add patch changeset describing the user-visible performance fix. |
| .changeset/config.json | Add snapshot/prerelease template config for Changesets. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** Internal accumulator form — avoids allocating a temp array per nesting level. */ | ||
| function objToCssArrayInto(obj: Dict<any>, rules: any[]): void { | ||
| for (const key in obj) { | ||
| const val = obj[key]; | ||
| if (!obj.hasOwnProperty(key) || isFalsish(val)) continue; | ||
|
|
||
| if ((Array.isArray(val) && cssTagged.has(val)) || isFunction(val)) { | ||
| rules.push(hyphenate(key) + ':', val, ';'); | ||
| } else if (isPlainObject(val)) { | ||
| rules.push(key + ' {', ...objToCssArray(val), '}'); | ||
| rules.push(key + ' {'); | ||
| objToCssArrayInto(val, rules); | ||
| rules.push('}'); | ||
| } else { | ||
| rules.push(hyphenate(key) + ': ' + addUnitIfNeeded(key, val) + ';'); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export const objToCssArray = (obj: Dict<any>): string[] => { | ||
| const rules: any[] = []; | ||
| objToCssArrayInto(obj, rules); | ||
| return rules; | ||
| }; |
There was a problem hiding this comment.
objToCssArray is typed as returning string[], but it pushes non-strings (e.g. functions and css-tagged arrays). This makes the exported signature incorrect and forces any[] internally. Consider typing objToCssArray/objToCssArrayInto as RuleSet<any> (or Interpolation<any>[]) and updating the accumulator param type accordingly.
| ## 12 | ||
|
|
||
| It seems you are interpolating a keyframe declaration (%s) into an untagged string. This was supported in styled-components v3, but is not longer supported in v4 as keyframes are now injected on-demand. Please wrap your string in the css\`\` helper which ensures the styles are injected correctly. See https://www.styled-components.com/docs/api#css | ||
| It seems you are interpolating a keyframe declaration (%s) into an untagged string. Please wrap your string in the css\`\` helper which ensures the styles are injected correctly. See https://styled-components.com/docs/api#css | ||
|
|
||
| ## 13 | ||
|
|
||
| %s is not a styled component and cannot be referred to via component selector. See https://www.styled-components.com/docs/advanced#referring-to-other-components for more details. | ||
| %s is not a styled component and cannot be referred to via component selector. See https://styled-components.com/docs/advanced#referring-to-other-components for more details. |
There was a problem hiding this comment.
errors.md has been updated, but the generated error map (packages/styled-components/src/utils/errors.ts) is still out of sync (it still contains the old error #12/#13 text + www URLs). Please run pnpm --filter styled-components generateErrors (or pnpm test, which runs it) and commit the regenerated errors.ts so source checkouts match the markdown.
| - name: Create git tag + GitHub prerelease per published package | ||
| if: steps.changesets.outputs.has_pending == 'true' | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: | | ||
| for pkg_json in packages/*/package.json; do | ||
| is_private=$(jq -r '.private // false' "$pkg_json") | ||
| [ "$is_private" = "true" ] && continue | ||
| name=$(jq -r '.name' "$pkg_json") | ||
| version=$(jq -r '.version' "$pkg_json") | ||
| ref="${name}@${version}" | ||
| git tag -a "$ref" -m "$ref" | ||
| git push origin "refs/tags/$ref" | ||
| gh release create "$ref" \ | ||
| --prerelease \ | ||
| --title "$ref" \ | ||
| --generate-notes | ||
| done |
There was a problem hiding this comment.
This workflow bumps versions "in-place, not committed" and then creates git tags like pkg@<snapshotVersion> pointing at the pre-bump commit. That makes tags/releases not correspond to the exact sources that produced the published tarballs, which is confusing for bisects/debugging. Consider either committing the snapshot bump to a temporary commit before tagging, or skipping git tags/GitHub releases for snapshots and relying on the npm dist-tag + the step summary.
| - name: Create git tag + GitHub prerelease per published package | |
| if: steps.changesets.outputs.has_pending == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| for pkg_json in packages/*/package.json; do | |
| is_private=$(jq -r '.private // false' "$pkg_json") | |
| [ "$is_private" = "true" ] && continue | |
| name=$(jq -r '.name' "$pkg_json") | |
| version=$(jq -r '.version' "$pkg_json") | |
| ref="${name}@${version}" | |
| git tag -a "$ref" -m "$ref" | |
| git push origin "refs/tags/$ref" | |
| gh release create "$ref" \ | |
| --prerelease \ | |
| --title "$ref" \ | |
| --generate-notes | |
| done | |
| - name: Record snapshot publish metadata | |
| if: steps.changesets.outputs.has_pending == 'true' | |
| run: | | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "Snapshot prereleases were published to npm with the \`test\` dist-tag." >> "$GITHUB_STEP_SUMMARY" | |
| echo "Git tags and GitHub prereleases are intentionally skipped for snapshots because the version bump is not committed, so tags would not match the exact published sources." >> "$GITHUB_STEP_SUMMARY" |
| name: Prerelease | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - main | ||
| workflow_dispatch: | ||
|
|
||
| concurrency: ${{ github.workflow }}-${{ github.ref }} | ||
|
|
||
| jobs: | ||
| prerelease: | ||
| name: Prerelease | ||
| runs-on: ubuntu-latest |
There was a problem hiding this comment.
The PR description is focused on the createGlobalStyle performance regression, but this adds automated publishing infrastructure (Prerelease workflow + snapshot config changes). If this is intentional, it should be called out in the PR description (or split into a separate PR) since it changes release/publishing behavior on every push to main.
…errors.md The generator wrote errors.js while the consumer imported ./errors, which resolves to errors.ts under allowJs: false. errors.ts had been accidentally committed (and gitignored) months ago and never updated, so every errors.md change rotted the tracked file further. rollup used the stale .ts, shipping old error text in published bundles. Switch the generator to overwrite errors.ts directly, un-gitignore it so drift is visible in review, and drop the obsolete errors.js ignore entry.
Prevents formatting drift from reaching CI. Generated errors.ts is already prettier-ignored so regeneration on postinstall doesn't block commits.
Fixes #5730.
In 6.4.0, dynamic
createGlobalStylecomponents caused large CPU spikes whenever a parent re-rendered — even when the rendered CSS hadn't changed. The internal teardown-and-reinsert step was running on every prop change instead of only on unmount, doing redundant work each time.This restores the intended behavior: global styles are only rebuilt when the CSS actually changes, not on every parent re-render. As a side effect, when multiple instances of the same
createGlobalStyleare mounted at once, their CSS is emitted in mount-order (restoring pre-6.4 cascade behavior).