diff --git a/packages/solid/src/render/component.ts b/packages/solid/src/render/component.ts index 67522a7de..433bc6eff 100644 --- a/packages/solid/src/render/component.ts +++ b/packages/solid/src/render/component.ts @@ -117,35 +117,68 @@ function trueFn() { return true; } -const propTraps: ProxyHandler<{ - get: (k: string | number | symbol) => any; - has: (k: string | number | symbol) => boolean; - keys: () => string[]; -}> = { - get(_, property, receiver) { - if (property === $PROXY) return receiver; - return _.get(property); +type PropsSource = Record; +const sourcesKey = Symbol("sources"); +const sourceKey = Symbol("source"); +const keysKey = Symbol("keys"); +const remainderKey = Symbol("remainder"); +type MergeState = { [sourcesKey]: any[] }; +function readMerge(state: MergeState, key: PropertyKey) { + for (let i = state[sourcesKey].length - 1; i >= 0; i--) { + const value = resolveSource(state[sourcesKey][i])[key]; + if (value !== undefined) return value; + } +} +const mergeHandler: ProxyHandler = { + get(state, key, receiver) { + return key === $PROXY ? receiver : readMerge(state, key); + }, + has(state, key) { + if (key === $PROXY) return true; + for (let i = state[sourcesKey].length - 1; i >= 0; i--) + if (key in resolveSource(state[sourcesKey][i])) return true; + return false; }, - has(_, property) { - if (property === $PROXY) return true; - return _.has(property); + ownKeys(state) { + const keys: string[] = []; + for (const source of state[sourcesKey]) keys.push(...Object.keys(resolveSource(source))); + return [...new Set(keys)]; + }, + getOwnPropertyDescriptor(state, key) { + return { configurable: true, enumerable: true, get: () => readMerge(state, key), set: trueFn }; }, set: trueFn, - deleteProperty: trueFn, - getOwnPropertyDescriptor(_, property) { - return { - configurable: true, - enumerable: true, - get() { - return _.get(property); - }, - set: trueFn, - deleteProperty: trueFn - }; + deleteProperty: trueFn +}; + +type SplitState = { + [sourceKey]: PropsSource; + [keysKey]: readonly PropertyKey[]; + [remainderKey]: boolean; +}; +function readSplit(state: SplitState, key: PropertyKey) { + return state[keysKey].includes(key) !== state[remainderKey] ? state[sourceKey][key] : undefined; +} +const splitHandler: ProxyHandler = { + get(state, key, receiver) { + return key === $PROXY ? receiver : readSplit(state, key); }, - ownKeys(_) { - return _.keys(); - } + has(state, key) { + return ( + key === $PROXY || + (state[keysKey].includes(key) !== state[remainderKey] && key in state[sourceKey]) + ); + }, + ownKeys(state) { + return state[remainderKey] + ? Object.keys(state[sourceKey]).filter(key => !state[keysKey].includes(key)) + : (state[keysKey].filter(key => key in state[sourceKey]) as (string | symbol)[]); + }, + getOwnPropertyDescriptor(state, key) { + return { configurable: true, enumerable: true, get: () => readSplit(state, key), set: trueFn }; + }, + set: trueFn, + deleteProperty: trueFn }; type DistributeOverride = T extends undefined ? F : T; @@ -207,29 +240,7 @@ export function mergeProps(...sources: T): MergeProps { typeof s === "function" ? ((proxy = true), createMemo(s as EffectFunction)) : s; } if (SUPPORTS_PROXY && proxy) { - return new Proxy( - { - get(property: string | number | symbol) { - for (let i = sources.length - 1; i >= 0; i--) { - const v = resolveSource(sources[i])[property]; - if (v !== undefined) return v; - } - }, - has(property: string | number | symbol) { - for (let i = sources.length - 1; i >= 0; i--) { - if (property in resolveSource(sources[i])) return true; - } - return false; - }, - keys() { - const keys = []; - for (let i = 0; i < sources.length; i++) - keys.push(...Object.keys(resolveSource(sources[i]))); - return [...new Set(keys)]; - } - }, - propTraps - ) as unknown as MergeProps; + return new Proxy({ [sourcesKey]: sources }, mergeHandler) as unknown as MergeProps; } const sourcesMap: Record = {}; const defined: Record = Object.create(null); @@ -298,35 +309,12 @@ export function splitProps< // a key belongs to the first group that lists it (matches non-proxy path) const owned = k.filter(property => !claimed.has(property) && (claimed.add(property), true)); return new Proxy( - { - get(property) { - return owned.includes(property) ? props[property as any] : undefined; - }, - has(property) { - return owned.includes(property) && property in props; - }, - keys() { - return owned.filter(property => property in props); - } - }, - propTraps + { [sourceKey]: props, [keysKey]: owned, [remainderKey]: false }, + splitHandler ); }); res.push( - new Proxy( - { - get(property) { - return blocked.includes(property) ? undefined : props[property as any]; - }, - has(property) { - return blocked.includes(property) ? false : property in props; - }, - keys() { - return Object.keys(props).filter(k => !blocked.includes(k)); - } - }, - propTraps - ) + new Proxy({ [sourceKey]: props, [keysKey]: blocked, [remainderKey]: true }, splitHandler) ); return res as SplitProps; } diff --git a/packages/solid/test/component.spec.ts b/packages/solid/test/component.spec.ts index eb4b65767..2351c0f61 100644 --- a/packages/solid/test/component.spec.ts +++ b/packages/solid/test/component.spec.ts @@ -486,6 +486,21 @@ describe("SplitProps Props", () => { expect(({} as any).evil).toBeUndefined(); }); + test("Keeps proxy state separate from defined properties", () => { + const [source, setSource] = createStore({ id: 1, title: "Title" }); + const merged = mergeProps({}, source); + const [selected, rest] = splitProps(merged, ["id"]); + Object.defineProperty(merged, "sources", { value: [], configurable: true }); + Object.defineProperty(selected, "source", { value: { id: 99 }, configurable: true }); + expect(merged.id).toBe(1); + expect(selected.id).toBe(1); + expect({ ...rest }).toEqual({ title: "Title" }); + expect(Object.getOwnPropertySymbols(merged)).toEqual([]); + expect(Object.getOwnPropertySymbols(selected)).toEqual([]); + setSource("id", 2); + expect(selected.id).toBe(2); + }); + test("Merge SplitProps", () => { let value: string | undefined = undefined; const [splittedProps] = splitProps({ color: "blue" } as { color: string; other?: string }, [ diff --git a/packages/solid/test/props-retained.bench.ts b/packages/solid/test/props-retained.bench.ts new file mode 100644 index 000000000..90be8ba73 --- /dev/null +++ b/packages/solid/test/props-retained.bench.ts @@ -0,0 +1,90 @@ +import { bench, describe } from "vitest"; +import { mergeProps, splitProps } from "../src/index.js"; +import { createStore } from "../store/src/index.js"; + +// Each operation represents a batch of mounted component instances, not one call. +// Keep results reachable between iterations; replace the previous batch to bound memory. +const instanceCount = 10_000; +type Props = { id: number; value: number; disabled: boolean; title: string }; +type Retained = { read: Pick; text: { title: string } }; +const defaults = { disabled: true, title: "Default" }; +const selectedKeys = ["id", "value", "disabled"] as const; + +const scenarios = [ + { + name: "mergeProps", + create: (source: Props): Retained => { + const merged = mergeProps(defaults, source); + return { read: merged, text: merged }; + }, + proxiesPerInstance: 1 + }, + { + name: "splitProps", + create: (source: Props): Retained => { + const [selected, rest] = splitProps(source, selectedKeys); + return { read: selected, text: rest }; + }, + proxiesPerInstance: 2 + }, + { + name: "mergeProps+splitProps", + create: (source: Props): Retained => { + const [selected, rest] = splitProps(mergeProps(defaults, source), selectedKeys); + return { read: selected, text: rest }; + }, + proxiesPerInstance: 3 + } +]; + +for (const scenario of scenarios) { + for (const operation of ["create", "read"] as const) { + describe(`${scenario.name}-retained(${instanceCount})-${operation}`, () => { + let sources: Props[] = []; + let retained: Retained[] = []; + let checksum = 0; + + const setup = () => { + // Real store proxies, one distinct source per instance; setup is untimed. + sources = Array.from( + { length: instanceCount }, + (_, id) => createStore({ id, value: id, disabled: false, title: "Title" })[0] + ); + if (operation === "read") retained = sources.map(scenario.create); + }; + + const teardown = () => { + // Consume observable results outside the timed callback and check the batch. + if ( + retained.length !== instanceCount || + retained[instanceCount - 1].read.id !== instanceCount - 1 + ) + throw new Error("Incomplete retained props batch"); + if ( + operation === "read" && + checksum !== instanceCount * (instanceCount - 1) + instanceCount * 5 + ) + throw new Error("Unexpected retained props checksum"); + sources = []; + retained = []; + }; + + bench( + `${operation}: ${instanceCount * scenario.proxiesPerInstance} utility proxies`, + () => { + if (operation === "create") { + retained = sources.map(scenario.create); + } else { + let sum = 0; + for (const entry of retained) { + sum += entry.read.id + entry.read.value + Number(entry.read.disabled); + sum += entry.text.title.length; + } + checksum = sum; + } + }, + { time: 1000, warmupTime: 250, iterations: 20, setup, teardown } + ); + }); + } +}