Skip to content

Commit 8f75a88

Browse files
committed
fix(sidebar): hold the rail flyout until its lists resolve for this workspace
Both the resource and folder queries keep the previous workspace's rows as placeholder data across a switch. Gating only on isPending let the flyout build a tree from one workspace's resources against another's folders, where no folder id resolves — which the builder reads as "archived out from under it" and files the whole list at the root. Gate on isPlaceholderData too, matching foldersResolved in use-folder-ancestors. An error settles a query without resolving it and is deliberately not held: the flyout then renders flat, which still reaches every row.
1 parent 49700d0 commit 8f75a88

2 files changed

Lines changed: 158 additions & 8 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockUseTablesList, mockUseFolders, mockUsePinnedIds } = vi.hoisted(() => ({
9+
mockUseTablesList: vi.fn(),
10+
mockUseFolders: vi.fn(),
11+
mockUsePinnedIds: vi.fn(),
12+
}))
13+
14+
vi.mock('next/link', () => ({
15+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
16+
<a href={href}>{children}</a>
17+
),
18+
}))
19+
vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'w1' }) }))
20+
vi.mock('@/hooks/queries/tables', () => ({ useTablesList: mockUseTablesList }))
21+
vi.mock('@/hooks/queries/folders', () => ({ useFolders: mockUseFolders }))
22+
vi.mock('@/hooks/queries/pinned-items', () => ({ usePinnedIds: mockUsePinnedIds }))
23+
vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: vi.fn() }))
24+
vi.mock('@/hooks/queries/workspace-file-folders', () => ({ useWorkspaceFileFolders: vi.fn() }))
25+
26+
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@sim/emcn'
27+
import { TablesRailFlyout } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout'
28+
29+
type QueryStub = { data?: unknown; isPending: boolean; isPlaceholderData: boolean }
30+
31+
const resolved = (data: unknown): QueryStub => ({
32+
data,
33+
isPending: false,
34+
isPlaceholderData: false,
35+
})
36+
const placeholder = (data: unknown): QueryStub => ({
37+
data,
38+
isPending: false,
39+
isPlaceholderData: true,
40+
})
41+
42+
const TABLE = {
43+
id: 't1',
44+
name: 'Leads',
45+
folderId: 'f1',
46+
updatedAt: new Date('2026-01-01'),
47+
}
48+
const FOLDER = { id: 'f1', name: 'Sales', parentId: null, updatedAt: new Date('2026-01-01') }
49+
50+
describe('TablesRailFlyout', () => {
51+
let container: HTMLDivElement
52+
let root: Root
53+
54+
beforeEach(() => {
55+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
56+
vi.stubGlobal(
57+
'ResizeObserver',
58+
class {
59+
observe() {}
60+
unobserve() {}
61+
disconnect() {}
62+
}
63+
)
64+
mockUsePinnedIds.mockReturnValue(new Set<string>())
65+
container = document.createElement('div')
66+
document.body.appendChild(container)
67+
root = createRoot(container)
68+
})
69+
70+
afterEach(() => {
71+
act(() => root.unmount())
72+
container.remove()
73+
vi.clearAllMocks()
74+
vi.unstubAllGlobals()
75+
})
76+
77+
function render() {
78+
act(() => {
79+
root.render(
80+
<DropdownMenu open modal={false}>
81+
<DropdownMenuTrigger asChild>
82+
<button type='button'>rail</button>
83+
</DropdownMenuTrigger>
84+
<DropdownMenuContent>
85+
<TablesRailFlyout workspaceId='w1' />
86+
</DropdownMenuContent>
87+
</DropdownMenu>
88+
)
89+
})
90+
return document.body.textContent ?? ''
91+
}
92+
93+
it('nests a table under its folder once both lists have resolved', () => {
94+
mockUseTablesList.mockReturnValue(resolved([TABLE]))
95+
mockUseFolders.mockReturnValue(resolved([FOLDER]))
96+
97+
const text = render()
98+
99+
expect(text).toContain('Sales')
100+
expect(text).not.toContain('Loading...')
101+
/* The table sits inside the folder's submenu, which is closed until hovered. */
102+
expect(document.querySelector('a[href="/workspace/w1/tables/t1"]')).toBeNull()
103+
})
104+
105+
it('waits rather than filing tables at the root while the folders are the previous workspace’s', () => {
106+
mockUseTablesList.mockReturnValue(resolved([TABLE]))
107+
mockUseFolders.mockReturnValue(placeholder([]))
108+
109+
const text = render()
110+
111+
expect(text).toContain('Loading...')
112+
expect(text).not.toContain('Leads')
113+
})
114+
115+
it('waits while the tables themselves are still placeholder data', () => {
116+
mockUseTablesList.mockReturnValue(placeholder([TABLE]))
117+
mockUseFolders.mockReturnValue(resolved([FOLDER]))
118+
119+
expect(render()).toContain('Loading...')
120+
})
121+
122+
it('still lists every table when the folder query failed outright', () => {
123+
mockUseTablesList.mockReturnValue(resolved([TABLE]))
124+
mockUseFolders.mockReturnValue({ data: undefined, isPending: false, isPlaceholderData: false })
125+
126+
const text = render()
127+
128+
expect(text).not.toContain('Loading...')
129+
expect(document.querySelector('a[href="/workspace/w1/tables/t1"]')?.textContent).toContain(
130+
'Leads'
131+
)
132+
})
133+
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,29 @@ import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
2626
const TABLE_META = FOLDERED_RESOURCE_HEADERS.table
2727
const FILE_META = FOLDERED_RESOURCE_HEADERS.file
2828

29+
/**
30+
* A list is usable only once it has resolved for THIS workspace. Every list here keeps the
31+
* previous workspace's rows as placeholder data across a switch, and a tree built from one
32+
* workspace's resources against another's folders resolves no folder id at all — which the
33+
* builder reads as "archived out from under it" and files the whole list at the root. So the
34+
* rows wait for both queries rather than render a shape that is wrong and then jumps.
35+
*
36+
* `isPlaceholderData` is what separates that from a real result; a pending-only check is the
37+
* exact gate `tables.tsx` warns against. An error settles a query without resolving it, and is
38+
* deliberately not held here: the flyout then renders flat, which still reaches every row.
39+
*/
40+
function isResolving(query: { isPending: boolean; isPlaceholderData: boolean }): boolean {
41+
return query.isPending || query.isPlaceholderData
42+
}
43+
2944
export function TablesRailFlyout({ workspaceId }: { workspaceId: string }) {
3045
const params = useParams()
31-
const { data: tables, isPending: isTablesPending } = useTablesList(workspaceId)
32-
const { data: folders, isPending: isFoldersPending } = useFolders(workspaceId, {
33-
resourceType: 'table',
34-
})
46+
const tablesQuery = useTablesList(workspaceId)
47+
const foldersQuery = useFolders(workspaceId, { resourceType: 'table' })
3548
const pinnedTableIds = usePinnedIds(workspaceId, 'table')
3649
const pinnedFolderIds = usePinnedIds(workspaceId, 'folder')
50+
const { data: tables } = tablesQuery
51+
const { data: folders } = foldersQuery
3752

3853
const entries = useMemo(
3954
() =>
@@ -52,18 +67,20 @@ export function TablesRailFlyout({ workspaceId }: { workspaceId: string }) {
5267
entries={entries}
5368
icon={TABLE_META.rootIcon}
5469
currentItemId={typeof params.tableId === 'string' ? params.tableId : undefined}
55-
isLoading={isTablesPending || isFoldersPending}
70+
isLoading={isResolving(tablesQuery) || isResolving(foldersQuery)}
5671
emptyLabel='No tables yet'
5772
/>
5873
)
5974
}
6075

6176
export function FilesRailFlyout({ workspaceId }: { workspaceId: string }) {
6277
const params = useParams()
63-
const { data: files, isPending: isFilesPending } = useWorkspaceFiles(workspaceId)
64-
const { data: folders, isPending: isFoldersPending } = useWorkspaceFileFolders(workspaceId)
78+
const filesQuery = useWorkspaceFiles(workspaceId)
79+
const foldersQuery = useWorkspaceFileFolders(workspaceId)
6580
const pinnedFileIds = usePinnedIds(workspaceId, 'file')
6681
const pinnedFolderIds = usePinnedIds(workspaceId, 'folder')
82+
const { data: files } = filesQuery
83+
const { data: folders } = foldersQuery
6784

6885
const entries = useMemo(
6986
() =>
@@ -82,7 +99,7 @@ export function FilesRailFlyout({ workspaceId }: { workspaceId: string }) {
8299
entries={entries}
83100
icon={FILE_META.rootIcon}
84101
currentItemId={typeof params.fileId === 'string' ? params.fileId : undefined}
85-
isLoading={isFilesPending || isFoldersPending}
102+
isLoading={isResolving(filesQuery) || isResolving(foldersQuery)}
86103
emptyLabel='No files yet'
87104
/>
88105
)

0 commit comments

Comments
 (0)