Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .changeset/omit-hidden-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@solidjs/signals": patch
---

`omit()` combines folded filters into one exact-size list while short, and chains them past that

When an omit folds over another view — an omit of an omit, or of a merge with omit leaves — the two filters were combined into one key list per leaf, per layer, by `slice()` + `push`. V8 grows the backing store to `1.5n + 16` slots on that push, so each copy allocated 2.4–2.7× an exact one, and on a component chain (defaults → omit → statics → omit …) the copies held every key hidden so far, growing with the depth. Two short lists now combine with `concat` — one builtin call, exact-size — up to 8 keys; past that, or with a predicate on either side, they chain as one two-field link over the filter folded, with no copy at all. A predicate filter no longer needs a closure to combine, and a filter with nothing to hide adds nothing.

Tier-1 `polymorphic-chain` SSR bench (Kobalte shape, `renderToString`, 200 rows): −17% optimized, −12% Sparkplug, −7% interpreter. Depth-7 props chain: build −14%, build + consume −9% optimized; +3–5% in the bytecode tiers, where the walk over the links past the copy limit costs more than one `includes` over a flat list would.
97 changes: 70 additions & 27 deletions packages/signals/src/store/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,31 +102,72 @@ export class OmitView {
) {}
}

type Hidden = PropertyKey[] | ((key: PropertyKey) => boolean);
// What an omit view hides: the caller's key list or predicate, or — for a
// view folded over another (an omit of an omit, or of a merge with omit
// leaves) — both filters. Two short lists combine into one exact-size list
// (one `includes` per check, the cheapest check in every tier); past
// `COPY_LIMIT` keys, or with a predicate on either side, they CHAIN — one
// two-field link over the filter folded, no copy. An omit over a merge
// combines once per leaf per layer, so on a component chain (defaults →
// omit → statics → omit …) a list that kept growing by copy was the largest
// allocation of the views (100–450 bytes a leaf on a Kobalte-shaped chain,
// quadratic in the depth); a chain that started at the first fold cost a
// link walk per key of every leaf on the same shape, and the bytecode tiers
// paid more for the walk than the copies had cost. The limit keeps most
// leaves a flat list and bounds a deep leaf to a short list plus a few
// links.
type Filter = PropertyKey[] | ((key: PropertyKey) => boolean);
type Hidden = Filter | HiddenChain;
class HiddenChain {
constructor(
/** the filter folded over: a list, predicate, or a chain of its own */
public inner: Hidden,
/** this link's own filter — one list or predicate, never a chain */
public outer: Filter
) {}
}
const COPY_LIMIT = 8;

// One loop, no recursion, and a link is told from a list or a predicate by
// its constructor (a load and a compare; `instanceof` and `Array.isArray`
// are builtin calls in the bytecode tiers): the check runs per key of every
// leaf when a view is enumerated or its table is built, so it is written
// for those tiers as much as for the optimizer.
function isHidden(view: OmitView, key: PropertyKey): boolean {
const h = view.hidden;
return typeof h === "function" ? h(key) : h.includes(key);
}

// Both filters as one. Two key lists stay a key list (one `includes`, no
// closure); a predicate on either side needs a closure. An omit over a
// merge builds one combined list per leaf, per component layer, so the
// copy's form matters in every tier: `concat` runs the species/spreadable
// protocol (2–3× the cost of a copy once optimized), a hand loop is 2–4×
// `concat` in the interpreter and baseline tiers (a bytecode per element
// against one builtin), and a presized `new Array(n)` is holey, which takes
// `includes` off its fast path. `slice` + `push` of the (short) second list
// is within a third of the best form in every tier, and packed.
function combineHidden(a: Hidden, b: Hidden): Hidden {
if (typeof a !== "function" && typeof b !== "function") {
const out = a.slice();
for (let i = 0; i < b.length; i++) out.push(b[i]);
return out;
let h = view.hidden;
for (;;) {
if (h.constructor !== HiddenChain)
return typeof h === "function" ? h(key) : (h as PropertyKey[]).includes(key);
const outer = (h as HiddenChain).outer;
if (typeof outer === "function" ? outer(key) : outer.includes(key)) return true;
h = (h as HiddenChain).inner;
}
}

// Both filters as one. A filter with nothing to hide (an `omit(props)` with
// no keys) adds nothing. Two lists within the limit copy — `concat`, an
// exact-size allocation in one builtin call (a `slice` + `push` grows the
// backing store to 1.5n + 16 slots on the push; a hand loop is a bytecode
// per element). `outer` is normally the current omit's own filter; when it
// is a chain (an omit over an omit that was itself over a merge), its links
// are re-hung over `inner` one by one so every link's own filter stays
// atomic.
function combineHidden(inner: Hidden, outer: Hidden): Hidden {
if (outer.constructor === HiddenChain) {
const o = outer as HiddenChain;
return combineHidden(combineHidden(inner, o.inner), o.outer);
}
// From here `outer` is a list or a predicate; a list is told by `length`
// (a predicate's is its arity — never read, the typeof comes first).
const outerList = typeof outer !== "function";
if (outerList && (outer as PropertyKey[]).length === 0) return inner;
if (typeof inner !== "function" && inner.constructor !== HiddenChain) {
const list = inner as PropertyKey[];
if (list.length === 0) return outer;
if (outerList && list.length + (outer as PropertyKey[]).length <= COPY_LIMIT)
return list.concat(outer as PropertyKey[]);
}
return key =>
(typeof a === "function" ? a(key) : a.includes(key)) ||
(typeof b === "function" ? b(key) : b.includes(key));
return new HiddenChain(inner, outer as Filter);
}

// The object a view filters (see `leafOf`).
Expand Down Expand Up @@ -933,17 +974,19 @@ export function omit(props: any, ...keys: any[]): any {
}
return new Proxy(new OmitView(source, kind, hidden, entries), omitTraps);
}
// No Proxy: no views exist, so `hidden` is the caller's list or predicate.
const own = hidden as PropertyKey[] | ((key: PropertyKey) => boolean);
const result: Record<string, any> = {};
const propNames = Object.getOwnPropertyNames(props);
const isHiddenKey: (key: string) => boolean =
typeof hidden === "function"
? hidden
: hidden.length > 4 && propNames.length > hidden.length
typeof own === "function"
? own
: own.length > 4 && propNames.length > own.length
? (
blocked => (key: string) =>
blocked.has(key)
)(new Set(hidden))
: key => hidden.includes(key);
)(new Set(own))
: key => own.includes(key);

