From 4acf3f2871f940972194235e404b6e0e79270f18 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Wed, 11 Sep 2024 15:20:20 -0700 Subject: [PATCH] Make usePaginationFragment compatible with Summary: Fixes several issues with `usePaginationFragment()` to make it compatible with unstable `` API. The motivating bug was as follows: * Load some component with pagination inside an `` * Call loadNext() * While the request is still pending, hide the Activity component * (request completes) * Reveal the Activity component again In this case the ideal behavior is that the component observes the pagination results when it is revealed and `hasNext` reflects the correct value. However, what was happening is that the fetch was being unsubscribed at the network level on unmount (or in this case, on detach), such that the results were never even written into the store. As the bug demonstrates, cancelling work in "unmount" can leave a component in an inconsistent state. For example, the component might internally set state to track that it is pending and therefore not attempt to initiate any more fetches. There are a few approaches - uses actions is one - but the fix here is to simply not cancel the fetch on unmount, in case it's actually just a detach. We continue to write the new results to the store and call internal and user callbacks so that the component state can update in sync with the updated store state. Reviewed By: tyao1 Differential Revision: D62474325 fbshipit-source-id: 66fa4522cda7897c56dd77ad4c13a4382f5f15db --- .../__tests__/usePaginationFragment-test.js | 6229 +++++++++-------- .../relay-hooks/getConnectionState.js | 97 + .../relay-hooks/useFragmentInternal.js | 2 +- .../relay-hooks/useLoadMoreFunction.js | 94 +- .../useLoadMoreFunction_EXPERIMENTAL.js | 280 + .../relay-runtime/util/RelayFeatureFlags.js | 6 +- 6 files changed, 3660 insertions(+), 3048 deletions(-) create mode 100644 packages/react-relay/relay-hooks/getConnectionState.js create mode 100644 packages/react-relay/relay-hooks/useLoadMoreFunction_EXPERIMENTAL.js diff --git a/packages/react-relay/relay-hooks/__tests__/usePaginationFragment-test.js b/packages/react-relay/relay-hooks/__tests__/usePaginationFragment-test.js index 61153bf0e22a9..55eadf7db9a30 100644 --- a/packages/react-relay/relay-hooks/__tests__/usePaginationFragment-test.js +++ b/packages/react-relay/relay-hooks/__tests__/usePaginationFragment-test.js @@ -49,6 +49,7 @@ const { Network, Observable, RecordSource, + RelayFeatureFlags, Store, createOperationDescriptor, graphql, @@ -631,392 +632,120 @@ afterEach(() => { renderSpy.mockClear(); }); -describe('initial render', () => { - // The bulk of initial render behavior is covered in useFragmentNode-test, - // so this suite covers the basic cases as a sanity check. - it('should throw error if fragment is plural', () => { - jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - - const UserFragment = graphql` - fragment usePaginationFragmentTest1Fragment on User @relay(plural: true) { - id - } - `; - const renderer = renderFragment({fragment: UserFragment}); - expect( - renderer.toJSON().includes('Remove `@relay(plural: true)` from fragment'), - ).toEqual(true); - }); - - it('should throw error if fragment is missing @refetchable directive', () => { - jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - - const UserFragment = graphql` - fragment usePaginationFragmentTest2Fragment on User { - id - } - `; - const renderer = renderFragment({fragment: UserFragment}); - expect( - renderer - .toJSON() - .includes( - 'Did you forget to add a @refetchable directive to the fragment?', - ), - ).toEqual(true); - }); - - it('should throw error if fragment is missing @connection directive', () => { - jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - - const UserFragment = graphql` - fragment usePaginationFragmentTest3Fragment on User - @refetchable( - queryName: "usePaginationFragmentTest3FragmentRefetchQuery" - ) { - id - } - `; - const renderer = renderFragment({fragment: UserFragment}); - expect( - renderer - .toJSON() - .includes( - 'Did you forget to add a @connection directive to the connection field in the fragment?', - ), - ).toEqual(true); - }); - - it('should render fragment without error when data is available', () => { - renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - - hasNext: true, - hasPrevious: false, - }, - ]); - }); - - it('should render fragment without error when ref is null', () => { - renderFragment({userRef: null}); - expectFragmentResults([ - { - data: null, - isLoadingNext: false, - isLoadingPrevious: false, - - hasNext: false, - hasPrevious: false, - }, - ]); +describe.each([ + ['Experimental', true], + ['Current', false], +])('usePaginationFragment (%s)', (_name, ENABLE_ACTIVITY_COMPATIBILITY) => { + beforeEach(() => { + RelayFeatureFlags.ENABLE_ACTIVITY_COMPATIBILITY = + ENABLE_ACTIVITY_COMPATIBILITY; }); - - it('should render fragment without error when ref is undefined', () => { - renderFragment({userRef: undefined}); - expectFragmentResults([ - { - data: null, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: false, - hasPrevious: false, - }, - ]); + afterEach(() => { + RelayFeatureFlags.ENABLE_ACTIVITY_COMPATIBILITY = false; }); - it('should update when fragment data changes', () => { - renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - - // Update parent record - TestRenderer.act(() => { - environment.commitPayload(query, { - node: { - __typename: 'User', - id: '1', - // Update name - name: 'Alice in Wonderland', - }, - }); - }); - expectFragmentResults([ - { - data: { - ...initialUser, - // Assert that name is updated - name: 'Alice in Wonderland', - }, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); + describe('initial render', () => { + // The bulk of initial render behavior is covered in useFragmentNode-test, + // so this suite covers the basic cases as a sanity check. + it('should throw error if fragment is plural', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - // Update edge - TestRenderer.act(() => { - environment.commitPayload(query, { - node: { - __typename: 'User', - id: 'node:1', - // Update name - name: 'name:node:1-updated', - }, - }); + const UserFragment = graphql` + fragment usePaginationFragmentTest1Fragment on User + @relay(plural: true) { + id + } + `; + const renderer = renderFragment({fragment: UserFragment}); + expect( + renderer + .toJSON() + .includes('Remove `@relay(plural: true)` from fragment'), + ).toEqual(true); }); - expectFragmentResults([ - { - data: { - ...initialUser, - name: 'Alice in Wonderland', - friends: { - ...initialUser.friends, - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - // Assert that name is updated - name: 'name:node:1-updated', - ...createFragmentRef('node:1', query), - }, - }, - ], - }, - }, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - }); - - it('should throw a promise if data is missing for fragment and request is in flight', () => { - // This prevents console.error output in the test, which is expected - jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - - const missingDataVariables = {...variables, id: '4'}; - const missingDataQuery = createOperationDescriptor( - gqlQuery, - missingDataVariables, - ); + it('should throw error if fragment is missing @refetchable directive', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - // Commit a payload with name and profile_picture are missing - environment.commitPayload(missingDataQuery, { - node: { - __typename: 'User', - id: '4', - }, + const UserFragment = graphql` + fragment usePaginationFragmentTest2Fragment on User { + id + } + `; + const renderer = renderFragment({fragment: UserFragment}); + expect( + renderer + .toJSON() + .includes( + 'Did you forget to add a @refetchable directive to the fragment?', + ), + ).toEqual(true); }); - // Make sure query is in flight - fetchQuery(environment, missingDataQuery).subscribe({}); - - const renderer = renderFragment({owner: missingDataQuery}); - expect(renderer.toJSON()).toEqual('Fallback'); - }); -}); - -describe('pagination', () => { - let release; - - beforeEach(() => { - release = jest.fn<$ReadOnlyArray, mixed>(); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - environment.retain.mockImplementation((...args) => { - return { - dispose: release, - }; - }); - }); + it('should throw error if fragment is missing @connection directive', () => { + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - function expectRequestIsInFlight(expected: any) { - expect(fetch).toBeCalledTimes(expected.requestCount); - const fetchCall = fetch.mock.calls.find(call => { - return ( - call[0] === - (expected.gqlPaginationQuery ?? gqlPaginationQuery).params && - areEqual(call[1], expected.paginationVariables) && - areEqual(call[2], {force: true}) - ); + const UserFragment = graphql` + fragment usePaginationFragmentTest3Fragment on User + @refetchable( + queryName: "usePaginationFragmentTest3FragmentRefetchQuery" + ) { + id + } + `; + const renderer = renderFragment({fragment: UserFragment}); + expect( + renderer + .toJSON() + .includes( + 'Did you forget to add a @connection directive to the connection field in the fragment?', + ), + ).toEqual(true); }); - const isInFlight = fetchCall != null; - expect(isInFlight).toEqual(expected.inFlight); - } - - function expectFragmentIsLoadingMore( - renderer: any, - direction: Direction, - expected: { - data: mixed, - hasNext: boolean, - hasPrevious: boolean, - paginationVariables: Variables, - gqlPaginationQuery?: $FlowFixMe, - }, - ) { - // Assert fragment sets isLoading to true - expect(renderSpy).toBeCalledTimes(1); - assertCall( - { - data: expected.data, - isLoadingNext: direction === 'forward', - isLoadingPrevious: direction === 'backward', - hasNext: expected.hasNext, - hasPrevious: expected.hasPrevious, - }, - 0, - ); - renderSpy.mockClear(); - - // Assert refetch query was fetched - expectRequestIsInFlight({...expected, inFlight: true, requestCount: 1}); - } - - // TODO - // - backward pagination - // - simultaneous pagination - // - TODO(T41131846): Fetch/Caching policies for loadMore / when network - // returns or errors synchronously - // - TODO(T41140071): Handle loadMore while refetch is in flight and vice-versa - - describe('loadNext', () => { - const direction = 'forward'; - it('does not load more if component has unmounted', () => { - const warning = require('warning'); - // $FlowFixMe[prop-missing] - warning.mockClear(); - - const renderer = renderFragment(); + it('should render fragment without error when data is available', () => { + renderFragment(); expectFragmentResults([ { data: initialUser, isLoadingNext: false, isLoadingPrevious: false, + hasNext: true, hasPrevious: false, }, ]); - - TestRenderer.act(() => { - renderer.unmount(); - }); - TestRenderer.act(() => { - loadNext(1); - }); - - expect(warning).toHaveBeenCalledTimes(2); - expect( - (warning: $FlowFixMe).mock.calls[1][1].includes( - 'Relay: Unexpected fetch on unmounted component', - ), - ).toEqual(true); - expect(fetch).toHaveBeenCalledTimes(0); }); - it('does not load more if fragment ref passed to usePaginationFragment() was null', () => { - const warning = require('warning'); - // $FlowFixMe[prop-missing] - warning.mockClear(); - + it('should render fragment without error when ref is null', () => { renderFragment({userRef: null}); expectFragmentResults([ { data: null, isLoadingNext: false, isLoadingPrevious: false, + hasNext: false, hasPrevious: false, }, ]); - - TestRenderer.act(() => { - loadNext(1); - }); - - expect(warning).toHaveBeenCalledTimes(2); - expect( - (warning: $FlowFixMe).mock.calls[1][1].includes( - 'Relay: Unexpected fetch while using a null fragment ref', - ), - ).toEqual(true); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(fetch).toHaveBeenCalledTimes(0); }); - it('does not load more if request is already in flight', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); + it('should render fragment without error when ref is undefined', () => { + renderFragment({userRef: undefined}); expectFragmentResults([ { - data: initialUser, + data: null, isLoadingNext: false, isLoadingPrevious: false, - hasNext: true, + hasNext: false, hasPrevious: false, }, ]); - - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - expect(callback).toBeCalledTimes(0); - - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - expect(fetch).toBeCalledTimes(1); - expect(callback).toBeCalledTimes(1); - expect(renderSpy).toBeCalledTimes(0); }); - it('does not load more if parent query is already active (i.e. during streaming)', () => { - // This prevents console.error output in the test, which is expected - jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - const { - __internal: {fetchQuery}, - } = require('relay-runtime'); - - fetchQuery(environment, query).subscribe({}); - - const callback = jest.fn<[Error | null], void>(); - fetch.mockClear(); + it('should update when fragment data changes', () => { renderFragment(); - expectFragmentResults([ { data: initialUser, @@ -1027,291 +756,304 @@ describe('pagination', () => { }, ]); + // Update parent record TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - expect(fetch).toBeCalledTimes(0); - expect(callback).toBeCalledTimes(1); - expect(renderSpy).toBeCalledTimes(0); - }); - - it('attempts to load more even if there are no more items to load', () => { - (environment.getStore().getSource(): $FlowFixMe).clear(); - environment.commitPayload(query, { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - username: 'username:node:1', - }, - }, - ], - pageInfo: { - endCursor: 'cursor:1', - hasNextPage: false, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + // Update name + name: 'Alice in Wonderland', }, - }, + }); }); - const callback = jest.fn<[Error | null], void>(); - - const renderer = renderFragment(); - const expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - pageInfo: expect.objectContaining({hasNextPage: false}), - }, - }; expectFragmentResults([ { - data: expectedUser, + data: { + ...initialUser, + // Assert that name is updated + name: 'Alice in Wonderland', + }, isLoadingNext: false, isLoadingPrevious: false, - hasNext: false, + hasNext: true, hasPrevious: false, }, ]); + // Update edge TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: expectedUser, - hasNext: false, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); - - resolveQuery({ - data: { + environment.commitPayload(query, { node: { __typename: 'User', - id: '1', - name: 'Alice', - friends: { - // $FlowFixMe[missing-empty-array-annot] - edges: [], - pageInfo: { - startCursor: null, - endCursor: null, - hasNextPage: null, - hasPreviousPage: null, - }, - }, + id: 'node:1', + // Update name + name: 'name:node:1-updated', }, - }, + }); }); - expectFragmentResults([ - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: false, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); - }); - it('loads and renders next items in connection', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); expectFragmentResults([ { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); - - resolveQuery({ - data: { - node: { - __typename: 'User', - id: '1', - name: 'Alice', + data: { + ...initialUser, + name: 'Alice in Wonderland', friends: { + ...initialUser.friends, edges: [ { - cursor: 'cursor:2', + cursor: 'cursor:1', node: { __typename: 'User', - id: 'node:2', - name: 'name:node:2', - username: 'username:node:2', + id: 'node:1', + // Assert that name is updated + name: 'name:node:1-updated', + ...createFragmentRef('node:1', query), }, }, ], - pageInfo: { - startCursor: 'cursor:2', - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: true, - }, }, }, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + + it('should throw a promise if data is missing for fragment and request is in flight', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + + const missingDataVariables = {...variables, id: '4'}; + const missingDataQuery = createOperationDescriptor( + gqlQuery, + missingDataVariables, + ); + + // Commit a payload with name and profile_picture are missing + environment.commitPayload(missingDataQuery, { + node: { + __typename: 'User', + id: '4', }, }); - const expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', query), - }, - }, - { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - ...createFragmentRef('node:2', query), - }, - }, - ], - pageInfo: { - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: expectedUser, - isLoadingNext: true, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); + // Make sure query is in flight + fetchQuery(environment, missingDataQuery).subscribe({}); + + const renderer = renderFragment({owner: missingDataQuery}); + expect(renderer.toJSON()).toEqual('Fallback'); }); + }); - it('loads more correctly using fragment variables from literal @argument values', () => { - let expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', queryWithLiteralArgs), - }, - }, - ], - }, - }; + describe('pagination', () => { + let release; - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment({owner: queryWithLiteralArgs}); - expectFragmentResults([ + beforeEach(() => { + release = jest.fn<$ReadOnlyArray, mixed>(); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + environment.retain.mockImplementation((...args) => { + return { + dispose: release, + }; + }); + }); + + function expectRequestIsInFlight(expected: any) { + expect(fetch).toBeCalledTimes(expected.requestCount); + const fetchCall = fetch.mock.calls.find(call => { + return ( + call[0] === + (expected.gqlPaginationQuery ?? gqlPaginationQuery).params && + areEqual(call[1], expected.paginationVariables) && + areEqual(call[2], {force: true}) + ); + }); + const isInFlight = fetchCall != null; + expect(isInFlight).toEqual(expected.inFlight); + } + + function expectFragmentIsLoadingMore( + renderer: any, + direction: Direction, + expected: { + data: mixed, + hasNext: boolean, + hasPrevious: boolean, + paginationVariables: Variables, + gqlPaginationQuery?: $FlowFixMe, + }, + ) { + // Assert fragment sets isLoading to true + expect(renderSpy).toBeCalledTimes(1); + assertCall( { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, + data: expected.data, + isLoadingNext: direction === 'forward', + isLoadingPrevious: direction === 'backward', + hasNext: expected.hasNext, + hasPrevious: expected.hasPrevious, + }, + 0, + ); + renderSpy.mockClear(); + + // Assert refetch query was fetched + expectRequestIsInFlight({...expected, inFlight: true, requestCount: 1}); + } + + // TODO + // - backward pagination + // - simultaneous pagination + // - TODO(T41131846): Fetch/Caching policies for loadMore / when network + // returns or errors synchronously + // - TODO(T41140071): Handle loadMore while refetch is in flight and vice-versa + + describe('loadNext', () => { + const direction = 'forward'; + + it('does not load more if component has unmounted', () => { + const warning = require('warning'); + // $FlowFixMe[prop-missing] + warning.mockClear(); + + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + renderer.unmount(); + }); + TestRenderer.act(() => { + loadNext(1); + }); + + expect(warning).toHaveBeenCalledTimes(2); + expect( + (warning: $FlowFixMe).mock.calls[1][1].includes( + 'Relay: Unexpected fetch on unmounted component', + ), + ).toEqual(true); + expect(fetch).toHaveBeenCalledTimes(0); + }); + + it('does not load more if fragment ref passed to usePaginationFragment() was null', () => { + const warning = require('warning'); + // $FlowFixMe[prop-missing] + warning.mockClear(); + + renderFragment({userRef: null}); + expectFragmentResults([ + { + data: null, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: false, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1); + }); + + expect(warning).toHaveBeenCalledTimes(2); + expect( + (warning: $FlowFixMe).mock.calls[1][1].includes( + 'Relay: Unexpected fetch while using a null fragment ref', + ), + ).toEqual(true); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(fetch).toHaveBeenCalledTimes(0); + }); + + it('does not load more if request is already in flight', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + expect(callback).toBeCalledTimes(0); + + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, hasNext: true, hasPrevious: false, - }, - ]); + paginationVariables, + gqlPaginationQuery, + }); - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + expect(fetch).toBeCalledTimes(1); + expect(callback).toBeCalledTimes(1); + expect(renderSpy).toBeCalledTimes(0); }); - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: true, - orderby: ['name'], - scale: null, - }; - expect(paginationVariables.isViewerFriendLocal).not.toBe( - variables.isViewerFriend, - ); - expectFragmentIsLoadingMore(renderer, direction, { - data: expectedUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, + + it('does not load more if parent query is already active (i.e. during streaming)', () => { + // This prevents console.error output in the test, which is expected + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + const { + __internal: {fetchQuery}, + } = require('relay-runtime'); + + fetchQuery(environment, query).subscribe({}); + + const callback = jest.fn<[Error | null], void>(); + fetch.mockClear(); + renderFragment(); + + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + expect(fetch).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(1); + expect(renderSpy).toBeCalledTimes(0); }); - expect(callback).toBeCalledTimes(0); - resolveQuery({ - data: { + it('attempts to load more even if there are no more items to load', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { node: { __typename: 'User', id: '1', @@ -1319,232 +1061,1050 @@ describe('pagination', () => { friends: { edges: [ { - cursor: 'cursor:2', + cursor: 'cursor:1', node: { __typename: 'User', - id: 'node:2', - name: 'name:node:2', - username: 'username:node:2', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', }, }, ], pageInfo: { - startCursor: 'cursor:2', - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: true, + endCursor: 'cursor:1', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor:1', }, }, }, - }, - }); + }); + const callback = jest.fn<[Error | null], void>(); - expectedUser = { - ...expectedUser, - friends: { - ...expectedUser.friends, - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', queryWithLiteralArgs), - }, - }, - { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - ...createFragmentRef('node:2', queryWithLiteralArgs), - }, - }, - ], - pageInfo: { - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', + const renderer = renderFragment(); + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({hasNextPage: false}), }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: expectedUser, - isLoadingNext: true, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); - }); + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: false, + hasPrevious: false, + }, + ]); - it('loads more correctly when original variables do not include an id', () => { - const callback = jest.fn<[Error | null], void>(); - const viewer = environment.lookup(queryWithoutID.fragment).data?.viewer; - const userRef = - typeof viewer === 'object' && viewer != null ? viewer?.actor : null; - invariant(userRef != null, 'Expected to have cached test data'); + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: expectedUser, + hasNext: false, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + // $FlowFixMe[missing-empty-array-annot] + edges: [], + pageInfo: { + startCursor: null, + endCursor: null, + hasNextPage: null, + hasPreviousPage: null, + }, + }, + }, + }, + }); + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: false, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('loads and renders next items in connection', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('loads more correctly using fragment variables from literal @argument values', () => { + let expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithLiteralArgs), + }, + }, + ], + }, + }; + + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment({owner: queryWithLiteralArgs}); + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: true, + orderby: ['name'], + scale: null, + }; + expect(paginationVariables.isViewerFriendLocal).not.toBe( + variables.isViewerFriend, + ); + expectFragmentIsLoadingMore(renderer, direction, { + data: expectedUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + expectedUser = { + ...expectedUser, + friends: { + ...expectedUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithLiteralArgs), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', queryWithLiteralArgs), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('loads more correctly when original variables do not include an id', () => { + const callback = jest.fn<[Error | null], void>(); + const viewer = environment.lookup(queryWithoutID.fragment).data?.viewer; + const userRef = + typeof viewer === 'object' && viewer != null ? viewer?.actor : null; + invariant(userRef != null, 'Expected to have cached test data'); + + let expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithoutID), + }, + }, + ], + }, + }; + + const renderer = renderFragment({owner: queryWithoutID, userRef}); + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: expectedUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithoutID), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', queryWithoutID), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('loads more with correct id from refetchable fragment when using a nested fragment', () => { + const callback = jest.fn<[Error | null], void>(); + + // Populate store with data for query using nested fragment + environment.commitPayload(queryNestedFragment, { + node: { + __typename: 'Feedback', + id: '', + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }, + }); + + // Get fragment ref for user using nested fragment + const userRef = (environment.lookup(queryNestedFragment.fragment) + .data: $FlowFixMe)?.node?.actor; + + initialUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryNestedFragment), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + + const renderer = renderFragment({ + owner: queryNestedFragment, + userRef, + }); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + // The id here should correspond to the user id, and not the + // feedback id from the query variables (i.e. ``) + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); - let expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', queryWithoutID), + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryNestedFragment), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', queryNestedFragment), + }, }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', }, - ], - }, - }; + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); - const renderer = renderFragment({owner: queryWithoutID, userRef}); - expectFragmentResults([ - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, + it('calls callback with error when error occurs during fetch', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, hasNext: true, hasPrevious: false, - }, - ]); + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); + const error = new Error('Oops'); + dataSource.error(error); + + TestRenderer.act(() => jest.runAllImmediates()); + // We pass the error in the callback, but do not throw during render + // since we want to continue rendering the existing items in the + // connection + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith(error); }); - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: expectedUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, + + it('preserves pagination request if re-rendered with same fragment ref', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + TestRenderer.act(() => { + setOwner({...query}); + }); + + // Assert that request is still in flight after re-rendering + // with new fragment ref that points to the same data. + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); }); - expect(callback).toBeCalledTimes(0); - resolveQuery({ - data: { - node: { - __typename: 'User', - id: '1', - name: 'Alice', + describe('extra variables', () => { + it('loads and renders the next items in the connection when passing extra variables', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, { + onComplete: callback, + // Pass extra variables that are different from original request + UNSTABLE_extraVariables: {scale: 2.0}, + }); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + // Assert that value from extra variables is used + scale: 2.0, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + + const expectedUser = { + ...initialUser, friends: { + ...initialUser.friends, edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, { cursor: 'cursor:2', node: { __typename: 'User', id: 'node:2', name: 'name:node:2', - username: 'username:node:2', + ...createFragmentRef('node:2', query), }, }, ], pageInfo: { - startCursor: 'cursor:2', endCursor: 'cursor:2', hasNextPage: true, - hasPreviousPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', }, }, - }, - }, - }); - - expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ + }; + expectFragmentResults([ { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', queryWithoutID), - }, + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, }, { - cursor: 'cursor:2', + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); + + it('loads the next items in the connection and ignores any pagination vars passed as extra vars', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, { + onComplete: callback, + // Pass pagination vars as extra variables + UNSTABLE_extraVariables: {first: 100, after: 'foo'}, + }); + }); + const paginationVariables = { + id: '1', + // Assert that pagination vars from extra variables are ignored + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + resolveQuery({ + data: { node: { __typename: 'User', - id: 'node:2', - name: 'name:node:2', - ...createFragmentRef('node:2', queryWithoutID), + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, }, }, - ], - pageInfo: { - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: expectedUser, - isLoadingNext: true, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); - }); - - it('loads more with correct id from refetchable fragment when using a nested fragment', () => { - const callback = jest.fn<[Error | null], void>(); + }); - // Populate store with data for query using nested fragment - environment.commitPayload(queryNestedFragment, { - node: { - __typename: 'Feedback', - id: '', - actor: { - __typename: 'User', - id: '1', - name: 'Alice', + const expectedUser = { + ...initialUser, friends: { + ...initialUser.friends, edges: [ { cursor: 'cursor:1', @@ -1552,390 +2112,454 @@ describe('pagination', () => { __typename: 'User', id: 'node:1', name: 'name:node:1', - username: 'username:node:1', + ...createFragmentRef('node:1', query), }, }, - ], - pageInfo: { - endCursor: 'cursor:1', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, - }, - }, - }, - }); - - // Get fragment ref for user using nested fragment - const userRef = (environment.lookup(queryNestedFragment.fragment) - .data: $FlowFixMe)?.node?.actor; - - initialUser = { - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', queryNestedFragment), - }, - }, - ], - pageInfo: { - endCursor: 'cursor:1', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, - }, - }; - - const renderer = renderFragment({ - owner: queryNestedFragment, - userRef, - }); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - const paginationVariables = { - // The id here should correspond to the user id, and not the - // feedback id from the query variables (i.e. ``) - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); - - resolveQuery({ - data: { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ { cursor: 'cursor:2', node: { __typename: 'User', id: 'node:2', name: 'name:node:2', - username: 'username:node:2', + ...createFragmentRef('node:2', query), }, }, ], pageInfo: { - startCursor: 'cursor:2', endCursor: 'cursor:2', hasNextPage: true, - hasPreviousPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', }, }, - }, - }, - }); - - const expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ + }; + expectFragmentResults([ { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', queryNestedFragment), - }, + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, }, { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - ...createFragmentRef('node:2', queryNestedFragment), - }, + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, }, - ], - pageInfo: { - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: expectedUser, - isLoadingNext: true, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); - }); - - it('calls callback with error when error occurs during fetch', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, + ]); + expect(callback).toBeCalledTimes(1); + }); }); - expect(callback).toBeCalledTimes(0); - const error = new Error('Oops'); - dataSource.error(error); + describe('disposing', () => { + if (!ENABLE_ACTIVITY_COMPATIBILITY) { + it('cancels load more if component unmounts (legacy behavior, incompatible with )', () => { + unsubscribe.mockClear(); + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); - TestRenderer.act(() => jest.runAllImmediates()); - // We pass the error in the callback, but do not throw during render - // since we want to continue rendering the existing items in the - // connection - expect(callback).toBeCalledTimes(1); - expect(callback).toBeCalledWith(error); - }); + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(unsubscribe).toHaveBeenCalledTimes(0); + + TestRenderer.act(() => { + renderer.unmount(); + jest.runAllTimers(); + }); + expect(unsubscribe).toHaveBeenCalledTimes(1); + // Resolve the query and make sure the callback is not called + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + expect(callback).toBeCalledTimes(0); // callback is still called + expect(fetch).toBeCalledTimes(1); + expect(renderSpy).toBeCalledTimes(0); + // unsubscribe runs as part of the observable completing, note this is + // not called above after unmount + expect(unsubscribe).toHaveBeenCalledTimes(1); + + // Check that the pagination data was not written to the store + renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + } else { + it('cancels load more if component unmounts (new behavior, compatible with )', () => { + unsubscribe.mockClear(); + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); - it('preserves pagination request if re-rendered with same fragment ref', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(unsubscribe).toHaveBeenCalledTimes(0); + + TestRenderer.act(() => { + renderer.unmount(); + jest.runAllTimers(); + }); + expect(unsubscribe).toHaveBeenCalledTimes(0); + // Resolve the query and make sure the callback is not called + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + expect(callback).toBeCalledTimes(1); // callback is still called + expect(fetch).toBeCalledTimes(1); + expect(renderSpy).toBeCalledTimes(0); + // unsubscribe runs as part of the observable completing, note this is + // not called above after unmount + expect(unsubscribe).toHaveBeenCalledTimes(1); + + // Check that the pagination data was written to the store + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), + }, + }, + ], + pageInfo: { + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }; + renderFragment(); + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + } - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); + it('cancels load more if refetch is called', () => { + unsubscribe.mockClear(); + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); - TestRenderer.act(() => { - setOwner({...query}); - }); + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(unsubscribe).toHaveBeenCalledTimes(0); + const loadNextUnsubscribe = unsubscribe; - // Assert that request is still in flight after re-rendering - // with new fragment ref that points to the same data. - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); + TestRenderer.act(() => { + refetch({id: '4'}); + }); + expect(fetch).toBeCalledTimes(2); // loadNext and refetch + expect(loadNextUnsubscribe).toHaveBeenCalledTimes(1); // loadNext is cancelled + expect(unsubscribe).toHaveBeenCalledTimes(0); // refetch is not cancelled + expect(callback).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + }); - resolveQuery({ - data: { - node: { - __typename: 'User', + it('disposes ongoing request if environment changes', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - username: 'username:node:2', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + const loadNextUnsubscribe = unsubscribe; + expect(callback).toBeCalledTimes(0); + + // Set new environment + const [newEnvironment, newFetch] = createMockEnvironment(); + fetch.mockClear(); + fetch = newFetch; + newEnvironment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice in a different environment', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', }, - ], - pageInfo: { - startCursor: 'cursor:2', - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: true, }, }, - }, - }, - }); + }); + TestRenderer.act(() => { + setEnvironment(newEnvironment); + }); - const expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ + // Assert request was canceled + expect(loadNextUnsubscribe).toBeCalledTimes(1); + // changing environments resets, we don't try to auto-paginate just bc a request was pending + expect(fetch).toBeCalledTimes(0); + + // Assert newly rendered data + expectFragmentResults([ { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', query), + data: { + ...initialUser, + name: 'Alice in a different environment', }, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, }, { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - ...createFragmentRef('node:2', query), + data: { + ...initialUser, + name: 'Alice in a different environment', }, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, }, - ], - pageInfo: { - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: expectedUser, - isLoadingNext: true, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); - }); + ]); + }); - describe('extra variables', () => { - it('loads and renders the next items in the connection when passing extra variables', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - expectFragmentResults([ - { + it('disposes ongoing request if fragment ref changes', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, hasNext: true, hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - loadNext(1, { - onComplete: callback, - // Pass extra variables that are different from original request - UNSTABLE_extraVariables: {scale: 2.0}, + paginationVariables, + gqlPaginationQuery, }); - }); - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - // Assert that value from extra variables is used - scale: 2.0, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); + expect(callback).toBeCalledTimes(0); - resolveQuery({ - data: { + // Pass new parent fragment ref with different variables + const newVariables = {...variables, isViewerFriend: true}; + const newQuery = createOperationDescriptor(gqlQuery, newVariables); + environment.commitPayload(newQuery, { node: { __typename: 'User', id: '1', @@ -1943,343 +2567,477 @@ describe('pagination', () => { friends: { edges: [ { - cursor: 'cursor:2', + cursor: 'cursor:1', node: { __typename: 'User', - id: 'node:2', - name: 'name:node:2', - username: 'username:node:2', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', }, }, ], pageInfo: { - startCursor: 'cursor:2', - endCursor: 'cursor:2', + endCursor: 'cursor:1', hasNextPage: true, - hasPreviousPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', }, }, }, - }, - }); + }); + fetch.mockClear(); + TestRenderer.act(() => { + setOwner(newQuery); + }); - const expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', query), - }, - }, - { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - ...createFragmentRef('node:2', query), + // Assert request was canceled + expect(unsubscribe).toBeCalledTimes(1); + // changing fragment ref resets, we don't try to auto-paginate just bc a request was pending + expect(fetch).toBeCalledTimes(0); + + // Assert newly rendered data + const expectedUser = { + ...initialUser, + friends: { + ...initialUser.friends, + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + // Assert fragment ref points to owner with new variables + ...createFragmentRef('node:1', newQuery), + }, }, - }, - ], - pageInfo: { - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', + ], }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: expectedUser, - isLoadingNext: true, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); - }); + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + }); - it('loads the next items in the connection and ignores any pagination vars passed as extra vars', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); + if (!ENABLE_ACTIVITY_COMPATIBILITY) { + it('disposes ongoing request if dispose is called manually', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); - TestRenderer.act(() => { - loadNext(1, { - onComplete: callback, - // Pass pagination vars as extra variables - UNSTABLE_extraVariables: {first: 100, after: 'foo'}, - }); - }); - const paginationVariables = { - id: '1', - // Assert that pagination vars from extra variables are ignored - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); + let disposable; + TestRenderer.act(() => { + disposable = loadNext(1, {onComplete: callback}); + }); - resolveQuery({ - data: { - node: { - __typename: 'User', + // Assert request is started + const paginationVariables = { id: '1', - name: 'Alice', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + expect(disposable).toBeTruthy(); + disposable?.dispose(); + + // Assert request was cancelled + expect(callback).toBeCalledTimes(0); + expect(unsubscribe).toBeCalledTimes(1); + expect(fetch).toBeCalledTimes(1); // the loadNext call + expect(renderSpy).toHaveBeenCalledTimes(0); + + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + expect(callback).toBeCalledTimes(0); // callback is not called + }); + } else { + it('does not dispose ongoing request even if dispose is called manually', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + let disposable; + TestRenderer.act(() => { + disposable = loadNext(1, {onComplete: callback}); + }); + + // Assert request is started + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + expect(disposable).toBeTruthy(); + disposable?.dispose(); + + // Assert request was not cancelled + expect(callback).toBeCalledTimes(0); + expect(unsubscribe).toBeCalledTimes(0); + expect(fetch).toBeCalledTimes(1); // the loadNext call + expect(renderSpy).toHaveBeenCalledTimes(0); + + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, + }, + }, + }); + expect(callback).toBeCalledTimes(1); // callback is still called + + // Check that the pagination data was written to the store + const expectedUser = { + ...initialUser, friends: { + ...initialUser.friends, edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', query), + }, + }, { cursor: 'cursor:2', node: { __typename: 'User', id: 'node:2', name: 'name:node:2', - username: 'username:node:2', + ...createFragmentRef('node:2', query), }, }, ], pageInfo: { - startCursor: 'cursor:2', endCursor: 'cursor:2', hasNextPage: true, - hasPreviousPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', }, }, - }, - }, - }); - - const expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ + }; + expectFragmentResults([ { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', query), - }, + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, }, { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - ...createFragmentRef('node:2', query), - }, + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, }, - ], - pageInfo: { - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: expectedUser, - isLoadingNext: true, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); - }); - }); - - describe('disposing', () => { - it('cancels load more if component unmounts', () => { - unsubscribe.mockClear(); - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(unsubscribe).toHaveBeenCalledTimes(0); - - TestRenderer.act(() => { - renderer.unmount(); - jest.runAllTimers(); - }); - expect(unsubscribe).toHaveBeenCalledTimes(1); - expect(fetch).toBeCalledTimes(1); - expect(callback).toBeCalledTimes(0); - expect(renderSpy).toBeCalledTimes(0); + ]); + }); + } }); - it('cancels load more if refetch is called', () => { - unsubscribe.mockClear(); - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, + describe('when parent query is streaming', () => { + beforeEach(() => { + [environment, fetch] = createMockEnvironment(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + }, + }); }); - expect(unsubscribe).toHaveBeenCalledTimes(0); - const loadNextUnsubscribe = unsubscribe; - TestRenderer.act(() => { - refetch({id: '4'}); - }); - expect(fetch).toBeCalledTimes(2); // loadNext and refetch - expect(loadNextUnsubscribe).toHaveBeenCalledTimes(1); // loadNext is cancelled - expect(unsubscribe).toHaveBeenCalledTimes(0); // refetch is not cancelled - expect(callback).toBeCalledTimes(0); - expect(renderSpy).toBeCalledTimes(0); - }); + it('does not start pagination request even if query is no longer active but loadNext is bound to snapshot of data while query was active', () => { + const { + __internal: {fetchQuery}, + } = require('relay-runtime'); + + // Start parent query and assert it is active + fetchQuery(environment, queryWithStreaming).subscribe({}); + expect( + environment.isRequestActive(queryWithStreaming.request.identifier), + ).toEqual(true); + + // Render initial fragment + const instance = renderFragment({ + fragment: gqlFragmentWithStreaming, + owner: queryWithStreaming, + }); + expect(instance.toJSON()).toEqual(null); + renderSpy.mockClear(); - it('disposes ongoing request if environment changes', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); + // Resolve first payload + TestRenderer.act(() => { + dataSource.next({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], + }, + }, + }, + extensions: { + is_final: false, + }, + }); + }); + // Ensure request is still active + expect( + environment.isRequestActive(queryWithStreaming.request.identifier), + ).toEqual(true); - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); + // Assert fragment rendered with correct data + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithStreaming), + }, + }, + ], + // Assert pageInfo is currently null + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: false, + hasPrevious: false, + }, + ]); + + // Capture the value of loadNext at this moment, which will + // would use the page info from the current fragment snapshot. + // At the moment of this snapshot the parent request is still active, + // so calling `capturedLoadNext` should be a no-op, otherwise it + // would attempt a pagination with the incorrect cursor as null. + const capturedLoadNext = loadNext; + + // Resolve page info + TestRenderer.act(() => { + resolveQuery({ + data: { + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + }, + }, + label: + 'usePaginationFragmentTestUserFragmentWithStreaming$defer$UserFragment_friends$pageInfo', + path: ['node', 'friends'], + extensions: { + is_final: true, + }, + }); + }); + // Ensure request is no longer active since final payload has been + // received + expect( + environment.isRequestActive(queryWithStreaming.request.identifier), + ).toEqual(false); + + // Assert fragment rendered with correct data + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryWithStreaming), + }, + }, + ], + // Assert pageInfo is updated + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + fetch.mockClear(); + renderSpy.mockClear(); + // Call `capturedLoadNext`, which should be a no-op since it's + // bound to the snapshot of the fragment taken while the query is + // still active and pointing to incomplete page info. + TestRenderer.act(() => { + capturedLoadNext(1); + }); - // Assert request is started - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, + // Assert that calling `capturedLoadNext` is a no-op + expect(fetch).toBeCalledTimes(0); + expect(renderSpy).toBeCalledTimes(0); + + // Calling `loadNext`, should be fine since it's bound to the + // latest fragment snapshot with the latest page info and when + // the request is no longer active + TestRenderer.act(() => { + loadNext(1); + }); + + // Assert that calling `loadNext` starts the request + expect(fetch).toBeCalledTimes(1); + expect(renderSpy).toBeCalledTimes(1); }); - const loadNextUnsubscribe = unsubscribe; - expect(callback).toBeCalledTimes(0); + }); + }); - // Set new environment - const [newEnvironment, newFetch] = createMockEnvironment(); - fetch.mockClear(); - fetch = newFetch; - newEnvironment.commitPayload(query, { + describe('hasNext', () => { + const direction = 'forward'; + + it('returns true if it has more items', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { node: { __typename: 'User', id: '1', - name: 'Alice in a different environment', + name: 'Alice', friends: { edges: [ { @@ -2301,81 +3059,107 @@ describe('pagination', () => { }, }, }); - TestRenderer.act(() => { - setEnvironment(newEnvironment); - }); - // Assert request was canceled - expect(loadNextUnsubscribe).toBeCalledTimes(1); - // changing environments resets, we don't try to auto-paginate just bc a request was pending - expect(fetch).toBeCalledTimes(0); - - // Assert newly rendered data + renderFragment(); expectFragmentResults([ { data: { ...initialUser, - name: 'Alice in a different environment', + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({hasNextPage: true}), + }, }, - isLoadingNext: true, + isLoadingNext: false, isLoadingPrevious: false, + // Assert hasNext is true hasNext: true, hasPrevious: false, }, + ]); + }); + + it('returns false if edges are null', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: null, + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); + expectFragmentResults([ { data: { ...initialUser, - name: 'Alice in a different environment', + friends: { + ...initialUser.friends, + edges: null, + pageInfo: expect.objectContaining({hasNextPage: true}), + }, }, isLoadingNext: false, isLoadingPrevious: false, - hasNext: true, + // Assert hasNext is false + hasNext: false, hasPrevious: false, }, ]); }); - it('disposes ongoing request if fragment ref changes', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); + it('returns false if edges are undefined', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: undefined, + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', + }, + }, + }, + }); + + renderFragment(); expectFragmentResults([ { - data: initialUser, + data: { + ...initialUser, + friends: { + ...initialUser.friends, + edges: undefined, + pageInfo: expect.objectContaining({hasNextPage: true}), + }, + }, isLoadingNext: false, isLoadingPrevious: false, - hasNext: true, + // Assert hasNext is false + hasNext: false, hasPrevious: false, }, ]); + }); - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - - // Assert request is started - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); - - // Pass new parent fragment ref with different variables - const newVariables = {...variables, isViewerFriend: true}; - const newQuery = createOperationDescriptor(gqlQuery, newVariables); - environment.commitPayload(newQuery, { + it('returns false if end cursor is null', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { node: { __typename: 'User', id: '1', @@ -2393,479 +3177,261 @@ describe('pagination', () => { }, ], pageInfo: { - endCursor: 'cursor:1', + // endCursor is null + endCursor: null, + // but hasNextPage is still true hasNextPage: true, hasPreviousPage: false, - startCursor: 'cursor:1', + startCursor: null, }, }, }, }); - fetch.mockClear(); - TestRenderer.act(() => { - setOwner(newQuery); - }); - - // Assert request was canceled - expect(unsubscribe).toBeCalledTimes(1); - // changing fragment ref resets, we don't try to auto-paginate just bc a request was pending - expect(fetch).toBeCalledTimes(0); - - // Assert newly rendered data - const expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - // Assert fragment ref points to owner with new variables - ...createFragmentRef('node:1', newQuery), - }, - }, - ], - }, - }; - expectFragmentResults([ - { - data: expectedUser, - isLoadingNext: true, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - }); - - it('disposes ongoing request on unmount', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - - // Assert request is started - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); - - TestRenderer.act(() => { - renderer.unmount(); - }); - - // Assert request was canceled - expect(unsubscribe).toBeCalledTimes(1); - expect(fetch).toBeCalledTimes(1); // the loadNext call - }); - it('disposes ongoing request if it is manually disposed', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); + renderFragment(); expectFragmentResults([ { - data: initialUser, + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ + endCursor: null, + hasNextPage: true, + }), + }, + }, isLoadingNext: false, isLoadingPrevious: false, - hasNext: true, + // Assert hasNext is false + hasNext: false, hasPrevious: false, }, ]); - - let disposable; - TestRenderer.act(() => { - disposable = loadNext(1, {onComplete: callback}); - }); - - // Assert request is started - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); - - expect(disposable).toBeTruthy(); - disposable?.dispose(); - - // Assert request was canceled - expect(unsubscribe).toBeCalledTimes(1); - expect(fetch).toBeCalledTimes(1); // the loadNext call - expect(renderSpy).toHaveBeenCalledTimes(0); - }); - }); - - describe('when parent query is streaming', () => { - beforeEach(() => { - [environment, fetch] = createMockEnvironment(); - environment.commitPayload(query, { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - }, - }); }); - - it('does not start pagination request even if query is no longer active but loadNext is bound to snapshot of data while query was active', () => { - const { - __internal: {fetchQuery}, - } = require('relay-runtime'); - - // Start parent query and assert it is active - fetchQuery(environment, queryWithStreaming).subscribe({}); - expect( - environment.isRequestActive(queryWithStreaming.request.identifier), - ).toEqual(true); - - // Render initial fragment - const instance = renderFragment({ - fragment: gqlFragmentWithStreaming, - owner: queryWithStreaming, - }); - expect(instance.toJSON()).toEqual(null); - renderSpy.mockClear(); - - // Resolve first payload - TestRenderer.act(() => { - dataSource.next({ - data: { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - username: 'username:node:1', - }, - }, - ], + + it('returns false if end cursor is undefined', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, }, + ], + pageInfo: { + // endCursor is undefined + endCursor: undefined, + // but hasNextPage is still true + hasNextPage: true, + hasPreviousPage: false, + startCursor: undefined, }, }, - extensions: { - is_final: false, - }, - }); + }, }); - // Ensure request is still active - expect( - environment.isRequestActive(queryWithStreaming.request.identifier), - ).toEqual(true); - // Assert fragment rendered with correct data + renderFragment(); expectFragmentResults([ { data: { ...initialUser, friends: { - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', queryWithStreaming), - }, - }, - ], - // Assert pageInfo is currently null - pageInfo: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, + hasNextPage: true, + }), }, }, isLoadingNext: false, isLoadingPrevious: false, + // Assert hasNext is false hasNext: false, hasPrevious: false, }, ]); + }); - // Capture the value of loadNext at this moment, which will - // would use the page info from the current fragment snapshot. - // At the moment of this snapshot the parent request is still active, - // so calling `capturedLoadNext` should be a no-op, otherwise it - // would attempt a pagination with the incorrect cursor as null. - const capturedLoadNext = loadNext; - - // Resolve page info - TestRenderer.act(() => { - resolveQuery({ - data: { + it('returns false if pageInfo.hasNextPage is false-ish', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, + }, + ], pageInfo: { endCursor: 'cursor:1', - hasNextPage: true, + hasNextPage: null, + hasPreviousPage: false, + startCursor: 'cursor:1', }, }, - label: - 'usePaginationFragmentTestUserFragmentWithStreaming$defer$UserFragment_friends$pageInfo', - path: ['node', 'friends'], - extensions: { - is_final: true, - }, - }); + }, }); - // Ensure request is no longer active since final payload has been - // received - expect( - environment.isRequestActive(queryWithStreaming.request.identifier), - ).toEqual(false); - // Assert fragment rendered with correct data + renderFragment(); expectFragmentResults([ { data: { ...initialUser, friends: { - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', queryWithStreaming), - }, - }, - ], - // Assert pageInfo is updated - pageInfo: { - endCursor: 'cursor:1', - hasNextPage: true, - hasPreviousPage: false, - startCursor: null, - }, + ...initialUser.friends, + pageInfo: expect.objectContaining({ + hasNextPage: null, + }), }, }, isLoadingNext: false, isLoadingPrevious: false, - hasNext: true, + // Assert hasNext is false + hasNext: false, hasPrevious: false, }, ]); - - fetch.mockClear(); - renderSpy.mockClear(); - // Call `capturedLoadNext`, which should be a no-op since it's - // bound to the snapshot of the fragment taken while the query is - // still active and pointing to incomplete page info. - TestRenderer.act(() => { - capturedLoadNext(1); - }); - - // Assert that calling `capturedLoadNext` is a no-op - expect(fetch).toBeCalledTimes(0); - expect(renderSpy).toBeCalledTimes(0); - - // Calling `loadNext`, should be fine since it's bound to the - // latest fragment snapshot with the latest page info and when - // the request is no longer active - TestRenderer.act(() => { - loadNext(1); - }); - - // Assert that calling `loadNext` starts the request - expect(fetch).toBeCalledTimes(1); - expect(renderSpy).toBeCalledTimes(1); }); - }); - }); - - describe('hasNext', () => { - const direction = 'forward'; - it('returns true if it has more items', () => { - (environment.getStore().getSource(): $FlowFixMe).clear(); - environment.commitPayload(query, { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - username: 'username:node:1', + it('returns false if pageInfo.hasNextPage is false', () => { + (environment.getStore().getSource(): $FlowFixMe).clear(); + environment.commitPayload(query, { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor:1', }, - ], - pageInfo: { - endCursor: 'cursor:1', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, - }, - }, - }); - - renderFragment(); - expectFragmentResults([ - { - data: { - ...initialUser, - friends: { - ...initialUser.friends, - pageInfo: expect.objectContaining({hasNextPage: true}), }, }, - isLoadingNext: false, - isLoadingPrevious: false, - // Assert hasNext is true - hasNext: true, - hasPrevious: false, - }, - ]); - }); + }); - it('returns false if edges are null', () => { - (environment.getStore().getSource(): $FlowFixMe).clear(); - environment.commitPayload(query, { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: null, - pageInfo: { - endCursor: 'cursor:1', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', + renderFragment(); + expectFragmentResults([ + { + data: { + ...initialUser, + friends: { + ...initialUser.friends, + pageInfo: expect.objectContaining({ + hasNextPage: false, + }), + }, }, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext is false + hasNext: false, + hasPrevious: false, }, - }, + ]); }); - renderFragment(); - expectFragmentResults([ - { - data: { - ...initialUser, - friends: { - ...initialUser.friends, - edges: null, - pageInfo: expect.objectContaining({hasNextPage: true}), - }, + it('updates after pagination if more results are available', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + + hasNext: true, + hasPrevious: false, }, - isLoadingNext: false, - isLoadingPrevious: false, - // Assert hasNext is false - hasNext: false, - hasPrevious: false, - }, - ]); - }); + ]); - it('returns false if edges are undefined', () => { - (environment.getStore().getSource(): $FlowFixMe).clear(); - environment.commitPayload(query, { - node: { - __typename: 'User', + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { id: '1', - name: 'Alice', - friends: { - edges: undefined, - pageInfo: { - endCursor: 'cursor:1', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, - }, - }, - }); + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); - renderFragment(); - expectFragmentResults([ - { - data: { - ...initialUser, - friends: { - ...initialUser.friends, - edges: undefined, - pageInfo: expect.objectContaining({hasNextPage: true}), + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: true, + hasPreviousPage: true, + }, + }, }, }, - isLoadingNext: false, - isLoadingPrevious: false, - // Assert hasNext is false - hasNext: false, - hasPrevious: false, - }, - ]); - }); + }); - it('returns false if end cursor is null', () => { - (environment.getStore().getSource(): $FlowFixMe).clear(); - environment.commitPayload(query, { - node: { - __typename: 'User', - id: '1', - name: 'Alice', + const expectedUser = { + ...initialUser, friends: { + ...initialUser.friends, edges: [ { cursor: 'cursor:1', @@ -2873,105 +3439,118 @@ describe('pagination', () => { __typename: 'User', id: 'node:1', name: 'name:node:1', - username: 'username:node:1', + ...createFragmentRef('node:1', query), }, }, - ], - pageInfo: { - // endCursor is null - endCursor: null, - // but hasNextPage is still true - hasNextPage: true, - hasPreviousPage: false, - startCursor: null, - }, - }, - }, - }); - - renderFragment(); - expectFragmentResults([ - { - data: { - ...initialUser, - friends: { - ...initialUser.friends, - pageInfo: expect.objectContaining({ - endCursor: null, - hasNextPage: true, - }), - }, - }, - isLoadingNext: false, - isLoadingPrevious: false, - // Assert hasNext is false - hasNext: false, - hasPrevious: false, - }, - ]); - }); - - it('returns false if end cursor is undefined', () => { - (environment.getStore().getSource(): $FlowFixMe).clear(); - environment.commitPayload(query, { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ { - cursor: 'cursor:1', + cursor: 'cursor:2', node: { __typename: 'User', - id: 'node:1', - name: 'name:node:1', - username: 'username:node:1', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), }, }, ], pageInfo: { - // endCursor is undefined - endCursor: undefined, - // but hasNextPage is still true + endCursor: 'cursor:2', hasNextPage: true, hasPreviousPage: false, - startCursor: undefined, + startCursor: 'cursor:1', }, }, - }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + // Assert hasNext reflects server response + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext reflects server response + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); }); - renderFragment(); - expectFragmentResults([ - { + it('updates after pagination if no more results are available', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: '1', + after: 'cursor:1', + first: 1, + before: null, + last: null, + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, direction, { + data: initialUser, + hasNext: true, + hasPrevious: false, + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); + + resolveQuery({ data: { - ...initialUser, - friends: { - ...initialUser.friends, - pageInfo: expect.objectContaining({ - endCursor: null, - hasNextPage: true, - }), + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:2', + node: { + __typename: 'User', + id: 'node:2', + name: 'name:node:2', + username: 'username:node:2', + }, + }, + ], + pageInfo: { + startCursor: 'cursor:2', + endCursor: 'cursor:2', + hasNextPage: false, + hasPreviousPage: true, + }, + }, }, }, - isLoadingNext: false, - isLoadingPrevious: false, - // Assert hasNext is false - hasNext: false, - hasPrevious: false, - }, - ]); - }); + }); - it('returns false if pageInfo.hasNextPage is false-ish', () => { - (environment.getStore().getSource(): $FlowFixMe).clear(); - environment.commitPayload(query, { - node: { - __typename: 'User', - id: '1', - name: 'Alice', + const expectedUser = { + ...initialUser, friends: { + ...initialUser.friends, edges: [ { cursor: 'cursor:1', @@ -2979,1080 +3558,825 @@ describe('pagination', () => { __typename: 'User', id: 'node:1', name: 'name:node:1', - username: 'username:node:1', + ...createFragmentRef('node:1', query), }, }, - ], - pageInfo: { - endCursor: 'cursor:1', - hasNextPage: null, - hasPreviousPage: false, - startCursor: 'cursor:1', - }, - }, - }, - }); - - renderFragment(); - expectFragmentResults([ - { - data: { - ...initialUser, - friends: { - ...initialUser.friends, - pageInfo: expect.objectContaining({ - hasNextPage: null, - }), - }, - }, - isLoadingNext: false, - isLoadingPrevious: false, - // Assert hasNext is false - hasNext: false, - hasPrevious: false, - }, - ]); - }); - - it('returns false if pageInfo.hasNextPage is false', () => { - (environment.getStore().getSource(): $FlowFixMe).clear(); - environment.commitPayload(query, { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ { - cursor: 'cursor:1', + cursor: 'cursor:2', node: { __typename: 'User', - id: 'node:1', - name: 'name:node:1', - username: 'username:node:1', + id: 'node:2', + name: 'name:node:2', + ...createFragmentRef('node:2', query), }, }, ], pageInfo: { - endCursor: 'cursor:1', + endCursor: 'cursor:2', hasNextPage: false, hasPreviousPage: false, startCursor: 'cursor:1', }, }, - }, - }); - - renderFragment(); - expectFragmentResults([ - { - data: { - ...initialUser, - friends: { - ...initialUser.friends, - pageInfo: expect.objectContaining({ - hasNextPage: false, - }), - }, + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedUser, + isLoadingNext: true, + isLoadingPrevious: false, + // Assert hasNext reflects server response + hasNext: false, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + // Assert hasNext reflects server response + hasNext: false, + hasPrevious: false, }, - isLoadingNext: false, - isLoadingPrevious: false, - // Assert hasNext is false - hasNext: false, - hasPrevious: false, - }, - ]); + ]); + expect(callback).toBeCalledTimes(1); + }); }); - it('updates after pagination if more results are available', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, + describe('refetch', () => { + // The bulk of refetch behavior is covered in useRefetchableFragmentNode-test, + // so this suite covers the pagination-related test cases. + function expectRefetchRequestIsInFlight(expected: { + data: mixed, + gqlRefetchQuery?: any, + hasNext: boolean, + hasPrevious: boolean, + inFlight: boolean, + refetchQuery?: OperationDescriptor, + refetchVariables: Variables, + requestCount: number, + }) { + expect(fetch).toBeCalledTimes(expected.requestCount); + const fetchCall = fetch.mock.calls.find(call => { + return ( + call[0] === + (expected.gqlRefetchQuery ?? gqlPaginationQuery).params && + areEqual(call[1], expected.refetchVariables) && + areEqual(call[2], {force: true}) + ); + }); + const isInFlight = fetchCall != null; + expect(isInFlight).toEqual(expected.inFlight); + } - hasNext: true, - hasPrevious: false, + function expectFragmentIsRefetching( + renderer: any, + expected: { + data: mixed, + hasNext: boolean, + hasPrevious: boolean, + refetchVariables: Variables, + refetchQuery?: OperationDescriptor, + gqlRefetchQuery?: $FlowFixMe, }, - ]); + ) { + expect(renderSpy).toBeCalledTimes(0); + renderSpy.mockClear(); - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); + // Assert refetch query was fetched + expectRefetchRequestIsInFlight({ + ...expected, + inFlight: true, + requestCount: 1, + }); - resolveQuery({ - data: { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - username: 'username:node:2', - }, - }, - ], - pageInfo: { - startCursor: 'cursor:2', - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: true, - }, - }, - }, - }, - }); + // Assert component suspended + expect(renderSpy).toBeCalledTimes(0); + expect(renderer.toJSON()).toEqual('Fallback'); + + // Assert query is retained by loadQuery and + // tentatively retained while component is suspended + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(environment.retain).toBeCalledTimes(2); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(environment.retain.mock.calls[0][0]).toEqual( + expected.refetchQuery ?? paginationQuery, + ); + } - const expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', query), - }, - }, - { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - ...createFragmentRef('node:2', query), - }, - }, - ], - pageInfo: { - endCursor: 'cursor:2', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', + it('refetches new variables correctly when refetching new id', () => { + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: expectedUser, - isLoadingNext: true, - isLoadingPrevious: false, - // Assert hasNext reflects server response - hasNext: true, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - // Assert hasNext reflects server response - hasNext: true, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); - }); + ]); - it('updates after pagination if no more results are available', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - expectFragmentResults([ - { + TestRenderer.act(() => { + refetch({id: '4'}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + id: '4', + isViewerFriendLocal: false, + orderby: ['name'], + scale: null, + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + {force: true}, + ); + expectFragmentIsRefetching(renderer, { data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, hasNext: true, hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - const paginationVariables = { - id: '1', - after: 'cursor:1', - first: 1, - before: null, - last: null, - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, direction, { - data: initialUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); + refetchVariables, + refetchQuery: paginationQuery, + }); - resolveQuery({ - data: { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - username: 'username:node:2', + // Mock network response + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '4', + name: 'Mark', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', }, - ], - pageInfo: { - startCursor: 'cursor:2', - endCursor: 'cursor:2', - hasNextPage: false, - hasPreviousPage: true, }, }, }, - }, - }); + }); - const expectedUser = { - ...initialUser, - friends: { - ...initialUser.friends, - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', query), - }, - }, - { - cursor: 'cursor:2', - node: { - __typename: 'User', - id: 'node:2', - name: 'name:node:2', - ...createFragmentRef('node:2', query), + // Assert fragment is rendered with new data + const expectedUser = { + id: '4', + name: 'Mark', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', }, - ], - pageInfo: { - endCursor: 'cursor:2', - hasNextPage: false, - hasPreviousPage: false, - startCursor: 'cursor:1', }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: expectedUser, - isLoadingNext: true, - isLoadingPrevious: false, - // Assert hasNext reflects server response - hasNext: false, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - // Assert hasNext reflects server response - hasNext: false, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); - }); - }); - - describe('refetch', () => { - // The bulk of refetch behavior is covered in useRefetchableFragmentNode-test, - // so this suite covers the pagination-related test cases. - function expectRefetchRequestIsInFlight(expected: { - data: mixed, - gqlRefetchQuery?: any, - hasNext: boolean, - hasPrevious: boolean, - inFlight: boolean, - refetchQuery?: OperationDescriptor, - refetchVariables: Variables, - requestCount: number, - }) { - expect(fetch).toBeCalledTimes(expected.requestCount); - const fetchCall = fetch.mock.calls.find(call => { - return ( - call[0] === (expected.gqlRefetchQuery ?? gqlPaginationQuery).params && - areEqual(call[1], expected.refetchVariables) && - areEqual(call[2], {force: true}) - ); - }); - const isInFlight = fetchCall != null; - expect(isInFlight).toEqual(expected.inFlight); - } - - function expectFragmentIsRefetching( - renderer: any, - expected: { - data: mixed, - hasNext: boolean, - hasPrevious: boolean, - refetchVariables: Variables, - refetchQuery?: OperationDescriptor, - gqlRefetchQuery?: $FlowFixMe, - }, - ) { - expect(renderSpy).toBeCalledTimes(0); - renderSpy.mockClear(); + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); - // Assert refetch query was fetched - expectRefetchRequestIsInFlight({ - ...expected, - inFlight: true, - requestCount: 1, + // Assert refetch query was retained by loadQuery and the component + expect(release).not.toBeCalled(); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(environment.retain).toBeCalledTimes(2); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(environment.retain.mock.calls[0][0]).toEqual(paginationQuery); }); - // Assert component suspended - expect(renderSpy).toBeCalledTimes(0); - expect(renderer.toJSON()).toEqual('Fallback'); + it('refetches new variables correctly when refetching same id', () => { + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); - // Assert query is retained by loadQuery and - // tentatively retained while component is suspended - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(environment.retain).toBeCalledTimes(2); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(environment.retain.mock.calls[0][0]).toEqual( - expected.refetchQuery ?? paginationQuery, - ); - } + TestRenderer.act(() => { + refetch({isViewerFriendLocal: true, orderby: ['lastname']}); + }); - it('refetches new variables correctly when refetching new id', () => { - const renderer = renderFragment(); - expectFragmentResults([ - { + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + id: '1', + isViewerFriendLocal: true, + orderby: ['lastname'], + scale: null, + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + {force: true}, + ); + expectFragmentIsRefetching(renderer, { data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, hasNext: true, hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - refetch({id: '4'}); - }); - - // Assert that fragment is refetching with the right variables and - // suspends upon refetch - const refetchVariables = { - after: null, - first: 1, - before: null, - last: null, - id: '4', - isViewerFriendLocal: false, - orderby: ['name'], - scale: null, - }; - paginationQuery = createOperationDescriptor( - gqlPaginationQuery, - refetchVariables, - {force: true}, - ); - expectFragmentIsRefetching(renderer, { - data: initialUser, - hasNext: true, - hasPrevious: false, - refetchVariables, - refetchQuery: paginationQuery, - }); + refetchVariables, + refetchQuery: paginationQuery, + }); - // Mock network response - resolveQuery({ - data: { - node: { - __typename: 'User', - id: '4', - name: 'Mark', - friends: { - edges: [ - { - cursor: 'cursor:100', - node: { - __typename: 'User', - id: 'node:100', - name: 'name:node:100', - username: 'username:node:100', + // Mock network response + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', }, - ], - pageInfo: { - endCursor: 'cursor:100', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:100', }, }, }, - }, - }); + }); - // Assert fragment is rendered with new data - const expectedUser = { - id: '4', - name: 'Mark', - friends: { - edges: [ - { - cursor: 'cursor:100', - node: { - __typename: 'User', - id: 'node:100', - name: 'name:node:100', - ...createFragmentRef('node:100', paginationQuery), + // Assert fragment is rendered with new data + const expectedUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', }, - ], - pageInfo: { - endCursor: 'cursor:100', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:100', }, - }, - }; - expectFragmentResults([ - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - - // Assert refetch query was retained by loadQuery and the component - expect(release).not.toBeCalled(); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(environment.retain).toBeCalledTimes(2); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(environment.retain.mock.calls[0][0]).toEqual(paginationQuery); - }); - - it('refetches new variables correctly when refetching same id', () => { - const renderer = renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - refetch({isViewerFriendLocal: true, orderby: ['lastname']}); - }); + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); - // Assert that fragment is refetching with the right variables and - // suspends upon refetch - const refetchVariables = { - after: null, - first: 1, - before: null, - last: null, - id: '1', - isViewerFriendLocal: true, - orderby: ['lastname'], - scale: null, - }; - paginationQuery = createOperationDescriptor( - gqlPaginationQuery, - refetchVariables, - {force: true}, - ); - expectFragmentIsRefetching(renderer, { - data: initialUser, - hasNext: true, - hasPrevious: false, - refetchVariables, - refetchQuery: paginationQuery, + // Assert refetch query was retained by loadQuery and the component + expect(release).not.toBeCalled(); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(environment.retain).toBeCalledTimes(2); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(environment.retain.mock.calls[0][0]).toEqual(paginationQuery); }); - // Mock network response - resolveQuery({ - data: { + it('refetches with correct id from refetchable fragment when using nested fragment', () => { + // Populate store with data for query using nested fragment + environment.commitPayload(queryNestedFragment, { node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:100', - node: { - __typename: 'User', - id: 'node:100', - name: 'name:node:100', - username: 'username:node:100', + __typename: 'Feedback', + id: '', + actor: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + username: 'username:node:1', + }, }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', }, - ], - pageInfo: { - endCursor: 'cursor:100', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:100', }, }, }, - }, - }); + }); - // Assert fragment is rendered with new data - const expectedUser = { - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:100', - node: { - __typename: 'User', - id: 'node:100', - name: 'name:node:100', - ...createFragmentRef('node:100', paginationQuery), + // Get fragment ref for user using nested fragment + const userRef = (environment.lookup(queryNestedFragment.fragment) + .data: $FlowFixMe)?.node?.actor; + + initialUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:1', + node: { + __typename: 'User', + id: 'node:1', + name: 'name:node:1', + ...createFragmentRef('node:1', queryNestedFragment), + }, }, + ], + pageInfo: { + endCursor: 'cursor:1', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:1', }, - ], - pageInfo: { - endCursor: 'cursor:100', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:100', }, - }, - }; - expectFragmentResults([ - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, + }; + + const renderer = renderFragment({ + owner: queryNestedFragment, + userRef, + }); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({isViewerFriendLocal: true, orderby: ['lastname']}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + // The id here should correspond to the user id, and not the + // feedback id from the query variables (i.e. ``) + id: '1', + isViewerFriendLocal: true, + orderby: ['lastname'], + scale: null, + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + {force: true}, + ); + expectFragmentIsRefetching(renderer, { + data: initialUser, hasNext: true, hasPrevious: false, - }, - ]); - - // Assert refetch query was retained by loadQuery and the component - expect(release).not.toBeCalled(); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(environment.retain).toBeCalledTimes(2); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(environment.retain.mock.calls[0][0]).toEqual(paginationQuery); - }); + refetchVariables, + refetchQuery: paginationQuery, + }); - it('refetches with correct id from refetchable fragment when using nested fragment', () => { - // Populate store with data for query using nested fragment - environment.commitPayload(queryNestedFragment, { - node: { - __typename: 'Feedback', - id: '', - actor: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - username: 'username:node:1', + // Mock network response + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', }, - ], - pageInfo: { - endCursor: 'cursor:1', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', }, }, }, - }, - }); - - // Get fragment ref for user using nested fragment - const userRef = (environment.lookup(queryNestedFragment.fragment) - .data: $FlowFixMe)?.node?.actor; + }); - initialUser = { - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:1', - node: { - __typename: 'User', - id: 'node:1', - name: 'name:node:1', - ...createFragmentRef('node:1', queryNestedFragment), + // Assert fragment is rendered with new data + const expectedUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', }, - ], - pageInfo: { - endCursor: 'cursor:1', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:1', }, - }, - }; + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); - const renderer = renderFragment({ - owner: queryNestedFragment, - userRef, + // Assert refetch query was retained by loadQuery and the component + expect(release).not.toBeCalled(); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(environment.retain).toBeCalledTimes(2); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(environment.retain.mock.calls[0][0]).toEqual(paginationQuery); }); - expectFragmentResults([ - { + + it('loads more items correctly after refetching', () => { + const renderer = renderFragment(); + expectFragmentResults([ + { + data: initialUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + + TestRenderer.act(() => { + refetch({isViewerFriendLocal: true, orderby: ['lastname']}); + }); + + // Assert that fragment is refetching with the right variables and + // suspends upon refetch + const refetchVariables = { + after: null, + first: 1, + before: null, + last: null, + id: '1', + isViewerFriendLocal: true, + orderby: ['lastname'], + scale: null, + }; + paginationQuery = createOperationDescriptor( + gqlPaginationQuery, + refetchVariables, + {force: true}, + ); + expectFragmentIsRefetching(renderer, { data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, hasNext: true, hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - refetch({isViewerFriendLocal: true, orderby: ['lastname']}); - }); - - // Assert that fragment is refetching with the right variables and - // suspends upon refetch - const refetchVariables = { - after: null, - first: 1, - before: null, - last: null, - // The id here should correspond to the user id, and not the - // feedback id from the query variables (i.e. ``) - id: '1', - isViewerFriendLocal: true, - orderby: ['lastname'], - scale: null, - }; - paginationQuery = createOperationDescriptor( - gqlPaginationQuery, - refetchVariables, - {force: true}, - ); - expectFragmentIsRefetching(renderer, { - data: initialUser, - hasNext: true, - hasPrevious: false, - refetchVariables, - refetchQuery: paginationQuery, - }); + refetchVariables, + refetchQuery: paginationQuery, + }); - // Mock network response - resolveQuery({ - data: { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:100', - node: { - __typename: 'User', - id: 'node:100', - name: 'name:node:100', - username: 'username:node:100', + // Mock network response + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + username: 'username:node:100', + }, }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', }, - ], - pageInfo: { - endCursor: 'cursor:100', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:100', }, }, }, - }, - }); + }); - // Assert fragment is rendered with new data - const expectedUser = { - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:100', - node: { - __typename: 'User', - id: 'node:100', - name: 'name:node:100', - ...createFragmentRef('node:100', paginationQuery), + // Assert fragment is rendered with new data + const expectedUser = { + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, }, + ], + pageInfo: { + endCursor: 'cursor:100', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', }, - ], - pageInfo: { - endCursor: 'cursor:100', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:100', }, - }, - }; - expectFragmentResults([ - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); + }; + expectFragmentResults([ + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + data: expectedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); - // Assert refetch query was retained by loadQuery and the component - expect(release).not.toBeCalled(); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(environment.retain).toBeCalledTimes(2); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(environment.retain.mock.calls[0][0]).toEqual(paginationQuery); - }); + // Assert refetch query was retained by loadQuery and the component + expect(release).not.toBeCalled(); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(environment.retain).toBeCalledTimes(2); + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + expect(environment.retain.mock.calls[0][0]).toEqual(paginationQuery); - it('loads more items correctly after refetching', () => { - const renderer = renderFragment(); - expectFragmentResults([ - { - data: initialUser, - isLoadingNext: false, - isLoadingPrevious: false, + // Paginate after refetching + fetch.mockClear(); + TestRenderer.act(() => { + loadNext(1); + }); + const paginationVariables = { + id: '1', + after: 'cursor:100', + first: 1, + before: null, + last: null, + isViewerFriendLocal: true, + orderby: ['lastname'], + scale: null, + }; + expectFragmentIsLoadingMore(renderer, 'forward', { + data: expectedUser, hasNext: true, hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - refetch({isViewerFriendLocal: true, orderby: ['lastname']}); - }); - - // Assert that fragment is refetching with the right variables and - // suspends upon refetch - const refetchVariables = { - after: null, - first: 1, - before: null, - last: null, - id: '1', - isViewerFriendLocal: true, - orderby: ['lastname'], - scale: null, - }; - paginationQuery = createOperationDescriptor( - gqlPaginationQuery, - refetchVariables, - {force: true}, - ); - expectFragmentIsRefetching(renderer, { - data: initialUser, - hasNext: true, - hasPrevious: false, - refetchVariables, - refetchQuery: paginationQuery, - }); + paginationVariables, + gqlPaginationQuery, + }); - // Mock network response - resolveQuery({ - data: { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:100', - node: { - __typename: 'User', - id: 'node:100', - name: 'name:node:100', - username: 'username:node:100', + resolveQuery({ + data: { + node: { + __typename: 'User', + id: '1', + name: 'Alice', + friends: { + edges: [ + { + cursor: 'cursor:200', + node: { + __typename: 'User', + id: 'node:200', + name: 'name:node:200', + username: 'username:node:200', + }, }, + ], + pageInfo: { + startCursor: 'cursor:200', + endCursor: 'cursor:200', + hasNextPage: true, + hasPreviousPage: true, }, - ], - pageInfo: { - endCursor: 'cursor:100', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:100', }, }, }, - }, - }); + }); - // Assert fragment is rendered with new data - const expectedUser = { - id: '1', - name: 'Alice', - friends: { - edges: [ - { - cursor: 'cursor:100', - node: { - __typename: 'User', - id: 'node:100', - name: 'name:node:100', - ...createFragmentRef('node:100', paginationQuery), + const paginatedUser = { + ...expectedUser, + friends: { + ...expectedUser.friends, + edges: [ + { + cursor: 'cursor:100', + node: { + __typename: 'User', + id: 'node:100', + name: 'name:node:100', + ...createFragmentRef('node:100', paginationQuery), + }, + }, + { + cursor: 'cursor:200', + node: { + __typename: 'User', + id: 'node:200', + name: 'name:node:200', + ...createFragmentRef('node:200', paginationQuery), + }, }, + ], + pageInfo: { + endCursor: 'cursor:200', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor:100', }, - ], - pageInfo: { - endCursor: 'cursor:100', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:100', }, - }, - }; - expectFragmentResults([ - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - data: expectedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); + }; + expectFragmentResults([ + { + // First update has updated connection + data: paginatedUser, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: paginatedUser, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + }); + }); - // Assert refetch query was retained by loadQuery and the component - expect(release).not.toBeCalled(); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(environment.retain).toBeCalledTimes(2); - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - expect(environment.retain.mock.calls[0][0]).toEqual(paginationQuery); + describe('paginating @fetchable types', () => { + beforeEach(() => { + const fetchVariables = {id: 'a'}; + gqlQuery = graphql` + query usePaginationFragmentTestStoryQuery($id: ID!) { + nonNodeStory(id: $id) { + ...usePaginationFragmentTestStoryFragment + } + } + `; + + // $FlowFixMe[prop-missing] + // $FlowFixMe[incompatible-type-arg] + gqlFragment = graphql` + fragment usePaginationFragmentTestStoryFragment on NonNodeStory + @argumentDefinitions( + count: {type: "Int", defaultValue: 10} + cursor: {type: "ID"} + ) + @refetchable( + queryName: "usePaginationFragmentTestStoryFragmentRefetchQuery" + ) { + comments(first: $count, after: $cursor) + @connection(key: "StoryFragment_comments") { + edges { + node { + id + } + } + } + } + `; + gqlPaginationQuery = require('./__generated__/usePaginationFragmentTestStoryFragmentRefetchQuery.graphql'); - // Paginate after refetching - fetch.mockClear(); - TestRenderer.act(() => { - loadNext(1); - }); - const paginationVariables = { - id: '1', - after: 'cursor:100', - first: 1, - before: null, - last: null, - isViewerFriendLocal: true, - orderby: ['lastname'], - scale: null, - }; - expectFragmentIsLoadingMore(renderer, 'forward', { - data: expectedUser, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); + query = createOperationDescriptor(gqlQuery, fetchVariables); - resolveQuery({ - data: { - node: { - __typename: 'User', - id: '1', - name: 'Alice', - friends: { + environment.commitPayload(query, { + nonNodeStory: { + __typename: 'NonNodeStory', + id: 'a', + fetch_id: 'fetch:a', + comments: { edges: [ { - cursor: 'cursor:200', + cursor: 'edge:0', node: { - __typename: 'User', - id: 'node:200', - name: 'name:node:200', - username: 'username:node:200', + __typename: 'Comment', + id: 'comment:0', }, }, ], pageInfo: { - startCursor: 'cursor:200', - endCursor: 'cursor:200', + endCursor: 'edge:0', hasNextPage: true, - hasPreviousPage: true, }, }, }, - }, + }); }); - const paginatedUser = { - ...expectedUser, - friends: { - ...expectedUser.friends, - edges: [ - { - cursor: 'cursor:100', - node: { - __typename: 'User', - id: 'node:100', - name: 'name:node:100', - ...createFragmentRef('node:100', paginationQuery), - }, - }, - { - cursor: 'cursor:200', - node: { - __typename: 'User', - id: 'node:200', - name: 'name:node:200', - ...createFragmentRef('node:200', paginationQuery), - }, - }, - ], - pageInfo: { - endCursor: 'cursor:200', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'cursor:100', - }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: paginatedUser, - isLoadingNext: true, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: paginatedUser, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - }); - }); - - describe('paginating @fetchable types', () => { - beforeEach(() => { - const fetchVariables = {id: 'a'}; - gqlQuery = graphql` - query usePaginationFragmentTestStoryQuery($id: ID!) { - nonNodeStory(id: $id) { - ...usePaginationFragmentTestStoryFragment - } - } - `; - - // $FlowFixMe[prop-missing] - // $FlowFixMe[incompatible-type-arg] - gqlFragment = graphql` - fragment usePaginationFragmentTestStoryFragment on NonNodeStory - @argumentDefinitions( - count: {type: "Int", defaultValue: 10} - cursor: {type: "ID"} - ) - @refetchable( - queryName: "usePaginationFragmentTestStoryFragmentRefetchQuery" - ) { - comments(first: $count, after: $cursor) - @connection(key: "StoryFragment_comments") { - edges { - node { - id - } - } - } - } - `; - gqlPaginationQuery = require('./__generated__/usePaginationFragmentTestStoryFragmentRefetchQuery.graphql'); - - query = createOperationDescriptor(gqlQuery, fetchVariables); - - environment.commitPayload(query, { - nonNodeStory: { - __typename: 'NonNodeStory', - id: 'a', + it('loads and renders next items in connection', () => { + const callback = jest.fn<[Error | null], void>(); + const renderer = renderFragment(); + const initialData = { fetch_id: 'fetch:a', comments: { edges: [ @@ -4069,120 +4393,97 @@ describe('pagination', () => { hasNextPage: true, }, }, - }, - }); - }); - - it('loads and renders next items in connection', () => { - const callback = jest.fn<[Error | null], void>(); - const renderer = renderFragment(); - const initialData = { - fetch_id: 'fetch:a', - comments: { - edges: [ - { - cursor: 'edge:0', - node: { - __typename: 'Comment', - id: 'comment:0', - }, - }, - ], - pageInfo: { - endCursor: 'edge:0', - hasNextPage: true, + }; + expectFragmentResults([ + { + data: initialData, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, }, - }, - }; - expectFragmentResults([ - { + ]); + + TestRenderer.act(() => { + loadNext(1, {onComplete: callback}); + }); + const paginationVariables = { + id: 'fetch:a', + cursor: 'edge:0', + count: 1, + }; + expectFragmentIsLoadingMore(renderer, 'forward', { data: initialData, - isLoadingNext: false, - isLoadingPrevious: false, hasNext: true, hasPrevious: false, - }, - ]); - - TestRenderer.act(() => { - loadNext(1, {onComplete: callback}); - }); - const paginationVariables = { - id: 'fetch:a', - cursor: 'edge:0', - count: 1, - }; - expectFragmentIsLoadingMore(renderer, 'forward', { - data: initialData, - hasNext: true, - hasPrevious: false, - paginationVariables, - gqlPaginationQuery, - }); - expect(callback).toBeCalledTimes(0); + paginationVariables, + gqlPaginationQuery, + }); + expect(callback).toBeCalledTimes(0); - resolveQuery({ - data: { - fetch__NonNodeStory: { - id: 'a', - fetch_id: 'fetch:a', - comments: { - edges: [ - { - cursor: 'edge:1', - node: { - __typename: 'Comment', - id: 'comment:1', + resolveQuery({ + data: { + fetch__NonNodeStory: { + id: 'a', + fetch_id: 'fetch:a', + comments: { + edges: [ + { + cursor: 'edge:1', + node: { + __typename: 'Comment', + id: 'comment:1', + }, }, + ], + pageInfo: { + endCursor: 'edge:1', + hasNextPage: true, }, - ], - pageInfo: { - endCursor: 'edge:1', - hasNextPage: true, }, }, }, - }, - }); + }); - const expectedData = { - ...initialData, - comments: { - edges: [ - ...initialData.comments.edges, - { - cursor: 'edge:1', - node: { - __typename: 'Comment', - id: 'comment:1', + const expectedData = { + ...initialData, + comments: { + edges: [ + ...initialData.comments.edges, + { + cursor: 'edge:1', + node: { + __typename: 'Comment', + id: 'comment:1', + }, }, + ], + pageInfo: { + endCursor: 'edge:1', + hasNextPage: true, }, - ], - pageInfo: { - endCursor: 'edge:1', - hasNextPage: true, }, - }, - }; - expectFragmentResults([ - { - // First update has updated connection - data: expectedData, - isLoadingNext: true, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - { - // Second update sets isLoading flag back to false - data: expectedData, - isLoadingNext: false, - isLoadingPrevious: false, - hasNext: true, - hasPrevious: false, - }, - ]); - expect(callback).toBeCalledTimes(1); + }; + expectFragmentResults([ + { + // First update has updated connection + data: expectedData, + isLoadingNext: true, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + { + // Second update sets isLoading flag back to false + data: expectedData, + isLoadingNext: false, + isLoadingPrevious: false, + hasNext: true, + hasPrevious: false, + }, + ]); + expect(callback).toBeCalledTimes(1); + }); }); }); }); diff --git a/packages/react-relay/relay-hooks/getConnectionState.js b/packages/react-relay/relay-hooks/getConnectionState.js new file mode 100644 index 0000000000000..a65fe3aee097c --- /dev/null +++ b/packages/react-relay/relay-hooks/getConnectionState.js @@ -0,0 +1,97 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall relay + */ + +'use strict'; + +import type {Direction, ReaderFragment} from 'relay-runtime'; + +const invariant = require('invariant'); +const {ConnectionInterface, getValueAtPath} = require('relay-runtime'); + +function getConnectionState( + direction: Direction, + fragmentNode: ReaderFragment, + fragmentData: mixed, + connectionPathInFragmentData: $ReadOnlyArray, +): { + cursor: ?string, + hasMore: boolean, +} { + const { + EDGES, + PAGE_INFO, + HAS_NEXT_PAGE, + HAS_PREV_PAGE, + END_CURSOR, + START_CURSOR, + } = ConnectionInterface.get(); + const connection = getValueAtPath(fragmentData, connectionPathInFragmentData); + if (connection == null) { + return {cursor: null, hasMore: false}; + } + + invariant( + typeof connection === 'object', + 'Relay: Expected connection in fragment `%s` to have been `null`, or ' + + 'a plain object with %s and %s properties. Instead got `%s`.', + fragmentNode.name, + EDGES, + PAGE_INFO, + connection, + ); + + const edges = connection[EDGES]; + const pageInfo = connection[PAGE_INFO]; + if (edges == null || pageInfo == null) { + return {cursor: null, hasMore: false}; + } + + invariant( + Array.isArray(edges), + 'Relay: Expected connection in fragment `%s` to have a plural `%s` field. ' + + 'Instead got `%s`.', + fragmentNode.name, + EDGES, + edges, + ); + invariant( + typeof pageInfo === 'object', + 'Relay: Expected connection in fragment `%s` to have a `%s` field. ' + + 'Instead got `%s`.', + fragmentNode.name, + PAGE_INFO, + pageInfo, + ); + + const cursor = + direction === 'forward' + ? pageInfo[END_CURSOR] ?? null + : pageInfo[START_CURSOR] ?? null; + invariant( + cursor === null || typeof cursor === 'string', + 'Relay: Expected page info for connection in fragment `%s` to have a ' + + 'valid `%s`. Instead got `%s`.', + fragmentNode.name, + START_CURSOR, + cursor, + ); + + let hasMore; + if (direction === 'forward') { + hasMore = cursor != null && pageInfo[HAS_NEXT_PAGE] === true; + } else { + hasMore = cursor != null && pageInfo[HAS_PREV_PAGE] === true; + } + + return {cursor, hasMore}; +} + +module.exports = getConnectionState; diff --git a/packages/react-relay/relay-hooks/useFragmentInternal.js b/packages/react-relay/relay-hooks/useFragmentInternal.js index ce3a6922883f8..6ca808b852da1 100644 --- a/packages/react-relay/relay-hooks/useFragmentInternal.js +++ b/packages/react-relay/relay-hooks/useFragmentInternal.js @@ -24,7 +24,7 @@ hook useFragmentInternal( hookDisplayName: string, queryOptions?: FragmentQueryOptions, ): ?SelectorData | Array { - if (RelayFeatureFlags.ENABLE_USE_FRAGMENT_EXPERIMENTAL) { + if (RelayFeatureFlags.ENABLE_ACTIVITY_COMPATIBILITY) { // $FlowFixMe[react-rule-hook] - the condition is static return useFragmentInternal_EXPERIMENTAL( fragmentNode, diff --git a/packages/react-relay/relay-hooks/useLoadMoreFunction.js b/packages/react-relay/relay-hooks/useLoadMoreFunction.js index 8c87e52222ed6..633dd229346c8 100644 --- a/packages/react-relay/relay-hooks/useLoadMoreFunction.js +++ b/packages/react-relay/relay-hooks/useLoadMoreFunction.js @@ -22,20 +22,21 @@ import type { Variables, } from 'relay-runtime'; +const getConnectionState = require('./getConnectionState'); const useFetchTrackingRef = require('./useFetchTrackingRef'); const useIsMountedRef = require('./useIsMountedRef'); const useIsOperationNodeActive = require('./useIsOperationNodeActive'); +const useLoadMoreFunction_EXPERIMENTAL = require('./useLoadMoreFunction_EXPERIMENTAL'); const useRelayEnvironment = require('./useRelayEnvironment'); const invariant = require('invariant'); const {useCallback, useEffect, useState} = require('react'); const { __internal: {fetchQuery}, - ConnectionInterface, + RelayFeatureFlags, createOperationDescriptor, getPaginationVariables, getRefetchMetadata, getSelector, - getValueAtPath, } = require('relay-runtime'); const warning = require('warning'); @@ -63,6 +64,17 @@ export type UseLoadMoreFunctionArgs = { hook useLoadMoreFunction( args: UseLoadMoreFunctionArgs, +): [LoadMoreFn, boolean, () => void] { + if (RelayFeatureFlags.ENABLE_ACTIVITY_COMPATIBILITY) { + // $FlowFixMe[react-rule-hook] - the condition is static + return useLoadMoreFunction_EXPERIMENTAL(args); + } + // $FlowFixMe[react-rule-hook] - the condition is static + return useLoadMoreFunction_CURRENT(args); +} + +hook useLoadMoreFunction_CURRENT( + args: UseLoadMoreFunctionArgs, ): [LoadMoreFn, boolean, () => void] { const { direction, @@ -269,82 +281,4 @@ hook useLoadMoreFunction( return [loadMore, hasMore, disposeFetch]; } -function getConnectionState( - direction: Direction, - fragmentNode: ReaderFragment, - fragmentData: mixed, - connectionPathInFragmentData: $ReadOnlyArray, -): { - cursor: ?string, - hasMore: boolean, -} { - const { - EDGES, - PAGE_INFO, - HAS_NEXT_PAGE, - HAS_PREV_PAGE, - END_CURSOR, - START_CURSOR, - } = ConnectionInterface.get(); - const connection = getValueAtPath(fragmentData, connectionPathInFragmentData); - if (connection == null) { - return {cursor: null, hasMore: false}; - } - - invariant( - typeof connection === 'object', - 'Relay: Expected connection in fragment `%s` to have been `null`, or ' + - 'a plain object with %s and %s properties. Instead got `%s`.', - fragmentNode.name, - EDGES, - PAGE_INFO, - connection, - ); - - const edges = connection[EDGES]; - const pageInfo = connection[PAGE_INFO]; - if (edges == null || pageInfo == null) { - return {cursor: null, hasMore: false}; - } - - invariant( - Array.isArray(edges), - 'Relay: Expected connection in fragment `%s` to have a plural `%s` field. ' + - 'Instead got `%s`.', - fragmentNode.name, - EDGES, - edges, - ); - invariant( - typeof pageInfo === 'object', - 'Relay: Expected connection in fragment `%s` to have a `%s` field. ' + - 'Instead got `%s`.', - fragmentNode.name, - PAGE_INFO, - pageInfo, - ); - - const cursor = - direction === 'forward' - ? pageInfo[END_CURSOR] ?? null - : pageInfo[START_CURSOR] ?? null; - invariant( - cursor === null || typeof cursor === 'string', - 'Relay: Expected page info for connection in fragment `%s` to have a ' + - 'valid `%s`. Instead got `%s`.', - fragmentNode.name, - START_CURSOR, - cursor, - ); - - let hasMore; - if (direction === 'forward') { - hasMore = cursor != null && pageInfo[HAS_NEXT_PAGE] === true; - } else { - hasMore = cursor != null && pageInfo[HAS_PREV_PAGE] === true; - } - - return {cursor, hasMore}; -} - module.exports = useLoadMoreFunction; diff --git a/packages/react-relay/relay-hooks/useLoadMoreFunction_EXPERIMENTAL.js b/packages/react-relay/relay-hooks/useLoadMoreFunction_EXPERIMENTAL.js new file mode 100644 index 0000000000000..7f6e40f1e7eb4 --- /dev/null +++ b/packages/react-relay/relay-hooks/useLoadMoreFunction_EXPERIMENTAL.js @@ -0,0 +1,280 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall relay + */ + +'use strict'; + +import type { + ConcreteRequest, + Direction, + Disposable, + GraphQLResponse, + Observer, + ReaderFragment, + ReaderPaginationMetadata, + Subscription, + Variables, +} from 'relay-runtime'; + +const getConnectionState = require('./getConnectionState'); +const useIsMountedRef = require('./useIsMountedRef'); +const useIsOperationNodeActive = require('./useIsOperationNodeActive'); +const useRelayEnvironment = require('./useRelayEnvironment'); +const invariant = require('invariant'); +const {useCallback, useRef, useState} = require('react'); +const { + __internal: {fetchQuery}, + createOperationDescriptor, + getPaginationVariables, + getRefetchMetadata, + getSelector, +} = require('relay-runtime'); +const warning = require('warning'); + +export type LoadMoreFn = ( + count: number, + options?: { + onComplete?: (Error | null) => void, + UNSTABLE_extraVariables?: Partial, + }, +) => Disposable; + +export type UseLoadMoreFunctionArgs = { + direction: Direction, + fragmentNode: ReaderFragment, + fragmentRef: mixed, + fragmentIdentifier: string, + fragmentData: mixed, + connectionPathInFragmentData: $ReadOnlyArray, + paginationRequest: ConcreteRequest, + paginationMetadata: ReaderPaginationMetadata, + componentDisplayName: string, + observer: Observer, + onReset: () => void, +}; + +hook useLoadMoreFunction_EXPERIMENTAL( + args: UseLoadMoreFunctionArgs, +): [ + // Function to load more data + LoadMoreFn, + // Whether the connection has more data to load + boolean, + // Force dispose function which cancels the in-flight fetch itself, and callbacks + () => void, +] { + const { + direction, + fragmentNode, + fragmentRef, + fragmentIdentifier, + fragmentData, + connectionPathInFragmentData, + paginationRequest, + paginationMetadata, + componentDisplayName, + observer, + onReset, + } = args; + const environment = useRelayEnvironment(); + + const {identifierInfo} = getRefetchMetadata( + fragmentNode, + componentDisplayName, + ); + const identifierValue = + identifierInfo?.identifierField != null && + fragmentData != null && + typeof fragmentData === 'object' + ? fragmentData[identifierInfo.identifierField] + : null; + + const fetchStatusRef = useRef< + {kind: 'fetching', subscription: Subscription} | {kind: 'none'}, + >({kind: 'none'}); + const [mirroredEnvironment, setMirroredEnvironment] = useState(environment); + const [mirroredFragmentIdentifier, setMirroredFragmentIdentifier] = + useState(fragmentIdentifier); + + const isParentQueryActive = useIsOperationNodeActive( + fragmentNode, + fragmentRef, + ); + + const forceDisposeFn = useCallback(() => { + // $FlowFixMe[react-rule-unsafe-ref] + if (fetchStatusRef.current.kind === 'fetching') { + // $FlowFixMe[react-rule-unsafe-ref] + fetchStatusRef.current.subscription.unsubscribe(); + } + // $FlowFixMe[react-rule-unsafe-ref] + fetchStatusRef.current = {kind: 'none'}; + }, []); + + const shouldReset = + environment !== mirroredEnvironment || + fragmentIdentifier !== mirroredFragmentIdentifier; + if (shouldReset) { + forceDisposeFn(); + onReset(); + setMirroredEnvironment(environment); + setMirroredFragmentIdentifier(fragmentIdentifier); + } + + const {cursor, hasMore} = getConnectionState( + direction, + fragmentNode, + fragmentData, + connectionPathInFragmentData, + ); + + const isMountedRef = useIsMountedRef(); + const loadMore = useCallback( + ( + count: number, + options: void | { + UNSTABLE_extraVariables?: Partial, + onComplete?: (Error | null) => void, + }, + ) => { + // TODO(T41131846): Fetch/Caching policies for loadMore + + const onComplete = options?.onComplete; + if (isMountedRef.current !== true) { + // Bail out and warn if we're trying to paginate after the component + // has unmounted + warning( + false, + 'Relay: Unexpected fetch on unmounted component for fragment ' + + '`%s` in `%s`. It looks like some instances of your component are ' + + 'still trying to fetch data but they already unmounted. ' + + 'Please make sure you clear all timers, intervals, ' + + 'async calls, etc that may trigger a fetch.', + fragmentNode.name, + componentDisplayName, + ); + return {dispose: () => {}}; + } + + const fragmentSelector = getSelector(fragmentNode, fragmentRef); + if ( + fetchStatusRef.current.kind === 'fetching' || + fragmentData == null || + isParentQueryActive + ) { + if (fragmentSelector == null) { + warning( + false, + 'Relay: Unexpected fetch while using a null fragment ref ' + + 'for fragment `%s` in `%s`. When fetching more items, we expect ' + + "initial fragment data to be non-null. Please make sure you're " + + 'passing a valid fragment ref to `%s` before paginating.', + fragmentNode.name, + componentDisplayName, + componentDisplayName, + ); + } + + if (onComplete) { + onComplete(null); + } + return {dispose: () => {}}; + } + + invariant( + fragmentSelector != null && + fragmentSelector.kind !== 'PluralReaderSelector', + 'Relay: Expected to be able to find a non-plural fragment owner for ' + + "fragment `%s` when using `%s`. If you're seeing this, " + + 'this is likely a bug in Relay.', + fragmentNode.name, + componentDisplayName, + ); + + const parentVariables = fragmentSelector.owner.variables; + const fragmentVariables = fragmentSelector.variables; + const extraVariables = options?.UNSTABLE_extraVariables; + const baseVariables = { + ...parentVariables, + ...fragmentVariables, + }; + const paginationVariables = getPaginationVariables( + direction, + count, + cursor, + baseVariables, + {...extraVariables}, + paginationMetadata, + ); + + // If the query needs an identifier value ('id' or similar) and one + // was not explicitly provided, read it from the fragment data. + if (identifierInfo != null) { + // @refetchable fragments are guaranteed to have an `id` selection + // if the type is Node, implements Node, or is @fetchable. Double-check + // that there actually is a value at runtime. + if (typeof identifierValue !== 'string') { + warning( + false, + 'Relay: Expected result to have a string ' + + '`%s` in order to refetch, got `%s`.', + identifierInfo.identifierField, + identifierValue, + ); + } + paginationVariables[identifierInfo.identifierQueryVariableName] = + identifierValue; + } + + const paginationQuery = createOperationDescriptor( + paginationRequest, + paginationVariables, + {force: true}, + ); + fetchQuery(environment, paginationQuery).subscribe({ + ...observer, + start: subscription => { + fetchStatusRef.current = {kind: 'fetching', subscription}; + observer.start && observer.start(subscription); + }, + complete: () => { + fetchStatusRef.current = {kind: 'none'}; + observer.complete && observer.complete(); + onComplete && onComplete(null); + }, + error: error => { + fetchStatusRef.current = {kind: 'none'}; + observer.complete && observer.complete(); + onComplete && onComplete(error); + }, + }); + return { + dispose: () => {}, + }; + }, + // NOTE: We disable react-hooks-deps warning because all values + // inside paginationMetadata are static + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + environment, + identifierValue, + direction, + cursor, + isParentQueryActive, + fragmentData, + fragmentNode.name, + fragmentRef, + componentDisplayName, + ], + ); + return [loadMore, hasMore, forceDisposeFn]; +} + +module.exports = useLoadMoreFunction_EXPERIMENTAL; diff --git a/packages/relay-runtime/util/RelayFeatureFlags.js b/packages/relay-runtime/util/RelayFeatureFlags.js index 3084b64fcd51e..43729cd7a4b28 100644 --- a/packages/relay-runtime/util/RelayFeatureFlags.js +++ b/packages/relay-runtime/util/RelayFeatureFlags.js @@ -51,8 +51,8 @@ export type FeatureFlags = { ENABLE_CYLE_DETECTION_IN_VARIABLES: boolean, - // Temporary flag to experiment with new useFragmentInternal implementation - ENABLE_USE_FRAGMENT_EXPERIMENTAL: boolean, + // Temporary flag to experiment to enable compatibility with React's unstable API + ENABLE_ACTIVITY_COMPATIBILITY: boolean, }; const RelayFeatureFlags: FeatureFlags = { @@ -75,7 +75,7 @@ const RelayFeatureFlags: FeatureFlags = { PROCESS_OPTIMISTIC_UPDATE_BEFORE_SUBSCRIPTION: false, MARK_RESOLVER_VALUES_AS_CLEAN_AFTER_FRAGMENT_REREAD: false, ENABLE_CYLE_DETECTION_IN_VARIABLES: false, - ENABLE_USE_FRAGMENT_EXPERIMENTAL: false, + ENABLE_ACTIVITY_COMPATIBILITY: false, }; module.exports = RelayFeatureFlags;