Skip to content

Commit 76537c8

Browse files
authored
improvement(files): react-doctor performance pass on the files module (#5330)
* improvement(files): react-doctor performance pass on the files module * fix(files): use spread-sort not toSorted in client (Safari<16/iOS15 crash) SWC does not polyfill Array.prototype.toSorted and the repo sets no core-js/browserslist target, so it throws on Safari <16 / iOS 15. Revert the two client-side toSorted calls to [...arr].sort() and correct the harness guidance in sim-components.md accordingly.
1 parent 6715354 commit 76537c8

3 files changed

Lines changed: 51 additions & 29 deletions

File tree

.claude/rules/sim-components.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,21 @@ Component authoring rules — structure order (refs → external hooks → store
1212
- `'use client'` only when using React hooks or browser-only APIs.
1313
- Prefer semantic HTML (`aside`, `nav`, `article`).
1414
- Optional-chain callbacks: `onAction?.(id)`.
15+
16+
## List-render performance
17+
18+
When rendering or sorting a list of rows against a lookup collection (members, folders, tags), keep the per-row work O(1):
19+
20+
- **Precompute a lookup `Map` once**, never `array.find(...)` per row. Build `const byId = useMemo(() => { const m = new Map<string, T>(); for (const x of items ?? []) m.set(x.id, x); return m }, [items])` and read `byId.get(id)` in the sort comparator, `.map(...)`, and cell builders. A `.find` inside a sort comparator is O(n²·log n) — the worst offender. Depend memos on the derived `Map`, not the raw array.
21+
- **Sort with `[...array].sort(cmp)` in client code — NOT `array.toSorted(cmp)`.** SWC (Next's compiler) transforms syntax but does not polyfill prototype methods, and the repo sets no core-js/browserslist target, so `toSorted`/`toReversed`/`toSpliced`/`Array.prototype.with` ship as-is and throw `TypeError` on Safari <16 / iOS 15 (they landed in Safari 16). `tsconfig` `lib: ES2023` only affects type-checking, never the runtime. The `[...arr].sort()` spread copy is the intended cost — it keeps the sort non-mutating without an unpolyfilled builtin. These methods are only safe in server-only modules (no `'use client'`, executed on Node ≥20). react-doctor's `js-tosorted-immutable` is therefore a won't-fix in client components.
22+
- **Partition in a single pass** — when splitting one collection into several (`fileIds`/`folderIds`), do one `for…of` pushing into each bucket and return `{ a, b }` from a single `useMemo`, not two memos that each `map→filter→map` the same source twice.
23+
24+
## react-doctor (`npx react-doctor`) — apply the wins, skip the false positives
25+
26+
react-doctor diagnostics are hypotheses, not verdicts — confirm against the code before acting, and preserve behavior. Known repo-specific false positives to NOT "fix":
27+
28+
- `no-barrel-import` — barrel imports are the repo convention (see sim-imports.md, "Barrel Exports"). Keep them.
29+
- `js-tosorted-immutable` — in `'use client'` code, keep `[...arr].sort(cmp)`; `toSorted` is unpolyfilled and crashes Safari <16 / iOS 15 (see "List-render performance" above). Only apply it in server-only modules.
30+
- `rerender-state-only-in-handlers` / "state set but never rendered" — a false positive when the `useState` is consumed by a `useEffect`/`useLayoutEffect` dependency (the effect must re-run on change). Only convert to a ref when nothing reads the value reactively.
31+
- `async-await-in-loop` on an upload/progress loop where sequential execution is intentional (per-item progress, server backpressure) — leave it.
32+
- Broad refactors (`prefer-useReducer` for many `useState`, `no-giant-component` splits) — out of scope for a perf pass; note, don't churn.

apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,20 @@ const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps)
2929
/**
3030
* Resolves a user ID into a ResourceCell with an avatar icon and display name.
3131
* Returns null label while members are still loading to avoid flashing raw IDs.
32+
*
33+
* Accepts either the raw member array or a precomputed `userId → member` map.
34+
* Prefer the map form when resolving many rows so lookups stay O(1) instead of
35+
* scanning the array per row.
3236
*/
3337
export function ownerCell(
3438
userId: string | null | undefined,
35-
members?: WorkspaceMember[]
39+
members?: WorkspaceMember[] | Map<string, WorkspaceMember>
3640
): ResourceCell {
3741
if (!userId) return { label: null }
3842
if (!members) return { label: null }
3943

40-
const member = members.find((m) => m.userId === userId)
44+
const member =
45+
members instanceof Map ? members.get(userId) : members.find((m) => m.userId === userId)
4146
if (!member) return { label: null }
4247

4348
return {

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

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-op
7878
import { filesParsers, filesUrlKeys } from '@/app/workspace/[workspaceId]/files/search-params'
7979
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
8080
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
81-
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
81+
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
8282
import {
8383
useBulkArchiveWorkspaceFileItems,
8484
useCreateWorkspaceFileFolder,
@@ -197,6 +197,11 @@ export function Files() {
197197
const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId)
198198
const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId)
199199
const { data: members } = useWorkspaceMembersQuery(workspaceId)
200+
const membersById = useMemo(() => {
201+
const map = new Map<string, WorkspaceMember>()
202+
for (const member of members ?? []) map.set(member.userId, member)
203+
return map
204+
}, [members])
200205
const uploadFile = useUploadWorkspaceFile()
201206
const notifyLimit = useLimitUpgradeToast()
202207
const deleteFile = useDeleteWorkspaceFile()
@@ -416,8 +421,8 @@ export function Files() {
416421
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
417422
break
418423
case 'owner':
419-
cmp = (members?.find((m) => m.userId === a.uploadedBy)?.name ?? '').localeCompare(
420-
members?.find((m) => m.userId === b.uploadedBy)?.name ?? ''
424+
cmp = (membersById.get(a.uploadedBy)?.name ?? '').localeCompare(
425+
membersById.get(b.uploadedBy)?.name ?? ''
421426
)
422427
break
423428
}
@@ -431,7 +436,7 @@ export function Files() {
431436
sizeFilter,
432437
uploadedByFilter,
433438
activeSort,
434-
members,
439+
membersById,
435440
])
436441

437442
const baseRows: ResourceRow[] = useMemo(() => {
@@ -453,7 +458,7 @@ export function Files() {
453458
label: 'Folder',
454459
},
455460
created: timeCell(folder.createdAt),
456-
owner: ownerCell(folder.userId, members),
461+
owner: ownerCell(folder.userId, membersById),
457462
updated: timeCell(folder.updatedAt),
458463
},
459464
}))
@@ -475,15 +480,15 @@ export function Files() {
475480
label: formatFileType(file.type, file.name),
476481
},
477482
created: timeCell(file.uploadedAt),
478-
owner: ownerCell(file.uploadedBy, members),
483+
owner: ownerCell(file.uploadedBy, membersById),
479484
updated: timeCell(file.updatedAt),
480485
},
481486
}
482487
return row
483488
})
484489

