fix(tree): refresh loaded branches on reconnect so they don't go stale (#159)
Third tree-sync finding (#8). On a socket reconnect after a missed-events gap (laptop sleep / wifi blip), the resync only invalidated the ROOT sidebar query; a move/rename/delete that happened INSIDE an already-loaded, expanded branch was never reflected — the branch stayed stale until the user manually interacted. (The #2 fix reconciles the root level; this covers the deeper loaded branches.) - `treeModel.reconcileChildren(tree, parentId, fresh)`: replace a loaded branch's DIRECT children with the authoritative fresh set (drop removed, add new, reorder to server) while PRESERVING each surviving child's already-loaded grandchildren, so deeper expansion is not collapsed. An unloaded branch (children === undefined) is left untouched (lazy-load fetches it fresh). - `loadedOpenBranchIds(tree, openIds)`: the branches a reconnect should refresh (open AND loaded). `fetchAllAncestorChildren(..., { fresh: true })` bypasses the 30-min sidebar cache so the reconcile sees current data (handler-order independent). - space-tree: on socket `connect`, re-fetch + reconcile each open loaded branch of the active space (space-switch-guarded; an unloaded branch is skipped). Tests: reconcileChildren (drop/add/reorder + preserve grandchildren + unloaded no-op) and loadedOpenBranchIds (open+loaded only, skip unloaded, nested). The pure logic is unit-tested; the live socket-reconnect round-trip is not browser-automated (simulating a reconnect gap is impractical) — sidebar render + expand were smoke-tested with no regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -223,6 +223,48 @@ export const treeModel = {
|
||||
return touched ? out : tree;
|
||||
},
|
||||
|
||||
// Replace a parent's DIRECT children with the authoritative `fresh` set while
|
||||
// PRESERVING each surviving child's already-loaded grandchildren (deeper
|
||||
// expansion). Unlike `appendChildren` (add-only), this DROPS children that are
|
||||
// no longer present and reorders to `fresh` — so a move/delete/rename that
|
||||
// happened inside a loaded branch while events were missed (a socket reconnect
|
||||
// gap) is reflected, not left stale (#159 #8). Only used to reconcile an
|
||||
// already-loaded branch against a fresh fetch; a parent with no loaded children
|
||||
// (`children === undefined`) is left untouched (lazy-load handles it).
|
||||
reconcileChildren<T extends object>(
|
||||
tree: TreeNode<T>[],
|
||||
parentId: string,
|
||||
fresh: TreeNode<T>[],
|
||||
): TreeNode<T>[] {
|
||||
let touched = false;
|
||||
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] =>
|
||||
nodes.map((n) => {
|
||||
if (n.id === parentId) {
|
||||
// Only reconcile a branch whose children were actually loaded; an
|
||||
// unloaded parent stays unloaded (lazy-load fetches it fresh later).
|
||||
if (n.children === undefined) return n;
|
||||
const prevById = new Map(n.children.map((c) => [c.id, c]));
|
||||
const merged = fresh.map((f) => {
|
||||
const prev = prevById.get(f.id);
|
||||
// Preserve the surviving child's previously loaded grandchildren so
|
||||
// deeper expansion is not collapsed by the reconcile.
|
||||
return prev?.children !== undefined
|
||||
? { ...f, children: prev.children }
|
||||
: f;
|
||||
});
|
||||
touched = true;
|
||||
return { ...n, children: merged };
|
||||
}
|
||||
if (n.children) {
|
||||
const next = walk(n.children);
|
||||
if (next !== n.children) return { ...n, children: next };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
const out = walk(tree);
|
||||
return touched ? out : tree;
|
||||
},
|
||||
|
||||
place<T extends object>(
|
||||
tree: TreeNode<T>[],
|
||||
sourceId: string,
|
||||
|
||||
Reference in New Issue
Block a user