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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user