diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index 1b1603cedd..eaccd07cf1 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -265,27 +265,30 @@ function InnerDocumentLink( }; }); - const nodeChildren = React.useMemo(() => { - const insertDraftDocument = - activeDocument?.isDraft && - activeDocument?.isActive && - activeDocument?.parentDocumentId === node.id; + const insertDraftChild = !!( + activeDocument?.isDraft && + activeDocument?.isActive && + activeDocument?.parentDocumentId === node.id + ); - return collection && insertDraftDocument - ? sortNavigationNodes( - [activeDocument?.asNavigationNode, ...node.children], - collection.sort, - false - ) - : node.children; - }, [ - activeDocument?.isActive, - activeDocument?.isDraft, - activeDocument?.parentDocumentId, - activeDocument?.asNavigationNode, - collection, - node, - ]); + // Only subscribe to asNavigationNode when this node is the parent of an + // active draft. This avoids every DocumentLink observer re-rendering on + // every title keystroke. + const draftNavNode = insertDraftChild + ? activeDocument?.asNavigationNode + : undefined; + + const nodeChildren = React.useMemo( + () => + collection && draftNavNode + ? sortNavigationNodes( + [draftNavNode, ...node.children], + collection.sort, + false + ) + : node.children, + [draftNavNode, collection, node] + ); const doc = documents.get(node.id); const title = doc?.title || node.title || t("Untitled"); diff --git a/app/components/Sidebar/hooks/useCollectionDocuments.ts b/app/components/Sidebar/hooks/useCollectionDocuments.ts index 9574db3ef8..a886d4f158 100644 --- a/app/components/Sidebar/hooks/useCollectionDocuments.ts +++ b/app/components/Sidebar/hooks/useCollectionDocuments.ts @@ -7,38 +7,32 @@ export default function useCollectionDocuments( collection: Collection | undefined, activeDocument: Document | undefined ) { - const insertDraftDocument = useMemo( - () => - activeDocument && - activeDocument.isActive && - activeDocument.isDraft && - activeDocument.collectionId === collection?.id && - !activeDocument.parentDocumentId, - [ - activeDocument?.isActive, - activeDocument?.isDraft, - activeDocument?.collectionId, - activeDocument?.parentDocumentId, - collection?.id, - ] + const insertDraftDocument = !!( + activeDocument && + activeDocument.isActive && + activeDocument.isDraft && + activeDocument.collectionId === collection?.id && + !activeDocument.parentDocumentId ); + // Only subscribe to asNavigationNode when we actually need to insert a draft + // into the sorted list. This avoids every CollectionLinkChildren observer + // re-rendering on every title keystroke. + const draftNavNode = insertDraftDocument + ? activeDocument?.asNavigationNode + : undefined; + return useMemo(() => { if (!collection?.sortedDocuments) { return undefined; } - return insertDraftDocument && activeDocument + return draftNavNode ? sortNavigationNodes( - [activeDocument.asNavigationNode, ...collection.sortedDocuments], + [draftNavNode, ...collection.sortedDocuments], collection.sort, false ) : collection.sortedDocuments; - }, [ - insertDraftDocument, - activeDocument?.asNavigationNode, - collection?.sortedDocuments, - collection?.sort, - ]); + }, [draftNavNode, collection?.sortedDocuments, collection?.sort]); } diff --git a/shared/utils/collections.ts b/shared/utils/collections.ts index b153f539fb..d0d9b3c6e3 100644 --- a/shared/utils/collections.ts +++ b/shared/utils/collections.ts @@ -1,4 +1,5 @@ import type { NavigationNode } from "../types"; +import shallowEqual from "./shallowEqual"; import naturalSort from "./naturalSort"; type Sort = { @@ -21,12 +22,23 @@ export const sortNavigationNodes = ( direction: sort.direction, }); - return orderedDocs.map((node) => ({ - ...node, - children: sortChildren - ? sortNavigationNodes(node.children, sort, sortChildren) - : node.children, - })); + if (!sortChildren) { + return orderedDocs; + } + + return orderedDocs.map((node) => { + const sortedChildren = sortNavigationNodes( + node.children, + sort, + sortChildren + ); + // Preserve the original node reference if children order didn't change. + // This allows React.memo to skip re-renders of unchanged tree nodes. + if (shallowEqual(sortedChildren, node.children)) { + return node; + } + return { ...node, children: sortedChildren }; + }); }; export const colorPalette = [ diff --git a/shared/utils/shallowEqual.ts b/shared/utils/shallowEqual.ts new file mode 100644 index 0000000000..6df0cbe43a --- /dev/null +++ b/shared/utils/shallowEqual.ts @@ -0,0 +1,23 @@ +/** + * Check if two arrays have the same elements in the same order by reference. + * Uses strict equality (===) rather than deep comparison, so object identity + * is preserved — important for React.memo optimizations. + * + * @param a first array. + * @param b second array. + * @returns true if the arrays are shallowly equal. + */ +export default function shallowEqual(a: T[], b: T[]): boolean { + if (a === b) { + return true; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +}