Skip to content

Commit 04ed053

Browse files
committed
Cache flattenNodes() in View to eliminate redundant tree walks
During token streaming, every tree update caused multiple O(n) tree traversals: once inside the View's _onTreeUpdate(), then again for each React hook subscriber calling view.flattenNodes(). With rapid streaming tokens this multiplied into a significant performance cost. The View now caches the flattenNodes() result in a _cachedNodes field. The public flattenNodes() returns this cache in O(1). A new private _computeFlatNodes() method performs the actual tree walk and is called only when the visible output may have changed: tree structural changes, branch selection changes, fork auto-selection, and history page reveal. The original design avoided caching ("no cache invalidation complexity, at the cost of repeated traversals") because the result depends on branch selection state. Caching is safe here because every mutation path that can change the visible output refreshes the cache synchronously before emitting events to consumers - JS single-threading eliminates async staleness risks.
1 parent 85506b4 commit 04ed053

File tree

4 files changed

+53
-12
lines changed

4 files changed

+53
-12
lines changed

docs/internals/glossary.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ A codec-provided component that assembles [decoder outputs](decoder.md#decoder-o
118118

119119
### Message materialization
120120

121-
The act of producing a flat message list from the [conversation tree](conversation-tree.md) via [`flattenNodes()`](#flatten). `flattenNodes()` returns `MessageNode<TMessage>[]`. Every call rebuilds from scratch - there is no cached list - because the result depends on branch selection state. All consumers go through the view's `flattenNodes()`: React hooks, `send()` (for the HTTP POST body), `view.loadOlder()` (for pagination snapshots). See [Message lifecycle](message-lifecycle.md#why-no-cached-message-list).
121+
The act of producing a flat message list from the [conversation tree](conversation-tree.md) via [`flattenNodes()`](#flatten). `flattenNodes()` returns `MessageNode<TMessage>[]`. The View caches the result and returns it in O(1) on subsequent calls. The cache is refreshed when the tree structure changes (new nodes, deletions, selection changes, history reveal). All consumers go through the view's `flattenNodes()`: React hooks, `send()` (for the HTTP POST body), `view.loadOlder()` (for pagination snapshots). See [Message lifecycle](message-lifecycle.md#cached-message-list).
122122

123123
### Flatten
124124

125-
`view.flattenNodes()` - the sole path from tree state to a message array. (`flattenNodes()` is internal to `TreeInternal`, not on the public `Tree` interface.) Walks the sorted node list, checks parent reachability and sibling selection, and returns the linear message sequence for the currently selected conversation path. See [Conversation tree: flatten](conversation-tree.md#flatten-producing-the-linear-path).
125+
`view.flattenNodes()` - the sole path from tree state to a message array. Returns the View's cached node list in O(1). The cache is rebuilt by an internal `_computeFlatNodes()` method that walks the sorted node list, checks parent reachability and sibling selection, and produces the linear message sequence for the currently selected conversation path. (`flattenNodes()` on `TreeInternal` does the actual tree walk; the View's public method returns cached results.) See [Conversation tree: flatten](conversation-tree.md#flatten-producing-the-linear-path).

docs/internals/message-lifecycle.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,18 @@ Every `'update'` event triggers a full `flattenNodes()` call, which rebuilds the
118118

119119
Each turn needs its own accumulator because events from interleaved concurrent turns would corrupt each other's message assembly - a text-delta from turn A would be accumulated into turn B's message.
120120

121-
## Why no cached message list
121+
## Cached message list
122122

123-
The tree is a DAG with branch selection state. The "current conversation" depends on which sibling is selected at each fork point. There is no single cached `TMessage[]` - every call to `flattenNodes()` rebuilds from scratch.
123+
The View caches the result of `flattenNodes()` in a `_cachedNodes` field. The public `flattenNodes()` method returns this cache in O(1). The cache is refreshed by `_computeFlatNodes()` - a private method that performs the actual tree walk - whenever the visible output may have changed:
124124

125-
This is a deliberate tradeoff: no cache invalidation complexity, at the cost of repeated traversals. Since message counts are conversation-sized (tens to low hundreds), this is cheap.
125+
| Trigger | What refreshes the cache |
126+
| ------------------------------------------------------------- | ----------------------------------------------------- |
127+
| Tree structural change (new node, deletion, serial promotion) | `_onTreeUpdate()` calls `_computeFlatNodes()` |
128+
| Branch selection change | `select()` calls `_computeFlatNodes()` |
129+
| Fork auto-selection after `send()` | `send()` auto-select path calls `_computeFlatNodes()` |
130+
| History page revealed | `_releaseWithheld()` calls `_computeFlatNodes()` |
126131

127-
All consumers go through `view.flattenNodes()`:
132+
All consumers go through the cached `view.flattenNodes()`:
128133

129134
| Consumer | When it calls `flattenNodes()` |
130135
| ----------------------------------------- | ------------------------------------------------- |
@@ -133,4 +138,6 @@ All consumers go through `view.flattenNodes()`:
133138
| `send()` / `regenerate()` | To build the HTTP POST body's message history |
134139
| `view.loadOlder()` | To snapshot the current tree state for pagination |
135140

141+
Because all consumers read the cache, a tree update triggers one tree walk (inside the View), not one per consumer. React hooks calling `flattenNodes()` after an `'update'` event get the pre-computed result without a redundant traversal.
142+
136143
See [Conversation tree](conversation-tree.md) for how `flattenNodes()` works. See [Codec interface](codec-interface.md#accumulator) for the accumulator's role. See [History hydration](history.md) for the history decode pipeline.

src/core/transport/view.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
145145
/** Unsubscribe functions for tree event subscriptions. */
146146
private readonly _unsubs: (() => void)[] = [];
147147

148+
/**
149+
* Cached result of the last flattenNodes computation. Public `flattenNodes()`
150+
* returns this in O(1); internal callers use `_computeFlatNodes()` when a
151+
* fresh tree walk is needed (structural changes, selection changes, history reveal).
152+
*/
153+
private _cachedNodes: MessageNode<TMessage>[] = [];
154+
148155
private _loadingOlder = false;
149156
private _processingHistory = false;
150157
private _closed = false;
@@ -159,8 +166,9 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
159166
this._logger.trace('DefaultView();');
160167
this._emitter = new EventEmitter<ViewEventsMap>(this._logger);
161168

162-
// Snapshot initial visible state
163-
this._updateVisibleSnapshot();
169+
// Compute initial cache and snapshot visible state
170+
this._cachedNodes = this._computeFlatNodes();
171+
this._updateVisibleSnapshot(this._cachedNodes);
164172

165173
// Subscribe to tree events and re-emit scoped versions
166174
this._unsubs.push(
@@ -186,6 +194,17 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
186194

187195
// Spec: AIT-CT9, AIT-CT11c
188196
flattenNodes(): MessageNode<TMessage>[] {
197+
return this._cachedNodes;
198+
}
199+
200+
/**
201+
* Walk the tree and compute a fresh visible node list, applying branch
202+
* selections and withheld-message filtering. Use this instead of the
203+
* public `flattenNodes()` when the cache may be stale (structural
204+
* changes, selection changes, history reveal).
205+
* @returns A fresh array of visible nodes.
206+
*/
207+
private _computeFlatNodes(): MessageNode<TMessage>[] {
189208
const nodes = this._tree.flattenNodes(this._resolveSelections());
190209
if (this._withheldMsgIds.size === 0) return nodes;
191210
return nodes.filter((n) => !this._withheldMsgIds.has(n.msgId));
@@ -255,7 +274,8 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
255274
if (!selected) return; // unreachable: clamped is always in bounds
256275
this._branchSelections.set(groupRootId, { kind: 'user', selectedId: selected.msgId });
257276
this._logger.debug('DefaultView.select();', { msgId, index: clamped, selectedId: selected.msgId });
258-
this._updateVisibleSnapshot();
277+
this._cachedNodes = this._computeFlatNodes();
278+
this._updateVisibleSnapshot(this._cachedNodes);
259279
this._emitter.emit('update');
260280
}
261281

@@ -311,7 +331,8 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
311331
const lastMsgId = result.optimisticMsgIds.at(-1);
312332
if (lastMsgId) {
313333
this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: lastMsgId });
314-
this._updateVisibleSnapshot();
334+
this._cachedNodes = this._computeFlatNodes();
335+
this._updateVisibleSnapshot(this._cachedNodes);
315336
this._emitter.emit('update');
316337
}
317338
} else {
@@ -584,7 +605,8 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
584605
this._withheldMsgIds.delete(n.msgId);
585606
}
586607
if (nodes.length > 0) {
587-
this._updateVisibleSnapshot();
608+
this._cachedNodes = this._computeFlatNodes();
609+
this._updateVisibleSnapshot(this._cachedNodes);
588610
this._emitter.emit('update');
589611
}
590612
}
@@ -621,10 +643,11 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
621643
this._pinBranchSelections();
622644
this._resolvePendingSelections();
623645

624-
const nodes = this.flattenNodes();
646+
const nodes = this._computeFlatNodes();
625647
const newIds = nodes.map((n) => n.msgId);
626648
const newMessages = nodes.map((n) => n.message);
627649
if (this._visibleChanged(newIds, newMessages)) {
650+
this._cachedNodes = nodes;
628651
this._updateVisibleSnapshot(nodes);
629652
this._emitter.emit('update');
630653
}

test/core/transport/view.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,17 @@ describe('DefaultView', () => {
11761176
// -------------------------------------------------------------------------
11771177

11781178
describe('flattenNodes caching and reference stability', () => {
1179+
it('returns the same array reference on consecutive calls without intervening changes', () => {
1180+
tree.upsert('m1', { id: '1', content: 'hi' }, makeHeaders('m1'), 'serial-1');
1181+
1182+
const ref1 = view.flattenNodes();
1183+
const ref2 = view.flattenNodes();
1184+
1185+
// flattenNodes() should return a cached result - the same array
1186+
// reference - when nothing has changed between calls.
1187+
expect(ref2).toBe(ref1);
1188+
});
1189+
11791190
it('preserves unchanged message references after a content-only update', () => {
11801191
const msg1 = { id: '1', content: 'stable' };
11811192
const msg2 = { id: '2', content: 'will-change' };

0 commit comments

Comments
 (0)