perf(ui): fix O(n²) reactive subscriptions in timeline effects (HUGE SPEED IMPROVEMENT) (#274)

## Summary

- Wraps store-proxied array iteration in `untrack()` in two
`createEffect` blocks and one `createMemo` in `message-section.tsx` to
prevent SolidJS from creating O(n) per-element reactive subscriptions on
every run
- Replaces `ids.includes()` with `Set.has()` for O(1) cleanup lookups in
the part-count tracking effect

## Problem

Two `createEffect` blocks in `message-section.tsx` iterate the
`messageIds()` store proxy array inside a tracked reactive context. This
causes SolidJS to create **O(n) per-element subscriptions** on every
run. When any element changes, all n subscriptions fire, re-running the
entire effect — resulting in **O(n²) total work**.

Additionally, the cleanup loop in the part-count tracking effect uses
`ids.includes(trackedId)` which is O(n) per tracked ID, compounding to
O(n²).

For long-running sessions with large message history (e.g. 7569
messages), this caused **~4.8 seconds of input latency** when sending a
new prompt.

## Fix

1. **Timeline sync effect (~line 738):** Wrap entire body in
`untrack()`, replace `ids.slice()` with `[...ids]` to snapshot without
proxy tracking
2. **Part-count tracking effect (~line 891):** Wrap iteration in
`untrack()`, replace `ids.includes()` with `new Set(ids).has()` for O(1)
lookups
3. **`lastAssistantIndex` memo:** Read message records via `untrack()`
to avoid O(n) subscriptions on part-level updates

## Result

On a 7569-message session: prompt input latency reduced from **~4.8s to
~42ms** (114x improvement).
This commit is contained in:
Pascal André
2026-04-04 00:01:13 +02:00
committed by GitHub
parent 259d457209
commit 141be2cde0
3 changed files with 169 additions and 118 deletions

View File

@@ -129,6 +129,8 @@ export default function MessageSection(props: MessageSectionProps) {
return map
})
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
const lastCompactionIndex = createMemo(() => {
// Depend on a single session revision signal (not every message/part read)
// to keep reactive overhead small.
@@ -315,15 +317,9 @@ export default function MessageSection(props: MessageSectionProps) {
}
const lastAssistantIndex = createMemo(() => {
const ids = messageIds()
const resolvedStore = store()
for (let index = ids.length - 1; index >= 0; index--) {
const record = resolvedStore.getMessage(ids[index])
if (record?.role === "assistant") {
return index
}
}
return -1
const messageId = lastAssistantMessageId()
if (!messageId) return -1
return messageIndexById().get(messageId) ?? -1
})
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
@@ -734,88 +730,93 @@ export default function MessageSection(props: MessageSectionProps) {
const loading = Boolean(props.loading)
const ids = messageIds()
if (loading) {
handleClearTimelineSelection()
previousTimelineIds = []
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
return
}
if (previousTimelineIds.length === 0 && ids.length > 0) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length === previousTimelineIds.length) {
let changedIndex = -1
let changeCount = 0
for (let index = 0; index < ids.length; index++) {
if (ids[index] !== previousTimelineIds[index]) {
changedIndex = index
changeCount += 1
if (changeCount > 1) break
// Wrap all iteration of the store-proxied `ids` array in untrack()
// to prevent O(n) per-element reactive subscriptions. The effect
// only needs to re-run when `messageIds` (memo) changes.
untrack(() => {
if (loading) {
handleClearTimelineSelection()
previousTimelineIds = []
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
return
}
if (changeCount === 1 && changedIndex >= 0) {
const oldId = previousTimelineIds[changedIndex]
const newId = ids[changedIndex]
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
seenTimelineMessageIds.delete(oldId)
seenTimelineMessageIds.add(newId)
setTimelineSegments((prev) => {
const next = prev.map((segment) => {
if (segment.messageId !== oldId) return segment
const updatedId = segment.id.replace(oldId, newId)
return { ...segment, messageId: newId, id: updatedId }
})
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
// Keep part count tracking in sync with id replacement.
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
if (previousTimelineIds.length === 0 && ids.length > 0) {
seedTimeline()
previousTimelineIds = [...ids]
return
}
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = [...ids]
return
}
if (ids.length === previousTimelineIds.length) {
let changedIndex = -1
let changeCount = 0
for (let index = 0; index < ids.length; index++) {
if (ids[index] !== previousTimelineIds[index]) {
changedIndex = index
changeCount += 1
if (changeCount > 1) break
}
}
if (changeCount === 1 && changedIndex >= 0) {
const oldId = previousTimelineIds[changedIndex]
const newId = ids[changedIndex]
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
seenTimelineMessageIds.delete(oldId)
seenTimelineMessageIds.add(newId)
setTimelineSegments((prev) => {
const next = prev.map((segment) => {
if (segment.messageId !== oldId) return segment
const updatedId = segment.id.replace(oldId, newId)
return { ...segment, messageId: newId, id: updatedId }
})
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
previousTimelineIds = ids.slice()
return
// Keep part count tracking in sync with id replacement.
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
}
previousTimelineIds = [...ids]
return
}
}
}
}
const newIds: string[] = []
ids.forEach((id) => {
if (!seenTimelineMessageIds.has(id)) {
newIds.push(id)
}
})
if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
const newIds: string[] = []
ids.forEach((id) => {
if (!seenTimelineMessageIds.has(id)) {
newIds.push(id)
}
})
}
previousTimelineIds = ids.slice()
if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
})
}
previousTimelineIds = [...ids]
})
})
function clearPendingTimelinePartUpdateFrame() {
@@ -886,36 +887,49 @@ export default function MessageSection(props: MessageSectionProps) {
createEffect(() => {
if (props.loading) return
const ids = messageIds()
const resolvedStore = store()
// Also re-run when sessionRevision bumps (covers part additions within
// existing messages) but read individual records inside untrack() to
// avoid creating O(n) fine-grained subscriptions.
sessionRevision()
let hasChanges = false
for (const messageId of ids) {
const record = resolvedStore.getMessage(messageId)
const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
// Wrap the iteration in untrack() so that accessing individual elements
// of the store-proxied `ids` array does not create O(n) per-element
// reactive subscriptions. We only need to re-run when the memo
// (messageIds) or sessionRevision changes — not per-element.
untrack(() => {
const resolvedStore = store()
const idsSet = new Set(ids)
let hasChanges = false
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
for (const messageId of ids) {
const record = resolvedStore.getMessage(messageId)
const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
}
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
// Drop tracking for ids that are no longer present.
// Use the Set for O(1) lookups instead of ids.includes() which is O(n).
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
if (!idsSet.has(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
}
}
}
// Drop tracking for ids that are no longer present.
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
if (!ids.includes(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
}
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
})
})
createEffect(() => {

View File

@@ -33,6 +33,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
sessions: {},
sessionOrder: [],
messages: {},
lastAssistantMessageIds: {},
messageInfoVersion: {},
pendingParts: {},
sessionRevisions: {},
@@ -218,6 +219,7 @@ export interface InstanceMessageStore {
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
getSessionRevision: (sessionId: string) => number
getSessionMessageIds: (sessionId: string) => string[]
getLastAssistantMessageId: (sessionId: string) => string | undefined
// Index of the most recent message in the session that contains a compaction part.
// Returns -1 if there has been no compaction.
getLastCompactionMessageIndex: (sessionId: string) => number
@@ -234,6 +236,21 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
const messageInfoCache = new Map<string, MessageInfo>()
function findLastAssistantMessageId(messageIds: readonly string[]): string | undefined {
for (let index = messageIds.length - 1; index >= 0; index -= 1) {
const messageId = messageIds[index]
if (state.messages[messageId]?.role === "assistant") {
return messageId
}
}
return undefined
}
function recomputeLastAssistantMessageId(sessionId: string, messageIds?: readonly string[]) {
if (!sessionId) return
setState("lastAssistantMessageIds", sessionId, findLastAssistantMessageId(messageIds ?? state.sessions[sessionId]?.messageIds ?? []))
}
function getLastCompactionMessageIndex(sessionId: string): number {
if (!sessionId) return -1
const ids = state.sessions[sessionId]?.messageIds ?? []
@@ -306,6 +323,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return state.sessionRevisions[sessionId] ?? 0
}
function getLastAssistantMessageIdValue(sessionId: string) {
return state.lastAssistantMessageIds[sessionId]
}
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
setState("usage", sessionId, (current) => {
const draft = current
@@ -375,6 +396,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
})
if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) {
recomputeLastAssistantMessageId(input.id, nextMessageIds)
bumpSessionRevision(input.id)
}
}
@@ -445,6 +467,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
messageIds: incomingIds,
updatedAt: Date.now(),
}))
recomputeLastAssistantMessageId(sessionId, incomingIds)
Object.values(normalizedRecords).forEach((record) => {
maybeUpdateLatestTodoFromRecord(record)
@@ -516,6 +539,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
insertMessageIntoSession(input.sessionId, input.id)
flushPendingParts(input.id)
recomputeLastAssistantMessageId(input.sessionId)
bumpSessionRevision(input.sessionId)
}
@@ -730,6 +754,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
if (state.latestTodos[sessionId]?.messageId === messageId) {
clearLatestTodoSnapshot(sessionId)
}
recomputeLastAssistantMessageId(sessionId)
bumpSessionRevision(sessionId)
})
})
@@ -816,7 +841,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
affectedSessions.add(session.id)
})
affectedSessions.forEach((sessionId) => bumpSessionRevision(sessionId))
affectedSessions.forEach((sessionId) => {
recomputeLastAssistantMessageId(sessionId)
bumpSessionRevision(sessionId)
})
const infoEntry = messageInfoCache.get(options.oldId)
if (infoEntry) {
@@ -1037,6 +1065,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
removedIds.forEach((id) => removeUsageEntry(draft, id))
})
recomputeLastAssistantMessageId(sessionId, keptIds)
bumpSessionRevision(sessionId)
}
@@ -1128,6 +1157,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return next
})
setState("lastAssistantMessageIds", (prev) => {
const next = { ...prev }
delete next[sessionId]
return next
})
setState("scrollState", (prev) => {
const next = { ...prev }
const prefix = `${sessionId}:`
@@ -1190,16 +1225,17 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setSessionRevert,
getSessionRevert,
rebuildUsage,
getSessionUsage,
setScrollSnapshot,
getScrollSnapshot,
getSessionRevision: getSessionRevisionValue,
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getLastCompactionMessageIndex,
getMessage: (messageId: string) => state.messages[messageId],
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
clearSession,
clearInstance,
}
}
rebuildUsage,
getSessionUsage,
setScrollSnapshot,
getScrollSnapshot,
getSessionRevision: getSessionRevisionValue,
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getLastAssistantMessageId: getLastAssistantMessageIdValue,
getLastCompactionMessageIndex,
getMessage: (messageId: string) => state.messages[messageId],
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
clearSession,
clearInstance,
}
}

View File

@@ -113,6 +113,7 @@ export interface InstanceMessageState {
sessions: Record<string, SessionRecord>
sessionOrder: string[]
messages: Record<string, MessageRecord>
lastAssistantMessageIds: Record<string, string | undefined>
messageInfoVersion: Record<string, number>
pendingParts: Record<string, PendingPartEntry[]>
sessionRevisions: Record<string, number>