Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-store-proxy-invariants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

store: fix two proxy invariant bugs — non-configurable property descriptor and stale indices after array truncation
31 changes: 29 additions & 2 deletions packages/solid-signals/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,21 @@ export const storeTraps: ProxyHandler<StoreNode> = {
override[property] = value;
if (nextLength !== undefined) override.length = nextLength;
}
// When shrinking an array's length, mark truncated indices as deleted in the
// override so that `has`, `ownKeys`, and index reads correctly reflect the
// shorter array rather than leaking stale entries from the underlying value.
if (
Array.isArray(state) &&
property === "length" &&
typeof value === "number" &&
typeof prev === "number" &&
value < prev
) {
const override = target[overrideKey] || (target[overrideKey] = Object.create(null));
for (let i = value; i < prev; i++) {
if (i in state) override[i] = $DELETED;
}
}
notifyStoreProperty(target, property, "set", value, prev, prevHas);
// notify length change
if (Array.isArray(state) && property !== "length" && nextLength !== undefined) {
Expand Down Expand Up @@ -690,7 +705,9 @@ export const storeTraps: ProxyHandler<StoreNode> = {
// Get base descriptor structure, override just the value
const baseDesc = getPropertyDescriptor(target[STORE_VALUE], target[STORE_OVERRIDE], property);
if (baseDesc) {
return { ...baseDesc, value: target[STORE_OPTIMISTIC_OVERRIDE][property] };
const targetDesc = Reflect.getOwnPropertyDescriptor(target, property);
const configurable = !targetDesc || targetDesc.configurable ? true : baseDesc.configurable;
return { ...baseDesc, configurable, value: target[STORE_OPTIMISTIC_OVERRIDE][property] };
}
return {
value: target[STORE_OPTIMISTIC_OVERRIDE][property],
Expand All @@ -699,7 +716,17 @@ export const storeTraps: ProxyHandler<StoreNode> = {
configurable: true
};
}
return getPropertyDescriptor(target[STORE_VALUE], target[STORE_OVERRIDE], property);
const desc = getPropertyDescriptor(target[STORE_VALUE], target[STORE_OVERRIDE], property);
// The proxy target is an internal node object, not the original source. When the
// source has a non-configurable property that does not also exist as non-configurable
// on the proxy target, the proxy invariant is violated: the engine requires that a
// property reported as non-configurable must actually be non-configurable on the
// target object. Override configurable to true only in that case.
if (desc && !desc.configurable) {
const targetDesc = Reflect.getOwnPropertyDescriptor(target, property);
if (!targetDesc || targetDesc.configurable) return { ...desc, configurable: true };
}
return desc;
},

getPrototypeOf(target) {
Expand Down
32 changes: 32 additions & 0 deletions packages/solid-signals/tests/store/createStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1319,3 +1319,35 @@ describe("derived store manual writes", () => {
expect(store.count).toBe(2);
});
});

describe("Proxy invariant correctness", () => {
test("Object.keys does not throw for stores wrapping objects with non-configurable own properties", () => {
// A non-configurable property on the source must not cause the getOwnPropertyDescriptor
// proxy trap to violate the proxy invariant when the store proxy target is a plain object
// that does not have the property as non-configurable.
const source: any = {};
Object.defineProperty(source, "id", {
value: 1,
enumerable: true,
writable: true,
configurable: false
});
const [store] = createStore(source);
expect(() => Object.keys(store)).not.toThrow();
expect(store.id).toBe(1);
});

test("Truncating array length clears stale indices from the store proxy", () => {
// Writing store.length = N must mark indices >= N as deleted so that `has`,
// index reads, and Object.keys all reflect the shorter array.
const [list, setList] = createStore([1, 2, 3]);
setList(s => {
s.length = 2;
});
flush();
expect(list.length).toBe(2);
expect(list[2]).toBeUndefined();
expect(2 in list).toBe(false);
expect(Object.keys(list).filter(k => k !== "length")).not.toContain("2");
});
});
Loading