August 22nd. The tree was functional – optimistic updates, API sync, drag-and-drop all working. Then I actually used it. Clicking the drag handle and waiting nearly a second before drag mode engaged made it feel seriously broken. Obviously, this wasn't a polish problem; it was a usability blocker.
The Investigation
The lag was specific: click the drag handle, nothing happens, then about a second later the node lifts. Once dragging started, movement was smooth. That distinction mattered – it pointed to drag activation rather than render cost or DOM thrashing. I worked through five approaches, none of which fixed the core issue.
All of this was local. I reset the commits deliberately, documenting what had been tried
before moving on – which is why c32d085 (the 22 August commit) is the
mutation queue work rather than any of this.
Attempts 1–3: Render Optimisation
The first theory was excessive re-renders cascading into drag startup cost. I added
React.memo to BaseNode, NodeComponentFactory and
SortableTreeNode, with a custom comparator on the tree nodes to avoid
re-renders when unrelated state changed:
1
2
3
4
5
6
7
8
9
10
export const SortableTreeNode = memo(SortableTreeNodeComponent, (prev, next) => {
return (
prev.item.id === next.item.id &&
prev.item.nodeData === next.item.nodeData &&
prev.item.children === next.item.children &&
prev.isRoot === next.isRoot &&
prev.pendingNodes === next.pendingNodes &&
prev.onAddChild === next.onAddChild
);
});
Alongside that: stabilising props with useCallback, switching to functional
setState to avoid stale closures, memoising getAllItemIds. A
follow-up pass memoised drag-related objects inside SortableTreeNode and
wrapped the drag handle component itself.
The React DevTools Profiler showed this working – individual node render times dropped from 2–10ms to under 0.1ms. The tree felt noticeably snappier for typing and expanding nodes. The drag activation lag didn't move.
An honest note on the code
Some of what landed during this pass had issues. The most persistent: inside
useNodeCapabilities, functions were memoised with useMemo
instead of useCallback.
1
2
3
4
5
6
7
8
9
// ❌ What landed – semantically wrong
const canPerformAction = useMemo(() => (action: NodeAction): boolean => {
return canPerformActionUtil(nodeType, action, customCapabilities);
}, [nodeType, customCapabilities]);
// ✅ What it should be
const canPerformAction = useCallback((action: NodeAction): boolean => {
return canPerformActionUtil(nodeType, action, customCapabilities);
}, [nodeType, customCapabilities]);
Both produce a stable function reference – but useMemo is for computed
values, useCallback
is for functions. The distinction matters for readability and for how linters reason
about the code. This slipped in during a performance pass and wasn't caught.
The custom memo comparator on SortableTreeNode had a subtler problem:
reference equality checks on item.children and item.nodeData.
If TreeDataAdapter.buildTreeFromData creates new array and object
references on each call – which it does – those checks fail every time and memo never
short-circuits. The render improvement was real, but it likely came from the
useCallback prop stabilisation rather than the comparator itself.
None of the React.memo wrappers survived as the components grew.
Useful to notice in hindsight.
Attempts 4–5: Library-Level Debugging
With rendering ruled out, I looked at the library itself. Attempt 4 memoised the drag
handle's event handlers and wrapped handleDragStart / handleDragEnd
in useCallback. Attempt 5 switched collision detection from
pointerWithin to closestCenter – pointerWithin
had been adopted a few weeks earlier for the insertion-line drop system. The theory
was that its per-frame zone calculations were expensive at startup.
Neither changed anything. The lag held at roughly one second.
What the Profiler Actually Showed
Chrome DevTools' Performance tab was the final check: no layout thrashing, no long JavaScript tasks blocking the main thread, paint operations normal – but the drag event itself was delayed. The bottleneck was inside @dnd-kit's sensor initialisation. On pointer down, it computes bounding rects for every droppable element before activating. With 20-plus nodes each carrying before/after insertion indicators, that startup sweep was the full second.
This wasn't an API issue or a data problem. It was purely a UI scaling issue – and fixing it properly would mean switching drag libraries, which meant rewriting the tree component from scratch. I didn't want to spend more time on it at that point. The component was ~320 lines and growing; a full rewrite before the core features were in place wasn't a sensible trade.
The pragmatic decision was to move on, log the root cause and keep the render improvements that had been verified.
The Resolution – A Phase 7 Story
The solution was clear from the profiling, even if the implementation had to wait. The problem was tree size: @dnd-kit's initialisation cost scales with the number of droppable elements. A large journal loaded as a single tree would always hit that wall. The answer was to not do that – scope each tree to a single thought's subgraph rather than an entire journal.
That direction was implemented two months later in the Phase 7 workspace rebuild
(016a6b0, October 2025). A filterSubgraph() method was added
to TreeDataAdapter, using BFS to extract just the selected thought's
hierarchy and the workspace was restructured around per-thought navigation. Individual
thought subgraphs rarely grow large enough to trigger the initialisation bottleneck.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static filterSubgraph(
nodes: ResponseNode[],
edges: Edge[],
rootId: string
): { nodes: ResponseNode[]; edges: Edge[] } {
const nodeIds = new Set<string>([rootId]);
const relevantEdges: Edge[] = [];
const queue: string[] = [rootId];
while (queue.length > 0) {
const currentId = queue.shift()!;
const outgoingEdges = edges.filter(edge => edge.from_node === currentId);
for (const edge of outgoingEdges) {
relevantEdges.push(edge);
if (!nodeIds.has(edge.to_node)) {
nodeIds.add(edge.to_node);
queue.push(edge.to_node);
}
}
}
return {
nodes: nodes.filter(node => nodeIds.has(node.id)),
edges: relevantEdges,
};
}
The drag lag was a real problem with a known cause. The fix just belonged to a different phase.
Key Commits from This Phase
The performance investigation was local and reset – documented before the commit
was cleared so it could be reimplemented. Render improvements were reintroduced
incrementally in subsequent commits; the drag lag fix landed in Phase 7
(016a6b0).