Skip to content

fix: avoid double rebuildGroup on every dynamic createGlobalStyle render (#5730)#5732

Merged
quantizor merged 14 commits into
mainfrom
claude/investigate-styled-components-5730-OysXI
Apr 17, 2026
Merged

fix: avoid double rebuildGroup on every dynamic createGlobalStyle render (#5730)#5732
quantizor merged 14 commits into
mainfrom
claude/investigate-styled-components-5730-OysXI

Conversation

@quantizor

@quantizor quantizor commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #5730.

In 6.4.0, dynamic createGlobalStyle components 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 createGlobalStyle are mounted at once, their CSS is emitted in mount-order (restoring pre-6.4 cascade behavior).

@changeset-bot

changeset-bot Bot commented Apr 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 899f59f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
styled-components Patch

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

@quantizor quantizor force-pushed the claude/investigate-styled-components-5730-OysXI branch from 974ec67 to ba857ed Compare April 17, 2026 15:17
claude added 7 commits April 17, 2026 17:19
…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.
@quantizor quantizor force-pushed the claude/investigate-styled-components-5730-OysXI branch from 3c552a7 to 247d6fc Compare April 17, 2026 17:22
claude and others added 4 commits April 17, 2026 17:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 createGlobalStyle client 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.com domain 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.

Comment on lines +49 to 71
/** 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;
};

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 64 to +70
## 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.

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +87
- 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

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- 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"

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +14
name: Prerelease

on:
push:
branches:
- main
workflow_dispatch:

concurrency: ${{ github.workflow }}-${{ github.ref }}

jobs:
prerelease:
name: Prerelease
runs-on: ubuntu-latest

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
claude added 3 commits April 17, 2026 18:16
…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.
@quantizor quantizor merged commit 52bda0c into main Apr 17, 2026
5 checks passed
@quantizor quantizor deleted the claude/investigate-styled-components-5730-OysXI branch April 17, 2026 18:30
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.

Performance regression with 6.4.0

3 participants