Skip to content

2.0.0-beta.15: Object.getOwnPropertyDescriptor(store, key).value reports the stale pre-write value #2835

Description

@yumemi-thomas

Describe the bug

After a plain setStore write, reading the property through the proxy returns the new value, but Object.getOwnPropertyDescriptor(store, key).value (and therefore Object.getOwnPropertyDescriptors(store)) still reports the pre-write value — permanently, not just until the next flush. This breaks descriptor-driven consumers (shallow-clone utilities, Object.defineProperties-based copying, devtools/serialization helpers) that trust the descriptor over a get.

Related to but distinct from #2797 (fixed in 5894f2a): that fix addressed the configurable proxy invariant in the same trap. The optimistic-override branch of the trap already patches value: correctly; the regular STORE_OVERRIDE path (where every plain setStore write lands) does not.

Your Example Website or App

https://stackblitz.com/edit/solidjs-templates-k8dgpbb6?file=src%2FApp.tsx

import { createSignal, createStore, flush, Show } from "solid-js";

type Verdict = { ok: boolean; actual: string };

export default function App() {
  const [store, setStore] = createStore({ count: 1 });
  const [verdict, setVerdict] = createSignal<Verdict>();

  function increment() {
    setStore(draft => {
      draft.count++;
    });
    flush();

    const value = store.count;
    const descriptorValue = Object.getOwnPropertyDescriptor(store, "count")?.value;

    setVerdict({
      ok: descriptorValue === value,
      actual: `store.count = ${value}; descriptor.value = ${descriptorValue}`
    });
  }

  return (
    <main style={{ "font-family": "system-ui", padding: "16px" }}>
      <h2>Store descriptor stale value</h2>
      <p>count: {store.count}</p>
      <button onClick={increment}>increment</button>
      <Show when={verdict()}>
        {v => (
          <section
            style={{
              padding: "12px",
              "margin-top": "12px",
              color: "white",
              background: v().ok ? "#137333" : "#c5221f"
            }}
          >
            <b>{v().ok ? "PASS - bug is fixed" : "FAIL - bug reproduced"}</b>
            <p>Expected descriptor.value to match store.count after setStore.</p>
            <pre>{v().actual}</pre>
          </section>
        )}
      </Show>
    </main>
  );
}

Steps to Reproduce the Bug or Issue

  1. Click increment. The rendered count updates to 2 — the proxy read is correct.
  2. Actual — the verdict banner shows the descriptor still reporting the pre-write value:
FAIL - bug reproduced
store.count = 2; descriptor.value = 1

The proxy get and the descriptor disagree about the same property, and they keep disagreeing on every later read (clicking again yields store.count = 3; descriptor.value = 1).

Expected behavior

The descriptor agrees with the proxy read:

PASS - bug is fixed
store.count = 2; descriptor.value = 2

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Chrome
  • Version: 2.0.0-beta.15 (verified at next @ 0e8672ab)

Additional context

Written values live in STORE_OVERRIDE, not in the base object. Root cause: getPropertyDescriptor in packages/solid-signals/src/store/store.ts:300-311:

if (override && property in override) {
  if (override[property] === $DELETED) return void 0;
  const overrideDesc = Reflect.getOwnPropertyDescriptor(override, property);
  if (overrideDesc?.get || overrideDesc?.set || !(property in source)) return overrideDesc;
}
return Reflect.getOwnPropertyDescriptor(source, property);   // ← stale base value

For a plain data write to a property that also exists on the base object (the common case), overrideDesc has no getter/setter and property in source is true, so the function falls through to the base descriptor with the pre-write value. The trap's optimistic branch shows the intended behavior one screen up (store.ts:722-727): it fetches the base descriptor for structure and overrides just the value —

return { ...baseDesc, configurable, value: target[STORE_OPTIMISTIC_OVERRIDE][property] };

Suggested fix direction: in getPropertyDescriptor, when the override holds a plain data value for a property that also exists on the source, return the base descriptor with value: override[property] patched in (mirroring the optimistic branch), instead of falling through.

Does this exist in Solid 1.x?

Regression from 1.x. Verified against solid-js 1.9.14: after setState("n", 2), Object.getOwnPropertyDescriptor(state, "n") reflects the written value, and Object.getOwnPropertyDescriptors(state) agrees. (1.x hands out a live getter descriptor for store properties — reading through it always observes the current value, so it can never go stale; 2.0 returns a plain data descriptor whose value is the pre-write value forever.)

Working 1.x comparison: https://playground.solidjs.com/anonymous/407e15da-2432-4e27-a06c-1128d5a6db42

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions