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
8 changes: 8 additions & 0 deletions .changeset/observe-covered-reactive-nodes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@retreejs/core": patch
"@retreejs/react": patch
---

Selector-only `Retree.select(selector, callback)`, `Retree.effect`, and `useSelect` now observe every `ReactiveNode` they read, so `onObserved` and `onUnobserved` run for it.

Before, a node reached through a parent the selector also read was never observed, whether it existed before the run or was created during it. A query node created lazily inside a selector, such as `vm.snapshotsFor(id).state`, never subscribed and stayed loading forever. A selector also lost a dependency when `onObserved` wrote state while it subscribed, and `useSelect` re-ran a kept node's `onUnobserved` and `onObserved` when its other dependencies moved.
82 changes: 82 additions & 0 deletions packages/retree-core/src/ReactiveNode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,3 +1002,85 @@ it("retains active dependency records while refreshing values and removing stale
expect(changed).not.toHaveBeenCalled();
off();
});

describe("ReactiveNode observation through tracked reads", () => {
class Query extends ReactiveNode {
public state: number | undefined = undefined;
public observed = 0;
public unobserved = 0;
get dependencies() {
return [];
}
protected onObserved(): void {
this.observed++;
}
protected onUnobserved(): void {
this.unobserved++;
}
}
class Owner extends ReactiveNode {
public queries: Record<string, Query> = {};
get dependencies() {
return [];
}
queryFor(id: string): Query {
const existing = this.queries[id];
if (existing !== undefined) return existing;
Retree.runSilent(() => {
this.queries[id] = new Query();
});
return this.queries[id];
}
}
type TTrack = (read: () => unknown) => () => void;
const select: TTrack = (read) => Retree.select(read, () => {});
const effect: TTrack = (read) => Retree.effect(() => void read());

it.each([
["Retree.select", "created during the run", select, false],
["Retree.effect", "created during the run", effect, false],
["Retree.select", "read through its owner", select, true],
])("%s observes a node %s", (_, __, track, createFirst) => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const owner = trackRoot(Retree.root(new Owner()));
if (createFirst) owner.queryFor("a");
const seen: unknown[] = [];
const stop = track(() => seen.push(owner.queryFor("a").state));
const query = owner.queries.a;

query.state = 5;
expect(seen.at(-1)).toBe(5);
expect(query.observed).toBe(1);
stop();

expect(query.observed).toBe(1);
expect(query.unobserved).toBe(1);
});

it("keeps every dependency when onObserved writes while subscribing", () => {
class Cached extends ReactiveNode {
public state: number | undefined = undefined;
get dependencies() {
return [];
}
protected onObserved(): void {
this.state = 5;
}
}
const owner = trackRoot(Retree.root({ query: new Cached() }));
const other = trackRoot(Retree.root({ value: 1 }));
const seen: string[] = [];
const stop = Retree.select(
() =>
owner.query.state === undefined
? "loading"
: `${owner.query.state}:${other.value}`,
(next) => seen.push(next)
);

other.value = 2;
stop();

expect(seen).toEqual(["5:1", "5:2"]);
});
});
29 changes: 25 additions & 4 deletions packages/retree-core/src/internals/dependency-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,11 +718,17 @@ export enum DependencySubscriptionKind {
Node,
/** The node and every descendant, delivered per changed node. */
Subtree,
/**
* A `ReactiveNode` read through a cover: held only to run its observed
* lifecycle, since the cover already delivers its changes.
*/
Observe,
}

