Skip to content

Commit a1335fa

Browse files
author
Ali Alakbarli
committed
perf: use React 18 startTransition to prevent spinner freeze and infinite re-render loops
Root Cause Analysis: - React Query with superjson deserializes Date objects on each query result, producing new object references even when data is identical - This breaks structural sharing, causing useMemo dependencies to trigger unnecessarily - forceUpdate({}) synchronous calls blocked main thread, preventing CSS animation paint commits (spinner freeze) - Batch JSON.parse in useMemo blocked main thread for 50-200ms Changes: 1. mock-api.ts - Convert useStabilizedRef to useStableKey pattern - Returns stable string key instead of data reference - Key only changes when serialized data actually differs - Used as useMemo dependency while reading result.data directly inside 2. mock-api.ts - Add useStableKey to useSubChatMessagesBatch - Previously missing stabilization caused infinite re-render cascade - Stable key prevents unnecessary Map recreation 3. mock-api.ts - Wrap batch parsing in startTransition - Heavy JSON.parse now runs as low-priority update - React yields to paint commits, allowing spinners to animate smoothly - Uses useTransition hook with effect-based state update 4. active-chat.tsx - Remove synchronous forceUpdate({}) calls - Zustand's useShallow subscriptions already trigger re-renders - Synchronous updates blocked animation frames 5. active-chat.tsx - Fix new chat infinite loop condition - Original: messagesBySubChatIdRef.current.has(subChatId) returned true even for empty arrays - Fixed: check actual message content (lazyMessages.length > 0) - Prevents eviction loop when both cached and lazy-load have empty messages Result: Smooth spinner animations during loading, no infinite re-renders on new chat creation, stable data references across renders.
1 parent 1941e33 commit a1335fa

2 files changed

Lines changed: 80 additions & 48 deletions

File tree

