Преглед изворни кода

test: add unit tests for use-data-loader hook and its caching behavior

Yuki Takei пре 4 месеци
родитељ
комит
131e3879d1

+ 363 - 0
apps/app/src/features/page-tree/client/components/SimplifiedItemsTree.spec.tsx

@@ -0,0 +1,363 @@
+import type React from 'react';
+import type { FC } from 'react';
+import { Suspense } from 'react';
+import { render, waitFor } from '@testing-library/react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import { clearChildrenCache } from '../hooks/use-data-loader';
+import type { TreeItemProps } from '../interfaces';
+import { SimplifiedItemsTree } from './SimplifiedItemsTree';
+
+// Mock the apiv3Get function
+const mockApiv3Get = vi.fn();
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Get: (...args: unknown[]) => mockApiv3Get(...args),
+}));
+
+// Mock useSWRxRootPage
+const mockRootPage: IPageForTreeItem = {
+  _id: 'root-page-id',
+  path: '/',
+  parent: null,
+  descendantCount: 10,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+};
+
+vi.mock('~/stores/page-listing', () => ({
+  useSWRxRootPage: () => ({
+    data: { rootPage: mockRootPage },
+  }),
+}));
+
+// Mock page-tree-create state hooks
+vi.mock('../states/page-tree-create', async () => {
+  const actual = await vi.importActual('../states/page-tree-create');
+  return {
+    ...actual,
+    useCreatingParentId: () => null,
+    useCreatingParentPath: () => null,
+  };
+});
+
+// Mock page-tree-update hooks
+vi.mock('../states/page-tree-update', () => ({
+  usePageTreeInformationGeneration: () => 1,
+  usePageTreeRevalidationEffect: () => {},
+}));
+
+// Mock usePageRename
+vi.mock('../hooks/use-page-rename', () => ({
+  usePageRename: () => ({
+    rename: vi.fn(),
+    getPageName: (item: { getItemData: () => IPageForTreeItem }) => {
+      const data = item.getItemData();
+      const parts = data.path?.split('/') ?? [];
+      return parts[parts.length - 1] || '/';
+    },
+  }),
+}));
+
+// Mock usePageCreate
+vi.mock('../hooks/use-page-create', () => ({
+  usePageCreate: () => ({
+    createFromPlaceholder: vi.fn(),
+    isCreatingPlaceholder: () => false,
+    cancelCreating: vi.fn(),
+  }),
+}));
+
+// Mock useScrollToSelectedItem
+vi.mock('../hooks/use-scroll-to-selected-item', () => ({
+  useScrollToSelectedItem: () => {},
+}));
+
+/**
+ * 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,
+});
+
+/**
+ * Simple TreeItem component for testing
+ */
+const TestTreeItem: FC<TreeItemProps> = ({ item }) => {
+  const itemData = item.getItemData();
+  return <div data-testid={`tree-item-${itemData._id}`}>{itemData.path}</div>;
+};
+
+/**
+ * Wrapper component with Suspense for testing
+ */
+const TestWrapper: FC<{ children: React.ReactNode }> = ({ children }) => (
+  <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
+);
+
+describe('SimplifiedItemsTree', () => {
+  beforeEach(() => {
+    // Clear cache before each test
+    clearChildrenCache();
+    // Reset mock
+    mockApiv3Get.mockReset();
+  });
+
+  describe('API call optimization', () => {
+    test('should only fetch children for expanded nodes, not for all visible nodes', async () => {
+      // Setup: Root page has 3 children, each with descendantCount > 0 (folders)
+      // but none are expanded initially except root
+      const rootChildren = [
+        createMockPage('child-1', '/Page1', { descendantCount: 5 }),
+        createMockPage('child-2', '/Page2', { descendantCount: 3 }),
+        createMockPage('child-3', '/Page3', { descendantCount: 0 }), // leaf node
+      ];
+
+      // Mock API responses
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            // Return empty children for other nodes (they shouldn't be called if not expanded)
+            return Promise.resolve({ data: { children: [] } });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      scrollerElem.style.overflow = 'auto';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <SimplifiedItemsTree
+            targetPath="/"
+            isEnableActions={false}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait for initial render and API calls to complete
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      // Give time for any additional API calls that might happen
+      await new Promise((resolve) => setTimeout(resolve, 100));
+
+      // Key assertion: API should only be called for:
+      // 1. root-page-id (the only expanded node by default)
+      // NOT for child-1, child-2, child-3 even though they are visible
+      const childrenApiCalls = mockApiv3Get.mock.calls.filter(
+        (call) => call[0] === '/page-listing/children',
+      );
+
+      // Should only have 1 call for root-page-id
+      expect(childrenApiCalls).toHaveLength(1);
+      expect(childrenApiCalls[0][1]).toEqual({ id: 'root-page-id' });
+
+      // Cleanup
+      document.body.removeChild(scrollerElem);
+    });
+
+    test('should not call API for nodes that have descendantCount of 0 (leaf nodes)', async () => {
+      // Setup: All children are leaf nodes (descendantCount = 0)
+      const rootChildren = [
+        createMockPage('leaf-1', '/Leaf1', { descendantCount: 0 }),
+        createMockPage('leaf-2', '/Leaf2', { descendantCount: 0 }),
+        createMockPage('leaf-3', '/Leaf3', { descendantCount: 0 }),
+      ];
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (
+            endpoint === '/page-listing/children' &&
+            params.id === 'root-page-id'
+          ) {
+            return Promise.resolve({ data: { children: rootChildren } });
+          }
+          return Promise.resolve({ data: { children: [] } });
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <SimplifiedItemsTree
+            targetPath="/"
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      await new Promise((resolve) => setTimeout(resolve, 100));
+
+      const childrenApiCalls = mockApiv3Get.mock.calls.filter(
+        (call) => call[0] === '/page-listing/children',
+      );
+
+      // Only root should have children fetched
+      expect(childrenApiCalls).toHaveLength(1);
+      expect(childrenApiCalls[0][1]).toEqual({ id: 'root-page-id' });
+
+      document.body.removeChild(scrollerElem);
+    });
+
+    test('isItemFolder should use descendantCount instead of calling getChildren()', async () => {
+      // This test verifies the fix for the bug where isItemFolder called getChildren()
+      // which triggered API calls for ALL visible nodes
+
+      const rootChildren = [
+        createMockPage('folder-1', '/Folder1', { descendantCount: 5 }),
+        createMockPage('folder-2', '/Folder2', { descendantCount: 10 }),
+        createMockPage('leaf-1', '/Leaf1', { descendantCount: 0 }),
+      ];
+
+      // Track which IDs have their children fetched
+      const fetchedChildrenIds: string[] = [];
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            fetchedChildrenIds.push(params.id);
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <SimplifiedItemsTree
+            targetPath="/"
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      // Wait for any potential additional API calls
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      // Critical assertion: Only root-page-id should have children fetched
+      // folder-1 and folder-2 should NOT be fetched even though they are folders (descendantCount > 0)
+      // This verifies that isItemFolder doesn't call getChildren()
+      expect(fetchedChildrenIds).toEqual(['root-page-id']);
+      expect(fetchedChildrenIds).not.toContain('folder-1');
+      expect(fetchedChildrenIds).not.toContain('folder-2');
+
+      document.body.removeChild(scrollerElem);
+    });
+  });
+
+  describe('auto-expand ancestors', () => {
+    test('should fetch children only for ancestors of targetPath', async () => {
+      // Setup: Deep nested structure
+      // / (root)
+      //   /Sandbox (expanded because it's ancestor of target)
+      //     /Sandbox/Test (target)
+      //   /Other (NOT expanded)
+
+      const rootChildren = [
+        createMockPage('sandbox-id', '/Sandbox', { descendantCount: 5 }),
+        createMockPage('other-id', '/Other', { descendantCount: 3 }),
+      ];
+
+      const sandboxChildren = [
+        createMockPage('test-id', '/Sandbox/Test', { descendantCount: 0 }),
+      ];
+
+      const fetchedChildrenIds: string[] = [];
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            fetchedChildrenIds.push(params.id);
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            if (params.id === 'sandbox-id') {
+              return Promise.resolve({ data: { children: sandboxChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <SimplifiedItemsTree
+            targetPath="/Sandbox/Test"
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait for auto-expand to complete
+      await waitFor(
+        () => {
+          expect(fetchedChildrenIds).toContain('sandbox-id');
+        },
+        { timeout: 1000 },
+      );
+
+      // Give some extra time for any unwanted calls
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      // Should fetch: root-page-id (initial), sandbox-id (ancestor of target)
+      // Should NOT fetch: other-id (not an ancestor of target)
+      expect(fetchedChildrenIds).toContain('root-page-id');
+      expect(fetchedChildrenIds).toContain('sandbox-id');
+      expect(fetchedChildrenIds).not.toContain('other-id');
+
+      document.body.removeChild(scrollerElem);
+    });
+  });
+});

+ 416 - 0
apps/app/src/features/page-tree/client/hooks/use-data-loader.spec.tsx

@@ -0,0 +1,416 @@
+import { renderHook } from '@testing-library/react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
+import { CREATING_PAGE_VIRTUAL_ID } from '../states/page-tree-create';
+import { clearChildrenCache, useDataLoader } from './use-data-loader';
+
+/**
+ * Type helper to extract getChildrenWithData from TreeDataLoader
+ * TreeDataLoader is a union type, and we're using the variant with getChildrenWithData
+ */
+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),
+}));
+
+// Mock the page-tree-create state hooks
+vi.mock('../states/page-tree-create', async () => {
+  const actual = await vi.importActual('../states/page-tree-create');
+  return {
+    ...actual,
+    useCreatingParentId: () => null,
+    useCreatingParentPath: () => null,
+  };
+});
+
+/**
+ * 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', () => {
+  const ROOT_PAGE_ID = 'root-page-id';
+  const ALL_PAGES_COUNT = 100;
+
+  beforeEach(() => {
+    // Clear cache before each test
+    clearChildrenCache();
+    // Reset mock
+    mockApiv3Get.mockReset();
+  });
+
+  describe('useDataLoader', () => {
+    describe('getItem', () => {
+      test('should return virtual root data without API call', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const item = await result.current.getItem(ROOT_PAGE_VIRTUAL_ID);
+
+        expect(item._id).toBe(ROOT_PAGE_ID);
+        expect(item.path).toBe('/');
+        expect(item.descendantCount).toBe(ALL_PAGES_COUNT);
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should return placeholder data without API call for creating placeholder', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const item = await result.current.getItem(CREATING_PAGE_VIRTUAL_ID);
+
+        expect(item._id).toBe(CREATING_PAGE_VIRTUAL_ID);
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should call API for regular item', async () => {
+        const mockPage = createMockPage('page-1', '/test');
+        mockApiv3Get.mockResolvedValue({ data: { item: mockPage } });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const item = await result.current.getItem('page-1');
+
+        expect(item).toEqual(mockPage);
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+        expect(mockApiv3Get).toHaveBeenCalledWith('/page-listing/item', {
+          id: 'page-1',
+        });
+      });
+    });
+
+    describe('getChildrenWithData', () => {
+      test('should return root page without API call for virtual root', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const children =
+          await getDataLoader(result).getChildrenWithData(ROOT_PAGE_VIRTUAL_ID);
+
+        expect(children).toHaveLength(1);
+        expect(children[0].id).toBe(ROOT_PAGE_ID);
+        expect(children[0].data.path).toBe('/');
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should return empty array without API call for placeholder node', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const children = await getDataLoader(result).getChildrenWithData(
+          CREATING_PAGE_VIRTUAL_ID,
+        );
+
+        expect(children).toHaveLength(0);
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should call API for regular item', async () => {
+        const mockChildren = [
+          createMockPage('child-1', '/parent/child-1'),
+          createMockPage('child-2', '/parent/child-2'),
+        ];
+        mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const children = await getDataLoader(result).getChildrenWithData('parent-id');
+
+        expect(children).toHaveLength(2);
+        expect(children[0].id).toBe('child-1');
+        expect(children[1].id).toBe('child-2');
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+        expect(mockApiv3Get).toHaveBeenCalledWith('/page-listing/children', {
+          id: 'parent-id',
+        });
+      });
+    });
+
+    describe('cache behavior - API call count', () => {
+      test('should call API only once for same itemId (cache hit)', async () => {
+        const mockChildren = [createMockPage('child-1', '/parent/child-1')];
+        mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // Call getChildrenWithData multiple times with the same ID
+        await getDataLoader(result).getChildrenWithData('parent-id');
+        await getDataLoader(result).getChildrenWithData('parent-id');
+        await getDataLoader(result).getChildrenWithData('parent-id');
+
+        // API should only be called once due to caching
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+      });
+
+      test('should call API once per unique itemId', async () => {
+        const mockChildren1 = [createMockPage('child-1', '/parent1/child-1')];
+        const mockChildren2 = [createMockPage('child-2', '/parent2/child-2')];
+        const mockChildren3 = [createMockPage('child-3', '/parent3/child-3')];
+
+        mockApiv3Get
+          .mockResolvedValueOnce({ data: { children: mockChildren1 } })
+          .mockResolvedValueOnce({ data: { children: mockChildren2 } })
+          .mockResolvedValueOnce({ data: { children: mockChildren3 } });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // Call getChildrenWithData for different IDs
+        await getDataLoader(result).getChildrenWithData('parent-1');
+        await getDataLoader(result).getChildrenWithData('parent-2');
+        await getDataLoader(result).getChildrenWithData('parent-3');
+
+        // API should be called once per unique ID
+        expect(mockApiv3Get).toHaveBeenCalledTimes(3);
+      });
+
+      test('should deduplicate concurrent requests for the same itemId', async () => {
+        const mockChildren = [createMockPage('child-1', '/parent/child-1')];
+
+        // Create a promise that we can resolve manually
+        let resolvePromise: ((value: unknown) => void) | undefined;
+        const delayedPromise = new Promise((resolve) => {
+          resolvePromise = resolve;
+        });
+
+        mockApiv3Get.mockReturnValue(delayedPromise);
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // Start multiple concurrent requests
+        const promise1 = getDataLoader(result).getChildrenWithData('parent-id');
+        const promise2 = getDataLoader(result).getChildrenWithData('parent-id');
+        const promise3 = getDataLoader(result).getChildrenWithData('parent-id');
+
+        // Resolve the API call
+        resolvePromise?.({ data: { children: mockChildren } });
+
+        // Wait for all promises
+        const [result1, result2, result3] = await Promise.all([
+          promise1,
+          promise2,
+          promise3,
+        ]);
+
+        // All should return the same data
+        expect(result1).toEqual(result2);
+        expect(result2).toEqual(result3);
+
+        // API should only be called once
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+      });
+
+      test('should call API again after cache is cleared', async () => {
+        const mockChildren = [createMockPage('child-1', '/parent/child-1')];
+        mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // First call
+        await getDataLoader(result).getChildrenWithData('parent-id');
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+
+        // Clear cache for specific ID
+        clearChildrenCache(['parent-id']);
+
+        // Second call after cache clear
+        await getDataLoader(result).getChildrenWithData('parent-id');
+        expect(mockApiv3Get).toHaveBeenCalledTimes(2);
+      });
+
+      test('should call API again after all cache is cleared', async () => {
+        const mockChildren1 = [createMockPage('child-1', '/parent1/child-1')];
+        const mockChildren2 = [createMockPage('child-2', '/parent2/child-2')];
+
+        mockApiv3Get
+          .mockResolvedValueOnce({ data: { children: mockChildren1 } })
+          .mockResolvedValueOnce({ data: { children: mockChildren2 } })
+          .mockResolvedValueOnce({ data: { children: mockChildren1 } })
+          .mockResolvedValueOnce({ data: { children: mockChildren2 } });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // First calls
+        await getDataLoader(result).getChildrenWithData('parent-1');
+        await getDataLoader(result).getChildrenWithData('parent-2');
+        expect(mockApiv3Get).toHaveBeenCalledTimes(2);
+
+        // Clear all cache
+        clearChildrenCache();
+
+        // Calls after cache clear
+        await getDataLoader(result).getChildrenWithData('parent-1');
+        await getDataLoader(result).getChildrenWithData('parent-2');
+        expect(mockApiv3Get).toHaveBeenCalledTimes(4);
+      });
+    });
+
+    describe('dataLoader reference stability', () => {
+      test('should return stable dataLoader reference when props do not change', () => {
+        const { result, rerender } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const firstDataLoader = result.current;
+
+        // Rerender with same props
+        rerender();
+
+        const secondDataLoader = result.current;
+
+        // Should be the same reference
+        expect(firstDataLoader).toBe(secondDataLoader);
+      });
+
+      test('should return new dataLoader reference when rootPageId changes', () => {
+        const { result, rerender } = renderHook(
+          ({ rootPageId }) => useDataLoader(rootPageId, ALL_PAGES_COUNT),
+          { initialProps: { rootPageId: ROOT_PAGE_ID } },
+        );
+
+        const firstDataLoader = result.current;
+
+        // Rerender with different rootPageId
+        rerender({ rootPageId: 'new-root-page-id' });
+
+        const secondDataLoader = result.current;
+
+        // Should be a new reference
+        expect(firstDataLoader).not.toBe(secondDataLoader);
+      });
+
+      test('should return new dataLoader reference when allPagesCount changes', () => {
+        const { result, rerender } = renderHook(
+          ({ allPagesCount }) => useDataLoader(ROOT_PAGE_ID, allPagesCount),
+          { initialProps: { allPagesCount: ALL_PAGES_COUNT } },
+        );
+
+        const firstDataLoader = result.current;
+
+        // Rerender with different allPagesCount
+        rerender({ allPagesCount: 200 });
+
+        const secondDataLoader = result.current;
+
+        // Should be a new reference
+        expect(firstDataLoader).not.toBe(secondDataLoader);
+      });
+    });
+
+    describe('error handling', () => {
+      test('should remove cache entry on API error', async () => {
+        const error = new Error('API Error');
+        mockApiv3Get.mockRejectedValueOnce(error).mockResolvedValueOnce({
+          data: { children: [createMockPage('child-1', '/parent/child-1')] },
+        });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // First call - should fail
+        await expect(
+          getDataLoader(result).getChildrenWithData('parent-id'),
+        ).rejects.toThrow('API Error');
+
+        // Second call - should retry since cache entry was removed
+        const children = await getDataLoader(result).getChildrenWithData('parent-id');
+
+        expect(children).toHaveLength(1);
+        expect(mockApiv3Get).toHaveBeenCalledTimes(2);
+      });
+    });
+  });
+
+  describe('clearChildrenCache', () => {
+    test('should clear specific cache entries', async () => {
+      const mockChildren = [createMockPage('child-1', '/parent/child-1')];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const { result } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      // Populate cache
+      await getDataLoader(result).getChildrenWithData('parent-1');
+      await getDataLoader(result).getChildrenWithData('parent-2');
+      expect(mockApiv3Get).toHaveBeenCalledTimes(2);
+
+      // Clear only parent-1
+      clearChildrenCache(['parent-1']);
+
+      // parent-1 should call API again, parent-2 should use cache
+      await getDataLoader(result).getChildrenWithData('parent-1');
+      await getDataLoader(result).getChildrenWithData('parent-2');
+      expect(mockApiv3Get).toHaveBeenCalledTimes(3);
+    });
+
+    test('should clear all cache entries when called without arguments', async () => {
+      const mockChildren = [createMockPage('child-1', '/parent/child-1')];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const { result } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      // Populate cache
+      await getDataLoader(result).getChildrenWithData('parent-1');
+      await getDataLoader(result).getChildrenWithData('parent-2');
+      expect(mockApiv3Get).toHaveBeenCalledTimes(2);
+
+      // Clear all cache
+      clearChildrenCache();
+
+      // Both should call API again
+      await getDataLoader(result).getChildrenWithData('parent-1');
+      await getDataLoader(result).getChildrenWithData('parent-2');
+      expect(mockApiv3Get).toHaveBeenCalledTimes(4);
+    });
+  });
+});