Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/merge-omit-lazy-views.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@solidjs/signals": patch
"solid-js": patch
"@solidjs/web": patch
"@solidjs/universal": patch
"@solidjs/html": patch
---

`merge()` and `omit()` are always lazy views, and props consumers read their leaves

`omit(props, ...keys)` returns a live view of `props` for every input — a plain object included — instead of copying it with a `getOwnPropertyDescriptor` + `defineProperty` per prop. A predicate form hides keys by rule without enumerating first: `omit(props, k => k[0] === "$")`. `merge()` no longer builds an eager copy when its sources are plain objects: under `Proxy` it always returns an O(1) view over the flattened sources (a single non-function source is returned as is).

The two compose flat. An `omit()` over a `merge()` carries one filtered view per flattened merge source, a `merge()` over an `omit()` takes the view record as a leaf, and nested omits fold their filters into one record. A component chain of `merge(defaults) → omit(consumed) → merge(statics) → omit("as")` — the shape headless-UI libraries render every element through — collapses to leaf views over the original objects, each with its accumulated filter, with no proxy layer left between the outermost spread and the author's props. `merge()` keeps the omitted keys hidden by construction (#3014) rather than by treating the omit as opaque. Construction cost drops 3–7× at depth 1–7; the SSR polymorphic-chain bench (#3448) runs ~2.4× faster.

Reads stay cheap: a view over plain objects resolves a key → owning-leaf table once, on first read, and every `get`/`has`/descriptor is one lookup after that. `spread()` (DOM and universal) and `ssrElement()` read the leaves directly — never through the proxies' traps — and walk that table when there is one, so an effect rerun costs one read per key, as it did over the copy. Both proxies use a class target and one shared handler (no per-instance closures).

A view over a store asks the store nothing but the read. Each source's kind (plain object, omit record, proxy, memo) is decided once, when the view is built, and carried beside it — every brand check on a Proxy is a trap (`instanceof` is a `getPrototypeOf` trap, as expensive as a store read), and store detection goes through `$TARGET`, a symbol the store's `get` trap answers on its fast path, never its generic tracked-read path. `merge(defaults, store)` constructs ~30% faster than the copy did and reads ~15% faster; `omit(store)` reads at parity.

The views tell the truth: `Object.getOwnPropertyDescriptor(view, key)` reports a data descriptor only when the key is a data property of a plain leaf (the compiler's encoding of a static prop) and an accessor for a getter, a store key, or a memo source. Together with the new internal `hasStaticKeys()`, `spread()` now skips the children effect for static children behind `omit`/`merge` layers (#3388 through views).

Behavior changes:

- Writes to a `merge()` or `omit()` result are no-ops (they already were for the proxy forms). A caller that needs its own object copies it (`{ ...merged }`), and the copy carries no sources (#3384). `@solidjs/html` now collects its own props and spreads into one `merge()` at the end instead of assigning onto the result.
- A data property on a source is read live through the view rather than snapshotted at `merge()`/`omit()` time.
- Key order of a merged view is the merged order — every key at the position of the last source that carries it — matching `ssrElement`'s array form.
- Sources are treated as 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; the internal consumers avoid it. Environments without `Proxy` keep the copy paths.

Internal helpers for consumers, exported from `solid-js`: `viewOf(o)`, `mergeView(o)`, `omitView(o)`, `sourceKeys(entry, kind)`, `sourceHas(entry, kind, key)`, `sourceGet(entry, kind, key)`, `hasStaticKeys(o)`, `resolvedTable(o)`, the `SOURCE_*` kinds.
20 changes: 14 additions & 6 deletions packages/html/src/tagged-jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,35 +219,43 @@ function createHtml() {
components: ComponentRegistry,
props: Record<string, any> = {}
) => {
// A merge() result is a read-only view — writes to it are no-ops — so
// own props are collected into plain objects and the spreads interleaved
// as sources, merged once at the end in source order (later wins).
const sources: unknown[] = [];
let own: Record<string, any> = props;
for (const prop of node.props) {
switch (prop.type) {
case BOOLEAN_PROP:
props[prop.name] = true;
own[prop.name] = true;
break;
case STATIC_PROP:
props[prop.name] = prop.value;
own[prop.name] = prop.value;
break;
case EXPRESSION_PROP:
applyGetter(props, prop.name, values[prop.value]);
applyGetter(own, prop.name, values[prop.value]);
break;
case SPREAD_PROP:
const spreadValue = values[prop.value];
if (!spreadValue || typeof spreadValue !== "object")
throw new Error("Can only spread objects");
props = mergeProps(props, spreadValue);
sources.push(own, spreadValue);
own = {};
break;
}
}

// children - childNodes overwrites any props.children
if (node.type === COMPONENT_NODE && node.children.length) {
Object.defineProperty(props, "children", {
Object.defineProperty(own, "children", {
get() {
return renderChildren(node, values, components);
}
});
}
return props;
if (sources.length === 0) return own;
sources.push(own);
return mergeProps(...sources) as Record<string, any>;
};

const applyGetter = (props: Record<string, any>, name: string, value: any) => {
Expand Down
19 changes: 18 additions & 1 deletion packages/signals/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,24 @@ export type {
export type { Merge, Omit } from "./utils.js";

export { isWrappable, $TRACK, $PROXY, $TARGET } from "./store.js";
export { mergeSources } from "./utils.js";
export {
mergeSources,
mergeView,
viewOf,
omitView,
sourceKeys,
sourceHas,
sourceGet,
hasStaticKeys,
resolvedTable,
OmitView,
MergeView,
SOURCE_PLAIN,
SOURCE_OMIT,
SOURCE_PROXY,
SOURCE_MEMO
} from "./utils.js";
export type { SourceKind } from "./utils.js";

import type { NoFn, ProjectionOptions, Store, StoreOptions, StoreSetter } from "./store.js";
import type { Refreshable } from "../core/index.js";
Expand Down
Loading
Loading