/** * Integration tests for use-data-loader with real Jotai atoms * * These tests verify that the dataLoader correctly reads creating state * from Jotai atoms. This is critical because: * * 1. The dataLoader callbacks (getChildrenWithData) need to read the latest * creating state when they are invoked * 2. The dataLoader reference must remain stable to prevent headless-tree * from refetching all data * 3. Changes to the creating state must be reflected in subsequent * getChildrenWithData calls WITHOUT recreating the dataLoader * * These tests use real Jotai atoms instead of mocks to ensure the integration * works correctly. This catches bugs like using getDefaultStore() incorrectly. */ import type { FC, PropsWithChildren } from 'react'; import { act, renderHook } from '@testing-library/react'; import { createStore, Provider } from 'jotai'; import type { IPageForTreeItem } from '~/interfaces/page'; import { invalidatePageTreeChildren } from '../services'; // Re-import the actions hook to use real implementation import { CREATING_PAGE_VIRTUAL_ID, usePageTreeCreateActions, } from '../states/page-tree-create'; import { useDataLoader } from './use-data-loader'; /** * Type helper to extract getChildrenWithData from TreeDataLoader */ type DataLoaderWithChildrenData = ReturnType & { getChildrenWithData: ( itemId: string, ) => Promise<{ id: string; data: IPageForTreeItem }[]>; }; // Mock the apiv3Get function const mockApiv3Get = vi.fn(); vi.mock('~/client/util/apiv3-client', () => ({ apiv3Get: (...args: unknown[]) => mockApiv3Get(...args), })); /** * Create mock page data for testing */ const createMockPage = ( id: string, path: string, options: Partial = {}, ): IPageForTreeItem => ({ _id: id, path, parent: null, descendantCount: 0, grant: 1, isEmpty: false, wip: false, ...options, }); /** * Helper to get typed dataLoader with getChildrenWithData */ const getDataLoader = (result: { current: ReturnType; }): DataLoaderWithChildrenData => { return result.current as DataLoaderWithChildrenData; }; describe('use-data-loader integration with Jotai atoms', () => { const ROOT_PAGE_ID = 'root-page-id'; const ALL_PAGES_COUNT = 100; // Create a fresh Jotai store for each test let store: ReturnType; // Wrapper component that provides the Jotai store const createWrapper = (): FC => { const Wrapper: FC = ({ children }) => ( {children} ); return Wrapper; }; beforeEach(() => { // Create fresh store for each test store = createStore(); // Clear pending requests before each test invalidatePageTreeChildren(); // Reset mock mockApiv3Get.mockReset(); }); describe('placeholder node with real Jotai atoms', () => { test('should prepend placeholder when creating state is set via actions hook', async () => { const mockChildren = [ createMockPage('existing-child', '/parent/existing'), ]; mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } }); const wrapper = createWrapper(); // Render both hooks in the same wrapper to share the store const { result: dataLoaderResult } = renderHook( () => useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT), { wrapper }, ); const { result: actionsResult } = renderHook( () => usePageTreeCreateActions(), { wrapper }, ); // First call - no placeholder (creating state is null) const childrenBefore = await getDataLoader(dataLoaderResult).getChildrenWithData('parent-id'); expect(childrenBefore).toHaveLength(1); expect(childrenBefore[0].id).toBe('existing-child'); // Set creating state using the actions hook act(() => { actionsResult.current.startCreating('parent-id', '/parent'); }); // Clear pending requests to force re-fetch invalidatePageTreeChildren(['parent-id']); // Second call - should have placeholder because atom state changed const childrenAfter = await getDataLoader(dataLoaderResult).getChildrenWithData('parent-id'); expect(childrenAfter).toHaveLength(2); expect(childrenAfter[0].id).toBe(CREATING_PAGE_VIRTUAL_ID); expect(childrenAfter[0].data.parent).toBe('parent-id'); expect(childrenAfter[0].data.path).toBe('/parent/'); expect(childrenAfter[1].id).toBe('existing-child'); }); test('should remove placeholder when creating state is cancelled', async () => { const mockChildren = [ createMockPage('existing-child', '/parent/existing'), ]; mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } }); const wrapper = createWrapper(); const { result: dataLoaderResult } = renderHook( () => useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT), { wrapper }, ); const { result: actionsResult } = renderHook( () => usePageTreeCreateActions(), { wrapper }, ); // Set creating state act(() => { actionsResult.current.startCreating('parent-id', '/parent'); }); // Clear pending requests and fetch - should have placeholder invalidatePageTreeChildren(['parent-id']); const childrenWithPlaceholder = await getDataLoader(dataLoaderResult).getChildrenWithData('parent-id'); expect(childrenWithPlaceholder).toHaveLength(2); expect(childrenWithPlaceholder[0].id).toBe(CREATING_PAGE_VIRTUAL_ID); // Cancel creating act(() => { actionsResult.current.cancelCreating(); }); // Clear pending requests and fetch - should NOT have placeholder invalidatePageTreeChildren(['parent-id']); const childrenAfterCancel = await getDataLoader(dataLoaderResult).getChildrenWithData('parent-id'); expect(childrenAfterCancel).toHaveLength(1); expect(childrenAfterCancel[0].id).toBe('existing-child'); }); test('dataLoader reference should remain stable when creating state changes via atom', async () => { const wrapper = createWrapper(); const { result: dataLoaderResult } = renderHook( () => useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT), { wrapper }, ); const { result: actionsResult } = renderHook( () => usePageTreeCreateActions(), { wrapper }, ); const firstDataLoader = dataLoaderResult.current; // Change creating state via atom act(() => { actionsResult.current.startCreating('some-parent', '/some-parent'); }); const secondDataLoader = dataLoaderResult.current; // DataLoader reference should be STABLE (same reference) // This is critical to prevent headless-tree from refetching all data expect(firstDataLoader).toBe(secondDataLoader); }); test('should correctly read state changes without rerender', async () => { // This test verifies that the dataLoader callbacks can read the latest // atom state even without a React rerender. This is the critical behavior // that was broken when using getDefaultStore() incorrectly. const mockChildren = [ createMockPage('existing-child', '/parent/existing'), ]; mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } }); const wrapper = createWrapper(); const { result: dataLoaderResult } = renderHook( () => useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT), { wrapper }, ); const { result: actionsResult } = renderHook( () => usePageTreeCreateActions(), { wrapper }, ); // Get the dataLoader reference BEFORE state change const dataLoader = getDataLoader(dataLoaderResult); // Set creating state act(() => { actionsResult.current.startCreating('parent-id', '/parent'); }); // Clear pending requests invalidatePageTreeChildren(['parent-id']); // Call getChildrenWithData using the SAME dataLoader reference // This should still see the updated atom state const children = await dataLoader.getChildrenWithData('parent-id'); expect(children).toHaveLength(2); expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID); expect(children[1].id).toBe('existing-child'); }); test('should work with multiple sequential state changes', async () => { const mockChildren = [ createMockPage('existing-child', '/parent/existing'), ]; mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } }); const wrapper = createWrapper(); const { result: dataLoaderResult } = renderHook( () => useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT), { wrapper }, ); const { result: actionsResult } = renderHook( () => usePageTreeCreateActions(), { wrapper }, ); const dataLoader = getDataLoader(dataLoaderResult); // Sequence: start -> cancel -> start again -> cancel // Each time, the dataLoader should correctly reflect the state // 1. Start creating act(() => { actionsResult.current.startCreating('parent-id', '/parent'); }); invalidatePageTreeChildren(['parent-id']); let children = await dataLoader.getChildrenWithData('parent-id'); expect(children).toHaveLength(2); expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID); // 2. Cancel act(() => { actionsResult.current.cancelCreating(); }); invalidatePageTreeChildren(['parent-id']); children = await dataLoader.getChildrenWithData('parent-id'); expect(children).toHaveLength(1); expect(children[0].id).toBe('existing-child'); // 3. Start again with different parent act(() => { actionsResult.current.startCreating('other-parent', '/other'); }); invalidatePageTreeChildren(['parent-id', 'other-parent']); // Original parent should NOT have placeholder children = await dataLoader.getChildrenWithData('parent-id'); expect(children).toHaveLength(1); // New parent should have placeholder mockApiv3Get.mockResolvedValueOnce({ data: { children: [] } }); children = await dataLoader.getChildrenWithData('other-parent'); expect(children).toHaveLength(1); expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID); expect(children[0].data.path).toBe('/other/'); // 4. Cancel again act(() => { actionsResult.current.cancelCreating(); }); invalidatePageTreeChildren(['other-parent']); mockApiv3Get.mockResolvedValueOnce({ data: { children: [] } }); children = await dataLoader.getChildrenWithData('other-parent'); expect(children).toHaveLength(0); }); }); });