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 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/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", diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 3835a31be88..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", @@ -291,6 +282,7 @@ Array [ "DocumentType", "operationName", "parser", + "verifyDocumentType", ] `; 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/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 6038a87755f..ff8f6a62bf2 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -305,9 +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 === 'no-cache') { + 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/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/components/__tests__/client/Mutation.test.tsx b/src/react/components/__tests__/client/Mutation.test.tsx index 272e1b5ed48..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,24 +161,28 @@ describe('General Mutation testing', () => { expect(spy).toHaveBeenCalledWith(mocksProps[1].result); }); - it('performs a mutation', async () => { + itAsync('performs a mutation', (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,7 +194,7 @@ describe('General Mutation testing', () => { ); - await wait(); + wait().then(resolve, reject); }); it('can bind only the mutation and not rerender by props', done => { @@ -922,7 +931,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 +987,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 +1037,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 +1089,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 +1118,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..5b6c2d9d6b6 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); @@ -1740,22 +1785,40 @@ 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(); }); - withErrorSpy(itAsync, + // 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 ' + '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 + } + } + } + `; + + let done = false; + const allPeopleData = { + allPeople: { people: [{ name: 'Luke Skywalker' }] }, + }; const query = allPeopleQuery; const link = mockSingleLink( { request: { query }, result: { data: {} } }, @@ -1764,15 +1827,22 @@ 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); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toMatch('Missing field'); + done = true; + } + } catch (err) { + reject(err); } return null; }} @@ -1785,7 +1855,7 @@ describe('Query component', () => { ); - return wait().then(resolve, reject); + wait(() => done).then(resolve, reject); } ); 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/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/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/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/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/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/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; 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__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index 968070c2b9e..3ced64c9cbd 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -1,477 +1,509 @@ 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 { ApolloClient, InMemoryCache } from '../../../core'; +import { ApolloProvider } from '../../../react'; +import { MockedProvider, mockSingleLink } from '../../../testing'; import { useLazyQuery } from '../useLazyQuery'; describe('useLazyQuery Hook', () => { - const CAR_QUERY: DocumentNode = gql` - query { - cars { - make - model - vin - } - } - `; + it('should hold query execution until manually triggered', async () => { + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: 'world' } }, + delay: 20, + }, + ]; + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query), + { + 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(); + expect(result.current[1].loading).toBe(true); - const CAR_RESULT_DATA = { - cars: [ + 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', async () => { + const query = gql`{ hello }`; + const mocks = [ { - make: 'Audi', - model: 'RS8', - vin: 'DOLLADOLLABILL', - __typename: 'Car' + 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 () => { + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: 'world' } }, + delay: 20, + }, + ]; + + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + 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 () => { + 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} + + ), + }, + ); + + 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 query = gql` + query($id: number) { + hello(id: $id) } - ] - }; + `; - const CAR_MOCKS = [ - { - request: { - query: CAR_QUERY + const mocks = [ + { + request: { query, variables: { id: 1 } }, + result: { data: { hello: 'world 1' } }, + delay: 20, }, - 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 + 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) } - renderCount += 1; - return null; - }; + `; - render( - - - + const mocks = [ + { + request: { query, variables: { id: 1 } }, + result: { data: { hello: 'world 1' } }, + delay: 20, + }, + { + request: { query, variables: { id: 2 } }, + result: { data: { hello: 'world 2' } }, + delay: 20, + }, + ]; + + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query, { + variables: { id: 1 }, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); - return wait(() => { - expect(renderCount).toBe(3); - }); + 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 set `called` to false by default', () => { - const Component = () => { - const [, { loading, called }] = useLazyQuery(CAR_QUERY); - expect(loading).toBeFalsy(); - expect(called).toBeFalsy(); - return null; - }; + 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' } }, + delay: 20, + }, + { + request: { query }, + result: { data: { hello: 'world 2' } }, + delay: 20, + }, + ]; - render( - - - + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query, { + fetchPolicy: 'network-only', + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); + + expect(result.current[1].loading).toBe(false); + 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' }); + + setTimeout(() => execute()); + + 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' }); }); - 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; - }; + 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' } }, + delay: 20, + }, + { + request: { query }, + result: { data: { hello: 'world 2' } }, + delay: 20, + }, + ]; - render( - - - + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query, { + notifyOnNetworkStatusChange: true, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); - return wait(() => { - expect(renderCount).toBe(3); - }); + 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' }); }); - 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; - }; + it('should allow for the query to start with polling', async () => { + const query = gql`{ hello }`; + const mocks = [ + { + request: { query }, + result: { data: { hello: "world 1" } }, + delay: 10, + }, + { + request: { query }, + result: { data: { hello: "world 2" } }, + }, + { + request: { query }, + result: { data: { hello: "world 3" } }, + }, + ]; + + const wrapper = ({ children }: any) => ( + {children} + ); - render( - - - + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query), + { wrapper }, ); - return wait(() => { - expect(renderCount).toBe(3); + 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); + + 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'); }); - 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) => { - const query = gql` - query car { - car { - id + 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: { - id: 1, - make: 'Venturi', + make: 'Audi', + model: 'A4', __typename: 'Car', - } + }, }; const data2 = { car: { - id: 2, - make: 'Wiesmann', + make: 'Audi', + model: 'RS8', __typename: 'Car', - } + }, }; const mocks = [ - { request: { query }, result: { data: data1 } }, - { request: { query }, result: { data: data2 } } + { + 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, + }, ]; - 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(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); + }); + + 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, + }); - return null; - } + cache.writeQuery({ query, data: { hello: 'from cache' }}); - render( - - - + const { result, waitForNextUpdate } = renderHook( + () => useLazyQuery(query, { fetchPolicy: 'cache-and-network' }), + { + 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); + 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__/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 }); diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 967f4ab74c9..83329de2255 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; @@ -523,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', () => { @@ -593,13 +631,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 }, + { + wrapper: ({ children }) => ( + + {children} + + ), + initialProps: { skip: undefined } as any + }, ); expect(result.current.loading).toBe(true); @@ -989,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 = [ @@ -1187,6 +1309,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', () => { @@ -1250,12 +1432,12 @@ 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), }), - }); + })); await waitForNextUpdate(); expect(result.current.loading).toBe(false); @@ -1291,14 +1473,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 }); @@ -1379,9 +1560,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 }); @@ -1539,6 +1718,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) { @@ -1963,6 +2207,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', () => { @@ -2020,7 +2315,7 @@ describe('useQuery Hook', () => { { request: { query: mutation }, error: new Error('Oh no!'), - delay: 10, + delay: 500, } ]; @@ -2076,13 +2371,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; @@ -2093,15 +2390,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); @@ -2113,15 +2401,71 @@ 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) { + 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 }, + result: { data: {} }, + delay: 20, + }, + { + request: { query }, + result: { data: { hello: "world" } }, + 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.error).toBe(undefined); + expect(result.current.networkStatus).toBe(NetworkStatus.loading); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(true); + 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); + }); + + 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 } @@ -2129,41 +2473,80 @@ describe('useQuery Hook', () => { } `; - interface Data { - allPeople: { - people: Array<{ name: string }>; - }; - } - - const peopleData: Data = { - allPeople: { people: [{ name: 'Luke Skywalker' }] } + const data = { + allPeople: { people: [{ name: 'Luke Skywalker' }] }, }; + const errorSpy = jest.spyOn(console, 'error') + .mockImplementation(() => {}); const link = mockSingleLink( + { request: { query }, result: { data: {} }, delay: 20 }, + { request: { query }, result: { data }, delay: 20 } + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query, { + partialRefetch: true, + notifyOnNetworkStatusChange: true, + }), { - request: { - query, - variables: { - someVar: 'abc123' - } - }, - result: { data: undefined }, + wrapper: ({ children }) => ( + + {children} + + ), }, + ); + + 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); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(true); + 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(data); + expect(result.current.error).toBe(undefined); + 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(() => {}); + const query = gql`{ hello }`; + + const link = mockSingleLink( { - request: { - query, - variables: { - someVar: 'abc123' - } - }, - result: { data: peopleData }, - delay: 10, + request: { query }, + result: { data: {} }, + delay: 20, + }, + { + request: { query }, + result: { data: { hello: "world" } }, + delay: 20, } ); const client = new ApolloClient({ link, - cache: new InMemoryCache(), + // THIS LINE IS THE ONLY DIFFERENCE FOR THIS TEST + cache: new InMemoryCache({ addTypename: false }), }); const wrapper = ({ children }: any) => ( @@ -2174,43 +2557,33 @@ describe('useQuery Hook', () => { const { result, waitForNextUpdate } = renderHook( () => useQuery(query, { - variables: { someVar: 'abc123' }, partialRefetch: true, notifyOnNetworkStatusChange: true, }), { wrapper }, ); - // Initial loading render 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); await waitForNextUpdate(); - 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); - 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(); - // 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.error).toBe(undefined); expect(result.current.networkStatus).toBe(NetworkStatus.ready); - - errorSpy.mockRestore(); }); }); @@ -2428,9 +2801,78 @@ 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); + }); + + 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' }); + }); + + it('should not refetch when skip is true', async () => { + const query = gql`{ hello }`; + const link = new ApolloLink(() => Observable.of({ + data: { hello: 'world' }, + })); + + 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(); }); }); @@ -3023,11 +3465,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(() => { @@ -3036,7 +3479,6 @@ describe('useQuery Hook', () => { }, 10); break; } - }); })), }); } 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(); }); }); 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/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts index 8032639ad98..c6db275f014 100644 --- a/src/react/hooks/useLazyQuery.ts +++ b/src/react/hooks/useLazyQuery.ts @@ -1,16 +1,77 @@ 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'; +// 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 -) { - return useBaseQuery(query, options, true) as QueryTuple< - TData, - TVariables - >; +): QueryTuple { + const [execution, setExecution] = useState< + { called: boolean, options?: QueryLazyOptions } + >({ + called: false, + }); + + const execute = useCallback< + QueryTuple[0] + >((executeOptions?: QueryLazyOptions) => { + setExecution((execution) => { + if (execution.called) { + result && result.refetch(executeOptions?.variables); + } + + return { called: true, options: executeOptions }; + }); + }, []); + + let result = useQuery(query, { + ...options, + ...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', + 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, + }; + + + for (const key of EAGER_METHODS) { + const method = result[key]; + result[key] = (...args: any) => { + setExecution({ called: true }); + return (method as any)(...args); + }; + } + } + + // TODO: fix the type of result + return [execute, result as LazyQueryResult]; } diff --git a/src/react/hooks/useMutation.ts b/src/react/hooks/useMutation.ts index 8119a01c4a8..ebff8f5230f 100644 --- a/src/react/hooks/useMutation.ts +++ b/src/react/hooks/useMutation.ts @@ -1,11 +1,23 @@ -import { useContext, useState, useRef, useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { + MutationFunctionOptions, + MutationHookOptions, + MutationResult, + MutationTuple, +} from '../types/types'; -import { MutationHookOptions, MutationTuple } from '../types/types'; -import { MutationData } from '../data'; -import { ApolloCache, DefaultContext, OperationVariables } from '../../core'; -import { getApolloContext } from '../context'; +import { + ApolloCache, + DefaultContext, + mergeOptions, + OperationVariables, +} from '../../core'; +import { equal } from '@wry/equality'; +import { DocumentType, verifyDocumentType } from '../parser'; +import { ApolloError } from '../../errors'; +import { useApolloClient } from './useApolloClient'; export function useMutation< TData = any, @@ -14,30 +26,105 @@ export function useMutation< TCache extends ApolloCache = ApolloCache, >( mutation: DocumentNode | TypedDocumentNode, - options?: MutationHookOptions + options?: MutationHookOptions, ): MutationTuple { - const context = useContext(getApolloContext()); - const [result, setResult] = useState({ called: false, loading: false }); - const updatedOptions = options ? { ...options, mutation } : { mutation }; - - const mutationDataRef = useRef>(); - function getMutationDataRef() { - if (!mutationDataRef.current) { - mutationDataRef.current = new MutationData({ - options: updatedOptions, - context, - result, - setResult + const client = useApolloClient(options?.client); + verifyDocumentType(mutation, DocumentType.Mutation); + const [result, setResult] = useState({ + called: false, + loading: false, + client, + }); + + const ref = useRef({ + result, + mutationId: 0, + isMounted: true, + }); + + const execute = useCallback(( + executeOptions: MutationFunctionOptions< + TData, + TVariables, + TContext, + TCache + > = {}, + ) => { + + const baseOptions = { ...options, mutation }; + if (!ref.current.result.loading && !baseOptions.ignoreResults) { + setResult(ref.current.result = { + loading: true, + error: void 0, + data: void 0, + called: true, + client, }); } - return mutationDataRef.current; - } - const mutationData = getMutationDataRef(); - mutationData.setOptions(updatedOptions); - mutationData.context = context; + const mutationId = ++ref.current.mutationId; + const clientOptions = mergeOptions( + baseOptions, + executeOptions as any, + ); + + 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 && + !baseOptions.ignoreResults + ) { + const result = { + called: true, + loading: false, + data, + error, + client, + }; + + if (ref.current.isMounted && !equal(ref.current.result, result)) { + setResult(ref.current.result = result); + } + } + + baseOptions.onCompleted?.(response.data!); + return response; + }).catch((error) => { + if ( + mutationId === ref.current.mutationId && + ref.current.isMounted + ) { + const result = { + loading: false, + error, + data: void 0, + called: true, + client, + }; + + if (!equal(ref.current.result, result)) { + setResult(ref.current.result = result); + } + } + + if (baseOptions.onError) { + baseOptions.onError(error); + // TODO(brian): why are we returning this here??? + return { data: void 0, errors: error }; + } + + throw error; + }); + }, [client, options, mutation]); - useEffect(() => mutationData.afterExecute()); + useEffect(() => () => { + ref.current.isMounted = false; + }, []); - return mutationData.execute(result); + return [execute, result]; } diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index bf75ad0e323..bb2b1691a52 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -1,16 +1,356 @@ -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, useMemo, useRef, useState } from 'react'; +import { equal } from '@wry/equality'; import { OperationVariables } from '../../core'; +import { getApolloContext } from '../context'; +import { ApolloError } from '../../errors'; +import { + ApolloQueryResult, + NetworkStatus, + ObservableQuery, + DocumentNode, + TypedDocumentNode, + WatchQueryOptions, +} from '../../core'; +import { + QueryHookOptions, + QueryResult, +} from '../types/types'; + +import { DocumentType, verifyDocumentType } from '../parser'; +import { useApolloClient } from './useApolloClient'; -export function useQuery( +export function useQuery< + TData = any, + TVariables = OperationVariables, +>( query: DocumentNode | TypedDocumentNode, - options?: QueryHookOptions -) { - return useBaseQuery(query, options, false) as QueryResult< - TData, - TVariables - >; + options?: QueryHookOptions, +): QueryResult { + const context = useContext(getApolloContext()); + const client = useApolloClient(options?.client); + verifyDocumentType(query, DocumentType.Query); + 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(watchQueryOptions); + } + + if (!obsQuery) { + // Is it safe (StrictMode/memory-wise) to call client.watchQuery here? + obsQuery = client.watchQuery(watchQueryOptions); + if (context.renderPromises) { + context.renderPromises.registerSSRObservable( + obsQuery, + watchQueryOptions, + ); + } + } + + if ( + context.renderPromises && + options?.ssr !== false && + !options?.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 + // RenderPromises class are query and variables. + getOptions: () => createWatchQueryOptions(query, options), + fetchData: () => new Promise((resolve) => { + const sub = obsQuery!.subscribe({ + next(result) { + if (!result.loading) { + resolve() + sub.unsubscribe(); + } + }, + error() { + resolve(); + sub.unsubscribe(); + }, + complete() { + resolve(); + }, + }); + }), + }, + // This callback never seemed to do anything + () => null, + ); + } + + return obsQuery; + }); + + let [result, setResult] = useState(() => { + const result = obsQuery.getCurrentResult(); + if (!result.loading && options) { + if (result.error) { + options.onError?.(result.error); + } else if (result.data) { + options.onCompleted?.(result.data); + } + } + + return result; + }); + + const ref = useRef({ + client, + query, + options, + result, + previousData: 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(watchQueryOptions); + setObsQuery(obsQuery); + nextResult = obsQuery.getCurrentResult(); + } else if (!equal(ref.current.watchQueryOptions, watchQueryOptions)) { + obsQuery.setOptions(watchQueryOptions).catch(() => {}); + nextResult = obsQuery.getCurrentResult(); + ref.current.watchQueryOptions = watchQueryOptions; + } + + if (nextResult) { + const previousResult = ref.current.result; + if (previousResult.data) { + ref.current.previousData = previousResult.data; + } + + setResult(ref.current.result = nextResult); + if (!nextResult.loading && options) { + if (!result.loading) { + if (result.error) { + options.onError?.(result.error); + } else if (result.data) { + options.onCompleted?.(result.data); + } + } + } + } + + Object.assign(ref.current, { client, query, options }); + }, [obsQuery, client, query, options]); + + // An effect to subscribe to the current observable query + useEffect(() => { + if (context.renderPromises || client.disableNetworkFetches) { + 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 = ref.current.result; + 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) { + ref.current.previousData = previousResult.data; + } + + setResult(ref.current.result = result); + if (!result.loading) { + ref.current.options?.onCompleted?.(result.data); + } + } + + function onError(error: Error) { + const last = obsQuery["last"]; + 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 + // 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(); + subscription = obsQuery.subscribe(onNext, onError); + } finally { + obsQuery["last"] = last; + } + + if (!error.hasOwnProperty('graphQLErrors')) { + // The error is not a GraphQL error + throw error; + } + + const previousResult = ref.current.result; + if ( + (previousResult && previousResult.loading) || + !equal(error, previousResult.error) + ) { + setResult(ref.current.result = { + data: previousResult.data, + error: error as ApolloError, + loading: false, + networkStatus: NetworkStatus.error, + }); + ref.current.options?.onError?.(error as ApolloError); + } + } + + return () => subscription.unsubscribe(); + }, [obsQuery, context.renderPromises, client.disableNetworkFetches]); + + 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 in certain + // edge cases when this block was put in an effect. + if ( + partial && + options?.partialRefetch && + !result.loading && + (!result.data || Object.keys(result.data).length === 0) && + obsQuery.options.fetchPolicy !== 'cache-only' + ) { + result = { + ...result, + loading: true, + networkStatus: NetworkStatus.refetch, + }; + + obsQuery.refetch(); + } + + // TODO: This is a hack to make sure useLazyQuery executions update the + // obsevable query options for ssr. + if ( + context.renderPromises && + options?.ssr !== false && + !options?.skip && + result.loading + ) { + obsQuery.setOptions(createWatchQueryOptions(query, options)).catch(() => {}); + } + } + + if ( + (context.renderPromises || client.disableNetworkFetches) && + 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 = ref.current.result = { + loading: true, + data: void 0 as unknown as TData, + error: void 0, + networkStatus: NetworkStatus.loading, + }; + } 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). + // + // 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 = { + loading: false, + data: void 0 as unknown as TData, + error: void 0, + networkStatus: NetworkStatus.ready, + }; + } + + 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 }), + }; + } + + 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: ref.current.previousData, + ...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 }; } diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index d0febf7d904..3b84a64459d 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -1,56 +1,122 @@ -import { useContext, useState, useRef, useEffect, useReducer } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { equal } from '@wry/equality'; -import { SubscriptionHookOptions } from '../types/types'; -import { SubscriptionData } from '../data'; +import { DocumentType, verifyDocumentType } from '../parser'; +import { + SubscriptionHookOptions, + SubscriptionResult +} from '../types/types'; import { OperationVariables } from '../../core'; -import { getApolloContext } from '../context'; -import { useAfterFastRefresh } from './utils/useAfterFastRefresh'; +import { useApolloClient } from './useApolloClient'; 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 = useApolloClient(options?.client); + 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 subscriptionDataRef.current; - } - const subscriptionData = getSubscriptionDataRef(); - subscriptionData.setOptions(updatedOptions, true); - subscriptionData.context = context; + return 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); - } + const ref = useRef({ client, subscription, options }); + useEffect(() => { + let shouldResubscribe = options?.shouldResubscribe; + if (typeof shouldResubscribe === 'function') { + shouldResubscribe = !!shouldResubscribe(options!); + } + + 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, + })); + } + + 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, + // TODO: fetchResult.data can be null but SubscriptionResult.data + // expects TData | undefined only + data: fetchResult.data!, + error: void 0, + variables: options?.variables, + }; + setResult(result); + + ref.current.options?.onSubscriptionData?.({ + client, + subscriptionData: result + }); + }, + error(error) { + setResult({ + loading: false, + data: void 0, + error, + variables: options?.variables, + }); + }, + complete() { + ref.current.options?.onSubscriptionComplete?.(); + }, + }); + return () => { - subscriptionData.cleanup(); - subscriptionDataRef.current = void 0; + subscription.unsubscribe(); }; - }, []); + }, [observable]); - return subscriptionData.execute(result); + return result; } 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/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.` + ); +} + diff --git a/src/react/ssr/RenderPromises.ts b/src/react/ssr/RenderPromises.ts index b2171ac9e1e..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; @@ -47,12 +53,12 @@ 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; } - public addQueryPromise( - queryInstance: QueryData, + public addQueryPromise( + queryInstance: QueryData, finish: () => React.ReactNode ): React.ReactNode { if (!this.stopped) { diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 3f957a9bdac..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, @@ -234,10 +213,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<