Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .claude/rules/sim-react-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ const [{ id }, { kbName }] = await Promise.all([params, searchParams])

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).

## Prefetch dynamic destination lists on intent

For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume
`router.prefetch()` warms the full route: in Next 16 it uses the automatic/PPR strategy. Gate
`<Link prefetch={true}>` behind deliberate hover or keyboard focus, and prefetch destination
server state with the consumer's shared React Query options. A short, cancelable hover dwell
avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling;
let the actual unmodified click start the data request.

If a continuity-focused surface intentionally omits `loading.tsx` so the current view remains
mounted until its peer is ready, the intent path must warm both the full route and its critical
data. Otherwise keep the loading boundary so dynamic navigation remains responsive.

## Local feature barrels are the convention — do not "fix" them

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.
5 changes: 5 additions & 0 deletions .claude/rules/sim-url-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/l

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

The narrow exception is a continuity-focused peer switch that deliberately keeps the current
view mounted and follows the full-route plus critical-data intent-prefetch rule in
`sim-react-performance.md`. It still needs a real in-page Suspense fallback; it only omits the
route-level `loading.tsx` that would replace the current peer before the destination is ready.

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".

## Debounced text inputs
Expand Down
19 changes: 0 additions & 19 deletions apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx

This file was deleted.

7 changes: 2 additions & 5 deletions apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@ import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/comp
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
import { useFolders } from '@/hooks/queries/folders'
import {
useMarkMothershipChatRead,
useMothershipChatHistory,
} from '@/hooks/queries/mothership-chats'
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
import { useWorkflows } from '@/hooks/queries/workflows'
import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
Expand Down Expand Up @@ -205,7 +202,6 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)

const wasSendingRef = useRef(false)

const { isPending: isChatHistoryPending } = useMothershipChatHistory(chatId)
const { mutate: markRead } = useMarkMothershipChatRead(workspaceId)

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

const {
messages,
isChatHistoryPending,
isSending,
isReconnecting,
sendMessage,
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ interface WithdrawnSend {

export interface UseChatReturn {
messages: ChatMessage[]
isChatHistoryPending: boolean
isSending: boolean
isReconnecting: boolean
error: string | null
Expand Down Expand Up @@ -1790,7 +1791,8 @@ export function useChat(
[flushPendingResources, queryClient, workspaceId]
)

const { data: chatHistory } = useMothershipChatHistory(resolvedChatId)
const { data: chatHistory, isPending: isChatHistoryPending } =
useMothershipChatHistory(resolvedChatId)
const messages = useMemo(() => {
const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages
return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current))
Expand Down Expand Up @@ -5210,6 +5212,7 @@ export function useChat(

return {
messages,
isChatHistoryPending,
isSending,
isReconnecting,
error,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { linkPrefetch } = vi.hoisted(() => ({
linkPrefetch: vi.fn(),
}))

vi.mock('next/link', () => ({
default: ({
href,
children,
prefetch,
...props
}: {
href: string
children: React.ReactNode
prefetch?: boolean
}) => {
linkPrefetch(prefetch)
return (
<a href={href} {...props}>
{children}
</a>
)
},
}))

import { ChatNavigationLink } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link'

describe('ChatNavigationLink', () => {
let container: HTMLDivElement
let queryClient: QueryClient
let root: Root
let prefetchQuery: ReturnType<typeof vi.spyOn>

beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
linkPrefetch.mockReset()
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
prefetchQuery = vi.spyOn(queryClient, 'prefetchQuery').mockResolvedValue()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
queryClient.clear()
vi.useRealTimers()
})

function renderLink(chatId = 'chat-1', isCurrentRoute = false) {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ChatNavigationLink
chatId={chatId}
href={`/workspace/ws-1/chat/${chatId}`}
isCurrentRoute={isCurrentRoute}
>
Open chat
</ChatNavigationLink>
</QueryClientProvider>
)
})
const link = container.querySelector('a')
if (!link) throw new Error('chat link not rendered')
return link
}

function pointerEvent(type: string, pointerType: 'mouse' | 'touch', init?: MouseEventInit) {
const event = new MouseEvent(type, { bubbles: true, ...init })
Object.defineProperty(event, 'pointerType', { value: pointerType })
return event
}

it('prefetches the route and exact history after deliberate pointer intent', () => {
const link = renderLink()

act(() => {
link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
vi.advanceTimersByTime(79)
})
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
expect(prefetchQuery).not.toHaveBeenCalled()

act(() => vi.advanceTimersByTime(1))

expect(linkPrefetch).toHaveBeenLastCalledWith(true)
expect(prefetchQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['mothership-chats', 'detail', 'chat-1'],
staleTime: 30_000,
})
)

act(() => link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })))

