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
7 changes: 7 additions & 0 deletions .changeset/keep-observed-across-resubscribe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@retreejs/react": patch
---

`useSelect` no longer re-runs `onUnobserved` and `onObserved` on a `ReactiveNode` it keeps reading when a prop moves the selector's other dependencies. Query nodes such as `ConvexQueryNode` no longer close and reopen their backend subscription on those re-renders.

React hooks now release a `ReactiveNode` one microtask after its last listener unsubscribes, so `onUnobserved` runs just after unmount rather than during it.
39 changes: 35 additions & 4 deletions packages/retree-react/src/internals/subscriptionHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import {
INodeFieldChanges,
ReactiveNode,
Retree,
TRetreeChangedEvents,
TreeNode,
Expand Down Expand Up @@ -38,6 +39,8 @@ interface SubscriptionHubEntry<T extends TreeNode = TreeNode> {
*/
listeners: Map<HubListener<T>, number>;
unsubscribeRetree: () => void;
/** A `ReactiveNode` hub lost its last listener and tears down next microtask. */
teardownQueued: boolean;
}

const subscriptionHubs: WeakMap<
Expand Down Expand Up @@ -89,6 +92,7 @@ export function subscribeToNode<T extends TreeNode = TreeNode>(
hub = {
listeners,
unsubscribeRetree,
teardownQueued: false,
};
nodeHubs.set(listenerType, hub as SubscriptionHubEntry);
}
Expand Down Expand Up @@ -117,10 +121,37 @@ export function subscribeToNode<T extends TreeNode = TreeNode>(
if (hub.listeners.size > 0) {
return;
}
hub.unsubscribeRetree();
nodeHubs.delete(listenerType);
if (nodeHubs.size === 0) {
subscriptionHubs.delete(baseProxy);
if (!(baseProxy instanceof ReactiveNode)) {
teardownHub(baseProxy, nodeHubs, listenerType, hub);
return;
}
// React swaps a moved store's subscription by unsubscribing the old
// one before subscribing the new one in the same commit. Deferring
// lets a resubscribe adopt this hub, so a node both stores hold keeps
// its listener instead of re-running `onUnobserved`/`onObserved`.
if (hub.teardownQueued) {
return;
}
hub.teardownQueued = true;
queueMicrotask(() => {
hub.teardownQueued = false;
if (hub.listeners.size > 0) {
return;
}
teardownHub(baseProxy, nodeHubs, listenerType, hub);
});
};
}

function teardownHub<T extends TreeNode>(
baseProxy: T,
nodeHubs: Map<RetreeStoreListenerType, SubscriptionHubEntry>,
listenerType: RetreeStoreListenerType,
hub: SubscriptionHubEntry<T>
): void {
hub.unsubscribeRetree();
nodeHubs.delete(listenerType);
if (nodeHubs.size === 0) {
subscriptionHubs.delete(baseProxy);
}
}
25 changes: 24 additions & 1 deletion packages/retree-react/src/useSelect.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1250,7 +1250,7 @@ describe("useSelect observation", () => {
}
}

it("observes a node it reads through its owner", () => {
it("observes a node it reads through its owner", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const owner = trackRoot(Retree.root(new Owner()));
let renders = 0;
Expand All @@ -1270,6 +1270,8 @@ describe("useSelect observation", () => {
expect(renders).toBe(2);

view.unmount();
expect(query.unobserved).toBe(0);
await Promise.resolve();
expect(query.observed).toBe(1);
expect(query.unobserved).toBe(1);
});
Expand Down Expand Up @@ -1300,4 +1302,25 @@ describe("useSelect observation", () => {
expect(query.observed).toBe(1);
expect(query.unobserved).toBe(0);
});

it("keeps observing a node when a prop moves its other dependencies", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const owner = trackRoot(Retree.root(new Owner()));
const other = trackRoot(Retree.root({ value: 1 }));
function View({ on }: { on: boolean }) {
const value = useSelect(
() => `${owner.queryFor("a").state}:${on ? other.value : 0}`
);
return <span>{value}</span>;
}
const view = render(<View on={false} />);
const query = owner.queries.a;

view.rerender(<View on={true} />);
view.rerender(<View on={false} />);
await Promise.resolve();

expect(query.observed).toBe(1);
expect(query.unobserved).toBe(0);
});
});
1 change: 1 addition & 0 deletions samples/04.convex-react-nextjs/app/page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ describe("compiled React sample", () => {
});
await screen.findByLabelText("Edit Updated task");
view.unmount();
await Promise.resolve();
expect(unsubscribe).toHaveBeenCalled();
});
});
Loading