Skip to content

Commit c3a0969

Browse files
authored
fix(tables): SSR crash from tableKeys in a 'use client' module + drop redundant flushChunks (#5204)
* fix(tables): move tableKeys to a non-client module so the SSR prefetch works The tables list page crashed at SSR ('tableKeys.list is not a function') because tables/prefetch.ts (a server component) imported tableKeys from hooks/queries/tables.ts — a 'use client' module whose exports resolve to client-reference stubs on the server. Extract the key factory into hooks/queries/utils/table-keys.ts (no 'use client'), mirroring folder-keys.ts, and import it from there in the prefetch, hook, trigger, and consumers. * refactor(chat): drop redundant flushChunks on the SSE error path On an error 'final' event the reader stops via return true, so the post-loop flush is the single flush point. Defer the error append to after that flush (single flush, correct ordering) instead of flushing inside onEvent and again post-loop. No behavior change. * fix(sse): process the final unterminated line on stream end readSSELines broke out of the read loop on 'done' without flushing the TextDecoder or processing the trailing buffer, so a final 'data:' line not terminated by a newline (and any buffered multi-byte character) was dropped. Flush the decoder on end-of-stream and process the remaining buffer. Addresses a Cursor Medium finding on the consolidated SSE reader.
1 parent 5a938e5 commit c3a0969

11 files changed

Lines changed: 79 additions & 40 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
2525
import { logKeys } from '@/hooks/queries/logs'
2626
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
2727
import { scheduleKeys } from '@/hooks/queries/schedules'
28-
import { tableKeys } from '@/hooks/queries/tables'
2928
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
3029
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
30+
import { tableKeys } from '@/hooks/queries/utils/table-keys'
3131
import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders'
3232
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
3333

apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch'
2121
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
2222
import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch'
2323
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
24-
import { tableKeys } from '@/hooks/queries/tables'
2524
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
25+
import { tableKeys } from '@/hooks/queries/utils/table-keys'
2626
import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders'
2727
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
2828

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
downloadExportResult,
1414
snapshotAndMutateRows,
1515
type TableRunState,
16-
tableKeys,
1716
} from '@/hooks/queries/tables'
17+
import { tableKeys } from '@/hooks/queries/utils/table-keys'
1818

1919
const logger = createLogger('useTableEventStream')
2020

apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { QueryClient } from '@tanstack/react-query'
22
import type { TableDefinition } from '@/lib/table'
33
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
4-
import { tableKeys } from '@/hooks/queries/tables'
4+
import { tableKeys } from '@/hooks/queries/utils/table-keys'
55

