Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/query-detach-shared-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@retreejs/query": patch
"@retreejs/convex": patch
---

Queries no longer adopt the objects a source emits, so two queries can share one cached result.

Two queries that received the same cached result threw "Retree cannot assign this node because it already has a structural parent". Rows added during reconciliation were also shared between queries, so editing one query changed the other. Query state now holds its own copies, including of `initialState`. Frozen objects and opaque built-ins are still shared, and reconciliation copies only the rows and fields it writes.

Custom reconcilers should not write objects from `next` into `current` directly. Use `reconcileArray`, or return a replacement value; it is copied, except for rows reused from `current`.
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,23 @@ describe("ConvexPaginatedQueryNode optimistic updates", () => {
);
});
});

describe("ConvexPaginatedQueryNode shared pages", () => {
it("loads one cached page into two queries without sharing rows", () => {
const client = new FakePaginatedConvexClient();
const first = subscribePaginatedNode(client);
const second = subscribePaginatedNode(client);
const page: RetreePaginatedQueryResult<PaginatedDoc> = {
results: [{ _id: "task-1", text: "Server", isCompleted: false }],
status: "CanLoadMore",
loadMore: () => true,
};

client.paginatedSubscriptions[0].callback(page);
client.paginatedSubscriptions[1].callback(page);
first.state!.results[0].text = "Edited";

expect(second.state?.results[0].text).toBe("Server");
expect(page.results[0].text).toBe("Server");
});
});
4 changes: 2 additions & 2 deletions packages/retree-convex/src/ConvexPaginatedQueryNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,14 +259,14 @@ export class ConvexPaginatedQueryNode<

const current = this.state;
if (current === undefined) {
this.state = next;
this.state = this.detach(next);
return;
}

if (!tryReconcileConvexDocuments(current.results, next.results)) {
// Rows without `_id` cannot be reconciled by identity; replace the
// loaded rows wholesale like the non-paginated node does.
current.results = next.results;
current.results = this.detach(next.results);
}

// Page bookkeeping: compare against the raw view so the function
Expand Down
1 change: 1 addition & 0 deletions packages/retree-core/src/internals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
export * from "./TypedEventEmitter.js";
export * from "./dependencies.js";
export * from "./dependency-tracking.js";
export { isLeafObject } from "./leaves.js";
export * from "./proxy.js";
export * from "./reproxy.js";
export * from "./select.js";
Expand Down
198 changes: 197 additions & 1 deletion packages/retree-query/src/QueryNode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from "vitest";
import { Retree } from "@retreejs/core";
import { QueryNode } from "./QueryNode.js";
import { reconcileArrayById } from "./reconcile.js";
import { IQuerySubscriptionSource, IQuerySubscriptionHandle } from "./types.js";
import {
IQuerySubscriptionSource,
IQuerySubscriptionHandle,
IStateReconciler,
} from "./types.js";

interface ITaskArgs {
listId: string;
Expand All @@ -14,6 +18,11 @@ interface ITask {
isCompleted: boolean;
}

interface ITaskWithMeta {
id: string;
meta?: { owner: string };
}

class FakeQuerySource<TArgs, TState>
implements IQuerySubscriptionSource<TArgs, TState>
{
Expand Down Expand Up @@ -632,3 +641,190 @@ describe("QueryNode optimistic updates", () => {
);
});
});

