use-data-loader.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import { useMemo, useRef } from 'react';
  2. import type { TreeDataLoader } from '@headless-tree/core';
  3. import { apiv3Get } from '~/client/util/apiv3-client';
  4. import type { IPageForTreeItem } from '~/interfaces/page';
  5. import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
  6. import {
  7. CREATING_PAGE_VIRTUAL_ID,
  8. createPlaceholderPageData,
  9. useCreatingParentId,
  10. useCreatingParentPath,
  11. } from '../states/page-tree-create';
  12. function constructRootPageForVirtualRoot(
  13. rootPageId: string,
  14. allPagesCount: number,
  15. ): IPageForTreeItem {
  16. return {
  17. _id: rootPageId,
  18. path: '/',
  19. parent: null,
  20. descendantCount: allPagesCount,
  21. grant: 1,
  22. isEmpty: false,
  23. wip: false,
  24. };
  25. }
  26. // Cache for children data to prevent duplicate API calls
  27. // Key: itemId, Value: { promise, data, timestamp }
  28. type CacheEntry = {
  29. promise: Promise<{ id: string; data: IPageForTreeItem }[]>;
  30. data?: { id: string; data: IPageForTreeItem }[];
  31. timestamp: number;
  32. };
  33. // Module-level cache (persists across component remounts)
  34. const childrenCache = new Map<string, CacheEntry>();
  35. // Cache TTL in milliseconds (5 minutes)
  36. const CACHE_TTL = 5 * 60 * 1000;
  37. /**
  38. * Clear cache for specific item IDs or all cache
  39. */
  40. export const clearChildrenCache = (itemIds?: string[]): void => {
  41. if (itemIds == null) {
  42. childrenCache.clear();
  43. } else {
  44. itemIds.forEach((id) => {
  45. childrenCache.delete(id);
  46. });
  47. }
  48. };
  49. export const useDataLoader = (
  50. rootPageId: string,
  51. allPagesCount: number,
  52. ): TreeDataLoader<IPageForTreeItem> => {
  53. const creatingParentId = useCreatingParentId();
  54. const creatingParentPath = useCreatingParentPath();
  55. // Use refs to avoid recreating dataLoader callbacks when creating state changes
  56. // The creating state is accessed via refs so that:
  57. // 1. The dataLoader reference stays stable (prevents headless-tree from refetching all data)
  58. // 2. The actual creating state is still read at execution time (when invalidateChildrenIds is called)
  59. const creatingParentIdRef = useRef(creatingParentId);
  60. const creatingParentPathRef = useRef(creatingParentPath);
  61. creatingParentIdRef.current = creatingParentId;
  62. creatingParentPathRef.current = creatingParentPath;
  63. // Memoize the entire dataLoader object to ensure reference stability
  64. // Only recreate when rootPageId or allPagesCount changes (which are truly needed for the API calls)
  65. const dataLoader = useMemo<TreeDataLoader<IPageForTreeItem>>(() => {
  66. const getItem = async (itemId: string): Promise<IPageForTreeItem> => {
  67. // Virtual root (should rarely be called since it's provided by getChildrenWithData)
  68. if (itemId === ROOT_PAGE_VIRTUAL_ID) {
  69. return constructRootPageForVirtualRoot(rootPageId, allPagesCount);
  70. }
  71. // Creating placeholder node - return placeholder data
  72. if (itemId === CREATING_PAGE_VIRTUAL_ID) {
  73. // This shouldn't normally be called, but return empty placeholder if it is
  74. return createPlaceholderPageData('', '/');
  75. }
  76. // For all pages (including root), use /page-listing/item endpoint
  77. // Note: This should rarely be called thanks to getChildrenWithData caching
  78. const response = await apiv3Get<{ item: IPageForTreeItem }>(
  79. '/page-listing/item',
  80. { id: itemId },
  81. );
  82. return response.data.item;
  83. };
  84. const fetchChildrenFromApi = async (
  85. itemId: string,
  86. ): Promise<{ id: string; data: IPageForTreeItem }[]> => {
  87. const response = await apiv3Get<{ children: IPageForTreeItem[] }>(
  88. '/page-listing/children',
  89. { id: itemId },
  90. );
  91. return response.data.children.map((child) => ({
  92. id: child._id,
  93. data: child,
  94. }));
  95. };
  96. const getChildrenWithData = async (itemId: string) => {
  97. // Virtual root returns root page as its only child
  98. // Use actual MongoDB _id as tree item ID to avoid duplicate API calls
  99. if (itemId === ROOT_PAGE_VIRTUAL_ID) {
  100. return [
  101. {
  102. id: rootPageId,
  103. data: constructRootPageForVirtualRoot(rootPageId, allPagesCount),
  104. },
  105. ];
  106. }
  107. // Placeholder node has no children
  108. if (itemId === CREATING_PAGE_VIRTUAL_ID) {
  109. return [];
  110. }
  111. // Check cache first
  112. const now = Date.now();
  113. const cached = childrenCache.get(itemId);
  114. let children: { id: string; data: IPageForTreeItem }[];
  115. if (cached != null && now - cached.timestamp < CACHE_TTL) {
  116. // Use cached data or wait for pending promise
  117. if (cached.data != null) {
  118. children = cached.data;
  119. } else {
  120. children = await cached.promise;
  121. }
  122. } else {
  123. // Fetch from API and cache the promise to prevent duplicate requests
  124. const promise = fetchChildrenFromApi(itemId);
  125. const entry: CacheEntry = { promise, timestamp: now };
  126. childrenCache.set(itemId, entry);
  127. try {
  128. children = await promise;
  129. // Store the resolved data in cache
  130. entry.data = children;
  131. } catch (error) {
  132. // Remove failed entry from cache
  133. childrenCache.delete(itemId);
  134. throw error;
  135. }
  136. }
  137. // If this parent is in "creating" mode, prepend placeholder node
  138. // Read from refs to get current value without triggering dataLoader recreation
  139. const currentCreatingParentId = creatingParentIdRef.current;
  140. const currentCreatingParentPath = creatingParentPathRef.current;
  141. if (
  142. currentCreatingParentId === itemId &&
  143. currentCreatingParentPath != null
  144. ) {
  145. const placeholderData = createPlaceholderPageData(
  146. itemId,
  147. currentCreatingParentPath,
  148. );
  149. return [
  150. { id: CREATING_PAGE_VIRTUAL_ID, data: placeholderData },
  151. ...children,
  152. ];
  153. }
  154. return children;
  155. };
  156. return { getItem, getChildrenWithData };
  157. }, [allPagesCount, rootPageId]);
  158. return dataLoader;
  159. };