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

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