485490
return [...folderRows, ...fileRows]
486-
}, [visibleFolders, filteredFiles, members, folderSizeMap])
491+
}, [visibleFolders, filteredFiles, membersById, folderSizeMap])
487492

488493
const rows: ResourceRow[] = useMemo(() => {
489494
if (!listRename.editingId) return baseRows
@@ -525,22 +530,16 @@ export function Files() {
525530

526531
const isAllSelected =
527532
visibleRowIds.length > 0 && visibleRowIds.every((id) => selectedRowIds.has(id))
528-
const selectedFileIds = useMemo(
529-
() =>
530-
Array.from(selectedRowIds)
531-
.map(parseRowId)
532-
.filter((item) => item.kind === 'file')
533-
.map((item) => item.id),
534-
[selectedRowIds]
535-
)
536-
const selectedFolderIds = useMemo(
537-
() =>
538-
Array.from(selectedRowIds)
539-
.map(parseRowId)
540-
.filter((item) => item.kind === 'folder')
541-
.map((item) => item.id),
542-
[selectedRowIds]
543-
)
533+
const { selectedFileIds, selectedFolderIds } = useMemo(() => {
534+
const fileIds: string[] = []
535+
const folderIds: string[] = []
536+
for (const rowId of selectedRowIds) {
537+
const item = parseRowId(rowId)
538+
if (item.kind === 'file') fileIds.push(item.id)
539+
else folderIds.push(item.id)
540+
}
541+
return { selectedFileIds: fileIds, selectedFolderIds: folderIds }
542+
}, [selectedRowIds])
544543

545544
const selectableConfig = useMemo(
546545
() => ({
@@ -1749,7 +1748,7 @@ export function Files() {
17491748
uploadedByFilter.length === 0
17501749
? 'All'
17511750
: uploadedByFilter.length === 1
1752-
? (members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member')
1751+
? (membersById.get(uploadedByFilter[0])?.name ?? '1 member')
17531752
: `${uploadedByFilter.length} members`
17541753

17551754
return (
@@ -1831,7 +1830,7 @@ export function Files() {
18311830
)}
18321831
</div>
18331832
)
1834-
}, [typeFilter, sizeFilter, uploadedByFilter, memberOptions, members, hasActiveFilters])
1833+
}, [typeFilter, sizeFilter, uploadedByFilter, memberOptions, membersById, hasActiveFilters])
18351834

18361835
const filterTags: FilterTag[] = useMemo(() => {
18371836
const tags: FilterTag[] = []
@@ -1863,12 +1862,12 @@ export function Files() {
18631862
if (uploadedByFilter.length > 0) {
18641863
const label =
18651864
uploadedByFilter.length === 1
1866-
? `Uploaded by: ${members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member'}`
1865+
? `Uploaded by: ${membersById.get(uploadedByFilter[0])?.name ?? '1 member'}`
18671866
: `Uploaded by: ${uploadedByFilter.length} members`
18681867
tags.push({ label, onRemove: () => setUploadedByFilter([]) })
18691868
}
18701869
return tags
1871-
}, [typeFilter, sizeFilter, uploadedByFilter, members])
1870+
}, [typeFilter, sizeFilter, uploadedByFilter, membersById])
18721871

18731872
if (fileIdFromRoute && !selectedFile && isLoading) {
18741873
return (

0 commit comments

Comments
 (0)