use-data-loader.integration.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. /**
  2. * Integration tests for use-data-loader with real Jotai atoms
  3. *
  4. * These tests verify that the dataLoader correctly reads creating state
  5. * from Jotai atoms. This is critical because:
  6. *
  7. * 1. The dataLoader callbacks (getChildrenWithData) need to read the latest
  8. * creating state when they are invoked
  9. * 2. The dataLoader reference must remain stable to prevent headless-tree
  10. * from refetching all data
  11. * 3. Changes to the creating state must be reflected in subsequent
  12. * getChildrenWithData calls WITHOUT recreating the dataLoader
  13. *
  14. * These tests use real Jotai atoms instead of mocks to ensure the integration
  15. * works correctly. This catches bugs like using getDefaultStore() incorrectly.
  16. */
  17. import type { FC, PropsWithChildren } from 'react';
  18. import { act, renderHook } from '@testing-library/react';
  19. import { createStore, Provider } from 'jotai';
  20. import type { IPageForTreeItem } from '~/interfaces/page';
  21. import { CREATING_PAGE_VIRTUAL_ID } from '../../constants/_inner';
  22. import { invalidatePageTreeChildren } from '../../services';
  23. // Re-import the actions hook to use real implementation
  24. import {
  25. resetCreatingFlagForTesting,
  26. usePageTreeCreateActions,
  27. } from '../../states/_inner';
  28. import { useDataLoader } from './use-data-loader';
  29. /**
  30. * Type helper to extract getChildrenWithData from TreeDataLoader
  31. */
  32. type DataLoaderWithChildrenData = ReturnType<typeof useDataLoader> & {
  33. getChildrenWithData: (
  34. itemId: string,
  35. ) => Promise<{ id: string; data: IPageForTreeItem }[]>;
  36. };
  37. /**
  38. * Combined hook result type for testing both hooks together
  39. */
  40. type CombinedHookResult = {
  41. dataLoader: ReturnType<typeof useDataLoader>;
  42. actions: ReturnType<typeof usePageTreeCreateActions>;
  43. };
  44. // Mock the apiv3Get function
  45. const mockApiv3Get = vi.fn();
  46. vi.mock('~/client/util/apiv3-client', () => ({
  47. apiv3Get: (...args: unknown[]) => mockApiv3Get(...args),
  48. }));
  49. /**
  50. * Create mock page data for testing
  51. */
  52. const createMockPage = (
  53. id: string,
  54. path: string,
  55. options: Partial<IPageForTreeItem> = {},
  56. ): IPageForTreeItem => ({
  57. _id: id,
  58. path,
  59. parent: null,
  60. descendantCount: 0,
  61. grant: 1,
  62. isEmpty: false,
  63. wip: false,
  64. ...options,
  65. });
  66. /**
  67. * Helper to get typed dataLoader with getChildrenWithData
  68. */
  69. const getDataLoader = (result: {
  70. current: CombinedHookResult;
  71. }): DataLoaderWithChildrenData => {
  72. return result.current.dataLoader as DataLoaderWithChildrenData;
  73. };
  74. describe('use-data-loader integration with Jotai atoms', () => {
  75. const ROOT_PAGE_ID = 'root-page-id';
  76. const ALL_PAGES_COUNT = 100;
  77. // Create a fresh Jotai store for each test
  78. let store: ReturnType<typeof createStore>;
  79. // Wrapper component that provides the Jotai store
  80. const createWrapper = (): FC<PropsWithChildren> => {
  81. const Wrapper: FC<PropsWithChildren> = ({ children }) => (
  82. <Provider store={store}>{children}</Provider>
  83. );
  84. return Wrapper;
  85. };
  86. beforeEach(() => {
  87. // Create fresh store for each test
  88. store = createStore();
  89. // Clear pending requests before each test
  90. invalidatePageTreeChildren();
  91. // Reset the creating flag for testing
  92. resetCreatingFlagForTesting();
  93. // Reset mock
  94. mockApiv3Get.mockReset();
  95. });
  96. describe('placeholder node with real Jotai atoms', () => {
  97. test('should prepend placeholder when creating state is set via actions hook', async () => {
  98. const mockChildren = [
  99. createMockPage('existing-child', '/parent/existing'),
  100. ];
  101. mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
  102. const wrapper = createWrapper();
  103. // Render both hooks together in the same component to share the store
  104. // and ensure refs are updated when atom state changes
  105. const { result, rerender } = renderHook(
  106. (): CombinedHookResult => ({
  107. dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
  108. actions: usePageTreeCreateActions(),
  109. }),
  110. { wrapper },
  111. );
  112. // First call - no placeholder (creating state is null)
  113. const childrenBefore =
  114. await getDataLoader(result).getChildrenWithData('parent-id');
  115. expect(childrenBefore).toHaveLength(1);
  116. expect(childrenBefore[0].id).toBe('existing-child');
  117. // Set creating state using the actions hook
  118. act(() => {
  119. result.current.actions.startCreating('parent-id', '/parent');
  120. });
  121. // Rerender to update refs with the new atom state
  122. rerender();
  123. // Clear pending requests to force re-fetch
  124. invalidatePageTreeChildren(['parent-id']);
  125. // Second call - should have placeholder because atom state changed
  126. const childrenAfter =
  127. await getDataLoader(result).getChildrenWithData('parent-id');
  128. expect(childrenAfter).toHaveLength(2);
  129. expect(childrenAfter[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
  130. expect(childrenAfter[0].data.parent).toBe('parent-id');
  131. expect(childrenAfter[0].data.path).toBe('/parent/');
  132. expect(childrenAfter[1].id).toBe('existing-child');
  133. });
  134. test('should remove placeholder when creating state is cancelled', async () => {
  135. const mockChildren = [
  136. createMockPage('existing-child', '/parent/existing'),
  137. ];
  138. mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
  139. const wrapper = createWrapper();
  140. // Render both hooks together in the same component
  141. const { result, rerender } = renderHook(
  142. (): CombinedHookResult => ({
  143. dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
  144. actions: usePageTreeCreateActions(),
  145. }),
  146. { wrapper },
  147. );
  148. // Set creating state
  149. act(() => {
  150. result.current.actions.startCreating('parent-id', '/parent');
  151. });
  152. // Rerender to update refs
  153. rerender();
  154. // Clear pending requests and fetch - should have placeholder
  155. invalidatePageTreeChildren(['parent-id']);
  156. const childrenWithPlaceholder =
  157. await getDataLoader(result).getChildrenWithData('parent-id');
  158. expect(childrenWithPlaceholder).toHaveLength(2);
  159. expect(childrenWithPlaceholder[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
  160. // Cancel creating
  161. act(() => {
  162. result.current.actions.cancelCreating();
  163. });
  164. // Rerender to update refs
  165. rerender();
  166. // Clear pending requests and fetch - should NOT have placeholder
  167. invalidatePageTreeChildren(['parent-id']);
  168. const childrenAfterCancel =
  169. await getDataLoader(result).getChildrenWithData('parent-id');
  170. expect(childrenAfterCancel).toHaveLength(1);
  171. expect(childrenAfterCancel[0].id).toBe('existing-child');
  172. });
  173. test('dataLoader reference should remain stable when creating state changes via atom', async () => {
  174. const wrapper = createWrapper();
  175. // Render both hooks together in the same component
  176. const { result, rerender } = renderHook(
  177. (): CombinedHookResult => ({
  178. dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
  179. actions: usePageTreeCreateActions(),
  180. }),
  181. { wrapper },
  182. );
  183. const firstDataLoader = result.current.dataLoader;
  184. // Change creating state via atom
  185. act(() => {
  186. result.current.actions.startCreating('some-parent', '/some-parent');
  187. });
  188. // Rerender to update refs
  189. rerender();
  190. const secondDataLoader = result.current.dataLoader;
  191. // DataLoader reference should be STABLE (same reference)
  192. // This is critical to prevent headless-tree from refetching all data
  193. expect(firstDataLoader).toBe(secondDataLoader);
  194. });
  195. test('should correctly read state changes after rerender', async () => {
  196. // This test verifies that the dataLoader callbacks can read the updated
  197. // atom state after a React rerender. The refs in useDataLoader are updated
  198. // during the render cycle, so a rerender is needed to see state changes.
  199. const mockChildren = [
  200. createMockPage('existing-child', '/parent/existing'),
  201. ];
  202. mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
  203. const wrapper = createWrapper();
  204. // Render both hooks together in the same component
  205. const { result, rerender } = renderHook(
  206. (): CombinedHookResult => ({
  207. dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
  208. actions: usePageTreeCreateActions(),
  209. }),
  210. { wrapper },
  211. );
  212. // Get the dataLoader reference BEFORE state change
  213. const dataLoader = getDataLoader(result);
  214. // Set creating state
  215. act(() => {
  216. result.current.actions.startCreating('parent-id', '/parent');
  217. });
  218. // Rerender to update refs
  219. rerender();
  220. // Clear pending requests
  221. invalidatePageTreeChildren(['parent-id']);
  222. // Call getChildrenWithData using the SAME dataLoader reference
  223. // This should still see the updated atom state
  224. const children = await dataLoader.getChildrenWithData('parent-id');
  225. expect(children).toHaveLength(2);
  226. expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
  227. expect(children[1].id).toBe('existing-child');
  228. });
  229. test('should work with multiple sequential state changes', async () => {
  230. const mockChildren = [
  231. createMockPage('existing-child', '/parent/existing'),
  232. ];
  233. mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
  234. const wrapper = createWrapper();
  235. // Render both hooks together in the same component
  236. const { result, rerender } = renderHook(
  237. (): CombinedHookResult => ({
  238. dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
  239. actions: usePageTreeCreateActions(),
  240. }),
  241. { wrapper },
  242. );
  243. const dataLoader = getDataLoader(result);
  244. // Sequence: start -> cancel -> start again -> cancel
  245. // Each time, the dataLoader should correctly reflect the state
  246. // 1. Start creating
  247. act(() => {
  248. result.current.actions.startCreating('parent-id', '/parent');
  249. });
  250. rerender();
  251. invalidatePageTreeChildren(['parent-id']);
  252. let children = await dataLoader.getChildrenWithData('parent-id');
  253. expect(children).toHaveLength(2);
  254. expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
  255. // 2. Cancel
  256. act(() => {
  257. result.current.actions.cancelCreating();
  258. });
  259. rerender();
  260. invalidatePageTreeChildren(['parent-id']);
  261. children = await dataLoader.getChildrenWithData('parent-id');
  262. expect(children).toHaveLength(1);
  263. expect(children[0].id).toBe('existing-child');
  264. // 3. Start again with different parent
  265. act(() => {
  266. result.current.actions.startCreating('other-parent', '/other');
  267. });
  268. rerender();
  269. invalidatePageTreeChildren(['parent-id', 'other-parent']);
  270. // Original parent should NOT have placeholder
  271. children = await dataLoader.getChildrenWithData('parent-id');
  272. expect(children).toHaveLength(1);
  273. // New parent should have placeholder
  274. mockApiv3Get.mockResolvedValueOnce({ data: { children: [] } });
  275. children = await dataLoader.getChildrenWithData('other-parent');
  276. expect(children).toHaveLength(1);
  277. expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
  278. expect(children[0].data.path).toBe('/other/');
  279. // 4. Cancel again
  280. act(() => {
  281. result.current.actions.cancelCreating();
  282. });
  283. rerender();
  284. invalidatePageTreeChildren(['other-parent']);
  285. mockApiv3Get.mockResolvedValueOnce({ data: { children: [] } });
  286. children = await dataLoader.getChildrenWithData('other-parent');
  287. expect(children).toHaveLength(0);
  288. });
  289. });
  290. });