perf(codegen): key composed css() args on identity instead of re-serializing - #3701
Merged
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: dd7bde7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 21 packages
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
memo's fast path is a value hash, andflatHashOrNullreturnsnullthe moment a value is an object — so a nested array sent every one of these calls toJSON.stringify(args). Per element, per render.What changed
Three regimes, picked per call:
WeakMaps, so a style object built for one render retains nothing once that render drops it.css({ … })needs, and it is the common call.JSON.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-inlineis the same wrapper chain with its styles written inline rather than hoisted, so the objects are rebuiltevery 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:
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
css({ … })with an inline literal, because a fresh object never hits and the cache thrashes.WeakMap.setdominates, not the allocation; worse than the fall-through.JSON.stringifyeverywhere; V8's serializer is hard to beat from JS.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
weakMemoalready assumes this forserializeCss, 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:
Verified
cargo nextest run --workspace— 2289 passedpnpm --filter @pandacss/compiler test— 533 passedpnpm --filter sandbox-codegen test— all 11 framework scenarioscargo fmt --check,clippy -D warningsclean🤖 Generated with Claude Code
https://claude.ai/code/session_01GVzc85Z4YGMGwQz2FnEfFo