Files
dbx/apps/desktop/src/lib/metadata/treeNodeLoadHandle.ts
T

126 lines
4.3 KiB
TypeScript

/**
* Per-tree-node load generation: one handle owns both apply eligibility and spinner clear.
* Connection/reconnect invalidation bumps generations under a connection so stale loads
* cannot write children or clear a newer load's isLoading.
*/
export type TreeNodeLike = {
id: string;
connectionId?: string;
isLoading?: boolean;
children?: TreeNodeLike[];
};
export type TreeNodeLoadHandle = {
readonly nodeId: string;
readonly generation: number;
isCurrent(): boolean;
/** After ensureConnected may invalidate gens; reclaim ownership and keep spinner lit. */
reclaim(node: TreeNodeLike): TreeNodeLoadHandle;
targetNode(findLive: (nodeId: string) => TreeNodeLike | null, isConnected: (connectionId: string) => boolean): TreeNodeLike | null;
finish(findLive: (nodeId: string) => TreeNodeLike | null): void;
};
/** Generation observer that does not claim a load or toggle isLoading. */
export type TreeNodeLoadEpoch = {
isCurrent(): boolean;
};
export class TreeNodeLoadRegistry {
private readonly generations = new Map<string, number>();
begin(node: TreeNodeLike): TreeNodeLoadHandle {
const generation = (this.generations.get(node.id) ?? 0) + 1;
this.generations.set(node.id, generation);
node.isLoading = true;
return this.createHandle(node.id, generation);
}
/** Snapshot the current generation without claiming ownership (no spinner). */
observe(nodeId: string): TreeNodeLoadEpoch {
// Register the id so later prefix invalidation (e.g. connection disconnect) can bump it
// even when this node never called begin().
if (!this.generations.has(nodeId)) {
this.generations.set(nodeId, 0);
}
const generation = this.generations.get(nodeId)!;
return {
isCurrent: () => this.generations.get(nodeId) === generation,
};
}
/** Bump every recorded generation under nodeId (inclusive), including pruned IDs. */
invalidatePrefix(nodeId: string): void {
// Always bump the prefix root, even when no load was recorded for it yet.
this.generations.set(nodeId, (this.generations.get(nodeId) ?? 0) + 1);
const prefix = `${nodeId}:`;
for (const id of [...this.generations.keys()]) {
if (id !== nodeId && id.startsWith(prefix)) {
this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
}
}
}
/** Bump recorded generations for descendants only (not the parent load itself). */
invalidateDescendants(parentId: string): void {
const prefix = `${parentId}:`;
for (const id of [...this.generations.keys()]) {
if (id.startsWith(prefix)) {
this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
}
}
}
/**
* Invalidate all loads for a connection (including pruned node IDs) and clear sticky
* spinners on surviving live nodes. Always prefix-bump first so removed IDs cannot stay current.
*/
invalidateConnection(connectionId: string, root: TreeNodeLike | null): void {
this.invalidatePrefix(connectionId);
if (!root) return;
const stack: TreeNodeLike[] = [root];
while (stack.length) {
const node = stack.pop()!;
node.isLoading = false;
if (node.children?.length) stack.push(...node.children);
}
}
private isCurrent(nodeId: string, generation: number): boolean {
return this.generations.get(nodeId) === generation;
}
private createHandle(nodeId: string, generation: number): TreeNodeLoadHandle {
const registry = this;
return {
nodeId,
generation,
isCurrent() {
return registry.isCurrent(nodeId, generation);
},
reclaim(node: TreeNodeLike) {
if (node.id !== nodeId) {
return registry.begin(node);
}
if (registry.isCurrent(nodeId, generation)) {
node.isLoading = true;
return this;
}
return registry.begin(node);
},
targetNode(findLive, isConnected) {
if (!registry.isCurrent(nodeId, generation)) return null;
const current = findLive(nodeId);
if (!current) return null;
if (current.connectionId && !isConnected(current.connectionId)) return null;
return current;
},
finish(findLive) {
if (!registry.isCurrent(nodeId, generation)) return;
const current = findLive(nodeId);
if (current) current.isLoading = false;
},
};
}
}