From a54bcb2de319764724cfaba4de8cc91dcf9fa8b9 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 5 Aug 2021 10:23:17 -0400 Subject: [PATCH 01/65] copy some tests from Query --- src/react/hooks/__tests__/useQuery.test.tsx | 131 +++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 967f4ab74c9..c62b352cbd5 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -1187,6 +1187,66 @@ describe('useQuery Hook', () => { expect(catchFn.mock.calls[0][0]).toBeInstanceOf(ApolloError); expect(catchFn.mock.calls[0][0].message).toBe('same error'); }); + + it('should call onCompleted when variables change', async () => { + const query = gql` + query people($first: Int) { + allPeople(first: $first) { + people { + name + } + } + } + `; + + const data1 = { allPeople: { people: [{ name: 'Luke Skywalker' }] } }; + const data2 = { allPeople: { people: [{ name: 'Han Solo' }] } }; + const mocks = [ + { + request: { query, variables: { first: 1 } }, + result: { data: data1 }, + }, + { + request: { query, variables: { first: 2 } }, + result: { data: data2 }, + }, + ]; + + const onCompleted = jest.fn(); + + const { result, rerender, waitForNextUpdate } = renderHook( + ({ variables }) => useQuery(query, { variables, onCompleted }), + { + wrapper: ({ children }) => ( + + {children} + + ), + initialProps: { + variables: { first: 1 }, + }, + }, + ); + + expect(result.current.loading).toBe(true); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(data1); + + rerender({ variables: { first: 2 } }); + expect(result.current.loading).toBe(true); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(data2); + + rerender({ variables: { first: 1 } }); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(data1); + + expect(onCompleted).toHaveBeenCalledTimes(3); + }); }); describe('Pagination', () => { @@ -1539,6 +1599,71 @@ describe('useQuery Hook', () => { expect(result.current.data).toEqual({ hello: 'world 2' }); }); + it('refetching after an error', async () => { + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: 'world 1' } }, + }, + { + request: { query }, + error: new Error('This is an error!'), + delay: 10, + }, + { + request: { query }, + result: { data: { hello: 'world 2' } }, + delay: 10, + }, + ]; + + const cache = new InMemoryCache(); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query, { + notifyOnNetworkStatusChange: true, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toEqual({ hello: 'world 1' }); + + result.current.refetch(); + await waitForNextUpdate(); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toEqual({ hello: 'world 1' }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeInstanceOf(ApolloError); + expect(result.current.data).toEqual({ hello: 'world 1' }); + + result.current.refetch(); + await waitForNextUpdate(); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toEqual({ hello: 'world 1' }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toEqual({ hello: 'world 2' }); + }); + describe('refetchWritePolicy', () => { const query = gql` query GetPrimes ($min: number, $max: number) { @@ -2144,7 +2269,7 @@ describe('useQuery Hook', () => { request: { query, variables: { - someVar: 'abc123' + name: 'Luke' } }, result: { data: undefined }, @@ -2153,7 +2278,7 @@ describe('useQuery Hook', () => { request: { query, variables: { - someVar: 'abc123' + name: 'Luke' } }, result: { data: peopleData }, @@ -2174,7 +2299,7 @@ describe('useQuery Hook', () => { const { result, waitForNextUpdate } = renderHook( () => useQuery(query, { - variables: { someVar: 'abc123' }, + variables: { name: 'Luke' }, partialRefetch: true, notifyOnNetworkStatusChange: true, }), From eb972f1a0c267246d0835ecddc78095641603afd Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 5 Aug 2021 19:02:57 -0400 Subject: [PATCH 02/65] refactor the partial refetch tests --- src/react/hooks/__tests__/useQuery.test.tsx | 122 +++++++++++--------- 1 file changed, 68 insertions(+), 54 deletions(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index c62b352cbd5..74b68caadf9 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2238,51 +2238,21 @@ describe('useQuery Hook', () => { }); }); - describe('Partial refetching', () => { - it('should attempt a refetch when the query result was marked as being ' + - 'partial, the returned data was reset to an empty Object by the ' + - 'Apollo Client QueryManager (due to a cache miss), and the ' + - '`partialRefetch` prop is `true`', async () => { - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const query: DocumentNode = gql` - query AllPeople($name: String!) { - allPeople(name: $name) { - people { - name - } - } - } - `; - - interface Data { - allPeople: { - people: Array<{ name: string }>; - }; - } - - const peopleData: Data = { - allPeople: { people: [{ name: 'Luke Skywalker' }] } - }; + describe('Partial refetch', () => { + it('should attempt a refetch when data is missing and partialRefetch is true', async () => { + const errorSpy = jest.spyOn(console, 'error') + .mockImplementation(() => {}); + const query = gql`{ hello }`; const link = mockSingleLink( { - request: { - query, - variables: { - name: 'Luke' - } - }, - result: { data: undefined }, + request: { query }, + result: { data: {} }, }, { - request: { - query, - variables: { - name: 'Luke' - } - }, - result: { data: peopleData }, - delay: 10, + request: { query }, + result: { data: { hello: "world" } }, + delay: 20, } ); @@ -2299,42 +2269,86 @@ describe('useQuery Hook', () => { const { result, waitForNextUpdate } = renderHook( () => useQuery(query, { - variables: { name: 'Luke' }, partialRefetch: true, notifyOnNetworkStatusChange: true, }), { wrapper }, ); - // Initial loading render expect(result.current.loading).toBe(true); expect(result.current.data).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.loading); await waitForNextUpdate(); + // waitForUpdate seems to miss the erroring render + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + expect(result.current.networkStatus).toBe(NetworkStatus.refetch); + expect(errorSpy).toHaveBeenCalledTimes(1); expect(errorSpy.mock.calls[0][0]).toMatch('Missing field'); - const previous = result.all[result.all.length - 2]; - if (previous instanceof Error) { - throw previous; - } - // `data` is missing and `partialRetch` is true, so a refetch - // is triggered and loading is set as true again - expect(previous.loading).toBe(true); - expect(previous.data).toBe(undefined); - expect(previous.networkStatus).toBe(NetworkStatus.loading); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ hello: 'world' }); + expect(result.current.networkStatus).toBe(NetworkStatus.ready); + errorSpy.mockRestore(); + }); + + it('should attempt a refetch when data is missing, partialRefetch is true and addTypename is false for the cache', async () => { + const errorSpy = jest.spyOn(console, 'error') + .mockImplementation(() => {}); + const query = gql`{ hello }`; + + const link = mockSingleLink( + { + request: { query }, + result: { data: {} }, + }, + { + request: { query }, + result: { data: { hello: "world" } }, + delay: 20, + } + ); + + const client = new ApolloClient({ + link, + // THIS LINE IS THE ONLY DIFFERENCE FOR THIS TEST + cache: new InMemoryCache({ addTypename: false }), + }); + + const wrapper = ({ children }: any) => ( + + {children} + + ); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query, { + partialRefetch: true, + notifyOnNetworkStatusChange: true, + }), + { wrapper }, + ); + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + expect(result.current.networkStatus).toBe(NetworkStatus.loading); + + await waitForNextUpdate(); + // waitForUpdate seems to miss the erroring render expect(result.current.loading).toBe(true); expect(result.current.data).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.refetch); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toMatch('Missing field'); + await waitForNextUpdate(); - // Refetch has completed expect(result.current.loading).toBe(false); - expect(result.current.data).toEqual(peopleData); + expect(result.current.data).toEqual({ hello: 'world' }); expect(result.current.networkStatus).toBe(NetworkStatus.ready); - errorSpy.mockRestore(); }); }); From daee4e9e213ea7c6a512ec9679554df87e171d51 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 6 Aug 2021 10:25:44 -0400 Subject: [PATCH 03/65] more assertions in the partialRefetch stuff --- src/react/hooks/__tests__/useQuery.test.tsx | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 74b68caadf9..4dcdc0b8cc5 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2279,9 +2279,21 @@ describe('useQuery Hook', () => { expect(result.current.data).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.loading); + const updates = result.all.length; await waitForNextUpdate(); + expect(result.all.length - updates).toBe(2); // waitForUpdate seems to miss the erroring render + const previous = result.all[result.all.length - 2]; + if (previous instanceof Error) { + throw previous; + } + + expect(previous.loading).toBe(true); + expect(previous.error).toBe(undefined); + expect(previous.data).toBe(undefined); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(undefined); expect(result.current.data).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.refetch); @@ -2336,8 +2348,19 @@ describe('useQuery Hook', () => { expect(result.current.data).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.loading); + const updates = result.all.length; await waitForNextUpdate(); + expect(result.all.length - updates).toBe(2); // waitForUpdate seems to miss the erroring render + const previous = result.all[result.all.length - 2]; + if (previous instanceof Error) { + throw previous; + } + + expect(previous.loading).toBe(true); + expect(previous.error).toBe(undefined); + expect(previous.data).toBe(undefined); + expect(result.current.loading).toBe(true); expect(result.current.data).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.refetch); From de27ddc92b0c9d51aaf44940bc03e19fd0371ef8 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 6 Aug 2021 13:14:18 -0400 Subject: [PATCH 04/65] add another test with partialRefetch --- src/react/hooks/__tests__/useQuery.test.tsx | 75 ++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 4dcdc0b8cc5..1fda753e9b8 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2307,6 +2307,79 @@ describe('useQuery Hook', () => { errorSpy.mockRestore(); }); + it('should attempt a refetch when data is missing and partialRefetch is true 2', async () => { + const query = gql` + query people { + allPeople(first: 1) { + people { + name + } + } + } + `; + + const data = { + allPeople: { people: [{ name: 'Luke Skywalker' }] }, + }; + + const errorSpy = jest.spyOn(console, 'error') + .mockImplementation(() => {}); + const link = mockSingleLink( + { request: { query }, result: { data: {} } }, + { request: { query }, result: { data }, delay: 20 } + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query, { + partialRefetch: true, + notifyOnNetworkStatusChange: true, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + expect(result.current.networkStatus).toBe(NetworkStatus.loading); + + const updates = result.all.length; + await waitForNextUpdate(); + expect(result.all.length - updates).toBe(2); + // waitForUpdate seems to miss the erroring render + const previous = result.all[result.all.length - 2]; + if (previous instanceof Error) { + throw previous; + } + + expect(previous.loading).toBe(true); + expect(previous.error).toBe(undefined); + expect(previous.data).toBe(undefined); + + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toBe(undefined); + expect(result.current.networkStatus).toBe(NetworkStatus.refetch); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toMatch('Missing field'); + errorSpy.mockRestore(); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(data); + expect(result.current.networkStatus).toBe(NetworkStatus.ready); + }); + it('should attempt a refetch when data is missing, partialRefetch is true and addTypename is false for the cache', async () => { const errorSpy = jest.spyOn(console, 'error') .mockImplementation(() => {}); @@ -2367,12 +2440,12 @@ describe('useQuery Hook', () => { expect(errorSpy).toHaveBeenCalledTimes(1); expect(errorSpy.mock.calls[0][0]).toMatch('Missing field'); + errorSpy.mockRestore(); await waitForNextUpdate(); expect(result.current.loading).toBe(false); expect(result.current.data).toEqual({ hello: 'world' }); expect(result.current.networkStatus).toBe(NetworkStatus.ready); - errorSpy.mockRestore(); }); }); From faad4fb910437d9850da59c662d1f7bb17c03294 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 9 Aug 2021 16:06:38 -0400 Subject: [PATCH 05/65] add a testTimeout to debug command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 52a79df2f96..a3f36cb39d4 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "resolve": "ts-node-script config/resolveModuleIds.ts", "clean": "rimraf -r dist coverage lib temp", "test": "jest --config ./config/jest.config.js", - "test:debug": "BABEL_ENV=server node --inspect-brk node_modules/.bin/jest --config ./config/jest.config.js --runInBand", + "test:debug": "BABEL_ENV=server node --inspect-brk node_modules/.bin/jest --config ./config/jest.config.js --runInBand --testTimeout 99999", "test:ci": "npm run test:coverage && npm run test:memory", "test:watch": "jest --config ./config/jest.config.js --watch", "test:memory": "cd scripts/memory && npm i && npm test", From d6e7442051611fc999875d9993884e2f69dc5644 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 10 Aug 2021 12:05:39 -0400 Subject: [PATCH 06/65] update useLazyQuery tests to use react testing hooks library --- .../hooks/__tests__/useLazyQuery.test.tsx | 715 +++++++----------- 1 file changed, 279 insertions(+), 436 deletions(-) diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index 968070c2b9e..f2415b5d6b7 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -1,477 +1,320 @@ import React from 'react'; -import { DocumentNode } from 'graphql'; import gql from 'graphql-tag'; -import { render, wait } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; -import { ApolloClient } from '../../../core'; -import { InMemoryCache } from '../../../cache'; -import { ApolloProvider } from '../../context'; -import { itAsync, MockedProvider } from '../../../testing'; +import { MockedProvider } from '../../../testing'; import { useLazyQuery } from '../useLazyQuery'; describe('useLazyQuery Hook', () => { - const CAR_QUERY: DocumentNode = gql` - query { - cars { - make - model - vin - } - } - `; - - const CAR_RESULT_DATA = { - cars: [ + it('should hold query execution until manually triggered', async () => { + const query = gql`{ hello }`; + const mocks = [ { - make: 'Audi', - model: 'RS8', - vin: 'DOLLADOLLABILL', - __typename: 'Car' - } - ] - }; - - const CAR_MOCKS = [ - { - request: { - query: CAR_QUERY + request: { query }, + result: { data: { hello: 'world' } }, + delay: 20, + }, + ]; + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query), + { + wrapper: ({ children }) => ( + + {children} + + ), }, - result: { data: CAR_RESULT_DATA } - } - ]; - - it('should hold query execution until manually triggered', async () => { - let renderCount = 0; - const Component = () => { - const [execute, { loading, data }] = useLazyQuery(CAR_QUERY); - switch (renderCount) { - case 0: - expect(loading).toEqual(false); - setTimeout(() => { - execute(); - }); - break; - case 1: - expect(loading).toEqual(true); - break; - case 2: - expect(loading).toEqual(false); - expect(data).toEqual(CAR_RESULT_DATA); - break; - default: // Do nothing - } - renderCount += 1; - return null; - }; - - render( - - - ); - return wait(() => { - expect(renderCount).toBe(3); - }); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toBe(undefined); + const execute = result.current[0]; + setTimeout(() => execute()); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(true); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: 'world' }); }); - it('should set `called` to false by default', () => { - const Component = () => { - const [, { loading, called }] = useLazyQuery(CAR_QUERY); - expect(loading).toBeFalsy(); - expect(called).toBeFalsy(); - return null; - }; - - render( - - - + it('should set `called` to false by default', async () => { + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: 'world' } }, + delay: 20, + }, + ]; + const { result } = renderHook( + () => useLazyQuery(query), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); + + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toBe(undefined); + expect(result.current[1].called).toBe(false); }); it('should set `called` to true after calling the lazy execute function', async () => { - let renderCount = 0; - const Component = () => { - const [execute, { loading, called, data }] = useLazyQuery(CAR_QUERY); - switch (renderCount) { - case 0: - expect(loading).toBeFalsy(); - expect(called).toBeFalsy(); - setTimeout(() => { - execute(); - }); - break; - case 1: - expect(loading).toBeTruthy(); - expect(called).toBeTruthy(); - break; - case 2: - expect(loading).toEqual(false); - expect(called).toBeTruthy(); - expect(data).toEqual(CAR_RESULT_DATA); - break; - default: // Do nothing - } - renderCount += 1; - return null; - }; - - render( - - - + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: 'world' } }, + delay: 20, + }, + ]; + + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); - return wait(() => { - expect(renderCount).toBe(3); - }); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].called).toBe(false); + const execute = result.current[0]; + setTimeout(() => execute()); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(true); + expect(result.current[1].called).toBe(true); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].called).toBe(true); }); it('should override `skip` if lazy mode execution function is called', async () => { - let renderCount = 0; - const Component = () => { - const [execute, { loading, data }] = useLazyQuery(CAR_QUERY, { - skip: true - } as any); - switch (renderCount) { - case 0: - expect(loading).toBeFalsy(); - setTimeout(() => { - execute(); - }); - break; - case 1: - expect(loading).toBeTruthy(); - break; - case 2: - expect(loading).toEqual(false); - expect(data).toEqual(CAR_RESULT_DATA); - break; - default: // Do nothing - } - renderCount += 1; - return null; - }; - - render( - - - + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: 'world' } }, + delay: 20, + }, + ]; + + const { result, waitForNextUpdate } = renderHook( + // skip isn’t actually an option on the types + () => useLazyQuery(query, { skip: true } as any), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); - return wait(() => { - expect(renderCount).toBe(3); - }); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].called).toBe(false); + const execute = result.current[0]; + setTimeout(() => execute()); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(true); + expect(result.current[1].called).toBe(true); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].called).toBe(true); }); - it( - 'should use variables defined in hook options (if any), when running ' + - 'the lazy execution function', - async () => { - const CAR_QUERY: DocumentNode = gql` - query AllCars($year: Int!) { - cars(year: $year) @client { - make - year - } - } - `; - - const CAR_RESULT_DATA = [ - { - make: 'Audi', - year: 2000, - __typename: 'Car' - }, - { - make: 'Hyundai', - year: 2001, - __typename: 'Car' - } - ]; - - const client = new ApolloClient({ - cache: new InMemoryCache(), - resolvers: { - Query: { - cars(_root, { year }) { - return CAR_RESULT_DATA.filter(car => car.year === year); - } - } - } - }); - - let renderCount = 0; - const Component = () => { - const [execute, { loading, data }] = useLazyQuery(CAR_QUERY, { - variables: { year: 2001 } - }); - switch (renderCount) { - case 0: - expect(loading).toBeFalsy(); - setTimeout(() => { - execute(); - }); - break; - case 1: - expect(loading).toBeTruthy(); - break; - case 2: - expect(loading).toEqual(false); - expect(data.cars).toEqual([CAR_RESULT_DATA[1]]); - break; - default: // Do nothing - } - renderCount += 1; - return null; - }; - - render( - - - - ); - - return wait(() => { - expect(renderCount).toBe(3); - }); - } - ); - - it( - 'should use variables passed into lazy execution function, ' + - 'overriding similar variables defined in Hook options', - async () => { - const CAR_QUERY: DocumentNode = gql` - query AllCars($year: Int!) { - cars(year: $year) @client { - make - year - } - } - `; - - const CAR_RESULT_DATA = [ - { - make: 'Audi', - year: 2000, - __typename: 'Car' - }, - { - make: 'Hyundai', - year: 2001, - __typename: 'Car' - } - ]; - - const client = new ApolloClient({ - cache: new InMemoryCache(), - resolvers: { - Query: { - cars(_root, { year }) { - return CAR_RESULT_DATA.filter(car => car.year === year); - } - } - } - }); - - let renderCount = 0; - const Component = () => { - const [execute, { loading, data }] = useLazyQuery(CAR_QUERY, { - variables: { year: 2001 } - }); - switch (renderCount) { - case 0: - expect(loading).toBeFalsy(); - setTimeout(() => { - execute({ variables: { year: 2000 } }); - }); - break; - case 1: - expect(loading).toBeTruthy(); - break; - case 2: - expect(loading).toEqual(false); - expect(data.cars).toEqual([CAR_RESULT_DATA[0]]); - break; - default: // Do nothing - } - renderCount += 1; - return null; - }; - - render( - - - - ); - - return wait(() => { - expect(renderCount).toBe(3); - }); - } - ); - - it( - 'should fetch data each time the execution function is called, when ' + - 'using a "network-only" fetch policy', - async () => { - const data1 = CAR_RESULT_DATA; - - const data2 = { - cars: [ - { - make: 'Audi', - model: 'SQ5', - vin: 'POWERANDTRUNKSPACE', - __typename: 'Car' - } - ] - }; - - const mocks = [ - { - request: { - query: CAR_QUERY - }, - result: { data: data1 } - }, - { - request: { - query: CAR_QUERY - }, - result: { data: data2 } - } - ]; - - let renderCount = 0; - const Component = () => { - const [execute, { loading, data }] = useLazyQuery(CAR_QUERY, { - fetchPolicy: 'network-only' - }); - switch (renderCount) { - case 0: - expect(loading).toEqual(false); - setTimeout(() => { - execute(); - }); - break; - case 1: - expect(loading).toEqual(true); - break; - case 2: - expect(loading).toEqual(false); - expect(data).toEqual(data1); - setTimeout(() => { - execute(); - }); - break; - case 3: - expect(loading).toEqual(true); - break; - case 4: - expect(loading).toEqual(false); - expect(data).toEqual(data2); - break; - default: // Do nothing - } - renderCount += 1; - return null; - }; - - render( - - - - ); - - return wait(() => { - expect(renderCount).toBe(5); - }); - } - ); - - itAsync('should persist previous data when a query is re-run', (resolve, reject) => { + it('should use variables defined in hook options (if any), when running the lazy execution function', async () => { const query = gql` - query car { - car { - id - make - } + query($id: number) { + hello(id: $id) } `; - const data1 = { - car: { - id: 1, - make: 'Venturi', - __typename: 'Car', - } - }; + const mocks = [ + { + request: { query, variables: { id: 1 } }, + result: { data: { hello: 'world 1' } }, + delay: 20, + }, + ]; - const data2 = { - car: { - id: 2, - make: 'Wiesmann', - __typename: 'Car', + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query, { + variables: { id: 1 }, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + const execute = result.current[0]; + setTimeout(() => execute()); + + await waitForNextUpdate(); + + expect(result.current[1].loading).toBe(true); + await waitForNextUpdate(); + + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: 'world 1' }); + }); + + it('should use variables passed into lazy execution function, overriding similar variables defined in Hook options', async () => { + const query = gql` + query($id: number) { + hello(id: $id) } - }; + `; const mocks = [ - { request: { query }, result: { data: data1 } }, - { request: { query }, result: { data: data2 } } + { + request: { query, variables: { id: 1 } }, + result: { data: { hello: 'world 1' } }, + delay: 20, + }, + { + request: { query, variables: { id: 2 } }, + result: { data: { hello: 'world 2' } }, + delay: 20, + }, ]; - let renderCount = 0; - function App() { - const [execute, { loading, data, previousData, refetch }] = useLazyQuery( - query, - { notifyOnNetworkStatusChange: true }, - ); - - switch (++renderCount) { - case 1: - expect(loading).toEqual(false); - expect(data).toBeUndefined(); - expect(previousData).toBeUndefined(); - setTimeout(execute); - break; - case 2: - expect(loading).toBeTruthy(); - expect(data).toBeUndefined(); - expect(previousData).toBeUndefined(); - break; - case 3: - expect(loading).toBeFalsy(); - expect(data).toEqual(data1); - expect(previousData).toBeUndefined(); - setTimeout(refetch!); - break; - case 4: - expect(loading).toBeTruthy(); - expect(data).toEqual(data1); - expect(previousData).toEqual(data1); - break; - case 5: - expect(loading).toBeFalsy(); - expect(data).toEqual(data2); - expect(previousData).toEqual(data1); - break; - default: // Do nothing - } + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query, { + variables: { id: 1 }, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + const execute = result.current[0]; + setTimeout(() => execute({ variables: { id: 2 } })); + + await waitForNextUpdate(); + + expect(result.current[1].loading).toBe(true); + await waitForNextUpdate(); + + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: 'world 2' }); + }); + + it('should fetch data each time the execution function is called, when using a "network-only" fetch policy', async () => { + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: 'world 1' } } + }, + { + request: { query }, + result: { data: { hello: 'world 2' } } + }, + ]; + + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query, { + fetchPolicy: 'network-only', + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); - return null; - } + expect(result.current[1].loading).toBe(false); + const execute = result.current[0]; + setTimeout(() => execute()); - render( - - - + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(true); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: 'world 1' }); + + setTimeout(() => execute()); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(true); + expect(result.current[1].data).toEqual({ hello: 'world 1' }); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: 'world 2' }); + }); + + it('should persist previous data when a query is re-run', async () => { + const query = gql`{ hello }`; + const mocks = [ + { request: { query }, result: { data: { hello: 'world 1' } } }, + { request: { query }, result: { data: { hello: 'world 2' } } }, + ]; + + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query, { + notifyOnNetworkStatusChange: true, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); - return wait(() => { - expect(renderCount).toBe(5); - }).then(resolve, reject); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toBe(undefined); + expect(result.current[1].previousData).toBe(undefined); + const execute = result.current[0]; + setTimeout(() => execute()); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(true); + expect(result.current[1].data).toBe(undefined); + expect(result.current[1].previousData).toBe(undefined); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: 'world 1' }); + expect(result.current[1].previousData).toBe(undefined); + + const refetch = result.current[1].refetch; + setTimeout(() => refetch!()); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(true); + expect(result.current[1].data).toEqual({ hello: 'world 1' }); + expect(result.current[1].previousData).toEqual({ hello: 'world 1' }); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: 'world 2' }); + expect(result.current[1].previousData).toEqual({ hello: 'world 1' }); }); }); From f19855fdcb3a241a98364d80dca938e977cb3ad4 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 11 Aug 2021 11:24:41 -0400 Subject: [PATCH 07/65] fix some timings in useLazyQuery --- .../hooks/__tests__/useLazyQuery.test.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index f2415b5d6b7..c5c9652d73f 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -225,11 +225,13 @@ describe('useLazyQuery Hook', () => { const mocks = [ { request: { query }, - result: { data: { hello: 'world 1' } } + result: { data: { hello: 'world 1' } }, + delay: 20, }, { request: { query }, - result: { data: { hello: 'world 2' } } + result: { data: { hello: 'world 2' } }, + delay: 20, }, ]; @@ -260,7 +262,7 @@ describe('useLazyQuery Hook', () => { setTimeout(() => execute()); await waitForNextUpdate(); - expect(result.current[1].loading).toBe(true); + expect(result.current[1].loading).toBe(false); expect(result.current[1].data).toEqual({ hello: 'world 1' }); await waitForNextUpdate(); @@ -271,8 +273,16 @@ describe('useLazyQuery Hook', () => { it('should persist previous data when a query is re-run', async () => { const query = gql`{ hello }`; const mocks = [ - { request: { query }, result: { data: { hello: 'world 1' } } }, - { request: { query }, result: { data: { hello: 'world 2' } } }, + { + request: { query }, + result: { data: { hello: 'world 1' } }, + delay: 20, + }, + { + request: { query }, + result: { data: { hello: 'world 2' } }, + delay: 20, + }, ]; const { result, waitForNextUpdate } = renderHook( From b6445f9d51d2986c36cc828eafa222df5125b2e2 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 23 Jul 2021 17:48:04 -0400 Subject: [PATCH 08/65] Inline everything into useQuery --- src/react/context/index.ts | 10 +- src/react/hooks/useQuery.ts | 642 +++++++++++++++++++++++++++++++++++- 2 files changed, 639 insertions(+), 13 deletions(-) diff --git a/src/react/context/index.ts b/src/react/context/index.ts index 860b3839b46..872a07df7da 100644 --- a/src/react/context/index.ts +++ b/src/react/context/index.ts @@ -1,3 +1,7 @@ -export * from './ApolloConsumer'; -export * from './ApolloContext'; -export * from './ApolloProvider'; +export { ApolloConsumer, ApolloConsumerProps } from './ApolloConsumer'; +export { + ApolloContextValue, + getApolloContext, + getApolloContext as resetApolloContext +} from './ApolloContext'; +export { ApolloProvider, ApolloProviderProps } from './ApolloProvider'; diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index bf75ad0e323..72e0899b947 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -1,16 +1,638 @@ -import { DocumentNode } from 'graphql'; -import { TypedDocumentNode } from '@graphql-typed-document-node/core'; - -import { QueryHookOptions, QueryResult } from '../types/types'; -import { useBaseQuery } from './utils/useBaseQuery'; +import { useContext, useEffect, useReducer, useRef } from 'react'; +import { invariant } from 'ts-invariant'; +import { equal } from '@wry/equality'; import { OperationVariables } from '../../core'; +import { getApolloContext } from '../context'; +import { ApolloError } from '../../errors'; +import { + ApolloClient, + NetworkStatus, + FetchMoreQueryOptions, + SubscribeToMoreOptions, + ObservableQuery, + FetchMoreOptions, + UpdateQueryOptions, + DocumentNode, + TypedDocumentNode, +} from '../../core'; +import { + CommonOptions, + QueryDataOptions, + QueryHookOptions, + QueryResult, + QueryLazyOptions, + ObservableQueryFields, +} from '../types/types'; + +import { + ObservableSubscription +} from '../../utilities'; +import { DocumentType, parser, operationName } from '../parser'; + +import { useDeepMemo } from './utils/useDeepMemo'; +import { useAfterFastRefresh } from './utils/useAfterFastRefresh'; + +type ObservableQueryOptions = + ReturnType["prepareObservableQueryOptions"]>; + +class QueryData = any> { + public isMounted: boolean = false; + public previousOptions: CommonOptions + = {} as CommonOptions; + public context: any = {}; + public client: ApolloClient; + + private options: CommonOptions = {} as CommonOptions; + + public setOptions( + newOptions: CommonOptions, + storePrevious: boolean = false + ) { + if (storePrevious && !equal(this.options, newOptions)) { + this.previousOptions = this.options; + } + this.options = newOptions; + } + + protected unmount() { + this.isMounted = false; + } + + protected refreshClient() { + const client = + (this.options && this.options.client) || + (this.context && this.context.client); + + invariant( + !!client, + 'Could not find "client" in the context or passed in as an option. ' + + 'Wrap the root component in an , or pass an ' + + 'ApolloClient instance in via options.' + ); + + let isNew = false; + if (client !== this.client) { + isNew = true; + this.client = client; + this.cleanup(); + } + return { + client: this.client as ApolloClient, + isNew + }; + } + + protected verifyDocumentType(document: DocumentNode, type: DocumentType) { + const operation = parser(document); + const requiredOperationName = operationName(type); + const usedOperationName = operationName(operation.type); + invariant( + operation.type === type, + `Running a ${requiredOperationName} requires a graphql ` + + `${requiredOperationName}, but a ${usedOperationName} was used instead.` + ); + } + public onNewData: () => void; + private currentObservable?: ObservableQuery; + private currentSubscription?: ObservableSubscription; + private lazyOptions?: QueryLazyOptions; + private previous: { + client?: ApolloClient; + query?: DocumentNode | TypedDocumentNode; + observableQueryOptions?: ObservableQueryOptions; + result?: QueryResult; + loading?: boolean; + options?: QueryDataOptions; + error?: ApolloError; + } = Object.create(null); + + constructor({ + options, + context, + onNewData + }: { + options: TOptions; + context: any; + onNewData: () => void; + }) { + this.options = options || ({} as CommonOptions); + this.context = context || {}; + this.onNewData = onNewData; + } + + public execute(): QueryResult { + this.refreshClient(); + + const { skip, query } = this.getOptions(); + if (skip || query !== this.previous.query) { + this.removeQuerySubscription(); + this.removeObservable(!skip); + this.previous.query = query; + } + + this.updateObservableQuery(); + + return this.getExecuteSsrResult() || this.getExecuteResult(); + } + + // For server-side rendering + public fetchData(): Promise | boolean { + const options = this.getOptions(); + if (options.skip || options.ssr === false) return false; + return new Promise(resolve => this.startQuerySubscription(resolve)); + } + + public afterExecute() { + this.isMounted = true; + const options = this.getOptions(); + const ssrDisabled = options.ssr === false; + if ( + this.currentObservable && + !ssrDisabled && + !this.ssrInitiated() + ) { + this.startQuerySubscription(); + } + + this.handleErrorOrCompleted(); + this.previousOptions = options; + return this.unmount.bind(this); + } + + public cleanup() { + this.removeQuerySubscription(); + this.removeObservable(true); + delete this.previous.result; + } + + public getOptions() { + const options = this.options; + if (this.lazyOptions) { + options.variables = { + ...options.variables, + ...this.lazyOptions.variables + } as TVariables; + options.context = { + ...options.context, + ...this.lazyOptions.context + }; + } + + return options; + } + + public ssrInitiated() { + return this.context && this.context.renderPromises; + } + + private getExecuteSsrResult() { + const { ssr, skip } = this.getOptions(); + const ssrDisabled = ssr === false; + const fetchDisabled = this.refreshClient().client.disableNetworkFetches; + + const ssrLoading = { + loading: true, + networkStatus: NetworkStatus.loading, + called: true, + data: undefined, + stale: false, + client: this.client, + ...this.observableQueryFields(), + } as QueryResult; + + // If SSR has been explicitly disabled, and this function has been called + // on the server side, return the default loading state. + if (ssrDisabled && (this.ssrInitiated() || fetchDisabled)) { + this.previous.result = ssrLoading; + return ssrLoading; + } + + if (this.ssrInitiated()) { + const result = this.getExecuteResult() || ssrLoading; + if (result.loading && !skip) { + this.context.renderPromises!.addQueryPromise(this, () => null); + } + return result; + } + } + + private prepareObservableQueryOptions() { + const options = this.getOptions(); + this.verifyDocumentType(options.query, DocumentType.Query); + const displayName = options.displayName || 'Query'; + + // Set the fetchPolicy to cache-first for network-only and cache-and-network + // fetches for server side renders. + if ( + this.ssrInitiated() && + (options.fetchPolicy === 'network-only' || + options.fetchPolicy === 'cache-and-network') + ) { + options.fetchPolicy = 'cache-first'; + } + + return { + ...options, + displayName, + context: options.context, + }; + } + + private initializeObservableQuery() { + // See if there is an existing observable that was used to fetch the same + // data and if so, use it instead since it will contain the proper queryId + // to fetch the result set. This is used during SSR. + if (this.ssrInitiated()) { + this.currentObservable = this.context!.renderPromises!.getSSRObservable( + this.getOptions() + ); + } + + if (!this.currentObservable) { + const observableQueryOptions = this.prepareObservableQueryOptions(); + + this.previous.observableQueryOptions = { + ...observableQueryOptions, + children: void 0, + }; + this.currentObservable = this.refreshClient().client.watchQuery({ + ...observableQueryOptions + }); + + if (this.ssrInitiated()) { + this.context!.renderPromises!.registerSSRObservable( + this.currentObservable, + observableQueryOptions + ); + } + } + } + + private updateObservableQuery() { + // If we skipped initially, we may not have yet created the observable + if (!this.currentObservable) { + this.initializeObservableQuery(); + return; + } + + const newObservableQueryOptions = { + ...this.prepareObservableQueryOptions(), + children: void 0, + }; + + if (this.getOptions().skip) { + this.previous.observableQueryOptions = newObservableQueryOptions; + return; + } + + if ( + !equal(newObservableQueryOptions, this.previous.observableQueryOptions) + ) { + this.previous.observableQueryOptions = newObservableQueryOptions; + this.currentObservable + .setOptions(newObservableQueryOptions) + // The error will be passed to the child container, so we don't + // need to log it here. We could conceivably log something if + // an option was set. OTOH we don't log errors w/ the original + // query. See https://github.com/apollostack/react-apollo/issues/404 + .catch(() => {}); + } + } + + // Setup a subscription to watch for Apollo Client `ObservableQuery` changes. + // When new data is received, and it doesn't match the data that was used + // during the last `QueryData.execute` call (and ultimately the last query + // component render), trigger the `onNewData` callback. If not specified, + // `onNewData` will fallback to the default `QueryData.onNewData` function + // (which usually leads to a query component re-render). + private startQuerySubscription(onNewData: () => void = this.onNewData) { + if (this.currentSubscription || this.getOptions().skip) return; + + this.currentSubscription = this.currentObservable!.subscribe({ + next: ({ loading, networkStatus, data }) => { + const previousResult = this.previous.result; + + // Make sure we're not attempting to re-render similar results + if ( + previousResult && + previousResult.loading === loading && + previousResult.networkStatus === networkStatus && + equal(previousResult.data, data) + ) { + return; + } + + onNewData(); + }, + error: error => { + this.resubscribeToQuery(); + if (!error.hasOwnProperty('graphQLErrors')) throw error; + + const previousResult = this.previous.result; + if ( + (previousResult && previousResult.loading) || + !equal(error, this.previous.error) + ) { + this.previous.error = error; + onNewData(); + } + } + }); + } + + private resubscribeToQuery() { + this.removeQuerySubscription(); + + // Unfortunately, if `lastError` is set in the current + // `observableQuery` when the subscription is re-created, + // the subscription will immediately receive the error, which will + // cause it to terminate again. To avoid this, we first clear + // the last error/result from the `observableQuery` before re-starting + // the subscription, and restore it afterwards (so the subscription + // has a chance to stay open). + const { currentObservable } = this; + if (currentObservable) { + const lastError = currentObservable.getLastError(); + const lastResult = currentObservable.getLastResult(); + currentObservable.resetLastResults(); + this.startQuerySubscription(); + Object.assign(currentObservable, { + lastError, + lastResult + }); + } + } + + private getExecuteResult(): QueryResult { + let result = this.observableQueryFields() as QueryResult; + const options = this.getOptions(); + + // When skipping a query (ie. we're not querying for data but still want + // to render children), make sure the `data` is cleared out and + // `loading` is set to `false` (since we aren't loading anything). + // + // NOTE: We no longer think this is the correct behavior. Skipping should + // not automatically set `data` to `undefined`, but instead leave the + // previous data in place. In other words, skipping should not mandate + // that previously received data is all of a sudden removed. Unfortunately, + // changing this is breaking, so we'll have to wait until Apollo Client + // 4.0 to address this. + if (options.skip) { + result = { + ...result, + data: undefined, + error: undefined, + loading: false, + networkStatus: NetworkStatus.ready, + called: true, + }; + } else if (this.currentObservable) { + // Fetch the current result (if any) from the store. + const currentResult = this.currentObservable.getCurrentResult(); + const { data, loading, partial, networkStatus, errors } = currentResult; + let { error } = currentResult; + + // Until a set naming convention for networkError and graphQLErrors is + // decided upon, we map errors (graphQLErrors) to the error options. + if (errors && errors.length > 0) { + error = new ApolloError({ graphQLErrors: errors }); + } + + result = { + ...result, + data, + loading, + networkStatus, + error, + called: true + }; + + if (loading) { + // Fall through without modifying result... + } else if (error) { + Object.assign(result, { + data: (this.currentObservable.getLastResult() || ({} as any)) + .data + }); + } else { + const { fetchPolicy } = this.currentObservable.options; + const { partialRefetch } = options; + if ( + partialRefetch && + partial && + (!data || Object.keys(data).length === 0) && + fetchPolicy !== 'cache-only' + ) { + // When a `Query` component is mounted, and a mutation is executed + // that returns the same ID as the mounted `Query`, but has less + // fields in its result, Apollo Client's `QueryManager` returns the + // data as `undefined` since a hit can't be found in the cache. + // This can lead to application errors when the UI elements rendered by + // the original `Query` component are expecting certain data values to + // exist, and they're all of a sudden stripped away. To help avoid + // this we'll attempt to refetch the `Query` data. + Object.assign(result, { + loading: true, + networkStatus: NetworkStatus.loading + }); + result.refetch(); + return result; + } + } + } + + result.client = this.client; + // Store options as this.previousOptions. + this.setOptions(options, true); + + const previousResult = this.previous.result; + + this.previous.loading = + previousResult && previousResult.loading || false; + + // Ensure the returned result contains previousData as a separate + // property, to give developers the flexibility of leveraging outdated + // data while new data is loading from the network. Falling back to + // previousResult.previousData when previousResult.data is falsy here + // allows result.previousData to persist across multiple results. + result.previousData = previousResult && + (previousResult.data || previousResult.previousData); + + this.previous.result = result; + + // Any query errors that exist are now available in `result`, so we'll + // remove the original errors from the `ObservableQuery` query store to + // make sure they aren't re-displayed on subsequent (potentially error + // free) requests/responses. + this.currentObservable && this.currentObservable.resetQueryStoreErrors(); + + return result; + } + + private handleErrorOrCompleted() { + if (!this.currentObservable || !this.previous.result) return; + + const { data, loading, error } = this.previous.result; + + if (!loading) { + const { + query, + variables, + onCompleted, + onError, + skip + } = this.getOptions(); + + // No changes, so we won't call onError/onCompleted. + if ( + this.previousOptions && + !this.previous.loading && + equal(this.previousOptions.query, query) && + equal(this.previousOptions.variables, variables) + ) { + return; + } + + if (onCompleted && !error && !skip) { + onCompleted(data as TData); + } else if (onError && error) { + onError(error); + } + } + } + + private removeQuerySubscription() { + if (this.currentSubscription) { + this.currentSubscription.unsubscribe(); + delete this.currentSubscription; + } + } + + private removeObservable(andDelete: boolean) { + if (this.currentObservable) { + this.currentObservable["tearDownQuery"](); + if (andDelete) { + delete this.currentObservable; + } + } + } + + private obsRefetch = (variables?: Partial) => + this.currentObservable?.refetch(variables); + + private obsFetchMore = ( + fetchMoreOptions: FetchMoreQueryOptions & + FetchMoreOptions + ) => this.currentObservable!.fetchMore(fetchMoreOptions); + + private obsUpdateQuery = ( + mapFn: ( + previousQueryResult: TData, + options: UpdateQueryOptions + ) => TData + ) => this.currentObservable!.updateQuery(mapFn); + + private obsStartPolling = (pollInterval: number) => { + this.currentObservable?.startPolling(pollInterval); + }; + + private obsStopPolling = () => { + this.currentObservable?.stopPolling(); + }; + + private obsSubscribeToMore = < + TSubscriptionData = TData, + TSubscriptionVariables = TVariables + >( + options: SubscribeToMoreOptions< + TData, + TSubscriptionVariables, + TSubscriptionData + > + ) => this.currentObservable!.subscribeToMore(options); + + private observableQueryFields() { + return { + variables: this.currentObservable?.variables, + refetch: this.obsRefetch, + fetchMore: this.obsFetchMore, + updateQuery: this.obsUpdateQuery, + startPolling: this.obsStartPolling, + stopPolling: this.obsStopPolling, + subscribeToMore: this.obsSubscribeToMore + } as ObservableQueryFields; + } +} export function useQuery( query: DocumentNode | TypedDocumentNode, - options?: QueryHookOptions + options?: QueryHookOptions, ) { - return useBaseQuery(query, options, false) as QueryResult< - TData, - TVariables - >; + const context = useContext(getApolloContext()); + const client = options?.client || context.client; + invariant( + !!client, + 'Could not find "client" in the context or passed in as an option. ' + + 'Wrap the root component in an , or pass an ' + + 'ApolloClient instance in via options.' + ); + + const [tick, forceUpdate] = useReducer(x => x + 1, 0); + const updatedOptions: QueryDataOptions + = options ? { ...options, query } : { query }; + + const queryDataRef = useRef>(); + const queryData = queryDataRef.current || ( + queryDataRef.current = new QueryData({ + options: updatedOptions, + context, + onNewData() { + if (!queryData.ssrInitiated()) { + // When new data is received from the `QueryData` object, we want to + // force a re-render to make sure the new data is displayed. We can't + // force that re-render if we're already rendering however so to be + // safe we'll trigger the re-render in a microtask. In case the + // component gets unmounted before this callback fires, we re-check + // queryDataRef.current.isMounted before calling forceUpdate(). + Promise.resolve().then(() => queryDataRef.current && queryDataRef.current.isMounted && forceUpdate()); + } else { + // If we're rendering on the server side we can force an update at + // any point. + forceUpdate(); + } + } + }) + ); + + queryData.setOptions(updatedOptions); + queryData.context = context; + const result = useDeepMemo( + () => queryData.execute(), + [updatedOptions, context, tick], + ); + + const queryResult = (result as QueryResult); + + if (__DEV__) { + // ensure we run an update after refreshing so that we reinitialize + useAfterFastRefresh(forceUpdate); + } + + useEffect(() => { + return () => { + queryData.cleanup(); + // this effect can run multiple times during a fast-refresh + // so make sure we clean up the ref + queryDataRef.current = void 0; + } + }, []); + + useEffect(() => queryData.afterExecute(), [ + queryResult.loading, + queryResult.networkStatus, + queryResult.error, + queryResult.data, + ]); + + return result; } From 599cd40352fc14b3cb7d8339d39a18e68a7fbc57 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 23 Jul 2021 19:09:34 -0400 Subject: [PATCH 09/65] Stop delaying useQuery calls by a microtask After https://github.com/apollographql/apollo-client/pull/8414, the changes made in https://github.com/apollographql/apollo-client/pull/6107 are unnneccessary, because all ObservableQuery callbacks will only be fired in useEffect calls (hopefully). This changes the timings of some of tests. --- src/react/hooks/__tests__/useQuery.test.tsx | 33 +++++++-------------- src/react/hooks/useQuery.ts | 12 ++------ 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 1fda753e9b8..b42155c5ac2 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -313,7 +313,6 @@ describe('useQuery Hook', () => { variables: { something } }, result: { data: CAR_RESULT_DATA }, - delay: 1000 })); let renderCount = 0; @@ -1310,7 +1309,7 @@ describe('useQuery Hook', () => { expect(result.current.loading).toBe(false); expect(result.current.networkStatus).toBe(NetworkStatus.ready); expect(result.current.data).toEqual({ letters: ab }); - result.current.fetchMore({ + act(() => void result.current.fetchMore({ variables: { limit: 2 }, updateQuery: (prev, { fetchMoreResult }) => ({ letters: prev.letters.concat(fetchMoreResult.letters), @@ -1351,14 +1350,13 @@ describe('useQuery Hook', () => { expect(result.current.networkStatus).toBe(NetworkStatus.ready); expect(result.current.data).toEqual({ letters: ab }); - result.current.fetchMore({ + act(() => void result.current.fetchMore({ variables: { limit: 2 }, updateQuery: (prev, { fetchMoreResult }) => ({ letters: prev.letters.concat(fetchMoreResult.letters), }), - }); + })); - await waitForNextUpdate(); expect(result.current.loading).toBe(true); expect(result.current.networkStatus).toBe(NetworkStatus.fetchMore); expect(result.current.data).toEqual({ letters: ab }); @@ -1439,9 +1437,7 @@ describe('useQuery Hook', () => { expect(result.current.networkStatus).toBe(NetworkStatus.ready); expect(result.current.data).toEqual({ letters: ab }); - result.current.fetchMore({ variables: { limit: 2 } }); - - await waitForNextUpdate(); + act(() => void result.current.fetchMore({ variables: { limit: 2 } })); expect(result.current.loading).toBe(true); expect(result.current.networkStatus).toBe(NetworkStatus.fetchMore); expect(result.current.data).toEqual({ letters: ab }); @@ -2145,7 +2141,7 @@ describe('useQuery Hook', () => { { request: { query: mutation }, error: new Error('Oh no!'), - delay: 10, + delay: 500, } ]; @@ -2201,13 +2197,15 @@ describe('useQuery Hook', () => { act(() => void mutate()); // The mutation ran and is loading the result. The query stays at not - // loading as nothing has changed for the query. + // loading as nothing has changed for the query, but optimistic data is + // rendered. + expect(result.current.mutation[1].loading).toBe(true); expect(result.current.query.loading).toBe(false); - + expect(result.current.query.data).toEqual(allCarsData); await waitForNextUpdate(); - // There is a missing update here because mutation and query update in - // the same microtask loop. + // TODO: There is a missing update here because mutation and query update + // in the same microtask loop. const previous = result.all[result.all.length - 2]; if (previous instanceof Error) { throw previous; @@ -2218,15 +2216,6 @@ describe('useQuery Hook', () => { expect(previous.mutation[1].loading).toBe(true); expect(previous.query.loading).toBe(false); - // The first part of the mutation has completed using the defined - // optimisticResponse data. This means that while the mutation stays in a - // loading state, it has made its optimistic data available to the query. - // New optimistic data doesn't trigger a query loading state. - expect(result.current.mutation[1].loading).toBe(true); - expect(result.current.query.loading).toBe(false); - expect(result.current.query.data).toEqual(allCarsData); - - await waitForNextUpdate(); // The mutation has completely finished, leaving the query with access to // the original cache data. expect(result.current.mutation[1].loading).toBe(false); diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 72e0899b947..241379465a9 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -587,18 +587,12 @@ export function useQuery( options: updatedOptions, context, onNewData() { - if (!queryData.ssrInitiated()) { - // When new data is received from the `QueryData` object, we want to - // force a re-render to make sure the new data is displayed. We can't - // force that re-render if we're already rendering however so to be - // safe we'll trigger the re-render in a microtask. In case the - // component gets unmounted before this callback fires, we re-check - // queryDataRef.current.isMounted before calling forceUpdate(). - Promise.resolve().then(() => queryDataRef.current && queryDataRef.current.isMounted && forceUpdate()); - } else { + if (queryData.ssrInitiated()) { // If we're rendering on the server side we can force an update at // any point. forceUpdate(); + } else if (queryDataRef.current && queryDataRef.current.isMounted) { + forceUpdate(); } } }) From ba37f54801723c90b5b325d891766f307cb2d9a6 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 26 Jul 2021 14:54:19 -0400 Subject: [PATCH 10/65] fix missing paren --- src/react/hooks/__tests__/useQuery.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index b42155c5ac2..ace495ec9d3 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -1314,7 +1314,7 @@ describe('useQuery Hook', () => { updateQuery: (prev, { fetchMoreResult }) => ({ letters: prev.letters.concat(fetchMoreResult.letters), }), - }); + })); await waitForNextUpdate(); expect(result.current.loading).toBe(false); From b765499b8e26937d9dbcdb0dd97f28e85a9aaa94 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 26 Jul 2021 18:05:53 -0400 Subject: [PATCH 11/65] delete some unnecessary things --- src/react/hooks/useQuery.ts | 166 +++++++++++++++--------------------- 1 file changed, 69 insertions(+), 97 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 241379465a9..c9895085a34 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -16,11 +16,9 @@ import { TypedDocumentNode, } from '../../core'; import { - CommonOptions, QueryDataOptions, QueryHookOptions, QueryResult, - QueryLazyOptions, ObservableQueryFields, } from '../types/types'; @@ -32,70 +30,52 @@ import { DocumentType, parser, operationName } from '../parser'; import { useDeepMemo } from './utils/useDeepMemo'; import { useAfterFastRefresh } from './utils/useAfterFastRefresh'; -type ObservableQueryOptions = - ReturnType["prepareObservableQueryOptions"]>; +function verifyDocumentType(document: DocumentNode, type: DocumentType) { + const operation = parser(document); + const requiredOperationName = operationName(type); + const usedOperationName = operationName(operation.type); + invariant( + operation.type === type, + `Running a ${requiredOperationName} requires a graphql ` + + `${requiredOperationName}, but a ${usedOperationName} was used instead.` + ); +} + +// These are the options which are actually used by +interface ObservableQueryOptions { + query: DocumentNode | TypedDocumentNode; // QueryDataOptions + displayName?: string; // QueryFunctionOptions + context?: unknown; // BaseQueryOptions/QueryOptions + children?: unknown; // QueryDataOptions +}; -class QueryData = any> { +class QueryData { public isMounted: boolean = false; - public previousOptions: CommonOptions - = {} as CommonOptions; public context: any = {}; - public client: ApolloClient; - private options: CommonOptions = {} as CommonOptions; + public previousOptions = {} as QueryDataOptions; - public setOptions( - newOptions: CommonOptions, - storePrevious: boolean = false - ) { - if (storePrevious && !equal(this.options, newOptions)) { + private options = {} as QueryDataOptions; + + public getOptions(): QueryDataOptions { + return this.options; + } + + public setOptions(newOptions: QueryDataOptions) { + if (!equal(this.options, newOptions)) { this.previousOptions = this.options; } + this.options = newOptions; } - protected unmount() { + private unmount() { this.isMounted = false; } - protected refreshClient() { - const client = - (this.options && this.options.client) || - (this.context && this.context.client); - - invariant( - !!client, - 'Could not find "client" in the context or passed in as an option. ' + - 'Wrap the root component in an , or pass an ' + - 'ApolloClient instance in via options.' - ); - - let isNew = false; - if (client !== this.client) { - isNew = true; - this.client = client; - this.cleanup(); - } - return { - client: this.client as ApolloClient, - isNew - }; - } - - protected verifyDocumentType(document: DocumentNode, type: DocumentType) { - const operation = parser(document); - const requiredOperationName = operationName(type); - const usedOperationName = operationName(operation.type); - invariant( - operation.type === type, - `Running a ${requiredOperationName} requires a graphql ` + - `${requiredOperationName}, but a ${usedOperationName} was used instead.` - ); - } public onNewData: () => void; private currentObservable?: ObservableQuery; private currentSubscription?: ObservableSubscription; - private lazyOptions?: QueryLazyOptions; private previous: { client?: ApolloClient; query?: DocumentNode | TypedDocumentNode; @@ -111,40 +91,46 @@ class QueryData; context: any; onNewData: () => void; }) { - this.options = options || ({} as CommonOptions); + this.options = options || ({} as QueryDataOptions); this.context = context || {}; this.onNewData = onNewData; } - public execute(): QueryResult { - this.refreshClient(); + public execute(client: ApolloClient): QueryResult { + const { skip, query } = this.options; + if (this.previous.client !== client) { + if (this.previous.client) { + this.cleanup(); + } + + this.previous.client = client; + } - const { skip, query } = this.getOptions(); if (skip || query !== this.previous.query) { this.removeQuerySubscription(); this.removeObservable(!skip); this.previous.query = query; } - this.updateObservableQuery(); + this.updateObservableQuery(client); - return this.getExecuteSsrResult() || this.getExecuteResult(); + return this.getExecuteSsrResult(client) || this.getExecuteResult(client); } // For server-side rendering public fetchData(): Promise | boolean { - const options = this.getOptions(); + const options = this.options; if (options.skip || options.ssr === false) return false; return new Promise(resolve => this.startQuerySubscription(resolve)); } public afterExecute() { this.isMounted = true; - const options = this.getOptions(); + const options = this.options; const ssrDisabled = options.ssr === false; if ( this.currentObservable && @@ -165,38 +151,22 @@ class QueryData) { + const { ssr, skip } = this.options; const ssrDisabled = ssr === false; - const fetchDisabled = this.refreshClient().client.disableNetworkFetches; - + const fetchDisabled = client.disableNetworkFetches; const ssrLoading = { loading: true, networkStatus: NetworkStatus.loading, called: true, data: undefined, stale: false, - client: this.client, + client, ...this.observableQueryFields(), } as QueryResult; @@ -208,7 +178,7 @@ class QueryData null); } @@ -216,9 +186,9 @@ class QueryData { + const options = this.options; + verifyDocumentType(options.query, DocumentType.Query); const displayName = options.displayName || 'Query'; // Set the fetchPolicy to cache-first for network-only and cache-and-network @@ -238,13 +208,13 @@ class QueryData) { // See if there is an existing observable that was used to fetch the same // data and if so, use it instead since it will contain the proper queryId // to fetch the result set. This is used during SSR. if (this.ssrInitiated()) { this.currentObservable = this.context!.renderPromises!.getSSRObservable( - this.getOptions() + this.options, ); } @@ -255,7 +225,8 @@ class QueryData) { // If we skipped initially, we may not have yet created the observable if (!this.currentObservable) { - this.initializeObservableQuery(); + this.initializeObservableQuery(client); return; } @@ -280,7 +251,7 @@ class QueryData void = this.onNewData) { - if (this.currentSubscription || this.getOptions().skip) return; + if (this.currentSubscription || this.options.skip) return; this.currentSubscription = this.currentObservable!.subscribe({ next: ({ loading, networkStatus, data }) => { @@ -363,9 +334,11 @@ class QueryData { + private getExecuteResult( + client: ApolloClient, + ): QueryResult { let result = this.observableQueryFields() as QueryResult; - const options = this.getOptions(); + const options = this.options; // When skipping a query (ie. we're not querying for data but still want // to render children), make sure the `data` is cleared out and @@ -441,10 +414,9 @@ class QueryData( queryData.setOptions(updatedOptions); queryData.context = context; const result = useDeepMemo( - () => queryData.execute(), + () => queryData.execute(client), [updatedOptions, context, tick], ); From 04209ffbc4a1d0ec55062be413f9eb6792cc2c41 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 26 Jul 2021 18:36:11 -0400 Subject: [PATCH 12/65] =?UTF-8?q?don=E2=80=99t=20have=20a=20separate=20pre?= =?UTF-8?q?viousOptions=20property?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/react/hooks/useQuery.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index c9895085a34..f2df14e1d8d 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -52,18 +52,16 @@ interface ObservableQueryOptions { class QueryData { public isMounted: boolean = false; public context: any = {}; - - public previousOptions = {} as QueryDataOptions; - private options = {} as QueryDataOptions; + // TODO: This is still called by ssr stuff ;_; public getOptions(): QueryDataOptions { return this.options; } public setOptions(newOptions: QueryDataOptions) { if (!equal(this.options, newOptions)) { - this.previousOptions = this.options; + this.previous.options = this.options; } this.options = newOptions; @@ -73,7 +71,7 @@ class QueryData { this.isMounted = false; } - public onNewData: () => void; + private onNewData: () => void; private currentObservable?: ObservableQuery; private currentSubscription?: ObservableSubscription; private previous: { @@ -141,7 +139,6 @@ class QueryData { } this.handleErrorOrCompleted(); - this.previousOptions = options; return this.unmount.bind(this); } @@ -415,7 +412,6 @@ class QueryData { } result.client = client; - // Store options as this.previousOptions. this.setOptions(options); const previousResult = this.previous.result; @@ -457,10 +453,10 @@ class QueryData { // No changes, so we won't call onError/onCompleted. if ( - this.previousOptions && + this.previous.options && !this.previous.loading && - equal(this.previousOptions.query, query) && - equal(this.previousOptions.variables, variables) + equal(this.previous.options.query, query) && + equal(this.previous.options.variables, variables) ) { return; } From 9a67046c330f40a9808c32c054baf5cab25ab2b1 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 27 Jul 2021 16:09:31 -0400 Subject: [PATCH 13/65] add an explicit return type to RenderPromises.getSSRObservable --- src/react/ssr/RenderPromises.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/ssr/RenderPromises.ts b/src/react/ssr/RenderPromises.ts index b2171ac9e1e..6bc06826051 100644 --- a/src/react/ssr/RenderPromises.ts +++ b/src/react/ssr/RenderPromises.ts @@ -47,7 +47,7 @@ export class RenderPromises { // Get's the cached observable that matches the SSR Query instances query and variables. public getSSRObservable( props: QueryDataOptions - ) { + ): ObservableQuery | null { return this.lookupQueryInfo(props).observable; } From 2bd2d2b1076afdb3209f36d5f24997eca05841ef Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 27 Jul 2021 16:32:56 -0400 Subject: [PATCH 14/65] crack some eggs Preparing to refactor QueryData away by trying to simplify some of its logic and inlining a majority of its methods. --- src/react/hooks/useQuery.ts | 431 ++++++++++++++++-------------------- 1 file changed, 195 insertions(+), 236 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index f2df14e1d8d..2070828fe50 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -2,7 +2,7 @@ import { useContext, useEffect, useReducer, useRef } from 'react'; import { invariant } from 'ts-invariant'; import { equal } from '@wry/equality'; import { OperationVariables } from '../../core'; -import { getApolloContext } from '../context'; +import { ApolloContextValue, getApolloContext } from '../context'; import { ApolloError } from '../../errors'; import { ApolloClient, @@ -41,46 +41,30 @@ function verifyDocumentType(document: DocumentNode, type: DocumentType) { ); } -// These are the options which are actually used by -interface ObservableQueryOptions { - query: DocumentNode | TypedDocumentNode; // QueryDataOptions - displayName?: string; // QueryFunctionOptions - context?: unknown; // BaseQueryOptions/QueryOptions - children?: unknown; // QueryDataOptions -}; + +// The interface expected by RenderPromises +interface QueryData { + getOptions(): QueryDataOptions; + fetchData(): Promise | boolean; +} class QueryData { - public isMounted: boolean = false; - public context: any = {}; + public isMounted: boolean; + public context: ApolloContextValue; private options = {} as QueryDataOptions; - - // TODO: This is still called by ssr stuff ;_; - public getOptions(): QueryDataOptions { - return this.options; - } - - public setOptions(newOptions: QueryDataOptions) { - if (!equal(this.options, newOptions)) { - this.previous.options = this.options; - } - - this.options = newOptions; - } - - private unmount() { - this.isMounted = false; - } - private onNewData: () => void; private currentObservable?: ObservableQuery; private currentSubscription?: ObservableSubscription; private previous: { client?: ApolloClient; query?: DocumentNode | TypedDocumentNode; - observableQueryOptions?: ObservableQueryOptions; + options?: QueryDataOptions; + // TODO(brian): WHAT IS THE DIFFERENCE??????????? + observableQueryOptions?: QueryDataOptions; + // TODO(brian): previous.result should be assigned once in an update, we + // shouldn’t have loading/error defined separately result?: QueryResult; loading?: boolean; - options?: QueryDataOptions; error?: ApolloError; } = Object.create(null); @@ -90,70 +74,42 @@ class QueryData { onNewData }: { options: QueryDataOptions; - context: any; + context: ApolloContextValue; onNewData: () => void; }) { this.options = options || ({} as QueryDataOptions); this.context = context || {}; this.onNewData = onNewData; + this.isMounted = false; } - public execute(client: ApolloClient): QueryResult { - const { skip, query } = this.options; - if (this.previous.client !== client) { - if (this.previous.client) { - this.cleanup(); - } - - this.previous.client = client; - } + // Called by RenderPromises + public getOptions(): QueryDataOptions { + return this.options; + } - if (skip || query !== this.previous.query) { - this.removeQuerySubscription(); - this.removeObservable(!skip); - this.previous.query = query; + public setOptions(newOptions: QueryDataOptions) { + if (!equal(this.options, newOptions)) { + this.previous.options = this.options; } - this.updateObservableQuery(client); - - return this.getExecuteSsrResult(client) || this.getExecuteResult(client); + this.options = newOptions; } - // For server-side rendering + // Called by RenderPromises public fetchData(): Promise | boolean { const options = this.options; if (options.skip || options.ssr === false) return false; return new Promise(resolve => this.startQuerySubscription(resolve)); } - public afterExecute() { - this.isMounted = true; - const options = this.options; - const ssrDisabled = options.ssr === false; - if ( - this.currentObservable && - !ssrDisabled && - !this.ssrInitiated() - ) { - this.startQuerySubscription(); - } - - this.handleErrorOrCompleted(); - return this.unmount.bind(this); - } - - public cleanup() { - this.removeQuerySubscription(); - this.removeObservable(true); - delete this.previous.result; - } - - - public ssrInitiated() { + private ssrInitiated() { return this.context && this.context.renderPromises; } - private getExecuteSsrResult(client: ApolloClient) { + private getExecuteSsrResult( + client: ApolloClient + ): QueryResult | undefined { const { ssr, skip } = this.options; const ssrDisabled = ssr === false; const fetchDisabled = client.disableNetworkFetches; @@ -183,88 +139,142 @@ class QueryData { } } - private prepareObservableQueryOptions(): ObservableQueryOptions { - const options = this.options; - verifyDocumentType(options.query, DocumentType.Query); - const displayName = options.displayName || 'Query'; - - // Set the fetchPolicy to cache-first for network-only and cache-and-network - // fetches for server side renders. - if ( - this.ssrInitiated() && - (options.fetchPolicy === 'network-only' || - options.fetchPolicy === 'cache-and-network') - ) { - options.fetchPolicy = 'cache-first'; + public cleanup() { + if (this.currentSubscription) { + this.currentSubscription.unsubscribe(); + delete this.currentSubscription; } - return { - ...options, - displayName, - context: options.context, - }; + if (this.currentObservable) { + // TODO(brian): HISSSSSSSSSSSSSSSSSSSSSSS BAD HISSSSSSSSSSSSSSSS + this.currentObservable["tearDownQuery"](); + delete this.currentObservable; + } } - private initializeObservableQuery(client: ApolloClient) { - // See if there is an existing observable that was used to fetch the same - // data and if so, use it instead since it will contain the proper queryId - // to fetch the result set. This is used during SSR. - if (this.ssrInitiated()) { - this.currentObservable = this.context!.renderPromises!.getSSRObservable( - this.options, - ); - } + public execute(client: ApolloClient): QueryResult { + const { skip, query } = this.options; + if (this.previous.client !== client) { + if (this.previous.client) { + this.cleanup(); + delete this.previous.result; + } - if (!this.currentObservable) { - const observableQueryOptions = this.prepareObservableQueryOptions(); + this.previous.client = client; + } - this.previous.observableQueryOptions = { - ...observableQueryOptions, - children: void 0, - }; + if (skip || query !== this.previous.query) { + this.cleanup(); + this.previous = Object.create(null); + this.previous.query = query; + } - this.currentObservable = client.watchQuery({ - ...observableQueryOptions - }); + verifyDocumentType(this.options.query, DocumentType.Query); + const observableQueryOptions = { + ...this.options, + fetchPolicy: + this.context && + this.context.renderPromises && + ( + this.options.fetchPolicy === 'network-only' || + this.options.fetchPolicy === 'cache-and-network' + ) + ? 'cache-first' + : this.options.fetchPolicy, + displayName: this.options.displayName || 'Query', + children: void 0, + }; - if (this.ssrInitiated()) { - this.context!.renderPromises!.registerSSRObservable( - this.currentObservable, - observableQueryOptions + // If we skipped initially, we may not have yet created the observable + if (!this.currentObservable) { + // See if there is an existing observable that was used to fetch the same + // data and if so, use it instead since it will contain the proper + // queryId to fetch the result set. This is used during SSR. + if (this.context && this.context.renderPromises) { + this.currentObservable = this.context.renderPromises.getSSRObservable( + this.options, ); } + + if (!this.currentObservable) { + this.previous.observableQueryOptions = observableQueryOptions; + this.currentObservable = client.watchQuery(observableQueryOptions); + + if (this.context && this.context.renderPromises) { + this.context.renderPromises.registerSSRObservable( + this.currentObservable, + observableQueryOptions + ); + } + } + } else { + if (this.options.skip) { + this.previous.observableQueryOptions = observableQueryOptions; + } else if ( + !equal(observableQueryOptions, this.previous.observableQueryOptions) + ) { + this.previous.observableQueryOptions = observableQueryOptions; + this.currentObservable + .setOptions(observableQueryOptions) + // The error will be passed to the child container, so we don't + // need to log it here. We could conceivably log something if + // an option was set. OTOH we don't log errors w/ the original + // query. See https://github.com/apollostack/react-apollo/issues/404 + .catch(() => {}); + } } + + // TODO(brian): No. + return this.getExecuteSsrResult(client) || this.getExecuteResult(client); } - private updateObservableQuery(client: ApolloClient) { - // If we skipped initially, we may not have yet created the observable - if (!this.currentObservable) { - this.initializeObservableQuery(client); - return; + public afterExecute() { + this.isMounted = true; + const options = this.options; + const ssrDisabled = options.ssr === false; + // TODO(brian): WHY WOULD this.currentObservable BE UNDEFINED HERE???????? + if ( + this.currentObservable && + !ssrDisabled && + !(this.context && this.context.renderPromises) + ) { + this.startQuerySubscription(); } - const newObservableQueryOptions = { - ...this.prepareObservableQueryOptions(), - children: void 0, - }; + if (this.currentObservable && this.previous.result) { + const { data, loading, error } = this.previous.result; + if (!loading) { + const { + query, + variables, + onCompleted, + onError, + skip + } = this.options; + + // No changes, so we won't call onError/onCompleted. + if ( + this.previous.options && + !this.previous.loading && + equal(this.previous.options.query, query) && + equal(this.previous.options.variables, variables) + ) { + return; + } - if (this.options.skip) { - this.previous.observableQueryOptions = newObservableQueryOptions; - return; + // TODO(brian): Why would we not fire onCompleted on skip? + // Why would we not apply the same logic for onError? + if (onCompleted && !error && !skip) { + onCompleted(data as TData); + } else if (onError && error) { + onError(error); + } + } } - if ( - !equal(newObservableQueryOptions, this.previous.observableQueryOptions) - ) { - this.previous.observableQueryOptions = newObservableQueryOptions; - this.currentObservable - .setOptions(newObservableQueryOptions) - // The error will be passed to the child container, so we don't - // need to log it here. We could conceivably log something if - // an option was set. OTOH we don't log errors w/ the original - // query. See https://github.com/apollostack/react-apollo/issues/404 - .catch(() => {}); - } + return () => { + this.isMounted = false; + }; } // Setup a subscription to watch for Apollo Client `ObservableQuery` changes. @@ -293,7 +303,31 @@ class QueryData { onNewData(); }, error: error => { - this.resubscribeToQuery(); + if (this.currentSubscription) { + this.currentSubscription.unsubscribe(); + delete this.currentSubscription; + } + + // Unfortunately, if `lastError` is set in the current + // `observableQuery` when the subscription is re-created, + // the subscription will immediately receive the error, which will + // cause it to terminate again. To avoid this, we first clear + // the last error/result from the `observableQuery` before re-starting + // the subscription, and restore it afterwards (so the subscription + // has a chance to stay open). + const { currentObservable } = this; + if (currentObservable) { + // TODO(brian): WHAT THE FUCK + const lastError = currentObservable.getLastError(); + const lastResult = currentObservable.getLastResult(); + currentObservable.resetLastResults(); + this.startQuerySubscription(); + Object.assign(currentObservable, { + lastError, + lastResult + }); + } + if (!error.hasOwnProperty('graphQLErrors')) throw error; const previousResult = this.previous.result; @@ -308,33 +342,10 @@ class QueryData { }); } - private resubscribeToQuery() { - this.removeQuerySubscription(); - - // Unfortunately, if `lastError` is set in the current - // `observableQuery` when the subscription is re-created, - // the subscription will immediately receive the error, which will - // cause it to terminate again. To avoid this, we first clear - // the last error/result from the `observableQuery` before re-starting - // the subscription, and restore it afterwards (so the subscription - // has a chance to stay open). - const { currentObservable } = this; - if (currentObservable) { - const lastError = currentObservable.getLastError(); - const lastResult = currentObservable.getLastResult(); - currentObservable.resetLastResults(); - this.startQuerySubscription(); - Object.assign(currentObservable, { - lastError, - lastResult - }); - } - } - private getExecuteResult( client: ApolloClient, ): QueryResult { - let result = this.observableQueryFields() as QueryResult; + const result = this.observableQueryFields() as QueryResult; const options = this.options; // When skipping a query (ie. we're not querying for data but still want @@ -348,14 +359,14 @@ class QueryData { // changing this is breaking, so we'll have to wait until Apollo Client // 4.0 to address this. if (options.skip) { - result = { - ...result, + + Object.assign(result, { data: undefined, error: undefined, loading: false, networkStatus: NetworkStatus.ready, called: true, - }; + }); } else if (this.currentObservable) { // Fetch the current result (if any) from the store. const currentResult = this.currentObservable.getCurrentResult(); @@ -368,14 +379,13 @@ class QueryData { error = new ApolloError({ graphQLErrors: errors }); } - result = { - ...result, + Object.assign(result, { data, loading, networkStatus, error, called: true - }; + }); if (loading) { // Fall through without modifying result... @@ -415,6 +425,7 @@ class QueryData { this.setOptions(options); const previousResult = this.previous.result; + // TODO(brian): WHAT THE FUCK this.previous.loading = previousResult && previousResult.loading || false; @@ -426,6 +437,7 @@ class QueryData { result.previousData = previousResult && (previousResult.data || previousResult.previousData); + // TODO(brian): WHY IS THIS ASSIGNED HERE this.previous.result = result; // Any query errors that exist are now available in `result`, so we'll @@ -437,54 +449,7 @@ class QueryData { return result; } - private handleErrorOrCompleted() { - if (!this.currentObservable || !this.previous.result) return; - - const { data, loading, error } = this.previous.result; - - if (!loading) { - const { - query, - variables, - onCompleted, - onError, - skip - } = this.options; - - // No changes, so we won't call onError/onCompleted. - if ( - this.previous.options && - !this.previous.loading && - equal(this.previous.options.query, query) && - equal(this.previous.options.variables, variables) - ) { - return; - } - - if (onCompleted && !error && !skip) { - onCompleted(data as TData); - } else if (onError && error) { - onError(error); - } - } - } - - private removeQuerySubscription() { - if (this.currentSubscription) { - this.currentSubscription.unsubscribe(); - delete this.currentSubscription; - } - } - - private removeObservable(andDelete: boolean) { - if (this.currentObservable) { - this.currentObservable["tearDownQuery"](); - if (andDelete) { - delete this.currentObservable; - } - } - } - + // observableQueryFields private obsRefetch = (variables?: Partial) => this.currentObservable?.refetch(variables); @@ -546,8 +511,10 @@ export function useQuery( ); const [tick, forceUpdate] = useReducer(x => x + 1, 0); - const updatedOptions: QueryDataOptions - = options ? { ...options, query } : { query }; + const updatedOptions: QueryDataOptions = { + ...options, + query, + }; const queryDataRef = useRef>(); const queryData = queryDataRef.current || ( @@ -555,11 +522,7 @@ export function useQuery( options: updatedOptions, context, onNewData() { - if (queryData.ssrInitiated()) { - // If we're rendering on the server side we can force an update at - // any point. - forceUpdate(); - } else if (queryDataRef.current && queryDataRef.current.isMounted) { + if (queryDataRef.current && queryDataRef.current.isMounted) { forceUpdate(); } } @@ -573,27 +536,23 @@ export function useQuery( [updatedOptions, context, tick], ); - const queryResult = (result as QueryResult); - if (__DEV__) { // ensure we run an update after refreshing so that we reinitialize useAfterFastRefresh(forceUpdate); } - useEffect(() => { - return () => { - queryData.cleanup(); - // this effect can run multiple times during a fast-refresh - // so make sure we clean up the ref - queryDataRef.current = void 0; - } + useEffect(() => () => { + queryData.cleanup(); + // this effect can run multiple times during a fast-refresh so make sure + // we clean up the ref + queryDataRef.current = void 0; }, []); useEffect(() => queryData.afterExecute(), [ - queryResult.loading, - queryResult.networkStatus, - queryResult.error, - queryResult.data, + result.loading, + result.networkStatus, + result.error, + result.data, ]); return result; From 9bc6a64b5777ba7e04d47cca7d0c2704f0aeb34d Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 27 Jul 2021 17:54:19 -0400 Subject: [PATCH 15/65] inline getExecuteSsrResult --- src/react/hooks/useQuery.ts | 281 ++++++++++++++++++------------------ 1 file changed, 138 insertions(+), 143 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 2070828fe50..3f251577698 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -83,7 +83,6 @@ class QueryData { this.isMounted = false; } - // Called by RenderPromises public getOptions(): QueryDataOptions { return this.options; } @@ -96,7 +95,6 @@ class QueryData { this.options = newOptions; } - // Called by RenderPromises public fetchData(): Promise | boolean { const options = this.options; if (options.skip || options.ssr === false) return false; @@ -107,38 +105,6 @@ class QueryData { return this.context && this.context.renderPromises; } - private getExecuteSsrResult( - client: ApolloClient - ): QueryResult | undefined { - const { ssr, skip } = this.options; - const ssrDisabled = ssr === false; - const fetchDisabled = client.disableNetworkFetches; - const ssrLoading = { - loading: true, - networkStatus: NetworkStatus.loading, - called: true, - data: undefined, - stale: false, - client, - ...this.observableQueryFields(), - } as QueryResult; - - // If SSR has been explicitly disabled, and this function has been called - // on the server side, return the default loading state. - if (ssrDisabled && (this.ssrInitiated() || fetchDisabled)) { - this.previous.result = ssrLoading; - return ssrLoading; - } - - if (this.ssrInitiated()) { - const result = this.getExecuteResult(client) || ssrLoading; - if (result.loading && !skip) { - this.context.renderPromises!.addQueryPromise(this, () => null); - } - return result; - } - } - public cleanup() { if (this.currentSubscription) { this.currentSubscription.unsubscribe(); @@ -224,10 +190,145 @@ class QueryData { } } - // TODO(brian): No. - return this.getExecuteSsrResult(client) || this.getExecuteResult(client); + // getExecuteSsrResult + const { ssr } = this.options; + const ssrDisabled = ssr === false; + const fetchDisabled = client.disableNetworkFetches; + const ssrLoading = { + ...this.observableQueryFields(), + loading: true, + networkStatus: NetworkStatus.loading, + called: true, + data: undefined, + stale: false, + client, + } as QueryResult; + + // If SSR has been explicitly disabled, and this function has been called + // on the server side, return the default loading state. + if (ssrDisabled && (this.ssrInitiated() || fetchDisabled)) { + // TODO(brian): Don’t assign this here. + this.previous.result = ssrLoading; + return ssrLoading; + } + + const result = this.getExecuteResult(client); + + if (this.ssrInitiated() && result.loading && !skip) { + this.context.renderPromises!.addQueryPromise(this, () => null); + } + + return result; + } + + private getExecuteResult( + client: ApolloClient, + ): QueryResult { + const result = this.observableQueryFields() as QueryResult; + const options = this.options; + + // When skipping a query (ie. we're not querying for data but still want + // to render children), make sure the `data` is cleared out and + // `loading` is set to `false` (since we aren't loading anything). + // + // NOTE: We no longer think this is the correct behavior. Skipping should + // not automatically set `data` to `undefined`, but instead leave the + // previous data in place. In other words, skipping should not mandate + // that previously received data is all of a sudden removed. Unfortunately, + // changing this is breaking, so we'll have to wait until Apollo Client + // 4.0 to address this. + if (options.skip) { + + Object.assign(result, { + data: undefined, + error: undefined, + loading: false, + networkStatus: NetworkStatus.ready, + called: true, + }); + } else if (this.currentObservable) { + // Fetch the current result (if any) from the store. + const currentResult = this.currentObservable.getCurrentResult(); + const { data, loading, partial, networkStatus, errors } = currentResult; + let { error } = currentResult; + + // Until a set naming convention for networkError and graphQLErrors is + // decided upon, we map errors (graphQLErrors) to the error options. + if (errors && errors.length > 0) { + error = new ApolloError({ graphQLErrors: errors }); + } + + Object.assign(result, { + data, + loading, + networkStatus, + error, + called: true + }); + + if (loading) { + // Fall through without modifying result... + } else if (error) { + Object.assign(result, { + data: (this.currentObservable.getLastResult() || ({} as any)) + .data + }); + } else { + const { fetchPolicy } = this.currentObservable.options; + const { partialRefetch } = options; + if ( + partialRefetch && + partial && + (!data || Object.keys(data).length === 0) && + fetchPolicy !== 'cache-only' + ) { + // When a `Query` component is mounted, and a mutation is executed + // that returns the same ID as the mounted `Query`, but has less + // fields in its result, Apollo Client's `QueryManager` returns the + // data as `undefined` since a hit can't be found in the cache. + // This can lead to application errors when the UI elements rendered by + // the original `Query` component are expecting certain data values to + // exist, and they're all of a sudden stripped away. To help avoid + // this we'll attempt to refetch the `Query` data. + Object.assign(result, { + loading: true, + networkStatus: NetworkStatus.loading + }); + result.refetch(); + return result; + } + } + } + + result.client = client; + this.setOptions(options); + const previousResult = this.previous.result; + + // TODO(brian): WHAT THE FUCK + this.previous.loading = + previousResult && previousResult.loading || false; + + // Ensure the returned result contains previousData as a separate + // property, to give developers the flexibility of leveraging outdated + // data while new data is loading from the network. Falling back to + // previousResult.previousData when previousResult.data is falsy here + // allows result.previousData to persist across multiple results. + result.previousData = previousResult && + (previousResult.data || previousResult.previousData); + + // TODO(brian): WHY IS THIS ASSIGNED HERE + this.previous.result = result; + + // Any query errors that exist are now available in `result`, so we'll + // remove the original errors from the `ObservableQuery` query store to + // make sure they aren't re-displayed on subsequent (potentially error + // free) requests/responses. + this.currentObservable && this.currentObservable.resetQueryStoreErrors(); + + return result; } + public afterExecute() { this.isMounted = true; const options = this.options; @@ -342,114 +443,8 @@ class QueryData { }); } - private getExecuteResult( - client: ApolloClient, - ): QueryResult { - const result = this.observableQueryFields() as QueryResult; - const options = this.options; - - // When skipping a query (ie. we're not querying for data but still want - // to render children), make sure the `data` is cleared out and - // `loading` is set to `false` (since we aren't loading anything). - // - // NOTE: We no longer think this is the correct behavior. Skipping should - // not automatically set `data` to `undefined`, but instead leave the - // previous data in place. In other words, skipping should not mandate - // that previously received data is all of a sudden removed. Unfortunately, - // changing this is breaking, so we'll have to wait until Apollo Client - // 4.0 to address this. - if (options.skip) { - - Object.assign(result, { - data: undefined, - error: undefined, - loading: false, - networkStatus: NetworkStatus.ready, - called: true, - }); - } else if (this.currentObservable) { - // Fetch the current result (if any) from the store. - const currentResult = this.currentObservable.getCurrentResult(); - const { data, loading, partial, networkStatus, errors } = currentResult; - let { error } = currentResult; - - // Until a set naming convention for networkError and graphQLErrors is - // decided upon, we map errors (graphQLErrors) to the error options. - if (errors && errors.length > 0) { - error = new ApolloError({ graphQLErrors: errors }); - } - - Object.assign(result, { - data, - loading, - networkStatus, - error, - called: true - }); - - if (loading) { - // Fall through without modifying result... - } else if (error) { - Object.assign(result, { - data: (this.currentObservable.getLastResult() || ({} as any)) - .data - }); - } else { - const { fetchPolicy } = this.currentObservable.options; - const { partialRefetch } = options; - if ( - partialRefetch && - partial && - (!data || Object.keys(data).length === 0) && - fetchPolicy !== 'cache-only' - ) { - // When a `Query` component is mounted, and a mutation is executed - // that returns the same ID as the mounted `Query`, but has less - // fields in its result, Apollo Client's `QueryManager` returns the - // data as `undefined` since a hit can't be found in the cache. - // This can lead to application errors when the UI elements rendered by - // the original `Query` component are expecting certain data values to - // exist, and they're all of a sudden stripped away. To help avoid - // this we'll attempt to refetch the `Query` data. - Object.assign(result, { - loading: true, - networkStatus: NetworkStatus.loading - }); - result.refetch(); - return result; - } - } - } - - result.client = client; - this.setOptions(options); - const previousResult = this.previous.result; - - // TODO(brian): WHAT THE FUCK - this.previous.loading = - previousResult && previousResult.loading || false; - - // Ensure the returned result contains previousData as a separate - // property, to give developers the flexibility of leveraging outdated - // data while new data is loading from the network. Falling back to - // previousResult.previousData when previousResult.data is falsy here - // allows result.previousData to persist across multiple results. - result.previousData = previousResult && - (previousResult.data || previousResult.previousData); - - // TODO(brian): WHY IS THIS ASSIGNED HERE - this.previous.result = result; - - // Any query errors that exist are now available in `result`, so we'll - // remove the original errors from the `ObservableQuery` query store to - // make sure they aren't re-displayed on subsequent (potentially error - // free) requests/responses. - this.currentObservable && this.currentObservable.resetQueryStoreErrors(); - - return result; - } - // observableQueryFields + private obsRefetch = (variables?: Partial) => this.currentObservable?.refetch(variables); From 6be18a1fc4a7254d4fc45eacd56191853b920abb Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 28 Jul 2021 11:37:27 -0400 Subject: [PATCH 16/65] inline getExecuteResult --- src/react/hooks/useQuery.ts | 47 ++++++++++++++----------------------- 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 3f251577698..3cf50b4fc14 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -190,10 +190,8 @@ class QueryData { } } - // getExecuteSsrResult const { ssr } = this.options; const ssrDisabled = ssr === false; - const fetchDisabled = client.disableNetworkFetches; const ssrLoading = { ...this.observableQueryFields(), loading: true, @@ -206,24 +204,12 @@ class QueryData { // If SSR has been explicitly disabled, and this function has been called // on the server side, return the default loading state. - if (ssrDisabled && (this.ssrInitiated() || fetchDisabled)) { + if (ssrDisabled && (this.ssrInitiated() || client.disableNetworkFetches)) { // TODO(brian): Don’t assign this here. this.previous.result = ssrLoading; return ssrLoading; } - const result = this.getExecuteResult(client); - - if (this.ssrInitiated() && result.loading && !skip) { - this.context.renderPromises!.addQueryPromise(this, () => null); - } - - return result; - } - - private getExecuteResult( - client: ApolloClient, - ): QueryResult { const result = this.observableQueryFields() as QueryResult; const options = this.options; @@ -238,7 +224,6 @@ class QueryData { // changing this is breaking, so we'll have to wait until Apollo Client // 4.0 to address this. if (options.skip) { - Object.assign(result, { data: undefined, error: undefined, @@ -304,9 +289,6 @@ class QueryData { this.setOptions(options); const previousResult = this.previous.result; - // TODO(brian): WHAT THE FUCK - this.previous.loading = - previousResult && previousResult.loading || false; // Ensure the returned result contains previousData as a separate // property, to give developers the flexibility of leveraging outdated @@ -316,19 +298,24 @@ class QueryData { result.previousData = previousResult && (previousResult.data || previousResult.previousData); - // TODO(brian): WHY IS THIS ASSIGNED HERE - this.previous.result = result; - // Any query errors that exist are now available in `result`, so we'll // remove the original errors from the `ObservableQuery` query store to // make sure they aren't re-displayed on subsequent (potentially error // free) requests/responses. this.currentObservable && this.currentObservable.resetQueryStoreErrors(); + if (this.context.renderPromises && result.loading && !skip) { + this.context.renderPromises!.addQueryPromise(this, () => null); + } + + // TODO(brian): Stop assigning this here!!!! + this.previous.loading = + previousResult && previousResult.loading || false; + this.previous.result = result; + return result; } - public afterExecute() { this.isMounted = true; const options = this.options; @@ -536,13 +523,6 @@ export function useQuery( useAfterFastRefresh(forceUpdate); } - useEffect(() => () => { - queryData.cleanup(); - // this effect can run multiple times during a fast-refresh so make sure - // we clean up the ref - queryDataRef.current = void 0; - }, []); - useEffect(() => queryData.afterExecute(), [ result.loading, result.networkStatus, @@ -550,5 +530,12 @@ export function useQuery( result.data, ]); + useEffect(() => () => { + queryData.cleanup(); + // this effect can run multiple times during a fast-refresh so make sure + // we clean up the ref + queryDataRef.current = void 0; + }, []); + return result; } From f736f16527478f6c43388b197cdf4812f4852c85 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 2 Aug 2021 16:50:55 -0400 Subject: [PATCH 17/65] Create a temporary useQuery1 hook --- src/__tests__/__snapshots__/exports.ts.snap | 3 + src/react/hooks/__tests__/useQuery.test.tsx | 42 ++-- src/react/hooks/useQuery.ts | 258 +++++++++++++++----- 3 files changed, 215 insertions(+), 88 deletions(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 3835a31be88..99d63de7d7e 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -55,6 +55,7 @@ Array [ "useLazyQuery", "useMutation", "useQuery", + "useQuery1", "useReactiveVar", "useSubscription", ] @@ -234,6 +235,7 @@ Array [ "useLazyQuery", "useMutation", "useQuery", + "useQuery1", "useReactiveVar", "useSubscription", ] @@ -281,6 +283,7 @@ Array [ "useLazyQuery", "useMutation", "useQuery", + "useQuery1", "useReactiveVar", "useSubscription", ] diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index ace495ec9d3..e294c54cc40 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -16,7 +16,7 @@ import { ApolloProvider } from '../../context'; import { Observable, Reference, concatPagination } from '../../../utilities'; import { ApolloLink } from '../../../link/core'; import { itAsync, MockLink, MockedProvider, mockSingleLink } from '../../../testing'; -import { useQuery } from '../useQuery'; +import { useQuery, useQuery1 } from '../useQuery'; import { useMutation } from '../useMutation'; describe('useQuery Hook', () => { @@ -35,7 +35,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query), + () => useQuery1(query), { wrapper }, ); @@ -61,7 +61,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - () => useQuery(query), + () => useQuery1(query), { wrapper } ); expect(result.current.loading).toBe(true); @@ -91,7 +91,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query), + () => useQuery1(query), { wrapper }, ); expect(result.current.loading).toBe(true); @@ -126,7 +126,7 @@ describe('useQuery Hook', () => { ); const { result } = renderHook( - () => useQuery(query), + () => useQuery1(query), { wrapper }, ); @@ -157,7 +157,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ id }) => useQuery(query, { variables: { id }}), + ({ id }) => useQuery1(query, { variables: { id }}), { wrapper, initialProps: { id: 1 } }, ); expect(result.current.loading).toBe(true); @@ -200,7 +200,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ id }) => useQuery(query, { variables: { id } }), + ({ id }) => useQuery1(query, { variables: { id } }), { wrapper, initialProps: { id: 1 } }, ); @@ -251,7 +251,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ name }) => useQuery(query, { variables: { name } }), + ({ name }) => useQuery1(query, { variables: { name } }), { wrapper, initialProps: { name: "" } }, ); @@ -318,7 +318,7 @@ describe('useQuery Hook', () => { let renderCount = 0; const InnerComponent = ({ something }: any) => { - const { loading, data } = useQuery(CAR_QUERY, { + const { loading, data } = useQuery1(CAR_QUERY, { fetchPolicy: 'network-only', variables: { something } }); @@ -330,7 +330,7 @@ describe('useQuery Hook', () => { }; function WrapperComponent({ something }: any) { - const { loading } = useQuery(CAR_QUERY, { + const { loading } = useQuery1(CAR_QUERY, { variables: { something } }); return loading ? null : ; @@ -367,7 +367,7 @@ describe('useQuery Hook', () => { ); const { unmount } = renderHook( - () => useQuery(query), + () => useQuery1(query), { wrapper }, ); @@ -548,7 +548,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { pollInterval: 10 }), + () => useQuery1(query, { pollInterval: 10 }), { wrapper }, ); @@ -656,7 +656,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate, unmount } = renderHook( - () => useQuery(query, { pollInterval: 10 }), + () => useQuery1(query, { pollInterval: 10 }), { wrapper }, ); @@ -702,7 +702,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate, unmount } = renderHook( - () => useQuery(query, { pollInterval: 10 }), + () => useQuery1(query, { pollInterval: 10 }), { wrapper }, ); @@ -745,7 +745,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { pollInterval: 20 }), + () => useQuery1(query, { pollInterval: 20 }), { wrapper }, ); @@ -788,7 +788,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate, unmount } = renderHook( - () => useQuery(query), + () => useQuery1(query), { wrapper }, ); @@ -1297,7 +1297,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { variables: { limit: 2 } }), + () => useQuery1(query, { variables: { limit: 2 } }), { wrapper }, ); @@ -1333,7 +1333,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { variables: { limit: 2 }, notifyOnNetworkStatusChange: true, }), @@ -1385,7 +1385,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { variables: { limit: 2 } }), + () => useQuery1(query, { variables: { limit: 2 } }), { wrapper }, ); @@ -1421,7 +1421,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { variables: { limit: 2 }, notifyOnNetworkStatusChange: true, }), @@ -1571,7 +1571,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { variables: { id: 1 }, notifyOnNetworkStatusChange: true, }), diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 3cf50b4fc14..5b25f7539bc 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -1,4 +1,11 @@ -import { useContext, useEffect, useReducer, useRef } from 'react'; +import { + useContext, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; import { invariant } from 'ts-invariant'; import { equal } from '@wry/equality'; import { OperationVariables } from '../../core'; @@ -6,6 +13,7 @@ import { ApolloContextValue, getApolloContext } from '../context'; import { ApolloError } from '../../errors'; import { ApolloClient, + ApolloQueryResult, NetworkStatus, FetchMoreQueryOptions, SubscribeToMoreOptions, @@ -14,6 +22,7 @@ import { UpdateQueryOptions, DocumentNode, TypedDocumentNode, + WatchQueryOptions, } from '../../core'; import { QueryDataOptions, @@ -41,6 +50,24 @@ function verifyDocumentType(document: DocumentNode, type: DocumentType) { ); } +function prepareOptions( + options: QueryDataOptions, + context: ApolloContextValue, +): WatchQueryOptions { + return { + ...options, + fetchPolicy: + context && + context.renderPromises && + ( + options.fetchPolicy === 'network-only' || + options.fetchPolicy === 'cache-and-network' + ) + ? 'cache-first' + : options.fetchPolicy, + }; +} + // The interface expected by RenderPromises interface QueryData { @@ -51,22 +78,22 @@ interface QueryData { class QueryData { public isMounted: boolean; public context: ApolloContextValue; - private options = {} as QueryDataOptions; + private options: QueryDataOptions; private onNewData: () => void; private currentObservable?: ObservableQuery; private currentSubscription?: ObservableSubscription; private previous: { client?: ApolloClient; - query?: DocumentNode | TypedDocumentNode; options?: QueryDataOptions; - // TODO(brian): WHAT IS THE DIFFERENCE??????????? - observableQueryOptions?: QueryDataOptions; + // TODO(brian): deduplicate with + query?: DocumentNode | TypedDocumentNode; + observableQueryOptions?: WatchQueryOptions; // TODO(brian): previous.result should be assigned once in an update, we // shouldn’t have loading/error defined separately result?: QueryResult; loading?: boolean; error?: ApolloError; - } = Object.create(null); + }; constructor({ options, @@ -81,30 +108,19 @@ class QueryData { this.context = context || {}; this.onNewData = onNewData; this.isMounted = false; + this.previous = {}; } public getOptions(): QueryDataOptions { return this.options; } - public setOptions(newOptions: QueryDataOptions) { - if (!equal(this.options, newOptions)) { - this.previous.options = this.options; - } - - this.options = newOptions; - } - public fetchData(): Promise | boolean { const options = this.options; if (options.skip || options.ssr === false) return false; return new Promise(resolve => this.startQuerySubscription(resolve)); } - private ssrInitiated() { - return this.context && this.context.renderPromises; - } - public cleanup() { if (this.currentSubscription) { this.currentSubscription.unsubscribe(); @@ -118,8 +134,19 @@ class QueryData { } } - public execute(client: ApolloClient): QueryResult { - const { skip, query } = this.options; + public execute( + client: ApolloClient, + options: QueryDataOptions + ): QueryResult { + // TODO: STOP ASSIGNING OPTIONS HERE + if (!equal(this.options, options)) { + this.previous.options = this.options; + this.options = options; + } + + const { skip, query, ssr } = this.options; + verifyDocumentType(query, DocumentType.Query); + // if skip, query, or client are different we restart? if (this.previous.client !== client) { if (this.previous.client) { this.cleanup(); @@ -135,22 +162,7 @@ class QueryData { this.previous.query = query; } - verifyDocumentType(this.options.query, DocumentType.Query); - const observableQueryOptions = { - ...this.options, - fetchPolicy: - this.context && - this.context.renderPromises && - ( - this.options.fetchPolicy === 'network-only' || - this.options.fetchPolicy === 'cache-and-network' - ) - ? 'cache-first' - : this.options.fetchPolicy, - displayName: this.options.displayName || 'Query', - children: void 0, - }; - + const observableQueryOptions = prepareOptions(this.options, this.context); // If we skipped initially, we may not have yet created the observable if (!this.currentObservable) { // See if there is an existing observable that was used to fetch the same @@ -173,24 +185,21 @@ class QueryData { ); } } - } else { - if (this.options.skip) { - this.previous.observableQueryOptions = observableQueryOptions; - } else if ( - !equal(observableQueryOptions, this.previous.observableQueryOptions) - ) { - this.previous.observableQueryOptions = observableQueryOptions; - this.currentObservable - .setOptions(observableQueryOptions) - // The error will be passed to the child container, so we don't - // need to log it here. We could conceivably log something if - // an option was set. OTOH we don't log errors w/ the original - // query. See https://github.com/apollostack/react-apollo/issues/404 - .catch(() => {}); - } + } else if (skip) { + this.previous.observableQueryOptions = observableQueryOptions; + } else if ( + !equal(observableQueryOptions, this.previous.observableQueryOptions) + ) { + this.previous.observableQueryOptions = observableQueryOptions; + this.currentObservable + .setOptions(observableQueryOptions) + // The error will be passed to the child container, so we don't + // need to log it here. We could conceivably log something if + // an option was set. OTOH we don't log errors w/ the original + // query. See https://github.com/apollostack/react-apollo/issues/404 + .catch(() => {}); } - const { ssr } = this.options; const ssrDisabled = ssr === false; const ssrLoading = { ...this.observableQueryFields(), @@ -204,15 +213,16 @@ class QueryData { // If SSR has been explicitly disabled, and this function has been called // on the server side, return the default loading state. - if (ssrDisabled && (this.ssrInitiated() || client.disableNetworkFetches)) { + if ( + ssrDisabled && + (this.context.renderPromises || client.disableNetworkFetches) + ) { // TODO(brian): Don’t assign this here. this.previous.result = ssrLoading; return ssrLoading; } const result = this.observableQueryFields() as QueryResult; - const options = this.options; - // When skipping a query (ie. we're not querying for data but still want // to render children), make sure the `data` is cleared out and // `loading` is set to `false` (since we aren't loading anything). @@ -223,7 +233,7 @@ class QueryData { // that previously received data is all of a sudden removed. Unfortunately, // changing this is breaking, so we'll have to wait until Apollo Client // 4.0 to address this. - if (options.skip) { + if (skip) { Object.assign(result, { data: undefined, error: undefined, @@ -260,7 +270,7 @@ class QueryData { }); } else { const { fetchPolicy } = this.currentObservable.options; - const { partialRefetch } = options; + const { partialRefetch } = this.options; if ( partialRefetch && partial && @@ -286,10 +296,8 @@ class QueryData { } result.client = client; - this.setOptions(options); const previousResult = this.previous.result; - // Ensure the returned result contains previousData as a separate // property, to give developers the flexibility of leveraging outdated // data while new data is loading from the network. Falling back to @@ -438,14 +446,14 @@ class QueryData { private obsFetchMore = ( fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions - ) => this.currentObservable!.fetchMore(fetchMoreOptions); + ) => this.currentObservable?.fetchMore(fetchMoreOptions); private obsUpdateQuery = ( mapFn: ( previousQueryResult: TData, options: UpdateQueryOptions ) => TData - ) => this.currentObservable!.updateQuery(mapFn); + ) => this.currentObservable?.updateQuery(mapFn); private obsStartPolling = (pollInterval: number) => { this.currentObservable?.startPolling(pollInterval); @@ -464,7 +472,7 @@ class QueryData { TSubscriptionVariables, TSubscriptionData > - ) => this.currentObservable!.subscribeToMore(options); + ) => this.currentObservable?.subscribeToMore(options); private observableQueryFields() { return { @@ -493,7 +501,7 @@ export function useQuery( ); const [tick, forceUpdate] = useReducer(x => x + 1, 0); - const updatedOptions: QueryDataOptions = { + const optionsWithQuery: QueryDataOptions = { ...options, query, }; @@ -501,7 +509,7 @@ export function useQuery( const queryDataRef = useRef>(); const queryData = queryDataRef.current || ( queryDataRef.current = new QueryData({ - options: updatedOptions, + options: optionsWithQuery, context, onNewData() { if (queryDataRef.current && queryDataRef.current.isMounted) { @@ -511,11 +519,10 @@ export function useQuery( }) ); - queryData.setOptions(updatedOptions); queryData.context = context; const result = useDeepMemo( - () => queryData.execute(client), - [updatedOptions, context, tick], + () => queryData.execute(client, optionsWithQuery), + [optionsWithQuery, context, tick], ); if (__DEV__) { @@ -539,3 +546,120 @@ export function useQuery( return result; } + +export function useQuery1< + TData = any, + TVariables = OperationVariables, +>( + query: DocumentNode | TypedDocumentNode, + hookOptions?: QueryHookOptions, +): QueryResult { + const context = useContext(getApolloContext()); + const client = hookOptions?.client || context.client; + invariant( + !!client, + 'Could not find "client" in the context or passed in as an option. ' + + 'Wrap the root component in an , or pass an ' + + 'ApolloClient instance in via options.' + ); + verifyDocumentType(query, DocumentType.Query); + + const options = useMemo(() => ({...hookOptions, query}), [hookOptions]); + const [obsQuery, setObsQuery] = useState(() => client.watchQuery(options)); + const [result, setResult] = useState(() => obsQuery.getCurrentResult()); + const previousRef = useRef<{ + client: ApolloClient, + query: DocumentNode | TypedDocumentNode, + options: QueryDataOptions, + result: ApolloQueryResult | undefined, + }>({ + client, + query, + options, + result, + }); + + const subRef = useRef(); + useEffect(() => { + if ( + previousRef.current.client !== client || + !equal(previousRef.current.query, query) + ) { + setObsQuery(client.watchQuery(options)); + } + + if (!equal(previousRef.current.options, options)) { + obsQuery.setOptions({...options, query}).catch(() => {}); + const result = obsQuery.getCurrentResult(); + previousRef.current.result = result; + setResult(result); + } + + Object.assign(previousRef.current, { client, query, options }); + + return () => { + if (!subRef.current) { + obsQuery['tearDownQuery'](); + } + }; + }, [client, query, options, obsQuery]); + + useEffect(() => { + const sub = subRef.current = obsQuery.subscribe( + () => { + // We use `getCurrentResult()` instead of the callback argument + // because the values differ slightly. Specifically, loading results + // will often have an empty object for data instead of `undefined`. + const nextResult = obsQuery.getCurrentResult(); + const previousResult = previousRef.current.result; + // Make sure we're not attempting to re-render similar results + if ( + previousResult && + previousResult.loading === nextResult.loading && + previousResult.networkStatus === nextResult.networkStatus && + equal(previousResult.data, nextResult.data) + ) { + return; + } + + previousRef.current.result = nextResult; + setResult(nextResult); + }, + (error) => { + throw error; + //subscriptionRef.current = undefined; + // Unfortunately, if `lastError` is set in the current + // `observableQuery` when the subscription is re-created, the + // subscription will immediately receive the error, which will cause + // it to terminate again. To avoid this, we first clear the last + // error/result from the `observableQuery` before re-starting the + // subscription, and restore it afterwards (so the subscription has a + // chance to stay open). + //const lastError = obsQuery.getLastError(); + //const lastResult = obsQuery.getLastResult(); + //obsQuery.resetLastResults(); + //this.startQuerySubscription(); + //Object.assign(obsQuery, { lastError, lastResult }); + }, + ); + + return () => sub.unsubscribe(); + }, [obsQuery]); + + const obsQueryFields = useMemo(() => ({ + refetch: obsQuery.refetch.bind(obsQuery), + fetchMore: obsQuery.fetchMore.bind(obsQuery), + updateQuery: obsQuery.updateQuery.bind(obsQuery), + startPolling: obsQuery.startPolling.bind(obsQuery), + stopPolling: obsQuery.stopPolling.bind(obsQuery), + subscribeToMore: obsQuery.subscribeToMore.bind(obsQuery), + }), [obsQuery]); + + return { + ...obsQueryFields, + variables: obsQuery.variables, + client, + called: true, + ...result, + }; +} From 76dcd52ac1c3a8459c1e7ec9e8122b6b4f4d64a0 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 2 Aug 2021 18:38:50 -0400 Subject: [PATCH 18/65] initial refactor of useQuery1 --- src/react/hooks/__tests__/useQuery.test.tsx | 38 ++++++------ src/react/hooks/useQuery.ts | 64 +++++++++++++-------- 2 files changed, 59 insertions(+), 43 deletions(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index e294c54cc40..8f26fe18d76 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -1716,7 +1716,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { variables: { min: 0, max: 12 }, notifyOnNetworkStatusChange: true, // This is the key line in this test. @@ -1797,7 +1797,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { variables: { min: 0, max: 12 }, notifyOnNetworkStatusChange: true, // This is the key line in this test. @@ -1883,7 +1883,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { variables: { min: 0, max: 12 }, notifyOnNetworkStatusChange: true, // Intentionally not passing refetchWritePolicy. @@ -1960,7 +1960,7 @@ describe('useQuery Hook', () => { const onCompleted = jest.fn(); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { fetchPolicy: 'cache-only', onCompleted, }), @@ -1993,7 +1993,7 @@ describe('useQuery Hook', () => { const onCompleted = jest.fn(); const { result, rerender, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { onCompleted, }), { wrapper }, @@ -2067,7 +2067,7 @@ describe('useQuery Hook', () => { const onCompleted = jest.fn(); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { fetchPolicy: 'network-only', onCompleted, }), @@ -2183,7 +2183,7 @@ describe('useQuery Hook', () => { }, onError, }), - query: useQuery(query), + query: useQuery1(query), }), { wrapper }, ); @@ -2510,7 +2510,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { variables: { id: entityId } }), + () => useQuery1(query, { variables: { id: entityId } }), { wrapper }, ); @@ -2701,7 +2701,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(carQuery, { variables: { id: 1 } }), + () => useQuery1(carQuery, { variables: { id: 1 } }), { wrapper }, ); @@ -2913,7 +2913,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { notifyOnNetworkStatusChange: true }), + () => useQuery1(query, { notifyOnNetworkStatusChange: true }), { wrapper }, ); @@ -2999,7 +2999,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { notifyOnNetworkStatusChange: true }), + () => useQuery1(query, { notifyOnNetworkStatusChange: true }), { wrapper }, ); @@ -3080,7 +3080,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ gender }) => useQuery(query, { + ({ gender }) => useQuery1(query, { variables: { gender }, fetchPolicy: 'network-only', }), @@ -3167,7 +3167,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ canonizeResults }) => useQuery(query, { + ({ canonizeResults }) => useQuery1(query, { fetchPolicy: 'cache-only', canonizeResults, }), @@ -3247,11 +3247,12 @@ describe('useQuery Hook', () => { return new ApolloClient({ cache: new InMemoryCache, link: new ApolloLink(operation => new Observable(observer => { - setTimeout(() => { switch (operation.operationName) { case "A": - observer.next({ data: aData }); - observer.complete(); + setTimeout(() => { + observer.next({ data: aData }); + observer.complete(); + }); break; case "B": setTimeout(() => { @@ -3260,7 +3261,6 @@ describe('useQuery Hook', () => { }, 10); break; } - }); })), }); } @@ -3272,8 +3272,8 @@ describe('useQuery Hook', () => { const client = makeClient(); const { result, waitForNextUpdate } = renderHook( () => ({ - a: useQuery(aQuery, { fetchPolicy: aFetchPolicy }), - b: useQuery(bQuery, { fetchPolicy: bFetchPolicy }), + a: useQuery1(aQuery, { fetchPolicy: aFetchPolicy }), + b: useQuery1(bQuery, { fetchPolicy: bFetchPolicy }), }), { wrapper: ({ children }) => ( diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 5b25f7539bc..f34cb0df005 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -559,59 +559,59 @@ export function useQuery1< invariant( !!client, 'Could not find "client" in the context or passed in as an option. ' + - 'Wrap the root component in an , or pass an ' + - 'ApolloClient instance in via options.' + 'Wrap the root component in an , or pass an ApolloClient' + + 'ApolloClient instance in via options.', ); verifyDocumentType(query, DocumentType.Query); - const options = useMemo(() => ({...hookOptions, query}), [hookOptions]); + const {onCompleted, onError} = options; const [obsQuery, setObsQuery] = useState(() => client.watchQuery(options)); const [result, setResult] = useState(() => obsQuery.getCurrentResult()); - const previousRef = useRef<{ + const prevRef = useRef<{ client: ApolloClient, query: DocumentNode | TypedDocumentNode, options: QueryDataOptions, - result: ApolloQueryResult | undefined, + result: ApolloQueryResult, + data: TData | undefined, }>({ client, query, options, result, + data: undefined, }); - const subRef = useRef(); useEffect(() => { if ( - previousRef.current.client !== client || - !equal(previousRef.current.query, query) + prevRef.current.client !== client || + !equal(prevRef.current.query, query) ) { setObsQuery(client.watchQuery(options)); } - if (!equal(previousRef.current.options, options)) { + if (!equal(prevRef.current.options, options)) { obsQuery.setOptions({...options, query}).catch(() => {}); const result = obsQuery.getCurrentResult(); - previousRef.current.result = result; + const previousResult = prevRef.current.result; + if (previousResult.data) { + prevRef.current.data = previousResult.data; + } + + prevRef.current.result = result; setResult(result); } - Object.assign(previousRef.current, { client, query, options }); - - return () => { - if (!subRef.current) { - obsQuery['tearDownQuery'](); - } - }; - }, [client, query, options, obsQuery]); + Object.assign(prevRef.current, { client, query, options }); + }, [obsQuery, client, query, options]); useEffect(() => { - const sub = subRef.current = obsQuery.subscribe( + const sub = obsQuery.subscribe( () => { - // We use `getCurrentResult()` instead of the callback argument - // because the values differ slightly. Specifically, loading results - // will often have an empty object for data instead of `undefined`. + const previousResult = prevRef.current.result; + // We use `getCurrentResult()` instead of the callback argument because + // the values differ slightly. Specifically, loading results will often + // have an empty object (`{}`) for data instead of `undefined`. const nextResult = obsQuery.getCurrentResult(); - const previousResult = previousRef.current.result; // Make sure we're not attempting to re-render similar results if ( previousResult && @@ -622,7 +622,11 @@ export function useQuery1< return; } - previousRef.current.result = nextResult; + if (previousResult.data) { + prevRef.current.data = previousResult.data; + } + + prevRef.current.result = nextResult; setResult(nextResult); }, (error) => { @@ -655,11 +659,23 @@ export function useQuery1< subscribeToMore: obsQuery.subscribeToMore.bind(obsQuery), }), [obsQuery]); + useEffect(() => { + if (!result.loading) { + if (result.error) { + onError?.(result.error); + } else { + onCompleted?.(result.data); + } + } + // TODO: Do we need to add onCompleted and onError to the dependency array + }, [result, onCompleted, onError]); + return { ...obsQueryFields, variables: obsQuery.variables, client, called: true, + previousData: prevRef.current.data, ...result, }; } From 5526f933aa18998ffbc52eee28eb5f89ae1b0a7f Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 2 Aug 2021 18:55:19 -0400 Subject: [PATCH 19/65] Fix ssr: false test --- src/react/hooks/useQuery.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index f34cb0df005..3ce6417cbf5 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -324,14 +324,11 @@ class QueryData { return result; } - public afterExecute() { + public afterExecute(client: ApolloClient) { this.isMounted = true; - const options = this.options; - const ssrDisabled = options.ssr === false; - // TODO(brian): WHY WOULD this.currentObservable BE UNDEFINED HERE???????? if ( this.currentObservable && - !ssrDisabled && + !client.disableNetworkFetches && !(this.context && this.context.renderPromises) ) { this.startQuerySubscription(); @@ -530,7 +527,7 @@ export function useQuery( useAfterFastRefresh(forceUpdate); } - useEffect(() => queryData.afterExecute(), [ + useEffect(() => queryData.afterExecute(client), [ result.loading, result.networkStatus, result.error, @@ -609,8 +606,8 @@ export function useQuery1< () => { const previousResult = prevRef.current.result; // We use `getCurrentResult()` instead of the callback argument because - // the values differ slightly. Specifically, loading results will often - // have an empty object (`{}`) for data instead of `undefined`. + // the values differ slightly. Specifically, loading results will have + // an empty object for data instead of `undefined` for some reason. const nextResult = obsQuery.getCurrentResult(); // Make sure we're not attempting to re-render similar results if ( From 888789fb693ae30b3e2a0a2d5aeff9d57640afd8 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 2 Aug 2021 20:02:00 -0400 Subject: [PATCH 20/65] add skip to the new useQuery1 --- src/react/hooks/__tests__/useQuery.test.tsx | 23 ++++++----- src/react/hooks/useQuery.ts | 42 ++++++++++++++++++--- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 8f26fe18d76..4d298ddb554 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -592,13 +592,16 @@ describe('useQuery Hook', () => { ]; const cache = new InMemoryCache(); - const wrapper = ({ children }: any) => ( - {children} - ); - const { result, rerender, waitForNextUpdate } = renderHook( - ({ skip }) => useQuery(query, { pollInterval: 10, skip }), - { wrapper, initialProps: { skip: undefined } as any }, + ({ skip }) => useQuery1(query, { pollInterval: 10, skip }), + { + wrapper: ({ children }) => ( + + {children} + + ), + initialProps: { skip: undefined } as any + }, ); expect(result.current.loading).toBe(true); @@ -2035,7 +2038,7 @@ describe('useQuery Hook', () => { const onCompleted = jest.fn(); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { skip: true, onCompleted, }), @@ -2583,7 +2586,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ skip }) => useQuery(query, { skip }), + ({ skip }) => useQuery1(query, { skip }), { wrapper, initialProps: { skip: true } }, ); @@ -2618,7 +2621,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ skip, variables }) => useQuery(query, { skip, variables }), + ({ skip, variables }) => useQuery1(query, { skip, variables }), { wrapper, initialProps: { skip: false, variables: undefined as any } }, ); @@ -2648,7 +2651,7 @@ describe('useQuery Hook', () => { ); const { unmount } = renderHook( - () => useQuery(query, { skip: true }), + () => useQuery1(query, { skip: true }), { wrapper }, ); diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 3ce6417cbf5..5cfb5f4e68c 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -544,6 +544,11 @@ export function useQuery( return result; } +//TODO +//- Partial Data stuff +//- Errors +//- SSR + export function useQuery1< TData = any, TVariables = OperationVariables, @@ -560,10 +565,19 @@ export function useQuery1< 'ApolloClient instance in via options.', ); verifyDocumentType(query, DocumentType.Query); - const options = useMemo(() => ({...hookOptions, query}), [hookOptions]); - const {onCompleted, onError} = options; + const options = useMemo(() => { + const { skip, ...options } = { ...hookOptions, query }; + if (skip) { + options.fetchPolicy = 'standby'; + } else if (!options.fetchPolicy) { + options.fetchPolicy = 'cache-first'; + } + + return options; + }, [hookOptions]); + const { onCompleted, onError } = options; const [obsQuery, setObsQuery] = useState(() => client.watchQuery(options)); - const [result, setResult] = useState(() => obsQuery.getCurrentResult()); + let [result, setResult] = useState(() => obsQuery.getCurrentResult()); const prevRef = useRef<{ client: ApolloClient, query: DocumentNode | TypedDocumentNode, @@ -587,7 +601,7 @@ export function useQuery1< } if (!equal(prevRef.current.options, options)) { - obsQuery.setOptions({...options, query}).catch(() => {}); + obsQuery.setOptions(options).catch(() => {}); const result = obsQuery.getCurrentResult(); const previousResult = prevRef.current.result; if (previousResult.data) { @@ -660,13 +674,31 @@ export function useQuery1< if (!result.loading) { if (result.error) { onError?.(result.error); - } else { + } else if (result.data) { onCompleted?.(result.data); } } // TODO: Do we need to add onCompleted and onError to the dependency array }, [result, onCompleted, onError]); + if (hookOptions?.skip) { + // When skipping a query (ie. we're not querying for data but still want to + // render children), make sure the `data` is cleared out and `loading` is + // set to `false` (since we aren't loading anything). + // + // NOTE: We no longer think this is the correct behavior. Skipping should + // not automatically set `data` to `undefined`, but instead leave the + // previous data in place. In other words, skipping should not mandate that + // previously received data is all of a sudden removed. Unfortunately, + // changing this is breaking, so we'll have to wait until Apollo Client 4.0 + // to address this. + result = { + data: undefined, + error: undefined, + loading: false, + networkStatus: NetworkStatus.ready, + }; + } return { ...obsQueryFields, variables: obsQuery.variables, From 2e75c0b53b272f37fb53aa0302d4e39901cad2a5 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 2 Aug 2021 22:41:02 -0400 Subject: [PATCH 21/65] Bring partialData logic to the new useQuery1 hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I honestly have no idea what these partialRefetch tests are testing but the one test we’ve written for it now passes --- src/react/hooks/__tests__/useQuery.test.tsx | 14 +++--- src/react/hooks/useQuery.ts | 49 ++++++++++++++++----- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 4d298ddb554..f760dac7326 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2253,18 +2253,18 @@ describe('useQuery Hook', () => { cache: new InMemoryCache(), }); - const wrapper = ({ children }: any) => ( - - {children} - - ); - const { result, waitForNextUpdate } = renderHook( () => useQuery(query, { partialRefetch: true, notifyOnNetworkStatusChange: true, }), - { wrapper }, + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); expect(result.current.loading).toBe(true); diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 5cfb5f4e68c..96c4649f766 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -545,7 +545,6 @@ export function useQuery( } //TODO -//- Partial Data stuff //- Errors //- SSR @@ -622,13 +621,13 @@ export function useQuery1< // We use `getCurrentResult()` instead of the callback argument because // the values differ slightly. Specifically, loading results will have // an empty object for data instead of `undefined` for some reason. - const nextResult = obsQuery.getCurrentResult(); + const result = obsQuery.getCurrentResult(); // Make sure we're not attempting to re-render similar results if ( previousResult && - previousResult.loading === nextResult.loading && - previousResult.networkStatus === nextResult.networkStatus && - equal(previousResult.data, nextResult.data) + previousResult.loading === result.loading && + previousResult.networkStatus === result.networkStatus && + equal(previousResult.data, result.data) ) { return; } @@ -637,12 +636,12 @@ export function useQuery1< prevRef.current.data = previousResult.data; } - prevRef.current.result = nextResult; - setResult(nextResult); + prevRef.current.result = result; + setResult(result); }, (error) => { - throw error; - //subscriptionRef.current = undefined; + console.log(69); + //throw error; // Unfortunately, if `lastError` is set in the current // `observableQuery` when the subscription is re-created, the // subscription will immediately receive the error, which will cause @@ -653,7 +652,6 @@ export function useQuery1< //const lastError = obsQuery.getLastError(); //const lastResult = obsQuery.getLastResult(); //obsQuery.resetLastResults(); - //this.startQuerySubscription(); //Object.assign(obsQuery, { lastError, lastResult }); }, ); @@ -681,6 +679,37 @@ export function useQuery1< // TODO: Do we need to add onCompleted and onError to the dependency array }, [result, onCompleted, onError]); + + // TODO: This effect should be removed when partialRefetch is removed. + useEffect(() => { + // When a `Query` component is mounted, and a mutation is executed + // that returns the same ID as the mounted `Query`, but has less + // fields in its result, Apollo Client's `QueryManager` returns the + // data as `undefined` since a hit can't be found in the cache. + // This can lead to application errors when the UI elements rendered by + // the original `Query` component are expecting certain data values to + // exist, and they're all of a sudden stripped away. To help avoid + // this we'll attempt to refetch the `Query` data. + if ( + hookOptions?.partialRefetch && + result.partial && + (!result.data || Object.keys(result.data).length === 0) && + obsQuery.options.fetchPolicy !== 'cache-only' + ) { + setResult((result) => ({ + ...result, + loading: true, + networkStatus: NetworkStatus.loading, + })); + setTimeout(() => obsQuery.refetch()); + } + }, [ + hookOptions?.partialRefetch, + result.data, + result.partial, + obsQuery.options.fetchPolicy, + ]); + if (hookOptions?.skip) { // When skipping a query (ie. we're not querying for data but still want to // render children), make sure the `data` is cleared out and `loading` is From 98e84e1f83f7aa2094955f8ce784939ed6fe1ff1 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 2 Aug 2021 23:06:26 -0400 Subject: [PATCH 22/65] handle errors with the new useQuery1 hook --- src/react/hooks/__tests__/useQuery.test.tsx | 14 +-- src/react/hooks/useQuery.ts | 97 ++++++++++++--------- 2 files changed, 65 insertions(+), 46 deletions(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index f760dac7326..7b530109588 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -833,7 +833,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query), + () => useQuery1(query), { wrapper }, ); @@ -870,7 +870,7 @@ describe('useQuery Hook', () => { const onError = jest.fn(); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { + () => useQuery1(query, { onError, notifyOnNetworkStatusChange: true, }), @@ -918,7 +918,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - () => useQuery(query), + () => useQuery1(query), { wrapper }, ); @@ -965,7 +965,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - () => useQuery(query, { onError: () => {}, onCompleted: () => {} }), + () => useQuery1(query, { onError: () => {}, onCompleted: () => {} }), { wrapper }, ); @@ -1017,7 +1017,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { notifyOnNetworkStatusChange: true }), + () => useQuery1(query, { notifyOnNetworkStatusChange: true }), { wrapper }, ); @@ -1077,7 +1077,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { notifyOnNetworkStatusChange: true }), + () => useQuery1(query, { notifyOnNetworkStatusChange: true }), { wrapper }, ); @@ -1142,7 +1142,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery(query, { notifyOnNetworkStatusChange: true }), + () => useQuery1(query, { notifyOnNetworkStatusChange: true }), { wrapper }, ); diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 96c4649f766..0c41658585a 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -545,7 +545,6 @@ export function useQuery( } //TODO -//- Errors //- SSR export function useQuery1< @@ -615,47 +614,66 @@ export function useQuery1< }, [obsQuery, client, query, options]); useEffect(() => { - const sub = obsQuery.subscribe( - () => { - const previousResult = prevRef.current.result; - // We use `getCurrentResult()` instead of the callback argument because - // the values differ slightly. Specifically, loading results will have - // an empty object for data instead of `undefined` for some reason. - const result = obsQuery.getCurrentResult(); - // Make sure we're not attempting to re-render similar results - if ( - previousResult && - previousResult.loading === result.loading && - previousResult.networkStatus === result.networkStatus && - equal(previousResult.data, result.data) - ) { - return; - } + function onNext() { + const previousResult = prevRef.current.result; + // We use `getCurrentResult()` instead of the callback argument because + // the values differ slightly. Specifically, loading results will have + // an empty object for data instead of `undefined` for some reason. + const result = obsQuery.getCurrentResult(); + // Make sure we're not attempting to re-render similar results + if ( + previousResult && + previousResult.loading === result.loading && + previousResult.networkStatus === result.networkStatus && + equal(previousResult.data, result.data) + ) { + return; + } - if (previousResult.data) { - prevRef.current.data = previousResult.data; - } + if (previousResult.data) { + prevRef.current.data = previousResult.data; + } - prevRef.current.result = result; - setResult(result); - }, - (error) => { - console.log(69); - //throw error; - // Unfortunately, if `lastError` is set in the current - // `observableQuery` when the subscription is re-created, the - // subscription will immediately receive the error, which will cause - // it to terminate again. To avoid this, we first clear the last - // error/result from the `observableQuery` before re-starting the - // subscription, and restore it afterwards (so the subscription has a - // chance to stay open). - //const lastError = obsQuery.getLastError(); - //const lastResult = obsQuery.getLastResult(); - //obsQuery.resetLastResults(); - //Object.assign(obsQuery, { lastError, lastResult }); - }, - ); + prevRef.current.result = result; + setResult(result); + } + + function onError(error: Error) { + const last = obsQuery["last"]; + sub.unsubscribe(); + // Unfortunately, if `lastError` is set in the current + // `observableQuery` when the subscription is re-created, + // the subscription will immediately receive the error, which will + // cause it to terminate again. To avoid this, we first clear + // the last error/result from the `observableQuery` before re-starting + // the subscription, and restore it afterwards (so the subscription + // has a chance to stay open). + try { + obsQuery.resetLastResults(); + sub = obsQuery.subscribe(onNext, onError); + } finally { + obsQuery["last"] = last; + } + if (!error.hasOwnProperty('graphQLErrors')) { + throw error; + } + + const previousResult = prevRef.current.result; + if ( + (previousResult && previousResult.loading) || + !equal(error, previousResult.error) + ) { + setResult((result) => ({ + ...result, + error: error as ApolloError, + loading: false, + networkStatus: NetworkStatus.error, + })); + } + } + + let sub = obsQuery.subscribe(onNext, onError); return () => sub.unsubscribe(); }, [obsQuery]); @@ -728,6 +746,7 @@ export function useQuery1< networkStatus: NetworkStatus.ready, }; } + return { ...obsQueryFields, variables: obsQuery.variables, From 18197c87e65c67903e3b91590e4fa5fc0cce0d67 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 2 Aug 2021 23:11:40 -0400 Subject: [PATCH 23/65] do the dang void 0 stuff --- src/react/hooks/useQuery.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 0c41658585a..3006ffbe273 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -206,7 +206,7 @@ class QueryData { loading: true, networkStatus: NetworkStatus.loading, called: true, - data: undefined, + data: void 0, stale: false, client, } as QueryResult; @@ -235,8 +235,8 @@ class QueryData { // 4.0 to address this. if (skip) { Object.assign(result, { - data: undefined, - error: undefined, + data: void 0, + error: void 0, loading: false, networkStatus: NetworkStatus.ready, called: true, @@ -587,7 +587,7 @@ export function useQuery1< query, options, result, - data: undefined, + data: void 0, }); useEffect(() => { @@ -740,8 +740,8 @@ export function useQuery1< // changing this is breaking, so we'll have to wait until Apollo Client 4.0 // to address this. result = { - data: undefined, - error: undefined, + data: void 0, + error: void 0, loading: false, networkStatus: NetworkStatus.ready, }; From 8fa07667ac83eb8d15dd220854d8caade62b3b03 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 2 Aug 2021 23:16:00 -0400 Subject: [PATCH 24/65] fix skip test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update the getObservableQueries thingy to use `all` because apparently that’s a thing --- src/react/hooks/__tests__/useQuery.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 7b530109588..5c7744650ac 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2655,9 +2655,9 @@ describe('useQuery Hook', () => { { wrapper }, ); - expect(client.getObservableQueries().size).toBe(0); + expect(client.getObservableQueries('all').size).toBe(1); unmount(); - expect(client.getObservableQueries().size).toBe(0); + expect(client.getObservableQueries('all').size).toBe(0); }); }); From 2730e24ebde4077d97836d5277819bfa23c7b1e4 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 3 Aug 2021 00:02:45 -0400 Subject: [PATCH 25/65] implement ssr in the new useQuery1 hook --- src/react/context/ApolloContext.ts | 3 +- src/react/hooks/__tests__/useQuery.test.tsx | 96 +++++++------- src/react/hooks/useQuery.ts | 133 ++++++++++++++++---- src/react/ssr/__tests__/useQuery.test.tsx | 2 +- 4 files changed, 158 insertions(+), 76 deletions(-) diff --git a/src/react/context/ApolloContext.ts b/src/react/context/ApolloContext.ts index 9e899297881..6a0322bbfae 100644 --- a/src/react/context/ApolloContext.ts +++ b/src/react/context/ApolloContext.ts @@ -1,10 +1,11 @@ import * as React from 'react'; import { ApolloClient } from '../../core'; import { canUseWeakMap } from '../../utilities'; +import type { RenderPromises } from '../ssr'; export interface ApolloContextValue { client?: ApolloClient; - renderPromises?: Record; + renderPromises?: RenderPromises; } // To make sure Apollo Client doesn't create more than one React context diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 5c7744650ac..68e42ddcb22 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -16,7 +16,7 @@ import { ApolloProvider } from '../../context'; import { Observable, Reference, concatPagination } from '../../../utilities'; import { ApolloLink } from '../../../link/core'; import { itAsync, MockLink, MockedProvider, mockSingleLink } from '../../../testing'; -import { useQuery, useQuery1 } from '../useQuery'; +import { useQuery1 as useQuery } from '../useQuery'; import { useMutation } from '../useMutation'; describe('useQuery Hook', () => { @@ -35,7 +35,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query), + () => useQuery(query), { wrapper }, ); @@ -61,7 +61,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - () => useQuery1(query), + () => useQuery(query), { wrapper } ); expect(result.current.loading).toBe(true); @@ -91,7 +91,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query), + () => useQuery(query), { wrapper }, ); expect(result.current.loading).toBe(true); @@ -126,7 +126,7 @@ describe('useQuery Hook', () => { ); const { result } = renderHook( - () => useQuery1(query), + () => useQuery(query), { wrapper }, ); @@ -157,7 +157,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ id }) => useQuery1(query, { variables: { id }}), + ({ id }) => useQuery(query, { variables: { id }}), { wrapper, initialProps: { id: 1 } }, ); expect(result.current.loading).toBe(true); @@ -200,7 +200,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ id }) => useQuery1(query, { variables: { id } }), + ({ id }) => useQuery(query, { variables: { id } }), { wrapper, initialProps: { id: 1 } }, ); @@ -251,7 +251,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ name }) => useQuery1(query, { variables: { name } }), + ({ name }) => useQuery(query, { variables: { name } }), { wrapper, initialProps: { name: "" } }, ); @@ -318,7 +318,7 @@ describe('useQuery Hook', () => { let renderCount = 0; const InnerComponent = ({ something }: any) => { - const { loading, data } = useQuery1(CAR_QUERY, { + const { loading, data } = useQuery(CAR_QUERY, { fetchPolicy: 'network-only', variables: { something } }); @@ -330,7 +330,7 @@ describe('useQuery Hook', () => { }; function WrapperComponent({ something }: any) { - const { loading } = useQuery1(CAR_QUERY, { + const { loading } = useQuery(CAR_QUERY, { variables: { something } }); return loading ? null : ; @@ -367,7 +367,7 @@ describe('useQuery Hook', () => { ); const { unmount } = renderHook( - () => useQuery1(query), + () => useQuery(query), { wrapper }, ); @@ -548,7 +548,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { pollInterval: 10 }), + () => useQuery(query, { pollInterval: 10 }), { wrapper }, ); @@ -593,7 +593,7 @@ describe('useQuery Hook', () => { const cache = new InMemoryCache(); const { result, rerender, waitForNextUpdate } = renderHook( - ({ skip }) => useQuery1(query, { pollInterval: 10, skip }), + ({ skip }) => useQuery(query, { pollInterval: 10, skip }), { wrapper: ({ children }) => ( @@ -659,7 +659,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate, unmount } = renderHook( - () => useQuery1(query, { pollInterval: 10 }), + () => useQuery(query, { pollInterval: 10 }), { wrapper }, ); @@ -705,7 +705,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate, unmount } = renderHook( - () => useQuery1(query, { pollInterval: 10 }), + () => useQuery(query, { pollInterval: 10 }), { wrapper }, ); @@ -748,7 +748,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { pollInterval: 20 }), + () => useQuery(query, { pollInterval: 20 }), { wrapper }, ); @@ -791,7 +791,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate, unmount } = renderHook( - () => useQuery1(query), + () => useQuery(query), { wrapper }, ); @@ -833,7 +833,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query), + () => useQuery(query), { wrapper }, ); @@ -870,7 +870,7 @@ describe('useQuery Hook', () => { const onError = jest.fn(); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { onError, notifyOnNetworkStatusChange: true, }), @@ -918,7 +918,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - () => useQuery1(query), + () => useQuery(query), { wrapper }, ); @@ -965,7 +965,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - () => useQuery1(query, { onError: () => {}, onCompleted: () => {} }), + () => useQuery(query, { onError: () => {}, onCompleted: () => {} }), { wrapper }, ); @@ -1017,7 +1017,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { notifyOnNetworkStatusChange: true }), + () => useQuery(query, { notifyOnNetworkStatusChange: true }), { wrapper }, ); @@ -1077,7 +1077,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { notifyOnNetworkStatusChange: true }), + () => useQuery(query, { notifyOnNetworkStatusChange: true }), { wrapper }, ); @@ -1142,7 +1142,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { notifyOnNetworkStatusChange: true }), + () => useQuery(query, { notifyOnNetworkStatusChange: true }), { wrapper }, ); @@ -1300,7 +1300,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { variables: { limit: 2 } }), + () => useQuery(query, { variables: { limit: 2 } }), { wrapper }, ); @@ -1336,7 +1336,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { variables: { limit: 2 }, notifyOnNetworkStatusChange: true, }), @@ -1388,7 +1388,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { variables: { limit: 2 } }), + () => useQuery(query, { variables: { limit: 2 } }), { wrapper }, ); @@ -1424,7 +1424,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { variables: { limit: 2 }, notifyOnNetworkStatusChange: true, }), @@ -1574,7 +1574,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { variables: { id: 1 }, notifyOnNetworkStatusChange: true, }), @@ -1719,7 +1719,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { variables: { min: 0, max: 12 }, notifyOnNetworkStatusChange: true, // This is the key line in this test. @@ -1800,7 +1800,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { variables: { min: 0, max: 12 }, notifyOnNetworkStatusChange: true, // This is the key line in this test. @@ -1886,7 +1886,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { variables: { min: 0, max: 12 }, notifyOnNetworkStatusChange: true, // Intentionally not passing refetchWritePolicy. @@ -1963,7 +1963,7 @@ describe('useQuery Hook', () => { const onCompleted = jest.fn(); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { fetchPolicy: 'cache-only', onCompleted, }), @@ -1996,7 +1996,7 @@ describe('useQuery Hook', () => { const onCompleted = jest.fn(); const { result, rerender, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { onCompleted, }), { wrapper }, @@ -2038,7 +2038,7 @@ describe('useQuery Hook', () => { const onCompleted = jest.fn(); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { skip: true, onCompleted, }), @@ -2070,7 +2070,7 @@ describe('useQuery Hook', () => { const onCompleted = jest.fn(); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { + () => useQuery(query, { fetchPolicy: 'network-only', onCompleted, }), @@ -2186,7 +2186,7 @@ describe('useQuery Hook', () => { }, onError, }), - query: useQuery1(query), + query: useQuery(query), }), { wrapper }, ); @@ -2513,7 +2513,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { variables: { id: entityId } }), + () => useQuery(query, { variables: { id: entityId } }), { wrapper }, ); @@ -2586,7 +2586,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ skip }) => useQuery1(query, { skip }), + ({ skip }) => useQuery(query, { skip }), { wrapper, initialProps: { skip: true } }, ); @@ -2621,7 +2621,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ skip, variables }) => useQuery1(query, { skip, variables }), + ({ skip, variables }) => useQuery(query, { skip, variables }), { wrapper, initialProps: { skip: false, variables: undefined as any } }, ); @@ -2651,7 +2651,7 @@ describe('useQuery Hook', () => { ); const { unmount } = renderHook( - () => useQuery1(query, { skip: true }), + () => useQuery(query, { skip: true }), { wrapper }, ); @@ -2704,7 +2704,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(carQuery, { variables: { id: 1 } }), + () => useQuery(carQuery, { variables: { id: 1 } }), { wrapper }, ); @@ -2916,7 +2916,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { notifyOnNetworkStatusChange: true }), + () => useQuery(query, { notifyOnNetworkStatusChange: true }), { wrapper }, ); @@ -3002,7 +3002,7 @@ describe('useQuery Hook', () => { ); const { result, waitForNextUpdate } = renderHook( - () => useQuery1(query, { notifyOnNetworkStatusChange: true }), + () => useQuery(query, { notifyOnNetworkStatusChange: true }), { wrapper }, ); @@ -3083,7 +3083,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ gender }) => useQuery1(query, { + ({ gender }) => useQuery(query, { variables: { gender }, fetchPolicy: 'network-only', }), @@ -3170,7 +3170,7 @@ describe('useQuery Hook', () => { ); const { result, rerender, waitForNextUpdate } = renderHook( - ({ canonizeResults }) => useQuery1(query, { + ({ canonizeResults }) => useQuery(query, { fetchPolicy: 'cache-only', canonizeResults, }), @@ -3275,8 +3275,8 @@ describe('useQuery Hook', () => { const client = makeClient(); const { result, waitForNextUpdate } = renderHook( () => ({ - a: useQuery1(aQuery, { fetchPolicy: aFetchPolicy }), - b: useQuery1(bQuery, { fetchPolicy: bFetchPolicy }), + a: useQuery(aQuery, { fetchPolicy: aFetchPolicy }), + b: useQuery(bQuery, { fetchPolicy: bFetchPolicy }), }), { wrapper: ({ children }) => ( diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 3006ffbe273..6f686c53045 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -69,12 +69,6 @@ function prepareOptions( } -// The interface expected by RenderPromises -interface QueryData { - getOptions(): QueryDataOptions; - fetchData(): Promise | boolean; -} - class QueryData { public isMounted: boolean; public context: ApolloContextValue; @@ -171,7 +165,7 @@ class QueryData { if (this.context && this.context.renderPromises) { this.currentObservable = this.context.renderPromises.getSSRObservable( this.options, - ); + ) || undefined; } if (!this.currentObservable) { @@ -313,7 +307,7 @@ class QueryData { this.currentObservable && this.currentObservable.resetQueryStoreErrors(); if (this.context.renderPromises && result.loading && !skip) { - this.context.renderPromises!.addQueryPromise(this, () => null); + this.context.renderPromises.addQueryPromise(this as any, () => null); } // TODO(brian): Stop assigning this here!!!! @@ -544,8 +538,11 @@ export function useQuery( return result; } -//TODO -//- SSR +// The interface expected by RenderPromises +export interface QueryData1 { + getOptions(): QueryDataOptions; + fetchData(): Promise | boolean; +} export function useQuery1< TData = any, @@ -563,18 +560,85 @@ export function useQuery1< 'ApolloClient instance in via options.', ); verifyDocumentType(query, DocumentType.Query); - const options = useMemo(() => { - const { skip, ...options } = { ...hookOptions, query }; + const { skip, ssr, partialRefetch, options } = useMemo(() => { + const { skip, ssr, partialRefetch, ...options } = { ...hookOptions, query }; if (skip) { options.fetchPolicy = 'standby'; + } else if ( + context.renderPromises && + ( + options.fetchPolicy === 'network-only' || + options.fetchPolicy === 'cache-and-network' + ) + ) { + // this behavior was added to react-apollo without explanation in this PR + // https://github.com/apollographql/react-apollo/pull/1579 + options.fetchPolicy = 'cache-first'; } else if (!options.fetchPolicy) { + // cache-first is the default policy, but we explicitly assign it here so + // the cache policies computed based on optiosn can be cleared options.fetchPolicy = 'cache-first'; } - return options; - }, [hookOptions]); + return { skip, ssr, partialRefetch, options }; + }, [hookOptions, context.renderPromises]); + const { onCompleted, onError } = options; - const [obsQuery, setObsQuery] = useState(() => client.watchQuery(options)); + const [obsQuery, setObsQuery] = useState(() => { + // See if there is an existing observable that was used to fetch the same + // data and if so, use it instead since it will contain the proper queryId + // to fetch the result set. This is used during SSR. + let obsQuery: ObservableQuery | null = null; + if (context.renderPromises) { + obsQuery = context.renderPromises.getSSRObservable(options); + } + + if (!obsQuery) { + obsQuery = client.watchQuery(options); + if (context.renderPromises) { + context.renderPromises.registerSSRObservable( + obsQuery, + options, + ); + } + } + + if ( + context.renderPromises && + ssr !== false && + !skip && + obsQuery.getCurrentResult().loading + ) { + context.renderPromises.addQueryPromise( + { + // The only options which seem to actually be used by the + // RenderPromises class are query and variables. + getOptions: () => options, + fetchData: () => new Promise((resolve) => { + const sub = obsQuery!.subscribe( + (result) => { + if (!result.loading) { + resolve() + sub.unsubscribe(); + } + }, + () => { + resolve(); + sub.unsubscribe(); + }, + () => { + resolve(); + }, + ); + }), + } as any, + () => null, + ); + } + + return obsQuery; + }); + let [result, setResult] = useState(() => obsQuery.getCurrentResult()); const prevRef = useRef<{ client: ApolloClient, @@ -614,6 +678,10 @@ export function useQuery1< }, [obsQuery, client, query, options]); useEffect(() => { + if (context.renderPromises || client.disableNetworkFetches) { + return; + } + function onNext() { const previousResult = prevRef.current.result; // We use `getCurrentResult()` instead of the callback argument because @@ -664,18 +732,18 @@ export function useQuery1< (previousResult && previousResult.loading) || !equal(error, previousResult.error) ) { - setResult((result) => ({ - ...result, + setResult({ + data: previousResult.data, error: error as ApolloError, loading: false, networkStatus: NetworkStatus.error, - })); + }); } } let sub = obsQuery.subscribe(onNext, onError); return () => sub.unsubscribe(); - }, [obsQuery]); + }, [obsQuery, client.disableNetworkFetches, context.renderPromises]); const obsQueryFields = useMemo(() => ({ refetch: obsQuery.refetch.bind(obsQuery), @@ -686,6 +754,7 @@ export function useQuery1< subscribeToMore: obsQuery.subscribeToMore.bind(obsQuery), }), [obsQuery]); + // calling the onCompleted and onError callbacks useEffect(() => { if (!result.loading) { if (result.error) { @@ -697,8 +766,8 @@ export function useQuery1< // TODO: Do we need to add onCompleted and onError to the dependency array }, [result, onCompleted, onError]); - - // TODO: This effect should be removed when partialRefetch is removed. + // TODO: This effect should be removed when the partialRefetch option is + // removed. useEffect(() => { // When a `Query` component is mounted, and a mutation is executed // that returns the same ID as the mounted `Query`, but has less @@ -709,7 +778,7 @@ export function useQuery1< // exist, and they're all of a sudden stripped away. To help avoid // this we'll attempt to refetch the `Query` data. if ( - hookOptions?.partialRefetch && + partialRefetch && result.partial && (!result.data || Object.keys(result.data).length === 0) && obsQuery.options.fetchPolicy !== 'cache-only' @@ -722,13 +791,25 @@ export function useQuery1< setTimeout(() => obsQuery.refetch()); } }, [ - hookOptions?.partialRefetch, + partialRefetch, result.data, result.partial, obsQuery.options.fetchPolicy, ]); - if (hookOptions?.skip) { + if ( + ssr === false && + (context.renderPromises || client.disableNetworkFetches) + ) { + // If SSR has been explicitly disabled, and this function has been called + // on the server side, return the default loading state. + result = prevRef.current.result = { + loading: true, + data: void 0 as unknown as TData, + error: void 0, + networkStatus: NetworkStatus.loading, + }; + } else if (skip) { // When skipping a query (ie. we're not querying for data but still want to // render children), make sure the `data` is cleared out and `loading` is // set to `false` (since we aren't loading anything). @@ -740,9 +821,9 @@ export function useQuery1< // changing this is breaking, so we'll have to wait until Apollo Client 4.0 // to address this. result = { - data: void 0, - error: void 0, loading: false, + data: void 0 as unknown as TData, + error: void 0, networkStatus: NetworkStatus.ready, }; } diff --git a/src/react/ssr/__tests__/useQuery.test.tsx b/src/react/ssr/__tests__/useQuery.test.tsx index cbf86af997c..167eebb8bf2 100644 --- a/src/react/ssr/__tests__/useQuery.test.tsx +++ b/src/react/ssr/__tests__/useQuery.test.tsx @@ -5,7 +5,7 @@ import { MockedProvider, mockSingleLink } from '../../../testing'; import { ApolloClient } from '../../../core'; import { InMemoryCache } from '../../../cache'; import { ApolloProvider } from '../../context'; -import { useApolloClient, useQuery } from '../../hooks'; +import { useApolloClient, useQuery1 as useQuery } from '../../hooks'; import { render, wait } from '@testing-library/react'; import { renderToStringWithData } from '..'; From 4fcb5e38c0a1aa8753d791f66f5aecc07711b18a Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 5 Aug 2021 16:22:04 -0400 Subject: [PATCH 26/65] delete useQuery1 --- src/__tests__/__snapshots__/exports.ts.snap | 3 - src/react/components/Query.tsx | 2 +- src/react/hooks/__tests__/useQuery.test.tsx | 2 +- src/react/hooks/useQuery.ts | 529 +------------------- src/react/ssr/__tests__/useQuery.test.tsx | 2 +- 5 files changed, 20 insertions(+), 518 deletions(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 99d63de7d7e..3835a31be88 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -55,7 +55,6 @@ Array [ "useLazyQuery", "useMutation", "useQuery", - "useQuery1", "useReactiveVar", "useSubscription", ] @@ -235,7 +234,6 @@ Array [ "useLazyQuery", "useMutation", "useQuery", - "useQuery1", "useReactiveVar", "useSubscription", ] @@ -283,7 +281,6 @@ Array [ "useLazyQuery", "useMutation", "useQuery", - "useQuery1", "useReactiveVar", "useSubscription", ] diff --git a/src/react/components/Query.tsx b/src/react/components/Query.tsx index ed33d9890f1..f70d33918b0 100644 --- a/src/react/components/Query.tsx +++ b/src/react/components/Query.tsx @@ -9,7 +9,7 @@ export function Query( ) { const { children, query, ...options } = props; const result = useQuery(query, options); - return result ? children(result) : null; + return result ? children(result as any) : null; } export interface Query { diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 68e42ddcb22..92bb30db7d8 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -16,7 +16,7 @@ import { ApolloProvider } from '../../context'; import { Observable, Reference, concatPagination } from '../../../utilities'; import { ApolloLink } from '../../../link/core'; import { itAsync, MockLink, MockedProvider, mockSingleLink } from '../../../testing'; -import { useQuery1 as useQuery } from '../useQuery'; +import { useQuery } from '../useQuery'; import { useMutation } from '../useMutation'; describe('useQuery Hook', () => { diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 6f686c53045..713db266195 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -2,43 +2,30 @@ import { useContext, useEffect, useMemo, - useReducer, useRef, useState, } from 'react'; import { invariant } from 'ts-invariant'; import { equal } from '@wry/equality'; import { OperationVariables } from '../../core'; -import { ApolloContextValue, getApolloContext } from '../context'; +import { getApolloContext } from '../context'; import { ApolloError } from '../../errors'; import { ApolloClient, ApolloQueryResult, NetworkStatus, - FetchMoreQueryOptions, - SubscribeToMoreOptions, ObservableQuery, - FetchMoreOptions, - UpdateQueryOptions, DocumentNode, TypedDocumentNode, - WatchQueryOptions, } from '../../core'; import { QueryDataOptions, QueryHookOptions, QueryResult, - ObservableQueryFields, } from '../types/types'; -import { - ObservableSubscription -} from '../../utilities'; import { DocumentType, parser, operationName } from '../parser'; -import { useDeepMemo } from './utils/useDeepMemo'; -import { useAfterFastRefresh } from './utils/useAfterFastRefresh'; - function verifyDocumentType(document: DocumentNode, type: DocumentType) { const operation = parser(document); const requiredOperationName = operationName(type); @@ -50,501 +37,7 @@ function verifyDocumentType(document: DocumentNode, type: DocumentType) { ); } -function prepareOptions( - options: QueryDataOptions, - context: ApolloContextValue, -): WatchQueryOptions { - return { - ...options, - fetchPolicy: - context && - context.renderPromises && - ( - options.fetchPolicy === 'network-only' || - options.fetchPolicy === 'cache-and-network' - ) - ? 'cache-first' - : options.fetchPolicy, - }; -} - - -class QueryData { - public isMounted: boolean; - public context: ApolloContextValue; - private options: QueryDataOptions; - private onNewData: () => void; - private currentObservable?: ObservableQuery; - private currentSubscription?: ObservableSubscription; - private previous: { - client?: ApolloClient; - options?: QueryDataOptions; - // TODO(brian): deduplicate with - query?: DocumentNode | TypedDocumentNode; - observableQueryOptions?: WatchQueryOptions; - // TODO(brian): previous.result should be assigned once in an update, we - // shouldn’t have loading/error defined separately - result?: QueryResult; - loading?: boolean; - error?: ApolloError; - }; - - constructor({ - options, - context, - onNewData - }: { - options: QueryDataOptions; - context: ApolloContextValue; - onNewData: () => void; - }) { - this.options = options || ({} as QueryDataOptions); - this.context = context || {}; - this.onNewData = onNewData; - this.isMounted = false; - this.previous = {}; - } - - public getOptions(): QueryDataOptions { - return this.options; - } - - public fetchData(): Promise | boolean { - const options = this.options; - if (options.skip || options.ssr === false) return false; - return new Promise(resolve => this.startQuerySubscription(resolve)); - } - - public cleanup() { - if (this.currentSubscription) { - this.currentSubscription.unsubscribe(); - delete this.currentSubscription; - } - - if (this.currentObservable) { - // TODO(brian): HISSSSSSSSSSSSSSSSSSSSSSS BAD HISSSSSSSSSSSSSSSS - this.currentObservable["tearDownQuery"](); - delete this.currentObservable; - } - } - - public execute( - client: ApolloClient, - options: QueryDataOptions - ): QueryResult { - // TODO: STOP ASSIGNING OPTIONS HERE - if (!equal(this.options, options)) { - this.previous.options = this.options; - this.options = options; - } - - const { skip, query, ssr } = this.options; - verifyDocumentType(query, DocumentType.Query); - // if skip, query, or client are different we restart? - if (this.previous.client !== client) { - if (this.previous.client) { - this.cleanup(); - delete this.previous.result; - } - - this.previous.client = client; - } - - if (skip || query !== this.previous.query) { - this.cleanup(); - this.previous = Object.create(null); - this.previous.query = query; - } - - const observableQueryOptions = prepareOptions(this.options, this.context); - // If we skipped initially, we may not have yet created the observable - if (!this.currentObservable) { - // See if there is an existing observable that was used to fetch the same - // data and if so, use it instead since it will contain the proper - // queryId to fetch the result set. This is used during SSR. - if (this.context && this.context.renderPromises) { - this.currentObservable = this.context.renderPromises.getSSRObservable( - this.options, - ) || undefined; - } - - if (!this.currentObservable) { - this.previous.observableQueryOptions = observableQueryOptions; - this.currentObservable = client.watchQuery(observableQueryOptions); - - if (this.context && this.context.renderPromises) { - this.context.renderPromises.registerSSRObservable( - this.currentObservable, - observableQueryOptions - ); - } - } - } else if (skip) { - this.previous.observableQueryOptions = observableQueryOptions; - } else if ( - !equal(observableQueryOptions, this.previous.observableQueryOptions) - ) { - this.previous.observableQueryOptions = observableQueryOptions; - this.currentObservable - .setOptions(observableQueryOptions) - // The error will be passed to the child container, so we don't - // need to log it here. We could conceivably log something if - // an option was set. OTOH we don't log errors w/ the original - // query. See https://github.com/apollostack/react-apollo/issues/404 - .catch(() => {}); - } - - const ssrDisabled = ssr === false; - const ssrLoading = { - ...this.observableQueryFields(), - loading: true, - networkStatus: NetworkStatus.loading, - called: true, - data: void 0, - stale: false, - client, - } as QueryResult; - - // If SSR has been explicitly disabled, and this function has been called - // on the server side, return the default loading state. - if ( - ssrDisabled && - (this.context.renderPromises || client.disableNetworkFetches) - ) { - // TODO(brian): Don’t assign this here. - this.previous.result = ssrLoading; - return ssrLoading; - } - - const result = this.observableQueryFields() as QueryResult; - // When skipping a query (ie. we're not querying for data but still want - // to render children), make sure the `data` is cleared out and - // `loading` is set to `false` (since we aren't loading anything). - // - // NOTE: We no longer think this is the correct behavior. Skipping should - // not automatically set `data` to `undefined`, but instead leave the - // previous data in place. In other words, skipping should not mandate - // that previously received data is all of a sudden removed. Unfortunately, - // changing this is breaking, so we'll have to wait until Apollo Client - // 4.0 to address this. - if (skip) { - Object.assign(result, { - data: void 0, - error: void 0, - loading: false, - networkStatus: NetworkStatus.ready, - called: true, - }); - } else if (this.currentObservable) { - // Fetch the current result (if any) from the store. - const currentResult = this.currentObservable.getCurrentResult(); - const { data, loading, partial, networkStatus, errors } = currentResult; - let { error } = currentResult; - - // Until a set naming convention for networkError and graphQLErrors is - // decided upon, we map errors (graphQLErrors) to the error options. - if (errors && errors.length > 0) { - error = new ApolloError({ graphQLErrors: errors }); - } - - Object.assign(result, { - data, - loading, - networkStatus, - error, - called: true - }); - - if (loading) { - // Fall through without modifying result... - } else if (error) { - Object.assign(result, { - data: (this.currentObservable.getLastResult() || ({} as any)) - .data - }); - } else { - const { fetchPolicy } = this.currentObservable.options; - const { partialRefetch } = this.options; - if ( - partialRefetch && - partial && - (!data || Object.keys(data).length === 0) && - fetchPolicy !== 'cache-only' - ) { - // When a `Query` component is mounted, and a mutation is executed - // that returns the same ID as the mounted `Query`, but has less - // fields in its result, Apollo Client's `QueryManager` returns the - // data as `undefined` since a hit can't be found in the cache. - // This can lead to application errors when the UI elements rendered by - // the original `Query` component are expecting certain data values to - // exist, and they're all of a sudden stripped away. To help avoid - // this we'll attempt to refetch the `Query` data. - Object.assign(result, { - loading: true, - networkStatus: NetworkStatus.loading - }); - result.refetch(); - return result; - } - } - } - - result.client = client; - const previousResult = this.previous.result; - - // Ensure the returned result contains previousData as a separate - // property, to give developers the flexibility of leveraging outdated - // data while new data is loading from the network. Falling back to - // previousResult.previousData when previousResult.data is falsy here - // allows result.previousData to persist across multiple results. - result.previousData = previousResult && - (previousResult.data || previousResult.previousData); - - // Any query errors that exist are now available in `result`, so we'll - // remove the original errors from the `ObservableQuery` query store to - // make sure they aren't re-displayed on subsequent (potentially error - // free) requests/responses. - this.currentObservable && this.currentObservable.resetQueryStoreErrors(); - - if (this.context.renderPromises && result.loading && !skip) { - this.context.renderPromises.addQueryPromise(this as any, () => null); - } - - // TODO(brian): Stop assigning this here!!!! - this.previous.loading = - previousResult && previousResult.loading || false; - this.previous.result = result; - - return result; - } - - public afterExecute(client: ApolloClient) { - this.isMounted = true; - if ( - this.currentObservable && - !client.disableNetworkFetches && - !(this.context && this.context.renderPromises) - ) { - this.startQuerySubscription(); - } - - if (this.currentObservable && this.previous.result) { - const { data, loading, error } = this.previous.result; - if (!loading) { - const { - query, - variables, - onCompleted, - onError, - skip - } = this.options; - - // No changes, so we won't call onError/onCompleted. - if ( - this.previous.options && - !this.previous.loading && - equal(this.previous.options.query, query) && - equal(this.previous.options.variables, variables) - ) { - return; - } - - // TODO(brian): Why would we not fire onCompleted on skip? - // Why would we not apply the same logic for onError? - if (onCompleted && !error && !skip) { - onCompleted(data as TData); - } else if (onError && error) { - onError(error); - } - } - } - - return () => { - this.isMounted = false; - }; - } - - // Setup a subscription to watch for Apollo Client `ObservableQuery` changes. - // When new data is received, and it doesn't match the data that was used - // during the last `QueryData.execute` call (and ultimately the last query - // component render), trigger the `onNewData` callback. If not specified, - // `onNewData` will fallback to the default `QueryData.onNewData` function - // (which usually leads to a query component re-render). - private startQuerySubscription(onNewData: () => void = this.onNewData) { - if (this.currentSubscription || this.options.skip) return; - - this.currentSubscription = this.currentObservable!.subscribe({ - next: ({ loading, networkStatus, data }) => { - const previousResult = this.previous.result; - - // Make sure we're not attempting to re-render similar results - if ( - previousResult && - previousResult.loading === loading && - previousResult.networkStatus === networkStatus && - equal(previousResult.data, data) - ) { - return; - } - - onNewData(); - }, - error: error => { - if (this.currentSubscription) { - this.currentSubscription.unsubscribe(); - delete this.currentSubscription; - } - - // Unfortunately, if `lastError` is set in the current - // `observableQuery` when the subscription is re-created, - // the subscription will immediately receive the error, which will - // cause it to terminate again. To avoid this, we first clear - // the last error/result from the `observableQuery` before re-starting - // the subscription, and restore it afterwards (so the subscription - // has a chance to stay open). - const { currentObservable } = this; - if (currentObservable) { - // TODO(brian): WHAT THE FUCK - const lastError = currentObservable.getLastError(); - const lastResult = currentObservable.getLastResult(); - currentObservable.resetLastResults(); - this.startQuerySubscription(); - Object.assign(currentObservable, { - lastError, - lastResult - }); - } - - if (!error.hasOwnProperty('graphQLErrors')) throw error; - - const previousResult = this.previous.result; - if ( - (previousResult && previousResult.loading) || - !equal(error, this.previous.error) - ) { - this.previous.error = error; - onNewData(); - } - } - }); - } - - // observableQueryFields - - private obsRefetch = (variables?: Partial) => - this.currentObservable?.refetch(variables); - - private obsFetchMore = ( - fetchMoreOptions: FetchMoreQueryOptions & - FetchMoreOptions - ) => this.currentObservable?.fetchMore(fetchMoreOptions); - - private obsUpdateQuery = ( - mapFn: ( - previousQueryResult: TData, - options: UpdateQueryOptions - ) => TData - ) => this.currentObservable?.updateQuery(mapFn); - - private obsStartPolling = (pollInterval: number) => { - this.currentObservable?.startPolling(pollInterval); - }; - - private obsStopPolling = () => { - this.currentObservable?.stopPolling(); - }; - - private obsSubscribeToMore = < - TSubscriptionData = TData, - TSubscriptionVariables = TVariables - >( - options: SubscribeToMoreOptions< - TData, - TSubscriptionVariables, - TSubscriptionData - > - ) => this.currentObservable?.subscribeToMore(options); - - private observableQueryFields() { - return { - variables: this.currentObservable?.variables, - refetch: this.obsRefetch, - fetchMore: this.obsFetchMore, - updateQuery: this.obsUpdateQuery, - startPolling: this.obsStartPolling, - stopPolling: this.obsStopPolling, - subscribeToMore: this.obsSubscribeToMore - } as ObservableQueryFields; - } -} - -export function useQuery( - query: DocumentNode | TypedDocumentNode, - options?: QueryHookOptions, -) { - const context = useContext(getApolloContext()); - const client = options?.client || context.client; - invariant( - !!client, - 'Could not find "client" in the context or passed in as an option. ' + - 'Wrap the root component in an , or pass an ' + - 'ApolloClient instance in via options.' - ); - - const [tick, forceUpdate] = useReducer(x => x + 1, 0); - const optionsWithQuery: QueryDataOptions = { - ...options, - query, - }; - - const queryDataRef = useRef>(); - const queryData = queryDataRef.current || ( - queryDataRef.current = new QueryData({ - options: optionsWithQuery, - context, - onNewData() { - if (queryDataRef.current && queryDataRef.current.isMounted) { - forceUpdate(); - } - } - }) - ); - - queryData.context = context; - const result = useDeepMemo( - () => queryData.execute(client, optionsWithQuery), - [optionsWithQuery, context, tick], - ); - - if (__DEV__) { - // ensure we run an update after refreshing so that we reinitialize - useAfterFastRefresh(forceUpdate); - } - - useEffect(() => queryData.afterExecute(client), [ - result.loading, - result.networkStatus, - result.error, - result.data, - ]); - - useEffect(() => () => { - queryData.cleanup(); - // this effect can run multiple times during a fast-refresh so make sure - // we clean up the ref - queryDataRef.current = void 0; - }, []); - - return result; -} - -// The interface expected by RenderPromises -export interface QueryData1 { - getOptions(): QueryDataOptions; - fetchData(): Promise | boolean; -} - -export function useQuery1< +export function useQuery< TData = any, TVariables = OperationVariables, >( @@ -560,6 +53,8 @@ export function useQuery1< 'ApolloClient instance in via options.', ); verifyDocumentType(query, DocumentType.Query); + + // create watchQueryOptions from hook options const { skip, ssr, partialRefetch, options } = useMemo(() => { const { skip, ssr, partialRefetch, ...options } = { ...hookOptions, query }; if (skip) { @@ -609,6 +104,7 @@ export function useQuery1< !skip && obsQuery.getCurrentResult().loading ) { + // TODO: This is a legacy API which could probably be cleaned up context.renderPromises.addQueryPromise( { // The only options which seem to actually be used by the @@ -654,6 +150,9 @@ export function useQuery1< data: void 0, }); + // An effect to recreate the obsQuery whenever the client or query changes. + // This effect is also responsible for checking and updating the obsQuery + // options whenever they change. useEffect(() => { if ( prevRef.current.client !== client || @@ -677,6 +176,7 @@ export function useQuery1< Object.assign(prevRef.current, { client, query, options }); }, [obsQuery, client, query, options]); + // An effect to subscribe to the current observable query useEffect(() => { if (context.renderPromises || client.disableNetworkFetches) { return; @@ -754,7 +254,7 @@ export function useQuery1< subscribeToMore: obsQuery.subscribeToMore.bind(obsQuery), }), [obsQuery]); - // calling the onCompleted and onError callbacks + // An effect which calls the onCompleted and onError callbacks useEffect(() => { if (!result.loading) { if (result.error) { @@ -766,6 +266,11 @@ export function useQuery1< // TODO: Do we need to add onCompleted and onError to the dependency array }, [result, onCompleted, onError]); + let partial: boolean | undefined; + ({ partial, ...result } = result); + // An effect which refetches queries when there is incomplete data in the + // cache. + // // TODO: This effect should be removed when the partialRefetch option is // removed. useEffect(() => { @@ -779,7 +284,7 @@ export function useQuery1< // this we'll attempt to refetch the `Query` data. if ( partialRefetch && - result.partial && + partial && (!result.data || Object.keys(result.data).length === 0) && obsQuery.options.fetchPolicy !== 'cache-only' ) { @@ -792,8 +297,8 @@ export function useQuery1< } }, [ partialRefetch, + partial, result.data, - result.partial, obsQuery.options.fetchPolicy, ]); diff --git a/src/react/ssr/__tests__/useQuery.test.tsx b/src/react/ssr/__tests__/useQuery.test.tsx index 167eebb8bf2..cbf86af997c 100644 --- a/src/react/ssr/__tests__/useQuery.test.tsx +++ b/src/react/ssr/__tests__/useQuery.test.tsx @@ -5,7 +5,7 @@ import { MockedProvider, mockSingleLink } from '../../../testing'; import { ApolloClient } from '../../../core'; import { InMemoryCache } from '../../../cache'; import { ApolloProvider } from '../../context'; -import { useApolloClient, useQuery1 as useQuery } from '../../hooks'; +import { useApolloClient, useQuery } from '../../hooks'; import { render, wait } from '@testing-library/react'; import { renderToStringWithData } from '..'; From baf31cd7d327aec0e6b48a56c7efd5a27678e562 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 5 Aug 2021 16:22:43 -0400 Subject: [PATCH 27/65] fix previousResult not updating on error --- src/react/hooks/useQuery.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 713db266195..dd943c55080 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -224,6 +224,7 @@ export function useQuery< } if (!error.hasOwnProperty('graphQLErrors')) { + // The error is not a graphQL error throw error; } @@ -232,12 +233,13 @@ export function useQuery< (previousResult && previousResult.loading) || !equal(error, previousResult.error) ) { - setResult({ + prevRef.current.result = { data: previousResult.data, error: error as ApolloError, loading: false, networkStatus: NetworkStatus.error, - }); + }; + setResult(prevRef.current.result); } } @@ -333,6 +335,12 @@ export function useQuery< }; } + // TODO: Is this still necessary? + // Any query errors that exist are now available in `result`, so we'll + // remove the original errors from the `ObservableQuery` query store to + // make sure they aren't re-displayed on subsequent (potentially error + // free) requests/responses. + obsQuery.resetQueryStoreErrors() return { ...obsQueryFields, variables: obsQuery.variables, From e1c197c98cc66e569eb161e1ec6e04cc781c65b3 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 6 Aug 2021 15:06:55 -0400 Subject: [PATCH 28/65] remove an unnecessary setResult call in partialRefetch effect --- src/react/hooks/useQuery.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index dd943c55080..97271fd617d 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -89,6 +89,7 @@ export function useQuery< } if (!obsQuery) { + // Is it safe (StrictMode/memory-wise) to call client.watchQuery here? obsQuery = client.watchQuery(options); if (context.renderPromises) { context.renderPromises.registerSSRObservable( @@ -290,11 +291,6 @@ export function useQuery< (!result.data || Object.keys(result.data).length === 0) && obsQuery.options.fetchPolicy !== 'cache-only' ) { - setResult((result) => ({ - ...result, - loading: true, - networkStatus: NetworkStatus.loading, - })); setTimeout(() => obsQuery.refetch()); } }, [ From fbd4e626820252a5b23044736c79d675804510c0 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 6 Aug 2021 15:44:04 -0400 Subject: [PATCH 29/65] bring back the old errors to error behavior in useQuery --- src/react/hooks/useQuery.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 97271fd617d..155feec1070 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -329,6 +329,14 @@ export function useQuery< error: void 0, networkStatus: NetworkStatus.ready, }; + } else if (result.errors && result.errors.length) { + // Until a set naming convention for networkError and graphQLErrors is + // decided upon, we map errors (graphQLErrors) to the error options. + // TODO: Is it possible for both result.error and result.errors to be defined here? + result = { + ...result, + error: result.error || new ApolloError({ graphQLErrors: result.errors }), + }; } // TODO: Is this still necessary? From a5dd4034caffc52695e3f23b7b9345fea9a26624 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 9 Aug 2021 18:33:30 -0400 Subject: [PATCH 30/65] reset the result when the client changes --- src/react/hooks/useQuery.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 155feec1070..76934c6b11e 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -155,23 +155,27 @@ export function useQuery< // This effect is also responsible for checking and updating the obsQuery // options whenever they change. useEffect(() => { + let nextResult: ApolloQueryResult | undefined; if ( prevRef.current.client !== client || !equal(prevRef.current.query, query) ) { - setObsQuery(client.watchQuery(options)); + const obsQuery = client.watchQuery(options); + setObsQuery(obsQuery); + nextResult = obsQuery.getCurrentResult(); + } else if (!equal(prevRef.current.options, options)) { + obsQuery.setOptions(options).catch(() => {}); + nextResult = obsQuery.getCurrentResult(); } - if (!equal(prevRef.current.options, options)) { - obsQuery.setOptions(options).catch(() => {}); - const result = obsQuery.getCurrentResult(); + if (nextResult) { const previousResult = prevRef.current.result; if (previousResult.data) { prevRef.current.data = previousResult.data; } - prevRef.current.result = result; - setResult(result); + prevRef.current.result = nextResult; + setResult(nextResult); } Object.assign(prevRef.current, { client, query, options }); From 504639531512a24ecb5a2b263c52df748cf2bb1b Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 6 Aug 2021 15:07:17 -0400 Subject: [PATCH 31/65] update some test timings --- .../__tests__/client/Mutation.test.tsx | 139 ++++--- .../__tests__/client/Query.test.tsx | 276 ++++++++----- .../client/__snapshots__/Query.test.tsx.snap | 61 --- .../hoc/__tests__/queries/errors.test.tsx | 48 ++- .../hoc/__tests__/queries/index.test.tsx | 13 +- .../hoc/__tests__/queries/lifecycle.test.tsx | 384 ++++++++++-------- .../hoc/__tests__/queries/loading.test.tsx | 161 +++++--- src/react/hoc/__tests__/queries/skip.test.tsx | 173 ++++---- 8 files changed, 714 insertions(+), 541 deletions(-) delete mode 100644 src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap diff --git a/src/react/components/__tests__/client/Mutation.test.tsx b/src/react/components/__tests__/client/Mutation.test.tsx index 272e1b5ed48..a1e44221531 100644 --- a/src/react/components/__tests__/client/Mutation.test.tsx +++ b/src/react/components/__tests__/client/Mutation.test.tsx @@ -156,24 +156,28 @@ describe('General Mutation testing', () => { expect(spy).toHaveBeenCalledWith(mocksProps[1].result); }); - it('performs a mutation', async () => { + it('performs a mutation', () => new Promise((resolve, reject) => { let count = 0; const Component = () => ( {(createTodo: any, result: any) => { - if (count === 0) { - expect(result.loading).toEqual(false); - expect(result.called).toEqual(false); - createTodo(); - } else if (count === 1) { - expect(result.called).toEqual(true); - expect(result.loading).toEqual(true); - } else if (count === 2) { - expect(result.called).toEqual(true); - expect(result.loading).toEqual(false); - expect(result.data).toEqual(data); + try { + if (count === 0) { + expect(result.loading).toEqual(false); + expect(result.called).toEqual(false); + createTodo(); + } else if (count === 1) { + expect(result.called).toEqual(true); + expect(result.loading).toEqual(true); + } else if (count === 2) { + expect(result.called).toEqual(true); + expect(result.loading).toEqual(false); + expect(result.data).toEqual(data); + } + count++; + } catch (err) { + reject(err); } - count++; return
; }} @@ -185,8 +189,8 @@ describe('General Mutation testing', () => { ); - await wait(); - }); + wait().then(resolve, reject); + })); it('can bind only the mutation and not rerender by props', done => { let count = 0; @@ -922,7 +926,7 @@ describe('General Mutation testing', () => { }); }); - it('allows a refetchQueries prop as string and variables have updated', async () => { + it('allows a refetchQueries prop as string and variables have updated', async () => new Promise((resolve, reject) => { const query = gql` query people($first: Int) { allPeople(first: $first) { @@ -978,33 +982,42 @@ describe('General Mutation testing', () => { {(createTodo: any, resultMutation: any) => ( {(resultQuery: any) => { - if (count === 0) { - // "first: 1" loading - expect(resultQuery.loading).toBe(true); - } else if (count === 1) { - // "first: 1" loaded - expect(resultQuery.loading).toBe(false); - setTimeout(() => setVariables({ first: 2 })); - } else if (count === 2) { - // "first: 2" loading - expect(resultQuery.loading).toBe(true); - } else if (count === 3) { - // "first: 2" loaded - expect(resultQuery.loading).toBe(false); - setTimeout(() => createTodo()); - } else if (count === 4) { - // mutation loading - expect(resultMutation.loading).toBe(true); - } else if (count === 5) { - // mutation loaded - expect(resultMutation.loading).toBe(false); - } else if (count === 6) { - // query refetched - expect(resultQuery.loading).toBe(false); - expect(resultMutation.loading).toBe(false); - expect(resultQuery.data).toEqual(peopleData3); + try { + if (count === 0) { + // "first: 1" loading + expect(resultQuery.loading).toBe(true); + } else if (count === 1) { + // "first: 1" loaded + expect(resultQuery.loading).toBe(false); + expect(resultQuery.data).toEqual(peopleData1); + setTimeout(() => setVariables({ first: 2 })); + } else if (count === 2) { + expect(resultQuery.loading).toBe(false); + expect(resultQuery.data).toEqual(peopleData1); + } else if (count === 3) { + // "first: 2" loading + expect(resultQuery.loading).toBe(true); + } else if (count === 4) { + // "first: 2" loaded + expect(resultQuery.loading).toBe(false); + expect(resultQuery.data).toEqual(peopleData2); + setTimeout(() => createTodo()); + } else if (count === 5) { + // mutation loading + expect(resultMutation.loading).toBe(true); + } else if (count === 6) { + // mutation loaded + expect(resultMutation.loading).toBe(false); + } else if (count === 7) { + // query refetched + expect(resultQuery.loading).toBe(false); + expect(resultMutation.loading).toBe(false); + expect(resultQuery.data).toEqual(peopleData3); + } + count++; + } catch (err) { + reject(err); } - count++; return null; }} @@ -1019,12 +1032,12 @@ describe('General Mutation testing', () => { ); - await wait(() => { - expect(count).toBe(7); - }); - }); + wait(() => { + expect(count).toBe(8); + }).then(resolve, reject); + })); - it('allows refetchQueries to be passed to the mutate function', async () => { + it('allows refetchQueries to be passed to the mutate function', () => new Promise((resolve, reject) => { const query = gql` query getTodo { todo { @@ -1071,18 +1084,22 @@ describe('General Mutation testing', () => { {(createTodo: any, resultMutation: any) => ( {(resultQuery: any) => { - if (count === 0) { - setTimeout(() => createTodo({ refetchQueries }), 10); - } else if (count === 1) { - expect(resultMutation.loading).toBe(false); - expect(resultQuery.loading).toBe(false); - } else if (count === 2) { - expect(resultMutation.loading).toBe(true); - expect(resultQuery.data).toEqual(queryData); - } else if (count === 3) { - expect(resultMutation.loading).toBe(false); + try { + if (count === 0) { + setTimeout(() => createTodo({ refetchQueries }), 10); + } else if (count === 1) { + expect(resultMutation.loading).toBe(false); + expect(resultQuery.loading).toBe(false); + } else if (count === 2) { + expect(resultMutation.loading).toBe(true); + expect(resultQuery.data).toEqual(queryData); + } else if (count === 3) { + expect(resultMutation.loading).toBe(false); + } + count++; + } catch (err) { + reject(err); } - count++; return null; }} @@ -1096,10 +1113,10 @@ describe('General Mutation testing', () => { ); - await wait(() => { + wait(() => { expect(count).toBe(4); - }); - }); + }).then(resolve, reject); + })); it('has an update prop for updating the store after the mutation', async () => { const update = (_proxy: DataProxy, response: ExecutionResult) => { diff --git a/src/react/components/__tests__/client/Query.test.tsx b/src/react/components/__tests__/client/Query.test.tsx index c3fa0a77422..3a277bb75a9 100644 --- a/src/react/components/__tests__/client/Query.test.tsx +++ b/src/react/components/__tests__/client/Query.test.tsx @@ -8,7 +8,7 @@ import { ApolloError } from '../../../../errors'; import { ApolloLink } from '../../../../link/core'; import { InMemoryCache } from '../../../../cache'; import { ApolloProvider } from '../../../context'; -import { itAsync, MockedProvider, mockSingleLink, withErrorSpy } from '../../../../testing'; +import { itAsync, MockedProvider, mockSingleLink } from '../../../../testing'; import { Query } from '../../Query'; const allPeopleQuery: DocumentNode = gql` @@ -53,16 +53,49 @@ describe('Query component', () => { const Component = () => ( {(result: any) => { - const { client: clientResult, ...rest } = result; - if (result.loading) { - expect(rest).toMatchSnapshot( - 'result in render prop while loading' - ); - expect(clientResult).toBe(client); - } else { - expect(rest).toMatchSnapshot( - 'result in render prop' - ); + const { + client: clientResult, + fetchMore, + refetch, + startPolling, + stopPolling, + subscribeToMore, + updateQuery, + ...rest + } = result; + try { + if (result.loading) { + expect(rest).toEqual({ + called: true, + data: undefined, + error: undefined, + loading: true, + networkStatus: 1, + previousData: undefined, + variables: {}, + }); + expect(clientResult).toBe(client); + } else { + expect(rest).toEqual({ + called: true, + data: { + allPeople: { + people: [ + { + name: "Luke Skywalker", + }, + ], + }, + }, + error: undefined, + loading: false, + networkStatus: 7, + previousData: undefined, + variables: {}, + }); + } + } catch (err) { + reject(err); } return null; }} @@ -994,16 +1027,6 @@ describe('Query component', () => { }, }; - componentDidMount() { - setTimeout(() => { - this.setState({ - variables: { - first: 2, - }, - }); - }, 50); - } - render() { const { variables } = this.state; @@ -1013,14 +1036,28 @@ describe('Query component', () => { if (result.loading) { return null; } + try { - if (count === 0) { - expect(variables).toEqual({ first: 1 }); - expect(result.data).toEqual(data1); - } - if (count === 1) { - expect(variables).toEqual({ first: 2 }); - expect(result.data).toEqual(data2); + switch (count) { + case 0: + expect(variables).toEqual({ first: 1 }); + expect(result.data).toEqual(data1); + setTimeout(() => { + this.setState({ + variables: { + first: 2, + }, + }); + }); + break; + case 1: + expect(variables).toEqual({ first: 2 }); + expect(result.data).toEqual(data1); + break; + case 2: + expect(variables).toEqual({ first: 2 }); + expect(result.data).toEqual(data2); + break; } } catch (error) { reject(error); @@ -1040,7 +1077,7 @@ describe('Query component', () => { ); - return wait(() => expect(count).toBe(2)).then(resolve, reject); + return wait(() => expect(count).toBe(3)).then(resolve, reject); }); itAsync('if the query changes', (resolve, reject) => { @@ -1084,14 +1121,22 @@ describe('Query component', () => { {(result: any) => { if (result.loading) return null; try { - if (count === 0) { - expect(result.data).toEqual(data1); - setTimeout(() => { - this.setState({ query: query2 }); - }); - } - if (count === 1) { - expect(result.data).toEqual(data2); + switch (count) { + case 0: + expect(query).toEqual(query1); + expect(result.data).toEqual(data1); + setTimeout(() => { + this.setState({ query: query2 }); + }); + break; + case 1: + expect(query).toEqual(query2); + expect(result.data).toEqual(data1); + break; + case 2: + expect(query).toEqual(query2); + expect(result.data).toEqual(data2); + break; } } catch (error) { reject(error); @@ -1111,7 +1156,7 @@ describe('Query component', () => { ); - return wait(() => expect(count).toBe(2)).then(resolve, reject); + return wait(() => expect(count).toBe(3)).then(resolve, reject); }); itAsync('with data while loading', (resolve, reject) => { @@ -1153,35 +1198,42 @@ describe('Query component', () => { }, }; - componentDidMount() { - setTimeout(() => { - this.setState({ variables: { first: 2 } }); - }, 10); - } - render() { const { variables } = this.state; return ( {(result: any) => { - if (count === 0) { - expect(result.loading).toBe(true); - expect(result.data).toBeUndefined(); - expect(result.networkStatus).toBe(NetworkStatus.loading); - } else if (count === 1) { - expect(result.loading).toBe(false); - expect(result.data).toEqual(data1); - expect(result.networkStatus).toBe(NetworkStatus.ready); - } else if (count === 2) { - expect(result.loading).toBe(true); - expect(result.data).toBeUndefined(); - expect(result.networkStatus).toBe(NetworkStatus.setVariables); - } else if (count === 3) { - expect(result.loading).toBe(false); - expect(result.data).toEqual(data2); - expect(result.networkStatus).toBe(NetworkStatus.ready); + try { + switch (count) { + case 0: + expect(result.loading).toBe(true); + expect(result.data).toBeUndefined(); + expect(result.networkStatus).toBe(NetworkStatus.loading); + break; + case 1: + setTimeout(() => { + this.setState({ variables: { first: 2 } }); + }); + // fallthrough + case 2: + expect(result.loading).toBe(false); + expect(result.data).toEqual(data1); + expect(result.networkStatus).toBe(NetworkStatus.ready); + break; + case 3: + expect(result.loading).toBe(true); + expect(result.networkStatus).toBe(NetworkStatus.setVariables); + break; + case 4: + expect(result.data).toEqual(data2); + expect(result.networkStatus).toBe(NetworkStatus.ready); + break; + } + } catch (err) { + reject(err); } + count++; return null; }} @@ -1196,7 +1248,7 @@ describe('Query component', () => { ); - return wait(() => expect(count).toBe(4)).then(resolve, reject); + return wait(() => expect(count).toBe(5)).then(resolve, reject); }); itAsync('should update if a manual `refetch` is triggered after a state change', (resolve, reject) => { @@ -1381,7 +1433,7 @@ describe('Query component', () => { const link = mockSingleLink( { request: { query }, result: { data } }, { request: { query }, error: new Error('This is an error!') }, - { request: { query }, result: { data: dataTwo } } + { request: { query }, result: { data: dataTwo }, delay: 10 }, ); const client = new ApolloClient({ link, @@ -1395,22 +1447,18 @@ describe('Query component', () => { function Container() { return ( - + {(result: any) => { try { switch (count++) { case 0: // Waiting for the first result to load - expect(result.loading).toBeTruthy(); + expect(result.loading).toBe(true); break; case 1: - if (!result.data!.allPeople) { - reject('Should have data by this point'); - break; - } // First result is loaded, run a refetch to get the second result // which is an error. - expect(result.data!.allPeople).toEqual( + expect(result.data.allPeople).toEqual( data.allPeople ); setTimeout(() => { @@ -1421,33 +1469,28 @@ describe('Query component', () => { break; case 2: // Waiting for the second result to load - expect(result.loading).toBeTruthy(); + expect(result.loading).toBe(true); break; case 3: - // The error arrived, run a refetch to get the third result - // which should now contain valid data. - expect(result.loading).toBeFalsy(); - expect(result.error).toBeTruthy(); setTimeout(() => { result.refetch().catch(() => { reject('Expected good data on second refetch.'); }); }, 0); + // fallthrough + // The error arrived, run a refetch to get the third result + // which should now contain valid data. + expect(result.loading).toBe(false); + expect(result.error).toBeTruthy(); break; case 4: - expect(result.loading).toBeTruthy(); + expect(result.loading).toBe(true); expect(result.error).toBeFalsy(); break; case 5: - expect(result.loading).toBeFalsy(); + expect(result.loading).toBe(false); expect(result.error).toBeFalsy(); - if (!result.data) { - reject('Should have data by this point'); - break; - } - expect(result.data.allPeople).toEqual( - dataTwo.allPeople - ); + expect(result.data.allPeople).toEqual(dataTwo.allPeople); break; default: throw new Error('Unexpected fall through'); @@ -1620,8 +1663,6 @@ describe('Query component', () => { let renderCount = 0; let onCompletedCallCount = 0; - let unmount: any; - class Component extends React.Component { state = { variables: { @@ -1650,26 +1691,30 @@ describe('Query component', () => { {({ loading, data }: any) => { switch (renderCount) { case 0: - expect(loading).toBeTruthy(); + expect(loading).toBe(true); break; case 1: - expect(loading).toBeFalsy(); - expect(data).toEqual(data1); - break; case 2: - expect(loading).toBeTruthy(); + expect(loading).toBe(false); + expect(data).toEqual(data1); break; case 3: - expect(loading).toBeFalsy(); - expect(data).toEqual(data2); - setTimeout(() => this.setState({ variables: { first: 1 } })); + expect(loading).toBe(true); break; case 4: - expect(loading).toBeFalsy(); + expect(loading).toBe(false); + expect(data).toEqual(data2); + setTimeout(() => { + this.setState({ variables: { first: 1 } }); + }); + case 5: + expect(loading).toBe(false); + expect(data).toEqual(data2); + break; + case 6: + expect(loading).toBe(false); expect(data).toEqual(data1); - setTimeout(unmount); break; - default: } renderCount += 1; return null; @@ -1679,11 +1724,11 @@ describe('Query component', () => { } } - unmount = render( + render( - ).unmount; + ); return wait(() => { expect(onCompletedCallCount).toBe(3); @@ -1750,12 +1795,27 @@ describe('Query component', () => { console.warn = origConsoleWarn; }); - withErrorSpy(itAsync, + itAsync( 'should attempt a refetch when the query result was marked as being ' + 'partial, the returned data was reset to an empty Object by the ' + 'Apollo Client QueryManager (due to a cache miss), and the ' + '`partialRefetch` prop is `true`', (resolve, reject) => { + const allPeopleQuery: DocumentNode = gql` + query people { + allPeople(first: 1) { + people { + name + } + } + } + `; + + const allPeopleData = { + allPeople: { people: [{ name: 'Luke Skywalker' }] }, + }; + const errorSpy = jest.spyOn(console, 'error') + .mockImplementation(() => {}); const query = allPeopleQuery; const link = mockSingleLink( { request: { query }, result: { data: {} } }, @@ -1764,15 +1824,19 @@ describe('Query component', () => { const client = new ApolloClient({ link, - cache: new InMemoryCache({ addTypename: false }), + cache: new InMemoryCache(), }); const Component = () => ( - + {(result: any) => { const { data, loading } = result; - if (!loading) { - expect(data).toEqual(allPeopleData); + try { + if (!loading) { + expect(data).toEqual(allPeopleData); + } + } catch (err) { + reject(err); } return null; }} @@ -1785,7 +1849,11 @@ describe('Query component', () => { ); - return wait().then(resolve, reject); + return wait().then(() => { + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toMatch('Missing field'); + errorSpy.mockRestore(); + }).then(resolve, reject); } ); diff --git a/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap b/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap deleted file mode 100644 index 895c6167e70..00000000000 --- a/src/react/components/__tests__/client/__snapshots__/Query.test.tsx.snap +++ /dev/null @@ -1,61 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Query component Partial refetching should attempt a refetch when the query result was marked as being partial, the returned data was reset to an empty Object by the Apollo Client QueryManager (due to a cache miss), and the \`partialRefetch\` prop is \`true\` 1`] = ` -[MockFunction] { - "calls": Array [ - Array [ - "Missing field 'allPeople' while writing result {}", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], -} -`; - -exports[`Query component calls the children prop: result in render prop 1`] = ` -Object { - "called": true, - "data": Object { - "allPeople": Object { - "people": Array [ - Object { - "name": "Luke Skywalker", - }, - ], - }, - }, - "error": undefined, - "fetchMore": [Function], - "loading": false, - "networkStatus": 7, - "previousData": undefined, - "refetch": [Function], - "startPolling": [Function], - "stopPolling": [Function], - "subscribeToMore": [Function], - "updateQuery": [Function], - "variables": Object {}, -} -`; - -exports[`Query component calls the children prop: result in render prop while loading 1`] = ` -Object { - "called": true, - "data": undefined, - "error": undefined, - "fetchMore": [Function], - "loading": true, - "networkStatus": 1, - "previousData": undefined, - "refetch": [Function], - "startPolling": [Function], - "stopPolling": [Function], - "subscribeToMore": [Function], - "updateQuery": [Function], - "variables": Object {}, -} -`; diff --git a/src/react/hoc/__tests__/queries/errors.test.tsx b/src/react/hoc/__tests__/queries/errors.test.tsx index df3664c4747..a2a16e49f6f 100644 --- a/src/react/hoc/__tests__/queries/errors.test.tsx +++ b/src/react/hoc/__tests__/queries/errors.test.tsx @@ -214,24 +214,36 @@ describe('[queries] errors', () => { componentDidUpdate() { const { props } = this; iteration += 1; - if (iteration === 1) { - // initial loading state is done, we have data - expect(props.data!.allPeople).toEqual( - data.allPeople - ); - props.setVar(2); - } else if (iteration === 2) { - // variables have changed, wee are loading again but also have data - expect(props.data!.loading).toBeTruthy(); - } else if (iteration === 3) { - // the second request had an error! - expect(props.data!.error).toBeTruthy(); - expect(props.data!.error!.networkError).toBeTruthy(); - // // We need to set a timeout to ensure the unhandled rejection is swept up - setTimeout(() => { - expect(unhandled.length).toEqual(0); - done = true; - }); + try { + if (iteration === 1) { + // initial loading state is done, we have data + expect(props.data!.allPeople).toEqual( + data.allPeople + ); + props.setVar(2); + } else if (iteration === 2) { + expect(props.data!.allPeople).toEqual( + data.allPeople + ); + } else if (iteration === 3) { + // variables have changed, wee are loading again but also have data + expect(props.data!.loading).toBeTruthy(); + } else if (iteration === 4) { + // the second request had an error! + expect(props.data!.error).toBeTruthy(); + expect(props.data!.error!.networkError).toBeTruthy(); + // // We need to set a timeout to ensure the unhandled rejection is swept up + setTimeout(() => { + try { + expect(unhandled.length).toEqual(0); + } catch (err) { + reject(err); + } + done = true; + }); + } + } catch (err) { + reject(err); } } render() { diff --git a/src/react/hoc/__tests__/queries/index.test.tsx b/src/react/hoc/__tests__/queries/index.test.tsx index 375e18067b3..7bf73a600ad 100644 --- a/src/react/hoc/__tests__/queries/index.test.tsx +++ b/src/react/hoc/__tests__/queries/index.test.tsx @@ -166,11 +166,16 @@ describe('queries', () => { options )(({ data }: ChildProps) => { expect(data).toBeTruthy(); - if (count === 0) { - expect(data!.variables.someId).toEqual(1); - } else if (count === 1) { - expect(data!.variables.someId).toEqual(2); + switch (count) { + case 0: + case 1: + expect(data!.variables.someId).toEqual(1); + break; + case 2: + expect(data!.variables.someId).toEqual(2); + break; } + count += 1; return null; }); diff --git a/src/react/hoc/__tests__/queries/lifecycle.test.tsx b/src/react/hoc/__tests__/queries/lifecycle.test.tsx index d1a0a38bb3e..d082200e0bc 100644 --- a/src/react/hoc/__tests__/queries/lifecycle.test.tsx +++ b/src/react/hoc/__tests__/queries/lifecycle.test.tsx @@ -43,7 +43,6 @@ describe('[queries] lifecycle', () => { cache: new Cache({ addTypename: false }) }); - let done = false; const Container = graphql(query, { options: props => ({ variables: props, @@ -52,18 +51,39 @@ describe('[queries] lifecycle', () => { })( class extends React.Component> { componentDidUpdate(prevProps: ChildProps) { - const { data } = this.props; - // loading is true, but data still there - if (count === 1) { - if (data!.loading) { - expect(data!.allPeople).toBeUndefined(); - } else { - expect(prevProps.data!.loading).toBe(true); - expect(data!.allPeople).toEqual(data2.allPeople); - done = true; + try { + const { data } = this.props; + switch (count) { + case 0: + expect(prevProps.data!.loading).toBe(true); + expect(prevProps.data!.allPeople).toBe(undefined); + expect(data!.loading).toBe(false); + expect(data!.variables).toEqual(variables1); + expect(data!.allPeople).toEqual(data1.allPeople); + break; + case 1: + expect(data!.loading).toBe(false); + expect(data!.variables).toEqual(variables1); + expect(data!.allPeople).toEqual(data1.allPeople); + break; + case 2: + expect(data!.loading).toBe(true); + expect(data!.variables).toEqual(variables2); + expect(data!.allPeople).toBe(undefined); + break; + case 3: + expect(data!.loading).toBe(false); + expect(data!.variables).toEqual(variables2); + expect(data!.allPeople).toEqual(data2.allPeople); + break; } + + count++; + } catch (err) { + reject(err); } } + render() { return null; } @@ -75,7 +95,6 @@ describe('[queries] lifecycle', () => { componentDidMount() { setTimeout(() => { - count++; this.setState({ first: 2 }); }, 50); } @@ -91,7 +110,7 @@ describe('[queries] lifecycle', () => { ); - return wait(() => expect(done).toBeTruthy()).then(resolve, reject); + return wait(() => expect(count).toBe(4)).then(resolve, reject); }); itAsync('rebuilds the queries on prop change when using `options`', (resolve, reject) => { @@ -189,24 +208,45 @@ describe('[queries] lifecycle', () => { cache: new Cache({ addTypename: false }) }); - let done = false; const Container = graphql(query, { options: props => ({ variables: props }) })( class extends React.Component> { componentDidUpdate(prevProps: ChildProps) { - const { data } = this.props; - // loading is true, but data still there - if (count === 1) { - if (data!.loading) { - expect(data!.allPeople).toBeUndefined(); - } else { - expect(prevProps.data!.loading).toBe(true); - expect(data!.allPeople).toEqual(data2.allPeople); - done = true; + try { + const { data } = this.props; + switch (count) { + case 0: + expect(prevProps.data!.loading).toBe(true); + expect(prevProps.data!.variables).toEqual({ first: 1 }); + expect(prevProps.data!.allPeople).toBe(undefined); + expect(data!.loading).toBe(false); + expect(data!.variables).toEqual({ first: 1 }); + expect(data!.allPeople).toEqual(data1.allPeople); + break; + case 1: + expect(data!.loading).toBe(false); + expect(data!.variables).toEqual({ first: 1 }); + expect(data!.allPeople).toEqual(data1.allPeople); + break; + case 2: + expect(data!.loading).toBe(true); + expect(data!.variables).toEqual({ first: 2 }); + expect(data!.allPeople).toBe(undefined); + break; + case 3: + expect(data!.loading).toBe(false); + expect(data!.variables).toEqual({ first: 2 }); + expect(data!.allPeople).toEqual(data2.allPeople); + break; } + } catch (err) { + reject(err); } + + count++; } + render() { return null; } @@ -218,7 +258,6 @@ describe('[queries] lifecycle', () => { componentDidMount() { setTimeout(() => { - count++; this.setState({ first: 2 }); }, 50); } @@ -234,7 +273,7 @@ describe('[queries] lifecycle', () => { ); - return wait(() => expect(done).toBeTruthy()).then(resolve, reject); + return wait(() => expect(count).toBe(4)).then(resolve, reject); }); itAsync('reruns the queries on prop change when using passed props', (resolve, reject) => { @@ -268,21 +307,41 @@ describe('[queries] lifecycle', () => { cache: new Cache({ addTypename: false }) }); - let done = false; const Container = graphql(query)( class extends React.Component> { componentDidUpdate(prevProps: ChildProps) { - const { data } = this.props; - // loading is true, but data still there - if (count === 1) { - if (data!.loading) { - expect(data!.allPeople).toBeUndefined(); - } else { - expect(prevProps.data!.loading).toBe(true); - expect(data!.allPeople).toEqual(data2.allPeople); - done = true; + try { + const { data } = this.props; + switch (count) { + case 0: + expect(prevProps.data!.loading).toBe(true); + expect(prevProps.data!.variables).toEqual({ first: 1 }); + expect(prevProps.data!.allPeople).toBe(undefined); + expect(data!.loading).toBe(false); + expect(data!.variables).toEqual({ first: 1 }); + expect(data!.allPeople).toEqual(data1.allPeople); + break; + case 1: + expect(data!.loading).toBe(false); + expect(data!.variables).toEqual({ first: 1 }); + expect(data!.allPeople).toEqual(data1.allPeople); + break; + case 2: + expect(data!.loading).toBe(true); + expect(data!.variables).toEqual({ first: 2 }); + expect(data!.allPeople).toBe(undefined); + break; + case 3: + expect(data!.loading).toBe(false); + expect(data!.variables).toEqual({ first: 2 }); + expect(data!.allPeople).toEqual(data2.allPeople); + break; } + } catch (err) { + reject(err); } + + count++; } render() { return null; @@ -295,7 +354,6 @@ describe('[queries] lifecycle', () => { componentDidMount() { setTimeout(() => { - count++; this.setState({ first: 2 }); }, 50); } @@ -311,7 +369,7 @@ describe('[queries] lifecycle', () => { ); - return wait(() => expect(done).toBeTruthy()).then(resolve, reject); + return wait(() => expect(count).toBe(4)).then(resolve, reject); }); itAsync('stays subscribed to updates after irrelevant prop changes', (resolve, reject) => { @@ -532,133 +590,133 @@ describe('[queries] lifecycle', () => { } render() { - const { loading, a, b, c } = this.props.data!; - switch (count) { - case 0: - expect({ loading, a, b, c }).toEqual({ - loading: true, - a: undefined, - b: undefined, - c: undefined - }); - break; - case 1: - expect({ loading, a, b, c }).toEqual({ - loading: false, - a: 1, - b: 2, - c: 3 - }); - refetchQuery!(); - break; - case 2: - expect({ loading, a, b, c }).toEqual({ - loading: true, - a: 1, - b: 2, - c: 3 - }); - break; - case 3: - expect({ loading, a, b, c }).toEqual({ - loading: false, - a: 1, - b: 2, - c: 3 - }); - setTimeout(() => { - switchClient!(client2); - }); - break; - case 4: - expect({ loading, a, b, c }).toEqual({ - loading: true, - a: undefined, - b: undefined, - c: undefined - }); - break; - case 5: - expect({ loading, a, b, c }).toEqual({ - loading: false, - a: 4, - b: 5, - c: 6 - }); - refetchQuery!(); - break; - case 6: - expect({ loading, a, b, c }).toEqual({ - loading: true, - a: 4, - b: 5, - c: 6 - }); - break; - case 7: - expect({ loading, a, b, c }).toEqual({ - loading: false, - a: 4, - b: 5, - c: 6 - }); - setTimeout(() => { - switchClient!(client3); - }); - break; - case 8: - expect({ loading, a, b, c }).toEqual({ - loading: true, - a: undefined, - b: undefined, - c: undefined - }); - break; - case 9: - expect({ loading, a, b, c }).toEqual({ - loading: false, - a: 7, - b: 8, - c: 9 - }); - setTimeout(() => { - switchClient!(client1); - }); - break; - case 10: - expect({ loading, a, b, c }).toEqual({ - loading: false, - a: 1, - b: 2, - c: 3 - }); - setTimeout(() => { - switchClient!(client2); - }); - break; - case 11: - expect({ loading, a, b, c }).toEqual({ - loading: false, - a: 4, - b: 5, - c: 6 - }); - setTimeout(() => { - switchClient!(client3); - }); - break; - case 12: - expect({ loading, a, b, c }).toEqual({ - loading: false, - a: 7, - b: 8, - c: 9 - }); - break; - default: - // do nothing + try { + const { loading, a, b, c } = this.props.data!; + switch (count) { + case 0: + expect({ loading, a, b, c }).toEqual({ + loading: true, + a: undefined, + b: undefined, + c: undefined + }); + break; + case 1: + expect({ loading, a, b, c }).toEqual({ + loading: false, + a: 1, + b: 2, + c: 3 + }); + refetchQuery!(); + break; + case 2: + expect({ loading, a, b, c }).toEqual({ + loading: true, + a: 1, + b: 2, + c: 3 + }); + break; + case 3: + setTimeout(() => { + switchClient!(client2); + }); + // fallthrough + case 4: + expect({ loading, a, b, c }).toEqual({ + loading: false, + a: 1, + b: 2, + c: 3 + }); + break; + case 5: + expect({ loading, a, b, c }).toEqual({ + loading: true, + a: undefined, + b: undefined, + c: undefined + }); + break; + case 6: + expect({ loading, a, b, c }).toEqual({ + loading: false, + a: 4, + b: 5, + c: 6 + }); + refetchQuery!(); + break; + case 7: + expect({ loading, a, b, c }).toEqual({ + loading: true, + a: 4, + b: 5, + c: 6 + }); + break; + case 8: + setTimeout(() => { + switchClient!(client3); + }); + // fallthrough + case 9: + expect({ loading, a, b, c }).toEqual({ + loading: false, + a: 4, + b: 5, + c: 6 + }); + break; + case 10: + expect({ loading, a, b, c }).toEqual({ + loading: true, + a: undefined, + b: undefined, + c: undefined + }); + break; + case 11: + setTimeout(() => { + switchClient!(client1); + }); + // fallthrough + case 12: + expect({ loading, a, b, c }).toEqual({ + loading: false, + a: 7, + b: 8, + c: 9 + }); + break; + case 13: + setTimeout(() => { + switchClient!(client3); + }); + // fallthrough + case 14: + expect({ loading, a, b, c }).toEqual({ + loading: false, + a: 1, + b: 2, + c: 3 + }); + break; + case 15: + expect({ loading, a, b, c }).toEqual({ + loading: false, + a: 7, + b: 8, + c: 9 + }); + break; + } + } catch (err) { + reject(err); } - count += 1; + + count++; return null; } } @@ -686,7 +744,7 @@ describe('[queries] lifecycle', () => { render(); - return wait(() => expect(count).toBe(13)).then(resolve, reject); + return wait(() => expect(count).toBe(16)).then(resolve, reject); }); itAsync('handles synchronous racecondition with prefilled data from the server', (resolve, reject) => { diff --git a/src/react/hoc/__tests__/queries/loading.test.tsx b/src/react/hoc/__tests__/queries/loading.test.tsx index 9c0bab58731..b5151a9c4e5 100644 --- a/src/react/hoc/__tests__/queries/loading.test.tsx +++ b/src/react/hoc/__tests__/queries/loading.test.tsx @@ -3,7 +3,7 @@ import { render, wait } from '@testing-library/react'; import gql from 'graphql-tag'; import { DocumentNode } from 'graphql'; -import { ApolloClient } from '../../../../core'; +import { ApolloClient, NetworkStatus } from '../../../../core'; import { ApolloProvider } from '../../../context'; import { InMemoryCache as Cache } from '../../../../cache'; import { itAsync, mockSingleLink } from '../../../../testing'; @@ -181,18 +181,62 @@ describe('[queries] loading', () => { })( class extends React.Component> { componentDidUpdate(prevProps: ChildProps) { - const { data } = this.props; - // variables changed, new query is loading, but old data is still there - if (count === 1) { - if (data!.loading) { - expect(data!.networkStatus).toBe(2); - expect(data!.allPeople).toBeUndefined(); - } else { - expect(prevProps.data!.loading).toBe(true); - expect(data!.networkStatus).toBe(7); - expect(data!.allPeople).toEqual(data2.allPeople); - done = true; + try { + // variables changed, new query is loading, but old data is still there + switch (count) { + case 0: + expect(prevProps.data!.loading).toBe(true); + expect(prevProps.data!.variables).toEqual(variables1); + expect(prevProps.data!.allPeople).toBe(undefined); + expect(prevProps.data!.error).toBe(undefined); + expect(prevProps.data!.networkStatus).toBe(NetworkStatus.loading); + expect(this.props.data!.loading).toBe(false); + expect(this.props.data!.variables).toEqual(variables1); + expect(this.props.data!.allPeople).toEqual(data1.allPeople); + expect(this.props.data!.error).toBe(undefined); + expect(this.props.data!.networkStatus).toBe(NetworkStatus.ready); + break; + case 1: + // TODO: What is this extra render + expect(prevProps.data!.loading).toBe(false); + expect(prevProps.data!.variables).toEqual(variables1); + expect(prevProps.data!.allPeople).toEqual(data1.allPeople); + expect(prevProps.data!.error).toBe(undefined); + expect(prevProps.data!.networkStatus).toBe(NetworkStatus.ready); + expect(this.props.data!.loading).toBe(false); + expect(this.props.data!.variables).toEqual(variables1); + expect(this.props.data!.allPeople).toEqual(data1.allPeople); + expect(this.props.data!.error).toBe(undefined); + expect(prevProps.data!.networkStatus).toBe(NetworkStatus.ready); + break; + case 2: + expect(prevProps.data!.loading).toBe(false); + expect(prevProps.data!.variables).toEqual(variables1); + expect(prevProps.data!.allPeople).toEqual(data1.allPeople); + expect(prevProps.data!.error).toBe(undefined); + expect(this.props.data!.loading).toBe(true); + expect(this.props.data!.variables).toEqual(variables2); + expect(this.props.data!.allPeople).toBe(undefined); + expect(this.props.data!.error).toBe(undefined); + expect(this.props.data!.networkStatus).toBe(NetworkStatus.setVariables); + break; + case 3: + expect(prevProps.data!.loading).toBe(true); + expect(prevProps.data!.variables).toEqual(variables2); + expect(prevProps.data!.allPeople).toBe(undefined); + expect(prevProps.data!.error).toBe(undefined); + expect(prevProps.data!.networkStatus).toBe(NetworkStatus.setVariables); + expect(this.props.data!.loading).toBe(false); + expect(this.props.data!.variables).toEqual(variables2); + expect(this.props.data!.allPeople).toEqual(data2.allPeople); + expect(this.props.data!.error).toBe(undefined); + expect(this.props.data!.networkStatus).toBe(NetworkStatus.ready); + done = true; + break; } + count++; + } catch (err) { + reject(err); } } render() { @@ -206,7 +250,6 @@ describe('[queries] loading', () => { componentDidMount() { setTimeout(() => { - count++; this.setState({ first: 2 }); }, 50); } @@ -222,7 +265,7 @@ describe('[queries] loading', () => { ); - return wait(() => expect(done).toBeTruthy()).then(resolve, reject); + wait(() => expect(done).toBe(true)).then(resolve, reject); }); itAsync('resets the loading state after a refetched query', (resolve, reject) => { @@ -592,25 +635,32 @@ describe('[queries] loading', () => { })( class extends React.Component> { render() { - if (count === 0) { - expect(this.props.data!.loading).toBeTruthy(); // has initial data - } - - if (count === 1) { - expect(this.props.data!.loading).toBeFalsy(); - setTimeout(() => { - this.props.setFirst(2); - }); - } - - if (count === 2) { - expect(this.props.data!.loading).toBeTruthy(); // on variables change + try { + switch (count) { + case 0: + expect(this.props.data!.loading).toBeTruthy(); // has initial data + break; + case 1: + expect(this.props.data!.loading).toBeFalsy(); + setTimeout(() => { + this.props.setFirst(2); + }); + break; + case 2: + expect(this.props.data!.loading).toBeFalsy(); // on variables change + break; + case 3: + expect(this.props.data!.loading).toBeTruthy(); // on variables change + break; + case 4: + // new data after fetch + expect(this.props.data!.loading).toBeFalsy(); + break; + } + } catch (err) { + reject(err); } - if (count === 3) { - // new data after fetch - expect(this.props.data!.loading).toBeFalsy(); - } count++; return null; @@ -625,7 +675,7 @@ describe('[queries] loading', () => { ); - return wait(() => expect(count).toBe(4)).then(resolve, reject); + return wait(() => expect(count).toBe(5)).then(resolve, reject); }); itAsync( @@ -706,27 +756,34 @@ describe('[queries] loading', () => { class extends React.Component> { render() { const { props } = this; - if (count === 0) { - expect(props.data!.loading).toBeTruthy(); + try { + switch (count) { + case 0: + expect(props.data!.loading).toBeTruthy(); + break; + case 1: + setTimeout(() => { + this.props.setFirst(2); + }); + //fallthrough + case 2: + expect(props.data!.loading).toBeFalsy(); // has initial data + expect(props.data!.allPeople).toEqual(data.allPeople); + break; + + case 3: + expect(props.data!.loading).toBeTruthy(); // on variables change + break; + case 4: + // new data after fetch + expect(props.data!.loading).toBeFalsy(); + expect(props.data!.allPeople).toEqual(data.allPeople); + break; + } + } catch (err) { + reject(err); } - if (count === 1) { - expect(props.data!.loading).toBeFalsy(); // has initial data - expect(props.data!.allPeople).toEqual(data.allPeople); - setTimeout(() => { - this.props.setFirst(2); - }); - } - - if (count === 2) { - expect(props.data!.loading).toBeTruthy(); // on variables change - } - - if (count === 3) { - // new data after fetch - expect(props.data!.loading).toBeFalsy(); - expect(props.data!.allPeople).toEqual(data.allPeople); - } count++; return null; } @@ -740,7 +797,7 @@ describe('[queries] loading', () => { ); - return wait(() => expect(count).toBe(4)).then(resolve, reject); + return wait(() => expect(count).toBe(5)).then(resolve, reject); } ); }); diff --git a/src/react/hoc/__tests__/queries/skip.test.tsx b/src/react/hoc/__tests__/queries/skip.test.tsx index 82bf2c111d2..36497011112 100644 --- a/src/react/hoc/__tests__/queries/skip.test.tsx +++ b/src/react/hoc/__tests__/queries/skip.test.tsx @@ -171,14 +171,25 @@ describe('[queries] skip', () => { })( class extends React.Component> { componentDidUpdate() { - const { props } = this; - count++; - if (count === 1) expect(props.data!.loading).toBeTruthy(); - if (count === 2) - expect(props.data!.allPeople).toEqual(data.allPeople); - if (count === 2) { - expect(renderCount).toBe(3); + try { + const { props } = this; + switch (count) { + case 0: + case 1: + expect(props.data!.loading).toBeTruthy(); + break; + case 2: + expect(props.data!.allPeople).toEqual(data.allPeople); + break; + case 3: + expect(renderCount).toBe(3); + break; + } + } catch (err) { + reject(err); } + + count++; } render() { renderCount++; @@ -207,7 +218,7 @@ describe('[queries] skip', () => { ); - return wait(() => expect(count).toBe(2)).then(resolve, reject); + return wait(() => expect(count).toBe(3)).then(resolve, reject); }); itAsync("doesn't run options or props when skipped, including option.client", (resolve, reject) => { @@ -576,7 +587,7 @@ describe('[queries] skip', () => { return wait(() => expect(done).toBeTruthy()).then(resolve, reject); }); - it('allows you to skip then unskip a query with opts syntax', async () => { + it('allows you to skip then unskip a query with opts syntax', () => new Promise((resolve, reject) => { const query: DocumentNode = gql` query people { allPeople(first: 1) { @@ -606,10 +617,6 @@ describe('[queries] skip', () => { request: { query }, result: { data: nextData }, }, - { - request: { query }, - result: { data: nextData }, - }, { request: { query }, result: { data: finalData }, @@ -636,70 +643,82 @@ describe('[queries] skip', () => { render() { expect(this.props.data?.error).toBeUndefined(); - switch (++count) { - case 1: - expect(this.props.data.loading).toBe(true); - expect(ranQuery).toBe(0); - break; - case 2: - // The first batch of data is fetched over the network, and - // verified here, followed by telling the component we want to - // skip running subsequent queries. - expect(this.props.data.loading).toBe(false); - expect(this.props.data.allPeople).toEqual(data.allPeople); - expect(ranQuery).toBe(1); - setTimeout(() => { - this.props.setSkip(true); - }, 10); - break; - case 3: - // This render is triggered after setting skip to true. Now - // let's set skip to false to re-trigger the query. - expect(this.props.skip).toBe(true); - expect(this.props.data).toBeUndefined(); - expect(ranQuery).toBe(1); - setTimeout(() => { - this.props.setSkip(false); - }, 10); - break; - case 4: - expect(this.props.skip).toBe(false); - expect(this.props.data!.loading).toBe(true); - expect(this.props.data.allPeople).toEqual(data.allPeople); - expect(ranQuery).toBe(2); - break; - case 5: - expect(this.props.skip).toBe(false); - expect(this.props.data!.loading).toBe(false); - expect(this.props.data.allPeople).toEqual(nextData.allPeople); - expect(ranQuery).toBe(3); - // Since the `nextFetchPolicy` was set to `cache-first`, our - // query isn't loading as it's able to find the result of the - // query directly from the cache. Let's trigger a refetch - // to manually load the next batch of data. - setTimeout(() => { - this.props.data.refetch(); - }, 10); - break; - case 6: - expect(this.props.skip).toBe(false); - expect(this.props.data!.loading).toBe(true); - expect(this.props.data.allPeople).toEqual(nextData.allPeople); - expect(ranQuery).toBe(4); - break; - case 7: - // The next batch of data has loaded. - expect(this.props.skip).toBe(false); - expect(this.props.data!.loading).toBe(false); - expect(this.props.data.allPeople).toEqual(finalData.allPeople); - expect(ranQuery).toBe(4); - break; - default: - throw new Error(`too many renders (${count})`); + try { + switch (++count) { + case 1: + expect(this.props.data.loading).toBe(true); + expect(ranQuery).toBe(0); + break; + case 2: + // The first batch of data is fetched over the network, and + // verified here, followed by telling the component we want to + // skip running subsequent queries. + expect(this.props.data.loading).toBe(false); + expect(this.props.data.allPeople).toEqual(data.allPeople); + expect(ranQuery).toBe(1); + setTimeout(() => { + this.props.setSkip(true); + }, 10); + break; + case 3: + // This render is triggered after setting skip to true. Now + // let's set skip to false to re-trigger the query. + setTimeout(() => { + this.props.setSkip(false); + }, 10); + // fallthrough + case 4: + expect(this.props.skip).toBe(true); + expect(this.props.data).toBeUndefined(); + expect(ranQuery).toBe(1); + break; + case 5: + expect(this.props.skip).toBe(false); + expect(this.props.data!.loading).toBe(true); + expect(this.props.data.allPeople).toEqual(data.allPeople); + expect(ranQuery).toBe(1); + break; + case 6: + expect(this.props.skip).toBe(false); + expect(this.props.data!.loading).toBe(true); + expect(this.props.data.allPeople).toEqual(data.allPeople); + expect(ranQuery).toBe(2); + break; + case 7: + expect(this.props.data!.loading).toBe(false); + expect(this.props.data.allPeople).toEqual(nextData.allPeople); + expect(ranQuery).toBe(2); + // Since the `nextFetchPolicy` was set to `cache-first`, our + // query isn't loading as it's able to find the result of the + // query directly from the cache. Let's trigger a refetch + // to manually load the next batch of data. + setTimeout(() => { + this.props.data.refetch(); + }, 10); + break; + case 8: + expect(this.props.skip).toBe(false); + expect(this.props.data!.loading).toBe(true); + expect(this.props.data.allPeople).toEqual(nextData.allPeople); + expect(ranQuery).toBe(3); + break; + case 9: + // The next batch of data has loaded. + expect(this.props.skip).toBe(false); + expect(this.props.data!.loading).toBe(false); + expect(this.props.data.allPeople).toEqual(finalData.allPeople); + expect(ranQuery).toBe(3); + break; + default: + throw new Error(`too many renders (${count})`); + } + } catch (err) { + reject(err); } + return null; } - } + }, ); class Parent extends React.Component<{}, { skip: boolean }> { @@ -720,10 +739,8 @@ describe('[queries] skip', () => { ); - await wait(() => { - expect(count).toEqual(7); - }); - }); + wait(() => expect(count).toEqual(9)).then(resolve, reject); + })); it('removes the injected props if skip becomes true', async () => { let count = 0; From d5bd6e804ef79557e4d35cd5d396d96b6d2a1344 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 10 Aug 2021 11:04:37 -0400 Subject: [PATCH 32/65] do partialRefetch directly in the render execution --- src/react/hooks/useQuery.ts | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 76934c6b11e..85af5fcb087 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -275,34 +275,27 @@ export function useQuery< let partial: boolean | undefined; ({ partial, ...result } = result); - // An effect which refetches queries when there is incomplete data in the - // cache. - // - // TODO: This effect should be removed when the partialRefetch option is - // removed. - useEffect(() => { - // When a `Query` component is mounted, and a mutation is executed - // that returns the same ID as the mounted `Query`, but has less - // fields in its result, Apollo Client's `QueryManager` returns the - // data as `undefined` since a hit can't be found in the cache. - // This can lead to application errors when the UI elements rendered by - // the original `Query` component are expecting certain data values to - // exist, and they're all of a sudden stripped away. To help avoid - // this we'll attempt to refetch the `Query` data. + { + // TODO: This code should be removed when the partialRefetch option is + // removed. + // I was unable to get this hook to behave reasonably when this block was + // put in an effect, so we’re doing side effects in the render. Forgive me. if ( - partialRefetch && partial && + partialRefetch && + !result.loading && (!result.data || Object.keys(result.data).length === 0) && obsQuery.options.fetchPolicy !== 'cache-only' ) { - setTimeout(() => obsQuery.refetch()); + result = { + ...result, + loading: true, + networkStatus: NetworkStatus.refetch, + }; + + obsQuery.refetch(); } - }, [ - partialRefetch, - partial, - result.data, - obsQuery.options.fetchPolicy, - ]); + } if ( ssr === false && From 8d0c814432a99fa83cc6e3b6be334ac18876de68 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 10 Aug 2021 11:05:14 -0400 Subject: [PATCH 33/65] update partialRefetch tests --- .../__tests__/client/Query.test.tsx | 24 ++++---- src/react/hooks/__tests__/useQuery.test.tsx | 55 +++++-------------- 2 files changed, 28 insertions(+), 51 deletions(-) diff --git a/src/react/components/__tests__/client/Query.test.tsx b/src/react/components/__tests__/client/Query.test.tsx index 3a277bb75a9..5b6c2d9d6b6 100644 --- a/src/react/components/__tests__/client/Query.test.tsx +++ b/src/react/components/__tests__/client/Query.test.tsx @@ -1785,16 +1785,20 @@ describe('Query component', () => { }); describe('Partial refetching', () => { - const origConsoleWarn = console.warn; + let errorSpy!: ReturnType; - beforeAll(() => { - console.warn = () => null; + beforeEach(() => { + errorSpy = jest.spyOn(console, 'error') + .mockImplementation(() => {}); }); afterAll(() => { - console.warn = origConsoleWarn; + errorSpy.mockRestore(); }); + // TODO(brian): This is a terrible legacy test which is causing console + // error calls no matter what I try and I do not want to care about it + // anymore :) itAsync( 'should attempt a refetch when the query result was marked as being ' + 'partial, the returned data was reset to an empty Object by the ' + @@ -1811,11 +1815,10 @@ describe('Query component', () => { } `; + let done = false; const allPeopleData = { allPeople: { people: [{ name: 'Luke Skywalker' }] }, }; - const errorSpy = jest.spyOn(console, 'error') - .mockImplementation(() => {}); const query = allPeopleQuery; const link = mockSingleLink( { request: { query }, result: { data: {} } }, @@ -1834,6 +1837,9 @@ describe('Query component', () => { try { if (!loading) { expect(data).toEqual(allPeopleData); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toMatch('Missing field'); + done = true; } } catch (err) { reject(err); @@ -1849,11 +1855,7 @@ describe('Query component', () => { ); - return wait().then(() => { - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy.mock.calls[0][0]).toMatch('Missing field'); - errorSpy.mockRestore(); - }).then(resolve, reject); + wait(() => done).then(resolve, reject); } ); diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 92bb30db7d8..b401773b671 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2240,6 +2240,7 @@ describe('useQuery Hook', () => { { request: { query }, result: { data: {} }, + delay: 20, }, { request: { query }, @@ -2269,34 +2270,25 @@ describe('useQuery Hook', () => { expect(result.current.loading).toBe(true); expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.loading); - const updates = result.all.length; await waitForNextUpdate(); - expect(result.all.length - updates).toBe(2); - // waitForUpdate seems to miss the erroring render - const previous = result.all[result.all.length - 2]; - if (previous instanceof Error) { - throw previous; - } - - expect(previous.loading).toBe(true); - expect(previous.error).toBe(undefined); - expect(previous.data).toBe(undefined); - expect(result.current.loading).toBe(true); - expect(result.current.error).toBe(undefined); expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.refetch); expect(errorSpy).toHaveBeenCalledTimes(1); expect(errorSpy.mock.calls[0][0]).toMatch('Missing field'); + errorSpy.mockRestore(); await waitForNextUpdate(); + expect(result.current.loading).toBe(false); expect(result.current.data).toEqual({ hello: 'world' }); + expect(result.current.error).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.ready); - errorSpy.mockRestore(); }); it('should attempt a refetch when data is missing and partialRefetch is true 2', async () => { @@ -2317,7 +2309,7 @@ describe('useQuery Hook', () => { const errorSpy = jest.spyOn(console, 'error') .mockImplementation(() => {}); const link = mockSingleLink( - { request: { query }, result: { data: {} } }, + { request: { query }, result: { data: {} }, delay: 20 }, { request: { query }, result: { data }, delay: 20 } ); @@ -2342,24 +2334,13 @@ describe('useQuery Hook', () => { expect(result.current.loading).toBe(true); expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.loading); - const updates = result.all.length; await waitForNextUpdate(); - expect(result.all.length - updates).toBe(2); - // waitForUpdate seems to miss the erroring render - const previous = result.all[result.all.length - 2]; - if (previous instanceof Error) { - throw previous; - } - - expect(previous.loading).toBe(true); - expect(previous.error).toBe(undefined); - expect(previous.data).toBe(undefined); - expect(result.current.loading).toBe(true); - expect(result.current.error).toBe(undefined); expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.refetch); expect(errorSpy).toHaveBeenCalledTimes(1); @@ -2369,6 +2350,7 @@ describe('useQuery Hook', () => { await waitForNextUpdate(); expect(result.current.loading).toBe(false); expect(result.current.data).toEqual(data); + expect(result.current.error).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.ready); }); @@ -2381,6 +2363,7 @@ describe('useQuery Hook', () => { { request: { query }, result: { data: {} }, + delay: 20, }, { request: { query }, @@ -2411,22 +2394,12 @@ describe('useQuery Hook', () => { expect(result.current.loading).toBe(true); expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.loading); - const updates = result.all.length; await waitForNextUpdate(); - expect(result.all.length - updates).toBe(2); - // waitForUpdate seems to miss the erroring render - const previous = result.all[result.all.length - 2]; - if (previous instanceof Error) { - throw previous; - } - - expect(previous.loading).toBe(true); - expect(previous.error).toBe(undefined); - expect(previous.data).toBe(undefined); - expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(undefined); expect(result.current.data).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.refetch); @@ -2435,8 +2408,10 @@ describe('useQuery Hook', () => { errorSpy.mockRestore(); await waitForNextUpdate(); + expect(result.current.loading).toBe(false); expect(result.current.data).toEqual({ hello: 'world' }); + expect(result.current.error).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.ready); }); }); From 24f3cab1ebd78c49efc98b2ac4f9552bc3342d4f Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 10 Aug 2021 16:20:29 -0400 Subject: [PATCH 34/65] move result.errors -> error stuff into its own code branch --- src/react/hooks/useQuery.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 85af5fcb087..6051d3dcdde 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -326,7 +326,9 @@ export function useQuery< error: void 0, networkStatus: NetworkStatus.ready, }; - } else if (result.errors && result.errors.length) { + } + + if (result.errors && result.errors.length) { // Until a set naming convention for networkError and graphQLErrors is // decided upon, we map errors (graphQLErrors) to the error options. // TODO: Is it possible for both result.error and result.errors to be defined here? From c6691f66ef25b8fb16fc459167a411c619d84421 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 11 Aug 2021 11:23:56 -0400 Subject: [PATCH 35/65] implement useLazyQuery in terms of useQuery --- src/react/hooks/useLazyQuery.ts | 56 ++++++++++++++++++++++++++++---- src/react/hooks/useQuery.ts | 57 ++++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 22 deletions(-) diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts index 8032639ad98..d6882d85cc5 100644 --- a/src/react/hooks/useLazyQuery.ts +++ b/src/react/hooks/useLazyQuery.ts @@ -1,16 +1,58 @@ import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { useCallback, useState } from 'react'; -import { LazyQueryHookOptions, QueryTuple } from '../types/types'; -import { useBaseQuery } from './utils/useBaseQuery'; +import { + LazyQueryHookOptions, + LazyQueryResult, + QueryLazyOptions, + QueryTuple, +} from '../types/types'; +import { useQuery } from './useQuery'; import { OperationVariables } from '../../core'; export function useLazyQuery( query: DocumentNode | TypedDocumentNode, options?: LazyQueryHookOptions -) { - return useBaseQuery(query, options, true) as QueryTuple< - TData, - TVariables - >; +): QueryTuple { + const [execution, setExecution] = useState< + { called: boolean, lazyOptions?: QueryLazyOptions } + >({ + called: false, + }); + + const execute = useCallback< + QueryTuple[0] + >((lazyOptions?: QueryLazyOptions) => { + setExecution((execution) => { + if (execution.called) { + result && result.refetch(execution.lazyOptions); + } + + return { called: true, lazyOptions }; + }); + }, []); + + let result = useQuery(query, { + ...options, + ...execution.lazyOptions, + // We don’t set skip to execution.called, because we need useQuery to call + // addQueryPromise, so that ssr calls waits for execute to be called. + fetchPolicy: execution.called ? options?.fetchPolicy : 'standby', + skip: undefined, + }); + + if (!execution.called) { + result = { + ...result, + loading: false, + data: void 0 as unknown as TData, + error: void 0, + // TODO: fix the type of result + called: false as any, + }; + } + + // TODO: fix the type of result + return [execute, result as LazyQueryResult]; } diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 6051d3dcdde..79917c30e49 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -55,8 +55,22 @@ export function useQuery< verifyDocumentType(query, DocumentType.Query); // create watchQueryOptions from hook options - const { skip, ssr, partialRefetch, options } = useMemo(() => { - const { skip, ssr, partialRefetch, ...options } = { ...hookOptions, query }; + const { + skip, + ssr, + partialRefetch, + onCompleted, + onError, + options, + } = useMemo(() => { + const { + skip, + ssr, + partialRefetch, + onCompleted, + onError, + ...options + } = { ...hookOptions, query }; if (skip) { options.fetchPolicy = 'standby'; } else if ( @@ -71,14 +85,13 @@ export function useQuery< options.fetchPolicy = 'cache-first'; } else if (!options.fetchPolicy) { // cache-first is the default policy, but we explicitly assign it here so - // the cache policies computed based on optiosn can be cleared + // the cache policies computed based on options can be cleared options.fetchPolicy = 'cache-first'; } - return { skip, ssr, partialRefetch, options }; + return { skip, ssr, partialRefetch, onCompleted, onError, options }; }, [hookOptions, context.renderPromises]); - const { onCompleted, onError } = options; const [obsQuery, setObsQuery] = useState(() => { // See if there is an existing observable that was used to fetch the same // data and if so, use it instead since it will contain the proper queryId @@ -112,23 +125,24 @@ export function useQuery< // RenderPromises class are query and variables. getOptions: () => options, fetchData: () => new Promise((resolve) => { - const sub = obsQuery!.subscribe( - (result) => { + const sub = obsQuery!.subscribe({ + next(result) { if (!result.loading) { resolve() sub.unsubscribe(); } }, - () => { + error() { resolve(); sub.unsubscribe(); }, - () => { + complete() { resolve(); }, - ); + }); }), } as any, + // This callback never seemed to do anything () => null, ); } @@ -275,11 +289,13 @@ export function useQuery< let partial: boolean | undefined; ({ partial, ...result } = result); + { + // BAD BOY CODE BLOCK WHERE WE PUT SIDE-EFFECTS IN THE RENDER FUNCTION + // // TODO: This code should be removed when the partialRefetch option is - // removed. - // I was unable to get this hook to behave reasonably when this block was - // put in an effect, so we’re doing side effects in the render. Forgive me. + // removed. I was unable to get this hook to behave reasonably in certain + // edge cases when this block was put in an effect. if ( partial && partialRefetch && @@ -295,11 +311,22 @@ export function useQuery< obsQuery.refetch(); } + + // TODO: This is a hack to make sure useLazyQuery executions update the + // obsevable query options in ssr mode. + if ( + context.renderPromises && + ssr !== false && + !skip && + obsQuery.getCurrentResult().loading + ) { + obsQuery.setOptions(options).catch(() => {}); + } } if ( - ssr === false && - (context.renderPromises || client.disableNetworkFetches) + (context.renderPromises || client.disableNetworkFetches) && + ssr === false ) { // If SSR has been explicitly disabled, and this function has been called // on the server side, return the default loading state. From fc1280928d4d9fe39e0ef7fbd666eb7fcf68798c Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 11 Aug 2021 12:45:07 -0400 Subject: [PATCH 36/65] move verifyDocumentType utility to parser --- src/__tests__/__snapshots__/exports.ts.snap | 1 + src/react/hooks/useQuery.ts | 13 +------------ src/react/parser/index.ts | 12 ++++++++++++ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 3835a31be88..e8198b071cf 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -291,6 +291,7 @@ Array [ "DocumentType", "operationName", "parser", + "verifyDocumentType", ] `; diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 79917c30e49..10ea50c2232 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -24,18 +24,7 @@ import { QueryResult, } from '../types/types'; -import { DocumentType, parser, operationName } from '../parser'; - -function verifyDocumentType(document: DocumentNode, type: DocumentType) { - const operation = parser(document); - const requiredOperationName = operationName(type); - const usedOperationName = operationName(operation.type); - invariant( - operation.type === type, - `Running a ${requiredOperationName} requires a graphql ` + - `${requiredOperationName}, but a ${usedOperationName} was used instead.` - ); -} +import { DocumentType, verifyDocumentType } from '../parser'; export function useQuery< TData = any, diff --git a/src/react/parser/index.ts b/src/react/parser/index.ts index ad0cbf69371..7934dfde813 100644 --- a/src/react/parser/index.ts +++ b/src/react/parser/index.ts @@ -113,3 +113,15 @@ export function parser(document: DocumentNode): IDocumentDefinition { cache.set(document, payload); return payload; } + +export function verifyDocumentType(document: DocumentNode, type: DocumentType) { + const operation = parser(document); + const requiredOperationName = operationName(type); + const usedOperationName = operationName(operation.type); + invariant( + operation.type === type, + `Running a ${requiredOperationName} requires a graphql ` + + `${requiredOperationName}, but a ${usedOperationName} was used instead.` + ); +} + From d5754f4de9a1933a022d610bbb2e939ae720f0ea Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 11 Aug 2021 13:21:11 -0400 Subject: [PATCH 37/65] smoooooosh useMutation into a single file --- src/react/hooks/useMutation.ts | 179 +++++++++++++++++++++++++++++++-- 1 file changed, 168 insertions(+), 11 deletions(-) diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index 8119a01c4a8..56f215ca14f 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -1,11 +1,159 @@ -import { useContext, useState, useRef, useEffect } from 'react'; +import { useContext, useEffect, useRef, useState } from 'react'; import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { invariant } from 'ts-invariant'; -import { MutationHookOptions, MutationTuple } from '../types/types'; -import { MutationData } from '../data'; -import { ApolloCache, DefaultContext, OperationVariables } from '../../core'; +import { + MutationDataOptions, + MutationFunctionOptions, + MutationHookOptions, + MutationResult, + MutationTuple +} from '../types/types'; + +import { + ApolloCache, + ApolloClient, + DefaultContext, + mergeOptions, + OperationVariables, +} from '../../core'; import { getApolloContext } from '../context'; +import { equal } from '@wry/equality'; +import { DocumentType, verifyDocumentType } from '../parser'; +import { ApolloError } from '../../errors'; +import { FetchResult } from '../../link/core'; + +type MutationResultWithoutClient = Omit, 'client'>; + +class MutationData< + TData = any, + TVariables = OperationVariables, + TContext = DefaultContext, + TCache extends ApolloCache = ApolloCache, +> { + private mostRecentMutationId: number; + private result: MutationResultWithoutClient; + private previousResult?: MutationResultWithoutClient; + private setResult: (result: MutationResultWithoutClient) => any; + private isMounted: boolean; + public client: ApolloClient; + public options: MutationDataOptions; + constructor({ + options, + client, + result, + setResult + }: { + client: ApolloClient, + options: MutationDataOptions; + result: MutationResultWithoutClient; + setResult: (result: MutationResultWithoutClient) => any; + }) { + this.result = result; + this.setResult = setResult; + this.mostRecentMutationId = 0; + this.isMounted = false; + this.client = client; + this.options = options; + } + + public execute(result: MutationResultWithoutClient): MutationTuple { + this.isMounted = true; + return [ + this.runMutation, + { ...result, client: this.client } + ] as MutationTuple; + } + + public afterExecute() { + this.isMounted = true; + return () => { + this.isMounted = false; + }; + } + + private runMutation = ( + mutationFunctionOptions: MutationFunctionOptions< + TData, + TVariables, + TContext, + TCache + > = {}, + ) => { + if (!this.result.loading && !this.options.ignoreResults) { + this.updateResult({ + loading: true, + error: undefined, + data: undefined, + called: true + }); + } + + const mutationId = ++this.mostRecentMutationId; + const options = mergeOptions( + this.options, + mutationFunctionOptions as any, + ); + + return this.client.mutate(options) + .then((response: FetchResult) => { + const { onCompleted, ignoreResults } = this.options; + const { data, errors } = response; + const error = + errors && errors.length > 0 + ? new ApolloError({ graphQLErrors: errors }) + : undefined; + + if (this.mostRecentMutationId === mutationId && !ignoreResults) { + this.updateResult({ + called: true, + loading: false, + data, + error + }); + } + + if (onCompleted) { + onCompleted(data!); + } + + return response; + }) + .catch((error: ApolloError) => { + if (this.mostRecentMutationId === mutationId) { + this.updateResult({ + loading: false, + error, + data: undefined, + called: true + }); + } + + const { onError } = this.options; + if (onError) { + onError(error); + return { + data: undefined, + errors: error, + }; + } + + throw error; + }); + }; + + private updateResult(result: MutationResultWithoutClient): MutationResultWithoutClient | undefined { + if ( + this.isMounted && + (!this.previousResult || !equal(this.previousResult, result)) + ) { + this.setResult(result); + this.previousResult = result; + return result; + } + } +} export function useMutation< TData = any, @@ -14,29 +162,38 @@ export function useMutation< TCache extends ApolloCache = ApolloCache, >( mutation: DocumentNode | TypedDocumentNode, - options?: MutationHookOptions + hookOptions?: MutationHookOptions ): MutationTuple { const context = useContext(getApolloContext()); + const client = hookOptions?.client || context.client; + invariant( + !!client, + 'Could not find "client" in the context or passed in as an option. ' + + 'Wrap the root component in an , or pass an ApolloClient' + + 'ApolloClient instance in via options.', + ); + verifyDocumentType(mutation, DocumentType.Mutation); + const [result, setResult] = useState({ called: false, loading: false }); - const updatedOptions = options ? { ...options, mutation } : { mutation }; + const options = { ...hookOptions, mutation }; const mutationDataRef = useRef>(); function getMutationDataRef() { if (!mutationDataRef.current) { mutationDataRef.current = new MutationData({ - options: updatedOptions, - context, + client: client!, + options, result, setResult }); } + return mutationDataRef.current; } const mutationData = getMutationDataRef(); - mutationData.setOptions(updatedOptions); - mutationData.context = context; - + mutationData.options = options; + mutationData.client = client; useEffect(() => mutationData.afterExecute()); return mutationData.execute(result); From 7365598122a9ba65879f85d9515af19eb79708b8 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 11 Aug 2021 15:34:43 -0400 Subject: [PATCH 38/65] refactor useMutation to not use MutationData --- src/react/hooks/useMutation.ts | 218 +++++++++++++-------------------- 1 file changed, 82 insertions(+), 136 deletions(-) diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index 56f215ca14f..15b5243a799 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -1,19 +1,23 @@ -import { useContext, useEffect, useRef, useState } from 'react'; +import { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { invariant } from 'ts-invariant'; import { - MutationDataOptions, MutationFunctionOptions, MutationHookOptions, MutationResult, - MutationTuple + MutationTuple, } from '../types/types'; import { ApolloCache, - ApolloClient, DefaultContext, mergeOptions, OperationVariables, @@ -24,177 +28,119 @@ import { DocumentType, verifyDocumentType } from '../parser'; import { ApolloError } from '../../errors'; import { FetchResult } from '../../link/core'; -type MutationResultWithoutClient = Omit, 'client'>; - -class MutationData< +export function useMutation< TData = any, TVariables = OperationVariables, TContext = DefaultContext, TCache extends ApolloCache = ApolloCache, -> { - private mostRecentMutationId: number; - private result: MutationResultWithoutClient; - private previousResult?: MutationResultWithoutClient; - private setResult: (result: MutationResultWithoutClient) => any; - private isMounted: boolean; - public client: ApolloClient; - public options: MutationDataOptions; - constructor({ - options, +>( + mutation: DocumentNode | TypedDocumentNode, + options?: MutationHookOptions, +): MutationTuple { + const context = useContext(getApolloContext()); + const client = options?.client || context.client; + invariant( + !!client, + 'Could not find "client" in the context or passed in as an option. ' + + 'Wrap the root component in an , or pass an ApolloClient' + + 'ApolloClient instance in via options.', + ); + verifyDocumentType(mutation, DocumentType.Mutation); + const [result, setResult] = useState({ + called: false, + loading: false, client, - result, - setResult - }: { - client: ApolloClient, - options: MutationDataOptions; - result: MutationResultWithoutClient; - setResult: (result: MutationResultWithoutClient) => any; - }) { - this.result = result; - this.setResult = setResult; - this.mostRecentMutationId = 0; - this.isMounted = false; - this.client = client; - this.options = options; - } - - public execute(result: MutationResultWithoutClient): MutationTuple { - this.isMounted = true; - return [ - this.runMutation, - { ...result, client: this.client } - ] as MutationTuple; - } + }); - public afterExecute() { - this.isMounted = true; - return () => { - this.isMounted = false; - }; - } + const ref = useRef({ + result, + mutationId: 0, + isMounted: true, + }); - private runMutation = ( - mutationFunctionOptions: MutationFunctionOptions< + const execute = useCallback(( + executeOptions: MutationFunctionOptions< TData, TVariables, TContext, TCache > = {}, ) => { - if (!this.result.loading && !this.options.ignoreResults) { - this.updateResult({ + if (!ref.current.result.loading && !options?.ignoreResults) { + setResult(ref.current.result = { loading: true, - error: undefined, - data: undefined, - called: true + error: void 0, + data: void 0, + called: true, + client, }); } - const mutationId = ++this.mostRecentMutationId; - const options = mergeOptions( - this.options, - mutationFunctionOptions as any, + const mutationId = ++ref.current.mutationId; + const clientOptions = mergeOptions( + { ...options, mutation }, + executeOptions as any, ); - - return this.client.mutate(options) + return client.mutate(clientOptions) .then((response: FetchResult) => { - const { onCompleted, ignoreResults } = this.options; const { data, errors } = response; const error = errors && errors.length > 0 ? new ApolloError({ graphQLErrors: errors }) - : undefined; + : void 0; - if (this.mostRecentMutationId === mutationId && !ignoreResults) { - this.updateResult({ + if (mutationId === ref.current.mutationId && !options?.ignoreResults) { + const result = { called: true, loading: false, data, - error - }); - } + error, + client, + }; - if (onCompleted) { - onCompleted(data!); + if ( + ref.current.isMounted && + !equal(ref.current.result, result) + ) { + ref.current.result = result; + setResult(result); + } } + options?.onCompleted?.(data!); return response; - }) - .catch((error: ApolloError) => { - if (this.mostRecentMutationId === mutationId) { - this.updateResult({ + }).catch((error) => { + if (mutationId === ref.current.mutationId) { + const result = { loading: false, error, - data: undefined, - called: true - }); + data: void 0, + called: true, + client, + }; + + if ( + ref.current.isMounted && + !equal(ref.current.result, result) + ) { + ref.current.result = result; + setResult(result); + } } - const { onError } = this.options; - if (onError) { - onError(error); - return { - data: undefined, - errors: error, - }; + if (options?.onError) { + options.onError(error); + // TODO(brian): why are we returning this here??? + return { data: void 0, errors: error }; } throw error; }); - }; - - private updateResult(result: MutationResultWithoutClient): MutationResultWithoutClient | undefined { - if ( - this.isMounted && - (!this.previousResult || !equal(this.previousResult, result)) - ) { - this.setResult(result); - this.previousResult = result; - return result; - } - } -} - -export function useMutation< - TData = any, - TVariables = OperationVariables, - TContext = DefaultContext, - TCache extends ApolloCache = ApolloCache, ->( - mutation: DocumentNode | TypedDocumentNode, - hookOptions?: MutationHookOptions -): MutationTuple { - const context = useContext(getApolloContext()); - const client = hookOptions?.client || context.client; - invariant( - !!client, - 'Could not find "client" in the context or passed in as an option. ' + - 'Wrap the root component in an , or pass an ApolloClient' + - 'ApolloClient instance in via options.', - ); - verifyDocumentType(mutation, DocumentType.Mutation); - - const [result, setResult] = useState({ called: false, loading: false }); - const options = { ...hookOptions, mutation }; - - const mutationDataRef = useRef>(); - function getMutationDataRef() { - if (!mutationDataRef.current) { - mutationDataRef.current = new MutationData({ - client: client!, - options, - result, - setResult - }); - } - - return mutationDataRef.current; - } + }, [client, options, mutation]); - const mutationData = getMutationDataRef(); - mutationData.options = options; - mutationData.client = client; - useEffect(() => mutationData.afterExecute()); + useEffect(() => () => { + ref.current.isMounted = false; + }, []); - return mutationData.execute(result); + return [execute, result]; } From 8263a1e3ace940ea8e1651b891fbb62583a625b6 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 12 Aug 2021 12:38:13 -0400 Subject: [PATCH 39/65] refactor useSubscription to not use SubscriptionData --- src/react/hooks/useSubscription.ts | 141 ++++++++++++++++++++++------- src/react/types/types.ts | 5 +- 2 files changed, 112 insertions(+), 34 deletions(-) diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index d0febf7d904..e10ed9e993c 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -1,56 +1,131 @@ -import { useContext, useState, useRef, useEffect, useReducer } from 'react'; +import { useContext, useState, useRef, useEffect } from 'react'; import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { invariant } from 'ts-invariant'; +import { DocumentType, verifyDocumentType } from '../parser'; + +import { + SubscriptionHookOptions, + SubscriptionResult +} from '../types/types'; -import { SubscriptionHookOptions } from '../types/types'; -import { SubscriptionData } from '../data'; import { OperationVariables } from '../../core'; import { getApolloContext } from '../context'; -import { useAfterFastRefresh } from './utils/useAfterFastRefresh'; + +import { equal } from '@wry/equality'; export function useSubscription( subscription: DocumentNode | TypedDocumentNode, - options?: SubscriptionHookOptions + options?: SubscriptionHookOptions, ) { - const [, forceUpdate] = useReducer(x => x + 1, 0); const context = useContext(getApolloContext()); - const updatedOptions = options - ? { ...options, subscription } - : { subscription }; - const [result, setResult] = useState({ - loading: !updatedOptions.skip, + const client = options?.client || context.client; + invariant( + !!client, + 'Could not find "client" in the context or passed in as an option. ' + + 'Wrap the root component in an , or pass an ApolloClient' + + 'ApolloClient instance in via options.', + ); + verifyDocumentType(subscription, DocumentType.Subscription); + + const [result, setResult] = useState>({ + loading: !options?.skip, error: void 0, data: void 0, + variables: options?.variables, }); - const subscriptionDataRef = useRef>(); - function getSubscriptionDataRef() { - if (!subscriptionDataRef.current) { - subscriptionDataRef.current = new SubscriptionData({ - options: updatedOptions, - context, - setResult - }); + const [observable, setObservable] = useState(() => { + if (options?.skip) { + return null; + } + + return client.subscribe({ + query: subscription, + variables: options?.variables, + fetchPolicy: options?.fetchPolicy, + context: options?.context, + }); + }); + + const ref = useRef({ client, subscription, options, observable }); + useEffect(() => { + let shouldResubscribe = options?.shouldResubscribe; + if (typeof shouldResubscribe === 'function') { + shouldResubscribe = !!shouldResubscribe(options!); } - return subscriptionDataRef.current; - } - const subscriptionData = getSubscriptionDataRef(); - subscriptionData.setOptions(updatedOptions, true); - subscriptionData.context = context; + if (options?.skip && !options?.skip !== !ref.current.options?.skip) { + setResult({ + loading: false, + data: void 0, + error: void 0, + variables: options?.variables, + }); + setObservable(null); + } else if ( + shouldResubscribe !== false && ( + client !== ref.current.client || + subscription !== ref.current.subscription || + options?.fetchPolicy !== ref.current.options?.fetchPolicy || + !options?.skip !== !ref.current.options?.skip || + !equal(options?.variables, ref.current.options?.variables) + ) + ) { + setResult({ + loading: true, + data: void 0, + error: void 0, + variables: options?.variables, + }); + setObservable(client.subscribe({ + query: subscription, + variables: options?.variables, + fetchPolicy: options?.fetchPolicy, + context: options?.context, + })); + } - if (__DEV__) { - // ensure we run an update after refreshing so that we can resubscribe - useAfterFastRefresh(forceUpdate); - } + Object.assign(ref.current, { client, subscription, options }); + }, [client, subscription, options]); - useEffect(() => subscriptionData.afterExecute()); useEffect(() => { + if (!observable) { + return; + } + + const subscription = observable.subscribe({ + next(fetchResult) { + const result = { + loading: false, + data: fetchResult.data!, + error: void 0, + variables: options?.variables, + }; + setResult(result); + + options?.onSubscriptionData?.({ + client, + subscriptionData: result + }); + }, + error(error) { + setResult({ + loading: false, + data: void 0, + error, + variables: options?.variables, + }); + }, + complete() { + options?.onSubscriptionComplete?.(); + }, + }); + return () => { - subscriptionData.cleanup(); - subscriptionDataRef.current = void 0; + subscription.unsubscribe(); }; - }, []); + }, [observable]); - return subscriptionData.execute(result); + return result; } diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 3f957a9bdac..552784aa749 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -234,10 +234,13 @@ export interface BaseSubscriptionOptions< onSubscriptionComplete?: () => void; } -export interface SubscriptionResult { +export interface SubscriptionResult { loading: boolean; data?: TData; error?: ApolloError; + // This was added by the legacy useSubscription type, and is tested in unit + // tests, but probably shouldn’t be added to the result. + variables?: TVariables; } export interface SubscriptionHookOptions< From 8c3948395309f2e430135d6ce5468adcf923a9b8 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 12 Aug 2021 12:38:41 -0400 Subject: [PATCH 40/65] update useSubscription tests --- .../__tests__/client/Subscription.test.tsx | 187 +++-- .../useSubscription.test.tsx.snap | 47 -- .../hooks/__tests__/useSubscription.test.tsx | 675 ++++++++---------- 3 files changed, 395 insertions(+), 514 deletions(-) delete mode 100644 src/react/hooks/__tests__/__snapshots__/useSubscription.test.tsx.snap diff --git a/src/react/components/__tests__/client/Subscription.test.tsx b/src/react/components/__tests__/client/Subscription.test.tsx index b6a33ca9165..c2f6eb809bd 100644 --- a/src/react/components/__tests__/client/Subscription.test.tsx +++ b/src/react/components/__tests__/client/Subscription.test.tsx @@ -361,28 +361,35 @@ describe('should update', () => { {(result: any) => { const { loading, data } = result; try { - if (count === 0) { - expect(loading).toBeTruthy(); - expect(data).toBeUndefined(); - } else if (count === 1) { - expect(loading).toBeFalsy(); - expect(data).toEqual(results[0].result.data); - setTimeout(() => { - this.setState( - { - client: client2 - }, - () => { - link2.simulateResult(results[1]); - } - ); - }); - } else if (count === 2) { - expect(loading).toBeTruthy(); - expect(data).toBeUndefined(); - } else if (count === 3) { - expect(loading).toBeFalsy(); - expect(data).toEqual(results[1].result.data); + switch (count) { + case 0: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + break; + case 1: + setTimeout(() => { + this.setState( + { + client: client2 + }, + () => { + link2.simulateResult(results[1]); + } + ); + }); + // fallthrough + case 2: + expect(loading).toBeFalsy(); + expect(data).toEqual(results[0].result.data); + break; + case 3: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + break; + case 4: + expect(loading).toBeFalsy(); + expect(data).toEqual(results[1].result.data); + break; } } catch (error) { reject(error); @@ -401,7 +408,7 @@ describe('should update', () => { link.simulateResult(results[0]); - return wait(() => expect(count).toBe(4)).then(resolve, reject); + return wait(() => expect(count).toBe(5)).then(resolve, reject); }); itAsync('if the query changes', (resolve, reject) => { @@ -449,28 +456,35 @@ describe('should update', () => { {(result: any) => { const { loading, data } = result; try { - if (count === 0) { - expect(loading).toBeTruthy(); - expect(data).toBeUndefined(); - } else if (count === 1) { - expect(loading).toBeFalsy(); - expect(data).toEqual(results[0].result.data); - setTimeout(() => { - this.setState( - { - subscription: subscriptionHero - }, - () => { - heroLink.simulateResult(heroResult); - } - ); - }); - } else if (count === 2) { - expect(loading).toBeTruthy(); - expect(data).toBeUndefined(); - } else if (count === 3) { - expect(loading).toBeFalsy(); - expect(data).toEqual(heroResult.result.data); + switch (count) { + case 0: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + break; + case 1: + setTimeout(() => { + this.setState( + { + subscription: subscriptionHero + }, + () => { + heroLink.simulateResult(heroResult); + } + ); + }); + // fallthrough + case 2: + expect(loading).toBeFalsy(); + expect(data).toEqual(results[0].result.data); + break; + case 3: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + break; + case 4: + expect(loading).toBeFalsy(); + expect(data).toEqual(heroResult.result.data); + break; } } catch (error) { reject(error); @@ -491,7 +505,7 @@ describe('should update', () => { userLink.simulateResult(results[0]); - return wait(() => expect(count).toBe(4)).then(resolve, reject); + return wait(() => expect(count).toBe(5)).then(resolve, reject); }); itAsync('if the variables change', (resolve, reject) => { @@ -518,31 +532,7 @@ describe('should update', () => { } }; - class MockSubscriptionLinkOverride extends MockSubscriptionLink { - variables: any; - request(req: Operation) { - this.variables = req.variables; - return super.request(req); - } - - simulateResult() { - if (this.variables.name === 'Luke Skywalker') { - return super.simulateResult({ - result: { - data: dataLuke - } - }); - } else if (this.variables.name === 'Han Solo') { - return super.simulateResult({ - result: { - data: dataHan - } - }); - } - } - } - - const mockLink = new MockSubscriptionLinkOverride(); + const mockLink = new MockSubscriptionLink(); const mockClient = new ApolloClient({ link: mockLink, @@ -565,28 +555,35 @@ describe('should update', () => { {(result: any) => { const { loading, data } = result; try { - if (count === 0) { - expect(loading).toBeTruthy(); - expect(data).toBeUndefined(); - } else if (count === 1) { - expect(loading).toBeFalsy(); - expect(data).toEqual(dataLuke); - setTimeout(() => { - this.setState( - { - variables: variablesHan - }, - () => { - mockLink.simulateResult(); - } - ); - }); - } else if (count === 2) { - expect(loading).toBeTruthy(); - expect(data).toBeUndefined(); - } else if (count === 3) { - expect(loading).toBeFalsy(); - expect(data).toEqual(dataHan); + switch (count) { + case 0: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + break; + case 1: + setTimeout(() => { + this.setState( + { + variables: variablesHan + }, + () => { + mockLink.simulateResult({ result: { data: dataHan } }); + } + ); + }); + // fallthrough + case 2: + expect(loading).toBeFalsy(); + expect(data).toEqual(dataLuke); + break; + case 3: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + break; + case 4: + expect(loading).toBeFalsy(); + expect(data).toEqual(dataHan); + break; } } catch (error) { reject(error); @@ -606,9 +603,9 @@ describe('should update', () => { ); - mockLink.simulateResult(); + mockLink.simulateResult({ result: { data: dataLuke } }); - return wait(() => expect(count).toBe(4)).then(resolve, reject); + return wait(() => expect(count).toBe(5)).then(resolve, reject); }); }); diff --git a/src/react/hooks/__tests__/__snapshots__/useSubscription.test.tsx.snap b/src/react/hooks/__tests__/__snapshots__/useSubscription.test.tsx.snap deleted file mode 100644 index ad04394392b..00000000000 --- a/src/react/hooks/__tests__/__snapshots__/useSubscription.test.tsx.snap +++ /dev/null @@ -1,47 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`useSubscription Hook should handle immediate completions gracefully 1`] = ` -[MockFunction] { - "calls": Array [ - Array [ - "Missing field 'car' while writing result {}", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], -} -`; - -exports[`useSubscription Hook should handle immediate completions with multiple subscriptions gracefully 1`] = ` -[MockFunction] { - "calls": Array [ - Array [ - "Missing field 'car' while writing result {}", - ], - Array [ - "Missing field 'car' while writing result {}", - ], - Array [ - "Missing field 'car' while writing result {}", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - Object { - "type": "return", - "value": undefined, - }, - Object { - "type": "return", - "value": undefined, - }, - ], -} -`; diff --git a/src/react/hooks/__tests__/useSubscription.test.tsx b/src/react/hooks/__tests__/useSubscription.test.tsx index 20a01f4eeed..3ad179278cb 100644 --- a/src/react/hooks/__tests__/useSubscription.test.tsx +++ b/src/react/hooks/__tests__/useSubscription.test.tsx @@ -1,16 +1,14 @@ import React from 'react'; -import { render, cleanup, wait } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; import gql from 'graphql-tag'; import { ApolloClient, ApolloLink, concat } from '../../../core'; import { InMemoryCache as Cache } from '../../../cache'; import { ApolloProvider } from '../../context'; -import { MockSubscriptionLink, withErrorSpy } from '../../../testing'; +import { MockSubscriptionLink } from '../../../testing'; import { useSubscription } from '../useSubscription'; describe('useSubscription Hook', () => { - afterEach(cleanup); - it('should handle a simple subscription properly', async () => { const subscription = gql` subscription { @@ -30,50 +28,37 @@ describe('useSubscription Hook', () => { cache: new Cache({ addTypename: false }) }); - let renderCount = 0; - const Component = () => { - const { loading, data, error } = useSubscription(subscription); - switch (renderCount) { - case 0: - expect(loading).toBe(true); - expect(error).toBeUndefined(); - expect(data).toBeUndefined(); - break; - case 1: - expect(loading).toBe(false); - expect(data).toEqual(results[0].result.data); - break; - case 2: - expect(loading).toBe(false); - expect(data).toEqual(results[1].result.data); - break; - case 3: - expect(loading).toBe(false); - expect(data).toEqual(results[2].result.data); - break; - case 4: - expect(loading).toBe(false); - expect(data).toEqual(results[3].result.data); - break; - default: - } - setTimeout(() => { - renderCount <= results.length && - link.simulateResult(results[renderCount - 1]); - }); - renderCount += 1; - return null; - }; - - render( - - - + + const { result, waitForNextUpdate } = renderHook( + () => useSubscription(subscription), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); - return wait(() => { - expect(renderCount).toBe(5); - }); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toBe(undefined); + setTimeout(() => link.simulateResult(results[0])); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(results[0].result.data); + setTimeout(() => link.simulateResult(results[1])); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(results[1].result.data); + setTimeout(() => link.simulateResult(results[2])); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(results[2].result.data); + setTimeout(() => link.simulateResult(results[3])); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(results[3].result.data); }); it('should cleanup after the subscription component has been unmounted', async () => { @@ -97,53 +82,40 @@ describe('useSubscription Hook', () => { cache: new Cache({ addTypename: false }) }); - let renderCount = 0; - let onSubscriptionDataCount = 0; - let unmount: any; + const onSubscriptionData = jest.fn(); + const { result, unmount, waitForNextUpdate } = renderHook( + () => useSubscription(subscription, { + onSubscriptionData, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); - const Component = () => { - const { loading, data, error } = useSubscription(subscription, { - onSubscriptionData() { - onSubscriptionDataCount += 1; - } - }); - switch (renderCount) { - case 0: - expect(loading).toBe(true); - expect(error).toBeUndefined(); - expect(data).toBeUndefined(); - link.simulateResult(results[0]); - break; - case 1: - expect(loading).toBe(false); - expect(data).toEqual(results[0].result.data); - - setTimeout(() => { - expect(onSubscriptionDataCount).toEqual(1); - - // After the component has been unmounted, the internal - // ObservableQuery should be stopped, meaning it shouldn't - // receive any new data (so the onSubscriptionDataCount should - // stay at 1). - unmount(); - link.simulateResult(results[0]); - }); - break; - default: - } - renderCount += 1; - return null; - }; - - unmount = render( - - - - ).unmount; - - return wait(() => { - expect(onSubscriptionDataCount).toEqual(1); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toBe(undefined); + setTimeout(() => link.simulateResult(results[0])); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toBe(results[0].result.data); + setTimeout(() => { + expect(onSubscriptionData).toHaveBeenCalledTimes(1); + // After the component has been unmounted, the internal + // ObservableQuery should be stopped, meaning it shouldn't + // receive any new data (so the onSubscriptionDataCount should + // stay at 1). + unmount(); + link.simulateResult(results[0]); }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(onSubscriptionData).toHaveBeenCalledTimes(1); }); it('should never execute a subscription with the skip option', async () => { @@ -161,42 +133,29 @@ describe('useSubscription Hook', () => { cache: new Cache({ addTypename: false }) }); - let renderCount = 0; - let onSubscriptionDataCount = 0; - let unmount: any; - - const Component = () => { - const { loading, data, error } = useSubscription(subscription, { + const onSubscriptionData = jest.fn(); + const { result, unmount, waitForNextUpdate } = renderHook( + () => useSubscription(subscription, { + onSubscriptionData, skip: true, - onSubscriptionData() { - onSubscriptionDataCount += 1; - } - }); - switch (renderCount) { - case 0: - expect(loading).toBe(false); - expect(error).toBeUndefined(); - expect(data).toBeUndefined(); - setTimeout(() => { - unmount(); - }); - break; - default: - } - renderCount += 1; - return null; - }; - - unmount = render( - - - - ).unmount; - - return wait(() => { - expect(onSubscriptionDataCount).toEqual(0); - expect(renderCount).toEqual(1); - }); + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toBe(undefined); + await expect(waitForNextUpdate({ timeout: 20 })) + .rejects.toThrow('Timed out'); + unmount(); + + expect(onSubscriptionData).toHaveBeenCalledTimes(0); }); it('should create a subscription after skip has changed from true to a falsy value', async () => { @@ -223,75 +182,65 @@ describe('useSubscription Hook', () => { cache: new Cache({ addTypename: false }) }); - let renderCount = 0; - let unmount: any; - - const Component = () => { - const [, triggerRerender] = React.useState(0); - const [skip, setSkip] = React.useState(true); - const { loading, data, error } = useSubscription(subscription, { - skip - }); - switch (renderCount) { - case 0: - expect(loading).toBe(false); - expect(error).toBeUndefined(); - expect(data).toBeUndefined(); - setSkip(false); - break; - case 1: - expect(loading).toBe(true); - expect(error).toBeUndefined(); - expect(data).toBeUndefined(); - link.simulateResult(results[0]); - break; - case 2: - expect(loading).toBe(false); - expect(data).toEqual(results[0].result.data); - setSkip(true); - break; - case 3: - expect(loading).toBe(false); - expect(data).toBeUndefined(); - expect(error).toBeUndefined(); - // ensure state persists across rerenders - triggerRerender(i => i + 1); - break; - case 4: - expect(loading).toBe(false); - expect(data).toBeUndefined(); - expect(error).toBeUndefined(); - setSkip(false); - break; - case 5: - expect(loading).toBe(true); - expect(error).toBeUndefined(); - expect(data).toBeUndefined(); - link.simulateResult(results[1]); - break; - case 6: - expect(loading).toBe(false); - expect(error).toBeUndefined(); - expect(data).toEqual(results[1].result.data); - setTimeout(() => { - unmount(); - }); - break; - default: - } - renderCount += 1; - return null; - }; - - unmount = render( - - - - ).unmount; - - return wait(() => { - expect(renderCount).toEqual(7); + const { result, rerender, waitForNextUpdate } = renderHook( + ({ skip }) => useSubscription(subscription, { skip }), + { + wrapper: ({ children }) => ( + + {children} + + ), + initialProps: { skip: true }, + }, + ); + + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); + + rerender({ skip: false }); + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); + + setTimeout(() => { + link.simulateResult(results[0]); }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(results[0].result.data); + expect(result.current.error).toBe(undefined); + + rerender({ skip: true }); + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); + + // ensure state persists across rerenders + rerender({ skip: true }); + + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); + + await expect(waitForNextUpdate({ timeout: 20 })) + .rejects.toThrow('Timed out'); + + // ensure state persists across rerenders + rerender({ skip: false }); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); + setTimeout(() => { + link.simulateResult(results[1]); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(results[1].result.data); + expect(result.current.error).toBe(undefined); }); it('should share context set in options', async () => { @@ -318,50 +267,44 @@ describe('useSubscription Hook', () => { cache: new Cache({ addTypename: false }) }); - let renderCount = 0; - const Component = () => { - const { loading, data, error } = useSubscription(subscription, { - context: { - make: 'Audi', - }, - }); - switch (renderCount) { - case 0: - expect(loading).toBe(true); - expect(error).toBeUndefined(); - expect(data).toBeUndefined(); - break; - case 1: - expect(loading).toBe(false); - expect(data).toEqual(results[0].result.data); - break; - case 2: - expect(loading).toBe(false); - expect(data).toEqual(results[1].result.data); - break; - default: - } - setTimeout(() => { - renderCount <= results.length && - link.simulateResult(results[renderCount - 1]); - }); - renderCount += 1; - return null; - }; - - render( - - - + const { result, waitForNextUpdate } = renderHook( + () => useSubscription(subscription, { + context: { make: 'Audi' }, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); - return wait(() => { - expect(renderCount).toBe(3); - expect(context).toEqual('Audi'); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toBe(undefined); + setTimeout(() => { + link.simulateResult(results[0]); + }, 100); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toEqual(results[0].result.data); + + setTimeout(() => { + link.simulateResult(results[1]); }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toEqual(results[1].result.data); + + expect(context!).toBe('Audi'); }); - it('should handle multiple subscriptions properly', () => { + it('should handle multiple subscriptions properly', async () => { const subscription = gql` subscription { car { @@ -380,68 +323,55 @@ describe('useSubscription Hook', () => { cache: new Cache({ addTypename: false }) }); - let renderCount = 0; - const Component = () => { - const { loading: loading1, data: data1, error: error1 } = useSubscription(subscription); - const { loading: loading2, data: data2, error: error2 } = useSubscription(subscription); - switch (renderCount) { - case 0: - expect(loading1).toBe(true); - expect(error1).toBeUndefined(); - expect(data1).toBeUndefined(); - expect(loading2).toBe(true); - expect(error2).toBeUndefined(); - expect(data2).toBeUndefined(); - break; - case 1: - expect(loading1).toBe(false); - expect(data1).toEqual(results[0].result.data); - expect(loading2).toBe(true); - expect(data2).toBe(undefined); - break; - case 2: - expect(loading1).toBe(false); - expect(data1).toEqual(results[0].result.data); - expect(loading2).toBe(false); - expect(data2).toEqual(results[0].result.data); - break; - case 3: - expect(loading1).toBe(false); - expect(data1).toEqual(results[1].result.data); - expect(loading2).toBe(false); - expect(data2).toEqual(results[0].result.data); - break; - case 4: - expect(loading1).toBe(false); - expect(data1).toEqual(results[1].result.data); - expect(loading2).toBe(false); - expect(data2).toEqual(results[1].result.data); - break; - default: - } + const { result, waitForNextUpdate } = renderHook( + () => ({ + sub1: useSubscription(subscription), + sub2: useSubscription(subscription), + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); - renderCount += 1; - return null; - }; + expect(result.current.sub1.loading).toBe(true); + expect(result.current.sub1.error).toBe(undefined); + expect(result.current.sub1.data).toBe(undefined); + expect(result.current.sub2.loading).toBe(true); + expect(result.current.sub2.error).toBe(undefined); + expect(result.current.sub2.data).toBe(undefined); - for (let i = 0; i < results.length; i++) { - setTimeout(() => { - link.simulateResult(results[i]); - }); - } + setTimeout(() => { + link.simulateResult(results[0]); + }); - render( - - - - ); + await waitForNextUpdate(); + expect(result.current.sub1.loading).toBe(false); + expect(result.current.sub1.error).toBe(undefined); + expect(result.current.sub1.data).toEqual(results[0].result.data); + expect(result.current.sub2.loading).toBe(false); + expect(result.current.sub2.error).toBe(undefined); + expect(result.current.sub2.data).toEqual(results[0].result.data); - return wait(() => { - expect(renderCount).toBe(5); + setTimeout(() => { + link.simulateResult(results[1]); }); + + await waitForNextUpdate(); + expect(result.current.sub1.loading).toBe(false); + expect(result.current.sub1.error).toBe(undefined); + expect(result.current.sub1.data).toEqual(results[1].result.data); + expect(result.current.sub2.loading).toBe(false); + expect(result.current.sub2.error).toBe(undefined); + expect(result.current.sub2.data).toEqual(results[1].result.data); }); - withErrorSpy(it, 'should handle immediate completions gracefully', () => { + it('should handle immediate completions gracefully', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const subscription = gql` subscription { car { @@ -450,53 +380,48 @@ describe('useSubscription Hook', () => { } `; - const result = { - result: { data: null }, - }; - const link = new MockSubscriptionLink(); const client = new ApolloClient({ link, cache: new Cache({ addTypename: false }) }); - let renderCount = 0; - const Component = () => { - const { loading, data, error } = useSubscription(subscription); - switch (renderCount) { - case 0: - expect(loading).toBe(true); - expect(data).toBeUndefined(); - expect(error).toBeUndefined(); - break; - case 1: - expect(loading).toBe(false); - expect(data).toBe(null); - break; - case 2: - throw new Error("Infinite rendering detected"); - default: - console.log(renderCount, {loading, data, error}); - } - - renderCount += 1; - return null; - }; - - // Simulating the behavior of HttpLink, which calls next and complete in sequence. - link.simulateResult(result, /* complete */ true); - render( - - - + const { result, waitForNextUpdate } = renderHook( + () => useSubscription(subscription), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); - return wait(() => { - expect(renderCount).toBe(2); + setTimeout(() => { + // Simulating the behavior of HttpLink, which calls next and complete in sequence. + link.simulateResult({ result: { data: null } }, /* complete */ true); }); + + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toBe(undefined); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toBe(null); + + await expect(waitForNextUpdate({ timeout: 20 })) + .rejects.toThrow('Timed out'); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toBe( + "Missing field 'car' while writing result {}", + ); + errorSpy.mockRestore(); }); - withErrorSpy(it, 'should handle immediate completions with multiple subscriptions gracefully', () => { + it('should handle immediate completions with multiple subscriptions gracefully', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); const subscription = gql` subscription { car { @@ -505,61 +430,67 @@ describe('useSubscription Hook', () => { } `; - const result = { - result: { data: null }, - }; - const link = new MockSubscriptionLink(); const client = new ApolloClient({ link, - cache: new Cache({ addTypename: false }) + cache: new Cache({ addTypename: false }), }); - let renderCount = 0; - const Component = () => { - const result1 = useSubscription(subscription); - const result2 = useSubscription(subscription); - const result3 = useSubscription(subscription); - switch (renderCount) { - case 0: - expect(result1).toEqual({loading: true, data: undefined, error: undefined}); - expect(result2).toEqual({loading: true, data: undefined, error: undefined}); - expect(result3).toEqual({loading: true, data: undefined, error: undefined}); - break; - case 1: - expect(result1).toEqual({loading: false, data: null, error: undefined}); - expect(result2).toEqual({loading: true, data: undefined, error: undefined}); - expect(result3).toEqual({loading: true, data: undefined, error: undefined}); - break; - case 2: - expect(result1).toEqual({loading: false, data: null, error: undefined}); - expect(result2).toEqual({loading: false, data: null, error: undefined}); - expect(result3).toEqual({loading: true, data: undefined, error: undefined}); - break; - case 3: - expect(result1).toEqual({loading: false, data: null, error: undefined}); - expect(result2).toEqual({loading: false, data: null, error: undefined}); - expect(result3).toEqual({loading: false, data: null, error: undefined}); - break; - case 4: - throw new Error("Infinite rendering detected"); - default: - } - - renderCount += 1; - return null; - }; - - // Simulating the behavior of HttpLink, which calls next and complete in sequence. - link.simulateResult(result, /* complete */ true); - render( - - - + const { result, waitForNextUpdate } = renderHook( + () => ({ + sub1: useSubscription(subscription), + sub2: useSubscription(subscription), + sub3: useSubscription(subscription), + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); - return wait(() => { - expect(renderCount).toBe(4); + expect(result.current.sub1.loading).toBe(true); + expect(result.current.sub1.error).toBe(undefined); + expect(result.current.sub1.data).toBe(undefined); + expect(result.current.sub2.loading).toBe(true); + expect(result.current.sub2.error).toBe(undefined); + expect(result.current.sub2.data).toBe(undefined); + expect(result.current.sub3.loading).toBe(true); + expect(result.current.sub3.error).toBe(undefined); + expect(result.current.sub3.data).toBe(undefined); + + setTimeout(() => { + // Simulating the behavior of HttpLink, which calls next and complete in sequence. + link.simulateResult({ result: { data: null } }, /* complete */ true); }); + + await waitForNextUpdate(); + + expect(result.current.sub1.loading).toBe(false); + expect(result.current.sub1.error).toBe(undefined); + expect(result.current.sub1.data).toBe(null); + expect(result.current.sub2.loading).toBe(false); + expect(result.current.sub2.error).toBe(undefined); + expect(result.current.sub2.data).toBe(null); + expect(result.current.sub3.loading).toBe(false); + expect(result.current.sub3.error).toBe(undefined); + expect(result.current.sub3.data).toBe(null); + + await expect(waitForNextUpdate({ timeout: 20 })) + .rejects.toThrow('Timed out'); + + expect(errorSpy).toHaveBeenCalledTimes(3); + expect(errorSpy.mock.calls[0][0]).toBe( + "Missing field 'car' while writing result {}", + ); + expect(errorSpy.mock.calls[1][0]).toBe( + "Missing field 'car' while writing result {}", + ); + expect(errorSpy.mock.calls[2][0]).toBe( + "Missing field 'car' while writing result {}", + ); + errorSpy.mockRestore(); }); }); From 96cda70bb75e7f81c29f8827c144b7c54e32b298 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 12 Aug 2021 12:53:35 -0400 Subject: [PATCH 41/65] Delete OperationData, QueryData, MutationData, SubscriptionData, useBaseQuery, useDeepMemo, useFastRefresh The Lannisters send their regards --- config/entryPoints.js | 1 - src/__tests__/__snapshots__/exports.ts.snap | 9 - src/__tests__/exports.ts | 2 - src/react/data/MutationData.ts | 172 ------ src/react/data/OperationData.ts | 80 --- src/react/data/QueryData.ts | 535 ------------------- src/react/data/SubscriptionData.ts | 156 ------ src/react/data/index.ts | 4 - src/react/hooks/utils/useAfterFastRefresh.ts | 29 - src/react/hooks/utils/useBaseQuery.ts | 97 ---- src/react/hooks/utils/useDeepMemo.ts | 22 - src/react/ssr/RenderPromises.ts | 12 +- 12 files changed, 9 insertions(+), 1110 deletions(-) delete mode 100644 src/react/data/MutationData.ts delete mode 100644 src/react/data/OperationData.ts delete mode 100644 src/react/data/QueryData.ts delete mode 100644 src/react/data/SubscriptionData.ts delete mode 100644 src/react/data/index.ts delete mode 100644 src/react/hooks/utils/useAfterFastRefresh.ts delete mode 100644 src/react/hooks/utils/useBaseQuery.ts delete mode 100644 src/react/hooks/utils/useDeepMemo.ts diff --git a/config/entryPoints.js b/config/entryPoints.js index 71c05482311..99d3a46485d 100644 --- a/config/entryPoints.js +++ b/config/entryPoints.js @@ -17,7 +17,6 @@ const entryPoints = [ { dirs: ['react'] }, { dirs: ['react', 'components'] }, { dirs: ['react', 'context'] }, - { dirs: ['react', 'data'] }, { dirs: ['react', 'hoc'] }, { dirs: ['react', 'hooks'] }, { dirs: ['react', 'parser'] }, diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index e8198b071cf..f797e80a0ce 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -256,15 +256,6 @@ Array [ ] `; -exports[`exports of public entry points @apollo/client/react/data 1`] = ` -Array [ - "MutationData", - "OperationData", - "QueryData", - "SubscriptionData", -] -`; - exports[`exports of public entry points @apollo/client/react/hoc 1`] = ` Array [ "graphql", diff --git a/src/__tests__/exports.ts b/src/__tests__/exports.ts index 13e00747a14..88ca3b43640 100644 --- a/src/__tests__/exports.ts +++ b/src/__tests__/exports.ts @@ -16,7 +16,6 @@ import * as linkWS from "../link/ws"; import * as react from "../react"; import * as reactComponents from "../react/components"; import * as reactContext from "../react/context"; -import * as reactData from "../react/data"; import * as reactHOC from "../react/hoc"; import * as reactHooks from "../react/hooks"; import * as reactParser from "../react/parser"; @@ -56,7 +55,6 @@ describe('exports of public entry points', () => { check("@apollo/client/react", react); check("@apollo/client/react/components", reactComponents); check("@apollo/client/react/context", reactContext); - check("@apollo/client/react/data", reactData); check("@apollo/client/react/hoc", reactHOC); check("@apollo/client/react/hooks", reactHooks); check("@apollo/client/react/parser", reactParser); diff --git a/src/react/data/MutationData.ts b/src/react/data/MutationData.ts deleted file mode 100644 index d16b45a2a96..00000000000 --- a/src/react/data/MutationData.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { equal } from '@wry/equality'; - -import { DocumentType } from '../parser'; -import { ApolloError } from '../../errors'; -import { - MutationDataOptions, - MutationTuple, - MutationFunctionOptions, - MutationResult, -} from '../types/types'; -import { OperationData } from './OperationData'; -import { MutationOptions, mergeOptions, ApolloCache, OperationVariables, DefaultContext } from '../../core'; -import { FetchResult } from '../../link/core'; - -type MutationResultWithoutClient = Omit, 'client'>; - -export class MutationData< - TData = any, - TVariables = OperationVariables, - TContext = DefaultContext, - TCache extends ApolloCache = ApolloCache, -> extends OperationData> { - private mostRecentMutationId: number; - private result: MutationResultWithoutClient; - private previousResult?: MutationResultWithoutClient; - private setResult: (result: MutationResultWithoutClient) => any; - - constructor({ - options, - context, - result, - setResult - }: { - options: MutationDataOptions; - context: any; - result: MutationResultWithoutClient; - setResult: (result: MutationResultWithoutClient) => any; - }) { - super(options, context); - this.verifyDocumentType(options.mutation, DocumentType.Mutation); - this.result = result; - this.setResult = setResult; - this.mostRecentMutationId = 0; - } - - public execute(result: MutationResultWithoutClient): MutationTuple { - this.isMounted = true; - this.verifyDocumentType(this.getOptions().mutation, DocumentType.Mutation); - return [ - this.runMutation, - { ...result, client: this.refreshClient().client } - ] as MutationTuple; - } - - public afterExecute() { - this.isMounted = true; - return this.unmount.bind(this); - } - - public cleanup() { - // No cleanup required. - } - - private runMutation = ( - mutationFunctionOptions: MutationFunctionOptions< - TData, - TVariables, - TContext, - TCache - > = {} as MutationFunctionOptions - ) => { - this.onMutationStart(); - const mutationId = this.generateNewMutationId(); - - return this.mutate(mutationFunctionOptions) - .then((response: FetchResult) => { - this.onMutationCompleted(response, mutationId); - return response; - }) - .catch((error: ApolloError) => { - const { onError } = this.getOptions(); - this.onMutationError(error, mutationId); - if (onError) { - onError(error); - return { - data: undefined, - errors: error, - }; - } else { - throw error; - } - }); - }; - - private mutate( - options: MutationFunctionOptions - ) { - return this.refreshClient().client.mutate( - mergeOptions( - this.getOptions(), - options as MutationOptions, - ), - ); - } - - private onMutationStart() { - if (!this.result.loading && !this.getOptions().ignoreResults) { - this.updateResult({ - loading: true, - error: undefined, - data: undefined, - called: true - }); - } - } - - private onMutationCompleted( - response: FetchResult, - mutationId: number - ) { - const { onCompleted, ignoreResults } = this.getOptions(); - - const { data, errors } = response; - const error = - errors && errors.length > 0 - ? new ApolloError({ graphQLErrors: errors }) - : undefined; - - const callOncomplete = () => - onCompleted ? onCompleted(data as TData) : null; - - if (this.isMostRecentMutation(mutationId) && !ignoreResults) { - this.updateResult({ - called: true, - loading: false, - data, - error - }); - } - callOncomplete(); - } - - private onMutationError(error: ApolloError, mutationId: number) { - if (this.isMostRecentMutation(mutationId)) { - this.updateResult({ - loading: false, - error, - data: undefined, - called: true - }); - } - } - - private generateNewMutationId(): number { - return ++this.mostRecentMutationId; - } - - private isMostRecentMutation(mutationId: number) { - return this.mostRecentMutationId === mutationId; - } - - private updateResult(result: MutationResultWithoutClient): MutationResultWithoutClient | undefined { - if ( - this.isMounted && - (!this.previousResult || !equal(this.previousResult, result)) - ) { - this.setResult(result); - this.previousResult = result; - return result; - } - } -} diff --git a/src/react/data/OperationData.ts b/src/react/data/OperationData.ts deleted file mode 100644 index f6f6584ba8c..00000000000 --- a/src/react/data/OperationData.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { DocumentNode } from 'graphql'; -import { equal } from '@wry/equality'; -import { invariant } from 'ts-invariant'; - -import { ApolloClient } from '../../core'; -import { DocumentType, parser, operationName } from '../parser'; -import { CommonOptions } from '../types/types'; - -export abstract class OperationData { - public isMounted: boolean = false; - public previousOptions: CommonOptions = {} as CommonOptions< - TOptions - >; - public context: any = {}; - public client: ApolloClient; - - private options: CommonOptions = {} as CommonOptions; - - constructor(options?: CommonOptions, context?: any) { - this.options = options || ({} as CommonOptions); - this.context = context || {}; - } - - public getOptions(): CommonOptions { - return this.options; - } - - public setOptions( - newOptions: CommonOptions, - storePrevious: boolean = false - ) { - if (storePrevious && !equal(this.options, newOptions)) { - this.previousOptions = this.options; - } - this.options = newOptions; - } - - public abstract execute(...args: any): any; - public abstract afterExecute(...args: any): void | (() => void); - public abstract cleanup(): void; - - protected unmount() { - this.isMounted = false; - } - - protected refreshClient() { - const client = - (this.options && this.options.client) || - (this.context && this.context.client); - - invariant( - !!client, - 'Could not find "client" in the context or passed in as an option. ' + - 'Wrap the root component in an , or pass an ' + - 'ApolloClient instance in via options.' - ); - - let isNew = false; - if (client !== this.client) { - isNew = true; - this.client = client; - this.cleanup(); - } - return { - client: this.client as ApolloClient, - isNew - }; - } - - protected verifyDocumentType(document: DocumentNode, type: DocumentType) { - const operation = parser(document); - const requiredOperationName = operationName(type); - const usedOperationName = operationName(operation.type); - invariant( - operation.type === type, - `Running a ${requiredOperationName} requires a graphql ` + - `${requiredOperationName}, but a ${usedOperationName} was used instead.` - ); - } -} diff --git a/src/react/data/QueryData.ts b/src/react/data/QueryData.ts deleted file mode 100644 index 71e05d0f718..00000000000 --- a/src/react/data/QueryData.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { equal } from '@wry/equality'; - -import { ApolloError } from '../../errors'; - -import { - ApolloClient, - NetworkStatus, - FetchMoreQueryOptions, - SubscribeToMoreOptions, - ObservableQuery, - FetchMoreOptions, - UpdateQueryOptions, - DocumentNode, - TypedDocumentNode, -} from '../../core'; - -import { - ObservableSubscription -} from '../../utilities'; - -import { DocumentType } from '../parser'; -import { - QueryResult, - QueryDataOptions, - QueryTuple, - QueryLazyOptions, - ObservableQueryFields, -} from '../types/types'; -import { OperationData } from './OperationData'; - -type ObservableQueryOptions = - ReturnType["prepareObservableQueryOptions"]>; - -export class QueryData extends OperationData< - QueryDataOptions -> { - public onNewData: () => void; - public currentObservable?: ObservableQuery; - private currentSubscription?: ObservableSubscription; - private runLazy: boolean = false; - private lazyOptions?: QueryLazyOptions; - private previous: { - client?: ApolloClient; - query?: DocumentNode | TypedDocumentNode; - observableQueryOptions?: ObservableQueryOptions; - result?: QueryResult; - loading?: boolean; - options?: QueryDataOptions; - error?: ApolloError; - } = Object.create(null); - - constructor({ - options, - context, - onNewData - }: { - options: QueryDataOptions; - context: any; - onNewData: () => void; - }) { - super(options, context); - this.onNewData = onNewData; - } - - public execute(): QueryResult { - this.refreshClient(); - - const { skip, query } = this.getOptions(); - if (skip || query !== this.previous.query) { - this.removeQuerySubscription(); - this.removeObservable(!skip); - this.previous.query = query; - } - - this.updateObservableQuery(); - - return this.getExecuteSsrResult() || this.getExecuteResult(); - } - - public executeLazy(): QueryTuple { - return !this.runLazy - ? [ - this.runLazyQuery, - { - loading: false, - networkStatus: NetworkStatus.ready, - called: false, - data: undefined - } - ] - : [this.runLazyQuery, this.execute()]; - } - - // For server-side rendering - public fetchData(): Promise | boolean { - const options = this.getOptions(); - if (options.skip || options.ssr === false) return false; - return new Promise(resolve => this.startQuerySubscription(resolve)); - } - - public afterExecute({ lazy = false }: { lazy?: boolean } = {}) { - this.isMounted = true; - const options = this.getOptions(); - if ( - this.currentObservable && - !this.ssrInitiated() && - !this.client.disableNetworkFetches - ) { - this.startQuerySubscription(); - } - - if (!lazy || this.runLazy) { - this.handleErrorOrCompleted(); - } - - this.previousOptions = options; - return this.unmount.bind(this); - } - - public cleanup() { - this.removeQuerySubscription(); - this.removeObservable(true); - delete this.previous.result; - } - - public getOptions() { - const options = super.getOptions(); - - if (this.lazyOptions) { - options.variables = { - ...options.variables, - ...this.lazyOptions.variables - } as TVariables; - options.context = { - ...options.context, - ...this.lazyOptions.context - }; - } - - // skip is not supported when using lazy query execution. - if (this.runLazy) { - delete options.skip; - } - - return options; - } - - public ssrInitiated() { - return this.context && this.context.renderPromises; - } - - private runLazyQuery = (options?: QueryLazyOptions) => { - this.cleanup(); - this.runLazy = true; - this.lazyOptions = options; - this.onNewData(); - }; - - private getExecuteSsrResult() { - const { ssr, skip } = this.getOptions(); - const ssrDisabled = ssr === false; - const fetchDisabled = this.refreshClient().client.disableNetworkFetches; - - const ssrLoading = { - loading: true, - networkStatus: NetworkStatus.loading, - called: true, - data: undefined, - stale: false, - client: this.client, - ...this.observableQueryFields(), - } as QueryResult; - - // If SSR has been explicitly disabled, and this function has been called - // on the server side, return the default loading state. - if (ssrDisabled && (this.ssrInitiated() || fetchDisabled)) { - this.previous.result = ssrLoading; - return ssrLoading; - } - - if (this.ssrInitiated()) { - const result = this.getExecuteResult() || ssrLoading; - if (result.loading && !skip) { - this.context.renderPromises!.addQueryPromise(this, () => null); - } - return result; - } - } - - private prepareObservableQueryOptions() { - const options = this.getOptions(); - this.verifyDocumentType(options.query, DocumentType.Query); - const displayName = options.displayName || 'Query'; - - // Set the fetchPolicy to cache-first for network-only and cache-and-network - // fetches for server side renders. - if ( - this.ssrInitiated() && - (options.fetchPolicy === 'network-only' || - options.fetchPolicy === 'cache-and-network') - ) { - options.fetchPolicy = 'cache-first'; - } - - return { - ...options, - displayName, - context: options.context, - }; - } - - private initializeObservableQuery() { - // See if there is an existing observable that was used to fetch the same - // data and if so, use it instead since it will contain the proper queryId - // to fetch the result set. This is used during SSR. - if (this.ssrInitiated()) { - this.currentObservable = this.context!.renderPromises!.getSSRObservable( - this.getOptions() - ); - } - - if (!this.currentObservable) { - const observableQueryOptions = this.prepareObservableQueryOptions(); - - this.previous.observableQueryOptions = { - ...observableQueryOptions, - children: void 0, - }; - this.currentObservable = this.refreshClient().client.watchQuery({ - ...observableQueryOptions - }); - - if (this.ssrInitiated()) { - this.context!.renderPromises!.registerSSRObservable( - this.currentObservable, - observableQueryOptions - ); - } - } - } - - private updateObservableQuery() { - // If we skipped initially, we may not have yet created the observable - if (!this.currentObservable) { - this.initializeObservableQuery(); - return; - } - - const newObservableQueryOptions = { - ...this.prepareObservableQueryOptions(), - children: void 0, - }; - - if (this.getOptions().skip) { - this.previous.observableQueryOptions = newObservableQueryOptions; - return; - } - - if ( - !equal(newObservableQueryOptions, this.previous.observableQueryOptions) - ) { - this.previous.observableQueryOptions = newObservableQueryOptions; - this.currentObservable - .setOptions(newObservableQueryOptions) - // The error will be passed to the child container, so we don't - // need to log it here. We could conceivably log something if - // an option was set. OTOH we don't log errors w/ the original - // query. See https://github.com/apollostack/react-apollo/issues/404 - .catch(() => {}); - } - } - - // Setup a subscription to watch for Apollo Client `ObservableQuery` changes. - // When new data is received, and it doesn't match the data that was used - // during the last `QueryData.execute` call (and ultimately the last query - // component render), trigger the `onNewData` callback. If not specified, - // `onNewData` will fallback to the default `QueryData.onNewData` function - // (which usually leads to a query component re-render). - private startQuerySubscription(onNewData: () => void = this.onNewData) { - if (this.currentSubscription || this.getOptions().skip) return; - - this.currentSubscription = this.currentObservable!.subscribe({ - next: ({ loading, networkStatus, data }) => { - const previousResult = this.previous.result; - - // Make sure we're not attempting to re-render similar results - if ( - previousResult && - previousResult.loading === loading && - previousResult.networkStatus === networkStatus && - equal(previousResult.data, data) - ) { - return; - } - - onNewData(); - }, - error: error => { - this.resubscribeToQuery(); - if (!error.hasOwnProperty('graphQLErrors')) throw error; - - const previousResult = this.previous.result; - if ( - (previousResult && previousResult.loading) || - !equal(error, this.previous.error) - ) { - this.previous.error = error; - onNewData(); - } - } - }); - } - - private resubscribeToQuery() { - this.removeQuerySubscription(); - - // Unfortunately, if `lastError` is set in the current - // `observableQuery` when the subscription is re-created, - // the subscription will immediately receive the error, which will - // cause it to terminate again. To avoid this, we first clear - // the last error/result from the `observableQuery` before re-starting - // the subscription, and restore it afterwards (so the subscription - // has a chance to stay open). - const { currentObservable } = this; - if (currentObservable) { - const last = currentObservable["last"]; - try { - currentObservable.resetLastResults(); - this.startQuerySubscription(); - } finally { - currentObservable["last"] = last; - } - } - } - - private getExecuteResult(): QueryResult { - let result = this.observableQueryFields() as QueryResult; - const options = this.getOptions(); - - // When skipping a query (ie. we're not querying for data but still want - // to render children), make sure the `data` is cleared out and - // `loading` is set to `false` (since we aren't loading anything). - // - // NOTE: We no longer think this is the correct behavior. Skipping should - // not automatically set `data` to `undefined`, but instead leave the - // previous data in place. In other words, skipping should not mandate - // that previously received data is all of a sudden removed. Unfortunately, - // changing this is breaking, so we'll have to wait until Apollo Client - // 4.0 to address this. - if (options.skip) { - result = { - ...result, - data: undefined, - error: undefined, - loading: false, - networkStatus: NetworkStatus.ready, - called: true, - }; - } else if (this.currentObservable) { - // Fetch the current result (if any) from the store. - const currentResult = this.currentObservable.getCurrentResult(); - const { data, loading, partial, networkStatus, errors } = currentResult; - let { error } = currentResult; - - // Until a set naming convention for networkError and graphQLErrors is - // decided upon, we map errors (graphQLErrors) to the error options. - if (errors && errors.length > 0) { - error = new ApolloError({ graphQLErrors: errors }); - } - - result = { - ...result, - data, - loading, - networkStatus, - error, - called: true - }; - - if (loading) { - // Fall through without modifying result... - } else if (error) { - Object.assign(result, { - data: (this.currentObservable.getLastResult() || ({} as any)) - .data - }); - } else { - const { fetchPolicy } = this.currentObservable.options; - const { partialRefetch } = options; - if ( - partialRefetch && - partial && - (!data || Object.keys(data).length === 0) && - fetchPolicy !== 'cache-only' - ) { - // When a `Query` component is mounted, and a mutation is executed - // that returns the same ID as the mounted `Query`, but has less - // fields in its result, Apollo Client's `QueryManager` returns the - // data as `undefined` since a hit can't be found in the cache. - // This can lead to application errors when the UI elements rendered by - // the original `Query` component are expecting certain data values to - // exist, and they're all of a sudden stripped away. To help avoid - // this we'll attempt to refetch the `Query` data. - Object.assign(result, { - loading: true, - networkStatus: NetworkStatus.loading - }); - result.refetch(); - return result; - } - } - } - - result.client = this.client; - // Store options as this.previousOptions. - this.setOptions(options, true); - - const previousResult = this.previous.result; - - this.previous.loading = - previousResult && previousResult.loading || false; - - // Ensure the returned result contains previousData as a separate - // property, to give developers the flexibility of leveraging outdated - // data while new data is loading from the network. Falling back to - // previousResult.previousData when previousResult.data is falsy here - // allows result.previousData to persist across multiple results. - result.previousData = previousResult && - (previousResult.data || previousResult.previousData); - - this.previous.result = result; - - // Any query errors that exist are now available in `result`, so we'll - // remove the original errors from the `ObservableQuery` query store to - // make sure they aren't re-displayed on subsequent (potentially error - // free) requests/responses. - this.currentObservable && this.currentObservable.resetQueryStoreErrors(); - - return result; - } - - private handleErrorOrCompleted() { - if (!this.currentObservable || !this.previous.result) return; - - const { data, loading, error } = this.previous.result; - - if (!loading) { - const { - query, - variables, - onCompleted, - onError, - skip - } = this.getOptions(); - - // No changes, so we won't call onError/onCompleted. - if ( - this.previousOptions && - !this.previous.loading && - equal(this.previousOptions.query, query) && - equal(this.previousOptions.variables, variables) - ) { - return; - } - - if (onCompleted && !error && !skip) { - onCompleted(data as TData); - } else if (onError && error) { - onError(error); - } - } - } - - private removeQuerySubscription() { - if (this.currentSubscription) { - this.currentSubscription.unsubscribe(); - delete this.currentSubscription; - } - } - - private removeObservable(andDelete: boolean) { - if (this.currentObservable) { - this.currentObservable["tearDownQuery"](); - if (andDelete) { - delete this.currentObservable; - } - } - } - - private obsRefetch = (variables?: Partial) => - this.currentObservable?.refetch(variables); - - private obsFetchMore = ( - fetchMoreOptions: FetchMoreQueryOptions & - FetchMoreOptions - ) => this.currentObservable?.fetchMore(fetchMoreOptions); - - private obsUpdateQuery = ( - mapFn: ( - previousQueryResult: TData, - options: UpdateQueryOptions - ) => TData - ) => this.currentObservable?.updateQuery(mapFn); - - private obsStartPolling = (pollInterval: number) => { - this.currentObservable?.startPolling(pollInterval); - }; - - private obsStopPolling = () => { - this.currentObservable?.stopPolling(); - }; - - private obsSubscribeToMore = < - TSubscriptionData = TData, - TSubscriptionVariables = TVariables - >( - options: SubscribeToMoreOptions< - TData, - TSubscriptionVariables, - TSubscriptionData - > - ) => this.currentObservable?.subscribeToMore(options); - - private observableQueryFields() { - return { - variables: this.currentObservable?.variables, - refetch: this.obsRefetch, - fetchMore: this.obsFetchMore, - updateQuery: this.obsUpdateQuery, - startPolling: this.obsStartPolling, - stopPolling: this.obsStopPolling, - subscribeToMore: this.obsSubscribeToMore - } as ObservableQueryFields; - } -} diff --git a/src/react/data/SubscriptionData.ts b/src/react/data/SubscriptionData.ts deleted file mode 100644 index 87fd89b97b3..00000000000 --- a/src/react/data/SubscriptionData.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { equal } from '@wry/equality'; - -import { OperationData } from './OperationData'; -import { - SubscriptionCurrentObservable, - SubscriptionDataOptions, - SubscriptionResult -} from '../types/types'; - -export class SubscriptionData< - TData = any, - TVariables = any -> extends OperationData> { - private setResult: any; - private currentObservable: SubscriptionCurrentObservable = {}; - - constructor({ - options, - context, - setResult - }: { - options: SubscriptionDataOptions; - context: any; - setResult: any; - }) { - super(options, context); - this.setResult = setResult; - this.initialize(options); - } - - public execute(result: SubscriptionResult) { - if (this.getOptions().skip === true) { - this.cleanup(); - return { - loading: false, - error: undefined, - data: undefined, - variables: this.getOptions().variables - }; - } - - let currentResult = result; - if (this.refreshClient().isNew) { - currentResult = this.getLoadingResult(); - } - - let { shouldResubscribe } = this.getOptions(); - if (typeof shouldResubscribe === 'function') { - shouldResubscribe = !!shouldResubscribe(this.getOptions()); - } - - if ( - shouldResubscribe !== false && - this.previousOptions && - Object.keys(this.previousOptions).length > 0 && - (this.previousOptions.subscription !== this.getOptions().subscription || - !equal(this.previousOptions.variables, this.getOptions().variables) || - this.previousOptions.skip !== this.getOptions().skip) - ) { - this.cleanup(); - currentResult = this.getLoadingResult(); - } - - this.initialize(this.getOptions()); - this.startSubscription(); - - this.previousOptions = this.getOptions(); - return { ...currentResult, variables: this.getOptions().variables }; - } - - public afterExecute() { - this.isMounted = true; - } - - public cleanup() { - this.endSubscription(); - delete this.currentObservable.query; - } - - private initialize(options: SubscriptionDataOptions) { - if (this.currentObservable.query || this.getOptions().skip === true) return; - this.currentObservable.query = this.refreshClient().client.subscribe({ - query: options.subscription, - variables: options.variables, - fetchPolicy: options.fetchPolicy, - context: options.context, - }); - } - - private startSubscription() { - if (this.currentObservable.subscription) return; - this.currentObservable.subscription = this.currentObservable.query!.subscribe( - { - next: this.updateCurrentData.bind(this), - error: this.updateError.bind(this), - complete: this.completeSubscription.bind(this) - } - ); - } - - private getLoadingResult() { - return { - loading: true, - error: undefined, - data: undefined - } as SubscriptionResult; - } - - private updateResult(result: SubscriptionResult) { - if (this.isMounted) { - this.setResult(result); - } - } - - private updateCurrentData(result: SubscriptionResult) { - const { onSubscriptionData } = this.getOptions(); - - this.updateResult({ - data: result.data, - loading: false, - error: undefined - }); - - if (onSubscriptionData) { - onSubscriptionData({ - client: this.refreshClient().client, - subscriptionData: result - }); - } - } - - private updateError(error: any) { - this.updateResult({ - error, - loading: false - }); - } - - private completeSubscription() { - // We have to defer this endSubscription call, because otherwise multiple - // subscriptions for the same component will cause infinite rendering. - // See https://github.com/apollographql/apollo-client/pull/7917 - Promise.resolve().then(() => { - const { onSubscriptionComplete } = this.getOptions(); - if (onSubscriptionComplete) onSubscriptionComplete(); - this.endSubscription(); - }); - } - - private endSubscription() { - if (this.currentObservable.subscription) { - this.currentObservable.subscription.unsubscribe(); - delete this.currentObservable.subscription; - } - } -} diff --git a/src/react/data/index.ts b/src/react/data/index.ts deleted file mode 100644 index 26776e66350..00000000000 --- a/src/react/data/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { SubscriptionData } from './SubscriptionData'; -export { OperationData } from './OperationData'; -export { MutationData } from './MutationData'; -export { QueryData } from './QueryData'; diff --git a/src/react/hooks/utils/useAfterFastRefresh.ts b/src/react/hooks/utils/useAfterFastRefresh.ts deleted file mode 100644 index de8742f398e..00000000000 --- a/src/react/hooks/utils/useAfterFastRefresh.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, useRef } from "react"; - -/** - * This hook allows running a function only immediately after a react - * fast refresh or live reload. - * - * Useful in order to ensure that we can reinitialize things that have been - * disposed by cleanup functions in `useEffect`. - * @param effectFn a function to run immediately after a fast refresh - */ -export function useAfterFastRefresh(effectFn: () => unknown) { - if (__DEV__) { - const didRefresh = useRef(false); - useEffect(() => { - return () => { - // Detect fast refresh, only runs multiple times in fast refresh - didRefresh.current = true; - }; - }, []); - - useEffect(() => { - if (didRefresh.current === true) { - // This block only runs after a fast refresh - didRefresh.current = false; - effectFn(); - } - }, []) - } -} diff --git a/src/react/hooks/utils/useBaseQuery.ts b/src/react/hooks/utils/useBaseQuery.ts deleted file mode 100644 index 48f5419dd4f..00000000000 --- a/src/react/hooks/utils/useBaseQuery.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useContext, useEffect, useReducer, useRef } from 'react'; -import { DocumentNode } from 'graphql'; -import { TypedDocumentNode } from '@graphql-typed-document-node/core'; - -import { - QueryHookOptions, - QueryDataOptions, - QueryTuple, - QueryResult, -} from '../../types/types'; -import { QueryData } from '../../data'; -import { useDeepMemo } from './useDeepMemo'; -import { OperationVariables } from '../../../core'; -import { getApolloContext } from '../../context'; -import { useAfterFastRefresh } from './useAfterFastRefresh'; - -export function useBaseQuery( - query: DocumentNode | TypedDocumentNode, - options?: QueryHookOptions, - lazy = false -) { - const context = useContext(getApolloContext()); - const [tick, forceUpdate] = useReducer(x => x + 1, 0); - const updatedOptions = options ? { ...options, query } : { query }; - - const queryDataRef = useRef>(); - const queryData = queryDataRef.current || ( - queryDataRef.current = new QueryData({ - options: updatedOptions as QueryDataOptions, - context, - onNewData() { - if (!queryData.ssrInitiated()) { - // When new data is received from the `QueryData` object, we want to - // force a re-render to make sure the new data is displayed. We can't - // force that re-render if we're already rendering however so to be - // safe we'll trigger the re-render in a microtask. In case the - // component gets unmounted before this callback fires, we re-check - // queryDataRef.current.isMounted before calling forceUpdate(). - Promise.resolve().then(() => queryDataRef.current && queryDataRef.current.isMounted && forceUpdate()); - } else { - // If we're rendering on the server side we can force an update at - // any point. - forceUpdate(); - } - } - }) - ); - - queryData.setOptions(updatedOptions); - queryData.context = context; - - // `onError` and `onCompleted` callback functions will not always have a - // stable identity, so we'll exclude them from the memoization key to - // prevent `afterExecute` from being triggered un-necessarily. - const memo = { - options: { - ...updatedOptions, - onError: void 0, - onCompleted: void 0 - } as QueryHookOptions, - context, - tick - }; - - const result = useDeepMemo( - () => (lazy ? queryData.executeLazy() : queryData.execute()), - memo - ); - - const queryResult = lazy - ? (result as QueryTuple)[1] - : (result as QueryResult); - - if (__DEV__) { - // ensure we run an update after refreshing so that we reinitialize - useAfterFastRefresh(forceUpdate); - } - - useEffect(() => { - return () => { - queryData.cleanup(); - // this effect can run multiple times during a fast-refresh - // so make sure we clean up the ref - queryDataRef.current = void 0; - } - }, []); - - useEffect(() => queryData.afterExecute({ lazy }), [ - queryResult.loading, - queryResult.networkStatus, - queryResult.error, - queryResult.data, - queryData.currentObservable, - ]); - - return result; -} diff --git a/src/react/hooks/utils/useDeepMemo.ts b/src/react/hooks/utils/useDeepMemo.ts deleted file mode 100644 index 868804e1bd4..00000000000 --- a/src/react/hooks/utils/useDeepMemo.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useRef } from 'react'; -import { equal } from '@wry/equality'; - -/** - * Memoize a result using deep equality. This hook has two advantages over - * React.useMemo: it uses deep equality to compare memo keys, and it guarantees - * that the memo function will only be called if the keys are unequal. - * React.useMemo cannot be relied on to do this, since it is only a performance - * optimization (see https://reactjs.org/docs/hooks-reference.html#usememo). - */ -export function useDeepMemo( - memoFn: () => TValue, - key: TKey -): TValue { - const ref = useRef<{ key: TKey; value: TValue }>(); - - if (!ref.current || !equal(key, ref.current.key)) { - ref.current = { key, value: memoFn() }; - } - - return ref.current.value; -} diff --git a/src/react/ssr/RenderPromises.ts b/src/react/ssr/RenderPromises.ts index 6bc06826051..fb74c87c304 100644 --- a/src/react/ssr/RenderPromises.ts +++ b/src/react/ssr/RenderPromises.ts @@ -2,7 +2,13 @@ import { DocumentNode } from 'graphql'; import { ObservableQuery } from '../../core'; import { QueryDataOptions } from '../types/types'; -import { QueryData } from '../data/QueryData'; + +// TODO: A vestigial interface from when hooks were implemented with utility +// classes, which should be deleted in the future. +interface QueryData { + getOptions(): any; + fetchData(): Promise; +} type QueryInfo = { seen: boolean; @@ -51,8 +57,8 @@ export class RenderPromises { return this.lookupQueryInfo(props).observable; } - public addQueryPromise( - queryInstance: QueryData, + public addQueryPromise( + queryInstance: QueryData, finish: () => React.ReactNode ): React.ReactNode { if (!this.stopped) { From 0b54af7705cf385f1b01691a6267133397746566 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 12 Aug 2021 14:07:35 -0400 Subject: [PATCH 42/65] reuse useApolloClient in the other hooks --- src/react/hooks/useApolloClient.ts | 20 ++++++++++++-------- src/react/hooks/useMutation.ts | 21 +++------------------ src/react/hooks/useQuery.ts | 11 +++-------- src/react/hooks/useSubscription.ts | 21 +++++---------------- 4 files changed, 23 insertions(+), 50 deletions(-) diff --git a/src/react/hooks/useApolloClient.ts b/src/react/hooks/useApolloClient.ts index 461635cab7f..7056fb69b28 100644 --- a/src/react/hooks/useApolloClient.ts +++ b/src/react/hooks/useApolloClient.ts @@ -1,15 +1,19 @@ -import * as React from 'react'; import { invariant } from 'ts-invariant'; - +import { useContext } from 'react'; import { ApolloClient } from '../../core'; import { getApolloContext } from '../context'; -export function useApolloClient(): ApolloClient { - const { client } = React.useContext(getApolloContext()); +export function useApolloClient( + override?: ApolloClient, +): ApolloClient { + const context = useContext(getApolloContext()); + const client = override || context.client; invariant( - client, - 'No Apollo Client instance can be found. Please ensure that you ' + - 'have called `ApolloProvider` higher up in your tree.' + !!client, + 'Could not find "client" in the context or passed in as an option. ' + + 'Wrap the root component in an , or pass an ApolloClient' + + 'ApolloClient instance in via options.', ); - return client!; + + return client; } diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index 15b5243a799..e0babac1ff3 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -1,14 +1,6 @@ -import { - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; -import { invariant } from 'ts-invariant'; - import { MutationFunctionOptions, MutationHookOptions, @@ -22,11 +14,11 @@ import { mergeOptions, OperationVariables, } from '../../core'; -import { getApolloContext } from '../context'; import { equal } from '@wry/equality'; import { DocumentType, verifyDocumentType } from '../parser'; import { ApolloError } from '../../errors'; import { FetchResult } from '../../link/core'; +import { useApolloClient } from './useApolloClient'; export function useMutation< TData = any, @@ -37,14 +29,7 @@ export function useMutation< mutation: DocumentNode | TypedDocumentNode, options?: MutationHookOptions, ): MutationTuple { - const context = useContext(getApolloContext()); - const client = options?.client || context.client; - invariant( - !!client, - 'Could not find "client" in the context or passed in as an option. ' + - 'Wrap the root component in an , or pass an ApolloClient' + - 'ApolloClient instance in via options.', - ); + const client = useApolloClient(options?.client); verifyDocumentType(mutation, DocumentType.Mutation); const [result, setResult] = useState({ called: false, diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 10ea50c2232..014c4339e13 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -5,7 +5,6 @@ import { useRef, useState, } from 'react'; -import { invariant } from 'ts-invariant'; import { equal } from '@wry/equality'; import { OperationVariables } from '../../core'; import { getApolloContext } from '../context'; @@ -25,6 +24,7 @@ import { } from '../types/types'; import { DocumentType, verifyDocumentType } from '../parser'; +import { useApolloClient } from './useApolloClient'; export function useQuery< TData = any, @@ -34,15 +34,10 @@ export function useQuery< hookOptions?: QueryHookOptions, ): QueryResult { const context = useContext(getApolloContext()); - const client = hookOptions?.client || context.client; - invariant( - !!client, - 'Could not find "client" in the context or passed in as an option. ' + - 'Wrap the root component in an , or pass an ApolloClient' + - 'ApolloClient instance in via options.', - ); + const client = useApolloClient(hookOptions?.client); verifyDocumentType(query, DocumentType.Query); + // TODO: useMemo is probably not correct here, what if options doesn’t change but the properties do? // create watchQueryOptions from hook options const { skip, diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index e10ed9e993c..6aedc964071 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -1,33 +1,22 @@ -import { useContext, useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; -import { invariant } from 'ts-invariant'; -import { DocumentType, verifyDocumentType } from '../parser'; +import { equal } from '@wry/equality'; +import { DocumentType, verifyDocumentType } from '../parser'; import { SubscriptionHookOptions, SubscriptionResult } from '../types/types'; - import { OperationVariables } from '../../core'; -import { getApolloContext } from '../context'; - -import { equal } from '@wry/equality'; +import { useApolloClient } from './useApolloClient'; export function useSubscription( subscription: DocumentNode | TypedDocumentNode, options?: SubscriptionHookOptions, ) { - const context = useContext(getApolloContext()); - const client = options?.client || context.client; - invariant( - !!client, - 'Could not find "client" in the context or passed in as an option. ' + - 'Wrap the root component in an , or pass an ApolloClient' + - 'ApolloClient instance in via options.', - ); + const client = useApolloClient(options?.client); verifyDocumentType(subscription, DocumentType.Subscription); - const [result, setResult] = useState>({ loading: !options?.skip, error: void 0, From 5e75f2495d3d148b9098c886a6aba2715bba0bfb Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 12 Aug 2021 14:10:34 -0400 Subject: [PATCH 43/65] remove resetQueryStoreErrors call from useQuery --- src/react/hooks/useQuery.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 014c4339e13..e2954f3b9aa 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -349,12 +349,6 @@ export function useQuery< }; } - // TODO: Is this still necessary? - // Any query errors that exist are now available in `result`, so we'll - // remove the original errors from the `ObservableQuery` query store to - // make sure they aren't re-displayed on subsequent (potentially error - // free) requests/responses. - obsQuery.resetQueryStoreErrors() return { ...obsQueryFields, variables: obsQuery.variables, From fb872634d51af92af0f254e8169e56373f2562f3 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 12 Aug 2021 14:10:55 -0400 Subject: [PATCH 44/65] add a comment about some useSubscription weirdness --- src/react/hooks/useSubscription.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index 6aedc964071..ea349b05a1d 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -87,6 +87,8 @@ export function useSubscription( next(fetchResult) { const result = { loading: false, + // TODO: fetchResult.data can be null but SubscriptionResult.data + // expects TData | undefined only data: fetchResult.data!, error: void 0, variables: options?.variables, From 92a262f1c6bd64e47a292ba20c7c1c399253d4b3 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 12 Aug 2021 20:19:43 -0400 Subject: [PATCH 45/65] fix useMutation test flake --- src/react/hooks/__tests__/useMutation.test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index abb2df851ad..82c3e081367 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -986,6 +986,7 @@ describe('useMutation Hook', () => { variables, }, result: { data: CREATE_TODO_RESULT }, + delay: 20, }), }); @@ -1066,6 +1067,12 @@ describe('useMutation Hook', () => { expect(result.current.mutation[1].data).toBe(undefined); expect(finishedReobserving).toBe(false); + await waitForNextUpdate(); + expect(result.current.query.loading).toBe(false); + expect(result.current.query.data).toEqual({ todoCount: 1 }); + expect(result.current.mutation[1].loading).toBe(true); + expect(result.current.mutation[1].data).toBe(undefined); + await waitForNextUpdate(); expect(result.current.query.loading).toBe(false); expect(result.current.query.data).toEqual({ todoCount: 1 }); From 91945a1574aeb600b8faf6d099dbeabe1af866a1 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 12 Aug 2021 20:51:25 -0400 Subject: [PATCH 46/65] clean up useQuery --- src/react/hooks/useQuery.ts | 249 ++++++++++++++++++------------------ 1 file changed, 123 insertions(+), 126 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index e2954f3b9aa..6c52deea6df 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -1,24 +1,17 @@ -import { - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { equal } from '@wry/equality'; import { OperationVariables } from '../../core'; import { getApolloContext } from '../context'; import { ApolloError } from '../../errors'; import { - ApolloClient, ApolloQueryResult, NetworkStatus, ObservableQuery, DocumentNode, TypedDocumentNode, + WatchQueryOptions, } from '../../core'; import { - QueryDataOptions, QueryHookOptions, QueryResult, } from '../types/types'; @@ -31,75 +24,36 @@ export function useQuery< TVariables = OperationVariables, >( query: DocumentNode | TypedDocumentNode, - hookOptions?: QueryHookOptions, + options?: QueryHookOptions, ): QueryResult { const context = useContext(getApolloContext()); - const client = useApolloClient(hookOptions?.client); + const client = useApolloClient(options?.client); verifyDocumentType(query, DocumentType.Query); - - // TODO: useMemo is probably not correct here, what if options doesn’t change but the properties do? - // create watchQueryOptions from hook options - const { - skip, - ssr, - partialRefetch, - onCompleted, - onError, - options, - } = useMemo(() => { - const { - skip, - ssr, - partialRefetch, - onCompleted, - onError, - ...options - } = { ...hookOptions, query }; - if (skip) { - options.fetchPolicy = 'standby'; - } else if ( - context.renderPromises && - ( - options.fetchPolicy === 'network-only' || - options.fetchPolicy === 'cache-and-network' - ) - ) { - // this behavior was added to react-apollo without explanation in this PR - // https://github.com/apollographql/react-apollo/pull/1579 - options.fetchPolicy = 'cache-first'; - } else if (!options.fetchPolicy) { - // cache-first is the default policy, but we explicitly assign it here so - // the cache policies computed based on options can be cleared - options.fetchPolicy = 'cache-first'; - } - - return { skip, ssr, partialRefetch, onCompleted, onError, options }; - }, [hookOptions, context.renderPromises]); - const [obsQuery, setObsQuery] = useState(() => { + const watchQueryOptions = createWatchQueryOptions(query, options); // See if there is an existing observable that was used to fetch the same // data and if so, use it instead since it will contain the proper queryId // to fetch the result set. This is used during SSR. let obsQuery: ObservableQuery | null = null; if (context.renderPromises) { - obsQuery = context.renderPromises.getSSRObservable(options); + obsQuery = context.renderPromises.getSSRObservable(watchQueryOptions); } if (!obsQuery) { // Is it safe (StrictMode/memory-wise) to call client.watchQuery here? - obsQuery = client.watchQuery(options); + obsQuery = client.watchQuery(watchQueryOptions); if (context.renderPromises) { context.renderPromises.registerSSRObservable( obsQuery, - options, + watchQueryOptions, ); } } if ( context.renderPromises && - ssr !== false && - !skip && + options?.ssr !== false && + !options?.skip && obsQuery.getCurrentResult().loading ) { // TODO: This is a legacy API which could probably be cleaned up @@ -107,7 +61,7 @@ export function useQuery< { // The only options which seem to actually be used by the // RenderPromises class are query and variables. - getOptions: () => options, + getOptions: () => createWatchQueryOptions(query, options), fetchData: () => new Promise((resolve) => { const sub = obsQuery!.subscribe({ next(result) { @@ -125,7 +79,7 @@ export function useQuery< }, }); }), - } as any, + }, // This callback never seemed to do anything () => null, ); @@ -134,19 +88,25 @@ export function useQuery< return obsQuery; }); - let [result, setResult] = useState(() => obsQuery.getCurrentResult()); - const prevRef = useRef<{ - client: ApolloClient, - query: DocumentNode | TypedDocumentNode, - options: QueryDataOptions, - result: ApolloQueryResult, - data: TData | undefined, - }>({ + let [result, setResult] = useState(() => { + const result = obsQuery.getCurrentResult(); + if (!result.loading) { + if (result.data) { + options?.onCompleted?.(result.data); + } else if (result.error) { + options?.onError?.(result.error); + } + } + + return result; + }); + + const ref = useRef({ client, query, options, result, - data: void 0, + data: void 0 as TData | undefined, }); // An effect to recreate the obsQuery whenever the client or query changes. @@ -155,28 +115,37 @@ export function useQuery< useEffect(() => { let nextResult: ApolloQueryResult | undefined; if ( - prevRef.current.client !== client || - !equal(prevRef.current.query, query) + ref.current.client !== client || + !equal(ref.current.query, query) ) { - const obsQuery = client.watchQuery(options); + const obsQuery = client.watchQuery( + createWatchQueryOptions(query, options), + ); setObsQuery(obsQuery); nextResult = obsQuery.getCurrentResult(); - } else if (!equal(prevRef.current.options, options)) { - obsQuery.setOptions(options).catch(() => {}); + } else if (!equal(ref.current.options, options)) { + obsQuery.setOptions(createWatchQueryOptions(query, options)) + .catch(() => {}); nextResult = obsQuery.getCurrentResult(); } if (nextResult) { - const previousResult = prevRef.current.result; + const previousResult = ref.current.result; if (previousResult.data) { - prevRef.current.data = previousResult.data; + ref.current.data = previousResult.data; } - prevRef.current.result = nextResult; - setResult(nextResult); + setResult(ref.current.result = nextResult); + if (!nextResult.loading) { + if (nextResult.data) { + options?.onCompleted?.(nextResult.data); + } else if (nextResult.error) { + options?.onError?.(nextResult.error); + } + } } - Object.assign(prevRef.current, { client, query, options }); + Object.assign(ref.current, { client, query, options }); }, [obsQuery, client, query, options]); // An effect to subscribe to the current observable query @@ -185,11 +154,12 @@ export function useQuery< return; } + let subscription = obsQuery.subscribe(onNext, onError); + // We use `getCurrentResult()` instead of the callback argument because + // the values differ slightly. Specifically, loading results will have + // an empty object for data instead of `undefined` for some reason. function onNext() { - const previousResult = prevRef.current.result; - // We use `getCurrentResult()` instead of the callback argument because - // the values differ slightly. Specifically, loading results will have - // an empty object for data instead of `undefined` for some reason. + const previousResult = ref.current.result; const result = obsQuery.getCurrentResult(); // Make sure we're not attempting to re-render similar results if ( @@ -202,16 +172,18 @@ export function useQuery< } if (previousResult.data) { - prevRef.current.data = previousResult.data; + ref.current.data = previousResult.data; } - prevRef.current.result = result; - setResult(result); + setResult(ref.current.result = result); + if (!result.loading) { + ref.current.options?.onCompleted?.(result.data); + } } function onError(error: Error) { const last = obsQuery["last"]; - sub.unsubscribe(); + subscription.unsubscribe(); // Unfortunately, if `lastError` is set in the current // `observableQuery` when the subscription is re-created, // the subscription will immediately receive the error, which will @@ -221,55 +193,33 @@ export function useQuery< // has a chance to stay open). try { obsQuery.resetLastResults(); - sub = obsQuery.subscribe(onNext, onError); + subscription = obsQuery.subscribe(onNext, onError); } finally { obsQuery["last"] = last; } if (!error.hasOwnProperty('graphQLErrors')) { - // The error is not a graphQL error + // The error is not a GraphQL error throw error; } - const previousResult = prevRef.current.result; + const previousResult = ref.current.result; if ( (previousResult && previousResult.loading) || !equal(error, previousResult.error) ) { - prevRef.current.result = { + setResult(ref.current.result = { data: previousResult.data, error: error as ApolloError, loading: false, networkStatus: NetworkStatus.error, - }; - setResult(prevRef.current.result); + }); + ref.current.options?.onError?.(error as ApolloError); } } - let sub = obsQuery.subscribe(onNext, onError); - return () => sub.unsubscribe(); - }, [obsQuery, client.disableNetworkFetches, context.renderPromises]); - - const obsQueryFields = useMemo(() => ({ - refetch: obsQuery.refetch.bind(obsQuery), - fetchMore: obsQuery.fetchMore.bind(obsQuery), - updateQuery: obsQuery.updateQuery.bind(obsQuery), - startPolling: obsQuery.startPolling.bind(obsQuery), - stopPolling: obsQuery.stopPolling.bind(obsQuery), - subscribeToMore: obsQuery.subscribeToMore.bind(obsQuery), - }), [obsQuery]); - - // An effect which calls the onCompleted and onError callbacks - useEffect(() => { - if (!result.loading) { - if (result.error) { - onError?.(result.error); - } else if (result.data) { - onCompleted?.(result.data); - } - } - // TODO: Do we need to add onCompleted and onError to the dependency array - }, [result, onCompleted, onError]); + return () => subscription.unsubscribe(); + }, [obsQuery, context.renderPromises, client.disableNetworkFetches]); let partial: boolean | undefined; ({ partial, ...result } = result); @@ -282,7 +232,7 @@ export function useQuery< // edge cases when this block was put in an effect. if ( partial && - partialRefetch && + options?.partialRefetch && !result.loading && (!result.data || Object.keys(result.data).length === 0) && obsQuery.options.fetchPolicy !== 'cache-only' @@ -297,30 +247,30 @@ export function useQuery< } // TODO: This is a hack to make sure useLazyQuery executions update the - // obsevable query options in ssr mode. + // obsevable query options for ssr. if ( context.renderPromises && - ssr !== false && - !skip && - obsQuery.getCurrentResult().loading + options?.ssr !== false && + !options?.skip && + result.loading ) { - obsQuery.setOptions(options).catch(() => {}); + obsQuery.setOptions(createWatchQueryOptions(query, options)).catch(() => {}); } } if ( (context.renderPromises || client.disableNetworkFetches) && - ssr === false + options?.ssr === false ) { // If SSR has been explicitly disabled, and this function has been called // on the server side, return the default loading state. - result = prevRef.current.result = { + result = ref.current.result = { loading: true, data: void 0 as unknown as TData, error: void 0, networkStatus: NetworkStatus.loading, }; - } else if (skip) { + } else if (options?.skip) { // When skipping a query (ie. we're not querying for data but still want to // render children), make sure the `data` is cleared out and `loading` is // set to `false` (since we aren't loading anything). @@ -342,19 +292,66 @@ export function useQuery< if (result.errors && result.errors.length) { // Until a set naming convention for networkError and graphQLErrors is // decided upon, we map errors (graphQLErrors) to the error options. - // TODO: Is it possible for both result.error and result.errors to be defined here? + // TODO: Is it possible for both result.error and result.errors to be + // defined here? result = { ...result, error: result.error || new ApolloError({ graphQLErrors: result.errors }), }; } + const obsQueryFields = useMemo(() => ({ + refetch: obsQuery.refetch.bind(obsQuery), + fetchMore: obsQuery.fetchMore.bind(obsQuery), + updateQuery: obsQuery.updateQuery.bind(obsQuery), + startPolling: obsQuery.startPolling.bind(obsQuery), + stopPolling: obsQuery.stopPolling.bind(obsQuery), + subscribeToMore: obsQuery.subscribeToMore.bind(obsQuery), + }), [obsQuery]); + return { ...obsQueryFields, variables: obsQuery.variables, client, called: true, - previousData: prevRef.current.data, + previousData: ref.current.data, ...result, }; } + +function createWatchQueryOptions( + query: DocumentNode | TypedDocumentNode, + options: QueryHookOptions = {}, +): WatchQueryOptions { + // TODO: For some reason, we pass context, which is the React Apollo Context, + // into observable queries, and test for that. + // removing hook specific options + const { + skip, + ssr, + onCompleted, + onError, + displayName, + ...watchQueryOptions + } = options; + + if (skip) { + watchQueryOptions.fetchPolicy = 'standby'; + } else if ( + watchQueryOptions.context?.renderPromises && + ( + watchQueryOptions.fetchPolicy === 'network-only' || + watchQueryOptions.fetchPolicy === 'cache-and-network' + ) + ) { + // this behavior was added to react-apollo without explanation in this PR + // https://github.com/apollographql/react-apollo/pull/1579 + watchQueryOptions.fetchPolicy = 'cache-first'; + } else if (!watchQueryOptions.fetchPolicy) { + // cache-first is the default policy, but we explicitly assign it here so + // the cache policies computed based on options can be cleared + watchQueryOptions.fetchPolicy = 'cache-first'; + } + + return { query, ...watchQueryOptions }; +} From 0eb8888cc86ab2440d24704178c9605a8a0e5cff Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 12 Aug 2021 21:03:41 -0400 Subject: [PATCH 47/65] clean up hooks --- src/react/hooks/useLazyQuery.ts | 2 +- src/react/hooks/useMutation.ts | 94 +++++++++++++++--------------- src/react/hooks/useSubscription.ts | 2 +- 3 files changed, 48 insertions(+), 50 deletions(-) diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts index d6882d85cc5..9e5b76532bd 100644 --- a/src/react/hooks/useLazyQuery.ts +++ b/src/react/hooks/useLazyQuery.ts @@ -26,7 +26,7 @@ export function useLazyQuery( >((lazyOptions?: QueryLazyOptions) => { setExecution((execution) => { if (execution.called) { - result && result.refetch(execution.lazyOptions); + result && result.refetch(lazyOptions); } return { called: true, lazyOptions }; diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index e0babac1ff3..ade44580ebf 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -17,7 +17,6 @@ import { import { equal } from '@wry/equality'; import { DocumentType, verifyDocumentType } from '../parser'; import { ApolloError } from '../../errors'; -import { FetchResult } from '../../link/core'; import { useApolloClient } from './useApolloClient'; export function useMutation< @@ -66,61 +65,60 @@ export function useMutation< { ...options, mutation }, executeOptions as any, ); - return client.mutate(clientOptions) - .then((response: FetchResult) => { - const { data, errors } = response; - const error = - errors && errors.length > 0 - ? new ApolloError({ graphQLErrors: errors }) - : void 0; + return client.mutate(clientOptions).then((response) => { + const { data, errors } = response; + const error = + errors && errors.length > 0 + ? new ApolloError({ graphQLErrors: errors }) + : void 0; - if (mutationId === ref.current.mutationId && !options?.ignoreResults) { - const result = { - called: true, - loading: false, - data, - error, - client, - }; + if (mutationId === ref.current.mutationId && !options?.ignoreResults) { + const result = { + called: true, + loading: false, + data, + error, + client, + }; - if ( - ref.current.isMounted && - !equal(ref.current.result, result) - ) { - ref.current.result = result; - setResult(result); - } + if ( + ref.current.isMounted && + !equal(ref.current.result, result) + ) { + ref.current.result = result; + setResult(result); } + } - options?.onCompleted?.(data!); - return response; - }).catch((error) => { - if (mutationId === ref.current.mutationId) { - const result = { - loading: false, - error, - data: void 0, - called: true, - client, - }; + options?.onCompleted?.(data!); + return response; + }).catch((error) => { + if (mutationId === ref.current.mutationId) { + const result = { + loading: false, + error, + data: void 0, + called: true, + client, + }; - if ( - ref.current.isMounted && - !equal(ref.current.result, result) - ) { - ref.current.result = result; - setResult(result); - } + if ( + ref.current.isMounted && + !equal(ref.current.result, result) + ) { + ref.current.result = result; + setResult(result); } + } - if (options?.onError) { - options.onError(error); - // TODO(brian): why are we returning this here??? - return { data: void 0, errors: error }; - } + if (options?.onError) { + options.onError(error); + // TODO(brian): why are we returning this here??? + return { data: void 0, errors: error }; + } - throw error; - }); + throw error; + }); }, [client, options, mutation]); useEffect(() => () => { diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index ea349b05a1d..bddc35c6c7b 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -37,7 +37,7 @@ export function useSubscription( }); }); - const ref = useRef({ client, subscription, options, observable }); + const ref = useRef({ client, subscription, options }); useEffect(() => { let shouldResubscribe = options?.shouldResubscribe; if (typeof shouldResubscribe === 'function') { From 726424fdfc9da03dc8c26461838cb6830f7698a1 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Thu, 12 Aug 2021 21:26:43 -0400 Subject: [PATCH 48/65] Allow observableQuery methods to be called by useLazyQuery Fixes #5140 --- .../hooks/__tests__/useLazyQuery.test.tsx | 54 +++++++++++++++++++ src/react/hooks/useLazyQuery.ts | 10 ++++ src/react/types/types.ts | 25 +-------- 3 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index c5c9652d73f..81f58cfb638 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -327,4 +327,58 @@ describe('useLazyQuery Hook', () => { expect(result.current[1].data).toEqual({ hello: 'world 2' }); expect(result.current[1].previousData).toEqual({ hello: 'world 1' }); }); + + it('should allow for the query to start with polling', async () => { + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: "world 1" } }, + }, + { + request: { query }, + result: { data: { hello: "world 2" } }, + }, + { + request: { query }, + result: { data: { hello: "world 3" } }, + }, + ]; + + const wrapper = ({ children }: any) => ( + {children} + ); + + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query), + { wrapper }, + ); + + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toBe(undefined); + + setTimeout(() => { + result.current[1].startPolling(10); + }); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(true); + expect(result.current[1].data).toBe(undefined); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: "world 1" }); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: "world 2" }); + + await waitForNextUpdate(); + + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: "world 3" }); + + result.current[1].stopPolling(); + await expect(waitForNextUpdate({ timeout: 20 })).rejects.toThrow('Timed out'); + }); }); diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts index 9e5b76532bd..1762d11694b 100644 --- a/src/react/hooks/useLazyQuery.ts +++ b/src/react/hooks/useLazyQuery.ts @@ -43,6 +43,16 @@ export function useLazyQuery( }); if (!execution.called) { + for (const key in result) { + if (typeof (result as any)[key] === 'function') { + const method = (result as any)[key]; + (result as any)[key] = (...args: any) => { + setExecution({ called: true }); + return method(...args); + }; + } + } + result = { ...result, loading: false, diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 552784aa749..3dbd527e6a0 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -104,29 +104,8 @@ export interface QueryLazyOptions { context?: DefaultContext; } -type UnexecutedLazyFields = { - loading: false; - networkStatus: NetworkStatus.ready; - called: false; - data: undefined; - previousData?: undefined; -} - -type Impartial = { - [P in keyof T]?: never; -} - -type AbsentLazyResultFields = - Omit< - Impartial>, - keyof UnexecutedLazyFields> - -type UnexecutedLazyResult = - UnexecutedLazyFields & AbsentLazyResultFields - -export type LazyQueryResult = - | UnexecutedLazyResult - | QueryResult; +// TODO: Delete this +export type LazyQueryResult = QueryResult; export type QueryTuple = [ (options?: QueryLazyOptions) => void, From b9808e29dd927950579cfa7f2c7d7ab7f9533c18 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 13 Aug 2021 00:20:41 -0400 Subject: [PATCH 49/65] treat standby fetch policies like skip queries Fixes #7564 --- src/react/hooks/__tests__/useQuery.test.tsx | 33 +++++++++++++++++++++ src/react/hooks/useQuery.ts | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index b401773b671..2d620505e04 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2634,6 +2634,39 @@ describe('useQuery Hook', () => { unmount(); expect(client.getObservableQueries('all').size).toBe(0); }); + + it('should treat fetchPolicy standby like skip', async () => { + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: 'world' } }, + }, + ]; + const { result, rerender, waitForNextUpdate } = renderHook( + ({ fetchPolicy }) => useQuery(query, { fetchPolicy }), + { + wrapper: ({ children }) => ( + + {children} + + ), + initialProps: { fetchPolicy: 'standby' as any }, + }, + ); + + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + await expect(waitForNextUpdate({ timeout: 20 })).rejects.toThrow('Timed out'); + + rerender({ fetchPolicy: 'cache-first' }); + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + + await waitForNextUpdate(); + expect(result.current.loading).toBeFalsy(); + expect(result.current.data).toEqual({ hello: 'world' }); + }); }); describe('Missing Fields', () => { diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 6c52deea6df..ebf19dd0437 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -270,7 +270,7 @@ export function useQuery< error: void 0, networkStatus: NetworkStatus.loading, }; - } else if (options?.skip) { + } else if (options?.skip || options?.fetchPolicy === 'standby') { // When skipping a query (ie. we're not querying for data but still want to // render children), make sure the `data` is cleared out and `loading` is // set to `false` (since we aren't loading anything). From fc1654152a64f3dbabe27adc2d8f01081be55092 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 13 Aug 2021 12:59:24 -0400 Subject: [PATCH 50/65] call setOptions based on watchQueryOptions --- src/react/hooks/useQuery.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index ebf19dd0437..74896a81543 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -107,26 +107,26 @@ export function useQuery< options, result, data: void 0 as TData | undefined, + watchQueryOptions: createWatchQueryOptions(query, options), }); // An effect to recreate the obsQuery whenever the client or query changes. // This effect is also responsible for checking and updating the obsQuery // options whenever they change. useEffect(() => { + const watchQueryOptions = createWatchQueryOptions(query, options); let nextResult: ApolloQueryResult | undefined; if ( ref.current.client !== client || !equal(ref.current.query, query) ) { - const obsQuery = client.watchQuery( - createWatchQueryOptions(query, options), - ); + const obsQuery = client.watchQuery(watchQueryOptions); setObsQuery(obsQuery); nextResult = obsQuery.getCurrentResult(); - } else if (!equal(ref.current.options, options)) { - obsQuery.setOptions(createWatchQueryOptions(query, options)) - .catch(() => {}); + } else if (!equal(ref.current.watchQueryOptions, watchQueryOptions)) { + obsQuery.setOptions(watchQueryOptions).catch(() => {}); nextResult = obsQuery.getCurrentResult(); + ref.current.watchQueryOptions = watchQueryOptions; } if (nextResult) { From 9e523d5c1165713ad9074b013943a46b448dc4ea Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 13 Aug 2021 13:46:07 -0400 Subject: [PATCH 51/65] add a test to make sure pollInterval triggers onCompleted Confirms #5531 is fixed. --- src/react/hooks/__tests__/useQuery.test.tsx | 51 +++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 2d620505e04..91f3b18c463 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2087,6 +2087,57 @@ describe('useQuery Hook', () => { await expect(waitForNextUpdate({ timeout: 20 })).rejects.toThrow('Timed out'); expect(onCompleted).toHaveBeenCalledTimes(1); }); + + it('onCompleted should work with polling', async () => { + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: 'world 1' } }, + }, + { + request: { query }, + result: { data: { hello: 'world 2' } }, + }, + { + request: { query }, + result: { data: { hello: 'world 3' } }, + }, + ]; + + const cache = new InMemoryCache(); + const onCompleted = jest.fn(); + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query, { + onCompleted, + pollInterval: 10, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(true); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ hello: 'world 1' }); + expect(onCompleted).toHaveBeenCalledTimes(1); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ hello: 'world 2' }); + expect(onCompleted).toHaveBeenCalledTimes(2); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ hello: 'world 3' }); + expect(onCompleted).toHaveBeenCalledTimes(3); + }); }); describe('Optimistic data', () => { From 14cf3420a8d1e5430bb04b3eadf73ea772a2b146 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 13 Aug 2021 14:14:05 -0400 Subject: [PATCH 52/65] =?UTF-8?q?Don=E2=80=99t=20refetch=20when=20fetchPol?= =?UTF-8?q?icy=20is=20standby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #8270 --- src/core/ObservableQuery.ts | 4 ++- src/react/hooks/__tests__/useQuery.test.tsx | 34 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 6038a87755f..cb4b57c6f06 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -305,7 +305,9 @@ export class ObservableQuery< // (no-cache, network-only, or cache-and-network), override it with // network-only to force the refetch for this fetchQuery call. const { fetchPolicy } = this.options; - if (fetchPolicy === 'no-cache') { + if (fetchPolicy === 'standby') { + // do nothing + } else if (fetchPolicy === 'no-cache') { reobserveOptions.fetchPolicy = 'no-cache'; } else if (fetchPolicy !== 'cache-and-network') { reobserveOptions.fetchPolicy = 'network-only'; diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 91f3b18c463..83b039845a9 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2718,6 +2718,40 @@ describe('useQuery Hook', () => { expect(result.current.loading).toBeFalsy(); expect(result.current.data).toEqual({ hello: 'world' }); }); + + it('should not refetch when skip is true', async () => { + const query = gql`{ hello }`; + const link = ApolloLink.empty(); + const requestSpy = jest.spyOn(link, 'request'); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + }); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query, { skip: true }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + await expect(waitForNextUpdate({ timeout: 20 })).rejects.toThrow('Timed out'); + + result.current.refetch(); + + await expect(waitForNextUpdate({ timeout: 20 })).rejects.toThrow('Timed out'); + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + expect(requestSpy).toHaveBeenCalledTimes(0); + requestSpy.mockRestore(); + }); }); describe('Missing Fields', () => { From 977d83d1c06af941401ae08a2e74c4cf133acd52 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Fri, 13 Aug 2021 15:29:04 -0400 Subject: [PATCH 53/65] fix useLazyQuery not refetching with the correct variables when execute is called multiple times Fixes #7396 --- .../hooks/__tests__/useLazyQuery.test.tsx | 81 ++++++++++++++++++- src/react/hooks/useLazyQuery.ts | 20 ++--- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index 81f58cfb638..fb36a142ff5 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -212,10 +212,9 @@ describe('useLazyQuery Hook', () => { setTimeout(() => execute({ variables: { id: 2 } })); await waitForNextUpdate(); - expect(result.current[1].loading).toBe(true); - await waitForNextUpdate(); + await waitForNextUpdate(); expect(result.current[1].loading).toBe(false); expect(result.current[1].data).toEqual({ hello: 'world 2' }); }); @@ -381,4 +380,82 @@ describe('useLazyQuery Hook', () => { result.current[1].stopPolling(); await expect(waitForNextUpdate({ timeout: 20 })).rejects.toThrow('Timed out'); }); + + it('should persist previous data when a query is re-run and variable changes', async () => { + const CAR_QUERY_BY_ID = gql` + query($id: Int) { + car(id: $id) { + make + model + } + } + `; + + const data1 = { + car: { + make: 'Audi', + model: 'A4', + __typename: 'Car', + }, + }; + + const data2 = { + car: { + make: 'Audi', + model: 'RS8', + __typename: 'Car', + }, + }; + + const mocks = [ + { + request: { query: CAR_QUERY_BY_ID, variables: { id: 1 } }, + result: { data: data1 }, + delay: 20, + }, + { + request: { query: CAR_QUERY_BY_ID, variables: { id: 2 } }, + result: { data: data2 }, + delay: 20, + }, + ]; + + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(CAR_QUERY_BY_ID), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toBe(undefined); + expect(result.current[1].previousData).toBe(undefined); + const execute = result.current[0]; + setTimeout(() => execute({ variables: { id: 1 }})); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(true); + expect(result.current[1].data).toBe(undefined); + expect(result.current[1].previousData).toBe(undefined); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual(data1); + expect(result.current[1].previousData).toBe(undefined); + + setTimeout(() => execute({ variables: { id: 2 }})); + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(true); + expect(result.current[1].data).toBe(undefined); + expect(result.current[1].previousData).toEqual(data1); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual(data2); + expect(result.current[1].previousData).toEqual(data1); + }); }); diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts index 1762d11694b..524c1214e1e 100644 --- a/src/react/hooks/useLazyQuery.ts +++ b/src/react/hooks/useLazyQuery.ts @@ -26,7 +26,7 @@ export function useLazyQuery( >((lazyOptions?: QueryLazyOptions) => { setExecution((execution) => { if (execution.called) { - result && result.refetch(lazyOptions); + result && result.refetch(lazyOptions?.variables); } return { called: true, lazyOptions }; @@ -43,6 +43,15 @@ export function useLazyQuery( }); if (!execution.called) { + result = { + ...result, + loading: false, + data: void 0 as unknown as TData, + error: void 0, + // TODO: fix the type of result + called: false as any, + }; + for (const key in result) { if (typeof (result as any)[key] === 'function') { const method = (result as any)[key]; @@ -52,15 +61,6 @@ export function useLazyQuery( }; } } - - result = { - ...result, - loading: false, - data: void 0 as unknown as TData, - error: void 0, - // TODO: fix the type of result - called: false as any, - }; } // TODO: fix the type of result From 91fbb4fc94dfe0f33f84e443c2a3388742f603ed Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 16 Aug 2021 13:41:26 -0400 Subject: [PATCH 54/65] use the ref to reference callbacks in useSubscription --- src/react/hooks/useSubscription.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index bddc35c6c7b..3b84a64459d 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -95,7 +95,7 @@ export function useSubscription( }; setResult(result); - options?.onSubscriptionData?.({ + ref.current.options?.onSubscriptionData?.({ client, subscriptionData: result }); @@ -109,7 +109,7 @@ export function useSubscription( }); }, complete() { - options?.onSubscriptionComplete?.(); + ref.current.options?.onSubscriptionComplete?.(); }, }); From 69a08ae91fab5dec079e5083f2280ac35b58df52 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 16 Aug 2021 16:02:32 -0400 Subject: [PATCH 55/65] standardize naming across useMutation and useLazyQuery --- src/react/hooks/useLazyQuery.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts index 524c1214e1e..dab804492a9 100644 --- a/src/react/hooks/useLazyQuery.ts +++ b/src/react/hooks/useLazyQuery.ts @@ -16,26 +16,26 @@ export function useLazyQuery( options?: LazyQueryHookOptions ): QueryTuple { const [execution, setExecution] = useState< - { called: boolean, lazyOptions?: QueryLazyOptions } + { called: boolean, options?: QueryLazyOptions } >({ called: false, }); const execute = useCallback< QueryTuple[0] - >((lazyOptions?: QueryLazyOptions) => { + >((executeOptions?: QueryLazyOptions) => { setExecution((execution) => { if (execution.called) { - result && result.refetch(lazyOptions?.variables); + result && result.refetch(executeOptions?.variables); } - return { called: true, lazyOptions }; + return { called: true, options: executeOptions }; }); }, []); let result = useQuery(query, { ...options, - ...execution.lazyOptions, + ...execution.options, // We don’t set skip to execution.called, because we need useQuery to call // addQueryPromise, so that ssr calls waits for execute to be called. fetchPolicy: execution.called ? options?.fetchPolicy : 'standby', From 29048b3c3983a8e29054ebbeb5dab39d37adaab5 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 16 Aug 2021 16:27:53 -0400 Subject: [PATCH 56/65] clarify refetch fetchPolicy code --- src/core/ObservableQuery.ts | 6 +++--- src/react/hooks/__tests__/useQuery.test.tsx | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index cb4b57c6f06..ff8f6a62bf2 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -305,11 +305,11 @@ export class ObservableQuery< // (no-cache, network-only, or cache-and-network), override it with // network-only to force the refetch for this fetchQuery call. const { fetchPolicy } = this.options; - if (fetchPolicy === 'standby') { - // do nothing + if (fetchPolicy === 'standby' || fetchPolicy === 'cache-and-network') { + reobserveOptions.fetchPolicy = fetchPolicy; } else if (fetchPolicy === 'no-cache') { reobserveOptions.fetchPolicy = 'no-cache'; - } else if (fetchPolicy !== 'cache-and-network') { + } else { reobserveOptions.fetchPolicy = 'network-only'; } diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 83b039845a9..8c5f9f6a9f7 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2721,9 +2721,11 @@ describe('useQuery Hook', () => { it('should not refetch when skip is true', async () => { const query = gql`{ hello }`; - const link = ApolloLink.empty(); - const requestSpy = jest.spyOn(link, 'request'); + const link = new ApolloLink(() => Observable.of({ + data: { hello: 'world' }, + })); + const requestSpy = jest.spyOn(link, 'request'); const client = new ApolloClient({ cache: new InMemoryCache(), link, @@ -2742,11 +2744,11 @@ describe('useQuery Hook', () => { expect(result.current.loading).toBe(false); expect(result.current.data).toBe(undefined); - await expect(waitForNextUpdate({ timeout: 20 })).rejects.toThrow('Timed out'); - + await expect(waitForNextUpdate({ timeout: 20 })) + .rejects.toThrow('Timed out'); result.current.refetch(); - - await expect(waitForNextUpdate({ timeout: 20 })).rejects.toThrow('Timed out'); + await expect(waitForNextUpdate({ timeout: 20 })) + .rejects.toThrow('Timed out'); expect(result.current.loading).toBe(false); expect(result.current.data).toBe(undefined); expect(requestSpy).toHaveBeenCalledTimes(0); From d33132eeb4e99d555d3b0245899c751f209beb30 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 16 Aug 2021 16:39:07 -0400 Subject: [PATCH 57/65] explicitly specify which methods should execute a useLazyQuery method --- src/react/hooks/useLazyQuery.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts index dab804492a9..c6db275f014 100644 --- a/src/react/hooks/useLazyQuery.ts +++ b/src/react/hooks/useLazyQuery.ts @@ -11,6 +11,16 @@ import { import { useQuery } from './useQuery'; import { OperationVariables } from '../../core'; +// The following methods, when called will execute the query, regardless of +// whether the useLazyQuery execute function was called before. +const EAGER_METHODS = [ + 'refetch', + 'fetchMore', + 'updateQuery', + 'startPolling', + 'subscribeToMore', +] as const; + export function useLazyQuery( query: DocumentNode | TypedDocumentNode, options?: LazyQueryHookOptions @@ -52,14 +62,13 @@ export function useLazyQuery( called: false as any, }; - for (const key in result) { - if (typeof (result as any)[key] === 'function') { - const method = (result as any)[key]; - (result as any)[key] = (...args: any) => { - setExecution({ called: true }); - return method(...args); - }; - } + + for (const key of EAGER_METHODS) { + const method = result[key]; + result[key] = (...args: any) => { + setExecution({ called: true }); + return (method as any)(...args); + }; } } From 7712bfa7485a7e07f0fd7a00716b9b2192bfb51e Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 16 Aug 2021 16:42:03 -0400 Subject: [PATCH 58/65] use itAsync --- .../components/__tests__/client/Mutation.test.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/react/components/__tests__/client/Mutation.test.tsx b/src/react/components/__tests__/client/Mutation.test.tsx index a1e44221531..e14a48c97dc 100644 --- a/src/react/components/__tests__/client/Mutation.test.tsx +++ b/src/react/components/__tests__/client/Mutation.test.tsx @@ -7,7 +7,12 @@ import { ApolloClient } from '../../../../core'; import { ApolloError } from '../../../../errors'; import { DataProxy, InMemoryCache as Cache } from '../../../../cache'; import { ApolloProvider } from '../../../context'; -import { MockedProvider, MockLink, mockSingleLink } from '../../../../testing'; +import { + itAsync, + MockedProvider, + MockLink, + mockSingleLink, +} from '../../../../testing'; import { Query } from '../../Query'; import { Mutation } from '../../Mutation'; @@ -156,7 +161,7 @@ describe('General Mutation testing', () => { expect(spy).toHaveBeenCalledWith(mocksProps[1].result); }); - it('performs a mutation', () => new Promise((resolve, reject) => { + itAsync('performs a mutation', (resolve, reject) => { let count = 0; const Component = () => ( @@ -190,7 +195,7 @@ describe('General Mutation testing', () => { ); wait().then(resolve, reject); - })); + }); it('can bind only the mutation and not rerender by props', done => { let count = 0; From f1a9770b2b6f0e30fb89424eea0306afd4f48216 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 16 Aug 2021 16:59:47 -0400 Subject: [PATCH 59/65] prioritize result.error calling onError over result.data calling onCompleted --- src/react/hooks/useQuery.ts | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 74896a81543..3eb8575a069 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -90,11 +90,11 @@ export function useQuery< let [result, setResult] = useState(() => { const result = obsQuery.getCurrentResult(); - if (!result.loading) { - if (result.data) { - options?.onCompleted?.(result.data); - } else if (result.error) { - options?.onError?.(result.error); + if (!result.loading && options) { + if (result.error) { + options.onError?.(result.error); + } else if (result.data) { + options.onCompleted?.(result.data); } } @@ -116,10 +116,7 @@ export function useQuery< useEffect(() => { const watchQueryOptions = createWatchQueryOptions(query, options); let nextResult: ApolloQueryResult | undefined; - if ( - ref.current.client !== client || - !equal(ref.current.query, query) - ) { + if (ref.current.client !== client || !equal(ref.current.query, query)) { const obsQuery = client.watchQuery(watchQueryOptions); setObsQuery(obsQuery); nextResult = obsQuery.getCurrentResult(); @@ -136,11 +133,13 @@ export function useQuery< } setResult(ref.current.result = nextResult); - if (!nextResult.loading) { - if (nextResult.data) { - options?.onCompleted?.(nextResult.data); - } else if (nextResult.error) { - options?.onError?.(nextResult.error); + if (!nextResult.loading && options) { + if (!result.loading) { + if (result.error) { + options.onError?.(result.error); + } else if (result.data) { + options.onCompleted?.(result.data); + } } } } From 1933ff1c610a54c60845ce4bf251677a14e7e336 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 16 Aug 2021 17:54:50 -0400 Subject: [PATCH 60/65] add some delay to possibly fix some flake in a useLazyQuery test --- src/react/hooks/__tests__/useLazyQuery.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index fb36a142ff5..aca367171ab 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -333,6 +333,7 @@ describe('useLazyQuery Hook', () => { { request: { query }, result: { data: { hello: "world 1" } }, + delay: 10, }, { request: { query }, @@ -362,7 +363,6 @@ describe('useLazyQuery Hook', () => { await waitForNextUpdate(); expect(result.current[1].loading).toBe(true); - expect(result.current[1].data).toBe(undefined); await waitForNextUpdate(); expect(result.current[1].loading).toBe(false); From c6d41e2b55bb370042d11e7f7a40d086e10d0844 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 18 Aug 2021 12:30:19 -0400 Subject: [PATCH 61/65] rename data to previousData in useQuery --- src/react/hooks/useQuery.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 3eb8575a069..bb2b1691a52 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -106,7 +106,7 @@ export function useQuery< query, options, result, - data: void 0 as TData | undefined, + previousData: void 0 as TData | undefined, watchQueryOptions: createWatchQueryOptions(query, options), }); @@ -129,7 +129,7 @@ export function useQuery< if (nextResult) { const previousResult = ref.current.result; if (previousResult.data) { - ref.current.data = previousResult.data; + ref.current.previousData = previousResult.data; } setResult(ref.current.result = nextResult); @@ -171,7 +171,7 @@ export function useQuery< } if (previousResult.data) { - ref.current.data = previousResult.data; + ref.current.previousData = previousResult.data; } setResult(ref.current.result = result); @@ -313,7 +313,7 @@ export function useQuery< variables: obsQuery.variables, client, called: true, - previousData: ref.current.data, + previousData: ref.current.previousData, ...result, }; } From 1834f5f72d6f195f3330c91d59e840ddc3e588ab Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 18 Aug 2021 12:26:12 -0400 Subject: [PATCH 62/65] add a busted cache-and-network test --- .../hooks/__tests__/useLazyQuery.test.tsx | 50 ++++++++++++++++++- src/react/hooks/__tests__/useQuery.test.tsx | 39 +++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index aca367171ab..3ced64c9cbd 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -2,7 +2,9 @@ import React from 'react'; import gql from 'graphql-tag'; import { renderHook } from '@testing-library/react-hooks'; -import { MockedProvider } from '../../../testing'; +import { ApolloClient, InMemoryCache } from '../../../core'; +import { ApolloProvider } from '../../../react'; +import { MockedProvider, mockSingleLink } from '../../../testing'; import { useLazyQuery } from '../useLazyQuery'; describe('useLazyQuery Hook', () => { @@ -458,4 +460,50 @@ describe('useLazyQuery Hook', () => { expect(result.current[1].data).toEqual(data2); expect(result.current[1].previousData).toEqual(data1); }); + + it('should work with cache-and-network fetch policy', async () => { + const query = gql`{ hello }`; + + const cache = new InMemoryCache(); + const link = mockSingleLink( + { + request: { query }, + result: { data: { hello: 'from link' } }, + delay: 20, + }, + ); + + const client = new ApolloClient({ + link, + cache, + }); + + cache.writeQuery({ query, data: { hello: 'from cache' }}); + + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query, { fetchPolicy: 'cache-and-network' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toBe(undefined); + const execute = result.current[0]; + setTimeout(() => execute()); + + await waitForNextUpdate(); + + // TODO: FIXME + expect(result.current[1].loading).toBe(true); + expect(result.current[1].data).toEqual({ hello: 'from cache' }); + + await waitForNextUpdate(); + expect(result.current[1].loading).toBe(false); + expect(result.current[1].data).toEqual({ hello: 'from link' }); + }); }); diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 8c5f9f6a9f7..7b7fabd1b09 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -522,6 +522,45 @@ describe('useQuery Hook', () => { expect(result.current.loading).toBe(false); expect(result.current.data).toEqual(mocks[1].result.data); }); + + it('`cache-and-network` fetch policy', async () => { + const query = gql`{ hello }`; + + const cache = new InMemoryCache(); + const link = mockSingleLink( + { + request: { query }, + result: { data: { hello: 'from link' } }, + delay: 20, + }, + ); + + const client = new ApolloClient({ + link, + cache, + }); + + cache.writeQuery({ query, data: { hello: 'from cache' }}); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query, { fetchPolicy: 'cache-and-network' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + // TODO: FIXME + expect(result.current.loading).toBe(true); + expect(result.current.data).toEqual({ hello: 'from cache' }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ hello: 'from link' }); + }); }); describe('polling', () => { From 73569ef8165d3321e854c3ac59dc6b5deacd4ded Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 18 Aug 2021 14:19:07 -0400 Subject: [PATCH 63/65] add a test for #8497 --- src/react/hooks/__tests__/useQuery.test.tsx | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 7b7fabd1b09..83329de2255 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -1030,6 +1030,87 @@ describe('useQuery Hook', () => { await expect(waitForNextUpdate({ timeout: 20 })).rejects.toThrow('Timed out'); }); + it('should not persist errors when variables change', async () => { + const query = gql` + query hello($id: ID) { + hello(id: $id) + } + `; + + const mocks = [ + { + request: { + query, + variables: { id: 1 }, + }, + result: { + errors: [new GraphQLError('error')] + }, + }, + { + request: { + query, + variables: { id: 2 }, + }, + result: { + data: { hello: 'world 2' }, + }, + }, + { + request: { + query, + variables: { id: 1 }, + }, + result: { + data: { hello: 'world 1' }, + }, + }, + ]; + + const { result, rerender, waitForNextUpdate } = renderHook( + ({ id }) => useQuery(query, { variables: { id } }), + { + wrapper: ({ children }) => ( + + {children} + + ), + initialProps: { id: 1 }, + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); + + await waitForNextUpdate(); + + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toBeInstanceOf(ApolloError); + expect(result.current.error!.message).toBe('error'); + + rerender({ id: 2 }); + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ hello: 'world 2' }); + expect(result.current.error).toBe(undefined); + + rerender({ id: 1 }); + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toBe(undefined); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ hello: 'world 1' }); + expect(result.current.error).toBe(undefined); + }); + it('should render multiple errors when refetching', async () => { const query = gql`{ hello }`; const mocks = [ From 7b95b2468594db8cffec4db4f96f60131bffcc7a Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 18 Aug 2021 18:12:31 -0400 Subject: [PATCH 64/65] tweak useMutation --- .../__tests__/mutations/lifecycle.test.tsx | 1 + src/react/hooks/useMutation.ts | 41 ++++++++++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/react/hoc/__tests__/mutations/lifecycle.test.tsx b/src/react/hoc/__tests__/mutations/lifecycle.test.tsx index 13c188c9d0f..4834da4324b 100644 --- a/src/react/hoc/__tests__/mutations/lifecycle.test.tsx +++ b/src/react/hoc/__tests__/mutations/lifecycle.test.tsx @@ -77,6 +77,7 @@ describe('graphql(mutation) lifecycle', () => { interface Props { listId: number; } + function options(props: Props) { return { variables: { diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index ade44580ebf..ebff8f5230f 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -50,7 +50,9 @@ export function useMutation< TCache > = {}, ) => { - if (!ref.current.result.loading && !options?.ignoreResults) { + + const baseOptions = { ...options, mutation }; + if (!ref.current.result.loading && !baseOptions.ignoreResults) { setResult(ref.current.result = { loading: true, error: void 0, @@ -62,17 +64,21 @@ export function useMutation< const mutationId = ++ref.current.mutationId; const clientOptions = mergeOptions( - { ...options, mutation }, + baseOptions, executeOptions as any, ); - return client.mutate(clientOptions).then((response) => { + + return client.mutate(clientOptions).then((response) =>{ const { data, errors } = response; const error = errors && errors.length > 0 ? new ApolloError({ graphQLErrors: errors }) : void 0; - if (mutationId === ref.current.mutationId && !options?.ignoreResults) { + if ( + mutationId === ref.current.mutationId && + !baseOptions.ignoreResults + ) { const result = { called: true, loading: false, @@ -81,19 +87,18 @@ export function useMutation< client, }; - if ( - ref.current.isMounted && - !equal(ref.current.result, result) - ) { - ref.current.result = result; - setResult(result); + if (ref.current.isMounted && !equal(ref.current.result, result)) { + setResult(ref.current.result = result); } } - options?.onCompleted?.(data!); + baseOptions.onCompleted?.(response.data!); return response; }).catch((error) => { - if (mutationId === ref.current.mutationId) { + if ( + mutationId === ref.current.mutationId && + ref.current.isMounted + ) { const result = { loading: false, error, @@ -102,17 +107,13 @@ export function useMutation< client, }; - if ( - ref.current.isMounted && - !equal(ref.current.result, result) - ) { - ref.current.result = result; - setResult(result); + if (!equal(ref.current.result, result)) { + setResult(ref.current.result = result); } } - if (options?.onError) { - options.onError(error); + if (baseOptions.onError) { + baseOptions.onError(error); // TODO(brian): why are we returning this here??? return { data: void 0, errors: error }; } From f3e1f35d0106590e9a328a6eec369c97ac8a3802 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 18 Aug 2021 18:29:31 -0400 Subject: [PATCH 65/65] update CHANGELOG.md --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95dbdd144c4..c82df26d60d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## Apollo Client 3.5 (unreleased) + +### Bug Fixes +- `useQuery` and `useLazyQuery` will now have observableQuery methods defined consistently.
[@brainkim](https://github.com/brainkim) in [#8596](https://github.com/apollographql/apollo-client/pull/8596) + +- Calling `useLazyQuery` methods like `startPolling` will start the query
[@brainkim](https://github.com/brainkim) in [#8596](https://github.com/apollographql/apollo-client/pull/8596) + +- Calling the `useLazyQuery` execution function will now behave more like `refetch`. `previousData` will be preserved.
[@brainkim](https://github.com/brainkim) in [#8596](https://github.com/apollographql/apollo-client/pull/8596) + +- `standby` fetchPolicies will now act like `skip: true` more consistently
[@brainkim](https://github.com/brainkim) in [#8596](https://github.com/apollographql/apollo-client/pull/8596) + +- Calling `refetch` on a skipped query will have no effect (https://github.com/apollographql/apollo-client/issues/8270).
[@brainkim](https://github.com/brainkim) in [#8596](https://github.com/apollographql/apollo-client/pull/8596) + +- Improvements to `onError` and `onCompleted` functions, preventing them from firing continuously, and working with polling
[@brainkim](https://github.com/brainkim) in [#8596](https://github.com/apollographql/apollo-client/pull/8596) + ## Apollo Client 3.4.8 ### Bug Fixes