Skip to content
Draft
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
136 changes: 62 additions & 74 deletions packages/solid/src/render/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PropertyKey, any>;
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<MergeState> = {
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<SplitState> = {
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, F> = T extends undefined ? F : T;
Expand Down Expand Up @@ -207,29 +240,7 @@ export function mergeProps<T extends unknown[]>(...sources: T): MergeProps<T> {
typeof s === "function" ? ((proxy = true), createMemo(s as EffectFunction<unknown>)) : 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<T>;
return new Proxy({ [sourcesKey]: sources }, mergeHandler) as unknown as MergeProps<T>;
}
const sourcesMap: Record<string, any[]> = {};
const defined: Record<string, PropertyDescriptor | undefined> = Object.create(null);
Expand Down Expand Up @@ -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<T, K>;
}
Expand Down
15 changes: 15 additions & 0 deletions packages/solid/test/component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }, [
Expand Down
90 changes: 90 additions & 0 deletions packages/solid/test/props-retained.bench.ts
Original file line number Diff line number Diff line change
@@ -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<Props, "id" | "value" | "disabled">; 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 }
);
});
}
}
Loading