perf(signals,web): dynamic(source, { static }) and isStatic(o, key) - #3471
Merged
Merged
Conversation
🦋 Changeset detectedLatest commit: 0e59278 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 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 |
ryansolid
force-pushed
the
chore/internal-view-protocol
branch
from
September 15, 2026 18:18
e2f084b to
29939bc
Compare
ryansolid
force-pushed
the
perf/dynamic-static
branch
2 times, most recently
from
September 15, 2026 18:33
d5607ed to
dc2248a
Compare
`dynamic()` pays for a factory memo plus a per-instance memo so the source can
change. A great many call sites never change: a runtime `styled()` that always
renders "li", and — the case this exists for — a polymorphic component whose
`as` arrived as a literal. The compiler encodes `as="button"` at a call site as
a data property and `as={isLink() ? "a" : "button"}` as a getter, so which one
the caller wrote is readable at runtime.
`isStatic(o, key)` reads it: one descriptor lookup, no read of the value,
nothing tracked, looking through merge()/omit() views to the leaf that owns the
key (the truthful `getOwnPropertyDescriptor` from #3454). Data property, or
absent from an object whose key set is fixed, is static; a getter, a store key,
a memo-backed merge() source, or any key of an object whose keys can appear
later is not. A foreign `$PROXY`-marked object is opaque, so not static.
`dynamic(source, { static: true })` then says the source cannot change: called
once, untracked, at dynamic() time, and each instance renders the result with
no computation of its own. A tag goes to the compiled element path (create or
claim, spread, runHydrationEvents) and a component is called directly — no
owner on either side, which is what keeps hydration ids aligned. `is`/`xmlns`
still decide creation, read untracked as on the memo path. A static source may
not resolve to a promise (dev throws).
function Polymorphic(props) {
const Tag = dynamic(() => props.as, { static: isStatic(props, "as") });
return <Tag {...omit(props, "as")} />;
}
`as` stays public and reactive; the literal case stops paying for it.
Note the two paths produce DIFFERENT hydration ids — the memo path's element
sits one owner deeper (`_hk=10` vs `_hk=1` for the same tree). That is fine
because isStatic reads the same descriptors on both sides, but it is why the
classification must be per instance rather than per component. A static
component is byte-identical to the compiled call; a static TAG still carries
its own key, since any element created at runtime does and a compiled element
inside a template does not.
Tests: isStatic across plain objects, view chains, stores, memo sources and
foreign proxies (four cases in utilities.test.ts); client and server specs for
both forms, the falsy source, the promise guard, xmlns, and the per-call-site
split; two parity-harness scenarios — `dynamic-static-forms` (tag, component
and falsy in one tree, with a reactive tail) and `polymorphic-chain-static`,
whose ids match `polymorphic-chain-compiled-floor` exactly (001010) against
`polymorphic-chain`'s 0010110.
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid
force-pushed
the
perf/dynamic-static
branch
from
September 15, 2026 18:44
dc2248a to
0e59278
Compare
ryansolid
changed the base branch from
chore/internal-view-protocol
to
next
September 15, 2026 18:44
This was referenced Sep 15, 2026
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.
Closes #3387. Stacked on #3470 (it needs
isStaticon the public surface while the view protocol moves off it) — base retargets tonextonce that merges.The cost being removed
dynamic()pays for a factory memo plus a per-instance memo so the source can change. A great many call sites never change: a runtimestyled()that always renders"li", and — the case this exists for — a polymorphic component whoseasarrived as a literal. Kobalte'sPolymorphicwraps every element in the library this way, and #3448'spolymorphic-chainbenches put six of those layers on one<a>.The compiler already tells us which is which:
as="button"at a call site is a data property,as={isLink() ? "a" : "button"}is a getter. So the caller's own static/dynamic classification is readable at runtime, identically on server and client.isStatic(o, key)One descriptor lookup. No read of the value, nothing tracked. Looks through
merge()/omit()views to the leaf that owns the key, using the truthfulgetOwnPropertyDescriptorfrom #3454.Static: a data property, or a key absent from an object whose key set is fixed. Not static: a getter, a key on a store (the value can change and the key can appear later), a memo-backed
merge()source, or any key of a foreign$PROXY-marked object (opaque — nothing about it is known to be fixed). Shadowing resolves to the winning leaf, somerge(getterSource, literal)is static andmerge(literal, getterSource)is not.Named
isStatic, notisStaticProp: it reads any object's property, and 2.0 already dropped the suffix onmergeProps → merge/splitProps → omit.dynamic(source, { static })Says the source cannot change: it is called once, untracked, at
dynamic()time, and each instance renders the result with no computation of its own. A tag goes to the compiled element path (create or claim,spread,runHydrationEvents); a component is called directly; a falsy source renders nothing.isandxmlnsstill decide how the element is created, read untracked as on the memo path. A static source may not resolve to a promise — dev throws rather than rendering a thenable as a component.asstays public and reactive; the literal case stops paying for it.The hydration-id consequence, stated plainly
The two paths produce different hydration ids — the memo path interposes one owner, so the same tree serializes
<a _hk=10>on the memo path and<a _hk=1>on the static path. That is fine, becauseisStaticreads the same descriptors on both sides and so both sides choose the same path per instance. But it is exactly why the classification must be per instance rather than per component, and why the specs pin the ids rather than just the markup.Two further details worth knowing:
Tests
utilities.test.ts: fourisStaticcases — plain objects (data / getter / absent / setter-only), view chains (mergedefaults, shadowing both directions,omitof the key itself, a six-deep Kobalte-shaped chain, and proof the answer doesn't depend on what a getter currently returns), stores and memo sources, and foreign proxies marked and unmarked.test/dynamic-static.spec.tsx(client, 7): the compiled element path with reactive attributes and children, source-called-once, owner depth measured against both compiled JSX and the memo path, falsy, the promise guard,is/xmlnsnamespacing, and the per-call-site split with a liveasswap on the memo instance.test/server/dynamic-static.spec.tsx(server, 8): the same set, with the id comparisons pinned literally.dynamic-static-forms(a static tag, a static component and a falsy source in one tree, followed by a reactive tail whose binding only survives if every claim above it landed) andpolymorphic-chain-static, whose ids come out001010— identical topolymorphic-chain-compiled-floor, againstpolymorphic-chain's0010110. The chain reaches the compiled floor exactly.Verification
Full build 25/25. signals 2044, web 803 + 939 + 185 (parity harness 117, up from 116), and all remaining suites and the 30
test/test-types/typechecktasks green.Size
All ten size-limit scenarios fit. No scenario uses
dynamic, sostaticDynamic/staticElementshake out;isStaticis one export nothing there imports. The minified bundles are byte-identical to the base on every scenario checked (+createStore47930 B, hydrating 55485 B); the brotli deltas (−11 … +47) are the minifier's name allocation shifting over identical code. The cost lands only in bundles that calldynamic(). Measured on a small app that does (render+ onedynamictag with a reactive child): +237 B minified / +81 B brotli —staticDynamic's dispatch (component / server-function binding / tag / nothing) and the option branch. The memo path'scase "string"body was the same code asstaticElement, so both paths now call it; that took 72 B back off the minified figure.Not in this PR
The SSR gap on composition-heavy app code (
tabs,multifile-compositionin the yak bench) is unrelated tomerge/omitordynamicand still needs its own profiling pass.🤖 Generated with Claude Code via Cursor
Made with Cursor