SimplifiedItemsTree.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import type { FC } from 'react';
  2. import { useEffect, useMemo, useRef } from 'react';
  3. import {
  4. asyncDataLoaderFeature,
  5. hotkeysCoreFeature,
  6. renamingFeature,
  7. selectionFeature,
  8. } from '@headless-tree/core';
  9. import { useTree } from '@headless-tree/react';
  10. import { useVirtualizer } from '@tanstack/react-virtual';
  11. import type { IPageForTreeItem } from '~/interfaces/page';
  12. import { useSWRxRootPage } from '~/stores/page-listing';
  13. import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
  14. import { useAutoExpandAncestors } from '../hooks/use-auto-expand-ancestors';
  15. import { useDataLoader } from '../hooks/use-data-loader';
  16. import { useScrollToSelectedItem } from '../hooks/use-scroll-to-selected-item';
  17. import { useTreeItemHandlers } from '../hooks/use-tree-item-handlers';
  18. import type { TreeItemProps } from '../interfaces';
  19. import { invalidatePageTreeChildren } from '../services';
  20. import {
  21. usePageTreeInformationGeneration,
  22. usePageTreeRevalidationEffect,
  23. } from '../states/page-tree-update';
  24. import {
  25. useTreeRebuildTrigger,
  26. useTriggerTreeRebuild,
  27. } from '../states/tree-rebuild';
  28. // Stable features array to avoid recreating on every render
  29. const TREE_FEATURES = [
  30. asyncDataLoaderFeature,
  31. selectionFeature,
  32. hotkeysCoreFeature,
  33. renamingFeature,
  34. ];
  35. // Stable createLoadingItemData function
  36. const createLoadingItemData = (): IPageForTreeItem => ({
  37. _id: '',
  38. path: 'Loading...',
  39. parent: '',
  40. descendantCount: 0,
  41. grant: 1,
  42. isEmpty: false,
  43. wip: false,
  44. });
  45. type Props = {
  46. targetPath: string;
  47. targetPathOrId?: string;
  48. isWipPageShown?: boolean;
  49. isEnableActions?: boolean;
  50. isReadOnlyUser?: boolean;
  51. CustomTreeItem: React.FunctionComponent<TreeItemProps>;
  52. estimateTreeItemSize: () => number;
  53. scrollerElem?: HTMLElement | null;
  54. };
  55. export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
  56. const {
  57. targetPath,
  58. targetPathOrId,
  59. isWipPageShown = true,
  60. isEnableActions = false,
  61. isReadOnlyUser = false,
  62. CustomTreeItem,
  63. estimateTreeItemSize,
  64. scrollerElem,
  65. } = props;
  66. // Subscribe to rebuild trigger to re-render when tree structure changes
  67. useTreeRebuildTrigger();
  68. const triggerTreeRebuild = useTriggerTreeRebuild();
  69. const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
  70. const rootPage = rootPageResult?.rootPage;
  71. const rootPageId = rootPage?._id ?? ROOT_PAGE_VIRTUAL_ID;
  72. const allPagesCount = rootPage?.descendantCount ?? 0;
  73. const dataLoader = useDataLoader(rootPageId, allPagesCount);
  74. // Tree item handlers (rename, create, etc.) with stable callbacks for headless-tree
  75. // Note: triggerTreeRebuild is stable (from useSetAtom), so no need for useCallback wrapper
  76. const { getItemName, isItemFolder, handleRename, creatingParentId } =
  77. useTreeItemHandlers(triggerTreeRebuild);
  78. // Stable initial state
  79. const initialState = useMemo(
  80. () => ({ expandedItems: [ROOT_PAGE_VIRTUAL_ID] }),
  81. [],
  82. );
  83. const tree = useTree<IPageForTreeItem>({
  84. rootItemId: ROOT_PAGE_VIRTUAL_ID,
  85. getItemName,
  86. initialState,
  87. isItemFolder,
  88. createLoadingItemData,
  89. dataLoader,
  90. onRename: handleRename,
  91. features: TREE_FEATURES,
  92. });
  93. // Track local generation number
  94. const localGenerationRef = useRef(1);
  95. const globalGeneration = usePageTreeInformationGeneration();
  96. // Refetch data when global generation is updated
  97. usePageTreeRevalidationEffect(tree, localGenerationRef.current, {
  98. // Update local generation number after revalidation
  99. onRevalidated: () => {
  100. localGenerationRef.current = globalGeneration;
  101. },
  102. });
  103. // Track previous creatingParentId to detect changes
  104. const prevCreatingParentIdRef = useRef<string | null>(null);
  105. // Expand and rebuild tree when creatingParentId changes
  106. // IMPORTANT: This effect intentionally has no dependency array and uses a ref to track
  107. // previous value. This prevents infinite loops that would occur if we put [creatingParentId, tree]
  108. // in deps, because tree object changes on every render, causing the effect to re-run continuously.
  109. // See: SimplifiedItemsTree.spec.tsx "page creation (creatingParentId)" tests
  110. useEffect(() => {
  111. // Only run when creatingParentId actually changes (not on every render)
  112. if (creatingParentId === prevCreatingParentIdRef.current) return;
  113. prevCreatingParentIdRef.current = creatingParentId;
  114. if (creatingParentId == null) return;
  115. // Rebuild tree first to re-evaluate isItemFolder
  116. tree.rebuildTree();
  117. // Then expand the parent item
  118. const parentItem = tree.getItemInstance(creatingParentId);
  119. if (parentItem != null && !parentItem.isExpanded()) {
  120. parentItem.expand();
  121. }
  122. // Clear cache for this parent and invalidate children to load placeholder
  123. invalidatePageTreeChildren([creatingParentId]);
  124. parentItem?.invalidateChildrenIds(true);
  125. // Trigger re-render
  126. triggerTreeRebuild();
  127. });
  128. const items = tree.getItems();
  129. // Track items count to detect when async data loading completes
  130. const prevItemsCountRef = useRef(items.length);
  131. useEffect(() => {
  132. if (items.length !== prevItemsCountRef.current) {
  133. prevItemsCountRef.current = items.length;
  134. // Trigger re-render when items count changes (e.g., after async load completes)
  135. triggerTreeRebuild();
  136. }
  137. }, [items.length, triggerTreeRebuild]);
  138. // Auto-expand items that are ancestors of targetPath
  139. // Note: triggerTreeRebuild is stable, no need for useCallback wrapper
  140. useAutoExpandAncestors({
  141. items,
  142. targetPath,
  143. onExpanded: triggerTreeRebuild,
  144. });
  145. const virtualizer = useVirtualizer({
  146. count: items.length,
  147. getScrollElement: () => scrollerElem ?? null,
  148. estimateSize: estimateTreeItemSize,
  149. overscan: 5,
  150. });
  151. // Scroll to selected item on mount or when targetPathOrId changes
  152. useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
  153. return (
  154. <div {...tree.getContainerProps()} className="list-group">
  155. {virtualizer.getVirtualItems().map((virtualItem) => {
  156. const item = items[virtualItem.index];
  157. const itemData = item.getItemData();
  158. // Skip rendering virtual root
  159. if (itemData._id === ROOT_PAGE_VIRTUAL_ID) {
  160. return null;
  161. }
  162. // Skip rendering WIP pages if not shown
  163. if (!isWipPageShown && itemData.wip) {
  164. return null;
  165. }
  166. const props = item.getProps();
  167. return (
  168. <div
  169. key={virtualItem.key}
  170. data-index={virtualItem.index}
  171. ref={(node) => {
  172. virtualizer.measureElement(node);
  173. if (node && props.ref) {
  174. (props.ref as (node: HTMLElement) => void)(node);
  175. }
  176. }}
  177. >
  178. <CustomTreeItem
  179. item={item}
  180. targetPath={targetPath}
  181. targetPathOrId={targetPathOrId}
  182. isWipPageShown={isWipPageShown}
  183. isEnableActions={isEnableActions}
  184. isReadOnlyUser={isReadOnlyUser}
  185. onToggle={triggerTreeRebuild}
  186. />
  187. </div>
  188. );
  189. })}
  190. </div>
  191. );
  192. };