/**
* One node a tracked run must subscribe to: a cover whose subtree holds
* every other node the run read through it.
* every other node the run read through it, or a covered `ReactiveNode` the
* run observes.
*/
export interface ITrackedDependencySource {
rawNode: TreeNode;
Expand All @@ -741,9 +747,11 @@ export interface ITrackedSelectionAccesses<T> {
/**
* The covers of the run's reads: nodes whose parent the run did not
* read. A cover with covered children subscribes to its subtree; a
* lone node subscribes to `nodeChanged`.
* lone node subscribes to `nodeChanged`. Covered `ReactiveNode`s follow
* as observe-only sources. Resolved on first access: `@select`
* collection never subscribes.
*/
sources: readonly ITrackedDependencySource[];
readonly sources: readonly ITrackedDependencySource[];
/** Every node the run read, keyed by raw node, in first-read order. */
reads: ReadonlyMap<TreeNode, NodeReadRecord>;
/**
Expand All @@ -763,6 +771,7 @@ export interface ITrackedSelectionAccesses<T> {
/**
* Resolve which records subscribe. A record created before its parent was
* read is re-checked here; the rest were settled as they were created.
* Covered `ReactiveNode`s are observed too, so their `onObserved` runs.
*/
function resolveCovers(
frame: DependencyAccessFrame,
Expand All @@ -788,6 +797,15 @@ function resolveCovers(
: DependencySubscriptionKind.Node,
});
}
for (const record of reads.values()) {
if (!record.covered) continue;
if (!(record.rawNode instanceof ReactiveNode)) continue;
covers.push({
rawNode: record.rawNode,
baseProxy: record.baseProxy,
kind: DependencySubscriptionKind.Observe,
});
}
return covers;
}

