Visitar URL original
perf(files): page the Files browser and resolve files without listing the workspace by waleedlatif1 · Pull Request #9436 · simstudioai/sim · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b58d9a0
feat(files): search and filter the paged file list by kind, size, and…
waleedlatif1 Oct 29, 2026
1b9376f
perf(files): page the Files browser and resolve files by lookup inste…
waleedlatif1 Oct 29, 2026
da95a83
fix(files): settle deleted files, refused lookups, and stale saved fi…
waleedlatif1 Oct 29, 2026
8ba63c5
feat(files): sort the paged list by type and owner, and rank file sea…
waleedlatif1 Oct 29, 2026
19e36df
feat(files): offer the Type and Owner sorts again on the paged browser
waleedlatif1 Oct 29, 2026
f76c9d3
refactor(files): share the file browsers' type rules and tighten the …
waleedlatif1 Oct 29, 2026
2156cce
perf(files): roll up the child folders of several parents in one fold…
waleedlatif1 Oct 29, 2026
1d14e47
refactor(files): consolidate the paged file reads' client hooks and n…
waleedlatif1 Oct 29, 2026
dde3796
perf(files): resolve a file chip's candidates by reference alone
waleedlatif1 Oct 29, 2026
c94cf78
chore(files): tidy the paged file reads' memos, query options, and co…
waleedlatif1 Oct 29, 2026
adc7f97
test(files): tighten the paged file reads' tests
waleedlatif1 Oct 29, 2026
cd527cd
test(files): drop a redundant global unstub and the deleted queries t…
waleedlatif1 Oct 29, 2026
03d3162
fix(files): address the paged file reads' first review round
waleedlatif1 Oct 29, 2026
6cd7537
fix(files): address the paged file reads' second review round
waleedlatif1 Oct 29, 2026
fcd000a
fix(files): address the paged file reads' third review round
waleedlatif1 Oct 29, 2026
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
Prev Previous commit
Next Next commit
test(files): tighten the paged file reads' tests
- Cover the load-more footer: paging resumes once a background refetch settles, and only a
  next-page load shows the spinner; a search never keeps another workspace's matches.
- The browser-invalidation test mounts a search beside the list and holds both refetches; the
  stale-key test checks its refetch filter against real cache entries.
- The full-table type parity test also covers names the MIME table does not know, replacing
  the sort-local label check.
- The lookup batcher test derives its sizes from the contract limits; drop a route test the
  contract enum already guarantees, a chip test the following ones cover, and an orphaned
  comment.
  • Loading branch information
waleedlatif1 committed Oct 29, 2026
commit adc7f97e94baaabb708f359c6a9fffd1e03ce5ef
6 changes: 0 additions & 6 deletions apps/sim/app/api/workspaces/[id]/files/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,5 @@ describe('/api/workspaces/[id]/files', () => {
}
expect(collection).toHaveBeenCalledTimes(2)
})

