import * as React from 'react'; import expect from 'expect'; import { render, waitFor } from '@testing-library/react'; import { QueryClient } from '@tanstack/react-query'; import { CoreAdminContext } from '../core'; import { useGetManyAggregate, type UseGetManyAggregateOptions, } from './useGetManyAggregate'; import { testDataProvider } from '../dataProvider'; const UseGetManyAggregate = ({ resource, ids, meta, options = {}, callback = null, }: { resource: string; ids: (string | number)[]; meta?: any; options?: UseGetManyAggregateOptions; callback?: (v: ReturnType) => void; }) => { const hookValue = useGetManyAggregate(resource, { ids, meta }, options); if (callback) callback(hookValue); return
hello
; }; describe('useGetManyAggregate', () => { let dataProvider; beforeEach(() => { dataProvider = testDataProvider({ getMany: jest .fn() .mockResolvedValue({ data: [{ id: 1, title: 'foo' }] }), }); }); it('should call dataProvider.getMany() on mount', async () => { render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); expect(dataProvider.getMany).toHaveBeenCalledWith('posts', { ids: [1], signal: undefined, }); }); }); it('should not call dataProvider.getMany() on mount if enabled is false', async () => { const { rerender } = render( ); await new Promise(resolve => setTimeout(resolve)); expect(dataProvider.getMany).toHaveBeenCalledTimes(0); rerender( ); await new Promise(resolve => setTimeout(resolve)); expect(dataProvider.getMany).toHaveBeenCalledTimes(1); }); it('should not call dataProvider.getMany() on update', async () => { const { rerender } = render( ); await new Promise(resolve => setTimeout(resolve)); expect(dataProvider.getMany).toHaveBeenCalledTimes(1); rerender( ); await new Promise(resolve => setTimeout(resolve)); expect(dataProvider.getMany).toHaveBeenCalledTimes(1); }); it('should not call dataProvider.getMany() if ids is empty', async () => { const { rerender } = render( ); await new Promise(resolve => setTimeout(resolve)); expect(dataProvider.getMany).toHaveBeenCalledTimes(0); rerender( ); await new Promise(resolve => setTimeout(resolve)); expect(dataProvider.getMany).toHaveBeenCalledTimes(0); }); it('should recall dataProvider.getMany() when ids changes', async () => { const { rerender } = render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); }); rerender( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(2); }); }); it('should recall dataProvider.getMany() when resource changes', async () => { const { rerender } = render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); }); rerender( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(2); }); }); it('should use data from query cache on mount', async () => { const FetchGetMany = () => { useGetManyAggregate('posts', { ids: ['1'] }); return dummy; }; const hookValue = jest.fn(); const { rerender } = render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); }); rerender( ); expect(hookValue).toHaveBeenCalledWith( expect.objectContaining({ data: [{ id: 1, title: 'foo' }], isFetching: true, isLoading: false, error: null, }) ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(2); }); expect(hookValue).toHaveBeenCalledWith( expect.objectContaining({ data: [{ id: 1, title: 'foo' }], isFetching: false, isLoading: false, error: null, }) ); }); it('should set the error state when the dataProvider fails', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); const hookValue = jest.fn(); const dataProvider = testDataProvider({ getMany: jest.fn().mockRejectedValue(new Error('failed')), }); render( ); expect(hookValue).toHaveBeenCalledWith( expect.objectContaining({ error: null, }) ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); }); expect(hookValue).toHaveBeenCalledWith( expect.objectContaining({ error: new Error('failed'), }) ); }); it('should execute success side effects on success', async () => { const onSuccess = jest.fn(); render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); expect(onSuccess).toHaveBeenCalledWith([{ id: 1, title: 'foo' }]); }); }); it('should execute error side effects on failure', async () => { jest.spyOn(console, 'error').mockImplementationOnce(() => {}); const dataProvider = testDataProvider({ getMany: jest.fn().mockRejectedValue(new Error('failed')), }); const onError = jest.fn(); render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith(new Error('failed')); }); }); it('should aggregate multiple calls for the same resource into one', async () => { render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); expect(dataProvider.getMany).toHaveBeenCalledWith('posts', { ids: [1, 2, 3, 4, 5, 6], signal: undefined, }); }); }); it('should not aggregate multiple calls for different resources', async () => { render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(2); expect(dataProvider.getMany).toHaveBeenCalledWith('posts', { ids: [1, 2, 3, 4], signal: undefined, }); expect(dataProvider.getMany).toHaveBeenCalledWith('comments', { ids: [5, 6], signal: undefined, }); }); }); it('should not aggregate multiple calls for different meta', async () => { render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(2); expect(dataProvider.getMany).toHaveBeenCalledWith('posts', { ids: [1, 2, 3, 4], meta: { test: true }, signal: undefined, }); expect(dataProvider.getMany).toHaveBeenCalledWith('posts', { ids: [5, 6], signal: undefined, }); }); }); it('should deduplicated repeated ids', async () => { render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); expect(dataProvider.getMany).toHaveBeenCalledWith('posts', { ids: [1, 2, 3, 4], signal: undefined, }); }); }); it('should aggregate multiple calls for the same resource into one even if one of the calls requests all the aggregated ids', async () => { const firstCallback = jest.fn(); const secondCallback = jest.fn(); const thirdCallback = jest.fn(); const dataProvider = testDataProvider({ getMany: jest.fn().mockResolvedValue({ data: [ { id: 1, title: 'one' }, { id: 2, title: 'two' }, { id: 3, title: 'three' }, ], }), }); render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); expect(dataProvider.getMany).toHaveBeenCalledWith('posts', { ids: [1, 2, 3], signal: undefined, }); }); await waitFor(() => { expect(firstCallback).toHaveBeenCalledWith( expect.objectContaining({ data: [{ id: 1, title: 'one' }], }) ); }); expect(secondCallback).toHaveBeenCalledWith( expect.objectContaining({ data: [ { id: 1, title: 'one' }, { id: 2, title: 'two' }, ], }) ); expect(thirdCallback).toHaveBeenCalledWith( expect.objectContaining({ data: [ { id: 1, title: 'one' }, { id: 2, title: 'two' }, { id: 3, title: 'three' }, ], }) ); }); it.each([ // case when we have only one query { queries: [{ ids: ['1'] }], expectedQueryKeyParams: { ids: ['1'] } }, // case when we have multiple queries on the same id (deduplication) { queries: [{ ids: ['1'] }, { ids: ['1'] }], expectedQueryKeyParams: { ids: ['1'] }, }, // case when we have multiple queries on different ids (aggregation) { queries: [{ ids: ['1'] }, { ids: ['2'] }], expectedQueryKeyParams: { ids: ['1', '2'] }, }, // case when we have multiple queries on different ids, including a call with all ids // (no manual aggregation needed) { queries: [{ ids: ['1'] }, { ids: ['2'] }, { ids: ['1', '2'] }], expectedQueryKeyParams: { ids: ['1', '2'] }, }, ])( 'should abort the request if the query is canceled', async ({ queries, expectedQueryKeyParams }) => { const abort = jest.fn(); const dataProvider = testDataProvider({ getMany: jest.fn( (_resource, { signal }) => new Promise(() => { signal.addEventListener('abort', () => { abort(signal.reason); }); }) ) as any, }); dataProvider.supportAbortSignal = true; const queryClient = new QueryClient(); render( {queries.map((query, index) => ( ))} ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalled(); }); expect(dataProvider.getMany).toHaveBeenCalledTimes(1); expect(dataProvider.getMany).toHaveBeenCalledWith( 'posts', expect.objectContaining(expectedQueryKeyParams) ); queryClient.cancelQueries({ queryKey: ['posts', 'getMany', expectedQueryKeyParams], }); await waitFor(() => { expect(abort).toHaveBeenCalled(); }); } ); it('should only call a query that is not yet aborted and then abort it successfully', async () => { const abort = jest.fn(); const reject = jest.fn(); const dataProvider = testDataProvider({ getMany: jest.fn( (_resource, { signal }) => new Promise(() => { if (signal.aborted) { reject( 'Test failure: called a query which already received an abort signal' ); } signal.addEventListener('abort', () => { abort(signal.reason); }); }) ) as any, }); dataProvider.supportAbortSignal = true; const queryClient = new QueryClient(); const { rerender } = render( ); queryClient.cancelQueries({ queryKey: ['posts', 'getMany', { ids: ['1'] }], }); rerender( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalled(); }); expect(dataProvider.getMany).toHaveBeenCalledTimes(1); expect(dataProvider.getMany).toHaveBeenCalledWith( 'posts', expect.objectContaining({ ids: ['1'] }) ); expect(reject).not.toHaveBeenCalled(); queryClient.cancelQueries({ queryKey: ['posts', 'getMany', { ids: ['1'] }], }); await waitFor(() => { expect(abort).toHaveBeenCalled(); }); }); it('should resolve all the aggregated calls in a single React commit', async () => { // Each aggregated call belongs to a distinct useQuery, so resolving them one // by one makes React commit each of them separately. A child updating its // state during the commit phase (like an avatar reporting its image loading // status from a layout effect) then turns those commits into nested updates // instead of batched ones, and React throws "Maximum update depth exceeded" // past 50 of them. // See https://github.com/marmelab/react-admin/issues/11324 const CONSUMERS = 30; let commits = 0; const commitsWithNewData: number[] = []; // updates its state during the commit phase, like Base UI's Avatar.Image const CommitPhaseChild = () => { const [status, setStatus] = React.useState('idle'); React.useLayoutEffect(() => { setStatus('loaded'); }, []); return {status}; }; const Consumer = ({ id }: { id: number }) => { const { data } = useGetManyAggregate('posts', { ids: [id] }); const hadData = React.useRef(false); React.useLayoutEffect(() => { if (data && !hadData.current) { hadData.current = true; commitsWithNewData.push(commits); } }); return data ? : pending; }; const dataProviderWithManyRecords = testDataProvider({ getMany: jest.fn((_resource, { ids }) => Promise.resolve({ data: ids.map(id => ({ id, title: `post ${id}` })), }) ) as any, }); render( commits++}> {Array.from({ length: CONSUMERS }, (_, index) => ( ))} ); await waitFor(() => { expect(commitsWithNewData).toHaveLength(CONSUMERS); }); expect(dataProviderWithManyRecords.getMany).toHaveBeenCalledTimes(1); expect(new Set(commitsWithNewData).size).toBe(1); }); it('should not repopulate the cache of a query canceled while the request is in flight', async () => { let resolveGetMany: (value: any) => void = () => undefined; const dataProvider = testDataProvider({ getMany: jest.fn( () => new Promise(resolve => { resolveGetMany = resolve; }) ) as any, }); const queryClient = new QueryClient(); render( ); await waitFor(() => { expect(dataProvider.getMany).toHaveBeenCalledTimes(1); }); // cancel one of the two pending calls, not the aggregated request await queryClient.cancelQueries({ queryKey: ['posts', 'getMany', { ids: ['1'] }], exact: true, }); resolveGetMany({ data: [ { id: 1, title: 'foo' }, { id: 2, title: 'bar' }, ], }); // the call that was not canceled still gets its data await waitFor(() => { expect( queryClient.getQueryData(['posts', 'getMany', { ids: ['2'] }]) ).toEqual([{ id: 2, title: 'bar' }]); }); // the canceled one is left alone expect( queryClient.getQueryData(['posts', 'getMany', { ids: ['1'] }]) ).toBeUndefined(); }); });