Expand All @@ -801,11 +819,14 @@ export function collectTrackedSelectionAccesses<T>(
): ITrackedSelectionAccesses<T> {
const { value, frame } = runDependenciesFrame(callback);
const reads = frame.reads ?? new Map<TreeNode, NodeReadRecord>();
let sources: readonly ITrackedDependencySource[] | undefined;
return {
value,
coverage: frame.coverage,
getDependencies: () => toDependencyValues(reads),
sources: resolveCovers(frame, reads),
get sources() {
return (sources ??= resolveCovers(frame, reads));
},
reads,
subtreeReads: frame.subtreeReads ?? [],
writeInvalidatedReads: frame.writeInvalidatedReads ?? [],
Expand Down
53 changes: 45 additions & 8 deletions packages/retree-core/src/internals/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ interface DependencySubscription {
}

/**
* The subscriptions a selector or effect holds on the covers of its last
* run. `update` diffs against the previous run so unchanged covers keep
* their subscription and only added, removed or re-kinded covers churn.
* The subscriptions a selector or effect holds on the sources of its last
* run: its covers, plus the covered `ReactiveNode`s it observes. `update`
* diffs against the previous run so unchanged sources keep their
* subscription and only added, removed or re-kinded sources churn.
*/
function createDependencySubscriptionSet(
subscribeToNode: SubscribeToNode,
Expand All @@ -104,6 +105,17 @@ function createDependencySubscriptionSet(
source: ITrackedDependencySource
): DependencySubscription => {
const rawNode = source.rawNode;
if (source.kind === DependencySubscriptionKind.Observe) {
// The node's cover delivers its changes; this only observes it.
return {
kind: source.kind,
unsubscribe: subscribeToNode(
source.baseProxy,
"nodeChanged",
() => undefined
),
};
}
if (source.kind === DependencySubscriptionKind.Node) {
return {
kind: source.kind,
Expand Down Expand Up @@ -448,11 +460,30 @@ export function createRetreeTrackedSelectionObserver<TSelected>(options: {
options.subscribeToSubtree,
(rawNode, changes) => evaluateForDependency(rawNode, changes)
);
// Subscribing can run `onObserved`, which may write a dependency. A
// nested evaluate would replace the subscriptions mid-update, so changes
// during an update re-run once it settles.
let updating = false;
let changedDuringUpdate = false;
const updateSubscriptions = (
sources: readonly ITrackedDependencySource[]
) => {
updating = true;
try {
subscriptions.update(sources);
} finally {
updating = false;
}
};

const evaluateForDependency = (
changedRawNode: TreeNode,
changes?: INodeFieldChanges[]
) => {
if (updating) {
changedDuringUpdate = true;
return;
}
// Subtree subscriptions deliver every descendant emission; a node
// the run never read cannot change what it selected.
const record = resolveChangedReadRecord(
Expand All @@ -475,7 +506,7 @@ export function createRetreeTrackedSelectionObserver<TSelected>(options: {
previous.selected,
next.selected
);
subscriptions.update(next.sources);
updateSubscriptions(next.sources);
const changed = hasTrackedSelectionChanged(
previous,
{ selected: nextSelected, reads: next.reads },
Expand All @@ -488,13 +519,19 @@ export function createRetreeTrackedSelectionObserver<TSelected>(options: {
reads: next.reads,
subtreeReads: next.subtreeReads,
};
if (!changed) {
return;
if (changed) {
options.onChange(nextSelected, previousToEmit.selected);
}
options.onChange(nextSelected, previousToEmit.selected);
rerunIfChangedDuringUpdate();
};
const rerunIfChangedDuringUpdate = () => {
if (!changedDuringUpdate) return;
changedDuringUpdate = false;
evaluate();
};

subscriptions.update(previous.sources);
updateSubscriptions(previous.sources);
rerunIfChangedDuringUpdate();

return () => subscriptions.dispose();
}
Expand Down
14 changes: 10 additions & 4 deletions packages/retree-react/src/internals/externalStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ export function getRetreeExternalStoreSource(
baseProxy,
listenerType,
getVersion() {
// An observed source never notifies, so its version never moves.
if (listenerType === "observed") {
return 0;
}
// A subtree source moves with any descendant, like a tree source.
if (listenerType === "nodeChanged") {
return getNodeSnapshotVersion(baseProxy);
Expand Down Expand Up @@ -285,14 +289,16 @@ export function createRetreeSwappableCompositeExternalStore(
},
swapSources: (nextSources) => {
current = createRetreeCompositeExternalStore(nextSources);
// Rewire synchronously: no other code runs between the old
// unsubscribe and the new subscribe, so the swap cannot miss a
// write. A swap with no live wirings (before mount, after
// Rewire synchronously so the swap cannot miss a write, and
// subscribe before unsubscribing so a node both lists hold never
// loses its last listener (which would re-run its observed
// lifecycle). A swap with no live wirings (before mount, after
// unmount) only replaces the inner store; `subscribe` reads
// `current` at call time.
for (const wiring of wirings) {
wiring.unsubscribe();
const unsubscribePrevious = wiring.unsubscribe;
wiring.unsubscribe = current.subscribe(wiring.onStoreChange);
unsubscribePrevious();
}
},
};
Expand Down
12 changes: 10 additions & 2 deletions packages/retree-react/src/internals/subscriptionHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ import { SUBSCRIBE_SUBTREE_CHANGED_SYMBOL } from "@retreejs/core/internal";

/**
* Listener kinds a hook can hold: the public change events plus the internal
* subtree subscription tracked `useSelect` uses for its read covers.
* subtree subscription tracked `useSelect` uses for its read covers, and the
* observe-only subscription it holds on `ReactiveNode`s read through them.
*/
export type RetreeStoreListenerType = TRetreeChangedEvents | "subtreeChanged";
export type RetreeStoreListenerType =
| TRetreeChangedEvents
| "subtreeChanged"
| "observed";

/**
* For `nodeChanged`/`treeChanged` the first argument is the node's reproxy;
Expand Down Expand Up @@ -46,6 +50,10 @@ function subscribeRetree<T extends TreeNode>(
listenerType: RetreeStoreListenerType,
notify: (node: T, changes: INodeFieldChanges[]) => void
): () => void {
if (listenerType === "observed") {
// Runs the node's observed lifecycle; its cover delivers changes.
return Retree.on<T>(baseProxy, "nodeChanged", () => undefined);
}
if (listenerType === "subtreeChanged") {
return Retree[SUBSCRIBE_SUBTREE_CHANGED_SYMBOL](
baseProxy,
Expand Down
Loading
Loading