| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- /**
- * 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<typeof useDataLoader> & {
- 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> = {},
- ): 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<typeof useDataLoader>;
- }): 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<typeof createStore>;
- // Wrapper component that provides the Jotai store
- const createWrapper = (): FC<PropsWithChildren> => {
- const Wrapper: FC<PropsWithChildren> = ({ children }) => (
- <Provider store={store}>{children}</Provider>
- );
- 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);
- });
- });
- });
|