for (const propName of propNames) {
if (!isHiddenKey(propName)) {
Expand Down
54 changes: 54 additions & 0 deletions packages/signals/tests/store/utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,7 @@ describe("view descriptors", () => {
// l3's statics, l1's defaults, the user's props — in merge order
expect((leaves[0] as OmitView).hidden).toEqual(["as"]);
expect((leaves[2] as OmitView).source).toBe(user);
// both filters as one short list: the inner omit's keys, then the outer's
expect((leaves[2] as OmitView).hidden).toEqual(["type", "as"]);
// and the truth reaches the top
expect(Object.getOwnPropertyDescriptor(l5, "class")!.value).toBe("btn");
Expand All @@ -833,6 +834,59 @@ describe("view descriptors", () => {
expect(Object.getOwnPropertyDescriptor(l5, "role")!.value).toBe("button");
expect(Object.keys(l5).sort()).toEqual(["class", "extra", "label", "role"]);
});
test("folded filters: short lists copy, predicates and long lists chain", () => {
const user = { a: 1, b: 2, c: 3, d: 4, e: 5 };
// omit of omit of predicate-omit of a no-key omit — every combination
const o1 = omit(user); // nothing hidden: adds nothing
const o2 = omit(o1, (key: PropertyKey) => key === "a");
const o3 = omit(o2, "b"); // a predicate cannot copy: one link
const o4 = omit(o3); // still nothing added
const o5 = omit(o4, "c", "zz"); // a list over a chain: another link
const view = viewOf(o5) as OmitView;
expect(view.source).toBe(user);
expect(view.hidden).toEqual({
inner: { inner: expect.any(Function), outer: ["b"] },
outer: ["c", "zz"]
});
// lists copy while short, then chain: eight keys in one list, the ninth
// fold a link over it
let deep: any = user;
for (let i = 0; i < 4; i++) deep = omit(deep, `k${i}a`, `k${i}b`);
expect((viewOf(deep) as OmitView).hidden).toEqual([
"k0a",
"k0b",
"k1a",
"k1b",
"k2a",
"k2b",
"k3a",
"k3b"
]);
deep = omit(deep, "k4a", "k4b");
expect((viewOf(deep) as OmitView).hidden).toEqual({
inner: ["k0a", "k0b", "k1a", "k1b", "k2a", "k2b", "k3a", "k3b"],
outer: ["k4a", "k4b"]
});
deep = omit(deep, "a");
expect(Object.keys(deep)).toEqual(["b", "c", "d", "e"]);
expect("a" in deep).toBe(false);
expect("k4a" in deep).toBe(false);
expect(Object.keys(o5)).toEqual(["d", "e"]);
expect("a" in o5).toBe(false);
expect("b" in o5).toBe(false);
expect("c" in o5).toBe(false);
expect("d" in o5).toBe(true);
expect(o5.a).toBeUndefined();
expect(o5.d).toBe(4);
expect(Object.getOwnPropertyDescriptor(o5, "b")).toBeUndefined();
expect(Object.getOwnPropertyDescriptor(o5, "e")!.value).toBe(5);
// through a merge, the chained filter travels with the leaf
const m = merge({ x: 0 }, o5);
expect(Object.keys(m)).toEqual(["x", "d", "e"]);
const o6 = omit(m, "d");
expect(Object.keys(o6)).toEqual(["x", "e"]);
expect({ ...o6 }).toEqual({ x: 0, e: 5 });
});
// A store-shaped proxy that logs every trap it is asked. `$PROXY in`,
// `$TARGET` and `$PROXY` are a store's fast paths; anything else — an
// unknown symbol taking its generic read path, `getPrototypeOf` from an
Expand Down
Loading