Skip to content

perf(signals,web,universal,html): merge/omit are always lazy views; consumers read the leaves - #3454

Merged
ryansolid merged 3 commits into
nextfrom
perf/omit-view
Sep 15, 2026
Merged

ryansolid merged 3 commits into
nextfrom
perf/omit-view

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 15, 2026

Copy link
Copy Markdown
Member

What

merge() and omit() never copy under Proxy, and the props consumers (spread in web and universal, ssrElement, a nested merge) read their leaves instead of going through the proxies' traps.

  • omit() returns a view record for every input (plain objects included) and takes a predicate: omit(props, k => k[0] === "$").
  • merge() returns an O(1) view over its flattened sources; a single non-function source is returned as is.
  • The two compose flat: omit-over-merge carries one filtered leaf view per source, merge-over-omit takes the record as a leaf, nested omits fold their filters. The headless-UI chain merge(defaults) → omit(consumed) → merge(statics) → omit("as") collapses to leaf views over the author's objects with no proxy layer between the outermost spread and the props. Omitted keys stay hidden by construction (SSR spread ignores omit()'s hidden keys when props are a merge proxy — omitted props leak into the HTML and the leaked handler is re-bound on hydration #3014).
  • Reads: a view over plain leaves resolves a key → owning-leaf table on first read (merged key order — a key at the position of its last source, matching ssrElement's array form). After that get/has/descriptor are one lookup, and spread/ssrElement walk the table, so an effect rerun is one read per key as it was over the eager copy. Views over a store, a memo source, or any $PROXY in s proxy (frames slot props) keep the in walk.
  • Truthful descriptors: Object.getOwnPropertyDescriptor(view, key) through any depth reports a data descriptor only for a data property of a plain leaf, an accessor for a getter / store key / memo source. New internal hasStaticKeys(o); spread uses both to skip the children effect for static children behind omit/merge layers (spread() creates three reactive nodes per element #3388 through views).
  • @solidjs/html collected props by assigning onto merge()'s result; it now builds its own objects and merges once at the end (merge() re-flattens through a stale $SOURCES and discards the object's own properties #3384).

Behavior changes

  • Writes to a merge()/omit() result are no-ops (already true for the proxy forms). Copy to own it: { ...merged }; the copy carries no sources.
  • A data property on a source is read live through the view rather than snapshotted.
  • Sources are own-keyed: a key added to a plain source after merging is not seen (the copy did not see it either).
  • Enumerating a view through its traps (for…in, Object.keys, {...view}) costs a trap per key, as any proxy does — ~9× the plain copy for a 15-key object. Solid's consumers don't; the cases that do in user code (rest destructuring after merge, snapshotting for a non-Solid library, Object.entries over a merged style object) are uncommon and one-shot.
  • No-Proxy platforms are unchanged: the copy paths are the pre-existing ones, and the things that always needed Proxy (stores, function sources) still do. Pinned by the new utilities-no-proxy.test.ts, which mocks SUPPORTS_PROXY off.

Numbers

Signals-layer chain (props-chain.bench.ts shape), same run, vs next:

depth 3 next this PR
build 28.1 µs 6.5 µs
build + point reads 28.4 µs 7.3 µs
build + enumerate once 30.9 µs 11.1 µs

polymorphic-chain benches from #3448: SSR 200 rows ~2.4× faster (18× → ~8× the compiled floor); DOM mount ~25% faster; DOM update at parity (it regressed 38% before the resolved table).

yak-bench SSR, normalized to React within each run (machine was noisy): solid-prim — the lane that stands in yak's copyProps/withTheme with merge(omit(...)) — +12% geomean, +22–33% on button-variants* / compose-*, +39% multifile-composition; solid-pr +6%. Markup parity verified on every lane and case.

CodSpeed regressions (utilities.bench.ts) and store shapes

Three shapes in the red column, wall-clock re-measured locally (next → PR):

  • Construction over plain leaves (merge-static(0, 15): −88% on CodSpeed) is 6–7× faster in wall clock (1.9M → 14.3M ops/s). The old path returned the last source when it covered every key; the new one allocates a Proxy, and the instrumented simulation charges that runtime call far out of proportion to its ~70 ns real cost. Artifact.

  • Store-backed views were genuinely slower — not just to construct, and not "uncommon": merge(defaults, store) is a typical shape and reads through it were 1.5× slower per read. Two causes, both fixed (1ae4058, 44da46e):

    • the helpers probed $SOURCES/$OMIT on the store, and unknown symbols take the store's generic get path (firewall gate, tracked key read), several times per call. Stores are now identified through $TARGET, a fast-path symbol in the store's trap that the view traps answer first;
    • every read went through sourceHas/sourceGet, each opening with instanceof OmitView — on a Proxy that is a getPrototypeOf trap, ~20 ns, the same as the store read itself, twice per read. Each source's kind is now decided once when the view is built and carried beside it (MergeView.kinds, OmitView.kind); the traps and the consumers (spread, ssrElement) switch on it and ask a proxy nothing but the read. A trap-logging store-shaped proxy in utilities.test.ts pins this.
    25-key store next PR
    merge(defaults, store) construct 84 ns 61 ns
    omit(store, …5) construct 36 ns 47 ns
    merge(defaults, store) + 5 reads 404 ns 346 ns
    merge(defaults, store) read every key 2081 ns 1824 ns
    omit(store, …5) read every key 1470 ns 1287 ns
    Object.keys(merge(defaults, store)) 9708 ns 9191 ns
    Object.keys(omit(store, …5)) 6979 ns 5417 ns
  • Enumerating a view over plain leaves (for…in, Object.keys, {...view}) is the one remaining cost above next: ~100–130 ns/key against ~15 ns/key over the eager copy, and it is the Proxy floor — two traps per key plus the engine's descriptor round-trip; the view's own work (cached keys array, cached descriptor shape, one Map lookup) is a minority of it. next already had this floor whenever a store or an omit was involved; only merge of plain objects changes class. None of Solid's consumers enumerate a view — they read resolvedTable/the sources directly.

Tests

  • utilities.test.ts: merge/omit view semantics, truthful descriptors through layers, hasStaticKeys, layer collapse to leaf views, copies are plain.
  • utilities-no-proxy.test.ts: the copy path, and the function-source limitation pinned as such.
  • spread-nodes.spec.tsx: static children behind omit/merge views cost no children effect; a store leaf keeps the reactive path.
  • spread-sources.spec.tsx, ssr-element-sources.spec.tsx, universal spread-sources.spec.js: consumers read views/leaves; attribute order matches the array form.

Closes #3388's remaining "through views" case; groundwork for #3387 (dynamic(source, { static }) / isStaticProp) which now needs only a descriptor check.

…onsumers read the leaves

merge() and omit() never copy under Proxy. omit() returns a view record for
every input (plain objects included, predicate filters supported); merge()
returns an O(1) view over its flattened sources, or the single non-function
source itself. omit-over-merge carries one filtered leaf view per source,
merge-over-omit takes the record as a leaf, nested omits fold their filters:
the headless-UI chain merge → omit → merge → omit collapses to leaf views over
the author's objects with no proxy layer between spread and props.

Reads: a view over plain leaves resolves a key → owning-leaf table on first
read (merged key order), after which get/has/descriptor are one lookup.
spread() (web, universal) and ssrElement() read leaves directly and walk the
table when present, so an effect rerun is one read per key as it was over
the eager copy. Views over a store, a memo source, or any `$PROXY in s`
proxy (frames slot props) keep the `in` walk.

Truthful descriptors: getOwnPropertyDescriptor through any depth reports a
data descriptor only for a data property of a plain leaf, an accessor for a
getter, store key, or memo source. hasStaticKeys() says when a key set is
fixed; spread() uses both to skip the children effect for static children
behind omit/merge layers (#3388 through views).

@solidjs/html built props by assigning onto merge()'s result; it now
collects its own props and merges once at the end (#3384).

Signals-layer chain (depth 1/3/7) vs next: build 7.4/29/131 µs → 2.2/6.4/19;
build+reads 8.3/31/95 → 2.5/7.4/20. SSR polymorphic-chain 200 rows ~2.4×
faster; DOM update path at parity.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 44da46e

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
solid-js Patch
@solidjs/web Patch
@solidjs/universal Patch
@solidjs/html Patch
test-integration Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics 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

@coveralls

coveralls commented Sep 15, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34954087642

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.06%) to 71.898%

Details

  • Coverage increased (+0.06%) from the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1007
Covered Lines: 772
Line Coverage: 76.66%
Relevant Branches: 790
Covered Branches: 520
Branch Coverage: 65.82%
Branches in Coverage %: Yes
Coverage Strength: 15.1 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 15, 2026

Copy link
Copy Markdown

Merging this PR will regress 37 benchmarks

⚡ 56 improved benchmarks
❌ 37 regressed benchmarks
✅ 79 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
ownKeys 358.5 µs 3,256 µs -88.99%
omit 15.6 µs 36.2 µs -56.94%
omit 15.5 µs 35.7 µs -56.46%
omit 17.1 µs 39 µs -56.19%
omit 17.3 µs 39.4 µs -56.14%
omit 18.1 µs 36.2 µs -49.97%
omit 17.9 µs 35.8 µs -49.91%
omit 19.7 µs 39.1 µs -49.6%
omit 19.7 µs 39 µs -49.54%
omit 24.6 µs 40 µs -38.49%
merge 75.9 µs 110 µs -31.02%
omit 21.8 µs 30.4 µs -28.3%
ownKeys 502.1 µs 680.3 µs -26.19%
ownKeys 187.5 µs 253.8 µs -26.13%
omit 22.4 µs 30.2 µs -26.05%
omit 29.7 µs 40.2 µs -26.03%
ownKeys 536.3 µs 716.8 µs -25.18%
merge 83.5 µs 109.9 µs -24.01%
merge 31.2 µs 41 µs -23.95%
merge 35.6 µs 46.2 µs -23%
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/omit-view (44da46e) with next (0d8347a)

Open in CodSpeed

ryansolid and others added 2 commits September 15, 2026 02:15
… generic read path

Constructing a view over a store proxy probed $SOURCES/$OMIT on the store;
unknown symbols take the store's generic get path (firewall gate, tracked
read), several times per omit()/merge(). Ask $TARGET first — a store's get
trap answers it on its symbol fast path and the view traps answer it before
anything else — and skip the second `in` on a store when the shadowing walk
already established presence.

omit(store, ...5): 2.9M/s -> 6.4M/s (next: 9.7M/s)
merge(defaults, store): 2.6M/s -> 6.9M/s (next: 4.3M/s)
Object.keys(omit(store)): 17k/s -> 28k/s (next: 33k/s)

Co-authored-by: Cursor <cursoragent@cursor.com>
… no brand check per read

A read through merge(defaults, store) was 1.5x slower than next: the walk
was the same (in, then get) but went through sourceHas/sourceGet, each
starting with `s instanceof OmitView` — on a store proxy that is a
getPrototypeOf trap (~20 ns, as much as the read itself), twice per read.
spread()'s per-key walk over a store-backed view paid the same.

Every source entry now carries its kind (plain / omit record / proxy /
memo), decided in merge()'s flattening loop and omit()'s argument check
where the information already exists (MergeView.kinds, OmitView.kind).
mergeGet/has/descriptor/ownKeys and the consumers (spread in web and
universal, ssrElement) switch on the kind; the exported entry helpers take
it as a parameter. viewOf(o) hands consumers the record behind a merge or
omit proxy in two fast traps.

Table-backed views also cache their own-keys array and per-key descriptor
shape, so an enumeration through the traps re-reads no leaf descriptor.

  merge(defaults, store)               84 ns ->  61 ns  (next -> branch)
  merge(defaults, store) + 5 reads    404 ns -> 346 ns  (was 635 before this)
  merge(defaults, store) every key   2081 ns -> 1824 ns (was 3149)
  omit(store, 5) every key           1470 ns -> 1287 ns
  Object.keys(omit(store, 5))        6979 ns -> 5417 ns

A trap-logging store-shaped proxy pins it: a read through a merge or omit
asks the store exactly what a direct read would.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit 899c2c4 into next Sep 15, 2026
6 of 7 checks passed
ryansolid added a commit that referenced this pull request Sep 15, 2026
…ether tip it by 9 B

Each PR fit alone; the union measures 15809 B on next a8a8949 against
the 15800 cap. No source change.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 15, 2026
…internal

`merge()`/`omit()` returning lazy views (#3454) gave `spread()` and
`ssrElement()` a protocol for reading props leaf by leaf instead of trapping
through the proxy per key. `@solidjs/web` and `@solidjs/universal` depend on
`solid-js` alone — never on `@solidjs/signals` directly, so an app holds
exactly one reactive engine — so every piece of that protocol went out through
`solid-js`'s main export: eleven names, `mergeSources` before them in #3325,
on the public surface with no marking. None of it is API.

`solid-js/internal` is now their home: the view protocol (`viewOf`,
`mergeView`/`omitView`, `MergeView`/`OmitView`, `sourceKeys`/`sourceHas`/
`sourceGet`, `hasStaticKeys`, `resolvedTable`, the `SOURCE_*` kinds), and the
server-scope seams that were already `@internal` in JSDoc and consumed only by
`@solidjs/web` (`ssrHandleError`, `ssrScope`, `runInServerComponentScope`,
`inServerComponentScope`, `creationStamp`, `getProjectionTrace`,
`materializeContainerTrace`).

The names stay exported from the main entries AT RUNTIME, so the subpath
shares one module state and the single-engine guarantee is untouched; they
carry `@internal` and `stripInternal` keeps them out of the generated
declarations, which is what makes them not-public for TypeScript. The protocol
half re-exports `@solidjs/signals` as-is; the seams are read back from
"solid-js" (external, so the platform/tier conditions pick the same build the
app runs) and declared with their signatures spelled out, since the main
entries no longer type them.

Dropped from the entries as dead: `storeIsShallow`, `storeHasFamily`,
`storeHasOptimisticFamily` (leftovers of the gutted patch channel),
`storePath` and `$REFRESH` (referenced only by `@solidjs/signals`'s own
internals), `NoHydrateContext` (`@internal`, used only by `solid-js`'s server
code). Nothing in the repo consumed them.

internal-surface.spec.ts pins the boundary in both directions — no internal
name in either main entry's declarations, all of them in internal.d.ts — which
is the check that would have caught #3454 leaking eleven of these.

No runtime behavior change. Size, measured on real app bundles: the client is
byte-identical raw and 13 bytes smaller gzipped (the seams tree-shake out
entirely, so the namespace import costs nothing), the server is +24 gzipped
from two alias consts the typed declarations require. solid-js's own entries
shrink 122/141 gzipped bytes from the dead exports.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 15, 2026
`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 added a commit that referenced this pull request Sep 15, 2026
`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 added a commit that referenced this pull request Sep 15, 2026
…internal (#3470)

* chore(solid,web,universal): the runtimes' seams move behind solid-js/internal

`merge()`/`omit()` returning lazy views (#3454) gave `spread()` and
`ssrElement()` a protocol for reading props leaf by leaf instead of trapping
through the proxy per key. `@solidjs/web` and `@solidjs/universal` depend on
`solid-js` alone — never on `@solidjs/signals` directly, so an app holds
exactly one reactive engine — so every piece of that protocol went out through
`solid-js`'s main export: eleven names, `mergeSources` before them in #3325,
on the public surface with no marking. None of it is API.

`solid-js/internal` is now their home: the view protocol (`viewOf`,
`mergeView`/`omitView`, `MergeView`/`OmitView`, `sourceKeys`/`sourceHas`/
`sourceGet`, `hasStaticKeys`, `resolvedTable`, the `SOURCE_*` kinds), and the
server-scope seams that were already `@internal` in JSDoc and consumed only by
`@solidjs/web` (`ssrHandleError`, `ssrScope`, `runInServerComponentScope`,
`inServerComponentScope`, `creationStamp`, `getProjectionTrace`,
`materializeContainerTrace`).

The names stay exported from the main entries AT RUNTIME, so the subpath
shares one module state and the single-engine guarantee is untouched; they
carry `@internal` and `stripInternal` keeps them out of the generated
declarations, which is what makes them not-public for TypeScript. The protocol
half re-exports `@solidjs/signals` as-is; the seams are read back from
"solid-js" (external, so the platform/tier conditions pick the same build the
app runs) and declared with their signatures spelled out, since the main
entries no longer type them.

Dropped from the entries as dead: `storeIsShallow`, `storeHasFamily`,
`storeHasOptimisticFamily` (leftovers of the gutted patch channel),
`storePath` and `$REFRESH` (referenced only by `@solidjs/signals`'s own
internals), `NoHydrateContext` (`@internal`, used only by `solid-js`'s server
code). Nothing in the repo consumed them.

internal-surface.spec.ts pins the boundary in both directions — no internal
name in either main entry's declarations, all of them in internal.d.ts — which
is the check that would have caught #3454 leaking eleven of these.

No runtime behavior change. Size, measured on real app bundles: the client is
byte-identical raw and 13 bytes smaller gzipped (the seams tree-shake out
entirely, so the namespace import costs nothing), the server is +24 gzipped
from two alias consts the typed declarations require. solid-js's own entries
shrink 122/141 gzipped bytes from the dead exports.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(size): route solid-js/internal in the size scenarios

esbuild's `alias` matches by PREFIX, so the bare `solid-js` entry remapped
`solid-js/internal` to `.../dist/solid.js/internal` and the prod and observe
scenarios failed to resolve — the same trap this file already documents for
`solid-js/attribution`. Subpath alias listed first in both maps, and added to
the frames scenario's `external` (its client entry imports the seam).

Two caps move, both measured against `next` on the same machine:

- frames 11.40 -> 11.45 KB: 11442 B against 11370 (+72 brotli on +31
  minified). Nothing in that bundle changed but one import's specifier —
  `materializeContainerTrace` used to fold into the single `from "solid-js"`
  statement and is now its own `from "solid-js/internal"` statement.
- hydrating + store family 28.90 -> 28.95 KB: 28901 B against 28861 (+40).
  Mangler noise: the minified bundle is byte-identical (89072 B both sides)
  and differs only in which short names the minifier hands out, the extra
  module boundary having shifted its allocation. The same swap compresses
  simple-app -33, hydrating -45 and CSR -21; this one came out 1 B over a
  cap #3459 had just used the room under.

The other seven scenarios are unchanged or smaller.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 15, 2026
`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 added a commit that referenced this pull request Sep 15, 2026
…3471)

`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 added a commit that referenced this pull request Sep 15, 2026
…r 16 reads, not on first read; ssrElement walks a view's entries

A merge()/omit() view over plain objects keeps a resolved key table so a
client spread that reruns, or Object.keys/{...props}, is one lookup per
key. Since #3454 the first per-key trap read built it too. On the server
that is the wrong trade: a component reads its merged props a few times,
the element serializes them once, and the view is gone — a Kobalte-shaped
chain (Dialog.Trigger → Button.Root → Polymorphic) paid for a table per
layer per element. Profiled under renderToString: a third of the time in
mergeTable/tableSet/omitTable and their garbage, another 11% in
Array.prototype.concat combining omit filters.

Signals: get/has/getOwnPropertyDescriptor answer by a source walk until
the view has been read 16 times — the break-even between a build (~60 ns
per key of every leaf) and a walk (~20 ns per source) — then build as
before, so a long-lived client view read on every rerun is one lookup per
read from its first few updates on. Enumeration builds outright. An omit
over one object never builds one. Combined hidden lists are copied by
hand (concat is 4× the cost for three-or-four-key lists).

Web: ssrElement never asks for a view's table; an omit over a merge is
walked as its filtered leaf entries, the array-form walk it already had.
Merged attribute order is unchanged.

Same-process A/B, tier-1 polymorphic-chain SSR (200 rows): chain 8.2× →
5.8× the compiled floor, chain-static 7.7× → 5.6× (−27%). Signals
props-chain: build −5…−17%, build+consume −6…−14%, steady-state reads on
a prebuilt view identical. DOM lane flat (the client spread still builds
its table by design). Tests: table undecided after a handful of reads on
a merge and an omit-over-merge, built after the 16th with identical
answers; enumeration builds on a fresh view; plain omit never; store-leaf
view settles to none. SSR: spread over omit-over-merge and bare merge
serializes the merged order and builds no table.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 15, 2026
…r 16 reads, not on first read; ssrElement walks a view's entries

A merge()/omit() view over plain objects keeps a resolved key table so a
client spread that reruns, or Object.keys/{...props}, is one lookup per
key. Since #3454 the first per-key trap read built it too. On the server
that is the wrong trade: a component reads its merged props a few times,
the element serializes them once, and the view is gone — a Kobalte-shaped
chain (Dialog.Trigger → Button.Root → Polymorphic) paid for a table per
layer per element. Profiled under renderToString: a third of the time in
mergeTable/tableSet/omitTable and their garbage, another 11% in
Array.prototype.concat combining omit filters.

Signals: get/has/getOwnPropertyDescriptor answer by a source walk until
the view has been read 16 times — the break-even between a build (~60 ns
per key of every leaf) and a walk (~20 ns per source) — then build as
before, so a long-lived client view read on every rerun is one lookup per
read from its first few updates on. Enumeration builds outright. An omit
over one object never builds one. Combined hidden lists are copied by
hand (concat is 4× the cost for three-or-four-key lists).

Web: ssrElement never asks for a view's table; an omit over a merge is
walked as its filtered leaf entries, the array-form walk it already had.
Merged attribute order is unchanged.

Same-process A/B, tier-1 polymorphic-chain SSR (200 rows): chain 8.2× →
5.8× the compiled floor, chain-static 7.7× → 5.6× (−27%). Signals
props-chain: build −5…−17%, build+consume −6…−14%, steady-state reads on
a prebuilt view identical. DOM lane flat (the client spread still builds
its table by design). Tests: table undecided after a handful of reads on
a merge and an omit-over-merge, built after the 16th with identical
answers; enumeration builds on a fresh view; plain omit never; store-leaf
view settles to none. SSR: spread over omit-over-merge and bare merge
serializes the merged order and builds no table.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 16, 2026
…ord per layer, one-pass owners walk for ssrElement

An omit over a merge flattened at construction: one OmitView plus one
combined hidden-key list per flattened leaf, and the next merge() copied
those entries into its arrays — on a Kobalte-shaped chain (defaults +
omit + spread, four layers) ~19 records and as many list copies per
element, the largest allocation of the render. #3487 tried to make those
copies cheaper and could not beat slice+push on instruction count; this
does not make them.

The omit now holds the MergeView record itself (new source kind
SOURCE_MERGE) and is one record whatever the merge's leaf count; a later
merge() carries it as one entry, a later omit() folds into it. Nothing on
the way is a trap: sourceKeys/sourceHas/sourceGet, descriptors,
hasStaticKeys and the tables recurse into the record by function call —
the property #3454 established (consumers read the leaves, never through
a proxy) is kept, the per-leaf copies are not.

Three things had to hold for it to pay, each found by measurement:
- one walk per read: a nested entry answers presence and value together
  (MISSING sentinel), not has-then-get per level;
- a record reached through an outer view counts no reads toward its own
  table threshold, and the outer view's table is collected in one pass
  over the leaves (collectTable) — not one table per layer;
- sourceOwners(source, keys, owners): every key of a plain object, store
  or view in merged order with its owning object, one pass. ssrElement
  collects any non-plain spread (a view, a store, the array form with one
  among them) this way and reads owners[i][keys[i]] — the flat form's
  read cost without its construction cost. pushEntry is gone.

An omit's $SOURCES answers nothing now; consumers reach the record via
viewOf. Reads through an omit no longer count on the inner merge: the
view that was asked decides for the tree.

Measured against next (interleaved, min of N, quiet machine): tier-1
polymorphic-chain SSR −12% bytes/row, −2…−6% time across interp,
Sparkplug, Maglev, TurboFan; props-chain build −6…−65%, build+consume
−7…−31% by depth and tier; omit/merge micro-suite flat or better in
every shape; yak-bench SSR all-primitives lane +7% geomean, +20–36% on
the composition cases, which reach parity with yak's hand-rolled runtime.

Co-authored-by: Claude via Cursor <noreply@cursor.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.

2 participants