expect(linkPrefetch).toHaveBeenLastCalledWith(false)
expect(prefetchQuery).toHaveBeenCalledTimes(1)
})

it('cancels drive-by hover prefetches', () => {
const link = renderLink()

act(() => {
link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))
vi.runAllTimers()
})

expect(linkPrefetch).toHaveBeenLastCalledWith(false)
expect(prefetchQuery).not.toHaveBeenCalled()
})

it('prefetches immediately for keyboard focus without fetching a new-chat history', () => {
const link = renderLink('new')

act(() => link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))

expect(linkPrefetch).toHaveBeenLastCalledWith(true)
expect(prefetchQuery).not.toHaveBeenCalled()
})

it('does not treat touch scrolling as navigation intent', () => {
const link = renderLink()

act(() => link.dispatchEvent(new TouchEvent('touchstart', { bubbles: true })))

expect(linkPrefetch).toHaveBeenLastCalledWith(false)
expect(prefetchQuery).not.toHaveBeenCalled()
})

it('prefetches before direct mouse clicks and completed touch taps', () => {
const link = renderLink()
linkPrefetch.mockClear()

act(() => {
link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 }))
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})

expect(linkPrefetch).toHaveBeenLastCalledWith(true)
expect(prefetchQuery).toHaveBeenCalledTimes(1)

act(() => link.dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
linkPrefetch.mockClear()
prefetchQuery.mockClear()
act(() => {
link.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 }))
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})

expect(linkPrefetch).toHaveBeenLastCalledWith(true)
expect(prefetchQuery).toHaveBeenCalledTimes(1)
})

it('cancels touch-scroll pointer intent before it can prefetch', () => {
const link = renderLink()

act(() => {
link.dispatchEvent(pointerEvent('pointerdown', 'touch', { button: 0 }))
link.dispatchEvent(pointerEvent('pointercancel', 'touch', { button: 0 }))
})

expect(linkPrefetch).toHaveBeenLastCalledWith(false)
expect(prefetchQuery).not.toHaveBeenCalled()
})

it('does not prefetch when a nested chat action is pressed', () => {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ChatNavigationLink chatId='chat-1' href='/workspace/ws-1/chat/chat-1'>
<button type='button' onClick={(event) => event.preventDefault()}>
Chat options
</button>
</ChatNavigationLink>
</QueryClientProvider>
)
})
const button = container.querySelector('button')
if (!button) throw new Error('chat action not rendered')

act(() => {
button.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 }))
button.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 }))
button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})

expect(linkPrefetch).toHaveBeenLastCalledWith(false)
expect(prefetchQuery).not.toHaveBeenCalled()
})

it('does not prefetch the chat that is already open', () => {
const link = renderLink('chat-1', true)

act(() => link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))

expect(linkPrefetch).toHaveBeenLastCalledWith(false)
expect(prefetchQuery).not.toHaveBeenCalled()
})

it('clears prior intent when a persistent row changes route roles', () => {
const renderRouteRole = (isCurrentRoute: boolean) => {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ChatNavigationLink
chatId='chat-1'
href='/workspace/ws-1/chat/chat-1'
isCurrentRoute={isCurrentRoute}
>
Open chat
</ChatNavigationLink>
</QueryClientProvider>
)
})
}

renderRouteRole(false)
const link = container.querySelector('a')
if (!link) throw new Error('chat link not rendered')
act(() => {
link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 }))
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})
expect(linkPrefetch).toHaveBeenLastCalledWith(true)

renderRouteRole(true)
renderRouteRole(false)

expect(linkPrefetch).toHaveBeenLastCalledWith(false)
prefetchQuery.mockClear()
const destinationLink = container.querySelector('a')
if (!destinationLink) throw new Error('destination link not rendered')
act(() => {
destinationLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})
expect(prefetchQuery).toHaveBeenCalledTimes(1)
})

it('does not prefetch when the click is canceled or opens another browsing context', () => {
const canceledLink = renderLink()

act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ChatNavigationLink
chatId='chat-1'
href='/workspace/ws-1/chat/chat-1'
onClick={(event) => event.preventDefault()}
>
Open chat
</ChatNavigationLink>
</QueryClientProvider>
)
})
act(() => {
canceledLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})

expect(linkPrefetch).toHaveBeenLastCalledWith(false)
expect(prefetchQuery).not.toHaveBeenCalled()

act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ChatNavigationLink chatId='chat-1' href='/workspace/ws-1/chat/chat-1'>
Open chat
</ChatNavigationLink>
</QueryClientProvider>
)
})
const modifiedLink = container.querySelector('a')
if (!modifiedLink) throw new Error('chat link not rendered')
act(() => {
modifiedLink.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true })
)
})

expect(linkPrefetch).toHaveBeenLastCalledWith(false)
expect(prefetchQuery).not.toHaveBeenCalled()
})
})
Loading
Loading