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 return map
}) })
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
const lastCompactionIndex = createMemo(() => { const lastCompactionIndex = createMemo(() => {
// Depend on a single session revision signal (not every message/part read) // Depend on a single session revision signal (not every message/part read)
// to keep reactive overhead small. // to keep reactive overhead small.
@@ -315,15 +317,9 @@ export default function MessageSection(props: MessageSectionProps) {
} }
const lastAssistantIndex = createMemo(() => { const lastAssistantIndex = createMemo(() => {
const ids = messageIds() const messageId = lastAssistantMessageId()
const resolvedStore = store() if (!messageId) return -1
for (let index = ids.length - 1; index >= 0; index--) { return messageIndexById().get(messageId) ?? -1
const record = resolvedStore.getMessage(ids[index])
if (record?.role === "assistant") {
return index
}
}
return -1
}) })
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([]) const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
@@ -734,6 +730,10 @@ export default function MessageSection(props: MessageSectionProps) {
const loading = Boolean(props.loading) const loading = Boolean(props.loading)
const ids = messageIds() const ids = messageIds()
// 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) { if (loading) {
handleClearTimelineSelection() handleClearTimelineSelection()
previousTimelineIds = [] previousTimelineIds = []
@@ -751,13 +751,13 @@ export default function MessageSection(props: MessageSectionProps) {
if (previousTimelineIds.length === 0 && ids.length > 0) { if (previousTimelineIds.length === 0 && ids.length > 0) {
seedTimeline() seedTimeline()
previousTimelineIds = ids.slice() previousTimelineIds = [...ids]
return return
} }
if (ids.length < previousTimelineIds.length) { if (ids.length < previousTimelineIds.length) {
seedTimeline() seedTimeline()
previousTimelineIds = ids.slice() previousTimelineIds = [...ids]
return return
} }
@@ -795,7 +795,7 @@ export default function MessageSection(props: MessageSectionProps) {
timelinePartCountsByMessageId.set(newId, existingPartCount) timelinePartCountsByMessageId.set(newId, existingPartCount)
} }
previousTimelineIds = ids.slice() previousTimelineIds = [...ids]
return return
} }
} }
@@ -815,7 +815,8 @@ export default function MessageSection(props: MessageSectionProps) {
}) })
} }
previousTimelineIds = ids.slice() previousTimelineIds = [...ids]
})
}) })
function clearPendingTimelinePartUpdateFrame() { function clearPendingTimelinePartUpdateFrame() {
@@ -886,9 +887,20 @@ export default function MessageSection(props: MessageSectionProps) {
createEffect(() => { createEffect(() => {
if (props.loading) return if (props.loading) return
const ids = messageIds() 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()
// 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 let hasChanges = false
for (const messageId of ids) { for (const messageId of ids) {
const record = resolvedStore.getMessage(messageId) const record = resolvedStore.getMessage(messageId)
const partCount = record?.partIds.length ?? 0 const partCount = record?.partIds.length ?? 0
@@ -907,8 +919,9 @@ export default function MessageSection(props: MessageSectionProps) {
} }
// Drop tracking for ids that are no longer present. // 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())) { for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
if (!ids.includes(trackedId)) { if (!idsSet.has(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId) timelinePartCountsByMessageId.delete(trackedId)
} }
} }
@@ -917,6 +930,7 @@ export default function MessageSection(props: MessageSectionProps) {
scheduleTimelinePartUpdateFlush() scheduleTimelinePartUpdateFlush()
} }
}) })
})
createEffect(() => { createEffect(() => {
if (!props.onQuoteSelection) { if (!props.onQuoteSelection) {

View File

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

View File

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