66
/**
77
* Prefetches the workspace's tables list under the same query key the client

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,7 @@ export function Chat() {
561561
}
562562
}
563563

564+
let finalError: string | null = null
564565
try {
565566
await readSSEEvents<{ event?: string; data?: ExecutionResult; chunk?: string }>(reader, {
566567
onParseError: (_data, e) => {
@@ -571,12 +572,7 @@ export function Chat() {
571572

572573
if (event === 'final' && eventData) {
573574
if ('success' in eventData && !eventData.success) {
574-
const errorMessage = eventData.error || 'Workflow execution failed'
575-
flushChunks()
576-
appendMessageContent(
577-
responseMessageId,
578-
`${accumulatedContent ? '\n\n' : ''}Error: ${errorMessage}`
579-
)
575+
finalError = eventData.error || 'Workflow execution failed'
580576
}
581577
return true
582578
}
@@ -589,6 +585,12 @@ export function Chat() {
589585
},
590586
})
591587
flushChunks()
588+
if (finalError) {
589+
appendMessageContent(
590+
responseMessageId,
591+
`${accumulatedContent ? '\n\n' : ''}Error: ${finalError}`
592+
)
593+
}
592594
finalizeMessageStream(responseMessageId)
593595
} catch (error) {
594596
if ((error as Error)?.name !== 'AbortError') {

apps/sim/hooks/queries/tables.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,13 @@ vi.mock('@/components/emcn', () => ({
8383
}))
8484

8585
import {
86-
tableKeys,
8786
tableRowsInfiniteOptions,
8887
tableRowsParamsKey,
8988
useDeleteColumn,
9089
useRestoreTable,
9190
useUpdateColumn,
9291
} from '@/hooks/queries/tables'
92+
import { tableKeys } from '@/hooks/queries/utils/table-keys'
9393

9494
const TABLE_ID = 'tbl-1'
9595
const WORKSPACE_ID = 'ws-1'

apps/sim/hooks/queries/tables.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -96,34 +96,10 @@ import {
9696
optimisticallyScheduleNewlyEligibleGroups,
9797
} from '@/lib/table/deps'
9898
import { runUploadStrategy } from '@/lib/uploads/client/direct-upload'
99+
import { type TableQueryScope, tableKeys } from '@/hooks/queries/utils/table-keys'
99100

100101
const logger = createLogger('TableQueries')
101102

102-
type TableQueryScope = 'active' | 'archived' | 'all'
103-
104-
export const tableKeys = {
105-
all: ['tables'] as const,
106-
lists: () => [...tableKeys.all, 'list'] as const,
107-
list: (workspaceId?: string, scope: TableQueryScope = 'active') =>
108-
[...tableKeys.lists(), workspaceId ?? '', scope] as const,
109-
details: () => [...tableKeys.all, 'detail'] as const,
110-
detail: (tableId: string) => [...tableKeys.details(), tableId] as const,
111-
exportJobs: (workspaceId?: string) =>
112-
[...tableKeys.all, 'export-jobs', workspaceId ?? ''] as const,
113-
rowsRoot: (tableId: string) => [...tableKeys.detail(tableId), 'rows'] as const,
114-
infiniteRows: (tableId: string, paramsKey: string) =>
115-
[...tableKeys.rowsRoot(tableId), 'infinite', paramsKey] as const,
116-
rowWrites: (tableId: string) => [...tableKeys.rowsRoot(tableId), 'write'] as const,
117-
find: (tableId: string, paramsKey: string) =>
118-
[...tableKeys.rowsRoot(tableId), 'find', paramsKey] as const,
119-
activeDispatches: (tableId: string) =>
120-
[...tableKeys.detail(tableId), 'active-dispatches'] as const,
121-
enrichmentDetails: (tableId: string) =>
122-
[...tableKeys.detail(tableId), 'enrichment-detail'] as const,
123-
enrichmentDetail: (tableId: string, rowId: string, groupId: string) =>
124-
[...tableKeys.enrichmentDetails(tableId), rowId, groupId] as const,
125-
}
126-
127103
type TableRowsParams = Omit<TableRowsQueryInput, 'filter' | 'sort'> &
128104
TableIdParamsInput & {
129105
filter?: Filter | null
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* React Query key factory for user-defined tables.
3+
*
4+
* Lives in this standalone (non-`'use client'`) module — like
5+
* {@link file://./folder-keys.ts} — so it can be imported from server
6+
* components (e.g. the tables page prefetch) without pulling in the
7+
* `'use client'` `@/hooks/queries/tables` module, whose exports would
8+
* otherwise resolve to client-reference stubs on the server.
9+
*/
10+
11+
export type TableQueryScope = 'active' | 'archived' | 'all'
12+
13+
export const tableKeys = {
14+
all: ['tables'] as const,
15+
lists: () => [...tableKeys.all, 'list'] as const,
16+
list: (workspaceId?: string, scope: TableQueryScope = 'active') =>
17+
[...tableKeys.lists(), workspaceId ?? '', scope] as const,
18+
details: () => [...tableKeys.all, 'detail'] as const,
19+
detail: (tableId: string) => [...tableKeys.details(), tableId] as const,
20+
exportJobs: (workspaceId?: string) =>
21+
[...tableKeys.all, 'export-jobs', workspaceId ?? ''] as const,
22+
rowsRoot: (tableId: string) => [...tableKeys.detail(tableId), 'rows'] as const,
23+
infiniteRows: (tableId: string, paramsKey: string) =>
24+
[...tableKeys.rowsRoot(tableId), 'infinite', paramsKey] as const,
25+
rowWrites: (tableId: string) => [...tableKeys.rowsRoot(tableId), 'write'] as const,
26+
find: (tableId: string, paramsKey: string) =>
27+
[...tableKeys.rowsRoot(tableId), 'find', paramsKey] as const,
28+
activeDispatches: (tableId: string) =>
29+
[...tableKeys.detail(tableId), 'active-dispatches'] as const,
30+
enrichmentDetails: (tableId: string) =>
31+
[...tableKeys.detail(tableId), 'enrichment-detail'] as const,
32+
enrichmentDetail: (tableId: string, rowId: string, groupId: string) =>
33+
[...tableKeys.enrichmentDetails(tableId), rowId, groupId] as const,
34+
}

apps/sim/lib/core/utils/sse.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,32 @@ describe('readSSEEvents', () => {
361361
expect(events).toEqual([{ msg: 'hello' }])
362362
})
363363

364+
it('emits a final data: line that has no trailing newline (stream tail)', async () => {
365+
const stream = streamFromStringChunks(['data: {"n":1}\n', 'data: {"n":2}'])
366+
const events: number[] = []
367+
await readSSEEvents<{ n: number }>(stream, {
368+
onEvent: (e) => {
369+
events.push(e.n)
370+
},
371+
})
372+
expect(events).toEqual([1, 2])
373+
})
374+
375+
it('flushes a multi-byte character in the final unterminated line', async () => {
376+
const encoder = new TextEncoder()
377+
const euro = encoder.encode('€')
378+
const chunk1 = new Uint8Array([...encoder.encode('data: {"s":"'), euro[0], euro[1]])
379+
const chunk2 = new Uint8Array([euro[2], ...encoder.encode('"}')])
380+
const stream = createStreamFromChunks([chunk1, chunk2])
381+
const events: Array<{ s: string }> = []
382+
await readSSEEvents<{ s: string }>(stream, {
383+
onEvent: (e) => {
384+
events.push(e)
385+
},
386+
})
387+
expect(events).toEqual([{ s: '€' }])
388+
})
389+
364390
it('skips the [DONE] sentinel', async () => {
365391
const stream = streamFromStringChunks(['data: {"n":1}\n\n', 'data: [DONE]\n\n'])
366392
const events: number[] = []

apps/sim/lib/core/utils/sse.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,10 @@ export async function readSSELines(source: SSESource, options: ReadSSELinesOptio
138138
if (signal?.aborted) break
139139

140140
const { done, value } = await reader.read()
141-
if (done) break
142141

143-
buffer += decoder.decode(value, { stream: true })
142+
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true })
144143
const lines = buffer.split('\n')
145-
buffer = lines.pop() ?? ''
144+
buffer = done ? '' : (lines.pop() ?? '')
146145

147146
for (const rawLine of lines) {
148147
if (signal?.aborted) return
@@ -156,6 +155,8 @@ export async function readSSELines(source: SSESource, options: ReadSSELinesOptio
156155

157156
if ((await onData(data)) === true) return
158157
}
158+
159+
if (done) break
159160
}
160161
} finally {
161162
if (ownsLock) reader.releaseLock()

0 commit comments

Comments
 (0)