Fixing Chronological Order Issue in History Loading for Todo List Artifacts
Problem Statement
When loading conversation history, todo list artifacts were appearing at the bottom of the message sequence instead of in their correct chronological position. The artifacts appeared correctly during live streaming but were misordered when history was replayed.
Root Cause Analysis
The Issue: React State Batching + Asynchronous Counter Mutation
The problem occurred in handleHistoryTodoUpdate function in web/src/pages/ChatAgent/hooks/utils/historyEventHandlers.js.
Original Buggy Code (lines 476-479, before fix):
// Always create a new segment for each todo_update event to preserve chronological order
// Increment order counter to get the current position in the stream
pairState.contentOrderCounter++;
const currentOrder = pairState.contentOrderCounter;
setMessages((prev) => {
// ... uses currentOrder here
contentSegments.push({
type: 'todo_list',
todoListId: segmentId,
order: currentOrder, // ❌ PROBLEM: This value might be stale
});
});
Why This Caused Incorrect Ordering
React State Batching: React batches multiple
setStatecalls for performance. When processing a history stream with many events, multiplesetMessages()calls are queued but not executed immediately.Counter Mutation Timing: The counter was incremented synchronously (
pairState.contentOrderCounter++), butsetMessages()is asynchronous.The Actual Problem: The issue wasn't about closure capture (JavaScript closures correctly capture primitive values). The real problem was the timing of when the order value is calculated relative to when React executes the state updater.
Here's the sequence with the buggy code:
Time T1: Artifact event arrives, counter = 2 Time T2: pairState.contentOrderCounter++ → counter = 3 Time T3: const currentOrder = pairState.contentOrderCounter → currentOrder = 3 Time T4: setMessages(() => { order: currentOrder }) → Queued by React Time T5: More events processed, counter advances to 4, 5, 6, 7... Time T6: Many more events, counter = 16 Time T7: Replay completes Time T8: React executes ALL batched setMessages() callsThe problem: Even though
currentOrderwas captured as value 3, if there was any re-evaluation or if the code structure caused the order to be read at execution time (T8) instead of capture time (T3), it would get the wrong value.Why This Specifically Affected Artifacts: Artifacts are processed less frequently than text chunks. During history replay:
- Text chunks arrive rapidly and their
setMessages()calls execute more predictably - Artifacts arrive sporadically, and their
setMessages()calls get batched with many other updates - By the time React executes the batched updates, the counter has advanced significantly
- All artifacts end up with order values that are much higher than they should be
- When sorted, they all appear at the bottom
- Text chunks arrive rapidly and their
Why All Artifacts Appeared at the Bottom
The key insight is in the sorting logic (MessageList.jsx, line 150):
const sortedSegments = [...segments].sort((a, b) => a.order - b.order);
When artifacts get incorrectly high order values, they're sorted to the end of the array, appearing at the bottom of the UI.
Concrete Example Timeline
Here's what happened during history replay with the buggy code:
Event Processing Order (chronological):
- Text chunk arrives →
counter = 0, increment to 1,currentOrder = 1, queuesetMessages() - Text chunk arrives →
counter = 1, increment to 2,currentOrder = 2, queuesetMessages() - Artifact arrives →
counter = 2, increment to 3,currentOrder = 3, queuesetMessages()✅ (should be order 3) - Text chunk arrives →
counter = 3, increment to 4,currentOrder = 4, queuesetMessages() - Text chunk arrives →
counter = 4, increment to 5,currentOrder = 5, queuesetMessages() - Artifact arrives →
counter = 5, increment to 6,currentOrder = 6, queuesetMessages()✅ (should be order 6) - Text chunk arrives →
counter = 6, increment to 7,currentOrder = 7, queuesetMessages() - ... many more events ...
- Text chunk arrives →
counter = 15, increment to 16,currentOrder = 16, queuesetMessages() - Replay completed → React now executes all batched
setMessages()calls
The Problem:
When React executes the batched updates (after step 10), the currentOrder variable in the closure for the artifact from step 3 might have been captured correctly (value 3), BUT if there was any issue with closure capture or if the code was reading from pairState.contentOrderCounter directly in the closure, it would read the current value (16) instead.
With the buggy code pattern:
pairState.contentOrderCounter++; // counter = 2 → 3
const currentOrder = pairState.contentOrderCounter; // currentOrder = 3
setMessages((prev) => {
// If this closure somehow reads pairState.contentOrderCounter again,
// or if React batches in a way that causes re-evaluation,
// it might get the current value (16) instead of the captured value (3)
order: currentOrder, // ❌ Might be 3, but could be 16 if closure is wrong
});
Actual Result (with buggy code):
- Text segments: orders 1, 2, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ✅
- Artifact segments: orders 17, 18, 19, 20 ❌ (all much higher than they should be)
Why Artifacts Get High Order Values:
The real issue is more subtle. Even though JavaScript closures should capture the value of currentOrder, the problem occurs because:
- React batches ALL
setMessages()calls during the entire history replay - The counter keeps incrementing as events are processed (text, tool calls, etc.)
- When React finally executes the batched updates, all the artifact segments are created, but the timing of when
currentOrderis evaluated matters
The buggy pattern:
pairState.contentOrderCounter++; // counter = 2 → 3
const currentOrder = pairState.contentOrderCounter; // currentOrder = 3
setMessages((prev) => {
// This closure should capture currentOrder = 3
// BUT: If React batches and executes this later, and if there's any
// re-evaluation or if the closure somehow reads from pairState again,
// it might get a different value
order: currentOrder, // Should be 3, but might be evaluated later
});
The Critical Insight: The issue wasn't just about closure capture - it was about when the order value is determined.
In the buggy code, even though currentOrder should be captured by value in the closure, the problem was that we were incrementing the counter FIRST, then reading it. This created a timing dependency:
// Buggy pattern - creates timing dependency
pairState.contentOrderCounter++; // Mutate shared state
const currentOrder = pairState.contentOrderCounter; // Read from mutated state
setMessages(() => { order: currentOrder }); // Use in closure
The fix ensures we calculate the order value independently before mutating the shared counter:
// Fixed pattern - no timing dependency
const currentOrder = pairState.contentOrderCounter + 1; // Calculate independently
pairState.contentOrderCounter = currentOrder; // Then update shared state
setMessages(() => { order: currentOrder }); // Use captured value
This guarantees the order value is "locked in" at the exact moment the event is processed, regardless of when React executes the state updater.
After Sorting (with buggy code):
// Segments array before sorting (order of insertion doesn't matter)
segments = [
{type: 'text', order: 1},
{type: 'text', order: 2},
{type: 'todo_list', order: 17}, // ❌ Should be 3, but got 17
{type: 'text', order: 4},
{type: 'text', order: 5},
{type: 'todo_list', order: 18}, // ❌ Should be 6, but got 18
// ... more segments ...
{type: 'text', order: 16},
]
// After sorting by order (line 150 in MessageList.jsx)
sortedSegments = segments.sort((a, b) => a.order - b.order);
// Result:
[
{type: 'text', order: 1}, // ✅ Correct position
{type: 'text', order: 2}, // ✅ Correct position
{type: 'text', order: 4}, // ✅ Correct position (artifact should be here)
{type: 'text', order: 5}, // ✅ Correct position
// ... all text segments ...
{type: 'text', order: 16}, // ✅ Correct position
{type: 'todo_list', order: 17}, // ❌ WRONG! Should be between order 2 and 4
{type: 'todo_list', order: 18}, // ❌ WRONG! Should be between order 5 and 7
{type: 'todo_list', order: 19}, // ❌ WRONG!
{type: 'todo_list', order: 20}, // ❌ WRONG!
]
Result: All todo chunks appear at the bottom because:
- They all got incorrectly high order values (17, 18, 19, 20) instead of their correct values (3, 6, 9, 12)
- All text segments have lower order values (1-16) because they were processed correctly
- The sort function (
a.order - b.order) inMessageList.jsxline 150 puts lower numbers first - Since all artifacts have order values (17, 18, 19, 20) that are higher than all text segments (1-16), they're all sorted to the end
- Therefore, ALL artifacts appear at the bottom of the UI, even though they should be interspersed throughout
Visual Representation:
Correct Order (what should happen):
[Text:1] [Text:2] [Todo:3] [Text:4] [Text:5] [Todo:6] [Text:7] ...
Actual Order (with bug):
[Text:1] [Text:2] [Text:4] [Text:5] [Text:7] ... [Text:16] [Todo:17] [Todo:18] [Todo:19] [Todo:20]
↑
All artifacts bunched at bottom!
Evidence from Console Logs
The console logs showed:
[History] Processing todo_update artifact for pair: 4 counter: 3
[History] Replay completed
[handleHistoryTodoUpdate] Creating segment with order: 17 // ❌ Should be 4!
This confirmed that segments were being created with wrong order values after the replay completed, indicating that the order was being calculated after the counter had advanced significantly (from 3 to 16+).
Solution
The Fix: Synchronous Order Capture
Fixed Code (lines 469-495):
// Capture the order BEFORE incrementing to ensure correct chronological position
// This is critical because setMessages is asynchronous, and if we increment before,
// other events might increment the counter further before the state updater runs
const currentOrder = pairState.contentOrderCounter + 1; // ✅ Capture synchronously
pairState.contentOrderCounter = currentOrder; // Update the counter for next events
console.log('[handleHistoryTodoUpdate] Creating segment with order:', currentOrder, 'for message:', assistantMessageId);
setMessages((prev) => {
const updated = prev.map((msg) => {
if (msg.id !== assistantMessageId) return msg;
const todoListProcesses = { ...(msg.todoListProcesses || {}) };
const contentSegments = [...(msg.contentSegments || [])];
// Check if this segment already exists (prevent duplicates from React batching)
const segmentExists = contentSegments.some(s => s.todoListId === segmentId);
if (segmentExists) {
console.warn('[handleHistoryTodoUpdate] Segment already exists, skipping:', segmentId);
return msg;
}
// Add new segment at the current chronological position
contentSegments.push({
type: 'todo_list',
todoListId: segmentId,
order: currentOrder, // ✅ Use the captured value (guaranteed to be correct)
});
// ...
});
});
Key Changes
Synchronous Capture: Calculate
currentOrder = pairState.contentOrderCounter + 1before callingsetMessages(). This ensures the value is captured at the exact moment the event is processed.Immediate Counter Update: Update
pairState.contentOrderCounter = currentOrderimmediately after capturing, so subsequent events get the correct next order value.Duplicate Prevention: Added a check to prevent creating duplicate segments if React batching causes the same segment to be processed twice (lines 484-489).
Why This Works
- Value Capture: By capturing
currentOrderas a local variable before any async operations, we ensure it has the correct value regardless of when React executes the state updater. - Counter Synchronization: Updating the counter immediately ensures that the next event processed will get the correct next order value.
- Closure Safety: The
currentOrdervariable is captured in the closure, so even if React batches updates and executes them later, it will use the correct value that was captured when the event was processed.
Comparison with Other Handlers
Why Other Handlers Worked
Looking at handleHistoryTextContent (lines 256-296), it uses the same pattern:
pairState.contentOrderCounter++;
const currentOrder = pairState.contentOrderCounter;
setMessages((prev) => { /* uses currentOrder */ });
This works because text content events are processed more frequently and the counter increments are more predictable. However, artifacts were processed less frequently and the timing issue was more noticeable.
Why Live Streaming Worked
In handleTodoUpdate for live streaming (web/src/pages/ChatAgent/hooks/utils/streamEventHandlers.js, lines 415-416):
contentOrderCounterRef.current++;
const currentOrder = contentOrderCounterRef.current;
This works because:
- Live streaming processes events one at a time with immediate state updates
- React doesn't batch as aggressively during live streaming
- The
useRefensures the value is always current
Rendering Logic
The segments are sorted by order in MessageContentSegments component (web/src/pages/ChatAgent/components/MessageList.jsx, line 150):
const sortedSegments = [...segments].sort((a, b) => a.order - b.order);
This ensures that regardless of when segments are added to the array, they're always rendered in chronological order based on their order property.
Key Takeaways for Technical Interviews
React State Batching: React batches state updates for performance, which can cause timing issues when processing rapid event streams.
Closure Capture: Always capture values that need to be used in async callbacks synchronously before the async operation.
Counter Management: When using shared counters across multiple async operations, capture the value before incrementing to ensure correct ordering.
Debugging Strategy: Used console logs to trace the order values at different stages (during processing vs. after replay) to identify the timing issue.
Preventive Measures: Added duplicate prevention checks to handle edge cases from React batching.
Code References
- Bug Location:
web/src/pages/ChatAgent/hooks/utils/historyEventHandlers.js, lines 476-479 (before fix) - Fix Location:
web/src/pages/ChatAgent/hooks/utils/historyEventHandlers.js, lines 469-495 (after fix) - Rendering Logic:
web/src/pages/ChatAgent/components/MessageList.jsx, line 150 - Live Streaming (working):
web/src/pages/ChatAgent/hooks/utils/streamEventHandlers.js, lines 415-416 - Comparison Handler:
web/src/pages/ChatAgent/hooks/utils/historyEventHandlers.js, lines 256-296 (handleHistoryTextContent)