From b5146419fb7c4d62221b4e1cff827893e6e7713a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:27:10 +0000 Subject: [PATCH] Keep the query baseline from owning cached results (#117) lastEmittedState is bookkeeping, not an owned child. Without @ignore, reading it after an overlapping optimistic rollback adopted the backend's cached result under the baseline, so the next query that received the same object threw a structural parent error. Claude-Session: https://claude.ai/code/session_01Us9f7SPLJUYScpdDnA8oM4 --- .changeset/query-baseline-ignore.md | 5 ++ packages/retree-query/src/QueryNode.spec.ts | 53 +++++++++++++++++++++ packages/retree-query/src/QueryNode.ts | 2 + 3 files changed, 60 insertions(+) create mode 100644 .changeset/query-baseline-ignore.md diff --git a/.changeset/query-baseline-ignore.md b/.changeset/query-baseline-ignore.md new file mode 100644 index 0000000..e7428f5 --- /dev/null +++ b/.changeset/query-baseline-ignore.md @@ -0,0 +1,5 @@ +--- +"@retreejs/query": patch +--- + +Fixed an "already has a structural parent" error when a query reused a backend-cached result after an overlapping optimistic update rolled back. diff --git a/packages/retree-query/src/QueryNode.spec.ts b/packages/retree-query/src/QueryNode.spec.ts index 61ed013..cc3a0eb 100644 --- a/packages/retree-query/src/QueryNode.spec.ts +++ b/packages/retree-query/src/QueryNode.spec.ts @@ -546,6 +546,59 @@ describe("QueryNode optimistic updates", () => { expect(node.error?.message).toBe("Mutation failed"); }); + it("re-observes a cached result that an overlapping rollback restored", async () => { + const source = new FakeQuerySource(); + const first = Retree.root( + new QueryNode(source, { args: { listId: "today" } }) + ); + const second = Retree.root( + new QueryNode(source, { args: { listId: "today" } }) + ); + let resolveFirstMutation!: () => void; + const firstMutation = new Promise((resolve) => { + resolveFirstMutation = () => resolve(null); + }); + let rejectSecondMutation!: () => void; + const secondMutation = new Promise((_, reject) => { + rejectSecondMutation = () => reject(new Error("Mutation failed")); + }); + const stopFirst = Retree.on(first, "nodeChanged", () => undefined); + source.subscriptions[0].onValue([ + { id: "task-1", text: "Buy groceries", isCompleted: false }, + ]); + + first.optimisticUpdate({ + ctx: { promise: firstMutation }, + apply(tasks) { + tasks[0].isCompleted = true; + }, + }); + first.optimisticUpdate({ + ctx: { promise: secondMutation }, + apply(tasks) { + tasks[0].text = "Optimistic text"; + }, + }); + resolveFirstMutation(); + await firstMutation; + // The backend caches the object it emits, so the next subscriber + // receives this same instance. + const cached = [ + { id: "task-1", text: "Buy groceries", isCompleted: true }, + ]; + source.nextCurrentValue = cached; + source.subscriptions[0].onValue(cached); + rejectSecondMutation(); + await secondMutation.catch(() => undefined); + stopFirst(); + + Retree.on(second, "nodeChanged", () => undefined); + + expect(first.state).toEqual(cached); + expect(second.state).toEqual(cached); + expect(source.subscriptions).toHaveLength(2); + }); + it("warns in dev mode when optimisticUpdate no-ops because state is undefined", () => { const warn = vi .spyOn(console, "warn") diff --git a/packages/retree-query/src/QueryNode.ts b/packages/retree-query/src/QueryNode.ts index 7044fab..601a07a 100644 --- a/packages/retree-query/src/QueryNode.ts +++ b/packages/retree-query/src/QueryNode.ts @@ -92,7 +92,9 @@ export class QueryNode extends ReactiveNode { * Last state emitted by the subscription that Retree accepted as a clean * server baseline. During overlapping optimistic updates this may advance * even when `state` intentionally keeps the newer optimistic value. + * Not an owned child: the backend may share this object with another query. */ + @ignore private lastEmittedState: TState | undefined; /** * True while local optimistic state differs from the last clean server