it('rejects a kind or size bucket the list does not know', async () => {
expect((await list('?types=spreadsheet')).status).toBe(400)
expect((await list('?sizes=huge')).status).toBe(400)
expect(collection).not.toHaveBeenCalled()
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @vitest-environment jsdom
*
* The table's load-more footer keeps two concerns apart: its spinner shows only while the next
* page loads, and its sentinel waits out any request for the list — a background refetch
* included — then asks again once it settles, so reaching the bottom mid-refetch never stalls.
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource'

/** Each observed sentinel, reported as on screen the moment it is observed. */
class OnScreenObserver {
constructor(private readonly report: IntersectionObserverCallback) {}
observe(target: Element) {
this.report(
[{ isIntersecting: true, target } as unknown as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}
disconnect() {}
unobserve() {}
takeRecords() {
return []
}
}

const columns = [{ id: 'name', header: 'Name' }]
const rows = [{ id: 'a', cells: { name: { label: 'a.md' } } }]

describe('Resource.Table paging footer', () => {
let root: Root
let container: HTMLDivElement

beforeEach(() => {
vi.stubGlobal('IntersectionObserver', OnScreenObserver)
container = document.createElement('div')
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
vi.unstubAllGlobals()
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
})

function render(props: { isLoadingMore: boolean; isFetching: boolean; onLoadMore: () => void }) {
act(() => {
root.render(<Resource.Table columns={columns} rows={rows} hasMore {...props} />)
})
}

it('asks for the next page once a background refetch it reached the bottom during settles', () => {
const onLoadMore = vi.fn()
render({ isLoadingMore: false, isFetching: true, onLoadMore })
expect(onLoadMore).not.toHaveBeenCalled()

render({ isLoadingMore: false, isFetching: false, onLoadMore })
expect(onLoadMore).toHaveBeenCalledTimes(1)
})

it('shows the bottom spinner for a next page, never for a background refetch', () => {
const onLoadMore = vi.fn()
/** The footer is the sentinel the table mounts below its rows while more pages remain. */
const footer = () => container.querySelector('.py-3')
render({ isLoadingMore: false, isFetching: true, onLoadMore })
expect(footer()).not.toBeNull()
expect(footer()?.childElementCount).toBe(0)

render({ isLoadingMore: true, isFetching: true, onLoadMore })
expect(footer()?.childElementCount).toBe(1)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,6 @@ describe('fetchWorkspaceResourceRef', () => {
).resolves.toMatchObject({ type: 'file', id: 'wf_b', path: 'files/b/notes.md' })
})

it('resolves a unique name with one batched lookup and its name search', async () => {
serveWorkspace([file({ id: 'wf_a', name: 'notes.md' }), file({ id: 'wf_c', name: 'plan.md' })])
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })

await expect(fetchWorkspaceResourceRef(client, 'ws-1', ref())).resolves.toMatchObject({
id: 'wf_a',
})
expect(
apiClientRequestMockFns.mockRequestJson.mock.calls.filter(
([contract]) => contract === lookupWorkspaceFilesContract
)
).toHaveLength(1)
})

it('asks for each candidate once, as a reference, which already answers an exact id', async () => {
serveWorkspace([file({ id: 'wf_a', name: 'notes.md' })])
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/hooks/queries/utils/workspace-file-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ describe('updateCachedWorkspaceFile', () => {
})

describe('cached file seeds', () => {
it('finds the freshest copy across reads, by one scan for a batch', () => {
it('finds the freshest copy across live reads, never one from an archived list', () => {
const client = new QueryClient()
client.setQueryData(liveKey, pages([file('a', { name: 'old.md' })]), { updatedAt: 1 })
client.setQueryData(workspaceFilesKeys.record('ws-1', 'a'), file('a', { name: 'new.md' }), {
Expand Down
17 changes: 14 additions & 3 deletions apps/sim/hooks/queries/utils/workspace-file-lookup-batcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { flushMicrotasks } from '@sim/testing/helpers/async'
import { createDeferred } from '@sim/testing/helpers/deferred'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
MAX_WORKSPACE_FILE_LOOKUP_ENTRIES,
MAX_WORKSPACE_FILE_LOOKUP_VALUE_LENGTH,
} from '@/lib/api/contracts/workspace-files'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { createWorkspaceFileLookupBatcher } from '@/hooks/queries/utils/workspace-file-lookup-batcher'

Expand Down Expand Up @@ -77,15 +81,22 @@ describe('createWorkspaceFileLookupBatcher', () => {
})

it('splits a kind past the per-request cap across requests, resolving every value', async () => {
const files = Array.from({ length: 150 }, (_, index) => record(`wf_${index}`))
const remainder = MAX_WORKSPACE_FILE_LOOKUP_ENTRIES / 2
const files = Array.from(
{ length: MAX_WORKSPACE_FILE_LOOKUP_ENTRIES + remainder },
(_, index) => record(`wf_${index}`)
)
const fetchLookup = serve(files)
const batcher = createWorkspaceFileLookupBatcher(fetchLookup)

const results = Promise.all(files.map((file) => batcher.load('ws-1', 'id', file.id)))
await vi.runAllTimersAsync()

expect(await results).toEqual(files)
expect(fetchLookup.mock.calls.map(([, body]) => body.ids?.length)).toEqual([100, 50])
expect(fetchLookup.mock.calls.map(([, body]) => body.ids?.length)).toEqual([
MAX_WORKSPACE_FILE_LOOKUP_ENTRIES,
remainder,
])
})

it('drops a lookup aborted before its batch is sent, and rejects one aborted after alone', async () => {
Expand Down Expand Up @@ -120,7 +131,7 @@ describe('createWorkspaceFileLookupBatcher', () => {
const batcher = createWorkspaceFileLookupBatcher(fetchLookup)

const results = Promise.all([
batcher.load('ws-1', 'key', 'x'.repeat(1025)),
batcher.load('ws-1', 'key', 'x'.repeat(MAX_WORKSPACE_FILE_LOOKUP_VALUE_LENGTH + 1)),
batcher.load('ws-1', 'reference', ''),
batcher.load('ws-1', 'id', a.id),
])
Expand Down
90 changes: 54 additions & 36 deletions apps/sim/hooks/queries/workspace-files.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,35 +282,23 @@ describe('useWorkspaceFileContent stale storage key', () => {
// is what would spin.
expect(refetchQueries).toHaveBeenCalledTimes(1)
/** Only the reads that can hold this file's key: its record and the lookups that found it. */
const [filter] = refetchQueries.mock.calls[0] as unknown as [
{
predicate: (query: {
queryHash: string
queryKey: unknown[]
state: { data: unknown }
}) => boolean
},
]
const query = (queryKey: readonly unknown[], data: unknown = null) => ({
queryKey: [...queryKey],
queryHash: JSON.stringify(queryKey),
state: { data },
})
expect(filter.predicate(query(workspaceFilesKeys.record('ws-1', 'file-1')))).toBe(true)
expect(
filter.predicate(
query(workspaceFilesKeys.lookup('ws-1', 'reference', 'doc.md'), fileRecord('file-1'))
)
).toBe(true)
expect(
filter.predicate(query(workspaceFilesKeys.lookup('ws-1', 'id', 'other'), fileRecord('other')))
).toBe(false)
expect(filter.predicate(query(workspaceFilesKeys.record('ws-1', 'other')))).toBe(false)
const record = workspaceFilesKeys.record('ws-1', 'file-1')
const lookup = workspaceFilesKeys.lookup('ws-1', 'reference', 'doc.md')
queryClient.setQueryData(record, fileRecord('file-1'))
queryClient.setQueryData(lookup, fileRecord('file-1'))
queryClient.setQueryData(workspaceFilesKeys.lookup('ws-1', 'id', 'other'), fileRecord('other'))
queryClient.setQueryData(workspaceFilesKeys.record('ws-1', 'other'), fileRecord('other'))
queryClient.setQueryData(workspaceFilesKeys.search('ws-1', { q: 'doc', limit: 20 }), [
fileRecord('file-1'),
])
const [filter] = refetchQueries.mock.calls[0]
expect(
filter.predicate(
query(workspaceFilesKeys.search('ws-1', { q: 'doc', limit: 20 }), [fileRecord('file-1')])
)
).toBe(false)
queryClient
.getQueryCache()
.findAll({ ...filter, type: 'all' })
.map((query) => query.queryKey)
).toEqual(expect.arrayContaining([record, lookup]))
expect(queryClient.getQueryCache().findAll({ ...filter, type: 'all' })).toHaveLength(2)
unmount()
})
})
Expand Down Expand Up @@ -709,33 +697,38 @@ describe('invalidateWorkspaceFileBrowsers', () => {
it('settles only once the mounted list and search reads have refetched', async () => {
let listCalls = 0
let searchCalls = 0
const refetch = createDeferred<void>()
const listRefetch = createDeferred<void>()
const searchRefetch = createDeferred<void>()
mockRequestJson.mockImplementation(async (contract) => {
if (contract === listWorkspaceFilesContract) {
if (++listCalls > 1) await refetch.promise
if (++listCalls > 1) await listRefetch.promise
return { success: true, files: [fileRecord('a')], nextCursor: null }
}
if (contract === searchWorkspaceFilesContract) {
searchCalls++
if (++searchCalls > 1) await searchRefetch.promise
return { success: true, files: [fileRecord('a')] }
}
return { success: true, folders: [] }
})
const params = workspaceFileListParams({ folderId: null, sortBy: 'name', sortOrder: 'asc' })
const hook = mountHook(() => useWorkspaceFileList('ws-1', params))
const hook = mountHook(() => ({
list: useWorkspaceFileList('ws-1', params),
search: useWorkspaceFileSearch('ws-1', { q: 'a', limit: 20 }),
}))
try {
await vi.waitFor(() => expect(hook.result().data).toBeDefined())
await vi.waitFor(() => expect(hook.result().list.data).toBeDefined())
await vi.waitFor(() => expect(hook.result().search.data).toBeDefined())
let done = false
const invalidation = invalidateWorkspaceFileBrowsers(hook.client, 'ws-1').then(() => {
done = true
})
await vi.waitFor(() => expect(listCalls).toBe(2))
await vi.waitFor(() => expect([listCalls, searchCalls]).toEqual([2, 2]))
listRefetch.resolve()
await flushMacrotask()
expect(done).toBe(false)
refetch.resolve()
searchRefetch.resolve()
await invalidation
expect(done).toBe(true)
expect(searchCalls).toBe(0)
} finally {
hook.unmount()
}
Expand Down Expand Up @@ -858,3 +851,28 @@ describe('useWorkspaceImageDimensionsAdapter', () => {
}
})
})

describe('useWorkspaceFileSearch placeholder', () => {
it("keeps the last matches while a new query loads, but never another workspace's", async () => {
const pending = createDeferred<unknown>()
mockRequestJson.mockImplementation(async (_contract, input) => {
const { q } = (input as { query: { q: string } }).query
if (q === 'first') return { success: true, files: [fileRecord('first')] }
return pending.promise
})
let workspaceId = 'ws-1'
let q = 'first'
const hook = mountHook(() => useWorkspaceFileSearch(workspaceId, { q, limit: 20 }))
try {
await vi.waitFor(() => expect(hook.result().data?.[0]?.id).toBe('first'))
q = 'second'
hook.rerender()
expect(hook.result().data?.[0]?.id).toBe('first')
workspaceId = 'ws-2'
hook.rerender()
expect(hook.result().data).toBeUndefined()
} finally {
hook.unmount()
}
})
})
7 changes: 0 additions & 7 deletions apps/sim/lib/uploads/contexts/workspace/file-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,3 @@ describe('queryWorkspaceFiles', () => {
).rejects.toMatchObject({ code: 'validation' })
})
})

/**
* `listWorkspaceFiles` materializes a whole scope, so what it reads per row is
* multiplied by the size of the workspace. These assertions pin the two ways that
* stays bounded: the projection, and the optional row cap its one budgeted caller
* (the workspace layout's server seed) uses to detect an oversized workspace.
*/
Original file line number Diff line number Diff line change
Expand Up @@ -647,15 +647,6 @@ describe('bounded workspace file reads in PostgreSQL', () => {
}
)

it('labels each file as its Type column does', async () => {
const rows = await db.execute<{ id: string; label: string }>(sql`
SELECT id, ${workspaceFileTypeLabelExpression} AS label FROM ${workspaceFiles}
WHERE workspace_id = ${sortWorkspaceId}`)
expect(new Map(rows.map((row) => [row.id, row.label]))).toEqual(
new Map(seeded.map((file) => [file.id, file.label]))
)
})

/**
* Neither key is indexed, so a page sorts its scope — but only its scope: the folder
* predicate must still seek the folder index rather than reading every live file.
Expand Down Expand Up @@ -700,15 +691,21 @@ describe('bounded workspace file reads in PostgreSQL', () => {
/**
* Every extension the MIME table knows, stored three ways: declaring nothing (so the
* extension decides), declaring the generic type (likewise), and declaring its own type (so
* the declared type decides). The SQL must agree with the JS rules for each.
* the declared type decides) — plus names the table does not know, with and without an
* extension. The SQL must agree with the JS rules for each.
*/
it('labels and classifies a file of every known extension as the JS rules do', async () => {
const cases = Object.keys(EXTENSION_TO_MIME).flatMap((extension) =>
['', 'application/octet-stream', EXTENSION_TO_MIME[extension]].map((contentType) => ({
name: `file.${extension}`,
contentType,
}))
)
const cases = [
...Object.keys(EXTENSION_TO_MIME).flatMap((extension) =>
['', 'application/octet-stream', EXTENSION_TO_MIME[extension]].map((contentType) => ({
name: `file.${extension}`,
contentType,
}))
),
{ name: 'noext', contentType: 'application/x-custom' },
{ name: 'file.foo', contentType: 'application/octet-stream' },
{ name: 'file.foo', contentType: '' },
]
const ids = new Map<string, { name: string; contentType: string }>()
for (const [index, fixture] of cases.entries()) {
const file = await insertFile({
Expand Down
Loading