The obvious model for nested toggle blocks is a tree of nodes with children arrays. It works until you want the thing that makes an outliner an outliner: Tab to indent the block you are typing in.
In a tree that is a graph rewrite. Detach from the parent, find the previous sibling, append to its children, re-parent every descendant — and the blocks after you have to become your children too. Every step can leave the tree inconsistent halfway through.
Flat, the same document is:
[ { id:1, text:"Trip", depth:0, open:true },
{ id:2, text:"Flights", depth:1, open:true },
{ id:3, text:"Outbound", depth:2, open:true },
{ id:4, text:"Hotels", depth:1, open:false } ]
Indent block 4 is b[3].depth++. One integer. Nothing can dangle because there are no pointers.
Live, type in it: https://dev48.infy.uk/design/day63-toggle-blocks.html
One rule keeps a flat list a tree
[0, 2] is meaningless — block two claims a grandparent that does not exist. So exactly one invariant is enforced:
depth[i] <= depth[i-1] + 1, anddepth[0] === 0
Indent is refused when it would break that. The Tab key sometimes doing nothing is not a missing feature; it is the invariant holding.
A subtree is a range, not a traversal
Because depth rises by at most 1, everything nested under block i is contiguous:
function subtreeEnd(b, i){
let j = i + 1;
while (j < b.length && b[j].depth > b[i].depth) j++;
return j; // subtree is [i, j)
}
Collapse hides [i+1, j). Delete splices [i, j). Drag moves [i, j). One function, four features, and it is a scan rather than a recursion.
Visibility must be derived
The tempting bug is a stored hidden flag. Collapse a parent, collapse a child inside it, expand the parent — the child comes back expanded, because two sources of truth drifted. Instead:
function visible(b, i){
let need = b[i].depth;
for (let k = i - 1; k >= 0 && need > 0; k--){
if (b[k].depth < need){ // an ancestor
if (!b[k].open) return false;
need = b[k].depth;
}
}
return true;
}
One backward scan. No stored state, no possible drift.
The bug the fuzz test found
I checked the flat model against an independently written parent-pointer tree, applying the same random operations to both and comparing outlines after every step.
It broke after 89 operations. move() re-seated a dragged subtree against the block before it — and never looked at the block after. Dropping into the middle of someone else's subtree produced …3, 1, 4…: a depth-4 block hanging off a depth-1 one. The render would have silently re-parented content the user never touched.
The fix is to validate and roll back rather than re-parent behind their back. 4,000 operations now, 0 divergences.
Visibility is checked two ways as well — the flat backward scan against walking parent pointers upward. 3,000 states, no disagreement. Two algorithms that share no code cannot agree for the wrong reason.
Top comments (0)