From 6bdf0a6584d9b3d8133e3c15e0eeb2b2a7d31f79 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 1 Jul 2026 12:37:50 -0700 Subject: [PATCH 1/3] improvement(knowledge): react-doctor perf pass across the knowledge base module --- .claude/rules/sim-react-performance.md | 123 +++++++++++++++++ .cursor/rules/sim-react-performance.mdc | 127 ++++++++++++++++++ .../knowledge/[id]/[documentId]/document.tsx | 24 ++-- .../knowledge/[id]/[documentId]/page.tsx | 3 +- .../[workspaceId]/knowledge/[id]/base.tsx | 34 ++--- .../base-tags-modal/base-tags-modal.tsx | 10 +- .../connector-selector-field.tsx | 3 +- .../connectors-section/connectors-section.tsx | 15 ++- .../edit-connector-modal.tsx | 16 +-- .../[id]/hooks/use-connector-config-fields.ts | 12 +- .../[workspaceId]/knowledge/[id]/page.tsx | 3 +- .../components/base-card/base-card.tsx | 9 +- .../knowledge/hooks/use-knowledge-upload.ts | 55 ++++---- .../[workspaceId]/knowledge/knowledge.tsx | 2 +- .../[workspaceId]/knowledge/utils/sort.ts | 2 +- 15 files changed, 352 insertions(+), 86 deletions(-) create mode 100644 .claude/rules/sim-react-performance.md create mode 100644 .cursor/rules/sim-react-performance.mdc diff --git a/.claude/rules/sim-react-performance.md b/.claude/rules/sim-react-performance.md new file mode 100644 index 00000000000..1f4cb29f07f --- /dev/null +++ b/.claude/rules/sim-react-performance.md @@ -0,0 +1,123 @@ +# React & JS Performance Patterns + +Hot-path patterns that keep renders cheap and lists fast. These mirror the +`react-doctor` ruleset that runs on the codebase — writing them right the first +time keeps the health score up. Apply in components, hooks, and server +components under `apps/sim/**`. + +## Stable references for memo/dep correctness + +A value rebuilt every render defeats every `useMemo`/`useCallback` that depends +on it — the memo recomputes every time and child components re-render. + +- **Never leave an array/object fallback inline in render** when it feeds a hook + dep. `const items = raw ?? []` creates a fresh `[]` every render. Wrap the + whole computation in `useMemo` so the reference is stable: + + ```typescript + // ✗ Bad — new array identity every render, downstream memos never cache + const displayChunks = rawChunks ?? [] + + // ✓ Good + const displayChunks = useMemo(() => rawChunks ?? [], [rawChunks]) + ``` + + Same for a conditional array (`cond ? a.slice() : []`) or `cond ? x ?? [] : []` + — memoize it with the inputs as deps. + +- **Lazy-init refs — never `useRef(new Set())` / `useRef(new Map())`.** The + argument is evaluated every render and thrown away. Initialize on first use: + + ```typescript + // ✗ Bad — allocates a Set every render + const timers = useRef>(new Set()) + + // ✓ Good — allocated once + const timersRef = useRef | null>(null) + timersRef.current ??= new Set() + // read as `timersRef.current ?? []` in effects/callbacks (ref is dep-exempt) + ``` + +- **Hoist pure functions to module scope.** A helper inside a component/hook that + closes over nothing local is rebuilt every render. Move it out of the + component so it's defined once. + +## One pass, not two — collapse iteration chains + +Every chained array method is another full traversal and another intermediate +array. Collapse them: + +- `.filter(...).map(...)` / `.map(...).filter(...)` → single `.reduce()` that + pushes only the kept, transformed items. Preserve order and the exact + predicate — `reduce` keeps first-seen order like the chain did. + + ```typescript + // ✗ Bad — two passes, one throwaway array + const opts = types.filter((t) => hasSlot(t)).map((t) => toOption(t)) + + // ✓ Good — one pass + const opts = types.reduce((acc, t) => { + if (hasSlot(t)) acc.push(toOption(t)) + return acc + }, []) + ``` + +- `.map(...).filter(Boolean)` → `.flatMap(x => keep ? [value] : [])`. +- `.map(...).filter(...).map(...)` → one `reduce`. + +## Map lookups, not `.find()` in a loop + +`array.find()` is O(n); calling it inside a loop over the same array is O(n²). +Build a `Map` once before the loop, then do O(1) lookups: + +```typescript +// ✗ Bad — O(n²) +for (const slot of slots) { + const def = definitions.find((d) => d.tagSlot === slot) +} + +// ✓ Good — O(n) +const defBySlot = new Map(definitions.map((d) => [d.tagSlot, d])) +for (const slot of slots) { + const def = defBySlot.get(slot) +} +``` + +Note the semantic detail: `.find()` returns the **first** match; `Map` built +by `.map(...)` keeps the **last** value for a duplicate key. When keys are +unique (the common case) they're equivalent — but if duplicates are possible and +first-wins matters, build the map with a guard (`if (!m.has(k)) m.set(k, v)`). + +## Immutable sort — `toSorted`, not spread + `sort` + +`[...arr].sort()` copies the array just to sort it. `apps/sim` targets ES2023, +so use `arr.toSorted(...)` (non-mutating, no manual copy). Packages pinned to +ES2022 (`packages/tsconfig/base.json`) do **not** have `toSorted` — keep +`[...arr].sort()` there. + +## Independent awaits run in parallel + +Sequential `await`s that don't use each other's result double the wait. In +server components this delays first paint: + +```typescript +// ✗ Bad — waits twice +const { id } = await params +const { kbName } = await searchParams + +// ✓ Good — one wait +const [{ id }, { kbName }] = await Promise.all([params, searchParams]) +``` + +The same applies to independent data fetches inside a request handler. Only keep +awaits sequential when a later call genuinely consumes an earlier result, or when +sequencing is deliberate (rate-limited batches, retry loops). + +## Don't defeat these with false fixes + +- A `Date.now()` inside an `onClick`/event handler is fine — it is not a render + hydration mismatch. Only `Date.now()` reached during render is. +- Don't add a mutation **object** to a `useCallback`/`useMemo` dep array; the + `.mutate` fn from TanStack Query v5 is already stable (see `sim-queries.md`). +- Memoizing a value that is already primitive/stable adds overhead for nothing — + memoize arrays, objects, and functions, not booleans or strings. diff --git a/.cursor/rules/sim-react-performance.mdc b/.cursor/rules/sim-react-performance.mdc new file mode 100644 index 00000000000..1f8b032468b --- /dev/null +++ b/.cursor/rules/sim-react-performance.mdc @@ -0,0 +1,127 @@ +--- +description: React & JS performance patterns (stable refs, single-pass iteration, Map lookups, immutable sort, parallel awaits) +globs: ["apps/sim/**/*.tsx", "apps/sim/**/*.ts"] +--- +# React & JS Performance Patterns + +Hot-path patterns that keep renders cheap and lists fast. These mirror the +`react-doctor` ruleset that runs on the codebase — writing them right the first +time keeps the health score up. Apply in components, hooks, and server +components under `apps/sim/**`. + +## Stable references for memo/dep correctness + +A value rebuilt every render defeats every `useMemo`/`useCallback` that depends +on it — the memo recomputes every time and child components re-render. + +- **Never leave an array/object fallback inline in render** when it feeds a hook + dep. `const items = raw ?? []` creates a fresh `[]` every render. Wrap the + whole computation in `useMemo` so the reference is stable: + + ```typescript + // ✗ Bad — new array identity every render, downstream memos never cache + const displayChunks = rawChunks ?? [] + + // ✓ Good + const displayChunks = useMemo(() => rawChunks ?? [], [rawChunks]) + ``` + + Same for a conditional array (`cond ? a.slice() : []`) or `cond ? x ?? [] : []` + — memoize it with the inputs as deps. + +- **Lazy-init refs — never `useRef(new Set())` / `useRef(new Map())`.** The + argument is evaluated every render and thrown away. Initialize on first use: + + ```typescript + // ✗ Bad — allocates a Set every render + const timers = useRef>(new Set()) + + // ✓ Good — allocated once + const timersRef = useRef | null>(null) + timersRef.current ??= new Set() + // read as `timersRef.current ?? []` in effects/callbacks (ref is dep-exempt) + ``` + +- **Hoist pure functions to module scope.** A helper inside a component/hook that + closes over nothing local is rebuilt every render. Move it out of the + component so it's defined once. + +## One pass, not two — collapse iteration chains + +Every chained array method is another full traversal and another intermediate +array. Collapse them: + +- `.filter(...).map(...)` / `.map(...).filter(...)` → single `.reduce()` that + pushes only the kept, transformed items. Preserve order and the exact + predicate — `reduce` keeps first-seen order like the chain did. + + ```typescript + // ✗ Bad — two passes, one throwaway array + const opts = types.filter((t) => hasSlot(t)).map((t) => toOption(t)) + + // ✓ Good — one pass + const opts = types.reduce((acc, t) => { + if (hasSlot(t)) acc.push(toOption(t)) + return acc + }, []) + ``` + +- `.map(...).filter(Boolean)` → `.flatMap(x => keep ? [value] : [])`. +- `.map(...).filter(...).map(...)` → one `reduce`. + +## Map lookups, not `.find()` in a loop + +`array.find()` is O(n); calling it inside a loop over the same array is O(n²). +Build a `Map` once before the loop, then do O(1) lookups: + +```typescript +// ✗ Bad — O(n²) +for (const slot of slots) { + const def = definitions.find((d) => d.tagSlot === slot) +} + +// ✓ Good — O(n) +const defBySlot = new Map(definitions.map((d) => [d.tagSlot, d])) +for (const slot of slots) { + const def = defBySlot.get(slot) +} +``` + +Note the semantic detail: `.find()` returns the **first** match; `Map` built +by `.map(...)` keeps the **last** value for a duplicate key. When keys are +unique (the common case) they're equivalent — but if duplicates are possible and +first-wins matters, build the map with a guard (`if (!m.has(k)) m.set(k, v)`). + +## Immutable sort — `toSorted`, not spread + `sort` + +`[...arr].sort()` copies the array just to sort it. `apps/sim` targets ES2023, +so use `arr.toSorted(...)` (non-mutating, no manual copy). Packages pinned to +ES2022 (`packages/tsconfig/base.json`) do **not** have `toSorted` — keep +`[...arr].sort()` there. + +## Independent awaits run in parallel + +Sequential `await`s that don't use each other's result double the wait. In +server components this delays first paint: + +```typescript +// ✗ Bad — waits twice +const { id } = await params +const { kbName } = await searchParams + +// ✓ Good — one wait +const [{ id }, { kbName }] = await Promise.all([params, searchParams]) +``` + +The same applies to independent data fetches inside a request handler. Only keep +awaits sequential when a later call genuinely consumes an earlier result, or when +sequencing is deliberate (rate-limited batches, retry loops). + +## Don't defeat these with false fixes + +- A `Date.now()` inside an `onClick`/event handler is fine — it is not a render + hydration mismatch. Only `Date.now()` reached during render is. +- Don't add a mutation **object** to a `useCallback`/`useMemo` dep array; the + `.mutate` fn from TanStack Query v5 is already stable (see `sim-queries`). +- Memoizing a value that is already primitive/stable adds overhead for nothing — + memoize arrays, objects, and functions, not booleans or strings. diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index cabe6a73332..5f7457d6ce1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -219,15 +219,19 @@ export function Document({ ? Math.max(1, Math.min(currentPageFromURL, maxSearchPages)) : 1 const searchTotalPages = Math.max(1, maxSearchPages) - const searchStartIndex = (searchCurrentPage - 1) * SEARCH_PAGE_SIZE - const paginatedSearchResults = searchResults.slice( - searchStartIndex, - searchStartIndex + SEARCH_PAGE_SIZE - ) - - const rawDisplayChunks = showingSearch ? paginatedSearchResults : initialChunks - const displayChunks = rawDisplayChunks ?? [] + /** + * Stable chunk list for the current view. Memoized so the many downstream + * `useMemo`/`useCallback` hooks that depend on it don't recompute every render + * (search pagination `.slice()` otherwise yields a fresh array each time). + */ + const displayChunks = useMemo(() => { + if (showingSearch) { + const start = (searchCurrentPage - 1) * SEARCH_PAGE_SIZE + return searchResults.slice(start, start + SEARCH_PAGE_SIZE) + } + return initialChunks ?? [] + }, [showingSearch, searchResults, searchCurrentPage, initialChunks]) const currentPage = showingSearch ? searchCurrentPage : initialPage const totalPages = showingSearch ? searchTotalPages : initialTotalPages @@ -672,7 +676,7 @@ export function Document({ { onError: () => updateChunk(chunkId, { enabled: chunk.enabled }) } ) }, - [displayChunks, knowledgeBaseId, documentId, updateChunk] + [displayChunks, knowledgeBaseId, documentId, updateChunk, updateChunkMutation] ) const handleDeleteChunk = useCallback( @@ -983,7 +987,7 @@ export function Document({ ...editorBreadcrumbBase, { label: selectedChunk ? `Chunk #${selectedChunk.chunkIndex}` : '', terminal: true }, ], - [editorBreadcrumbBase, selectedChunk?.chunkIndex] + [editorBreadcrumbBase, selectedChunk] ) const loadingBreadcrumbs = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx index b15f99638ba..9403f0a2448 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx @@ -21,8 +21,7 @@ export async function generateMetadata({ searchParams }: DocumentPageProps): Pro } export default async function DocumentChunksPage({ params, searchParams }: DocumentPageProps) { - const { id, documentId } = await params - const { kbName, docName } = await searchParams + const [{ id, documentId }, { kbName, docName }] = await Promise.all([params, searchParams]) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index f43dfa29181..f3c673c8325 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -180,12 +180,13 @@ interface TagValue { */ function getDocumentTags(doc: DocumentData, definitions: TagDefinition[]): TagValue[] { const result: TagValue[] = [] + const defsBySlot = new Map(definitions.map((d) => [d.tagSlot, d])) for (const slot of ALL_TAG_SLOTS) { const raw = doc[slot] if (raw == null) continue - const def = definitions.find((d) => d.tagSlot === slot) + const def = defsBySlot.get(slot) const fieldType = def?.fieldType || getFieldTypeForSlot(slot) || 'text' let value: string @@ -263,22 +264,21 @@ export function KnowledgeBase({ const activeTagFilters: DocumentTagFilter[] = useMemo( () => - tagFilterEntries - .filter((f) => { - if (!f.tagSlot || !f.value.trim()) return false - // A `between` filter only applies once both bounds are set. Sending it - // with just the lower bound would be rejected at the API boundary and - // break the whole list while the user is still entering the range. - if (f.operator === 'between' && !f.valueTo.trim()) return false - return true - }) - .map((f) => ({ + tagFilterEntries.reduce((acc, f) => { + if (!f.tagSlot || !f.value.trim()) return acc + // A `between` filter only applies once both bounds are set. Sending it + // with just the lower bound would be rejected at the API boundary and + // break the whole list while the user is still entering the range. + if (f.operator === 'between' && !f.valueTo.trim()) return acc + acc.push({ tagSlot: f.tagSlot, fieldType: f.fieldType, operator: f.operator, value: f.value, ...(f.operator === 'between' ? { valueTo: f.valueTo } : {}), - })), + }) + return acc + }, []), [tagFilterEntries] ) @@ -1023,9 +1023,9 @@ export function KnowledgeBase({ }, ] : []), - ...tagFilterEntries - .filter((f) => f.tagSlot && f.value.trim()) - .map((f) => ({ + ...tagFilterEntries.reduce<{ label: string; onRemove: () => void }[]>((acc, f) => { + if (!f.tagSlot || !f.value.trim()) return acc + acc.push({ label: `${f.tagName}: ${f.value}`, onRemove: () => { const updated = tagFilterEntries.filter((e) => e.id !== f.id) @@ -1034,7 +1034,9 @@ export function KnowledgeBase({ setSelectedDocuments(new Set()) setIsSelectAllMode(false) }, - })), + }) + return acc + }, []), ], [enabledFilter, tagFilterEntries] ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx index f0faed182c4..161db5369df 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx @@ -170,13 +170,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM } const fieldTypeOptions: ComboboxOption[] = useMemo(() => { - return SUPPORTED_FIELD_TYPES.filter((type) => hasAvailableSlots(type)).map((type) => { + return SUPPORTED_FIELD_TYPES.reduce((acc, type) => { const { used, max } = getSlotUsageByFieldType(type) - return { - value: type, - label: `${FIELD_TYPE_LABELS[type]} (${used}/${max})`, + if (used < max) { + acc.push({ value: type, label: `${FIELD_TYPE_LABELS[type]} (${used}/${max})` }) } - }) + return acc + }, []) }, [kbTagDefinitions]) const saveTagDefinition = async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index f0b855bb229..3b5a9d0cc56 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -40,8 +40,9 @@ export function ConnectorSelectorField({ if (credentialId) ctx.oauthCredential = credentialId if (field.mimeType) ctx.mimeType = field.mimeType + const fieldsById = new Map(configFields.map((f) => [f.id, f])) for (const depFieldId of getDependsOnFields(field.dependsOn)) { - const depField = configFields.find((f) => f.id === depFieldId) + const depField = fieldsById.get(depFieldId) const canonicalId = depField?.canonicalParamId ?? depFieldId const depValue = resolveDepValue(depFieldId, configFields, canonicalModes, sourceConfig) if (depValue && SELECTOR_CONTEXT_FIELDS.has(canonicalId as keyof SelectorContext)) { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index 43be80879e4..b6e01e3c026 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -96,12 +96,13 @@ export function ConnectorsSection({ }, []) const syncTriggeredAt = useRef>({}) - const cooldownTimers = useRef>>(new Set()) + const cooldownTimersRef = useRef> | null>(null) + cooldownTimersRef.current ??= new Set() const [, forceUpdate] = useState(0) useEffect(() => { return () => { - for (const timer of cooldownTimers.current) { + for (const timer of cooldownTimersRef.current ?? []) { clearTimeout(timer) } } @@ -126,10 +127,10 @@ export function ConnectorsSection({ onSuccess: () => { setError(null) const timer = setTimeout(() => { - cooldownTimers.current.delete(timer) + cooldownTimersRef.current?.delete(timer) forceUpdate((n) => n + 1) }, SYNC_COOLDOWN_MS) - cooldownTimers.current.add(timer) + cooldownTimersRef.current?.add(timer) }, onError: (err) => { logger.error('Sync trigger failed', { error: err.message }) @@ -302,8 +303,10 @@ function ConnectorCard({ const serviceId = connectorDef?.auth.mode === 'oauth' ? connectorDef.auth.provider : undefined const providerId = serviceId ? getProviderIdFromServiceId(serviceId) : undefined - const requiredScopes = - connectorDef?.auth.mode === 'oauth' ? (connectorDef.auth.requiredScopes ?? []) : [] + const requiredScopes = useMemo( + () => (connectorDef?.auth.mode === 'oauth' ? (connectorDef.auth.requiredScopes ?? []) : []), + [connectorDef] + ) const { data: credentials, refetch: refetchCredentials } = useOAuthCredentials(providerId, { workspaceId, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 35f7a667b60..434e4738546 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -79,10 +79,10 @@ function valuesEqual(a: unknown, b: unknown): boolean { const toArray = (v: unknown): string[] | null => { if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string') if (typeof v === 'string') { - return v - .split(',') - .map((s) => s.trim()) - .filter(Boolean) + return v.split(',').flatMap((s) => { + const t = s.trim() + return t ? [t] : [] + }) } return null } @@ -159,10 +159,10 @@ export function EditConnectorModal({ if (Array.isArray(rawValue)) { config[field.id] = rawValue.filter((v): v is string => typeof v === 'string') } else if (typeof rawValue === 'string') { - config[field.id] = rawValue - .split(',') - .map((s) => s.trim()) - .filter(Boolean) + config[field.id] = rawValue.split(',').flatMap((s) => { + const t = s.trim() + return t ? [t] : [] + }) } else { config[field.id] = [] } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts index ee171b214c8..955891811d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts @@ -44,10 +44,10 @@ function coerceForField(field: ConnectorConfigField, raw: unknown): ConfigFieldV if (typeof raw === 'string') { const trimmed = raw.trim() if (!trimmed) return [] - return trimmed - .split(',') - .map((s) => s.trim()) - .filter(Boolean) + return trimmed.split(',').flatMap((s) => { + const t = s.trim() + return t ? [t] : [] + }) } return [] } @@ -119,7 +119,7 @@ export function useConnectorConfigFields({ for (const field of group) { for (const dep of map.get(field.id) ?? []) { allDependents.add(dep) - const depField = connectorConfig.configFields.find((f) => f.id === dep) + const depField = fieldsById.get(dep) if (depField?.canonicalParamId) { for (const sibling of canonicalGroups.get(depField.canonicalParamId) ?? []) { allDependents.add(sibling.id) @@ -133,7 +133,7 @@ export function useConnectorConfigFields({ } for (const [key, value] of map) result.set(key, [...value]) return result - }, [connectorConfig, canonicalGroups]) + }, [connectorConfig, canonicalGroups, fieldsById]) const isFieldVisible = useCallback( (field: ConnectorConfigField): boolean => { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx index c7223f4b255..18a90b7cce0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx @@ -17,8 +17,7 @@ export async function generateMetadata({ searchParams }: PageProps): Promise diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx index 47a33dd0a2a..b16a2d3ef27 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx @@ -8,6 +8,7 @@ import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/comp import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import type { ConnectorMeta } from '@/connectors/types' import { DeleteKnowledgeBaseModal } from '../delete-knowledge-base-modal/delete-knowledge-base-modal' import { EditKnowledgeBaseModal } from '../edit-knowledge-base-modal/edit-knowledge-base-modal' import { KnowledgeBaseContextMenu } from '../knowledge-base-context-menu/knowledge-base-context-menu' @@ -104,9 +105,11 @@ export function BaseCard({ const [isDeleting, setIsDeleting] = useState(false) const connectorEntries = useMemo( () => - connectorTypes - .map((type) => ({ type, config: CONNECTOR_META_REGISTRY[type] })) - .filter((entry) => Boolean(entry.config?.icon)), + connectorTypes.reduce<{ type: string; config: ConnectorMeta }[]>((acc, type) => { + const config = CONNECTOR_META_REGISTRY[type] + if (config?.icon) acc.push({ type, config }) + return acc + }, []), [connectorTypes] ) const visibleConnectorEntries = useMemo(() => connectorEntries.slice(0, 3), [connectorEntries]) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts index 518844178b0..91c7d969a35 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts @@ -203,6 +203,36 @@ const uploadFileThroughAPI = async ( const toAbsoluteUrl = (path: string): string => path.startsWith('http') ? path : `${window.location.origin}${path}` +/** + * Build the {@link UploadedFile} payload from a `File`, carrying through any + * `tagN` fields the caller attached to it. Pure — kept at module scope so it + * isn't rebuilt on every render of the hook. + */ +const buildUploadedFile = (file: File, fileUrl: string): UploadedFile => { + const f = file as File & { + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + tag6?: string + tag7?: string + } + return { + filename: file.name, + fileUrl, + fileSize: file.size, + mimeType: getFileContentType(file), + tag1: f.tag1, + tag2: f.tag2, + tag3: f.tag3, + tag4: f.tag4, + tag5: f.tag5, + tag6: f.tag6, + tag7: f.tag7, + } +} + export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { const queryClient = useQueryClient() const [isUploading, setIsUploading] = useState(false) @@ -213,31 +243,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { }) const [uploadError, setUploadError] = useState(null) - const buildUploadedFile = (file: File, fileUrl: string): UploadedFile => { - const f = file as File & { - tag1?: string - tag2?: string - tag3?: string - tag4?: string - tag5?: string - tag6?: string - tag7?: string - } - return { - filename: file.name, - fileUrl, - fileSize: file.size, - mimeType: getFileContentType(file), - tag1: f.tag1, - tag2: f.tag2, - tag3: f.tag3, - tag4: f.tag4, - tag5: f.tag5, - tag6: f.tag6, - tag7: f.tag7, - } - } - const updateFileStatus = (fileIndex: number, patch: Partial) => { setUploadProgress((prev) => ({ ...prev, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index be7b5ccd801..e63c8af9a07 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -251,7 +251,7 @@ export function Knowledge() { const col = activeSort?.column ?? 'updated' const dir = activeSort?.direction ?? 'desc' - return [...result].sort((a, b) => { + return result.toSorted((a, b) => { let cmp = 0 switch (col) { case 'name': diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts index 76bc770e972..5ace0164120 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts @@ -13,7 +13,7 @@ export function sortKnowledgeBases( sortBy: SortOption, sortOrder: SortOrder ): KnowledgeBaseData[] { - return [...knowledgeBases].sort((a, b) => { + return knowledgeBases.toSorted((a, b) => { let comparison = 0 switch (sortBy) { From fc697617ec3ea9ef06914a780b835d9e712f7e06 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 1 Jul 2026 12:46:16 -0700 Subject: [PATCH 2/3] improvement(knowledge): drop stable mutate fn from useCallback deps --- .../[workspaceId]/knowledge/[id]/[documentId]/document.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 5f7457d6ce1..e25881872d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -676,7 +676,7 @@ export function Document({ { onError: () => updateChunk(chunkId, { enabled: chunk.enabled }) } ) }, - [displayChunks, knowledgeBaseId, documentId, updateChunk, updateChunkMutation] + [displayChunks, knowledgeBaseId, documentId, updateChunk] ) const handleDeleteChunk = useCallback( From f2355e24b4eee2adf452ff05bd44222755ddb7a7 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 1 Jul 2026 13:07:05 -0700 Subject: [PATCH 3/3] docs(rules): tighten sim-react-performance mutation + toSorted wording --- .claude/rules/sim-react-performance.md | 16 ++++++++++------ .cursor/rules/sim-react-performance.mdc | 16 ++++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.claude/rules/sim-react-performance.md b/.claude/rules/sim-react-performance.md index 1f4cb29f07f..28db584044b 100644 --- a/.claude/rules/sim-react-performance.md +++ b/.claude/rules/sim-react-performance.md @@ -90,10 +90,11 @@ first-wins matters, build the map with a guard (`if (!m.has(k)) m.set(k, v)`). ## Immutable sort — `toSorted`, not spread + `sort` -`[...arr].sort()` copies the array just to sort it. `apps/sim` targets ES2023, -so use `arr.toSorted(...)` (non-mutating, no manual copy). Packages pinned to -ES2022 (`packages/tsconfig/base.json`) do **not** have `toSorted` — keep -`[...arr].sort()` there. +`[...arr].sort()` copies the array just to sort it. `apps/sim/tsconfig.json` +sets `lib` to ES2023, so `arr.toSorted(...)` (non-mutating, no manual copy) +type-resolves and runs on every runtime the app targets (Node 20+, evergreen +browsers). Packages pinned to ES2022 `lib` (`packages/tsconfig/base.json`) do +**not** expose `toSorted` — keep `[...arr].sort()` there. ## Independent awaits run in parallel @@ -117,7 +118,10 @@ sequencing is deliberate (rate-limited batches, retry loops). - A `Date.now()` inside an `onClick`/event handler is fine — it is not a render hydration mismatch. Only `Date.now()` reached during render is. -- Don't add a mutation **object** to a `useCallback`/`useMemo` dep array; the - `.mutate` fn from TanStack Query v5 is already stable (see `sim-queries.md`). +- A TanStack Query v5 `.mutate` / `.mutateAsync` fn is referentially stable — + call it inside a `useCallback`/`useMemo` without listing it in the deps. + Adding it is inert noise (and a lint tool that flags it as a missing dep is + wrong here); never add the mutation **object** either — it is not stable, so + depend on nothing and call `.mutate` on it. See `sim-queries.md`. - Memoizing a value that is already primitive/stable adds overhead for nothing — memoize arrays, objects, and functions, not booleans or strings. diff --git a/.cursor/rules/sim-react-performance.mdc b/.cursor/rules/sim-react-performance.mdc index 1f8b032468b..f4ffb0112b1 100644 --- a/.cursor/rules/sim-react-performance.mdc +++ b/.cursor/rules/sim-react-performance.mdc @@ -94,10 +94,11 @@ first-wins matters, build the map with a guard (`if (!m.has(k)) m.set(k, v)`). ## Immutable sort — `toSorted`, not spread + `sort` -`[...arr].sort()` copies the array just to sort it. `apps/sim` targets ES2023, -so use `arr.toSorted(...)` (non-mutating, no manual copy). Packages pinned to -ES2022 (`packages/tsconfig/base.json`) do **not** have `toSorted` — keep -`[...arr].sort()` there. +`[...arr].sort()` copies the array just to sort it. `apps/sim/tsconfig.json` +sets `lib` to ES2023, so `arr.toSorted(...)` (non-mutating, no manual copy) +type-resolves and runs on every runtime the app targets (Node 20+, evergreen +browsers). Packages pinned to ES2022 `lib` (`packages/tsconfig/base.json`) do +**not** expose `toSorted` — keep `[...arr].sort()` there. ## Independent awaits run in parallel @@ -121,7 +122,10 @@ sequencing is deliberate (rate-limited batches, retry loops). - A `Date.now()` inside an `onClick`/event handler is fine — it is not a render hydration mismatch. Only `Date.now()` reached during render is. -- Don't add a mutation **object** to a `useCallback`/`useMemo` dep array; the - `.mutate` fn from TanStack Query v5 is already stable (see `sim-queries`). +- A TanStack Query v5 `.mutate` / `.mutateAsync` fn is referentially stable — + call it inside a `useCallback`/`useMemo` without listing it in the deps. + Adding it is inert noise (and a lint tool that flags it as a missing dep is + wrong here); never add the mutation **object** either — it is not stable, so + depend on nothing and call `.mutate` on it. See `sim-queries`. - Memoizing a value that is already primitive/stable adds overhead for nothing — memoize arrays, objects, and functions, not booleans or strings.