Skip to content

perf(signals,web): dynamic(source, { static }) and isStatic(o, key) - #3471

Merged
ryansolid merged 1 commit into
nextfrom
perf/dynamic-static
Sep 15, 2026
Merged

ryansolid merged 1 commit into
nextfrom
perf/dynamic-static

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 15, 2026

Copy link
Copy Markdown
Member

Closes #3387. Stacked on #3470 (it needs isStatic on the public surface while the view protocol moves off it) — base retargets to next once 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 runtime styled() that always renders "li", and — the case this exists for — a polymorphic component whose as arrived as a literal. Kobalte's Polymorphic wraps every element in the library this way, and #3448's polymorphic-chain benches 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 truthful getOwnPropertyDescriptor from #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, so merge(getterSource, literal) is static and merge(literal, getterSource) is not.

Named isStatic, not isStaticProp: it reads any object's property, and 2.0 already dropped the suffix on mergeProps → 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. is and xmlns still 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.

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.

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, because isStatic reads 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:

  • A static component is byte-identical to the compiled call — no element of its own, nothing left to distinguish them.
  • A static tag still carries its own key. Any element created at runtime does; a compiled element inside a template does not. What the static path removes is the owner, which the key's depth shows.

Tests

  • utilities.test.ts: four isStatic cases — plain objects (data / getter / absent / setter-only), view chains (merge defaults, shadowing both directions, omit of 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/xmlns namespacing, and the per-call-site split with a live as swap on the memo instance.
  • test/server/dynamic-static.spec.tsx (server, 8): the same set, with the id comparisons pinned literally.
  • Parity harness, two scenarios: 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) and polymorphic-chain-static, whose ids come out 001010identical to polymorphic-chain-compiled-floor, against polymorphic-chain's 0010110. 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/typecheck tasks green.

Size

All ten size-limit scenarios fit. No scenario uses dynamic, so staticDynamic/staticElement shake out; isStatic is one export nothing there imports. The minified bundles are byte-identical to the base on every scenario checked (+createStore 47930 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 call dynamic(). Measured on a small app that does (render + one dynamic tag with a reactive child): +237 B minified / +81 B brotlistaticDynamic's dispatch (component / server-function binding / tag / nothing) and the option branch. The memo path's case "string" body was the same code as staticElement, 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-composition in the yak bench) is unrelated to merge/omit or dynamic and still needs its own profiling pass.


🤖 Generated with Claude Code via Cursor

Made with Cursor

@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0e59278

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

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
@solidjs/web Patch
test-integration Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
solid-js Patch
@solidjs/universal 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

@ryansolid
ryansolid force-pushed the chore/internal-view-protocol branch from e2f084b to 29939bc Compare September 15, 2026 18:18
@ryansolid
ryansolid force-pushed the perf/dynamic-static branch 2 times, most recently from d5607ed to dc2248a Compare September 15, 2026 18:33
`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>
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