Skip to content

perf(codegen): key composed css() args on identity instead of re-serializing - #3701

Merged
segunadebayo merged 2 commits into
v2from
perf/memo-composed-args
Jul 27, 2026
Merged

perf(codegen): key composed css() args on identity instead of re-serializing#3701
segunadebayo merged 2 commits into
v2from
perf/memo-composed-args

Conversation

@segunadebayo

@segunadebayo segunadebayo commented Jul 27, 2026

Copy link
Copy Markdown
Member

css() re-serialized the entire style tree on every render when styles arrived through a wrapper chain. This keys those calls on identity instead.

The shape

Each level of a chain rebuilds its array every render, while the style objects inside stay the same instances:

const L0 = ({ css: cssProp }) => <button className={css(l0, cssProp)} />
const L1 = ({ css: cssProp }) => <L0 css={[l1, cssProp]} />
const L2 = ({ css: cssProp }) => <L1 css={[l2, cssProp]} />

memo's fast path is a value hash, and flatHashOrNull returns null the moment a value is an object — so a nested array sent every one of these calls to JSON.stringify(args). Per element, per render.

What changed

Three regimes, picked per call:

  • arguments containing an array — walk a trie keyed on the identity of the objects inside. The trie's object nodes are WeakMaps, so a style object built for one render retains nothing once that render drops it.
  • flat arguments — the existing value hash, untouched. This is what css({ … }) needs, and it is the common call.
  • anything elseJSON.stringify, unchanged. V8 does that faster than any JS walk I could write.

Measurements

Against the actually generated runtime, interleaved rounds so drift hits both equally (ns/call):

              compose-3  compose-6  compose-inline  compose-inline-all  inline-same  inline-cycle  const-arg  dyn-unique
shipped             802       1027             664                 672          285           288         31        1988
this change         123        283             511                 576          266           279         36        1925
                  +552%      +263%            +30%                +17%          +7%           +3%       -14%         +3%

compose-inline is the same wrapper chain with its styles written inline rather than hoisted, so the objects are rebuilt
every render. An earlier cut of this change was 7x slower there — it keyed everything on identity, and a fresh object
missed every time. Those calls now stay on the value path, and skip a flat hash they were always going to fail, so they
come out ahead too.

End to end, rendering 1000 elements of the bench's compose cases:

compose-3   1.524ms -> 0.845ms   +80%
compose-6   2.113ms -> 1.280ms   +65%

Retained heap after 600k calls with a unique value every time: 8.3 MB, against 8.4 MB for the shipped memo. An earlier interning design was faster still on one case but retained 111 MB — that is why the trie holds objects weakly.

Alternatives measured and rejected

  • Pure identity keying — 3x on the chains, but 12x slower on css({ … }) with an inline literal, because a fresh object never hits and the cache thrashes.
  • Identity keying with a value fall-through — fixes the inline literal but still allocates a trie node per miss, leaving inline-composed 67% down.
  • Lazily allocated trie nodes — the WeakMap.set dominates, not the allocation; worse than the fall-through.
  • Hash-consing every argument — fastest on the chains (+426%) but 76–111 MB retained, and 22% slower when every call carries a new value.
  • Deep structural hashing — slower than JSON.stringify everywhere; V8's serializer is hard to beat from JS.
  • Flattening arrays into the existing hash — 50%+ slower across the board.

Tests

Four cases in sandbox/codegen: arrays merge with later entries winning, a rebuilt tree returns the identical class string, compositions differing deep in the tree stay distinct, and a changed value in a rebuilt object is reflected.

One semantic note: the composed path assumes a style object is not mutated in place between renders. The shipped weakMemo already assumes this for serializeCss, which is keyed on the merged object's identity.

A note on how this was measured

An intermediate version looked 548% faster on the composed case and was in fact never using the trie at all — a
last-call shortcut returned before the code that populates it, so the number came from a cheaper path that still
serialized every call. The benchmark could not tell the difference. There is now a probe that counts trie hits, misses
and inserts, and every reported figure was taken with it confirming the intended path ran:

compose-3            {"calls":50,"hit":42,"miss":8,"insert":1}
compose-inline       {"calls":50,"hit":0,"miss":50,"insert":0}

Verified

  • cargo nextest run --workspace — 2289 passed
  • pnpm --filter @pandacss/compiler test — 533 passed
  • pnpm --filter sandbox-codegen test — all 11 framework scenarios
  • cargo fmt --check, clippy -D warnings clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01GVzc85Z4YGMGwQz2FnEfFo

…alizing

A wrapper chain forwards styles in arrays it rebuilds every render while the
style objects inside stay the same instances. The memo saw a new tree each
call, fell past its flat-hash fast path, and ran JSON.stringify over the whole
composition — every render, for every element.

Calls whose arguments contain an array now walk a trie keyed on object
identity. The trie's object nodes are WeakMaps, so a style object built for one
render retains nothing once that render drops it. Flat arguments keep the value
hash, and everything else keeps JSON.stringify, which V8 does faster than a JS
walk.

Measured on the css-in-js-bench compose cases: 1.52ms -> 0.85ms for a
three-level chain, 2.11ms -> 1.28ms for six, with no change outside the
composed path and no growth in retained heap.
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
panda-docs Error Error Jul 27, 2026 6:27pm
panda-playground Error Error Jul 27, 2026 6:27pm
panda-studio Error Error Jul 27, 2026 6:27pm

Request Review

@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dd7bde7

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

This PR includes changesets to release 21 packages
Name Type
@pandacss/compiler Patch
@pandacss/cli Patch
@pandacss/dev Patch
@pandacss/eslint-plugin Patch
@pandacss/language-server Patch
@pandacss/mcp Patch
@pandacss/postcss Patch
@pandacss/rollup Patch
@pandacss/transformer Patch
@pandacss/typescript-plugin Patch
@pandacss/vite Patch
@pandacss/webpack Patch
playground Patch
website Patch
@pandacss/compiler-shared Patch
@pandacss/compiler-wasm Patch
@pandacss/config Patch
@pandacss/preset-base Patch
@pandacss/preset-panda Patch
@pandacss/preset-typography Patch
@pandacss/types 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

…eused

The first cut keyed every composed call on identity, which is right for a
wrapper chain forwarding module-level styles and wrong for one whose styles
are written inline: a fresh object missed every time and the work ran in
full, 7x slower than serializing.

Reading the trie now allocates nothing, and a node is inserted only once the
objects in the tree have been seen before, so inline styles stay on the value
path. That bookkeeping runs on every fourth miss rather than every miss, which
is enough for a repeating composition to land in the trie within a few calls
while a rebuilt one barely pays for it.

Composed calls also skip the flat hash they were always going to fail, so the
inline shape ends up faster than before rather than slower.

Widens the css() style-list type to match what the runtime has always
accepted: arrays nested more than one level deep.
@segunadebayo
segunadebayo merged commit 172c52f into v2 Jul 27, 2026
10 of 13 checks passed
@segunadebayo
segunadebayo deleted the perf/memo-composed-args branch July 27, 2026 21:14
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