Skip to content

Commit a5630df

Browse files
authored
Merge pull request #2836 from yumemi-thomas/fix/store-descriptor-value
fix(store): report override values in property descriptors
2 parents 1458907 + c165ec2 commit a5630df

3 files changed

Lines changed: 140 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/signals": patch
3+
---
4+
5+
Fix store property descriptors reporting stale values after `setStore` writes. `Object.getOwnPropertyDescriptor(store, key)` and `Object.getOwnPropertyDescriptors(store)` now agree with proxy reads for written string and symbol keys while preserving the source descriptor's flags. Writes over prototype-inherited properties now also report an own descriptor and no longer crash `snapshot()`.

packages/solid-signals/src/store/store.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,18 @@ export function getPropertyDescriptor(
305305
if (override && property in override) {
306306
if (override[property] === $DELETED) return void 0;
307307
const overrideDesc = Reflect.getOwnPropertyDescriptor(override, property);
308-
if (overrideDesc?.get || overrideDesc?.set || !(property in source)) return overrideDesc;
308+
if (overrideDesc?.get || overrideDesc?.set) return overrideDesc;
309+
// Plain writes live in the override while the source keeps its old value.
310+
// Preserve the source descriptor flags, but report the current override
311+
// value. Source accessors cannot be patched with a value, and inherited
312+
// properties have no source own descriptor, so those keep their descriptor.
313+
const baseDesc = Reflect.getOwnPropertyDescriptor(source, property);
314+
if (!baseDesc) return overrideDesc;
315+
if (baseDesc.get || baseDesc.set) return baseDesc;
316+
// Reflect returns a fresh descriptor, so patching in place is safe and
317+
// avoids an allocation on Object.keys/spread over written stores.
318+
baseDesc.value = override[property];
319+
return baseDesc;
309320
}
310321
return Reflect.getOwnPropertyDescriptor(source, property);
311322
}

packages/solid-signals/tests/store/createStore.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,129 @@ describe("Proxy invariant correctness", () => {
13541354
expect(2 in list).toBe(false);
13551355
expect(Object.keys(list).filter(k => k !== "length")).not.toContain("2");
13561356
});
1357+
1358+
test("getOwnPropertyDescriptor reports the written value, not the stale base value", () => {
1359+
const [state, setState] = createStore({ count: 1 });
1360+
setState(s => {
1361+
s.count++;
1362+
});
1363+
flush();
1364+
expect(state.count).toBe(2);
1365+
expect(Object.getOwnPropertyDescriptor(state, "count")?.value).toBe(2);
1366+
1367+
// and it must keep agreeing on later writes, not just the first
1368+
setState(s => {
1369+
s.count++;
1370+
});
1371+
flush();
1372+
expect(state.count).toBe(3);
1373+
expect(Object.getOwnPropertyDescriptor(state, "count")?.value).toBe(3);
1374+
});
1375+
1376+
test("getOwnPropertyDescriptors agrees with reads for string and symbol keys", () => {
1377+
const sym = Symbol("count");
1378+
const [state, setState] = createStore({ a: "old", b: 1, [sym]: 1 });
1379+
setState(s => {
1380+
s.a = "new";
1381+
s[sym] = 2;
1382+
});
1383+
flush();
1384+
const descs = Object.getOwnPropertyDescriptors(state);
1385+
expect(state[sym]).toBe(2);
1386+
expect(descs.a.value).toBe("new");
1387+
expect(descs.a.enumerable).toBe(true);
1388+
expect(descs.b.value).toBe(1);
1389+
expect(Object.getOwnPropertyDescriptor(state, sym)?.value).toBe(2);
1390+
expect(descs[sym].value).toBe(2);
1391+
});
1392+
1393+
test("descriptor behavior for added and deleted keys is unchanged", () => {
1394+
const [state, setState] = createStore<Record<string, number>>({ kept: 1, dropped: 2 });
1395+
setState(s => {
1396+
s.added = 3;
1397+
delete s.dropped;
1398+
});
1399+
flush();
1400+
expect(Object.getOwnPropertyDescriptor(state, "added")?.value).toBe(3);
1401+
expect(Object.getOwnPropertyDescriptor(state, "dropped")).toBeUndefined();
1402+
expect(Object.getOwnPropertyDescriptor(state, "kept")?.value).toBe(1);
1403+
});
1404+
1405+
test("written non-configurable properties keep satisfying the proxy invariant", () => {
1406+
// The proxy target is the internal node object, not the source object.
1407+
const source = {};
1408+
Object.defineProperty(source, "locked", {
1409+
value: 1,
1410+
enumerable: true,
1411+
writable: false,
1412+
configurable: false
1413+
});
1414+
const [state, setState] = createStore<{ locked: number }>(source);
1415+
setState(s => {
1416+
s.locked = 2;
1417+
});
1418+
flush();
1419+
expect(state.locked).toBe(2);
1420+
expect(() => Object.keys(state)).not.toThrow();
1421+
expect(() => ({ ...state })).not.toThrow();
1422+
const desc = Object.getOwnPropertyDescriptor(state, "locked");
1423+
expect(desc?.value).toBe(2);
1424+
expect(desc?.configurable).toBe(true);
1425+
});
1426+
1427+
test("writes preserve the base descriptor's structure, matching plain assignment semantics", () => {
1428+
const source = { visible: 1 };
1429+
Object.defineProperty(source, "hidden", {
1430+
value: 10,
1431+
enumerable: false,
1432+
writable: true,
1433+
configurable: true
1434+
});
1435+
const [state, setState] = createStore<{ visible: number; hidden: number }>(source);
1436+
setState(s => {
1437+
s.hidden = 20;
1438+
});
1439+
flush();
1440+
expect(state.hidden).toBe(20);
1441+
const desc = Object.getOwnPropertyDescriptor(state, "hidden");
1442+
expect(desc?.value).toBe(20);
1443+
expect(desc?.enumerable).toBe(false);
1444+
expect(Object.keys(state)).toEqual(["visible"]);
1445+
});
1446+
1447+
test("written accessor properties keep accessor descriptors", () => {
1448+
const source = {
1449+
_count: 1,
1450+
get count() {
1451+
return this._count;
1452+
},
1453+
set count(value: number) {
1454+
this._count = value;
1455+
}
1456+
};
1457+
const [state, setState] = createStore(source);
1458+
setState(s => {
1459+
s.count = 2;
1460+
});
1461+
flush();
1462+
const desc = Object.getOwnPropertyDescriptor(state, "count");
1463+
expect(desc?.get).toBe(Object.getOwnPropertyDescriptor(source, "count")?.get);
1464+
expect(desc?.set).toBe(Object.getOwnPropertyDescriptor(source, "count")?.set);
1465+
expect("value" in desc!).toBe(false);
1466+
});
1467+
1468+
test("writing over an inherited property yields an own data descriptor and a safe snapshot", () => {
1469+
// This used to return no descriptor, which also crashed snapshot().
1470+
const proto = { label: "proto" };
1471+
const [state, setState] = createStore<{ label: string }>(Object.create(proto));
1472+
setState(s => {
1473+
s.label = "own";
1474+
});
1475+
flush();
1476+
expect(state.label).toBe("own");
1477+
expect(Object.getOwnPropertyDescriptor(state, "label")?.value).toBe("own");
1478+
expect(snapshot(state).label).toBe("own");
1479+
});
13571480
});
13581481

13591482
describe("Store key handling and tracked truncation", () => {

0 commit comments

Comments
 (0)