describe("QueryNode shared source values", () => {
function subscribeTwo(
source: FakeQuerySource<ITaskArgs, ITask[]>,
reconcile?: IStateReconciler<ITask[]>
) {
const nodes = [0, 1].map(() =>
Retree.root(
new QueryNode(source, { args: { listId: "today" }, reconcile })
)
);
for (const node of nodes) {
Retree.on(node, "nodeChanged", () => undefined);
}
return nodes;
}

it("observes one cached result from two queries without sharing it", () => {
const source = new FakeQuerySource<ITaskArgs, ITask[]>();
const cached = [{ id: "task-1", text: "Server", isCompleted: false }];
source.nextCurrentValue = cached;
const [first, second] = subscribeTwo(source);

first.state![0].text = "Edited";

expect(first.state![0].text).toBe("Edited");
expect(second.state![0].text).toBe("Server");
expect(cached[0].text).toBe("Server");
});

it("copies rows that reconciliation inserts", () => {
const source = new FakeQuerySource<ITaskArgs, ITask[]>();
const [first, second] = subscribeTwo(source, reconcileArrayById("id"));
const initial = { id: "task-1", text: "First", isCompleted: false };
source.subscriptions[0].onValue([{ ...initial }]);
source.subscriptions[1].onValue([{ ...initial }]);

const next = [
initial,
{ id: "task-2", text: "Second", isCompleted: false },
];
source.subscriptions[0].onValue(next);
source.subscriptions[1].onValue(next);
first.state![1].text = "Edited";

expect(second.state![1].text).toBe("Second");
expect(next[1].text).toBe("Second");
});

it("copies objects that reconciliation writes into existing rows", () => {
const source = new FakeQuerySource<ITaskArgs, ITaskWithMeta[]>();
const nodes = [0, 1].map(() =>
Retree.root(
new QueryNode(source, {
args: { listId: "today" },
reconcile: reconcileArrayById<ITaskWithMeta, "id">("id"),
})
)
);
for (const node of nodes) {
Retree.on(node, "nodeChanged", () => undefined);
}
source.subscriptions[0].onValue([{ id: "task-1" }]);
source.subscriptions[1].onValue([{ id: "task-1" }]);

const next = [{ id: "task-1", meta: { owner: "Ada" } }];
source.subscriptions[0].onValue(next);
source.subscriptions[1].onValue(next);
nodes[0].state![0].meta!.owner = "Grace";

expect(nodes[1].state![0].meta?.owner).toBe("Ada");
expect(next[0].meta.owner).toBe("Ada");
});

it("copies a replacement value but keeps rows it reuses", () => {
const source = new FakeQuerySource<ITaskArgs, ITask[]>();
const replaceById: IStateReconciler<ITask[]> = {
reconcile(current, next) {
return next.map(
(task) => current?.find((row) => row.id === task.id) ?? task
);
},
};
const [first, second] = subscribeTwo(source, replaceById);
const initial = { id: "task-1", text: "First", isCompleted: false };
source.subscriptions[0].onValue([{ ...initial }]);
source.subscriptions[1].onValue([{ ...initial }]);
const row = first.state![0];

const next = [
initial,
{ id: "task-2", text: "Second", isCompleted: false },
];
source.subscriptions[0].onValue(next);
source.subscriptions[1].onValue(next);
first.state![1].text = "Edited";

expect(first.state![0]).toBe(row);
expect(second.state![1].text).toBe("Second");
});

it("rolls back when a custom reconciler adopts emitted rows", async () => {
const source = new FakeQuerySource<ITaskArgs, ITask[]>();
const adoptRows: IStateReconciler<ITask[]> = {
reconcile(current, next) {
if (current === undefined) return next;
next.forEach((task, index) => {
current[index] = task;
});
return current;
},
};
const node = Retree.root(
new QueryNode(source, {
args: { listId: "today" },
reconcile: adoptRows,
})
);
Retree.on(node, "nodeChanged", () => undefined);
source.subscriptions[0].onValue([
{ id: "task-1", text: "Server", isCompleted: false },
]);
source.subscriptions[0].onValue([
{ id: "task-1", text: "Server", isCompleted: true },
]);
const mutation = Promise.reject(new Error("Mutation failed"));

node.optimisticUpdate({
ctx: { promise: mutation },
apply(tasks) {
tasks[0].text = "Optimistic";
},
});
await mutation.catch(() => undefined);

expect(node.state![0].text).toBe("Server");
});

it("gives each query its own copy of a shared initial state", () => {
const source = new FakeQuerySource<ITaskArgs, ITask[]>();
const empty: ITask[] = [];
const [first, second] = [0, 1].map(() =>
Retree.root(
new QueryNode(source, {
args: { listId: "today" },
initialState: empty,
})
)
);

first.state!.push({ id: "task-1", text: "Local", isCompleted: false });

expect(second.state).toEqual([]);
expect(empty).toEqual([]);
});

it("keeps state when the source repeats the emission it mirrors", () => {
const source = new FakeQuerySource<ITaskArgs, ITask[]>();
const cached = [{ id: "task-1", text: "Server", isCompleted: false }];
source.nextCurrentValue = cached;
const node = Retree.root(
new QueryNode(source, { args: { listId: "today" } })
);
Retree.on(node, "nodeChanged", () => undefined)();
const state = node.state;

Retree.on(node, "nodeChanged", () => undefined);

expect(node.state).toBe(state);
});

it("shares frozen leaves instead of copying them", () => {
const source = new FakeQuerySource<ITaskArgs, { task: ITask }>();
const task = Object.freeze({
id: "task-1",
text: "Server",
isCompleted: false,
});
source.nextCurrentValue = { task };
const node = Retree.root(
new QueryNode(source, { args: { listId: "today" } })
);
Retree.on(node, "nodeChanged", () => undefined);

expect(node.state?.task).toBe(task);
});
});
Loading
Loading