Skip to content

[P1] 2.0.0-rc.6: deep() drops descendant subscriptions after a parent field edit #3283

Description

@brenelz

Describe the bug

An effect observing deep(store) stops receiving descendant updates after an unrelated property on the parent is edited. With { title: "a", child: { count: 0 } }, changing title to "b" reruns the observer, but subsequently changing child.count to 2 does not. Direct reads see 2; the observer's last snapshot still contains 0.

Priority assessment: P1. Ordinary parent edits can silently disconnect deep observers, leaving UI, autosave, or synchronization effects with stale data.

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/deep-probes.test.ts and run from the repository root:

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

for (const editParent of [false, true]) {
  it(`deep remains subscribed to descendants after ${editParent ? "parent" : "child"} edit`, () => {
    let dispose!: () => void;
    const seen: { title: string; child: { count: number } }[] = [];
    const [store, setStore] = createRoot((d) => {
      dispose = d;
      const pair = createStore({ title: "a", child: { count: 0 } });
      createRenderEffect(
        () => deep(pair[0]),
        (value) => {
          seen.push(value);
        },
      );
      return pair;
    });
    try {
      flush();
      setStore((draft) => {
        if (editParent) draft.title = "b";
        else draft.child.count = 1;
      });
      flush();
      expect(seen.at(-1)).toEqual(
        editParent
          ? { title: "b", child: { count: 0 } }
          : { title: "a", child: { count: 1 } },
      );
      setStore((draft) => {
        draft.child.count = 2;
      });
      flush();
      expect(untrack(() => store.child.count)).toBe(2);
      expect(seen.at(-1)?.child.count).toBe(2);
    } finally {
      dispose();
    }
  });
}

Expected and actual behavior

After the two edits, the observer should receive { title: "b", child: { count: 2 } }. Instead, its last value remains { title: "b", child: { count: 0 } } even though store.child.count is 2.

The matrix produces 1 failure and 1 passing control: editing the child first preserves the subscription, while editing the parent first loses it. The same results occur with __DEV__ and __TEST__ both enabled and both disabled when compiling local source.

Source analysis

The tracking walk in deepNext() enumerates Reflect.ownKeys(readSource(t)). During the rerun following a parent edit, that source can be a pending prototype overlay whose own keys contain only changed fields. Unchanged children live on its prototype, so the walk does not resubscribe to them. Snapshot materialization happens after tracking and does not restore the dropped subscriptions.

The reproduction uses a plain synchronous store with a flush between writes; it needs no optimistic state or transaction.

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