‎src/renderer/features/agents/main/active-chat.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,15 @@ import { AnimatePresence, motion } from "motion/react"
5353
import {
5454
createContext,
5555
memo,
56+
startTransition,
5657
useCallback,
5758
useContext,
5859
useEffect,
5960
useLayoutEffect,
6061
useMemo,
6162
useRef,
62-
useState
63+
useState,
64+
useTransition
6365
} from "react"
6466
import { flushSync } from "react-dom"
6567
import { toast } from "sonner"
@@ -6676,9 +6678,11 @@ Make sure to preserve all functionality from both branches when resolving confli
66766678
if (!overrideProvider) {
66776679
// Check if cached Chat has 0 messages but lazy-load now has data
66786680
const existingMsgCount = Array.isArray((existing as any)?.messages) ? (existing as any).messages.length : 0
6679-
if (existingMsgCount === 0 && messagesBySubChatIdRef.current.has(subChatId)) {
6681+
const lazyMessages = messagesBySubChatIdRef.current.get(subChatId)
6682+
// Only evict if lazy-load has MORE messages than the cached Chat
6683+
// This prevents infinite loop when both have empty messages (new chat case)
6684+
if (existingMsgCount === 0 && lazyMessages && lazyMessages.length > 0) {
66806685
// Evict the empty Chat so it's recreated with messages below.
6681-
// Guard against infinite loop: only evict if we have actual messages.
66826686
agentChatStore.delete(subChatId)
66836687
} else {
66846688
return existing
@@ -6896,7 +6900,7 @@ Make sure to preserve all functionality from both branches when resolving confli
68966900
// Store streamId at creation time to prevent resume during active streaming
68976901
// tRPC refetch would update stream_id in DB, but store stays stable
68986902
agentChatStore.setStreamId(subChatId, subChat?.stream_id || null)
6899-
forceUpdate({}) // Trigger re-render to use new chat
6903+
// No forceUpdate needed - store subscription triggers re-render via Zustand's useShallow
69006904
return newChat
69016905
},
69026906
[
@@ -6940,7 +6944,7 @@ Make sure to preserve all functionality from both branches when resolving confli
69406944

69416945
// Force transport recreation with the newly selected provider.
69426946
agentChatStore.delete(subChatId)
6943-
forceUpdate({})
6947+
// No forceUpdate needed - store deletion triggers re-render via Zustand's useShallow
69446948
},
69456949
[agentSubChatsWithMessages],
69466950
)

‎src/renderer/lib/mock-api.ts‎

Lines changed: 71 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Wraps real tRPC calls and provides stubs for web-only features
44
*/
55

6-
import { useMemo, useRef } from "react"
6+
import { useEffect, useMemo, useRef, startTransition, useState, useTransition } from "react"
77
import { normalizeCodexToolPart } from "../../shared/codex-tool-normalizer"
88
import { trpc, trpcClient } from "./trpc"
99

@@ -18,27 +18,18 @@ type AnyObj = Record<string, any>
1818
* causing structural sharing to fail and result.data to get a new reference
1919
* even when the underlying data hasn't changed.
2020
*
21-
* This hook returns a stable reference to the input as long as a lightweight
22-
* serialization of it hasn't changed. Used as the dependency for useMemo
23-
* transforms so they only recompute when data actually changes.
21+
* Returns a stable string key that only changes when the serialized data
22+
* actually changes. The key is stored in a ref so it keeps the same identity
23+
* across renders when the data hasn't changed, making it safe as a useMemo dep.
2424
*/
25-
function useStabilizedRef<T>(input: T | undefined, serialize: (data: T) => string): T | undefined {
26-
const prevRef = useRef<{ raw: T | undefined; serialized: string }>({
27-
raw: undefined,
28-
serialized: "",
29-
})
30-
31-
if (!input) return undefined
32-
33-
const serialized = serialize(input)
34-
if (serialized === prevRef.current.serialized) {
35-
// Same data — return previous raw reference to preserve identity
36-
return prevRef.current.raw
25+
function useStableKey<T>(input: T | undefined, serialize: (data: T) => string): string {
26+
const serialized = input ? serialize(input) : ""
27+
const prevRef = useRef(serialized)
28+
// Only update the key when the serialized value actually changes
29+
if (serialized !== prevRef.current) {
30+
prevRef.current = serialized
3731
}
38-
39-
// Data actually changed — update ref and return new input
40-
prevRef.current = { raw: input, serialized }
41-
return input
32+
return prevRef.current
4233
}
4334

4435
// Message parse cache - prevents re-parsing same messages across renders
@@ -217,22 +208,23 @@ export const api = {
217208
// Use real tRPC
218209
const result = trpc.chats.list.useQuery({})
219210
// Ensure Date objects from Drizzle/superjson are converted to ISO strings
220-
// Use stabilized ref to prevent infinite re-renders when result.data
211+
// Use stable key to prevent infinite re-renders when result.data
221212
// gets a new reference but contains the same data (common with superjson Date deserialization)
222-
const stableData = useStabilizedRef(
213+
const dataKey = useStableKey(
223214
result.data,
224215
(data) => JSON.stringify(data.map((c: AnyObj) => [c.id, String(c.updatedAt)])),
225216
)
226217
const serialized = useMemo(() => {
227-
if (!stableData) return []
218+
if (!result.data) return []
228219
const serializeDate = (v: unknown) => v instanceof Date ? v.toISOString() : (v as string | null | undefined)
229-
return stableData.map((chat: AnyObj) => ({
220+
return result.data.map((chat: AnyObj) => ({
230221
...chat,
231222
createdAt: serializeDate(chat.createdAt),
232223
updatedAt: serializeDate(chat.updatedAt),
233224
archivedAt: serializeDate(chat.archivedAt),
234225
}))
235-
}, [stableData])
226+
// eslint-disable-next-line react-hooks/exhaustive-deps
227+
}, [dataKey])
236228
return {
237229
data: serialized,
238230
isLoading: result.isLoading,
@@ -254,15 +246,15 @@ export const api = {
254246

255247
// Lightweight transformation: messages are loaded on-demand per sub-chat
256248
// via getSubChatMessages to avoid JSON.parse on ALL sub-chats during workspace switch
257-
// Use stabilized ref to prevent infinite re-renders when result.data
249+
// Use stable key to prevent infinite re-renders when result.data
258250
// gets a new reference but contains the same data (common with superjson Date deserialization)
259-
const stableData = useStabilizedRef(
260-
result.data,
261-
(d) => JSON.stringify([d.id, String(d.updatedAt), d.subChats?.map((sc: AnyObj) => [sc.id, sc.updatedAt])]),
251+
const dataKey = useStableKey(
252+
result.data as AnyObj | undefined,
253+
(d: AnyObj) => JSON.stringify([d.id, String(d.updatedAt), d.subChats?.map((sc: AnyObj) => [sc.id, sc.updatedAt])]),
262254
)
263255
const transformedData = useMemo(() => {
264-
if (!stableData) return null
265-
const d = stableData
256+
if (!result.data) return null
257+
const d = result.data
266258
const serializeDate = (v: unknown) => v instanceof Date ? v.toISOString() : (v as string | null | undefined)
267259
return {
268260
...d,
@@ -298,7 +290,8 @@ export const api = {
298290
stream_id: null,
299291
})),
300292
}
301-
}, [stableData])
293+
// eslint-disable-next-line react-hooks/exhaustive-deps
294+
}, [dataKey])
302295

303296
return {
304297
data: transformedData,
@@ -309,20 +302,21 @@ export const api = {
309302
getArchivedChats: {
310303
useQuery: (_args?: AnyObj, _opts?: AnyObj) => {
311304
const result = trpc.chats.listArchived.useQuery({})
312-
const stableData = useStabilizedRef(
305+
const dataKey = useStableKey(
313306
result.data,
314307
(data) => JSON.stringify(data.map((c: AnyObj) => [c.id, String(c.updatedAt)])),
315308
)
316309
const serialized = useMemo(() => {
317-
if (!stableData) return []
310+
if (!result.data) return []
318311
const serializeDate = (v: unknown) => v instanceof Date ? v.toISOString() : (v as string | null | undefined)
319-
return stableData.map((chat: AnyObj) => ({
312+
return result.data.map((chat: AnyObj) => ({
320313
...chat,
321314
createdAt: serializeDate(chat.createdAt),
322315
updatedAt: serializeDate(chat.updatedAt),
323316
archivedAt: serializeDate(chat.archivedAt),
324317
}))
325-
}, [stableData])
318+
// eslint-disable-next-line react-hooks/exhaustive-deps
319+
}, [dataKey])
326320
return {
327321
data: serialized,
328322
isLoading: result.isLoading,
@@ -729,6 +723,8 @@ export const api = {
729723
* Uses batch endpoint to fetch messages for all tabsToRender IDs at once,
730724
* avoiding JSON.parse on ALL sub-chats during workspace switch while still
731725
* providing messages for every rendered tab (not just the active one).
726+
*
727+
* Uses startTransition to keep animations smooth during heavy parsing.
732728
*/
733729
export function useSubChatMessagesBatch(subChatIds: string[]) {
734730
const result = trpc.chats.getSubChatMessagesBatch.useQuery(
@@ -740,14 +736,46 @@ export function useSubChatMessagesBatch(subChatIds: string[]) {
740736
},
741737
)
742738

743-
const messagesBySubChatId = useMemo(() => {
744-
const map = new Map<string, AnyObj[]>()
745-
if (!result.data) return map
746-
for (const [id, messagesJson] of Object.entries(result.data)) {
747-
map.set(id, parseAndNormalizeMessages(messagesJson))
739+
// Use stable key to prevent infinite re-renders when result.data
740+
// gets a new reference but contains the same data (common with superjson Date deserialization)
741+
const dataKey = useStableKey(
742+
result.data,
743+
(data) => JSON.stringify(
744+
Object.entries(data)
745+
.map(([id, msgs]) => [id, msgs?.length ?? 0])
746+
.sort((a, b) => a[0].localeCompare(b[0]))
747+
),
748+
)
749+
750+
// Track previous dataKey to detect when data actually changed
751+
const prevDataKeyRef = useRef<string>("")
752+
// Store parsed messages - updated in startTransition to keep UI responsive
753+
const [messagesBySubChatId, setMessagesMap] = useState<Map<string, AnyObj[]>>(new Map())
754+
const [, startParseTransition] = useTransition()
755+
756+
// Parse messages in a transition when data changes
757+
// This allows React to yield to paint commits (animations) during heavy parsing
758+
useEffect(() => {
759+
if (!result.data) {
760+
setMessagesMap(new Map())
761+
prevDataKeyRef.current = ""
762+
return
748763
}
749-
return map
750-
}, [result.data])
764+
765+
// Skip if data hasn't actually changed
766+
if (dataKey === prevDataKeyRef.current) return
767+
prevDataKeyRef.current = dataKey
768+
769+
// Parse in startTransition so animations can paint during heavy work
770+
startParseTransition(() => {
771+
const map = new Map<string, AnyObj[]>()
772+
for (const [id, messagesJson] of Object.entries(result.data!)) {
773+
map.set(id, parseAndNormalizeMessages(messagesJson))
774+
}
775+
setMessagesMap(map)
776+
})
777+
// eslint-disable-next-line react-hooks/exhaustive-deps
778+
}, [dataKey])
751779

752780
return { messagesBySubChatId, isLoading: result.isLoading && subChatIds.length > 0 }
753781
}

0 commit comments

Comments
 (0)