Skip to content

Commit 1cb9c86

Browse files
authored
improvement(chat): speed up conversation navigation (#7011)
* improvement(chat): speed up conversation navigation * fix(chat): prefetch direct navigation intent * fix(chat): preserve quick-click prefetch intent
1 parent b44d285 commit 1cb9c86

12 files changed

Lines changed: 523 additions & 41 deletions

File tree

.claude/rules/sim-react-performance.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,19 @@ const [{ id }, { kbName }] = await Promise.all([params, searchParams])
9090

9191
Only keep awaits sequential when a later call genuinely uses an earlier result, or when the ordering is deliberate (rate-limited batches, retry loops, write-then-read).
9292

93+
## Prefetch dynamic destination lists on intent
94+
95+
For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume
96+
`router.prefetch()` warms the full route: in Next 16 it uses the automatic/PPR strategy. Gate
97+
`<Link prefetch={true}>` behind deliberate hover or keyboard focus, and prefetch destination
98+
server state with the consumer's shared React Query options. A short, cancelable hover dwell
99+
avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling;
100+
let the actual unmodified click start the data request.
101+
102+
If a continuity-focused surface intentionally omits `loading.tsx` so the current view remains
103+
mounted until its peer is ready, the intent path must warm both the full route and its critical
104+
data. Otherwise keep the loading boundary so dynamic navigation remains responsive.
105+
93106
## Local feature barrels are the convention — do not "fix" them
94107

95108
Tooling (e.g. react-doctor's `no-barrel-import`) will flag imports from local `index.ts` barrels as a bundle cost. In this repo that is a **false positive**: barrel imports for 3+ export folders are mandated by `.claude/rules/sim-imports.md`. Leave them.

.claude/rules/sim-url-state.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,11 @@ import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/l
143143

144144
Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`.
145145

146+
The narrow exception is a continuity-focused peer switch that deliberately keeps the current
147+
view mounted and follows the full-route plus critical-data intent-prefetch rule in
148+
`sim-react-performance.md`. It still needs a real in-page Suspense fallback; it only omits the
149+
route-level `loading.tsx` that would replace the current peer before the destination is ready.
150+
146151
This applies to **page entries**. An inner `<Suspense>` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels".
147152

148153
## Debounced text inputs

apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx

Lines changed: 0 additions & 19 deletions
This file was deleted.

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,7 @@ import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/comp
3838
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
3939
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
4040
import { useFolders } from '@/hooks/queries/folders'
41-
import {
42-
useMarkMothershipChatRead,
43-
useMothershipChatHistory,
44-
} from '@/hooks/queries/mothership-chats'
41+
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
4542
import { useWorkflows } from '@/hooks/queries/workflows'
4643
import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
4744
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
@@ -205,7 +202,6 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
205202

206203
const wasSendingRef = useRef(false)
207204

208-
const { isPending: isChatHistoryPending } = useMothershipChatHistory(chatId)
209205
const { mutate: markRead } = useMarkMothershipChatRead(workspaceId)
210206

211207
const [isResourceCollapsed, setIsResourceCollapsed] = useState(true)
@@ -242,6 +238,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
242238

243239
const {
244240
messages,
241+
isChatHistoryPending,
245242
isSending,
246243
isReconnecting,
247244
sendMessage,

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ interface WithdrawnSend {
193193

194194
export interface UseChatReturn {
195195
messages: ChatMessage[]
196+
isChatHistoryPending: boolean
196197
isSending: boolean
197198
isReconnecting: boolean
198199
error: string | null
@@ -1790,7 +1791,8 @@ export function useChat(
17901791
[flushPendingResources, queryClient, workspaceId]
17911792
)
17921793

1793-
const { data: chatHistory } = useMothershipChatHistory(resolvedChatId)
1794+
const { data: chatHistory, isPending: isChatHistoryPending } =
1795+
useMothershipChatHistory(resolvedChatId)
17941796
const messages = useMemo(() => {
17951797
const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages
17961798
return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current))
@@ -5210,6 +5212,7 @@ export function useChat(
52105212

52115213
return {
52125214
messages,
5215+
isChatHistoryPending,
52135216
isSending,
52145217
isReconnecting,
52155218
error,
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { linkPrefetch } = vi.hoisted(() => ({
10+
linkPrefetch: vi.fn(),
11+
}))
12+
13+
vi.mock('next/link', () => ({
14+
default: ({
15+
href,
16+
children,
17+
prefetch,
18+
...props
19+
}: {
20+
href: string
21+
children: React.ReactNode
22+
prefetch?: boolean
23+
}) => {
24+
linkPrefetch(prefetch)
25+
return (
26+
<a href={href} {...props}>
27+
{children}
28+
</a>
29+
)
30+
},
31+
}))
32+
33+
import { ChatNavigationLink } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link'
34+
35+
describe('ChatNavigationLink', () => {
36+
let container: HTMLDivElement
37+
let queryClient: QueryClient
38+
let root: Root
39+
let prefetchQuery: ReturnType<typeof vi.spyOn>
40+
41+
beforeEach(() => {
42+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
43+
vi.useFakeTimers()
44+
linkPrefetch.mockReset()
45+
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
46+
prefetchQuery = vi.spyOn(queryClient, 'prefetchQuery').mockResolvedValue()
47+
container = document.createElement('div')
48+
document.body.appendChild(container)
49+
root = createRoot(container)
50+
})
51+
52+
afterEach(() => {
53+
act(() => root.unmount())
54+
container.remove()
55+
queryClient.clear()
56+
vi.useRealTimers()
57+
})
58+
59+
function renderLink(chatId = 'chat-1', isCurrentRoute = false) {
60+
act(() => {
61+
root.render(
62+
<QueryClientProvider client={queryClient}>
63+
<ChatNavigationLink
64+
chatId={chatId}
65+
href={`/workspace/ws-1/chat/${chatId}`}
66+
isCurrentRoute={isCurrentRoute}
67+
>
68+
Open chat
69+
</ChatNavigationLink>
70+
</QueryClientProvider>
71+
)
72+
})
73+
const link = container.querySelector('a')
74+
if (!link) throw new Error('chat link not rendered')
75+
return link
76+
}
77+
78+
function pointerEvent(type: string, pointerType: 'mouse' | 'touch', init?: MouseEventInit) {
79+
const event = new MouseEvent(type, { bubbles: true, ...init })
80+
Object.defineProperty(event, 'pointerType', { value: pointerType })
81+
return event
82+
}
83+
84+
it('prefetches the route and exact history after deliberate pointer intent', () => {
85+
const link = renderLink()
86+
87+
act(() => {
88+
link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
89+
vi.advanceTimersByTime(79)
90+
})
91+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
92+
expect(prefetchQuery).not.toHaveBeenCalled()
93+
94+
act(() => vi.advanceTimersByTime(1))
95+
96+
expect(linkPrefetch).toHaveBeenLastCalledWith(true)
97+
expect(prefetchQuery).toHaveBeenCalledWith(
98+
expect.objectContaining({
99+
queryKey: ['mothership-chats', 'detail', 'chat-1'],
100+
staleTime: 30_000,
101+
})
102+
)
103+
104+
act(() => link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })))
105+
106+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
107+
expect(prefetchQuery).toHaveBeenCalledTimes(1)
108+
})
109+
110+
it('cancels drive-by hover prefetches', () => {
111+
const link = renderLink()
112+
113+
act(() => {
114+
link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
115+
link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))
116+
vi.runAllTimers()
117+
})
118+
119+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
120+
expect(prefetchQuery).not.toHaveBeenCalled()
121+
})
122+
123+
it('prefetches immediately for keyboard focus without fetching a new-chat history', () => {
124+
const link = renderLink('new')
125+
126+
act(() => link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
127+
128+
expect(linkPrefetch).toHaveBeenLastCalledWith(true)
129+
expect(prefetchQuery).not.toHaveBeenCalled()
130+
})
131+
132+
it('does not treat touch scrolling as navigation intent', () => {
133+
const link = renderLink()
134+
135+
act(() => link.dispatchEvent(new TouchEvent('touchstart', { bubbles: true })))
136+
137+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
138+
expect(prefetchQuery).not.toHaveBeenCalled()
139+
})
140+
141+
it('prefetches before direct mouse clicks and completed touch taps', () => {
142+
const link = renderLink()
143+
linkPrefetch.mockClear()
144+
145+
act(() => {
146+
link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 }))
147+
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
148+
})
149+
150+
expect(linkPrefetch).toHaveBeenLastCalledWith(true)
151+
expect(prefetchQuery).toHaveBeenCalledTimes(1)
152+
153+
act(() => link.dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
154+
linkPrefetch.mockClear()
155+
prefetchQuery.mockClear()
156+
act(() => {
157+
link.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 }))
158+
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
159+
})
160+
161+
expect(linkPrefetch).toHaveBeenLastCalledWith(true)
162+
expect(prefetchQuery).toHaveBeenCalledTimes(1)
163+
})
164+
165+
it('cancels touch-scroll pointer intent before it can prefetch', () => {
166+
const link = renderLink()
167+
168+
act(() => {
169+
link.dispatchEvent(pointerEvent('pointerdown', 'touch', { button: 0 }))
170+
link.dispatchEvent(pointerEvent('pointercancel', 'touch', { button: 0 }))
171+
})
172+
173+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
174+
expect(prefetchQuery).not.toHaveBeenCalled()
175+
})
176+
177+
it('does not prefetch when a nested chat action is pressed', () => {
178+
act(() => {
179+
root.render(
180+
<QueryClientProvider client={queryClient}>
181+
<ChatNavigationLink chatId='chat-1' href='/workspace/ws-1/chat/chat-1'>
182+
<button type='button' onClick={(event) => event.preventDefault()}>
183+
Chat options
184+
</button>
185+
</ChatNavigationLink>
186+
</QueryClientProvider>
187+
)
188+
})
189+
const button = container.querySelector('button')
190+
if (!button) throw new Error('chat action not rendered')
191+
192+
act(() => {
193+
button.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 }))
194+
button.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 }))
195+
button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
196+
})
197+
198+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
199+
expect(prefetchQuery).not.toHaveBeenCalled()
200+
})
201+
202+
it('does not prefetch the chat that is already open', () => {
203+
const link = renderLink('chat-1', true)
204+
205+
act(() => link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
206+
207+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
208+
expect(prefetchQuery).not.toHaveBeenCalled()
209+
})
210+
211+
it('clears prior intent when a persistent row changes route roles', () => {
212+
const renderRouteRole = (isCurrentRoute: boolean) => {
213+
act(() => {
214+
root.render(
215+
<QueryClientProvider client={queryClient}>
216+
<ChatNavigationLink
217+
chatId='chat-1'
218+
href='/workspace/ws-1/chat/chat-1'
219+
isCurrentRoute={isCurrentRoute}
220+
>
221+
Open chat
222+
</ChatNavigationLink>
223+
</QueryClientProvider>
224+
)
225+
})
226+
}
227+
228+
renderRouteRole(false)
229+
const link = container.querySelector('a')
230+
if (!link) throw new Error('chat link not rendered')
231+
act(() => {
232+
link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 }))
233+
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
234+
})
235+
expect(linkPrefetch).toHaveBeenLastCalledWith(true)
236+
237+
renderRouteRole(true)
238+
renderRouteRole(false)
239+
240+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
241+
prefetchQuery.mockClear()
242+
const destinationLink = container.querySelector('a')
243+
if (!destinationLink) throw new Error('destination link not rendered')
244+
act(() => {
245+
destinationLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
246+
})
247+
expect(prefetchQuery).toHaveBeenCalledTimes(1)
248+
})
249+
250+
it('does not prefetch when the click is canceled or opens another browsing context', () => {
251+
const canceledLink = renderLink()
252+
253+
act(() => {
254+
root.render(
255+
<QueryClientProvider client={queryClient}>
256+
<ChatNavigationLink
257+
chatId='chat-1'
258+
href='/workspace/ws-1/chat/chat-1'
259+
onClick={(event) => event.preventDefault()}
260+
>
261+
Open chat
262+
</ChatNavigationLink>
263+
</QueryClientProvider>
264+
)
265+
})
266+
act(() => {
267+
canceledLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
268+
})
269+
270+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
271+
expect(prefetchQuery).not.toHaveBeenCalled()
272+
273+
act(() => {
274+
root.render(
275+
<QueryClientProvider client={queryClient}>
276+
<ChatNavigationLink chatId='chat-1' href='/workspace/ws-1/chat/chat-1'>
277+
Open chat
278+
</ChatNavigationLink>
279+
</QueryClientProvider>
280+
)
281+
})
282+
const modifiedLink = container.querySelector('a')
283+
if (!modifiedLink) throw new Error('chat link not rendered')
284+
act(() => {
285+
modifiedLink.dispatchEvent(
286+
new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true })
287+
)
288+
})
289+
290+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
291+
expect(prefetchQuery).not.toHaveBeenCalled()
292+
})
293+
})

0 commit comments

Comments
 (0)