Skip to content

Commit e177502

Browse files
committed
fix(files): refresh the file list after a flush so the retype reads a live key
A collaborative flush persists through a versioned object swap: it mints a new storage key and deletes the previous blob. The retype then swaps editors from the optimistic rename patch, so the newly mounted viewer read `key` off a record the flush had already invalidated - a 404, or pre-edit text from the content cache keyed on that dead key, until the rename's own invalidation landed. Awaits a list refetch between a confirmed `persisted` flush and the rename. `refetchType: 'all'`, because the caller awaits this for a usable key and the default `active` resolves immediately against an unobserved list. Also moves this file's two sibling imports onto the `@/` alias per the repo's absolute-import rule.
1 parent e278ae3 commit e177502

4 files changed

Lines changed: 150 additions & 6 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import { FILE_DOC_EVENTS, type FileDocPresence } from '@sim/realtime-protocol/fi
55
import { Awareness } from 'y-protocols/awareness'
66
import * as Y from 'yjs'
77
import { getUserColor } from '@/lib/workspaces/colors'
8+
import { FileDocProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider'
9+
import {
10+
useReportFileDocFlush,
11+
useReportFileDocOthers,
12+
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context'
813
import { useSocket } from '@/app/workspace/providers/socket-provider'
9-
import { FileDocProvider } from './file-doc-provider'
10-
import { useReportFileDocFlush, useReportFileDocOthers } from './file-doc-room-context'
1114

1215
/** The live collaboration binding the editor wires into TipTap's Collaboration
1316
* (the {@link Y.Doc}) and CollaborationCaret (the awareness). */

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import {
125125
} from '@/hooks/queries/workspace-file-folders'
126126
import {
127127
useDeleteWorkspaceFile,
128+
useRefreshWorkspaceFiles,
128129
useRenameWorkspaceFile,
129130
useUploadWorkspaceFile,
130131
useWorkspaceFiles,
@@ -305,6 +306,7 @@ export function Files() {
305306
const notifyLimit = useLimitUpgradeToast()
306307
const deleteFile = useDeleteWorkspaceFile()
307308
const renameFile = useRenameWorkspaceFile()
309+
const refreshFiles = useRefreshWorkspaceFiles()
308310
const createFolder = useCreateWorkspaceFileFolder()
309311
const updateFolder = useUpdateWorkspaceFileFolder()
310312
const moveItems = useMoveWorkspaceFileItems()
@@ -1239,8 +1241,13 @@ export function Files() {
12391241

12401242
if (isDirtyRef.current) await saveRef.current?.()
12411243
const flushed = await flushFileDocRef(fileDocFlushRef)
1242-
if (flushed.status !== 'persisted') {
1243-
// Not an error — `unchanged` means there was nothing to write, and `skipped` means the write
1244+
if (flushed.status === 'persisted') {
1245+
// The persist minted a new storage key and deleted the previous blob, so the cached record
1246+
// the viewer renders from now points at a key that 404s. Wait for the refreshed list before
1247+
// the rename swaps editors, or the newly mounted viewer reads the dead key.
1248+
await refreshFiles(workspaceId)
1249+
} else {
1250+
// Not an error - `unchanged` means there was nothing to write, and `skipped` means the write
12441251
// did not land in time. The retype proceeds either way; this is the breadcrumb for a stale
12451252
// first paint, which is otherwise indistinguishable from a rendering bug.
12461253
logger.info('Changing file type without a confirmed durable flush', {
@@ -1265,7 +1272,7 @@ export function Files() {
12651272
logger.error('Failed to change file type:', err)
12661273
}
12671274
},
1268-
[workspaceId]
1275+
[workspaceId, refreshFiles]
12691276
)
12701277

12711278
const handleDownloadSelected = useCallback(() => {

apps/sim/hooks/queries/workspace-files.test.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1313
import { createRoot, type Root } from 'react-dom/client'
1414
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1515
import {
16+
useRefreshWorkspaceFiles,
1617
useRenameWorkspaceFile,
1718
useWorkspaceFileContent,
1819
workspaceFilesKeys,
@@ -246,3 +247,112 @@ describe('useRenameWorkspaceFile optimistic cache patch', () => {
246247
unmount()
247248
})
248249
})
250+
251+
/**
252+
* A collaborative flush mints a new storage key and deletes the previous blob, so a retype has to
253+
* wait for the refreshed list before it swaps editors - the newly mounted viewer reads `key` off
254+
* that record, and the pre-flush one 404s.
255+
*/
256+
describe('useRefreshWorkspaceFiles', () => {
257+
const WS = 'ws-1'
258+
259+
function renderRefresh(): {
260+
refresh: () => (workspaceId: string) => Promise<void>
261+
queryClient: QueryClient
262+
unmount: () => void
263+
} {
264+
const queryClient = new QueryClient({
265+
defaultOptions: { queries: { retry: false } },
266+
})
267+
const container = document.createElement('div')
268+
const root: Root = createRoot(container)
269+
let result: ((workspaceId: string) => Promise<void>) | null = null
270+
271+
function Probe() {
272+
result = useRefreshWorkspaceFiles()
273+
return null
274+
}
275+
276+
act(() => {
277+
root.render(
278+
<QueryClientProvider client={queryClient}>
279+
<Probe />
280+
</QueryClientProvider>
281+
)
282+
})
283+
284+
return {
285+
refresh: () => {
286+
if (!result) throw new Error('hook did not render')
287+
return result
288+
},
289+
queryClient,
290+
unmount: () => {
291+
act(() => root.unmount())
292+
queryClient.clear()
293+
},
294+
}
295+
}
296+
297+
it('resolves only after the refetched list has landed', async () => {
298+
const keys = ['workspace/ws-1/old-key', 'workspace/ws-1/new-key']
299+
let call = 0
300+
const queryFn = vi.fn(async () => {
301+
const key = keys[Math.min(call, keys.length - 1)]
302+
call += 1
303+
await sleep(10)
304+
return [{ id: 'file-1', key }]
305+
})
306+
307+
const { refresh, queryClient, unmount } = renderRefresh()
308+
const queryKey = workspaceFilesKeys.list(WS, 'active')
309+
await act(async () => {
310+
await queryClient.fetchQuery({ queryKey, queryFn })
311+
})
312+
expect(queryClient.getQueryData<{ key: string }[]>(queryKey)?.[0].key).toBe(
313+
'workspace/ws-1/old-key'
314+
)
315+
316+
await act(async () => {
317+
await refresh()(WS)
318+
})
319+
320+
// The awaited call must have already replaced the dead key, not merely marked it stale.
321+
expect(queryClient.getQueryData<{ key: string }[]>(queryKey)?.[0].key).toBe(
322+
'workspace/ws-1/new-key'
323+
)
324+
expect(queryFn).toHaveBeenCalledTimes(2)
325+
unmount()
326+
})
327+
328+
it('refetches a cached list that no component is observing', async () => {
329+
// `refetchType: 'all'` is load-bearing: the retype awaits this before mounting the next viewer,
330+
// and the default `active` would resolve instantly against an unobserved list.
331+
const queryFn = vi.fn(async () => [{ id: 'file-1', key: 'k' }])
332+
const { refresh, queryClient, unmount } = renderRefresh()
333+
334+
await act(async () => {
335+
await queryClient.fetchQuery({ queryKey: workspaceFilesKeys.list(WS, 'active'), queryFn })
336+
await refresh()(WS)
337+
})
338+
339+
expect(queryFn).toHaveBeenCalledTimes(2)
340+
unmount()
341+
})
342+
343+
it('leaves another workspace list alone', async () => {
344+
const otherQueryFn = vi.fn(async () => [{ id: 'file-2', key: 'k2' }])
345+
const { refresh, queryClient, unmount } = renderRefresh()
346+
347+
await act(async () => {
348+
await queryClient.fetchQuery({
349+
queryKey: workspaceFilesKeys.list('ws-2', 'active'),
350+
queryFn: otherQueryFn,
351+
})
352+
await refresh()(WS)
353+
})
354+
355+
expect(otherQueryFn).toHaveBeenCalledTimes(1)
356+
unmount()
357+
})
358+
})

apps/sim/hooks/queries/workspace-files.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo } from 'react'
1+
import { useCallback, useMemo } from 'react'
22
import { toast } from '@sim/emcn'
33
import { createLogger } from '@sim/logger'
44
import { toError } from '@sim/utils/errors'
@@ -580,6 +580,30 @@ export function useUpdateWorkspaceFileContent() {
580580
})
581581
}
582582

583+
/**
584+
* Refetch the workspace file list and resolve once the fresh records have landed.
585+
*
586+
* Every content write mints a new storage key and deletes the previous blob, so a cached record's
587+
* `key` is dead the moment one lands. A caller that is about to mount a viewer from that record -
588+
* a retype, which swaps editors optimistically - has to wait for the refreshed list, or the new
589+
* viewer fetches a key the store has already deleted.
590+
*/
591+
export function useRefreshWorkspaceFiles() {
592+
const queryClient = useQueryClient()
593+
594+
return useCallback(
595+
(workspaceId: string) =>
596+
queryClient.invalidateQueries({
597+
queryKey: workspaceFilesKeys.workspaceLists(workspaceId),
598+
// `all`, not the default `active`: the caller awaits this to get a usable key back, and an
599+
// invalidation that only marks an unobserved list stale resolves immediately with the dead
600+
// key still cached - the exact staleness this exists to close.
601+
refetchType: 'all',
602+
}),
603+
[queryClient]
604+
)
605+
}
606+
583607
/**
584608
* Rename a workspace file
585609
*/

0 commit comments

Comments
 (0)