perf(signals,web,universal,html): merge/omit are always lazy views; consumers read the leaves - #3454
Conversation
…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 detectedLatest commit: 44da46e 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 |
Coverage Report for CI Build 34954087642Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage increased (+0.06%) to 71.898%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will regress 37 benchmarks
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing |
… 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>
…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>
…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>
`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>
`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>
…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>
`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>
…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>
…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>
…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>
…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>
What
merge()andomit()never copy underProxy, and the props consumers (spreadin web and universal,ssrElement, a nestedmerge) 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.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).ssrElement's array form). After thatget/has/descriptor are one lookup, andspread/ssrElementwalk 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 sproxy (frames slot props) keep theinwalk.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 internalhasStaticKeys(o);spreaduses both to skip the children effect for static children behind omit/merge layers (spread()creates three reactive nodes per element #3388 through views).@solidjs/htmlcollected props by assigning ontomerge()'s result; it now builds its own objects and merges once at the end (merge()re-flattens through a stale$SOURCESand discards the object's own properties #3384).Behavior changes
merge()/omit()result are no-ops (already true for the proxy forms). Copy to own it:{ ...merged }; the copy carries no sources.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.entriesover a merged style object) are uncommon and one-shot.utilities-no-proxy.test.ts, which mocksSUPPORTS_PROXYoff.Numbers
Signals-layer chain (
props-chain.bench.tsshape), same run, vsnext:polymorphic-chainbenches 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'scopyProps/withThemewithmerge(omit(...))— +12% geomean, +22–33% onbutton-variants*/compose-*, +39%multifile-composition;solid-pr+6%. Markup parity verified on every lane and case.CodSpeed regressions (
utilities.bench.ts) and store shapesThree 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):$SOURCES/$OMITon 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;sourceHas/sourceGet, each opening withinstanceof OmitView— on a Proxy that is agetPrototypeOftrap, ~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 inutilities.test.tspins this.merge(defaults, store)constructomit(store, …5)constructmerge(defaults, store)+ 5 readsmerge(defaults, store)read every keyomit(store, …5)read every keyObject.keys(merge(defaults, store))Object.keys(omit(store, …5))Enumerating a view over plain leaves (
for…in,Object.keys,{...view}) is the one remaining cost abovenext: ~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.nextalready had this floor whenever a store or an omit was involved; onlymergeof plain objects changes class. None of Solid's consumers enumerate a view — they readresolvedTable/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 behindomit/mergeviews cost no children effect; a store leaf keeps the reactive path.spread-sources.spec.tsx,ssr-element-sources.spec.tsx, universalspread-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.