Skip to content

[P1] 2.0.0-rc.6: A nested derived-store write disconnects ancestor property observers #3284

Description

@brenelz

Describe the bug

In a derived store created with createStore(seed, seed()), editing a nested child can change an ancestor's proxy identity and disconnect existing property observers. An effect watching store.row.selected receives the initial true, but after a child write and then row.selected = false, it never receives false. Direct reads return the updated value.

Priority assessment: P1. Existing reactive bindings silently remain stale after ordinary nested edits, despite the store accepting the later write.

Platform and version

  • Source checkout: next at 472232bf; package version 2.0.0-rc.6.
  • macOS, Node.js v25.1.0, Vitest 4.1.6. No browser or DOM required.
  • Verified against the tracked @solidjs/signals source, rather than a downloaded npm release.

Reproduction

In a checkout of the commit above with dependencies installed, save the following as packages/signals/tests/store/derived-ancestor-observers.test.ts and run from the repository root:

pnpm --dir packages/signals exec vitest run tests/store/derived-ancestor-observers.test.ts
import {
  createRenderEffect,
  createRoot,
  createStore,
  flush,
  untrack,
} from "../../src/index.ts";

describe("subscriptions after descendant writes", () => {
  for (const derived of [false, true]) {
    it(`${derived ? "derived" : "plain"} store keeps an ancestor observer connected`, () => {
      const seed = () => ({ row: { selected: true, child: { count: 0 } } });
      let dispose!: () => void;
      const seen: boolean[] = [];
      const [store, setStore] = createRoot((d) => {
        dispose = d;
        const pair = derived ? createStore(seed, seed()) : createStore(seed());
        createRenderEffect(
          () => pair[0].row.selected,
          (value) => {
            seen.push(value);
          },
        );
        return pair;
      });
      try {
        flush();
        const originalRow = untrack(() => store.row);
        setStore((draft) => {
          draft.row.child.count = 1;
        });
        flush();
        setStore((draft) => {
          draft.row.selected = false;
        });
        flush();
        expect(untrack(() => store.row.selected)).toBe(false);
        expect.soft(seen).toEqual([true, false]);
        expect(untrack(() => store.row === originalRow)).toBe(true);
      } finally {
        dispose();
      }
    });
  }
});

Expected and actual behavior

After writing row.child.count = 1, flushing, then writing row.selected = false and flushing:

Check Expected Actual for derived store
Direct store.row.selected read false false
Values received by the observer [true, false] [true]
store.row === originalRow true false

The matrix produces 1 failing derived-store case and 1 passing plain-store control. Both the notification and identity assertions fail in the derived case.

Source analysis

privatizeCommitted() registers the cloned ancestor in storeNextLookup, while reads for a derived store resolve targets through its family map. The next read can therefore wrap the clone as a new target, leaving existing subscribers attached to the previous target.

Related context: #3271 concerned a descendant-first write discarding an ancestor's value in the same flush. Here the writes are separated by flush(), the final value is correct, and the defect is lost observer connectivity and proxy identity. This still fails on the commit above, which includes the #3271 fix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions