From a0665ee8167084bb4a16d9ff471ad9e64ee1bf2b Mon Sep 17 00:00:00 2001 From: Alessia Bellisario Date: Thu, 27 Oct 2022 14:35:13 -0400 Subject: [PATCH 001/159] fix(regression): avoid calling `useQuery` `onCompleted` for cache writes (#10229) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fad22ff6aa..5ad783aaf99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Apollo Client 3.8.0 + +### Bug fixes + +- Avoid calling `useQuery` `onCompleted` callback after cache writes, only after the originating query's network request(s) complete.
+ [@alessbell](https://github.com/alessbell) in [#10229](https://github.com/apollographql/apollo-client/pull/10229) + ## Apollo Client 3.7.2 (2022-12-06) ### Improvements From 9d77dd224a315a59757e355b113325aed1407aa1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 3 Nov 2022 23:29:55 -0600 Subject: [PATCH 002/159] Write the first failing test for useSuspenseQuery --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/react/hooks/__tests__/useSuspenseQuery.test.tsx diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx new file mode 100644 index 00000000000..ae26d8017dd --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -0,0 +1,89 @@ +import React, { ReactElement, Suspense } from 'react'; +import { render, screen, waitFor, RenderResult } from "@testing-library/react"; + +import { ApolloProvider } from "../../context"; +import { + InMemoryCache, + gql, + TypedDocumentNode, + ApolloClient, + Observable, + ApolloLink, +} from "../../../core"; +import { useSuspenseQuery, UseSuspenseQueryResult } from '../useSuspenseQuery'; + +function renderWithClient( + client: ApolloClient, + element: ReactElement +): RenderResult { + const { rerender, ...result } = render( + {element} + ); + + return { + ...result, + rerender: (element: ReactElement) => { + return rerender( + {element} + ); + } + } +} + +describe('useSuspenseQuery', () => { + it('is importable and callable', () => { + expect(typeof useSuspenseQuery).toBe('function'); + }) + + it('suspends the component until resolved', async () => { + interface QueryData { + greeting: string; + }; + + const query: TypedDocumentNode = gql` + query UserQuery { + greeting + } + `; + + const link = new ApolloLink(() => { + return new Observable(observer => { + setTimeout(() => { + observer.next({ data: { greeting: 'Hello' } }); + observer.complete(); + }, 10); + }); + }) + + const client = new ApolloClient({ + link, + cache: new InMemoryCache() + }); + + const results: UseSuspenseQueryResult[] = []; + let renders = 0; + + function Test() { + renders++; + const result = useSuspenseQuery(query); + + results.push(result); + + return
{result.data.greeting} suspense
; + } + + renderWithClient(client, ( + + + + )); + + await waitFor(() => screen.getByText('loading')); + await waitFor(() => screen.getByText('Hello suspense')); + + expect(renders).toBe(2); + expect(results).toEqual([ + expect.objectContaining({ data: { greeting: 'Hello' } }), + ]); + }); +}); From 9f582ff743b4c8f6f886441a2aa8ec4b6d8658dc Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 3 Nov 2022 23:30:26 -0600 Subject: [PATCH 003/159] Add extremely basic implementation to get a suspense query working --- src/react/hooks/useSuspenseQuery.ts | 32 +++++++++++++++++++++++++++++ src/react/types/types.ts | 5 +++++ 2 files changed, 37 insertions(+) create mode 100644 src/react/hooks/useSuspenseQuery.ts diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts new file mode 100644 index 00000000000..9fcd0946b7d --- /dev/null +++ b/src/react/hooks/useSuspenseQuery.ts @@ -0,0 +1,32 @@ +import { useState } from 'react'; +import { + DocumentNode, + OperationVariables, + TypedDocumentNode +} from "../../core"; +import { useApolloClient } from './useApolloClient'; +import { SuspenseQueryHookOptions } from "../types/types"; + +export interface UseSuspenseQueryResult { + data: TData; +} + +export function useSuspenseQuery( + query: DocumentNode | TypedDocumentNode, + options: SuspenseQueryHookOptions = Object.create(null) +): UseSuspenseQueryResult { + const client = useApolloClient(options?.client); + const [observable] = useState(() => { + return client.watchQuery({ ...options, query }) + }); + + const result = observable.getCurrentResult(); + + if (result.loading) { + const promise = observable.reobserve(); + + throw promise; + } + + return result; +} diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 200a856e559..fde7630469c 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -91,6 +91,11 @@ export interface LazyQueryHookOptions< TVariables = OperationVariables > extends Omit, 'skip'> {} +export interface SuspenseQueryHookOptions< + TData = any, + TVariables = OperationVariables +> extends Omit, 'ssr'> {} + /** * @deprecated TODO Delete this unused interface. */ From 3fd7874dd173f00399de10b2d576c8542199ac76 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 3 Nov 2022 23:46:38 -0600 Subject: [PATCH 004/159] Use MockedProvider for test --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 63 ++++++------------- 1 file changed, 18 insertions(+), 45 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index ae26d8017dd..8dc80f3e36e 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,41 +1,19 @@ -import React, { ReactElement, Suspense } from 'react'; -import { render, screen, waitFor, RenderResult } from "@testing-library/react"; +import React, { Suspense } from 'react'; +import { render, screen, waitFor } from "@testing-library/react"; -import { ApolloProvider } from "../../context"; import { - InMemoryCache, gql, TypedDocumentNode, - ApolloClient, - Observable, - ApolloLink, } from "../../../core"; +import { MockedProvider } from '../../../testing'; import { useSuspenseQuery, UseSuspenseQueryResult } from '../useSuspenseQuery'; -function renderWithClient( - client: ApolloClient, - element: ReactElement -): RenderResult { - const { rerender, ...result } = render( - {element} - ); - - return { - ...result, - rerender: (element: ReactElement) => { - return rerender( - {element} - ); - } - } -} - describe('useSuspenseQuery', () => { it('is importable and callable', () => { expect(typeof useSuspenseQuery).toBe('function'); }) - it('suspends the component until resolved', async () => { + it('can suspend a basic query and return results', async () => { interface QueryData { greeting: string; }; @@ -46,20 +24,6 @@ describe('useSuspenseQuery', () => { } `; - const link = new ApolloLink(() => { - return new Observable(observer => { - setTimeout(() => { - observer.next({ data: { greeting: 'Hello' } }); - observer.complete(); - }, 10); - }); - }) - - const client = new ApolloClient({ - link, - cache: new InMemoryCache() - }); - const results: UseSuspenseQueryResult[] = []; let renders = 0; @@ -72,11 +36,20 @@ describe('useSuspenseQuery', () => { return
{result.data.greeting} suspense
; } - renderWithClient(client, ( - - - - )); + render( + + + + + + ); await waitFor(() => screen.getByText('loading')); await waitFor(() => screen.getByText('Hello suspense')); From e6d0f873c36f7e0a9a0608b5834dc58d62f24701 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 4 Nov 2022 12:33:05 -0600 Subject: [PATCH 005/159] Add jest-dom for tests --- config/jest.config.js | 2 +- package-lock.json | 164 +++++++++++++++++++++++++++++++++++++++ package.json | 1 + src/config/jest/setup.ts | 1 + 4 files changed, 167 insertions(+), 1 deletion(-) diff --git a/config/jest.config.js b/config/jest.config.js index 9862ff2c9aa..21bbe7dad47 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -2,7 +2,7 @@ const defaults = { rootDir: "src", preset: "ts-jest", testEnvironment: "jsdom", - setupFiles: ["/config/jest/setup.ts"], + setupFilesAfterEnv: ["/config/jest/setup.ts"], testEnvironmentOptions: { url: "http://localhost", }, diff --git a/package-lock.json b/package-lock.json index 14ad167c4f1..09942e4de46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@changesets/cli": "2.25.2", "@graphql-tools/schema": "9.0.10", "@rollup/plugin-node-resolve": "11.2.1", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "13.4.0", "@testing-library/react-12": "npm:@testing-library/react@^12", "@testing-library/react-hooks": "8.0.1", @@ -102,6 +103,12 @@ } } }, + "node_modules/@adobe/css-tools": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.0.1.tgz", + "integrity": "sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g==", + "dev": true + }, "node_modules/@babel/code-frame": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", @@ -1773,6 +1780,74 @@ "node": ">=12" } }, + "node_modules/@testing-library/jest-dom": { + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", + "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, "node_modules/@testing-library/react": { "version": "13.4.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", @@ -2199,6 +2274,15 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/testing-library__jest-dom": { + "version": "5.14.5", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz", + "integrity": "sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==", + "dev": true, + "dependencies": { + "@types/jest": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", @@ -2943,6 +3027,12 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "node_modules/cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", @@ -8536,6 +8626,12 @@ } }, "dependencies": { + "@adobe/css-tools": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.0.1.tgz", + "integrity": "sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g==", + "dev": true + }, "@babel/code-frame": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", @@ -9910,6 +10006,59 @@ "pretty-format": "^27.0.2" } }, + "@testing-library/jest-dom": { + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", + "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, "@testing-library/react": { "version": "13.4.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", @@ -10285,6 +10434,15 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/testing-library__jest-dom": { + "version": "5.14.5", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz", + "integrity": "sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==", + "dev": true, + "requires": { + "@types/jest": "*" + } + }, "@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", @@ -10866,6 +11024,12 @@ "which": "^2.0.1" } }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", diff --git a/package.json b/package.json index 856626daa0b..e2e530cb927 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@changesets/cli": "2.25.2", "@graphql-tools/schema": "9.0.10", "@rollup/plugin-node-resolve": "11.2.1", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "13.4.0", "@testing-library/react-12": "npm:@testing-library/react@^12", "@testing-library/react-hooks": "8.0.1", diff --git a/src/config/jest/setup.ts b/src/config/jest/setup.ts index 911b1835cdc..49369785472 100644 --- a/src/config/jest/setup.ts +++ b/src/config/jest/setup.ts @@ -1,4 +1,5 @@ import gql from 'graphql-tag'; +import '@testing-library/jest-dom'; // Turn off warnings for repeated fragment names gql.disableFragmentWarnings(); From 8770c15054a2a6352c838e9d45b582a2b82134d3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 4 Nov 2022 12:34:35 -0600 Subject: [PATCH 006/159] Use toBeInTheDocument matcher for suspense query --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 8dc80f3e36e..4f5161bee05 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,5 +1,5 @@ import React, { Suspense } from 'react'; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { gql, @@ -13,7 +13,7 @@ describe('useSuspenseQuery', () => { expect(typeof useSuspenseQuery).toBe('function'); }) - it('can suspend a basic query and return results', async () => { + it('can suspend a query and return results', async () => { interface QueryData { greeting: string; }; @@ -51,9 +51,11 @@ describe('useSuspenseQuery', () => { ); - await waitFor(() => screen.getByText('loading')); - await waitFor(() => screen.getByText('Hello suspense')); + expect(screen.getByText('loading')).toBeInTheDocument(); + const greeting = await screen.findByText('Hello suspense') + + expect(greeting).toBeInTheDocument(); expect(renders).toBe(2); expect(results).toEqual([ expect.objectContaining({ data: { greeting: 'Hello' } }), From ae1e414032d1df92eb738ba89555136ec58a4faa Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 4 Nov 2022 12:54:56 -0600 Subject: [PATCH 007/159] Minor tweak to test name --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 4f5161bee05..4c18e44a299 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -13,7 +13,7 @@ describe('useSuspenseQuery', () => { expect(typeof useSuspenseQuery).toBe('function'); }) - it('can suspend a query and return results', async () => { + it('suspends a query and return results', async () => { interface QueryData { greeting: string; }; From ce07b6ef476b1998fe4a539fdd127440dda31be3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 4 Nov 2022 13:37:06 -0600 Subject: [PATCH 008/159] Rename useSuspenseQuery to useSuspenseQuery_experimental --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 5 ++++- src/react/hooks/useSuspenseQuery.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 4c18e44a299..87e343c3e21 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -6,7 +6,10 @@ import { TypedDocumentNode, } from "../../../core"; import { MockedProvider } from '../../../testing'; -import { useSuspenseQuery, UseSuspenseQueryResult } from '../useSuspenseQuery'; +import { + useSuspenseQuery_experimental as useSuspenseQuery, + UseSuspenseQueryResult +} from '../useSuspenseQuery'; describe('useSuspenseQuery', () => { it('is importable and callable', () => { diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 9fcd0946b7d..007ef7329c8 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -11,7 +11,10 @@ export interface UseSuspenseQueryResult { data: TData; } -export function useSuspenseQuery( +export function useSuspenseQuery_experimental< + TData = any, + TVariables = OperationVariables +>( query: DocumentNode | TypedDocumentNode, options: SuspenseQueryHookOptions = Object.create(null) ): UseSuspenseQueryResult { From 4c885b6f4e22c601c2bfa267cae5327fa0a381e0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 4 Nov 2022 13:37:25 -0600 Subject: [PATCH 009/159] Export useSuspenseQuery --- src/react/hooks/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/react/hooks/index.ts b/src/react/hooks/index.ts index b7e45dfeda8..6597c734248 100644 --- a/src/react/hooks/index.ts +++ b/src/react/hooks/index.ts @@ -7,3 +7,4 @@ export { useQuery } from './useQuery'; export * from './useSubscription'; export * from './useReactiveVar'; export * from './useFragment'; +export * from './useSuspenseQuery'; From 79fda6253841c4f7aa7c190729ca0210f14e1b1e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 4 Nov 2022 14:34:19 -0600 Subject: [PATCH 010/159] Add test to validate suspense works with variables --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 87e343c3e21..5f3dc2aca73 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react"; import { gql, + NetworkStatus, TypedDocumentNode, } from "../../../core"; import { MockedProvider } from '../../../testing'; @@ -61,7 +62,76 @@ describe('useSuspenseQuery', () => { expect(greeting).toBeInTheDocument(); expect(renders).toBe(2); expect(results).toEqual([ - expect.objectContaining({ data: { greeting: 'Hello' } }), + { + data: { greeting: 'Hello' }, + loading: false, + networkStatus: NetworkStatus.ready, + variables: {} + }, + ]); + }); + + it('suspends a query with variables and return results', async () => { + interface QueryData { + character: { + id: string + name: string + }; + }; + + interface QueryVariables { + id: string + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; + + const results: UseSuspenseQueryResult[] = []; + let renders = 0; + + function Test() { + renders++; + const result = useSuspenseQuery(query, { variables: { id: '1' } }); + + results.push(result); + + return
{result.data.character.name}
; + } + + render( + + + + + + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + + const character = await screen.findByText('Spider-Man') + + expect(character).toBeInTheDocument(); + expect(renders).toBe(2); + expect(results).toEqual([ + { + data: { user: { id: '1', name: 'Spider-Man' }}, + variables: { id: '1' }, + loading: false, + networkStatus: NetworkStatus.ready, + }, ]); }); }); From b027aa1e3ad9140bef75f1bab2575c696dac9cb6 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 4 Nov 2022 14:37:49 -0600 Subject: [PATCH 011/159] Limit result to data and variables for now --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 21 +++++++++---------- src/react/hooks/useSuspenseQuery.ts | 17 +++++++++++---- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 5f3dc2aca73..fe68cbb9744 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,11 +1,7 @@ import React, { Suspense } from 'react'; import { render, screen } from "@testing-library/react"; -import { - gql, - NetworkStatus, - TypedDocumentNode, -} from "../../../core"; +import { gql, TypedDocumentNode } from "../../../core"; import { MockedProvider } from '../../../testing'; import { useSuspenseQuery_experimental as useSuspenseQuery, @@ -64,8 +60,6 @@ describe('useSuspenseQuery', () => { expect(results).toEqual([ { data: { greeting: 'Hello' }, - loading: false, - networkStatus: NetworkStatus.ready, variables: {} }, ]); @@ -109,7 +103,7 @@ describe('useSuspenseQuery', () => { mocks={[ { request: { query, variables: { id: '1' } }, - result: { data: { user: { id: '1', name: 'Spider-Man' } } } + result: { data: { character: { id: '1', name: 'Spider-Man' } } } }, ]} > @@ -127,11 +121,16 @@ describe('useSuspenseQuery', () => { expect(renders).toBe(2); expect(results).toEqual([ { - data: { user: { id: '1', name: 'Spider-Man' }}, + data: { character: { id: '1', name: 'Spider-Man' }}, variables: { id: '1' }, - loading: false, - networkStatus: NetworkStatus.ready, }, ]); }); + + it('validates the query', () => {}); + it('ensures a valid fetch policy is used', () => {}); + it('result is referentially stable', () => {}); + it('handles changing variables', () => {}); + it('handles changing queries', () => {}); + it('tears down the query on unmount', () => {}); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 007ef7329c8..bd4d7d7059f 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { DocumentNode, OperationVariables, @@ -7,8 +7,12 @@ import { import { useApolloClient } from './useApolloClient'; import { SuspenseQueryHookOptions } from "../types/types"; -export interface UseSuspenseQueryResult { +export interface UseSuspenseQueryResult< + TData = any, + TVariables = OperationVariables +> { data: TData; + variables: TVariables; } export function useSuspenseQuery_experimental< @@ -17,7 +21,7 @@ export function useSuspenseQuery_experimental< >( query: DocumentNode | TypedDocumentNode, options: SuspenseQueryHookOptions = Object.create(null) -): UseSuspenseQueryResult { +): UseSuspenseQueryResult { const client = useApolloClient(options?.client); const [observable] = useState(() => { return client.watchQuery({ ...options, query }) @@ -31,5 +35,10 @@ export function useSuspenseQuery_experimental< throw promise; } - return result; + return useMemo(() => { + return { + data: result.data, + variables: observable.variables as TVariables + }; + }, [result, observable]); } From 74493ef0d1ba19d7cfdb68372dc48458a520b578 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 4 Nov 2022 14:40:14 -0600 Subject: [PATCH 012/159] Skip useSuspenseQuery TODO tests --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index fe68cbb9744..4de2727be6f 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -127,10 +127,10 @@ describe('useSuspenseQuery', () => { ]); }); - it('validates the query', () => {}); - it('ensures a valid fetch policy is used', () => {}); - it('result is referentially stable', () => {}); - it('handles changing variables', () => {}); - it('handles changing queries', () => {}); - it('tears down the query on unmount', () => {}); + it.skip('validates the query', () => {}); + it.skip('ensures a valid fetch policy is used', () => {}); + it.skip('result is referentially stable', () => {}); + it.skip('handles changing variables', () => {}); + it.skip('handles changing queries', () => {}); + it.skip('tears down the query on unmount', () => {}); }); From 9a46d24f42ef8310d06cdef76d998034480755cf Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 7 Nov 2022 10:43:06 -0700 Subject: [PATCH 013/159] Add check to verify document type --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 26 ++++++++++++++++++- src/react/hooks/useSuspenseQuery.ts | 10 ++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 4de2727be6f..660f337ef89 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,5 +1,7 @@ import React, { Suspense } from 'react'; import { render, screen } from "@testing-library/react"; +import { renderHook } from '@testing-library/react-hooks'; +import { InvariantError } from 'ts-invariant'; import { gql, TypedDocumentNode } from "../../../core"; import { MockedProvider } from '../../../testing'; @@ -127,7 +129,29 @@ describe('useSuspenseQuery', () => { ]); }); - it.skip('validates the query', () => {}); + it('validates the GraphQL query as a query', () => { + // supress console.error calls for this test since they are expected + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const query = gql` + mutation ShouldThrow { + createException + } + `; + + const { result } = renderHook(() => useSuspenseQuery(query), { + wrapper: ({ children }) => {children} + }); + + expect(result.error).toEqual( + new InvariantError( + 'Running a Query requires a graphql Query, but a Mutation was used instead.' + ) + ); + + consoleSpy.mockRestore(); + }); + it.skip('ensures a valid fetch policy is used', () => {}); it.skip('result is referentially stable', () => {}); it.skip('handles changing variables', () => {}); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index bd4d7d7059f..c63e4bf704c 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -1,10 +1,11 @@ -import { useMemo, useState } from 'react'; +import { useRef, useMemo, useState } from 'react'; import { DocumentNode, OperationVariables, TypedDocumentNode } from "../../core"; import { useApolloClient } from './useApolloClient'; +import { DocumentType, verifyDocumentType } from '../parser'; import { SuspenseQueryHookOptions } from "../types/types"; export interface UseSuspenseQueryResult< @@ -22,6 +23,13 @@ export function useSuspenseQuery_experimental< query: DocumentNode | TypedDocumentNode, options: SuspenseQueryHookOptions = Object.create(null) ): UseSuspenseQueryResult { + const hasVerifiedDocument = useRef(false); + + if (!hasVerifiedDocument.current) { + verifyDocumentType(query, DocumentType.Query); + hasVerifiedDocument.current = true; + } + const client = useApolloClient(options?.client); const [observable] = useState(() => { return client.watchQuery({ ...options, query }) From 44c88108a3a3d8b111860fbd80faaf922618e8e6 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 7 Nov 2022 14:38:08 -0700 Subject: [PATCH 014/159] Add a suspense cache and ensure it checks for it --- src/react/cache/SuspenseCache.ts | 55 +++++++++++++++++++ src/react/cache/index.ts | 1 + src/react/context/ApolloContext.ts | 2 + src/react/context/ApolloProvider.tsx | 7 +++ .../hooks/__tests__/useSuspenseQuery.test.tsx | 34 +++++++++++- src/react/hooks/useSuspenseCache.ts | 15 +++++ src/react/index.ts | 1 + 7 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 src/react/cache/SuspenseCache.ts create mode 100644 src/react/cache/index.ts create mode 100644 src/react/hooks/useSuspenseCache.ts diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts new file mode 100644 index 00000000000..91ca5e6295f --- /dev/null +++ b/src/react/cache/SuspenseCache.ts @@ -0,0 +1,55 @@ +import { + ApolloQueryResult, + DocumentNode, + ObservableQuery, + OperationVariables, + TypedDocumentNode, +} from '../../core'; +import { canonicalStringify } from '../../cache'; + +export class SuspenseCache { + private inFlightObservables = new Map< + DocumentNode, + Map>> + >(); + + private suspendedQueries = new Map< + ObservableQuery, + Promise> + >(); + + getObservable( + query: DocumentNode | TypedDocumentNode, + variables?: OperationVariables + ): ObservableQuery | undefined { + return this + .inFlightObservables + .get(query) + ?.get(canonicalStringify(variables)); + } + + getPromise(observable: ObservableQuery) { + return this.suspendedQueries.get(observable); + } + + setObservable( + query: DocumentNode | TypedDocumentNode, + variables: TVariables, + observable: ObservableQuery + ) { + const byVariables = this.inFlightObservables.get(query) || new Map(); + byVariables.set(canonicalStringify(variables), observable); + + return this; + } + + setPromise(observableQuery: ObservableQuery, promise: Promise) { + this.suspendedQueries.set(observableQuery, promise); + + return this; + } + + removePromise(observable: ObservableQuery) { + this.suspendedQueries.delete(observable); + } +} diff --git a/src/react/cache/index.ts b/src/react/cache/index.ts new file mode 100644 index 00000000000..534c51cda6e --- /dev/null +++ b/src/react/cache/index.ts @@ -0,0 +1 @@ +export { SuspenseCache } from './SuspenseCache'; diff --git a/src/react/context/ApolloContext.ts b/src/react/context/ApolloContext.ts index d64f0c3e39f..2453a6356de 100644 --- a/src/react/context/ApolloContext.ts +++ b/src/react/context/ApolloContext.ts @@ -1,11 +1,13 @@ import * as React from 'react'; import { ApolloClient } from '../../core'; import { canUseSymbol } from '../../utilities'; +import { SuspenseCache } from '../cache'; import type { RenderPromises } from '../ssr'; export interface ApolloContextValue { client?: ApolloClient; renderPromises?: RenderPromises; + suspenseCache?: SuspenseCache; } // To make sure Apollo Client doesn't create more than one React context diff --git a/src/react/context/ApolloProvider.tsx b/src/react/context/ApolloProvider.tsx index b05215da0c2..2fe8bc88b81 100644 --- a/src/react/context/ApolloProvider.tsx +++ b/src/react/context/ApolloProvider.tsx @@ -4,14 +4,17 @@ import * as React from 'react'; import { ApolloClient } from '../../core'; import { getApolloContext } from './ApolloContext'; +import { SuspenseCache } from '../cache'; export interface ApolloProviderProps { client: ApolloClient; + suspenseCache?: SuspenseCache; children: React.ReactNode | React.ReactNode[] | null; } export const ApolloProvider: React.FC> = ({ client, + suspenseCache, children }) => { const ApolloContext = getApolloContext(); @@ -22,6 +25,10 @@ export const ApolloProvider: React.FC> = ({ context = Object.assign({}, context, { client }); } + if (suspenseCache && !context.suspenseCache) { + context = Object.assign({}, context, { suspenseCache }); + } + invariant( context.client, 'ApolloProvider was not passed a client instance. Make ' + diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 660f337ef89..49c4953f409 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -3,8 +3,14 @@ import { render, screen } from "@testing-library/react"; import { renderHook } from '@testing-library/react-hooks'; import { InvariantError } from 'ts-invariant'; -import { gql, TypedDocumentNode } from "../../../core"; +import { + gql, + ApolloClient, + InMemoryCache, + TypedDocumentNode +} from "../../../core"; import { MockedProvider } from '../../../testing'; +import { ApolloProvider } from '../../context'; import { useSuspenseQuery_experimental as useSuspenseQuery, UseSuspenseQueryResult @@ -152,6 +158,32 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); + it('ensures a suspense cache is provided', () => { + // supress console.error calls for this test since they are expected + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const query = gql` + query { hello } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const { result } = renderHook(() => useSuspenseQuery(query), { + wrapper: ({ children }) => ( + {children} + ) + }); + + expect(result.error).toEqual( + new InvariantError( + 'Could not find a "suspenseCache" in the context. Wrap the root component ' + + 'in an and provide a suspenseCache.' + ) + ); + + consoleSpy.mockRestore(); + }); + it.skip('ensures a valid fetch policy is used', () => {}); it.skip('result is referentially stable', () => {}); it.skip('handles changing variables', () => {}); diff --git a/src/react/hooks/useSuspenseCache.ts b/src/react/hooks/useSuspenseCache.ts new file mode 100644 index 00000000000..661677f87a3 --- /dev/null +++ b/src/react/hooks/useSuspenseCache.ts @@ -0,0 +1,15 @@ +import { useContext } from 'react'; +import { getApolloContext } from '../context'; +import { invariant } from '../../utilities/globals'; + +export function useSuspenseCache() { + const { suspenseCache } = useContext(getApolloContext()); + + invariant( + suspenseCache, + 'Could not find a "suspenseCache" in the context. Wrap the root component ' + + 'in an and provide a suspenseCache.' + ); + + return suspenseCache; +} diff --git a/src/react/index.ts b/src/react/index.ts index 769c0cfddc9..b7df5ca3c21 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -9,6 +9,7 @@ export { } from './context'; export * from './hooks'; +export * from './cache'; export { DocumentType, From 367343cda907479d6ac44449abb40dc6f3407464 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 7 Nov 2022 14:40:24 -0700 Subject: [PATCH 015/159] Add suspenseCache to MockedProvider --- src/testing/react/MockedProvider.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/testing/react/MockedProvider.tsx b/src/testing/react/MockedProvider.tsx index b64526411ff..95abc67d7a8 100644 --- a/src/testing/react/MockedProvider.tsx +++ b/src/testing/react/MockedProvider.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { ApolloClient, DefaultOptions } from '../../core'; import { InMemoryCache as Cache } from '../../cache'; import { ApolloProvider } from '../../react/context'; +import { SuspenseCache } from '../../react/cache'; import { MockLink, MockedResponse } from '../core'; import { ApolloLink } from '../../link/core'; import { Resolvers } from '../../core'; @@ -17,10 +18,12 @@ export interface MockedProviderProps { childProps?: object; children?: any; link?: ApolloLink; + suspenseCache?: SuspenseCache; } export interface MockedProviderState { client: ApolloClient; + suspenseCache: SuspenseCache; } export class MockedProvider extends React.Component< @@ -40,7 +43,8 @@ export class MockedProvider extends React.Component< defaultOptions, cache, resolvers, - link + link, + suspenseCache, } = this.props; const client = new ApolloClient({ cache: cache || new Cache({ addTypename }), @@ -52,13 +56,18 @@ export class MockedProvider extends React.Component< resolvers, }); - this.state = { client }; + this.state = { + client, + suspenseCache: suspenseCache || new SuspenseCache() + }; } public render() { const { children, childProps } = this.props; + const { client, suspenseCache } = this.state; + return React.isValidElement(children) ? ( - + {React.cloneElement(React.Children.only(children), { ...childProps })} ) : null; From f3d5ab331ff214d2371c00dd30eba18899236fde Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 7 Nov 2022 15:41:49 -0700 Subject: [PATCH 016/159] Add ability to resuspend when a query changes variables --- src/react/cache/SuspenseCache.ts | 30 +++---- .../hooks/__tests__/useSuspenseQuery.test.tsx | 86 ++++++++++++++++++- src/react/hooks/useSuspenseQuery.ts | 42 ++++++--- src/react/types/types.ts | 13 ++- 4 files changed, 140 insertions(+), 31 deletions(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index 91ca5e6295f..e794dee75bb 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -13,12 +13,7 @@ export class SuspenseCache { Map>> >(); - private suspendedQueries = new Map< - ObservableQuery, - Promise> - >(); - - getObservable( + get( query: DocumentNode | TypedDocumentNode, variables?: OperationVariables ): ObservableQuery | undefined { @@ -28,28 +23,29 @@ export class SuspenseCache { ?.get(canonicalStringify(variables)); } - getPromise(observable: ObservableQuery) { - return this.suspendedQueries.get(observable); - } - - setObservable( + set( query: DocumentNode | TypedDocumentNode, variables: TVariables, observable: ObservableQuery ) { const byVariables = this.inFlightObservables.get(query) || new Map(); byVariables.set(canonicalStringify(variables), observable); + this.inFlightObservables.set(query, byVariables); return this; } - setPromise(observableQuery: ObservableQuery, promise: Promise) { - this.suspendedQueries.set(observableQuery, promise); + remove(query: DocumentNode | TypedDocumentNode, variables?: OperationVariables) { + const byVariables = this.inFlightObservables.get(query); - return this; - } + if (!byVariables) { + return + } + + byVariables.delete(canonicalStringify(variables)); - removePromise(observable: ObservableQuery) { - this.suspendedQueries.delete(observable); + if (byVariables.size === 0) { + this.inFlightObservables.delete(query) + } } } diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 49c4953f409..2b7c9fbfe00 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -11,6 +11,7 @@ import { } from "../../../core"; import { MockedProvider } from '../../../testing'; import { ApolloProvider } from '../../context'; +import { SuspenseCache } from '../../cache'; import { useSuspenseQuery_experimental as useSuspenseQuery, UseSuspenseQueryResult @@ -184,9 +185,92 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); + it('re-suspends the component when changing variables and suspensePolicy is set to "always"', async () => { + interface QueryData { + character: { + id: string + name: string + }; + }; + + interface QueryVariables { + id: string + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; + + const suspenseCache = new SuspenseCache(); + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Spider-Man' } } } + }, + { + request: { query, variables: { id: '2' } }, + result: { data: { character: { id: '2', name: 'Iron Man' } } } + }, + ]; + + const results: UseSuspenseQueryResult[] = []; + let renders = 0; + + function Test({ id }: { id: string }) { + renders++; + const result = useSuspenseQuery(query, { + suspensePolicy: 'always', + variables: { id } + }); + + results.push(result); + + return
{result.data.character.name}
; + } + + const { rerender } = render( + + + + + + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + expect(await screen.findByText('Spider-Man')).toBeInTheDocument(); + + rerender( + + + + + + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + expect(await screen.findByText('Iron Man')).toBeInTheDocument(); + + expect(renders).toBe(4); + expect(results).toEqual([ + { + ...mocks[0].result, + variables: { id: '1' }, + }, + { + ...mocks[1].result, + variables: { id: '2' }, + }, + ]); + }); + it.skip('ensures a valid fetch policy is used', () => {}); it.skip('result is referentially stable', () => {}); - it.skip('handles changing variables', () => {}); it.skip('handles changing queries', () => {}); it.skip('tears down the query on unmount', () => {}); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index c63e4bf704c..15a1f2b1d61 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -1,4 +1,5 @@ -import { useRef, useMemo, useState } from 'react'; +import { useRef, useMemo, DependencyList } from 'react'; +import { equal } from '@wry/equality'; import { DocumentNode, OperationVariables, @@ -7,6 +8,7 @@ import { import { useApolloClient } from './useApolloClient'; import { DocumentType, verifyDocumentType } from '../parser'; import { SuspenseQueryHookOptions } from "../types/types"; +import { useSuspenseCache } from './useSuspenseCache'; export interface UseSuspenseQueryResult< TData = any, @@ -16,6 +18,10 @@ export interface UseSuspenseQueryResult< variables: TVariables; } +const DEFAULT_OPTIONS: Partial = { + suspensePolicy: 'always' +} + export function useSuspenseQuery_experimental< TData = any, TVariables = OperationVariables @@ -23,30 +29,42 @@ export function useSuspenseQuery_experimental< query: DocumentNode | TypedDocumentNode, options: SuspenseQueryHookOptions = Object.create(null) ): UseSuspenseQueryResult { + const suspenseCache = useSuspenseCache(); const hasVerifiedDocument = useRef(false); + const opts = useDeepMemo(() => ({ ...DEFAULT_OPTIONS, ...options }), [options]); + const client = useApolloClient(opts.client); + + let observable = suspenseCache.get(query, opts.variables); if (!hasVerifiedDocument.current) { verifyDocumentType(query, DocumentType.Query); hasVerifiedDocument.current = true; } - const client = useApolloClient(options?.client); - const [observable] = useState(() => { - return client.watchQuery({ ...options, query }) - }); + if (!observable) { + const variables = opts.variables; + observable = client.watchQuery({ ...opts, query }); + suspenseCache.set(query, variables, observable); - const result = observable.getCurrentResult(); - - if (result.loading) { - const promise = observable.reobserve(); - - throw promise; + throw observable.reobserve(); } + const result = observable.getCurrentResult(); + return useMemo(() => { return { data: result.data, - variables: observable.variables as TVariables + variables: observable!.variables as TVariables }; }, [result, observable]); } + +function useDeepMemo(memoFn: () => TValue, deps: DependencyList) { + const ref = useRef<{ deps: DependencyList, value: TValue }>(); + + if (!ref.current || !equal(ref.current.deps, deps)) { + ref.current = { value: memoFn(), deps }; + } + + return ref.current.value; +} diff --git a/src/react/types/types.ts b/src/react/types/types.ts index fde7630469c..8cd2c6205cd 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -91,10 +91,21 @@ export interface LazyQueryHookOptions< TVariables = OperationVariables > extends Omit, 'skip'> {} +/** +* suspensePolicy determines when to suspend a component. The options are: +* - always (default): Always suspend, including refetches +* - initial: Only suspend on the first execution. Subsequent refetches will not suspend. +*/ +export type SuspensePolicy = + | 'always' + | 'initial'; + export interface SuspenseQueryHookOptions< TData = any, TVariables = OperationVariables -> extends Omit, 'ssr'> {} +> extends QueryHookOptions { + suspensePolicy?: SuspensePolicy; +} /** * @deprecated TODO Delete this unused interface. From 78e9e6a2c4c062c32baac499ee387ec1988e3813 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 7 Nov 2022 16:12:48 -0700 Subject: [PATCH 017/159] Add a test to verify same variables returns same results --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 2b7c9fbfe00..365d70406bd 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -269,6 +269,81 @@ describe('useSuspenseQuery', () => { ]); }); + it('returns the same results for the same variables', async () => { + interface QueryData { + character: { + id: string + name: string + }; + }; + + interface QueryVariables { + id: string + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; + + const suspenseCache = new SuspenseCache(); + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Spider-Man' } } } + } + ]; + + const results: UseSuspenseQueryResult[] = []; + let renders = 0; + + function Test({ id }: { id: string }) { + renders++; + const result = useSuspenseQuery(query, { + variables: { id } + }); + + results.push(result); + + return
{result.data.character.name}
; + } + + const { rerender } = render( + + + + + + ); + + expect(await screen.findByText('Spider-Man')).toBeInTheDocument(); + + rerender( + + + + + + ); + + expect(renders).toBe(3); + expect(results).toEqual([ + { + ...mocks[0].result, + variables: { id: '1' }, + }, + { + ...mocks[0].result, + variables: { id: '1' }, + }, + ]); + }); + it.skip('ensures a valid fetch policy is used', () => {}); it.skip('result is referentially stable', () => {}); it.skip('handles changing queries', () => {}); From 2bc01da054e2b0466d0b9ed1929c5e5ff7d2928a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 7 Nov 2022 16:34:24 -0700 Subject: [PATCH 018/159] Minor tweak to order of tests --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 142 +++++++++--------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 365d70406bd..077666b51ab 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -20,7 +20,55 @@ import { describe('useSuspenseQuery', () => { it('is importable and callable', () => { expect(typeof useSuspenseQuery).toBe('function'); - }) + }); + + it('validates the GraphQL query as a query', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const query = gql` + mutation ShouldThrow { + createException + } + `; + + const { result } = renderHook(() => useSuspenseQuery(query), { + wrapper: ({ children }) => {children} + }); + + expect(result.error).toEqual( + new InvariantError( + 'Running a Query requires a graphql Query, but a Mutation was used instead.' + ) + ); + + consoleSpy.mockRestore(); + }); + + it('ensures a suspense cache is provided', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const query = gql` + query { hello } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const { result } = renderHook(() => useSuspenseQuery(query), { + wrapper: ({ children }) => ( + {children} + ) + }); + + expect(result.error).toEqual( + new InvariantError( + 'Could not find a "suspenseCache" in the context. Wrap the root component ' + + 'in an and provide a suspenseCache.' + ) + ); + + consoleSpy.mockRestore(); + }); + it('suspends a query and return results', async () => { interface QueryData { @@ -136,56 +184,7 @@ describe('useSuspenseQuery', () => { ]); }); - it('validates the GraphQL query as a query', () => { - // supress console.error calls for this test since they are expected - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - - const query = gql` - mutation ShouldThrow { - createException - } - `; - - const { result } = renderHook(() => useSuspenseQuery(query), { - wrapper: ({ children }) => {children} - }); - - expect(result.error).toEqual( - new InvariantError( - 'Running a Query requires a graphql Query, but a Mutation was used instead.' - ) - ); - - consoleSpy.mockRestore(); - }); - - it('ensures a suspense cache is provided', () => { - // supress console.error calls for this test since they are expected - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - - const query = gql` - query { hello } - `; - - const client = new ApolloClient({ cache: new InMemoryCache() }); - - const { result } = renderHook(() => useSuspenseQuery(query), { - wrapper: ({ children }) => ( - {children} - ) - }); - - expect(result.error).toEqual( - new InvariantError( - 'Could not find a "suspenseCache" in the context. Wrap the root component ' + - 'in an and provide a suspenseCache.' - ) - ); - - consoleSpy.mockRestore(); - }); - - it('re-suspends the component when changing variables and suspensePolicy is set to "always"', async () => { + it('returns the same results for the same variables', async () => { interface QueryData { character: { id: string @@ -212,11 +211,7 @@ describe('useSuspenseQuery', () => { { request: { query, variables: { id: '1' } }, result: { data: { character: { id: '1', name: 'Spider-Man' } } } - }, - { - request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Iron Man' } } } - }, + } ]; const results: UseSuspenseQueryResult[] = []; @@ -225,7 +220,6 @@ describe('useSuspenseQuery', () => { function Test({ id }: { id: string }) { renders++; const result = useSuspenseQuery(query, { - suspensePolicy: 'always', variables: { id } }); @@ -242,34 +236,30 @@ describe('useSuspenseQuery', () => { ); - expect(screen.getByText('loading')).toBeInTheDocument(); expect(await screen.findByText('Spider-Man')).toBeInTheDocument(); rerender( - + ); - expect(screen.getByText('loading')).toBeInTheDocument(); - expect(await screen.findByText('Iron Man')).toBeInTheDocument(); - - expect(renders).toBe(4); + expect(renders).toBe(3); expect(results).toEqual([ { ...mocks[0].result, variables: { id: '1' }, }, { - ...mocks[1].result, - variables: { id: '2' }, + ...mocks[0].result, + variables: { id: '1' }, }, ]); }); - it('returns the same results for the same variables', async () => { + it('re-suspends the component when changing variables and suspensePolicy is set to "always"', async () => { interface QueryData { character: { id: string @@ -296,7 +286,11 @@ describe('useSuspenseQuery', () => { { request: { query, variables: { id: '1' } }, result: { data: { character: { id: '1', name: 'Spider-Man' } } } - } + }, + { + request: { query, variables: { id: '2' } }, + result: { data: { character: { id: '2', name: 'Iron Man' } } } + }, ]; const results: UseSuspenseQueryResult[] = []; @@ -305,6 +299,7 @@ describe('useSuspenseQuery', () => { function Test({ id }: { id: string }) { renders++; const result = useSuspenseQuery(query, { + suspensePolicy: 'always', variables: { id } }); @@ -321,29 +316,34 @@ describe('useSuspenseQuery', () => { ); + expect(screen.getByText('loading')).toBeInTheDocument(); expect(await screen.findByText('Spider-Man')).toBeInTheDocument(); rerender( - + ); - expect(renders).toBe(3); + expect(screen.getByText('loading')).toBeInTheDocument(); + expect(await screen.findByText('Iron Man')).toBeInTheDocument(); + + expect(renders).toBe(4); expect(results).toEqual([ { ...mocks[0].result, variables: { id: '1' }, }, { - ...mocks[0].result, - variables: { id: '1' }, + ...mocks[1].result, + variables: { id: '2' }, }, ]); }); + it.skip('ensures a valid fetch policy is used', () => {}); it.skip('result is referentially stable', () => {}); it.skip('handles changing queries', () => {}); From e1fe3892bf742077631d1484743875a807c7d51b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 7 Nov 2022 17:47:48 -0700 Subject: [PATCH 019/159] Rework suspense cache and implementation a bit --- src/react/cache/SuspenseCache.ts | 46 +++++++++++++++-------------- src/react/hooks/useSuspenseQuery.ts | 18 ++++++----- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index e794dee75bb..c6c2d3a2b6c 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -7,45 +7,47 @@ import { } from '../../core'; import { canonicalStringify } from '../../cache'; +interface CacheEntry { + resolved: boolean; + observable: ObservableQuery, + promise: Promise> +} + export class SuspenseCache { - private inFlightObservables = new Map< + private cache = new Map< DocumentNode, - Map>> + Map> >(); get( query: DocumentNode | TypedDocumentNode, variables?: OperationVariables - ): ObservableQuery | undefined { + ): CacheEntry | undefined { return this - .inFlightObservables + .cache .get(query) - ?.get(canonicalStringify(variables)); + ?.get(canonicalStringify(variables)) } set( query: DocumentNode | TypedDocumentNode, variables: TVariables, - observable: ObservableQuery + observable: ObservableQuery, + promise: Promise> ) { - const byVariables = this.inFlightObservables.get(query) || new Map(); - byVariables.set(canonicalStringify(variables), observable); - this.inFlightObservables.set(query, byVariables); - - return this; - } - - remove(query: DocumentNode | TypedDocumentNode, variables?: OperationVariables) { - const byVariables = this.inFlightObservables.get(query); - - if (!byVariables) { - return + const entry: CacheEntry = { + resolved: false, + observable, + promise: promise.finally(() => { + entry.resolved = true + }) } - byVariables.delete(canonicalStringify(variables)); + const entries = this.cache.get(query) || new Map(); + entries.set(canonicalStringify(variables), entry); - if (byVariables.size === 0) { - this.inFlightObservables.delete(query) - } + this.cache.set(query, entries); + + return this; } } diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 15a1f2b1d61..49d5bb84492 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -1,4 +1,4 @@ -import { useRef, useMemo, DependencyList } from 'react'; +import { useRef, useMemo, useState, DependencyList } from 'react'; import { equal } from '@wry/equality'; import { DocumentNode, @@ -33,20 +33,24 @@ export function useSuspenseQuery_experimental< const hasVerifiedDocument = useRef(false); const opts = useDeepMemo(() => ({ ...DEFAULT_OPTIONS, ...options }), [options]); const client = useApolloClient(opts.client); + const cacheEntry = suspenseCache.get(query, opts.variables); - let observable = suspenseCache.get(query, opts.variables); + const [observable] = useState(() => { + return cacheEntry?.observable || client.watchQuery({ ...opts, query }); + }); if (!hasVerifiedDocument.current) { verifyDocumentType(query, DocumentType.Query); hasVerifiedDocument.current = true; } - if (!observable) { - const variables = opts.variables; - observable = client.watchQuery({ ...opts, query }); - suspenseCache.set(query, variables, observable); + // We have never run this query before so kick it off and suspend + if (!cacheEntry) { + const promise = observable.reobserve(opts); - throw observable.reobserve(); + suspenseCache.set(query, opts.variables, observable, promise); + + throw promise; } const result = observable.getCurrentResult(); From 9e67ddfaeb8904618211065404a76b8b6b5b1c3c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 7 Nov 2022 19:02:26 -0700 Subject: [PATCH 020/159] Add ability to skip suspense when variables change --- src/react/cache/SuspenseCache.ts | 39 ++++++--- .../hooks/__tests__/useSuspenseQuery.test.tsx | 87 +++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 68 ++++++++++++--- 3 files changed, 170 insertions(+), 24 deletions(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index c6c2d3a2b6c..56bae578c27 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -9,44 +9,57 @@ import { canonicalStringify } from '../../cache'; interface CacheEntry { resolved: boolean; - observable: ObservableQuery, promise: Promise> } export class SuspenseCache { + private queries = new Map(); private cache = new Map< - DocumentNode, + ObservableQuery, Map> >(); - get( + registerQuery( query: DocumentNode | TypedDocumentNode, - variables?: OperationVariables + observable: ObservableQuery + ) { + this.queries.set(query, observable); + + return observable; + } + + getQuery( + query: DocumentNode | TypedDocumentNode + ): ObservableQuery | undefined { + return this.queries.get(query); + } + + getVariables( + observable: ObservableQuery, + variables?: TVariables, ): CacheEntry | undefined { return this .cache - .get(query) + .get(observable) ?.get(canonicalStringify(variables)) } - set( - query: DocumentNode | TypedDocumentNode, + setVariables( + observable: ObservableQuery, variables: TVariables, - observable: ObservableQuery, promise: Promise> ) { const entry: CacheEntry = { resolved: false, - observable, promise: promise.finally(() => { - entry.resolved = true - }) + entry.resolved = true; + }), } - const entries = this.cache.get(query) || new Map(); + const entries = this.cache.get(observable) || new Map(); entries.set(canonicalStringify(variables), entry); - this.cache.set(query, entries); + this.cache.set(observable, entries); return this; } diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 077666b51ab..1053c04ca98 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -343,6 +343,93 @@ describe('useSuspenseQuery', () => { ]); }); + it('returns previous results when changing variables and suspensePolicy is set to "initial"', async () => { + interface QueryData { + character: { + id: string + name: string + }; + }; + + interface QueryVariables { + id: string + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; + + const suspenseCache = new SuspenseCache(); + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Spider-Man' } } } + }, + { + request: { query, variables: { id: '2' } }, + result: { data: { character: { id: '2', name: 'Iron Man' } } } + }, + ]; + + const results: UseSuspenseQueryResult[] = []; + let renders = 0; + + function Test({ id }: { id: string }) { + renders++; + const result = useSuspenseQuery(query, { + suspensePolicy: 'initial', + variables: { id } + }); + + results.push(result); + + return
{result.data.character.name}
; + } + + const { rerender } = render( + + + + + + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + expect(await screen.findByText('Spider-Man')).toBeInTheDocument(); + + rerender( + + + + + + ); + + expect(screen.queryByText('loading')).not.toBeInTheDocument(); + expect(await screen.findByText('Iron Man')).toBeInTheDocument(); + + expect(renders).toBe(4); + expect(results).toEqual([ + { + ...mocks[0].result, + variables: { id: '1' }, + }, + { + ...mocks[0].result, + variables: { id: '1' }, + }, + { + ...mocks[1].result, + variables: { id: '2' }, + }, + ]); + }); it.skip('ensures a valid fetch policy is used', () => {}); it.skip('result is referentially stable', () => {}); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 49d5bb84492..4a4ee305e0b 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -1,4 +1,4 @@ -import { useRef, useMemo, useState, DependencyList } from 'react'; +import { useRef, useCallback, useMemo, useEffect, useState, DependencyList } from 'react'; import { equal } from '@wry/equality'; import { DocumentNode, @@ -9,6 +9,7 @@ import { useApolloClient } from './useApolloClient'; import { DocumentType, verifyDocumentType } from '../parser'; import { SuspenseQueryHookOptions } from "../types/types"; import { useSuspenseCache } from './useSuspenseCache'; +import { useSyncExternalStore } from './useSyncExternalStore'; export interface UseSuspenseQueryResult< TData = any, @@ -33,32 +34,77 @@ export function useSuspenseQuery_experimental< const hasVerifiedDocument = useRef(false); const opts = useDeepMemo(() => ({ ...DEFAULT_OPTIONS, ...options }), [options]); const client = useApolloClient(opts.client); - const cacheEntry = suspenseCache.get(query, opts.variables); - - const [observable] = useState(() => { - return cacheEntry?.observable || client.watchQuery({ ...opts, query }); - }); + const firstRun = !suspenseCache.getQuery(query); + const { variables, suspensePolicy } = opts; if (!hasVerifiedDocument.current) { verifyDocumentType(query, DocumentType.Query); hasVerifiedDocument.current = true; } - // We have never run this query before so kick it off and suspend - if (!cacheEntry) { + const [observable] = useState(() => { + return suspenseCache.getQuery(query) || + suspenseCache.registerQuery(query, client.watchQuery({ ...opts, query })); + }); + + const lastResult = useRef(observable.getCurrentResult()); + const lastOpts = useRef(opts); + const cacheEntry = suspenseCache.getVariables(observable, variables); + + // Always suspend on the first run + if (firstRun) { + const promise = observable.reobserve(opts); + + suspenseCache.setVariables(observable, variables, promise); + + throw promise; + } else if (!cacheEntry && suspensePolicy === 'always') { const promise = observable.reobserve(opts); - suspenseCache.set(query, opts.variables, observable, promise); + suspenseCache.setVariables(observable, variables, promise); throw promise; } - const result = observable.getCurrentResult(); + const result = useSyncExternalStore( + useCallback((forceUpdate) => { + const subscription = observable.subscribe(() => { + const previousResult = lastResult.current; + const result = observable.getCurrentResult(); + + if ( + previousResult && + previousResult.loading === result.loading && + previousResult.networkStatus === result.networkStatus && + equal(previousResult.data, result.data) + ) { + return + } + + lastResult.current = result; + forceUpdate(); + }) + + return () => subscription.unsubscribe(); + }, [observable]), + () => lastResult.current, + () => lastResult.current, + ) + + useEffect(() => { + if (opts !== lastOpts.current) { + observable.reobserve(opts); + } + }, [opts, lastOpts.current]); + + useEffect(() => { + lastOpts.current = opts; + }, [opts]) return useMemo(() => { return { data: result.data, - variables: observable!.variables as TVariables + variables: observable.variables as TVariables }; }, [result, observable]); } From e0ce47c73bc53c25f5ffbf04e7c9f98a52a17cad Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 8 Nov 2022 10:58:13 -0700 Subject: [PATCH 021/159] Use patch-package to add renderHook to @testing-library/react-12 --- package-lock.json | 432 ++++++++++++++++++ package.json | 2 + .../@testing-library+react-12+12.1.5.patch | 63 +++ 3 files changed, 497 insertions(+) create mode 100644 patches/@testing-library+react-12+12.1.5.patch diff --git a/package-lock.json b/package-lock.json index 09942e4de46..4bea90e03e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@apollo/client", "version": "3.7.2", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", @@ -58,6 +59,7 @@ "jest-environment-jsdom": "29.3.1", "jest-junit": "15.0.0", "lodash": "4.17.21", + "patch-package": "^6.5.0", "react": "18.2.0", "react-17": "npm:react@^17", "react-dom": "18.2.0", @@ -2343,6 +2345,12 @@ "node": ">=8" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -3743,6 +3751,15 @@ "node": ">=8" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/find-yarn-workspace-root2": { "version": "1.2.16", "resolved": "https://registry.npmjs.org/find-yarn-workspace-root2/-/find-yarn-workspace-root2-1.2.16.tgz", @@ -4362,6 +4379,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4567,6 +4599,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5816,6 +5860,15 @@ "node": ">=0.10.0" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6140,6 +6193,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", + "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minimist-options": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", @@ -6181,6 +6243,12 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -6351,6 +6419,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optimism": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.16.1.tgz", @@ -6490,6 +6574,161 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/patch-package": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-6.5.0.tgz", + "integrity": "sha512-tC3EqJmo74yKqfsMzELaFwxOAu6FH6t+FzFOsnWAuARm7/n2xB5AOeOueE221eM9gtMuIKMKpF9tBy/X2mNP0Q==", + "dev": true, + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "cross-spawn": "^6.0.5", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^7.0.1", + "is-ci": "^2.0.0", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "rimraf": "^2.6.3", + "semver": "^5.6.0", + "slash": "^2.0.0", + "tmp": "^0.0.33", + "yaml": "^1.10.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=10", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "node_modules/patch-package/node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/patch-package/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/patch-package/node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/patch-package/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/patch-package/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/patch-package/node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/patch-package/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/patch-package/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/patch-package/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8563,6 +8802,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.5.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.5.1.tgz", @@ -10494,6 +10742,12 @@ "tslib": "^2.1.0" } }, + "@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true + }, "abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -11578,6 +11832,15 @@ "path-exists": "^4.0.0" } }, + "find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "requires": { + "micromatch": "^4.0.2" + } + }, "find-yarn-workspace-root2": { "version": "1.2.16", "resolved": "https://registry.npmjs.org/find-yarn-workspace-root2/-/find-yarn-workspace-root2-1.2.16.tgz", @@ -12027,6 +12290,12 @@ "has-tostringtag": "^1.0.0" } }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true + }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -12166,6 +12435,15 @@ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -13110,6 +13388,15 @@ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, + "klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11" + } + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -13357,6 +13644,12 @@ "brace-expansion": "^1.1.7" } }, + "minimist": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", + "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", + "dev": true + }, "minimist-options": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", @@ -13386,6 +13679,12 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -13519,6 +13818,16 @@ "mimic-fn": "^2.1.0" } }, + "open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + } + }, "optimism": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.16.1.tgz", @@ -13624,6 +13933,123 @@ "entities": "^4.4.0" } }, + "patch-package": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-6.5.0.tgz", + "integrity": "sha512-tC3EqJmo74yKqfsMzELaFwxOAu6FH6t+FzFOsnWAuARm7/n2xB5AOeOueE221eM9gtMuIKMKpF9tBy/X2mNP0Q==", + "dev": true, + "requires": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "cross-spawn": "^6.0.5", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^7.0.1", + "is-ci": "^2.0.0", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "rimraf": "^2.6.3", + "semver": "^5.6.0", + "slash": "^2.0.0", + "tmp": "^0.0.33", + "yaml": "^1.10.2" + }, + "dependencies": { + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -15189,6 +15615,12 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true + }, "yargs": { "version": "17.5.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.5.1.tgz", diff --git a/package.json b/package.json index e2e530cb927..72b1b266f44 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "prebuild": "npm run clean", "build": "tsc", "postbuild": "npm run update-version && npm run invariants && npm run sourcemaps && npm run rollup && npm run prepdist && npm run postprocess-dist && npm run verify-version", + "postinstall": "patch-package", "update-version": "node config/version.js update", "verify-version": "node config/version.js verify", "invariants": "ts-node-script config/processInvariants.ts", @@ -131,6 +132,7 @@ "jest-environment-jsdom": "29.3.1", "jest-junit": "15.0.0", "lodash": "4.17.21", + "patch-package": "^6.5.0", "react": "18.2.0", "react-17": "npm:react@^17", "react-dom": "18.2.0", diff --git a/patches/@testing-library+react-12+12.1.5.patch b/patches/@testing-library+react-12+12.1.5.patch new file mode 100644 index 00000000000..818d1cd989f --- /dev/null +++ b/patches/@testing-library+react-12+12.1.5.patch @@ -0,0 +1,63 @@ +diff --git a/node_modules/@testing-library/react-12/dist/pure.js b/node_modules/@testing-library/react-12/dist/pure.js +index 72287ac..f0d2c59 100644 +--- a/node_modules/@testing-library/react-12/dist/pure.js ++++ b/node_modules/@testing-library/react-12/dist/pure.js +@@ -7,6 +7,7 @@ Object.defineProperty(exports, "__esModule", { + }); + var _exportNames = { + render: true, ++ renderHook: true, + cleanup: true, + act: true, + fireEvent: true +@@ -25,6 +26,7 @@ Object.defineProperty(exports, "fireEvent", { + } + }); + exports.render = render; ++exports.renderHook = renderHook; + + var React = _interopRequireWildcard(require("react")); + +@@ -138,6 +140,42 @@ function cleanup() { + } // maybe one day we'll expose this (perhaps even as a utility returned by render). + // but let's wait until someone asks for it. + ++function renderHook(renderCallback, options = {}) { ++ const { ++ initialProps, ++ ...renderOptions ++ } = options; ++ const result = /*#__PURE__*/React.createRef(); ++ ++ function TestComponent({ ++ renderCallbackProps ++ }) { ++ const pendingResult = renderCallback(renderCallbackProps); ++ React.useEffect(() => { ++ result.current = pendingResult; ++ }); ++ return null; ++ } ++ ++ const { ++ rerender: baseRerender, ++ unmount ++ } = render( /*#__PURE__*/React.createElement(TestComponent, { ++ renderCallbackProps: initialProps ++ }), renderOptions); ++ ++ function rerender(rerenderCallbackProps) { ++ return baseRerender( /*#__PURE__*/React.createElement(TestComponent, { ++ renderCallbackProps: rerenderCallbackProps ++ })); ++ } ++ ++ return { ++ result, ++ rerender, ++ unmount ++ }; ++} // just re-export everything from dom-testing-library + + function cleanupAtContainer(container) { + (0, _actCompat.default)(() => { From c92fe715514fde9d6dd79792f3c315df3a599abb Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 8 Nov 2022 11:04:13 -0700 Subject: [PATCH 022/159] Use renderHook exported from @testing-library/react --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 1053c04ca98..e0657388789 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,6 +1,5 @@ import React, { Suspense } from 'react'; -import { render, screen } from "@testing-library/react"; -import { renderHook } from '@testing-library/react-hooks'; +import { render, screen, renderHook } from "@testing-library/react"; import { InvariantError } from 'ts-invariant'; import { @@ -31,11 +30,11 @@ describe('useSuspenseQuery', () => { } `; - const { result } = renderHook(() => useSuspenseQuery(query), { - wrapper: ({ children }) => {children} - }); - - expect(result.error).toEqual( + expect(() => { + renderHook(() => useSuspenseQuery(query), { + wrapper: ({ children }) => {children} + }) + }).toThrowError( new InvariantError( 'Running a Query requires a graphql Query, but a Mutation was used instead.' ) @@ -53,13 +52,13 @@ describe('useSuspenseQuery', () => { const client = new ApolloClient({ cache: new InMemoryCache() }); - const { result } = renderHook(() => useSuspenseQuery(query), { - wrapper: ({ children }) => ( - {children} - ) - }); - - expect(result.error).toEqual( + expect(() => { + renderHook(() => useSuspenseQuery(query), { + wrapper: ({ children }) => ( + {children} + ) + }); + }).toThrowError( new InvariantError( 'Could not find a "suspenseCache" in the context. Wrap the root component ' + 'in an and provide a suspenseCache.' From b3bd05c0b15baaf20721640db2eb2b7f2d372b36 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 8 Nov 2022 11:23:56 -0700 Subject: [PATCH 023/159] Rewrite useSuspenseQuery tests with renderHook --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 151 ++++++++---------- 1 file changed, 70 insertions(+), 81 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index e0657388789..813f36649df 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,5 +1,5 @@ import React, { Suspense } from 'react'; -import { render, screen, renderHook } from "@testing-library/react"; +import { render, screen, renderHook, waitFor } from "@testing-library/react"; import { InvariantError } from 'ts-invariant'; import { @@ -68,7 +68,6 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); - it('suspends a query and return results', async () => { interface QueryData { greeting: string; @@ -80,45 +79,40 @@ describe('useSuspenseQuery', () => { } `; - const results: UseSuspenseQueryResult[] = []; let renders = 0; - function Test() { + const { result } = renderHook(() => { renders++; const result = useSuspenseQuery(query); - results.push(result); - - return
{result.data.greeting} suspense
; - } - - render( - - - - - - ); + return result + }, { + wrapper: ({ children }) => ( + + + {children} + + + ) + }); expect(screen.getByText('loading')).toBeInTheDocument(); - const greeting = await screen.findByText('Hello suspense') - - expect(greeting).toBeInTheDocument(); - expect(renders).toBe(2); - expect(results).toEqual([ - { + await waitFor(() => { + expect(result.current).toEqual({ data: { greeting: 'Hello' }, variables: {} - }, - ]); + }); + }) + + expect(renders).toBe(2); }); it('suspends a query with variables and return results', async () => { @@ -142,45 +136,38 @@ describe('useSuspenseQuery', () => { } `; - const results: UseSuspenseQueryResult[] = []; let renders = 0; - function Test() { + const { result } = renderHook(() => { renders++; - const result = useSuspenseQuery(query, { variables: { id: '1' } }); - - results.push(result); - - return
{result.data.character.name}
; - } - - render( - - - - - - ); + return useSuspenseQuery(query, { variables: { id: '1' } }) + }, { + wrapper: ({ children }) => ( + + + {children} + + + ) + }); expect(screen.getByText('loading')).toBeInTheDocument(); - const character = await screen.findByText('Spider-Man') - - expect(character).toBeInTheDocument(); - expect(renders).toBe(2); - expect(results).toEqual([ - { + await waitFor(() => { + expect(result.current).toEqual({ data: { character: { id: '1', name: 'Spider-Man' }}, variables: { id: '1' }, - }, - ]); + }); + }); + + expect(renders).toBe(2); }); it('returns the same results for the same variables', async () => { @@ -216,34 +203,36 @@ describe('useSuspenseQuery', () => { const results: UseSuspenseQueryResult[] = []; let renders = 0; - function Test({ id }: { id: string }) { + const { result, rerender } = renderHook(({ id }) => { renders++; const result = useSuspenseQuery(query, { variables: { id } - }); + }) results.push(result); - return
{result.data.character.name}
; - } + return result; + }, { + initialProps: { id: '1' }, + wrapper: ({ children }) => ( + + + {children} + + + ) + }); - const { rerender } = render( - - - - - - ); + expect(screen.getByText('loading')).toBeInTheDocument(); - expect(await screen.findByText('Spider-Man')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' } + }) + }); - rerender( - - - - - - ); + rerender({ id: '1' }); expect(renders).toBe(3); expect(results).toEqual([ From 51a4924e5130bd9f67cedfd7e34d56216515b195 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 8 Nov 2022 16:08:00 -0700 Subject: [PATCH 024/159] Update suspense query options to a more limited set --- src/react/types/types.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 8cd2c6205cd..a352e8a806d 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -91,21 +91,13 @@ export interface LazyQueryHookOptions< TVariables = OperationVariables > extends Omit, 'skip'> {} -/** -* suspensePolicy determines when to suspend a component. The options are: -* - always (default): Always suspend, including refetches -* - initial: Only suspend on the first execution. Subsequent refetches will not suspend. -*/ -export type SuspensePolicy = - | 'always' - | 'initial'; - -export interface SuspenseQueryHookOptions< +export type SuspenseQueryHookOptions< TData = any, TVariables = OperationVariables -> extends QueryHookOptions { - suspensePolicy?: SuspensePolicy; -} +> = Pick< + QueryHookOptions, + 'client' | 'variables' | 'errorPolicy' | 'context' | 'fetchPolicy' +> /** * @deprecated TODO Delete this unused interface. From f8aa4bae4a66d691a30e26a86370038fac0c5be7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 8 Nov 2022 17:54:41 -0700 Subject: [PATCH 025/159] Limit the fetch policies available for useSuspenseQuery --- src/react/types/types.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/react/types/types.ts b/src/react/types/types.ts index a352e8a806d..081e4eabb5e 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -16,6 +16,7 @@ import { OperationVariables, InternalRefetchQueriesInclude, WatchQueryOptions, + WatchQueryFetchPolicy, } from '../../core'; /* Common types */ @@ -91,13 +92,22 @@ export interface LazyQueryHookOptions< TVariables = OperationVariables > extends Omit, 'skip'> {} -export type SuspenseQueryHookOptions< +export interface SuspenseQueryHookOptions< TData = any, TVariables = OperationVariables -> = Pick< +> extends Pick< QueryHookOptions, 'client' | 'variables' | 'errorPolicy' | 'context' | 'fetchPolicy' -> +> { + fetchPolicy?: Extract< + WatchQueryFetchPolicy, + | 'cache-first' + | 'network-only' + | 'no-cache' + | 'standby' + | 'cache-and-network' + >; +} /** * @deprecated TODO Delete this unused interface. From c24ed8f0ada4294ee064502da0a8016cf087ae16 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 8 Nov 2022 18:44:13 -0700 Subject: [PATCH 026/159] Update tests to check component resuspends when changing variables for all fetch policies --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 317 +++++++++--------- 1 file changed, 160 insertions(+), 157 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 813f36649df..4194b687aa5 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,10 +1,11 @@ import React, { Suspense } from 'react'; -import { render, screen, renderHook, waitFor } from "@testing-library/react"; +import { screen, renderHook, waitFor } from "@testing-library/react"; import { InvariantError } from 'ts-invariant'; import { gql, ApolloClient, + DocumentNode, InMemoryCache, TypedDocumentNode } from "../../../core"; @@ -15,6 +16,15 @@ import { useSuspenseQuery_experimental as useSuspenseQuery, UseSuspenseQueryResult } from '../useSuspenseQuery'; +import { SuspenseQueryHookOptions } from '../../types/types' + +const SUPPORTED_FETCH_POLICIES: SuspenseQueryHookOptions['fetchPolicy'][] = [ + 'cache-first', + 'network-only', + 'no-cache', + 'standby', + 'cache-and-network' +] describe('useSuspenseQuery', () => { it('is importable and callable', () => { @@ -68,7 +78,7 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); - it('suspends a query and return results', async () => { + it('suspends a query and returns results', async () => { interface QueryData { greeting: string; }; @@ -115,7 +125,7 @@ describe('useSuspenseQuery', () => { expect(renders).toBe(2); }); - it('suspends a query with variables and return results', async () => { + it('suspends a query with variables and returns results', async () => { interface QueryData { character: { id: string @@ -247,180 +257,173 @@ describe('useSuspenseQuery', () => { ]); }); - it('re-suspends the component when changing variables and suspensePolicy is set to "always"', async () => { - interface QueryData { - character: { - id: string - name: string + SUPPORTED_FETCH_POLICIES.forEach((fetchPolicy) => { + it(`re-suspends the component when changing variables and using a "${fetchPolicy}" fetch policy`, async () => { + interface QueryData { + character: { + id: string + name: string + }; }; - }; - interface QueryVariables { - id: string - } - - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name - } + interface QueryVariables { + id: string } - `; - - const suspenseCache = new SuspenseCache(); - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } } - }, - { - request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Iron Man' } } } - }, - ]; - - const results: UseSuspenseQueryResult[] = []; - let renders = 0; - - function Test({ id }: { id: string }) { - renders++; - const result = useSuspenseQuery(query, { - suspensePolicy: 'always', - variables: { id } + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Spider-Man' } } } + }, + { + request: { query, variables: { id: '2' } }, + result: { data: { character: { id: '2', name: 'Iron Man' } } } + }, + ]; + + const results: UseSuspenseQueryResult[] = []; + let renders = 0; + + const { result, rerender } = renderHook(({ id }) => { + renders++; + + const result = useSuspenseQuery(query, { + fetchPolicy, + variables: { id } + }); + + results.push(result); + + return result; + }, { + initialProps: { id: '1' }, + wrapper: ({ children }) => ( + + + {children} + + + ) }); - results.push(result); - - return
{result.data.character.name}
; - } - - const { rerender } = render( - - - - - - ); - - expect(screen.getByText('loading')).toBeInTheDocument(); - expect(await screen.findByText('Spider-Man')).toBeInTheDocument(); - - rerender( - - - - - - ); - - expect(screen.getByText('loading')).toBeInTheDocument(); - expect(await screen.findByText('Iron Man')).toBeInTheDocument(); + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' } + }) + }); - expect(renders).toBe(4); - expect(results).toEqual([ - { - ...mocks[0].result, - variables: { id: '1' }, - }, - { - ...mocks[1].result, - variables: { id: '2' }, - }, - ]); - }); + rerender({ id: '2' }) - it('returns previous results when changing variables and suspensePolicy is set to "initial"', async () => { - interface QueryData { - character: { - id: string - name: string - }; - }; + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' } + }); + }); - interface QueryVariables { - id: string - } + expect(renders).toBe(4); + expect(results).toEqual([ + { + ...mocks[0].result, + variables: { id: '1' }, + }, + { + ...mocks[1].result, + variables: { id: '2' }, + }, + ]); + }); - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name + it(`re-suspends the component when changing queries and using a "${fetchPolicy}" fetch policy`, async () => { + const query1: TypedDocumentNode<{ hello: string }> = gql` + query Query1 { + hello } - } - `; - - const suspenseCache = new SuspenseCache(); + `; - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } } - }, - { - request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Iron Man' } } } - }, - ]; - - const results: UseSuspenseQueryResult[] = []; - let renders = 0; - - function Test({ id }: { id: string }) { - renders++; - const result = useSuspenseQuery(query, { - suspensePolicy: 'initial', - variables: { id } + const query2: TypedDocumentNode<{ world: string }> = gql` + query Query2 { + world + } + `; + + const mocks = [ + { + request: { query: query1 }, + result: { data: { hello: "hello" } } + }, + { + request: { query: query2 }, + result: { data: { world: "world" } } + }, + ]; + + const results: UseSuspenseQueryResult[] = []; + let renders = 0; + + const { result, rerender } = renderHook(({ query }) => { + renders++; + const result = useSuspenseQuery(query, { fetchPolicy }); + + results.push(result); + + return result; + }, { + initialProps: { query: query1 } as { query: DocumentNode }, + wrapper: ({ children }) => ( + + + {children} + + + ) }); - results.push(result); - - return
{result.data.character.name}
; - } - - const { rerender } = render( - - - - - - ); + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: {} + }) + }); - expect(screen.getByText('loading')).toBeInTheDocument(); - expect(await screen.findByText('Spider-Man')).toBeInTheDocument(); - - rerender( - - - - - - ); + rerender({ query: query2 }); - expect(screen.queryByText('loading')).not.toBeInTheDocument(); - expect(await screen.findByText('Iron Man')).toBeInTheDocument(); + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: {}, + }) + }) - expect(renders).toBe(4); - expect(results).toEqual([ - { - ...mocks[0].result, - variables: { id: '1' }, - }, - { - ...mocks[0].result, - variables: { id: '1' }, - }, - { - ...mocks[1].result, - variables: { id: '2' }, - }, - ]); + expect(renders).toBe(4); + expect(results).toEqual([ + { + ...mocks[0].result, + variables: {}, + }, + { + ...mocks[1].result, + variables: {}, + }, + ]); + }); }); it.skip('ensures a valid fetch policy is used', () => {}); it.skip('result is referentially stable', () => {}); - it.skip('handles changing queries', () => {}); it.skip('tears down the query on unmount', () => {}); }); From dbbc9ba3a6598b58d4a3a7fc591dd3004e179e91 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 9 Nov 2022 16:37:25 -0700 Subject: [PATCH 027/159] Create a helper to render base case hook --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 232 ++++++++++++------ 1 file changed, 159 insertions(+), 73 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 4194b687aa5..d14d61e0df8 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,5 +1,5 @@ -import React, { Suspense } from 'react'; -import { screen, renderHook, waitFor } from "@testing-library/react"; +import React, { ReactNode, Suspense } from 'react'; +import { screen, renderHook, waitFor, RenderHookOptions } from "@testing-library/react"; import { InvariantError } from 'ts-invariant'; import { @@ -11,7 +11,6 @@ import { } from "../../../core"; import { MockedProvider } from '../../../testing'; import { ApolloProvider } from '../../context'; -import { SuspenseCache } from '../../cache'; import { useSuspenseQuery_experimental as useSuspenseQuery, UseSuspenseQueryResult @@ -26,6 +25,51 @@ const SUPPORTED_FETCH_POLICIES: SuspenseQueryHookOptions['fetchPolicy'][] = [ 'cache-and-network' ] +type RenderSuspenseHookOptions = RenderHookOptions & { + suspenseFallback?: ReactNode; + mocks?: any[]; +} + +interface Renders { + count: number; + frames: Result[]; +} + +function renderSuspenseHook( + render: (initialProps: Props) => Result, + options: RenderSuspenseHookOptions = Object.create(null) +) { + const { + mocks = [], + suspenseFallback = 'loading', + wrapper = ({ children }) => ( + + + {children} + + + ), + ...renderHookOptions + } = options; + + const renders: Renders = { + count: 0, + frames: [] + }; + + const result = renderHook((props) => { + renders.count++; + + const result = render(props); + + renders.frames.push(result); + + return result; + }, { ...renderHookOptions, wrapper }); + + return { ...result, renders }; +} + describe('useSuspenseQuery', () => { it('is importable and callable', () => { expect(typeof useSuspenseQuery).toBe('function'); @@ -89,29 +133,17 @@ describe('useSuspenseQuery', () => { } `; - let renders = 0; - - const { result } = renderHook(() => { - renders++; - const result = useSuspenseQuery(query); - - return result - }, { - wrapper: ({ children }) => ( - - - {children} - - - ) - }); + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query), + { + mocks: [ + { + request: { query }, + result: { data: { greeting: 'Hello' } } + }, + ] + } + ); expect(screen.getByText('loading')).toBeInTheDocument(); @@ -122,7 +154,7 @@ describe('useSuspenseQuery', () => { }); }) - expect(renders).toBe(2); + expect(renders.count).toBe(2); }); it('suspends a query with variables and returns results', async () => { @@ -146,27 +178,17 @@ describe('useSuspenseQuery', () => { } `; - let renders = 0; - - const { result } = renderHook(() => { - renders++; - return useSuspenseQuery(query, { variables: { id: '1' } }) - }, { - wrapper: ({ children }) => ( - - - {children} - - - ) - }); + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { id: '1' } }), + { + mocks: [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Spider-Man' } } } + }, + ] + } + ); expect(screen.getByText('loading')).toBeInTheDocument(); @@ -177,7 +199,7 @@ describe('useSuspenseQuery', () => { }); }); - expect(renders).toBe(2); + expect(renders.count).toBe(2); }); it('returns the same results for the same variables', async () => { @@ -201,8 +223,6 @@ describe('useSuspenseQuery', () => { } `; - const suspenseCache = new SuspenseCache(); - const mocks = [ { request: { query, variables: { id: '1' } }, @@ -210,31 +230,80 @@ describe('useSuspenseQuery', () => { } ]; - const results: UseSuspenseQueryResult[] = []; - let renders = 0; + const { result, rerender, renders } = renderSuspenseHook(({ id }) => { + return useSuspenseQuery(query, { variables: { id } }); + }, { + initialProps: { id: '1' }, + mocks, + }); - const { result, rerender } = renderHook(({ id }) => { - renders++; - const result = useSuspenseQuery(query, { - variables: { id } + expect(screen.getByText('loading')).toBeInTheDocument(); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' } }) + }); + + rerender({ id: '1' }); + + expect(renders.count).toBe(3); + expect(renders.frames).toEqual([ + { + ...mocks[0].result, + variables: { id: '1' }, + }, + { + ...mocks[0].result, + variables: { id: '1' }, + }, + ]); + }); + + it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { + interface QueryData { + character: { + id: string + name: string + }; + }; + + interface QueryVariables { + id: string + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; - results.push(result); + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Spider-Man' } } } + }, + { + request: { query, variables: { id: '2' } }, + result: { data: { character: { id: '2', name: 'Iron Man' } } } + }, + ]; - return result; + const { result, rerender, renders } = renderSuspenseHook(({ id }) => { + return useSuspenseQuery(query, { + fetchPolicy: 'network-only', + variables: { id } + }); }, { + mocks, initialProps: { id: '1' }, - wrapper: ({ children }) => ( - - - {children} - - - ) }); expect(screen.getByText('loading')).toBeInTheDocument(); - await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -242,10 +311,18 @@ describe('useSuspenseQuery', () => { }) }); - rerender({ id: '1' }); + rerender({ id: '2' }) + + expect(await screen.findByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' } + }); + }); - expect(renders).toBe(3); - expect(results).toEqual([ + expect(renders.count).toBe(5); + expect(renders.frames).toEqual([ { ...mocks[0].result, variables: { id: '1' }, @@ -254,6 +331,10 @@ describe('useSuspenseQuery', () => { ...mocks[0].result, variables: { id: '1' }, }, + { + ...mocks[1].result, + variables: { id: '2' }, + }, ]); }); @@ -298,6 +379,7 @@ describe('useSuspenseQuery', () => { const result = useSuspenseQuery(query, { fetchPolicy, + notifyOnNetworkStatusChange: true, variables: { id } }); @@ -315,7 +397,7 @@ describe('useSuspenseQuery', () => { ) }); - expect(screen.getByText('loading')).toBeInTheDocument(); + expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -325,7 +407,7 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }) - expect(screen.getByText('loading')).toBeInTheDocument(); + expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, @@ -333,8 +415,12 @@ describe('useSuspenseQuery', () => { }); }); - expect(renders).toBe(4); + expect(renders).toBe(5); expect(results).toEqual([ + { + ...mocks[0].result, + variables: { id: '1' }, + }, { ...mocks[0].result, variables: { id: '1' }, From 60a901bda40bbfc9a398ee3f401dbc3a3958e3fc Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 9 Nov 2022 17:40:08 -0700 Subject: [PATCH 028/159] Add prettier and config but only for a few files --- .prettierignore | 34 ++++++++++++++++++++++++++++++++++ package-lock.json | 1 + package.json | 9 +++++++++ 3 files changed, 44 insertions(+) diff --git a/.prettierignore b/.prettierignore index 7fc13da26e3..b2103c845c1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,40 @@ +##### DISCLAIMER ###### +# We have disabled the use of prettier in this project for a variety of reasons. +# Because much of this project has not been formatted, we don't want to want to +# apply formatting to everything and skew `git blame` stats. Instead, we should +# only format newly created files that we can guarantee have no existing git +# history. For this reason, we have disabled prettier project-wide except for +# a handful of files. +# +# ONLY ADD NEWLY CREATED FILES/POTHS TO THE LIST BELOW. DO NOT ADD EXISTING +# PROJECT FILES. + # ignores all files in /docs directory /docs/** # Ignore all mdx & md files: *.mdx *.md + +# Do not format anything automatically except files listed below +/* + +##### PATHS TO BE FORMATTEDi ##### +!src/ +src/* +!src/react/ +src/react/* + +# Allow src/react/cache +!src/react/cache/ + +## Allowed React Hooks +!src/react/hooks/ +src/react/hooks/* +!src/react/hooks/useSuspenseCache.ts +!src/react/hooks/useSuspenseQuery.ts + +## Allowed React hook tests +!src/react/hooks/__tests__/ +src/react/hooks/__tests__/* +!src/react/hooks/__tests__/useSuspenseQuery.test.tsx diff --git a/package-lock.json b/package-lock.json index 4bea90e03e4..017166d6b4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "jest-junit": "15.0.0", "lodash": "4.17.21", "patch-package": "^6.5.0", + "prettier": "^2.7.1", "react": "18.2.0", "react-17": "npm:react@^17", "react-dom": "18.2.0", diff --git a/package.json b/package.json index 72b1b266f44..85c5289bff0 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "jest-junit": "15.0.0", "lodash": "4.17.21", "patch-package": "^6.5.0", + "prettier": "^2.7.1", "react": "18.2.0", "react-17": "npm:react@^17", "react-dom": "18.2.0", @@ -155,5 +156,13 @@ }, "publishConfig": { "access": "public" + }, + "prettier": { + "bracketSpacing": true, + "printWidth": 80, + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5" } } From 4c479c558dc0f2f69f8b214c82915c0f7422d794 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 9 Nov 2022 17:41:55 -0700 Subject: [PATCH 029/159] Run prettier on allowed files --- src/react/cache/SuspenseCache.ts | 18 +- .../hooks/__tests__/useSuspenseQuery.test.tsx | 252 ++++++++++-------- src/react/hooks/useSuspenseCache.ts | 2 +- src/react/hooks/useSuspenseQuery.ts | 20 +- 4 files changed, 153 insertions(+), 139 deletions(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index 56bae578c27..b1a475745d8 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -9,15 +9,12 @@ import { canonicalStringify } from '../../cache'; interface CacheEntry { resolved: boolean; - promise: Promise> + promise: Promise>; } export class SuspenseCache { private queries = new Map(); - private cache = new Map< - ObservableQuery, - Map> - >(); + private cache = new Map>>(); registerQuery( query: DocumentNode | TypedDocumentNode, @@ -36,12 +33,9 @@ export class SuspenseCache { getVariables( observable: ObservableQuery, - variables?: TVariables, + variables?: TVariables ): CacheEntry | undefined { - return this - .cache - .get(observable) - ?.get(canonicalStringify(variables)) + return this.cache.get(observable)?.get(canonicalStringify(variables)); } setVariables( @@ -54,13 +48,13 @@ export class SuspenseCache { promise: promise.finally(() => { entry.resolved = true; }), - } + }; const entries = this.cache.get(observable) || new Map(); entries.set(canonicalStringify(variables), entry); this.cache.set(observable, entries); - return this; + return entry; } } diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index d14d61e0df8..afce40aacee 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,5 +1,10 @@ import React, { ReactNode, Suspense } from 'react'; -import { screen, renderHook, waitFor, RenderHookOptions } from "@testing-library/react"; +import { + screen, + renderHook, + waitFor, + RenderHookOptions, +} from '@testing-library/react'; import { InvariantError } from 'ts-invariant'; import { @@ -7,28 +12,28 @@ import { ApolloClient, DocumentNode, InMemoryCache, - TypedDocumentNode -} from "../../../core"; + TypedDocumentNode, +} from '../../../core'; import { MockedProvider } from '../../../testing'; import { ApolloProvider } from '../../context'; import { useSuspenseQuery_experimental as useSuspenseQuery, - UseSuspenseQueryResult + UseSuspenseQueryResult, } from '../useSuspenseQuery'; -import { SuspenseQueryHookOptions } from '../../types/types' +import { SuspenseQueryHookOptions } from '../../types/types'; const SUPPORTED_FETCH_POLICIES: SuspenseQueryHookOptions['fetchPolicy'][] = [ 'cache-first', 'network-only', 'no-cache', 'standby', - 'cache-and-network' -] + 'cache-and-network', +]; type RenderSuspenseHookOptions = RenderHookOptions & { suspenseFallback?: ReactNode; mocks?: any[]; -} +}; interface Renders { count: number; @@ -44,9 +49,7 @@ function renderSuspenseHook( suspenseFallback = 'loading', wrapper = ({ children }) => ( - - {children} - + {children} ), ...renderHookOptions @@ -54,18 +57,21 @@ function renderSuspenseHook( const renders: Renders = { count: 0, - frames: [] + frames: [], }; - const result = renderHook((props) => { - renders.count++; + const result = renderHook( + (props) => { + renders.count++; - const result = render(props); + const result = render(props); - renders.frames.push(result); + renders.frames.push(result); - return result; - }, { ...renderHookOptions, wrapper }); + return result; + }, + { ...renderHookOptions, wrapper } + ); return { ...result, renders }; } @@ -86,8 +92,8 @@ describe('useSuspenseQuery', () => { expect(() => { renderHook(() => useSuspenseQuery(query), { - wrapper: ({ children }) => {children} - }) + wrapper: ({ children }) => {children}, + }); }).toThrowError( new InvariantError( 'Running a Query requires a graphql Query, but a Mutation was used instead.' @@ -101,7 +107,9 @@ describe('useSuspenseQuery', () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const query = gql` - query { hello } + query { + hello + } `; const client = new ApolloClient({ cache: new InMemoryCache() }); @@ -110,12 +118,12 @@ describe('useSuspenseQuery', () => { renderHook(() => useSuspenseQuery(query), { wrapper: ({ children }) => ( {children} - ) + ), }); }).toThrowError( new InvariantError( 'Could not find a "suspenseCache" in the context. Wrap the root component ' + - 'in an and provide a suspenseCache.' + 'in an and provide a suspenseCache.' ) ); @@ -125,7 +133,7 @@ describe('useSuspenseQuery', () => { it('suspends a query and returns results', async () => { interface QueryData { greeting: string; - }; + } const query: TypedDocumentNode = gql` query UserQuery { @@ -139,9 +147,9 @@ describe('useSuspenseQuery', () => { mocks: [ { request: { query }, - result: { data: { greeting: 'Hello' } } + result: { data: { greeting: 'Hello' } }, }, - ] + ], } ); @@ -150,9 +158,9 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ data: { greeting: 'Hello' }, - variables: {} + variables: {}, }); - }) + }); expect(renders.count).toBe(2); }); @@ -160,13 +168,13 @@ describe('useSuspenseQuery', () => { it('suspends a query with variables and returns results', async () => { interface QueryData { character: { - id: string - name: string + id: string; + name: string; }; - }; + } interface QueryVariables { - id: string + id: string; } const query: TypedDocumentNode = gql` @@ -184,9 +192,9 @@ describe('useSuspenseQuery', () => { mocks: [ { request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } } + result: { data: { character: { id: '1', name: 'Spider-Man' } } }, }, - ] + ], } ); @@ -194,7 +202,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ - data: { character: { id: '1', name: 'Spider-Man' }}, + data: { character: { id: '1', name: 'Spider-Man' } }, variables: { id: '1' }, }); }); @@ -205,13 +213,13 @@ describe('useSuspenseQuery', () => { it('returns the same results for the same variables', async () => { interface QueryData { character: { - id: string - name: string + id: string; + name: string; }; - }; + } interface QueryVariables { - id: string + id: string; } const query: TypedDocumentNode = gql` @@ -226,24 +234,27 @@ describe('useSuspenseQuery', () => { const mocks = [ { request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } } - } + result: { data: { character: { id: '1', name: 'Spider-Man' } } }, + }, ]; - const { result, rerender, renders } = renderSuspenseHook(({ id }) => { - return useSuspenseQuery(query, { variables: { id } }); - }, { - initialProps: { id: '1' }, - mocks, - }); + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => { + return useSuspenseQuery(query, { variables: { id } }); + }, + { + initialProps: { id: '1' }, + mocks, + } + ); expect(screen.getByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, - variables: { id: '1' } - }) + variables: { id: '1' }, + }); }); rerender({ id: '1' }); @@ -264,13 +275,13 @@ describe('useSuspenseQuery', () => { it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { interface QueryData { character: { - id: string - name: string + id: string; + name: string; }; - }; + } interface QueryVariables { - id: string + id: string; } const query: TypedDocumentNode = gql` @@ -285,39 +296,42 @@ describe('useSuspenseQuery', () => { const mocks = [ { request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } } + result: { data: { character: { id: '1', name: 'Spider-Man' } } }, }, { request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Iron Man' } } } + result: { data: { character: { id: '2', name: 'Iron Man' } } }, }, ]; - const { result, rerender, renders } = renderSuspenseHook(({ id }) => { - return useSuspenseQuery(query, { - fetchPolicy: 'network-only', - variables: { id } - }); - }, { - mocks, - initialProps: { id: '1' }, - }); + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => { + return useSuspenseQuery(query, { + fetchPolicy: 'network-only', + variables: { id }, + }); + }, + { + mocks, + initialProps: { id: '1' }, + } + ); expect(screen.getByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, - variables: { id: '1' } - }) + variables: { id: '1' }, + }); }); - rerender({ id: '2' }) + rerender({ id: '2' }); expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, - variables: { id: '2' } + variables: { id: '2' }, }); }); @@ -342,13 +356,13 @@ describe('useSuspenseQuery', () => { it(`re-suspends the component when changing variables and using a "${fetchPolicy}" fetch policy`, async () => { interface QueryData { character: { - id: string - name: string + id: string; + name: string; }; - }; + } interface QueryVariables { - id: string + id: string; } const query: TypedDocumentNode = gql` @@ -363,55 +377,56 @@ describe('useSuspenseQuery', () => { const mocks = [ { request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } } + result: { data: { character: { id: '1', name: 'Spider-Man' } } }, }, { request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Iron Man' } } } + result: { data: { character: { id: '2', name: 'Iron Man' } } }, }, ]; const results: UseSuspenseQueryResult[] = []; let renders = 0; - const { result, rerender } = renderHook(({ id }) => { - renders++; + const { result, rerender } = renderHook( + ({ id }) => { + renders++; - const result = useSuspenseQuery(query, { - fetchPolicy, - notifyOnNetworkStatusChange: true, - variables: { id } - }); + const result = useSuspenseQuery(query, { + fetchPolicy, + notifyOnNetworkStatusChange: true, + variables: { id }, + }); - results.push(result); + results.push(result); - return result; - }, { - initialProps: { id: '1' }, - wrapper: ({ children }) => ( - - - {children} - - - ) - }); + return result; + }, + { + initialProps: { id: '1' }, + wrapper: ({ children }) => ( + + {children} + + ), + } + ); expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, - variables: { id: '1' } - }) + variables: { id: '1' }, + }); }); - rerender({ id: '2' }) + rerender({ id: '2' }); expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, - variables: { id: '2' } + variables: { id: '2' }, }); }); @@ -448,41 +463,42 @@ describe('useSuspenseQuery', () => { const mocks = [ { request: { query: query1 }, - result: { data: { hello: "hello" } } + result: { data: { hello: 'hello' } }, }, { request: { query: query2 }, - result: { data: { world: "world" } } + result: { data: { world: 'world' } }, }, ]; const results: UseSuspenseQueryResult[] = []; let renders = 0; - const { result, rerender } = renderHook(({ query }) => { - renders++; - const result = useSuspenseQuery(query, { fetchPolicy }); + const { result, rerender } = renderHook( + ({ query }) => { + renders++; + const result = useSuspenseQuery(query, { fetchPolicy }); - results.push(result); + results.push(result); - return result; - }, { - initialProps: { query: query1 } as { query: DocumentNode }, - wrapper: ({ children }) => ( - - - {children} - - - ) - }); + return result; + }, + { + initialProps: { query: query1 } as { query: DocumentNode }, + wrapper: ({ children }) => ( + + {children} + + ), + } + ); expect(screen.getByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, - variables: {} - }) + variables: {}, + }); }); rerender({ query: query2 }); @@ -492,8 +508,8 @@ describe('useSuspenseQuery', () => { expect(result.current).toEqual({ ...mocks[1].result, variables: {}, - }) - }) + }); + }); expect(renders).toBe(4); expect(results).toEqual([ diff --git a/src/react/hooks/useSuspenseCache.ts b/src/react/hooks/useSuspenseCache.ts index 661677f87a3..67bb3464b1c 100644 --- a/src/react/hooks/useSuspenseCache.ts +++ b/src/react/hooks/useSuspenseCache.ts @@ -8,7 +8,7 @@ export function useSuspenseCache() { invariant( suspenseCache, 'Could not find a "suspenseCache" in the context. Wrap the root component ' + - 'in an and provide a suspenseCache.' + 'in an and provide a suspenseCache.' ); return suspenseCache; diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 4a4ee305e0b..74de03d0c37 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -1,13 +1,21 @@ -import { useRef, useCallback, useMemo, useEffect, useState, DependencyList } from 'react'; +import { + useRef, + useEffect, + useCallback, + useMemo, + useState, + DependencyList, +} from 'react'; import { equal } from '@wry/equality'; import { + ApolloQueryResult, DocumentNode, OperationVariables, - TypedDocumentNode -} from "../../core"; + TypedDocumentNode, +} from '../../core'; import { useApolloClient } from './useApolloClient'; import { DocumentType, verifyDocumentType } from '../parser'; -import { SuspenseQueryHookOptions } from "../types/types"; +import { SuspenseQueryHookOptions } from '../types/types'; import { useSuspenseCache } from './useSuspenseCache'; import { useSyncExternalStore } from './useSyncExternalStore'; @@ -19,10 +27,6 @@ export interface UseSuspenseQueryResult< variables: TVariables; } -const DEFAULT_OPTIONS: Partial = { - suspensePolicy: 'always' -} - export function useSuspenseQuery_experimental< TData = any, TVariables = OperationVariables From 5989879ee14981414792131b89461f644dc223af Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 10:35:19 -0700 Subject: [PATCH 030/159] Pin prettier to exact version --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 017166d6b4f..14cf95a0d3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "jest-junit": "15.0.0", "lodash": "4.17.21", "patch-package": "^6.5.0", - "prettier": "^2.7.1", + "prettier": "2.7.1", "react": "18.2.0", "react-17": "npm:react@^17", "react-dom": "18.2.0", diff --git a/package.json b/package.json index 85c5289bff0..e45efbbbbe9 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "jest-junit": "15.0.0", "lodash": "4.17.21", "patch-package": "^6.5.0", - "prettier": "^2.7.1", + "prettier": "2.7.1", "react": "18.2.0", "react-17": "npm:react@^17", "react-dom": "18.2.0", From afc19bcfd2e658debd4d21307af0af125b3615a0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 10:55:50 -0700 Subject: [PATCH 031/159] Add test to validate fetch is called correct number of times --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 91 ++++++++++++++++++- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index afce40aacee..d2e5cdac258 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -6,15 +6,18 @@ import { RenderHookOptions, } from '@testing-library/react'; import { InvariantError } from 'ts-invariant'; +import { equal } from '@wry/equality'; import { gql, ApolloClient, + ApolloLink, DocumentNode, InMemoryCache, + Observable, TypedDocumentNode, } from '../../../core'; -import { MockedProvider } from '../../../testing'; +import { MockedProvider, MockedResponse } from '../../../testing'; import { ApolloProvider } from '../../context'; import { useSuspenseQuery_experimental as useSuspenseQuery, @@ -31,6 +34,7 @@ const SUPPORTED_FETCH_POLICIES: SuspenseQueryHookOptions['fetchPolicy'][] = [ ]; type RenderSuspenseHookOptions = RenderHookOptions & { + link?: ApolloLink; suspenseFallback?: ReactNode; mocks?: any[]; }; @@ -45,10 +49,11 @@ function renderSuspenseHook( options: RenderSuspenseHookOptions = Object.create(null) ) { const { + link, mocks = [], suspenseFallback = 'loading', wrapper = ({ children }) => ( - + {children} ), @@ -272,7 +277,7 @@ describe('useSuspenseQuery', () => { ]); }); - it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { + it('ensures data is fetched is the correct amount of times', async () => { interface QueryData { character: { id: string; @@ -293,7 +298,87 @@ describe('useSuspenseQuery', () => { } `; + let fetchCount = 0; + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Black Widow' } } }, + }, + { + request: { query, variables: { id: '2' } }, + result: { data: { character: { id: '2', name: 'Hulk' } } }, + }, + ]; + + const link = new ApolloLink((operation) => { + return new Observable((observer) => { + fetchCount++; + + const mock = mocks.find(({ request }) => + equal(request.variables, operation.variables) + ); + + if (!mock) { + throw new Error('Could not find mock for operation'); + } + + observer.next(mock.result); + observer.complete(); + }); + }); + + const { result, rerender } = renderSuspenseHook( + ({ id }) => useSuspenseQuery(query, { variables: { id } }), + { + link, + initialProps: { id: '1' }, + } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' }, + }); + }); + + expect(fetchCount).toBe(1); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' }, + }); + }); + + expect(fetchCount).toBe(2); + }); + + it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { + interface QueryData { + character: { + id: string; + name: string; + }; + } + + interface QueryVariables { + id: string; + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; + + const mocks: MockedResponse[] = [ { request: { query, variables: { id: '1' } }, result: { data: { character: { id: '1', name: 'Spider-Man' } } }, From cc1c65de6bc5b2e8b5a0dd6c2fea52873cad8f5d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 12:35:06 -0700 Subject: [PATCH 032/159] Ensure changing variables suspends properly --- src/react/cache/SuspenseCache.ts | 30 +++-- .../hooks/__tests__/useSuspenseQuery.test.tsx | 2 +- src/react/hooks/useSuspenseQuery.ts | 117 ++++++++++-------- 3 files changed, 86 insertions(+), 63 deletions(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index b1a475745d8..b7edc262670 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -16,31 +16,43 @@ export class SuspenseCache { private queries = new Map(); private cache = new Map>>(); - registerQuery( + registerQuery< + TData = any, + TVariables extends OperationVariables = OperationVariables + >( query: DocumentNode | TypedDocumentNode, - observable: ObservableQuery + observable: ObservableQuery ) { this.queries.set(query, observable); return observable; } - getQuery( + getQuery< + TData = any, + TVariables extends OperationVariables = OperationVariables + >( query: DocumentNode | TypedDocumentNode - ): ObservableQuery | undefined { - return this.queries.get(query); + ): ObservableQuery | undefined { + return this.queries.get(query) as ObservableQuery; } - getVariables( + getVariables< + TData = any, + TVariables extends OperationVariables = OperationVariables + >( observable: ObservableQuery, - variables?: TVariables + variables: TVariables | undefined ): CacheEntry | undefined { return this.cache.get(observable)?.get(canonicalStringify(variables)); } - setVariables( + setVariables< + TData = any, + TVariables extends OperationVariables = OperationVariables + >( observable: ObservableQuery, - variables: TVariables, + variables: TVariables | undefined, promise: Promise> ) { const entry: CacheEntry = { diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index d2e5cdac258..9c95641c1aa 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -392,7 +392,7 @@ describe('useSuspenseQuery', () => { const { result, rerender, renders } = renderSuspenseHook( ({ id }) => { return useSuspenseQuery(query, { - fetchPolicy: 'network-only', + fetchPolicy: 'cache-first', variables: { id }, }); }, diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 74de03d0c37..c007b3ede10 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -29,17 +29,19 @@ export interface UseSuspenseQueryResult< export function useSuspenseQuery_experimental< TData = any, - TVariables = OperationVariables + TVariables extends OperationVariables = OperationVariables >( query: DocumentNode | TypedDocumentNode, options: SuspenseQueryHookOptions = Object.create(null) ): UseSuspenseQueryResult { const suspenseCache = useSuspenseCache(); const hasVerifiedDocument = useRef(false); - const opts = useDeepMemo(() => ({ ...DEFAULT_OPTIONS, ...options }), [options]); + const opts = useDeepMemo( + () => ({ ...options, query, notifyOnNetworkStatusChange: true }), + [options, query] + ); const client = useApolloClient(opts.client); - const firstRun = !suspenseCache.getQuery(query); - const { variables, suspensePolicy } = opts; + const { variables } = opts; if (!hasVerifiedDocument.current) { verifyDocumentType(query, DocumentType.Query); @@ -47,74 +49,83 @@ export function useSuspenseQuery_experimental< } const [observable] = useState(() => { - return suspenseCache.getQuery(query) || - suspenseCache.registerQuery(query, client.watchQuery({ ...opts, query })); + return ( + suspenseCache.getQuery(query) || + suspenseCache.registerQuery(query, client.watchQuery({ ...opts, query })) + ); }); - const lastResult = useRef(observable.getCurrentResult()); - const lastOpts = useRef(opts); - const cacheEntry = suspenseCache.getVariables(observable, variables); - - // Always suspend on the first run - if (firstRun) { - const promise = observable.reobserve(opts); - - suspenseCache.setVariables(observable, variables, promise); - - throw promise; - } else if (!cacheEntry && suspensePolicy === 'always') { - const promise = observable.reobserve(opts); - - suspenseCache.setVariables(observable, variables, promise); + const resultRef = useRef>(); + const previousOptsRef = + useRef>(opts); - throw promise; + if (!resultRef.current) { + resultRef.current = observable.getCurrentResult(); } const result = useSyncExternalStore( - useCallback((forceUpdate) => { - const subscription = observable.subscribe(() => { - const previousResult = lastResult.current; - const result = observable.getCurrentResult(); - - if ( - previousResult && - previousResult.loading === result.loading && - previousResult.networkStatus === result.networkStatus && - equal(previousResult.data, result.data) - ) { - return - } - - lastResult.current = result; - forceUpdate(); - }) - - return () => subscription.unsubscribe(); - }, [observable]), - () => lastResult.current, - () => lastResult.current, - ) + useCallback( + (forceUpdate) => { + const subscription = observable.subscribe(() => { + const previousResult = resultRef.current!; + const result = observable.getCurrentResult(); + + if ( + previousResult.loading === result.loading && + previousResult.networkStatus === result.networkStatus && + equal(previousResult.data, result.data) + ) { + return; + } + + resultRef.current = result; + forceUpdate(); + }); + + return () => subscription.unsubscribe(); + }, + [observable] + ), + () => resultRef.current!, + () => resultRef.current! + ); + + if (result.loading) { + let cacheEntry = suspenseCache.getVariables(observable, variables); + + if (!cacheEntry) { + const promise = observable.reobserve(opts); + cacheEntry = suspenseCache.setVariables( + observable, + opts.variables, + promise + ); + } - useEffect(() => { - if (opts !== lastOpts.current) { - observable.reobserve(opts); + if (!cacheEntry.resolved) { + throw cacheEntry.promise; } - }, [opts, lastOpts.current]); + } useEffect(() => { - lastOpts.current = opts; - }, [opts]) + if (opts.variables !== previousOptsRef.current?.variables) { + const promise = observable.reobserve(opts); + + suspenseCache.setVariables(observable, opts.variables, promise); + previousOptsRef.current = opts; + } + }, [opts.variables]); return useMemo(() => { return { data: result.data, - variables: observable.variables as TVariables + variables: observable.variables as TVariables, }; }, [result, observable]); } function useDeepMemo(memoFn: () => TValue, deps: DependencyList) { - const ref = useRef<{ deps: DependencyList, value: TValue }>(); + const ref = useRef<{ deps: DependencyList; value: TValue }>(); if (!ref.current || !equal(ref.current.deps, deps)) { ref.current = { value: memoFn(), deps }; From 257dd5a06809559026dd6220ac1d8255b9038692 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 12:50:13 -0700 Subject: [PATCH 033/159] Remove sanity check test --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 9c95641c1aa..eb9ae50225b 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -82,10 +82,6 @@ function renderSuspenseHook( } describe('useSuspenseQuery', () => { - it('is importable and callable', () => { - expect(typeof useSuspenseQuery).toBe('function'); - }); - it('validates the GraphQL query as a query', () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); From 58227186a928da29bb2f9549f8528e1de1add42d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 13:04:36 -0700 Subject: [PATCH 034/159] Minor adjustments to tests --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 133 ++++++------------ 1 file changed, 45 insertions(+), 88 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index eb9ae50225b..e72a4fb7932 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -142,23 +142,23 @@ describe('useSuspenseQuery', () => { } `; + const mocks = [ + { + request: { query }, + result: { data: { greeting: 'Hello' } }, + }, + ]; + const { result, renders } = renderSuspenseHook( () => useSuspenseQuery(query), - { - mocks: [ - { - request: { query }, - result: { data: { greeting: 'Hello' } }, - }, - ], - } + { mocks } ); expect(screen.getByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ - data: { greeting: 'Hello' }, + ...mocks[0].result, variables: {}, }); }); @@ -187,23 +187,23 @@ describe('useSuspenseQuery', () => { } `; + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Spider-Man' } } }, + }, + ]; + const { result, renders } = renderSuspenseHook( () => useSuspenseQuery(query, { variables: { id: '1' } }), - { - mocks: [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } }, - }, - ], - } + { mocks } ); expect(screen.getByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ - data: { character: { id: '1', name: 'Spider-Man' } }, + ...mocks[0].result, variables: { id: '1' }, }); }); @@ -240,13 +240,8 @@ describe('useSuspenseQuery', () => { ]; const { result, rerender, renders } = renderSuspenseHook( - ({ id }) => { - return useSuspenseQuery(query, { variables: { id } }); - }, - { - initialProps: { id: '1' }, - mocks, - } + ({ id }) => useSuspenseQuery(query, { variables: { id } }), + { mocks, initialProps: { id: '1' } } ); expect(screen.getByText('loading')).toBeInTheDocument(); @@ -262,14 +257,8 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(3); expect(renders.frames).toEqual([ - { - ...mocks[0].result, - variables: { id: '1' }, - }, - { - ...mocks[0].result, - variables: { id: '1' }, - }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, variables: { id: '1' } }, ]); }); @@ -326,10 +315,7 @@ describe('useSuspenseQuery', () => { const { result, rerender } = renderSuspenseHook( ({ id }) => useSuspenseQuery(query, { variables: { id } }), - { - link, - initialProps: { id: '1' }, - } + { link, initialProps: { id: '1' } } ); await waitFor(() => { @@ -386,16 +372,12 @@ describe('useSuspenseQuery', () => { ]; const { result, rerender, renders } = renderSuspenseHook( - ({ id }) => { - return useSuspenseQuery(query, { + ({ id }) => + useSuspenseQuery(query, { fetchPolicy: 'cache-first', variables: { id }, - }); - }, - { - mocks, - initialProps: { id: '1' }, - } + }), + { mocks, initialProps: { id: '1' } } ); expect(screen.getByText('loading')).toBeInTheDocument(); @@ -416,25 +398,22 @@ describe('useSuspenseQuery', () => { }); }); + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change variables + // 4. Initiate refetch and suspend + // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); expect(renders.frames).toEqual([ - { - ...mocks[0].result, - variables: { id: '1' }, - }, - { - ...mocks[0].result, - variables: { id: '1' }, - }, - { - ...mocks[1].result, - variables: { id: '2' }, - }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[1].result, variables: { id: '2' } }, ]); }); SUPPORTED_FETCH_POLICIES.forEach((fetchPolicy) => { - it(`re-suspends the component when changing variables and using a "${fetchPolicy}" fetch policy`, async () => { + it.skip(`re-suspends the component when changing variables and using a "${fetchPolicy}" fetch policy`, async () => { interface QueryData { character: { id: string; @@ -475,7 +454,6 @@ describe('useSuspenseQuery', () => { const result = useSuspenseQuery(query, { fetchPolicy, - notifyOnNetworkStatusChange: true, variables: { id }, }); @@ -513,22 +491,13 @@ describe('useSuspenseQuery', () => { expect(renders).toBe(5); expect(results).toEqual([ - { - ...mocks[0].result, - variables: { id: '1' }, - }, - { - ...mocks[0].result, - variables: { id: '1' }, - }, - { - ...mocks[1].result, - variables: { id: '2' }, - }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[1].result, variables: { id: '2' } }, ]); }); - it(`re-suspends the component when changing queries and using a "${fetchPolicy}" fetch policy`, async () => { + it.skip(`re-suspends the component when changing queries and using a "${fetchPolicy}" fetch policy`, async () => { const query1: TypedDocumentNode<{ hello: string }> = gql` query Query1 { hello @@ -576,32 +545,20 @@ describe('useSuspenseQuery', () => { expect(screen.getByText('loading')).toBeInTheDocument(); await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - variables: {}, - }); + expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); }); rerender({ query: query2 }); expect(screen.getByText('loading')).toBeInTheDocument(); await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - variables: {}, - }); + expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); }); expect(renders).toBe(4); expect(results).toEqual([ - { - ...mocks[0].result, - variables: {}, - }, - { - ...mocks[1].result, - variables: {}, - }, + { ...mocks[0].result, variables: {} }, + { ...mocks[1].result, variables: {} }, ]); }); }); From 9bda93aa4fbdb2a9dff5facb30e0657cd627769c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 13:32:46 -0700 Subject: [PATCH 035/159] Add ability to re-suspend when changing queries --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 55 +++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 9 ++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index e72a4fb7932..02850ae1dd2 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -412,6 +412,61 @@ describe('useSuspenseQuery', () => { ]); }); + it('re-suspends the component when changing queries and using a "cache-first" fetch policy', async () => { + const query1: TypedDocumentNode<{ hello: string }> = gql` + query Query1 { + hello + } + `; + + const query2: TypedDocumentNode<{ world: string }> = gql` + query Query2 { + world + } + `; + + const mocks = [ + { + request: { query: query1 }, + result: { data: { hello: 'hello' } }, + }, + { + request: { query: query2 }, + result: { data: { world: 'world' } }, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ query }) => useSuspenseQuery(query, { fetchPolicy: 'cache-first' }), + { mocks, initialProps: { query: query1 as DocumentNode } } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + }); + + rerender({ query: query2 }); + + expect(await screen.findByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change queries + // 4. Initiate refetch and suspend + // 5. Unsuspend and return results from refetch + expect(renders.count).toBe(5); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: {} }, + { ...mocks[0].result, variables: {} }, + { ...mocks[1].result, variables: {} }, + ]); + }); + SUPPORTED_FETCH_POLICIES.forEach((fetchPolicy) => { it.skip(`re-suspends the component when changing variables and using a "${fetchPolicy}" fetch policy`, async () => { interface QueryData { diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index c007b3ede10..266401b9d1c 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -51,7 +51,7 @@ export function useSuspenseQuery_experimental< const [observable] = useState(() => { return ( suspenseCache.getQuery(query) || - suspenseCache.registerQuery(query, client.watchQuery({ ...opts, query })) + suspenseCache.registerQuery(query, client.watchQuery(opts)) ); }); @@ -108,13 +108,16 @@ export function useSuspenseQuery_experimental< } useEffect(() => { - if (opts.variables !== previousOptsRef.current?.variables) { + if ( + opts.variables !== previousOptsRef.current?.variables || + opts.query !== previousOptsRef.current.query + ) { const promise = observable.reobserve(opts); suspenseCache.setVariables(observable, opts.variables, promise); previousOptsRef.current = opts; } - }, [opts.variables]); + }, [opts.variables, opts.query]); return useMemo(() => { return { From 67a813a93ff4a9a79de391d18ccc4f8b95e4a479 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 13:34:05 -0700 Subject: [PATCH 036/159] Update name of test to better reflect what its testing --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 02850ae1dd2..3fe4e79127b 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -262,7 +262,7 @@ describe('useSuspenseQuery', () => { ]); }); - it('ensures data is fetched is the correct amount of times', async () => { + it('ensures data is fetched is the correct amount of times when using a "cache-first" fetch policy', async () => { interface QueryData { character: { id: string; @@ -314,7 +314,11 @@ describe('useSuspenseQuery', () => { }); const { result, rerender } = renderSuspenseHook( - ({ id }) => useSuspenseQuery(query, { variables: { id } }), + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'cache-first', + variables: { id }, + }), { link, initialProps: { id: '1' } } ); From 9c474d82d6cd490e7c2c1b273c5f885d0c02bd24 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 13:36:36 -0700 Subject: [PATCH 037/159] Remove auto test setup for all fetch policies --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 165 +----------------- 1 file changed, 1 insertion(+), 164 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 3fe4e79127b..97a49f11c38 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -19,19 +19,7 @@ import { } from '../../../core'; import { MockedProvider, MockedResponse } from '../../../testing'; import { ApolloProvider } from '../../context'; -import { - useSuspenseQuery_experimental as useSuspenseQuery, - UseSuspenseQueryResult, -} from '../useSuspenseQuery'; -import { SuspenseQueryHookOptions } from '../../types/types'; - -const SUPPORTED_FETCH_POLICIES: SuspenseQueryHookOptions['fetchPolicy'][] = [ - 'cache-first', - 'network-only', - 'no-cache', - 'standby', - 'cache-and-network', -]; +import { useSuspenseQuery_experimental as useSuspenseQuery } from '../useSuspenseQuery'; type RenderSuspenseHookOptions = RenderHookOptions & { link?: ApolloLink; @@ -471,157 +459,6 @@ describe('useSuspenseQuery', () => { ]); }); - SUPPORTED_FETCH_POLICIES.forEach((fetchPolicy) => { - it.skip(`re-suspends the component when changing variables and using a "${fetchPolicy}" fetch policy`, async () => { - interface QueryData { - character: { - id: string; - name: string; - }; - } - - interface QueryVariables { - id: string; - } - - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name - } - } - `; - - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } }, - }, - { - request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Iron Man' } } }, - }, - ]; - - const results: UseSuspenseQueryResult[] = []; - let renders = 0; - - const { result, rerender } = renderHook( - ({ id }) => { - renders++; - - const result = useSuspenseQuery(query, { - fetchPolicy, - variables: { id }, - }); - - results.push(result); - - return result; - }, - { - initialProps: { id: '1' }, - wrapper: ({ children }) => ( - - {children} - - ), - } - ); - - expect(await screen.findByText('loading')).toBeInTheDocument(); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - variables: { id: '1' }, - }); - }); - - rerender({ id: '2' }); - - expect(await screen.findByText('loading')).toBeInTheDocument(); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - variables: { id: '2' }, - }); - }); - - expect(renders).toBe(5); - expect(results).toEqual([ - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[1].result, variables: { id: '2' } }, - ]); - }); - - it.skip(`re-suspends the component when changing queries and using a "${fetchPolicy}" fetch policy`, async () => { - const query1: TypedDocumentNode<{ hello: string }> = gql` - query Query1 { - hello - } - `; - - const query2: TypedDocumentNode<{ world: string }> = gql` - query Query2 { - world - } - `; - - const mocks = [ - { - request: { query: query1 }, - result: { data: { hello: 'hello' } }, - }, - { - request: { query: query2 }, - result: { data: { world: 'world' } }, - }, - ]; - - const results: UseSuspenseQueryResult[] = []; - let renders = 0; - - const { result, rerender } = renderHook( - ({ query }) => { - renders++; - const result = useSuspenseQuery(query, { fetchPolicy }); - - results.push(result); - - return result; - }, - { - initialProps: { query: query1 } as { query: DocumentNode }, - wrapper: ({ children }) => ( - - {children} - - ), - } - ); - - expect(screen.getByText('loading')).toBeInTheDocument(); - await waitFor(() => { - expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); - }); - - rerender({ query: query2 }); - - expect(screen.getByText('loading')).toBeInTheDocument(); - await waitFor(() => { - expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); - }); - - expect(renders).toBe(4); - expect(results).toEqual([ - { ...mocks[0].result, variables: {} }, - { ...mocks[1].result, variables: {} }, - ]); - }); - }); - it.skip('ensures a valid fetch policy is used', () => {}); it.skip('result is referentially stable', () => {}); it.skip('tears down the query on unmount', () => {}); From 3e4d95ca07d3f4fe17719d6d0b5f8e0dccc1758d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 14:06:20 -0700 Subject: [PATCH 038/159] Add validation to ensure support fetch policy is used --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 28 ++++++++++++++- src/react/hooks/useSuspenseQuery.ts | 35 ++++++++++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 97a49f11c38..b40f8953d45 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -119,6 +119,33 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); + it('ensures a valid fetch policy is used', () => { + const INVALID_FETCH_POLICIES = ['cache-only']; + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const query = gql` + query { + hello + } + `; + + INVALID_FETCH_POLICIES.forEach((fetchPolicy: any) => { + expect(() => { + renderHook(() => useSuspenseQuery(query, { fetchPolicy }), { + wrapper: ({ children }) => ( + {children} + ), + }); + }).toThrowError( + new InvariantError( + `The fetch policy \`${fetchPolicy}\` is not supported with suspense.` + ) + ); + }); + + consoleSpy.mockRestore(); + }); + it('suspends a query and returns results', async () => { interface QueryData { greeting: string; @@ -459,7 +486,6 @@ describe('useSuspenseQuery', () => { ]); }); - it.skip('ensures a valid fetch policy is used', () => {}); it.skip('result is referentially stable', () => {}); it.skip('tears down the query on unmount', () => {}); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 266401b9d1c..1998baf1c59 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -19,6 +19,8 @@ import { SuspenseQueryHookOptions } from '../types/types'; import { useSuspenseCache } from './useSuspenseCache'; import { useSyncExternalStore } from './useSyncExternalStore'; +type FetchPolicy = SuspenseQueryHookOptions['fetchPolicy']; + export interface UseSuspenseQueryResult< TData = any, TVariables = OperationVariables @@ -27,6 +29,14 @@ export interface UseSuspenseQueryResult< variables: TVariables; } +const SUPPORTED_FETCH_POLICIES: FetchPolicy[] = [ + 'cache-first', + 'network-only', + 'no-cache', + 'standby', + 'cache-and-network', +]; + export function useSuspenseQuery_experimental< TData = any, TVariables extends OperationVariables = OperationVariables @@ -35,7 +45,7 @@ export function useSuspenseQuery_experimental< options: SuspenseQueryHookOptions = Object.create(null) ): UseSuspenseQueryResult { const suspenseCache = useSuspenseCache(); - const hasVerifiedDocument = useRef(false); + const hasRunValidations = useRef(false); const opts = useDeepMemo( () => ({ ...options, query, notifyOnNetworkStatusChange: true }), [options, query] @@ -43,9 +53,9 @@ export function useSuspenseQuery_experimental< const client = useApolloClient(opts.client); const { variables } = opts; - if (!hasVerifiedDocument.current) { - verifyDocumentType(query, DocumentType.Query); - hasVerifiedDocument.current = true; + if (!hasRunValidations.current) { + validateOptions(query, options); + hasRunValidations.current = true; } const [observable] = useState(() => { @@ -127,6 +137,23 @@ export function useSuspenseQuery_experimental< }, [result, observable]); } +function validateOptions( + query: DocumentNode | TypedDocumentNode, + options: SuspenseQueryHookOptions +) { + verifyDocumentType(query, DocumentType.Query); + validateFetchPolicy(options.fetchPolicy); +} + +function validateFetchPolicy( + fetchPolicy: SuspenseQueryHookOptions['fetchPolicy'] +) { + invariant( + SUPPORTED_FETCH_POLICIES.includes(fetchPolicy), + `The fetch policy \`${fetchPolicy}\` is not supported with suspense.` + ); +} + function useDeepMemo(memoFn: () => TValue, deps: DependencyList) { const ref = useRef<{ deps: DependencyList; value: TValue }>(); From 7899824ba4a13643aa54a28e4fbf7f5358848e8f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 14:56:09 -0700 Subject: [PATCH 039/159] Ensure default fetch policy is used for validation --- src/react/hooks/useSuspenseQuery.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 1998baf1c59..5b5f1cfe3ac 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -47,14 +47,19 @@ export function useSuspenseQuery_experimental< const suspenseCache = useSuspenseCache(); const hasRunValidations = useRef(false); const opts = useDeepMemo( - () => ({ ...options, query, notifyOnNetworkStatusChange: true }), + () => ({ + ...options, + query, + fetchPolicy: options.fetchPolicy || DEFAULT_FETCH_POLICY, + notifyOnNetworkStatusChange: true, + }), [options, query] ); const client = useApolloClient(opts.client); const { variables } = opts; if (!hasRunValidations.current) { - validateOptions(query, options); + validateOptions(opts); hasRunValidations.current = true; } @@ -137,12 +142,15 @@ export function useSuspenseQuery_experimental< }, [result, observable]); } -function validateOptions( - query: DocumentNode | TypedDocumentNode, - options: SuspenseQueryHookOptions -) { +type ValidateFunctionOptions = SuspenseQueryHookOptions & { + query: DocumentNode | TypedDocumentNode; +}; + +function validateOptions(options: ValidateFunctionOptions) { + const { query, fetchPolicy = DEFAULT_FETCH_POLICY } = options; + verifyDocumentType(query, DocumentType.Query); - validateFetchPolicy(options.fetchPolicy); + validateFetchPolicy(fetchPolicy); } function validateFetchPolicy( From def002d2917f9204557dd73c51306a60bd69ff1c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 15:03:46 -0700 Subject: [PATCH 040/159] Simpler test to better focus on its purpose --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index b40f8953d45..a4095b1088d 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -338,10 +338,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - variables: { id: '1' }, - }); + expect(result.current).toEqual(mocks[0].result.data); }); expect(fetchCount).toBe(1); @@ -349,10 +346,7 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - variables: { id: '2' }, - }); + expect(result.current.data).toEqual(mocks[1].result.data); }); expect(fetchCount).toBe(2); From 19779ffd8f3977d7548d9ca717b656895a708195 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 15:26:14 -0700 Subject: [PATCH 041/159] Better type for renderSuspenseHook --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index a4095b1088d..8190af2d66d 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -24,7 +24,7 @@ import { useSuspenseQuery_experimental as useSuspenseQuery } from '../useSuspens type RenderSuspenseHookOptions = RenderHookOptions & { link?: ApolloLink; suspenseFallback?: ReactNode; - mocks?: any[]; + mocks?: MockedResponse[]; }; interface Renders { @@ -373,7 +373,7 @@ describe('useSuspenseQuery', () => { } `; - const mocks: MockedResponse[] = [ + const mocks = [ { request: { query, variables: { id: '1' } }, result: { data: { character: { id: '1', name: 'Spider-Man' } } }, From c17ecc913034365c20233b96d97db9f48576680a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 15:29:20 -0700 Subject: [PATCH 042/159] Add missing DEFAULT_FETCH_POLICY constant --- src/react/hooks/useSuspenseQuery.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 5b5f1cfe3ac..a6117a3773f 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -37,6 +37,8 @@ const SUPPORTED_FETCH_POLICIES: FetchPolicy[] = [ 'cache-and-network', ]; +const DEFAULT_FETCH_POLICY: FetchPolicy = 'cache-first'; + export function useSuspenseQuery_experimental< TData = any, TVariables extends OperationVariables = OperationVariables From f844d83796639ca27b88825c3a768d67645469c1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 15:29:45 -0700 Subject: [PATCH 043/159] Fix type of previous opts ref --- src/react/hooks/useSuspenseQuery.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index a6117a3773f..4c0c1b4d317 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -73,8 +73,7 @@ export function useSuspenseQuery_experimental< }); const resultRef = useRef>(); - const previousOptsRef = - useRef>(opts); + const previousOptsRef = useRef(opts); if (!resultRef.current) { resultRef.current = observable.getCurrentResult(); From bdb81996a2f4a029876e53f058d531f354176c3b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 15:31:01 -0700 Subject: [PATCH 044/159] Fix incorrect test assertion --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 8190af2d66d..f07e3808bca 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -338,7 +338,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual(mocks[0].result.data); + expect(result.current.data).toEqual(mocks[0].result.data); }); expect(fetchCount).toBe(1); From 838e0ef8dab0b1dded84b56e0c63ad4e89346751 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 15:42:02 -0700 Subject: [PATCH 045/159] Minor change to order of tests --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 150 +++++++++--------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index f07e3808bca..72ff941a5ac 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -277,81 +277,6 @@ describe('useSuspenseQuery', () => { ]); }); - it('ensures data is fetched is the correct amount of times when using a "cache-first" fetch policy', async () => { - interface QueryData { - character: { - id: string; - name: string; - }; - } - - interface QueryVariables { - id: string; - } - - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name - } - } - `; - - let fetchCount = 0; - - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Black Widow' } } }, - }, - { - request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Hulk' } } }, - }, - ]; - - const link = new ApolloLink((operation) => { - return new Observable((observer) => { - fetchCount++; - - const mock = mocks.find(({ request }) => - equal(request.variables, operation.variables) - ); - - if (!mock) { - throw new Error('Could not find mock for operation'); - } - - observer.next(mock.result); - observer.complete(); - }); - }); - - const { result, rerender } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'cache-first', - variables: { id }, - }), - { link, initialProps: { id: '1' } } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); - - expect(fetchCount).toBe(1); - - rerender({ id: '2' }); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[1].result.data); - }); - - expect(fetchCount).toBe(2); - }); - it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { interface QueryData { character: { @@ -480,6 +405,81 @@ describe('useSuspenseQuery', () => { ]); }); + it('ensures data is fetched is the correct amount of times when using a "cache-first" fetch policy', async () => { + interface QueryData { + character: { + id: string; + name: string; + }; + } + + interface QueryVariables { + id: string; + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; + + let fetchCount = 0; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Black Widow' } } }, + }, + { + request: { query, variables: { id: '2' } }, + result: { data: { character: { id: '2', name: 'Hulk' } } }, + }, + ]; + + const link = new ApolloLink((operation) => { + return new Observable((observer) => { + fetchCount++; + + const mock = mocks.find(({ request }) => + equal(request.variables, operation.variables) + ); + + if (!mock) { + throw new Error('Could not find mock for operation'); + } + + observer.next(mock.result); + observer.complete(); + }); + }); + + const { result, rerender } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'cache-first', + variables: { id }, + }), + { link, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + expect(fetchCount).toBe(1); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[1].result.data); + }); + + expect(fetchCount).toBe(2); + }); + it.skip('result is referentially stable', () => {}); it.skip('tears down the query on unmount', () => {}); }); From c62921263e66f1fee7c44136a183437bd243e8a6 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 15:46:06 -0700 Subject: [PATCH 046/159] Add test to ensure referential equality is maintained --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 72ff941a5ac..6f372e6058c 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -277,6 +277,55 @@ describe('useSuspenseQuery', () => { ]); }); + it('ensures result is referentially stable', async () => { + interface QueryData { + character: { + id: string; + name: string; + }; + } + + interface QueryVariables { + id: string; + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Spider-Man' } } }, + }, + ]; + + const { result, rerender } = renderSuspenseHook( + ({ id }) => useSuspenseQuery(query, { variables: { id } }), + { mocks, initialProps: { id: '1' } } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' }, + }); + }); + + const previousResult = result.current; + + rerender({ id: '1' }); + + expect(result.current).toBe(previousResult); + }); + it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { interface QueryData { character: { @@ -480,6 +529,5 @@ describe('useSuspenseQuery', () => { expect(fetchCount).toBe(2); }); - it.skip('result is referentially stable', () => {}); it.skip('tears down the query on unmount', () => {}); }); From 9e8e2eac222f1a8e338186ec750d3bf299a856d6 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 15:53:15 -0700 Subject: [PATCH 047/159] Add test to validate observable is torn down on unmount --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 6f372e6058c..ed69b5fbd22 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -19,6 +19,7 @@ import { } from '../../../core'; import { MockedProvider, MockedResponse } from '../../../testing'; import { ApolloProvider } from '../../context'; +import { SuspenseCache } from '../../cache'; import { useSuspenseQuery_experimental as useSuspenseQuery } from '../useSuspenseQuery'; type RenderSuspenseHookOptions = RenderHookOptions & { @@ -326,6 +327,42 @@ describe('useSuspenseQuery', () => { expect(result.current).toBe(previousResult); }); + it('tears down the query on unmount', async () => { + const query = gql` + query { + hello + } + `; + + const client = new ApolloClient({ + link: new ApolloLink(() => Observable.of({ data: { hello: 'world' } })), + cache: new InMemoryCache(), + }); + + const { result, unmount } = renderSuspenseHook( + () => useSuspenseQuery(query), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + // We don't subscribe to the observable until after the component has been + // unsuspended, so we need to wait for the result + await waitFor(() => + expect(result.current.data).toEqual({ hello: 'world' }) + ); + + expect(client.getObservableQueries().size).toBe(1); + + unmount(); + + expect(client.getObservableQueries().size).toBe(0); + }); + it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { interface QueryData { character: { @@ -528,6 +565,4 @@ describe('useSuspenseQuery', () => { expect(fetchCount).toBe(2); }); - - it.skip('tears down the query on unmount', () => {}); }); From fa672851ca8711b45cb21987066fbfab392cc4cf Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 16:01:34 -0700 Subject: [PATCH 048/159] Ensure suspense cache removes observable when town down --- src/react/cache/SuspenseCache.ts | 11 +++++++++++ src/react/hooks/useSuspenseQuery.ts | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index b7edc262670..cfd27cb8e20 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -37,6 +37,17 @@ export class SuspenseCache { return this.queries.get(query) as ObservableQuery; } + deregisterQuery(query: DocumentNode | TypedDocumentNode) { + const observable = this.queries.get(query); + + if (!observable || observable.hasObservers()) { + return; + } + + this.queries.delete(query); + this.cache.delete(observable); + } + getVariables< TData = any, TVariables extends OperationVariables = OperationVariables diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 4c0c1b4d317..ed017d1ef26 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -98,7 +98,10 @@ export function useSuspenseQuery_experimental< forceUpdate(); }); - return () => subscription.unsubscribe(); + return () => { + subscription.unsubscribe(); + suspenseCache.deregisterQuery(query); + }; }, [observable] ), From 2f6a9c2695b4d04e28bec17b631cdadad8e78fc7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 16:11:33 -0700 Subject: [PATCH 049/159] Add test to validate suspense cache keeps query if used by others --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index ed69b5fbd22..9a8af3c7a05 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -361,6 +361,54 @@ describe('useSuspenseQuery', () => { unmount(); expect(client.getObservableQueries().size).toBe(0); + expect(suspenseCache.getQuery(query)).toBeUndefined(); + }); + + it('does not remove query from suspense cache if other queries are using it', async () => { + const query = gql` + query { + hello + } + `; + + const client = new ApolloClient({ + link: new ApolloLink(() => Observable.of({ data: { hello: 'world' } })), + cache: new InMemoryCache(), + }); + + const suspenseCache = new SuspenseCache(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result: result1, unmount } = renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper } + ); + + const { result: result2 } = renderSuspenseHook( + () => useSuspenseQuery(query), + { wrapper } + ); + + // We don't subscribe to the observable until after the component has been + // unsuspended, so we need to wait for the results of all queries + await waitFor(() => { + expect(result1.current.data).toEqual({ hello: 'world' }); + expect(result2.current.data).toEqual({ hello: 'world' }); + }); + + // Because they are the same query, the 2 components use the same observable + // in the suspense cache + expect(client.getObservableQueries().size).toBe(1); + + unmount(); + + expect(client.getObservableQueries().size).toBe(1); + expect(suspenseCache.getQuery(query)).not.toBeUndefined(); }); it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { From df25fe197c1fd3cfbd92988a6add27691fc052e7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 17:04:30 -0700 Subject: [PATCH 050/159] Ensure useSuspenseQuery works properly with network-only fetch policy --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 207 +++++++++++++++++- src/react/hooks/useSuspenseQuery.ts | 12 + 2 files changed, 218 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 9a8af3c7a05..3286c03cd7d 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -339,11 +339,13 @@ describe('useSuspenseQuery', () => { cache: new InMemoryCache(), }); + const suspenseCache = new SuspenseCache(); + const { result, unmount } = renderSuspenseHook( () => useSuspenseQuery(query), { wrapper: ({ children }) => ( - + {children} ), @@ -613,4 +615,207 @@ describe('useSuspenseQuery', () => { expect(fetchCount).toBe(2); }); + + it('re-suspends the component when changing variables and using a "network-only" fetch policy', async () => { + interface QueryData { + character: { + id: string; + name: string; + }; + } + + interface QueryVariables { + id: string; + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Spider-Man' } } }, + }, + { + request: { query, variables: { id: '2' } }, + result: { data: { character: { id: '2', name: 'Iron Man' } } }, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'network-only', + variables: { id }, + }), + { mocks, initialProps: { id: '1' } } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + expect(await screen.findByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' }, + }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change variables + // 4. Initiate refetch and suspend + // 5. Unsuspend and return results from refetch + expect(renders.count).toBe(5); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[1].result, variables: { id: '2' } }, + ]); + }); + + it('re-suspends the component when changing queries and using a "network-only" fetch policy', async () => { + const query1: TypedDocumentNode<{ hello: string }> = gql` + query Query1 { + hello + } + `; + + const query2: TypedDocumentNode<{ world: string }> = gql` + query Query2 { + world + } + `; + + const mocks = [ + { + request: { query: query1 }, + result: { data: { hello: 'hello' } }, + }, + { + request: { query: query2 }, + result: { data: { world: 'world' } }, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ query }) => useSuspenseQuery(query, { fetchPolicy: 'network-only' }), + { mocks, initialProps: { query: query1 as DocumentNode } } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + }); + + rerender({ query: query2 }); + + expect(await screen.findByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change queries + // 4. Initiate refetch and suspend + // 5. Unsuspend and return results from refetch + expect(renders.count).toBe(5); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: {} }, + { ...mocks[0].result, variables: {} }, + { ...mocks[1].result, variables: {} }, + ]); + }); + + it('ensures data is fetched is the correct amount of times when using a "network-only" fetch policy', async () => { + interface QueryData { + character: { + id: string; + name: string; + }; + } + + interface QueryVariables { + id: string; + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: String!) { + character(id: $id) { + id + name + } + } + `; + + let fetchCount = 0; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { data: { character: { id: '1', name: 'Black Widow' } } }, + }, + { + request: { query, variables: { id: '2' } }, + result: { data: { character: { id: '2', name: 'Hulk' } } }, + }, + ]; + + const link = new ApolloLink((operation) => { + return new Observable((observer) => { + fetchCount++; + + const mock = mocks.find(({ request }) => + equal(request.variables, operation.variables) + ); + + if (!mock) { + throw new Error('Could not find mock for operation'); + } + + observer.next(mock.result); + observer.complete(); + }); + }); + + const { result, rerender } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'network-only', + variables: { id }, + }), + { link, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + expect(fetchCount).toBe(1); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[1].result.data); + }); + + expect(fetchCount).toBe(2); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index ed017d1ef26..0cb681650b6 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -82,6 +82,16 @@ export function useSuspenseQuery_experimental< const result = useSyncExternalStore( useCallback( (forceUpdate) => { + // ObservableQuery will call `reobserve` as soon as the first + // subscription is created. Because we don't subscribe to the + // observable until after we've suspended via the initial fetch, we + // don't want to initiate another network request for fetch policies + // that always fetch (e.g. 'network-only'). Instead, we set the cache + // policy to `cache-only` to prevent the network request until the + // subscription is created, then reset it back to its original. + const originalFetchPolicy = opts.fetchPolicy; + observable.options.fetchPolicy = 'cache-only'; + const subscription = observable.subscribe(() => { const previousResult = resultRef.current!; const result = observable.getCurrentResult(); @@ -98,6 +108,8 @@ export function useSuspenseQuery_experimental< forceUpdate(); }); + observable.options.fetchPolicy = originalFetchPolicy; + return () => { subscription.unsubscribe(); suspenseCache.deregisterQuery(query); From 3fd11e26bbd21407a552cc19fbcaa7597bae44aa Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 17:27:19 -0700 Subject: [PATCH 051/159] Refactor tests to move some of the common test setup into own functions --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 324 +++++------------- 1 file changed, 77 insertions(+), 247 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 3286c03cd7d..018eb4fdb16 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -70,6 +70,58 @@ function renderSuspenseHook( return { ...result, renders }; } +function useSimpleQueryCase() { + interface QueryData { + greeting: string; + } + + const query: TypedDocumentNode = gql` + query UserQuery { + greeting + } + `; + + const mocks = [ + { + request: { query }, + result: { data: { greeting: 'Hello' } }, + }, + ]; + + return { query, mocks }; +} + +function useVariablesQueryCase() { + const CHARACTERS = ['Spider-Man', 'Black Widow', 'Iron Man', 'Hulk']; + + interface QueryData { + character: { + id: string; + name: string; + }; + } + + interface QueryVariables { + id: string; + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: ID!) { + character(id: $id) { + id + name + } + } + `; + + const mocks = CHARACTERS.map((name, index) => ({ + request: { query, variables: { id: String(index + 1) } }, + result: { data: { character: { id: String(index + 1), name } } }, + })); + + return { query, mocks }; +} + describe('useSuspenseQuery', () => { it('validates the GraphQL query as a query', () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); @@ -95,19 +147,16 @@ describe('useSuspenseQuery', () => { it('ensures a suspense cache is provided', () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - - const query = gql` - query { - hello - } - `; + const { query } = useSimpleQueryCase(); const client = new ApolloClient({ cache: new InMemoryCache() }); expect(() => { renderHook(() => useSuspenseQuery(query), { wrapper: ({ children }) => ( - {children} + + {children} + ), }); }).toThrowError( @@ -123,12 +172,7 @@ describe('useSuspenseQuery', () => { it('ensures a valid fetch policy is used', () => { const INVALID_FETCH_POLICIES = ['cache-only']; const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - - const query = gql` - query { - hello - } - `; + const { query } = useSimpleQueryCase(); INVALID_FETCH_POLICIES.forEach((fetchPolicy: any) => { expect(() => { @@ -148,22 +192,7 @@ describe('useSuspenseQuery', () => { }); it('suspends a query and returns results', async () => { - interface QueryData { - greeting: string; - } - - const query: TypedDocumentNode = gql` - query UserQuery { - greeting - } - `; - - const mocks = [ - { - request: { query }, - result: { data: { greeting: 'Hello' } }, - }, - ]; + const { query, mocks } = useSimpleQueryCase(); const { result, renders } = renderSuspenseHook( () => useSuspenseQuery(query), @@ -183,32 +212,7 @@ describe('useSuspenseQuery', () => { }); it('suspends a query with variables and returns results', async () => { - interface QueryData { - character: { - id: string; - name: string; - }; - } - - interface QueryVariables { - id: string; - } - - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name - } - } - `; - - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } }, - }, - ]; + const { query, mocks } = useVariablesQueryCase(); const { result, renders } = renderSuspenseHook( () => useSuspenseQuery(query, { variables: { id: '1' } }), @@ -228,32 +232,7 @@ describe('useSuspenseQuery', () => { }); it('returns the same results for the same variables', async () => { - interface QueryData { - character: { - id: string; - name: string; - }; - } - - interface QueryVariables { - id: string; - } - - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name - } - } - `; - - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } }, - }, - ]; + const { query, mocks } = useVariablesQueryCase(); const { result, rerender, renders } = renderSuspenseHook( ({ id }) => useSuspenseQuery(query, { variables: { id } }), @@ -279,32 +258,7 @@ describe('useSuspenseQuery', () => { }); it('ensures result is referentially stable', async () => { - interface QueryData { - character: { - id: string; - name: string; - }; - } - - interface QueryVariables { - id: string; - } - - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name - } - } - `; - - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } }, - }, - ]; + const { query, mocks } = useVariablesQueryCase(); const { result, rerender } = renderSuspenseHook( ({ id }) => useSuspenseQuery(query, { variables: { id } }), @@ -328,14 +282,10 @@ describe('useSuspenseQuery', () => { }); it('tears down the query on unmount', async () => { - const query = gql` - query { - hello - } - `; + const { query, mocks } = useSimpleQueryCase(); const client = new ApolloClient({ - link: new ApolloLink(() => Observable.of({ data: { hello: 'world' } })), + link: new ApolloLink(() => Observable.of(mocks[0].result)), cache: new InMemoryCache(), }); @@ -355,7 +305,7 @@ describe('useSuspenseQuery', () => { // We don't subscribe to the observable until after the component has been // unsuspended, so we need to wait for the result await waitFor(() => - expect(result.current.data).toEqual({ hello: 'world' }) + expect(result.current.data).toEqual(mocks[0].result.data) ); expect(client.getObservableQueries().size).toBe(1); @@ -367,14 +317,10 @@ describe('useSuspenseQuery', () => { }); it('does not remove query from suspense cache if other queries are using it', async () => { - const query = gql` - query { - hello - } - `; + const { query, mocks } = useSimpleQueryCase(); const client = new ApolloClient({ - link: new ApolloLink(() => Observable.of({ data: { hello: 'world' } })), + link: new ApolloLink(() => Observable.of(mocks[0].result)), cache: new InMemoryCache(), }); @@ -399,8 +345,8 @@ describe('useSuspenseQuery', () => { // We don't subscribe to the observable until after the component has been // unsuspended, so we need to wait for the results of all queries await waitFor(() => { - expect(result1.current.data).toEqual({ hello: 'world' }); - expect(result2.current.data).toEqual({ hello: 'world' }); + expect(result1.current.data).toEqual(mocks[0].result.data); + expect(result2.current.data).toEqual(mocks[0].result.data); }); // Because they are the same query, the 2 components use the same observable @@ -414,36 +360,7 @@ describe('useSuspenseQuery', () => { }); it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { - interface QueryData { - character: { - id: string; - name: string; - }; - } - - interface QueryVariables { - id: string; - } - - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name - } - } - `; - - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } }, - }, - { - request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Iron Man' } } }, - }, - ]; + const { query, mocks } = useVariablesQueryCase(); const { result, rerender, renders } = renderSuspenseHook( ({ id }) => @@ -502,11 +419,11 @@ describe('useSuspenseQuery', () => { const mocks = [ { request: { query: query1 }, - result: { data: { hello: 'hello' } }, + result: { data: { hello: 'query1' } }, }, { request: { query: query2 }, - result: { data: { world: 'world' } }, + result: { data: { world: 'query2' } }, }, ]; @@ -542,39 +459,10 @@ describe('useSuspenseQuery', () => { }); it('ensures data is fetched is the correct amount of times when using a "cache-first" fetch policy', async () => { - interface QueryData { - character: { - id: string; - name: string; - }; - } - - interface QueryVariables { - id: string; - } - - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name - } - } - `; + const { query, mocks } = useVariablesQueryCase(); let fetchCount = 0; - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Black Widow' } } }, - }, - { - request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Hulk' } } }, - }, - ]; - const link = new ApolloLink((operation) => { return new Observable((observer) => { fetchCount++; @@ -587,7 +475,7 @@ describe('useSuspenseQuery', () => { throw new Error('Could not find mock for operation'); } - observer.next(mock.result); + observer.next(mock.result!); observer.complete(); }); }); @@ -617,36 +505,7 @@ describe('useSuspenseQuery', () => { }); it('re-suspends the component when changing variables and using a "network-only" fetch policy', async () => { - interface QueryData { - character: { - id: string; - name: string; - }; - } - - interface QueryVariables { - id: string; - } - - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name - } - } - `; - - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Spider-Man' } } }, - }, - { - request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Iron Man' } } }, - }, - ]; + const { query, mocks } = useVariablesQueryCase(); const { result, rerender, renders } = renderSuspenseHook( ({ id }) => @@ -705,11 +564,11 @@ describe('useSuspenseQuery', () => { const mocks = [ { request: { query: query1 }, - result: { data: { hello: 'hello' } }, + result: { data: { hello: 'query1' } }, }, { request: { query: query2 }, - result: { data: { world: 'world' } }, + result: { data: { world: 'query2' } }, }, ]; @@ -745,39 +604,10 @@ describe('useSuspenseQuery', () => { }); it('ensures data is fetched is the correct amount of times when using a "network-only" fetch policy', async () => { - interface QueryData { - character: { - id: string; - name: string; - }; - } - - interface QueryVariables { - id: string; - } - - const query: TypedDocumentNode = gql` - query CharacterQuery($id: String!) { - character(id: $id) { - id - name - } - } - `; + const { query, mocks } = useVariablesQueryCase(); let fetchCount = 0; - const mocks = [ - { - request: { query, variables: { id: '1' } }, - result: { data: { character: { id: '1', name: 'Black Widow' } } }, - }, - { - request: { query, variables: { id: '2' } }, - result: { data: { character: { id: '2', name: 'Hulk' } } }, - }, - ]; - const link = new ApolloLink((operation) => { return new Observable((observer) => { fetchCount++; From 654fae853f7f50a56a8b1975dfd0209c7edc76dc Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 17:31:43 -0700 Subject: [PATCH 052/159] Add tests to verify no-cache fetch policy works correctly --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 018eb4fdb16..b227ab0a8a8 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -648,4 +648,149 @@ describe('useSuspenseQuery', () => { expect(fetchCount).toBe(2); }); + + it('re-suspends the component when changing variables and using a "no-cache" fetch policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'no-cache', + variables: { id }, + }), + { mocks, initialProps: { id: '1' } } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + expect(await screen.findByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' }, + }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change variables + // 4. Initiate refetch and suspend + // 5. Unsuspend and return results from refetch + expect(renders.count).toBe(5); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[1].result, variables: { id: '2' } }, + ]); + }); + + it('re-suspends the component when changing queries and using a "no-cache" fetch policy', async () => { + const query1: TypedDocumentNode<{ hello: string }> = gql` + query Query1 { + hello + } + `; + + const query2: TypedDocumentNode<{ world: string }> = gql` + query Query2 { + world + } + `; + + const mocks = [ + { + request: { query: query1 }, + result: { data: { hello: 'query1' } }, + }, + { + request: { query: query2 }, + result: { data: { world: 'query2' } }, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ query }) => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), + { mocks, initialProps: { query: query1 as DocumentNode } } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + }); + + rerender({ query: query2 }); + + expect(await screen.findByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change queries + // 4. Initiate refetch and suspend + // 5. Unsuspend and return results from refetch + expect(renders.count).toBe(5); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: {} }, + { ...mocks[0].result, variables: {} }, + { ...mocks[1].result, variables: {} }, + ]); + }); + + it('ensures data is fetched is the correct amount of times when using a "no-cache" fetch policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + let fetchCount = 0; + + const link = new ApolloLink((operation) => { + return new Observable((observer) => { + fetchCount++; + + const mock = mocks.find(({ request }) => + equal(request.variables, operation.variables) + ); + + if (!mock) { + throw new Error('Could not find mock for operation'); + } + + observer.next(mock.result); + observer.complete(); + }); + }); + + const { result, rerender } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'no-cache', + variables: { id }, + }), + { link, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + expect(fetchCount).toBe(1); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[1].result.data); + }); + + expect(fetchCount).toBe(2); + }); }); From 6acd59b5ace1804f38bdd5e7738b25178b7e9d74 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 17:33:39 -0700 Subject: [PATCH 053/159] Add tests to validate cache-and-network fetch policy works correctly --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index b227ab0a8a8..2aca9f27ad1 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -793,4 +793,150 @@ describe('useSuspenseQuery', () => { expect(fetchCount).toBe(2); }); + + it('re-suspends the component when changing variables and using a "cache-and-network" fetch policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'cache-and-network', + variables: { id }, + }), + { mocks, initialProps: { id: '1' } } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + expect(await screen.findByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' }, + }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change variables + // 4. Initiate refetch and suspend + // 5. Unsuspend and return results from refetch + expect(renders.count).toBe(5); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[1].result, variables: { id: '2' } }, + ]); + }); + + it('re-suspends the component when changing queries and using a "cache-and-network" fetch policy', async () => { + const query1: TypedDocumentNode<{ hello: string }> = gql` + query Query1 { + hello + } + `; + + const query2: TypedDocumentNode<{ world: string }> = gql` + query Query2 { + world + } + `; + + const mocks = [ + { + request: { query: query1 }, + result: { data: { hello: 'query1' } }, + }, + { + request: { query: query2 }, + result: { data: { world: 'query2' } }, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ query }) => + useSuspenseQuery(query, { fetchPolicy: 'cache-and-network' }), + { mocks, initialProps: { query: query1 as DocumentNode } } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + }); + + rerender({ query: query2 }); + + expect(await screen.findByText('loading')).toBeInTheDocument(); + await waitFor(() => { + expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change queries + // 4. Initiate refetch and suspend + // 5. Unsuspend and return results from refetch + expect(renders.count).toBe(5); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: {} }, + { ...mocks[0].result, variables: {} }, + { ...mocks[1].result, variables: {} }, + ]); + }); + + it('ensures data is fetched is the correct amount of times when using a "cache-and-network" fetch policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + let fetchCount = 0; + + const link = new ApolloLink((operation) => { + return new Observable((observer) => { + fetchCount++; + + const mock = mocks.find(({ request }) => + equal(request.variables, operation.variables) + ); + + if (!mock) { + throw new Error('Could not find mock for operation'); + } + + observer.next(mock.result); + observer.complete(); + }); + }); + + const { result, rerender } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'cache-and-network', + variables: { id }, + }), + { link, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + expect(fetchCount).toBe(1); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[1].result.data); + }); + + expect(fetchCount).toBe(2); + }); }); From 2166ca41ff0bc38e447cf5436658c03916b0caa2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 17:40:58 -0700 Subject: [PATCH 054/159] Don't support standby fetch policy for now --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 2 +- src/react/hooks/useSuspenseQuery.ts | 2 +- src/react/types/types.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 2aca9f27ad1..979f3ebcfd9 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -170,7 +170,7 @@ describe('useSuspenseQuery', () => { }); it('ensures a valid fetch policy is used', () => { - const INVALID_FETCH_POLICIES = ['cache-only']; + const INVALID_FETCH_POLICIES = ['cache-only', 'standby']; const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const { query } = useSimpleQueryCase(); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 0cb681650b6..b7f176d4434 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -13,6 +13,7 @@ import { OperationVariables, TypedDocumentNode, } from '../../core'; +import { invariant } from '../../utilities/globals'; import { useApolloClient } from './useApolloClient'; import { DocumentType, verifyDocumentType } from '../parser'; import { SuspenseQueryHookOptions } from '../types/types'; @@ -33,7 +34,6 @@ const SUPPORTED_FETCH_POLICIES: FetchPolicy[] = [ 'cache-first', 'network-only', 'no-cache', - 'standby', 'cache-and-network', ]; diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 081e4eabb5e..3bbe0744aa9 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -104,7 +104,6 @@ export interface SuspenseQueryHookOptions< | 'cache-first' | 'network-only' | 'no-cache' - | 'standby' | 'cache-and-network' >; } From fa037a02bacbd78af727452443043ad120fc487f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 22:36:58 -0700 Subject: [PATCH 055/159] Add test to verify client can be overridden --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 979f3ebcfd9..22a3c458f6c 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -359,6 +359,43 @@ describe('useSuspenseQuery', () => { expect(suspenseCache.getQuery(query)).not.toBeUndefined(); }); + it('allows the client to be overridden', async () => { + const { query } = useSimpleQueryCase(); + + const globalClient = new ApolloClient({ + link: new ApolloLink(() => + Observable.of({ data: { greeting: 'global hello' } }) + ), + cache: new InMemoryCache(), + }); + + const localClient = new ApolloClient({ + link: new ApolloLink(() => + Observable.of({ data: { greeting: 'local hello' } }) + ), + cache: new InMemoryCache(), + }); + + const suspenseCache = new SuspenseCache(); + + const { result } = renderSuspenseHook( + () => useSuspenseQuery(query, { client: localClient }), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + // We don't subscribe to the observable until after the component has been + // unsuspended, so we need to wait for the result + await waitFor(() => + expect(result.current.data).toEqual({ greeting: 'local hello' }) + ); + }); + it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); From 3ac3792f2e236ef87fac1181ed8abcfc65463501 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 10 Nov 2022 23:04:28 -0700 Subject: [PATCH 056/159] Add tests to verify cache is written properly --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 102 +++++++++++++++++- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 22a3c458f6c..5a4936f17de 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -10,6 +10,7 @@ import { equal } from '@wry/equality'; import { gql, + ApolloCache, ApolloClient, ApolloLink, DocumentNode, @@ -22,8 +23,12 @@ import { ApolloProvider } from '../../context'; import { SuspenseCache } from '../../cache'; import { useSuspenseQuery_experimental as useSuspenseQuery } from '../useSuspenseQuery'; -type RenderSuspenseHookOptions = RenderHookOptions & { +type RenderSuspenseHookOptions< + Props, + TSerializedCache = {} +> = RenderHookOptions & { link?: ApolloLink; + cache?: ApolloCache; suspenseFallback?: ReactNode; mocks?: MockedResponse[]; }; @@ -38,11 +43,12 @@ function renderSuspenseHook( options: RenderSuspenseHookOptions = Object.create(null) ) { const { + cache, link, mocks = [], suspenseFallback = 'loading', wrapper = ({ children }) => ( - + {children} ), @@ -541,6 +547,29 @@ describe('useSuspenseQuery', () => { expect(fetchCount).toBe(2); }); + it('writes to the cache when using a "cache-first" fetch policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const cache = new InMemoryCache(); + + const { result } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'cache-first', + variables: { id }, + }), + { cache, mocks, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + const cachedData = cache.readQuery({ query, variables: { id: '1' } }); + + expect(cachedData).toEqual(mocks[0].result.data); + }); + it('re-suspends the component when changing variables and using a "network-only" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -686,6 +715,29 @@ describe('useSuspenseQuery', () => { expect(fetchCount).toBe(2); }); + it('writes to the cache when using a "network-only" fetch policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const cache = new InMemoryCache(); + + const { result } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'network-only', + variables: { id }, + }), + { cache, mocks, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + const cachedData = cache.readQuery({ query, variables: { id: '1' } }); + + expect(cachedData).toEqual(mocks[0].result.data); + }); + it('re-suspends the component when changing variables and using a "no-cache" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -831,6 +883,29 @@ describe('useSuspenseQuery', () => { expect(fetchCount).toBe(2); }); + it('does not write to the cache when using a "no-cache" fetch policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const cache = new InMemoryCache(); + + const { result } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'no-cache', + variables: { id }, + }), + { cache, mocks, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + const cachedData = cache.readQuery({ query, variables: { id: '1' } }); + + expect(cachedData).toBeNull(); + }); + it('re-suspends the component when changing variables and using a "cache-and-network" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -976,4 +1051,27 @@ describe('useSuspenseQuery', () => { expect(fetchCount).toBe(2); }); + + it('writes to the cache when using a "cache-and-network" fetch policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const cache = new InMemoryCache(); + + const { result } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'cache-and-network', + variables: { id }, + }), + { cache, mocks, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + const cachedData = cache.readQuery({ query, variables: { id: '1' } }); + + expect(cachedData).toEqual(mocks[0].result.data); + }); }); From 97a1e78d9d0a6673c04d433b5d9e6131aaa8b2de Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 00:18:41 -0700 Subject: [PATCH 057/159] Handle when to suspend based on fetch policy and data in the cache --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 122 ++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 43 ++++-- 2 files changed, 151 insertions(+), 14 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 5a4936f17de..d67277836f9 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -501,6 +501,33 @@ describe('useSuspenseQuery', () => { ]); }); + it('does not suspend when data is in the cache and using a "cache-first" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { greeting: 'hello from cache' }, + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'cache-first' }), + { cache, mocks } + ); + + expect(screen.queryByText('loading')).not.toBeInTheDocument(); + expect(result.current).toEqual({ + data: { greeting: 'hello from cache' }, + variables: {}, + }); + + expect(renders.count).toBe(1); + expect(renders.frames).toEqual([ + { data: { greeting: 'hello from cache' }, variables: {} }, + ]); + }); + it('ensures data is fetched is the correct amount of times when using a "cache-first" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -669,6 +696,36 @@ describe('useSuspenseQuery', () => { ]); }); + it('suspends when data is in the cache and using a "network-only" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { greeting: 'hello from cache' }, + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'network-only' }), + { cache, mocks } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: {}, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.frames).toEqual([ + { data: { greeting: 'Hello' }, variables: {} }, + ]); + }); + it('ensures data is fetched is the correct amount of times when using a "network-only" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -837,6 +894,39 @@ describe('useSuspenseQuery', () => { ]); }); + it('suspends and does not overwrite cache when data is in the cache and using a "no-cache" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { greeting: 'hello from cache' }, + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), + { cache, mocks } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: {}, + }); + }); + + const cachedData = cache.readQuery({ query }); + + expect(renders.count).toBe(2); + expect(renders.frames).toEqual([ + { data: { greeting: 'Hello' }, variables: {} }, + ]); + expect(cachedData).toEqual({ greeting: 'hello from cache' }); + }); + it('ensures data is fetched is the correct amount of times when using a "no-cache" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -1006,6 +1096,38 @@ describe('useSuspenseQuery', () => { ]); }); + it('does not suspend when data is in the cache and using a "cache-and-network" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { greeting: 'hello from cache' }, + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'cache-and-network' }), + { cache, mocks } + ); + + expect(screen.queryByText('loading')).not.toBeInTheDocument(); + expect(result.current).toEqual({ + data: { greeting: 'hello from cache' }, + variables: {}, + }); + + await waitFor(() => { + expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + }); + + expect(renders.count).toBe(2); + expect(renders.frames).toEqual([ + { data: { greeting: 'hello from cache' }, variables: {} }, + { data: { greeting: 'Hello' }, variables: {} }, + ]); + }); + it('ensures data is fetched is the correct amount of times when using a "cache-and-network" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index b7f176d4434..6037618e1fc 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -79,6 +79,8 @@ export function useSuspenseQuery_experimental< resultRef.current = observable.getCurrentResult(); } + let cacheEntry = suspenseCache.getVariables(observable, variables); + const result = useSyncExternalStore( useCallback( (forceUpdate) => { @@ -90,7 +92,10 @@ export function useSuspenseQuery_experimental< // policy to `cache-only` to prevent the network request until the // subscription is created, then reset it back to its original. const originalFetchPolicy = opts.fetchPolicy; - observable.options.fetchPolicy = 'cache-only'; + + if (cacheEntry?.resolved) { + observable.options.fetchPolicy = 'cache-only'; + } const subscription = observable.subscribe(() => { const previousResult = resultRef.current!; @@ -121,20 +126,30 @@ export function useSuspenseQuery_experimental< () => resultRef.current! ); - if (result.loading) { - let cacheEntry = suspenseCache.getVariables(observable, variables); - if (!cacheEntry) { - const promise = observable.reobserve(opts); - cacheEntry = suspenseCache.setVariables( - observable, - opts.variables, - promise - ); - } - - if (!cacheEntry.resolved) { - throw cacheEntry.promise; + if (result.loading) { + switch (opts.fetchPolicy) { + case 'cache-and-network': { + if (!result.partial) { + break; + } + + // fallthrough when data is not in the cache + } + default: { + if (!cacheEntry) { + const promise = observable.reobserve(opts); + promise.then((data) => console.log('resolve', data)); + cacheEntry = suspenseCache.setVariables( + observable, + opts.variables, + promise + ); + } + if (!cacheEntry.resolved) { + throw cacheEntry.promise; + } + } } } From 524d2cb13e744d5a07382809084a7c0d666d014b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 11:56:07 -0700 Subject: [PATCH 058/159] Rename opts to watchQueryOptions and use better type --- src/react/hooks/useSuspenseQuery.ts | 46 ++++++++++++++--------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 6037618e1fc..5e18a867912 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -48,7 +48,7 @@ export function useSuspenseQuery_experimental< ): UseSuspenseQueryResult { const suspenseCache = useSuspenseCache(); const hasRunValidations = useRef(false); - const opts = useDeepMemo( + const watchQueryOptions: WatchQueryOptions = useDeepMemo( () => ({ ...options, query, @@ -57,23 +57,23 @@ export function useSuspenseQuery_experimental< }), [options, query] ); - const client = useApolloClient(opts.client); - const { variables } = opts; + const client = useApolloClient(options.client); + const { variables } = watchQueryOptions; if (!hasRunValidations.current) { - validateOptions(opts); + validateOptions(watchQueryOptions); hasRunValidations.current = true; } const [observable] = useState(() => { return ( suspenseCache.getQuery(query) || - suspenseCache.registerQuery(query, client.watchQuery(opts)) + suspenseCache.registerQuery(query, client.watchQuery(watchQueryOptions)) ); }); const resultRef = useRef>(); - const previousOptsRef = useRef(opts); + const previousOptsRef = useRef(watchQueryOptions); if (!resultRef.current) { resultRef.current = observable.getCurrentResult(); @@ -128,7 +128,7 @@ export function useSuspenseQuery_experimental< if (result.loading) { - switch (opts.fetchPolicy) { + switch (watchQueryOptions.fetchPolicy) { case 'cache-and-network': { if (!result.partial) { break; @@ -138,11 +138,11 @@ export function useSuspenseQuery_experimental< } default: { if (!cacheEntry) { - const promise = observable.reobserve(opts); + const promise = observable.reobserve(watchQueryOptions); promise.then((data) => console.log('resolve', data)); cacheEntry = suspenseCache.setVariables( observable, - opts.variables, + watchQueryOptions.variables, promise ); } @@ -155,15 +155,19 @@ export function useSuspenseQuery_experimental< useEffect(() => { if ( - opts.variables !== previousOptsRef.current?.variables || - opts.query !== previousOptsRef.current.query + watchQueryOptions.variables !== previousOptsRef.current?.variables || + watchQueryOptions.query !== previousOptsRef.current.query ) { - const promise = observable.reobserve(opts); - - suspenseCache.setVariables(observable, opts.variables, promise); - previousOptsRef.current = opts; + const promise = observable.reobserve(watchQueryOptions); + + suspenseCache.setVariables( + observable, + watchQueryOptions.variables, + promise + ); + previousOptsRef.current = watchQueryOptions; } - }, [opts.variables, opts.query]); + }, [watchQueryOptions.variables, watchQueryOptions.query]); return useMemo(() => { return { @@ -173,20 +177,14 @@ export function useSuspenseQuery_experimental< }, [result, observable]); } -type ValidateFunctionOptions = SuspenseQueryHookOptions & { - query: DocumentNode | TypedDocumentNode; -}; - -function validateOptions(options: ValidateFunctionOptions) { +function validateOptions(options: WatchQueryOptions) { const { query, fetchPolicy = DEFAULT_FETCH_POLICY } = options; verifyDocumentType(query, DocumentType.Query); validateFetchPolicy(fetchPolicy); } -function validateFetchPolicy( - fetchPolicy: SuspenseQueryHookOptions['fetchPolicy'] -) { +function validateFetchPolicy(fetchPolicy: WatchQueryFetchPolicy) { invariant( SUPPORTED_FETCH_POLICIES.includes(fetchPolicy), `The fetch policy \`${fetchPolicy}\` is not supported with suspense.` From e48f3e425a349428fc387e4530c16a7e2c742441 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 12:24:02 -0700 Subject: [PATCH 059/159] More robust test for checking overridden client --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index d67277836f9..0fe9d9554b4 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -384,7 +384,7 @@ describe('useSuspenseQuery', () => { const suspenseCache = new SuspenseCache(); - const { result } = renderSuspenseHook( + const { result, renders } = renderSuspenseHook( () => useSuspenseQuery(query, { client: localClient }), { wrapper: ({ children }) => ( @@ -395,11 +395,13 @@ describe('useSuspenseQuery', () => { } ); - // We don't subscribe to the observable until after the component has been - // unsuspended, so we need to wait for the result await waitFor(() => expect(result.current.data).toEqual({ greeting: 'local hello' }) ); + + expect(renders.frames).toEqual([ + { data: { greeting: 'local hello' }, variables: {} }, + ]); }); it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { From 1c1a4a82195ad203edb8f263aec6903e3c92f50b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 12:50:19 -0700 Subject: [PATCH 060/159] More robust way to check how many times a component suspended --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 0fe9d9554b4..f6812cfb562 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, Suspense } from 'react'; +import React, { Suspense } from 'react'; import { screen, renderHook, @@ -29,11 +29,11 @@ type RenderSuspenseHookOptions< > = RenderHookOptions & { link?: ApolloLink; cache?: ApolloCache; - suspenseFallback?: ReactNode; mocks?: MockedResponse[]; }; interface Renders { + suspenseCount: number; count: number; frames: Result[]; } @@ -42,20 +42,26 @@ function renderSuspenseHook( render: (initialProps: Props) => Result, options: RenderSuspenseHookOptions = Object.create(null) ) { + function SuspenseFallback() { + renders.suspenseCount++; + + return
loading
; + } + const { cache, link, mocks = [], - suspenseFallback = 'loading', wrapper = ({ children }) => ( - {children} + }>{children} ), ...renderHookOptions } = options; const renders: Renders = { + suspenseCount: 0, count: 0, frames: [], }; @@ -205,8 +211,6 @@ describe('useSuspenseQuery', () => { { mocks } ); - expect(screen.getByText('loading')).toBeInTheDocument(); - await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -214,6 +218,7 @@ describe('useSuspenseQuery', () => { }); }); + expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); }); @@ -225,8 +230,6 @@ describe('useSuspenseQuery', () => { { mocks } ); - expect(screen.getByText('loading')).toBeInTheDocument(); - await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -234,6 +237,7 @@ describe('useSuspenseQuery', () => { }); }); + expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); }); @@ -245,8 +249,6 @@ describe('useSuspenseQuery', () => { { mocks, initialProps: { id: '1' } } ); - expect(screen.getByText('loading')).toBeInTheDocument(); - await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -257,6 +259,7 @@ describe('useSuspenseQuery', () => { rerender({ id: '1' }); expect(renders.count).toBe(3); + expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ { ...mocks[0].result, variables: { id: '1' } }, { ...mocks[0].result, variables: { id: '1' } }, @@ -416,7 +419,7 @@ describe('useSuspenseQuery', () => { { mocks, initialProps: { id: '1' } } ); - expect(screen.getByText('loading')).toBeInTheDocument(); + expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -426,7 +429,6 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); - expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, @@ -441,6 +443,7 @@ describe('useSuspenseQuery', () => { // 4. Initiate refetch and suspend // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ { ...mocks[0].result, variables: { id: '1' } }, { ...mocks[0].result, variables: { id: '1' } }, @@ -477,14 +480,13 @@ describe('useSuspenseQuery', () => { { mocks, initialProps: { query: query1 as DocumentNode } } ); - expect(screen.getByText('loading')).toBeInTheDocument(); + expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); }); rerender({ query: query2 }); - expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); }); @@ -496,6 +498,7 @@ describe('useSuspenseQuery', () => { // 4. Initiate refetch and suspend // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ { ...mocks[0].result, variables: {} }, { ...mocks[0].result, variables: {} }, @@ -518,13 +521,13 @@ describe('useSuspenseQuery', () => { { cache, mocks } ); - expect(screen.queryByText('loading')).not.toBeInTheDocument(); expect(result.current).toEqual({ data: { greeting: 'hello from cache' }, variables: {}, }); expect(renders.count).toBe(1); + expect(renders.suspenseCount).toBe(0); expect(renders.frames).toEqual([ { data: { greeting: 'hello from cache' }, variables: {} }, ]); @@ -611,7 +614,7 @@ describe('useSuspenseQuery', () => { { mocks, initialProps: { id: '1' } } ); - expect(screen.getByText('loading')).toBeInTheDocument(); + expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -621,7 +624,6 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); - expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, @@ -636,6 +638,7 @@ describe('useSuspenseQuery', () => { // 4. Initiate refetch and suspend // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ { ...mocks[0].result, variables: { id: '1' } }, { ...mocks[0].result, variables: { id: '1' } }, @@ -672,14 +675,13 @@ describe('useSuspenseQuery', () => { { mocks, initialProps: { query: query1 as DocumentNode } } ); - expect(screen.getByText('loading')).toBeInTheDocument(); + expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); }); rerender({ query: query2 }); - expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); }); @@ -691,6 +693,7 @@ describe('useSuspenseQuery', () => { // 4. Initiate refetch and suspend // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ { ...mocks[0].result, variables: {} }, { ...mocks[0].result, variables: {} }, @@ -713,8 +716,6 @@ describe('useSuspenseQuery', () => { { cache, mocks } ); - expect(screen.getByText('loading')).toBeInTheDocument(); - await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -723,6 +724,7 @@ describe('useSuspenseQuery', () => { }); expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ { data: { greeting: 'Hello' }, variables: {} }, ]); @@ -809,7 +811,7 @@ describe('useSuspenseQuery', () => { { mocks, initialProps: { id: '1' } } ); - expect(screen.getByText('loading')).toBeInTheDocument(); + expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -819,7 +821,6 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); - expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, @@ -834,6 +835,7 @@ describe('useSuspenseQuery', () => { // 4. Initiate refetch and suspend // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ { ...mocks[0].result, variables: { id: '1' } }, { ...mocks[0].result, variables: { id: '1' } }, @@ -870,14 +872,13 @@ describe('useSuspenseQuery', () => { { mocks, initialProps: { query: query1 as DocumentNode } } ); - expect(screen.getByText('loading')).toBeInTheDocument(); + expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); }); rerender({ query: query2 }); - expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); }); @@ -889,6 +890,7 @@ describe('useSuspenseQuery', () => { // 4. Initiate refetch and suspend // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ { ...mocks[0].result, variables: {} }, { ...mocks[0].result, variables: {} }, @@ -911,8 +913,6 @@ describe('useSuspenseQuery', () => { { cache, mocks } ); - expect(screen.getByText('loading')).toBeInTheDocument(); - await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -923,6 +923,7 @@ describe('useSuspenseQuery', () => { const cachedData = cache.readQuery({ query }); expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ { data: { greeting: 'Hello' }, variables: {} }, ]); @@ -1010,7 +1011,7 @@ describe('useSuspenseQuery', () => { { mocks, initialProps: { id: '1' } } ); - expect(screen.getByText('loading')).toBeInTheDocument(); + expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, @@ -1020,7 +1021,6 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); - expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, @@ -1035,6 +1035,7 @@ describe('useSuspenseQuery', () => { // 4. Initiate refetch and suspend // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ { ...mocks[0].result, variables: { id: '1' } }, { ...mocks[0].result, variables: { id: '1' } }, @@ -1072,14 +1073,13 @@ describe('useSuspenseQuery', () => { { mocks, initialProps: { query: query1 as DocumentNode } } ); - expect(screen.getByText('loading')).toBeInTheDocument(); + expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); }); rerender({ query: query2 }); - expect(await screen.findByText('loading')).toBeInTheDocument(); await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); }); @@ -1091,6 +1091,7 @@ describe('useSuspenseQuery', () => { // 4. Initiate refetch and suspend // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ { ...mocks[0].result, variables: {} }, { ...mocks[0].result, variables: {} }, @@ -1113,7 +1114,6 @@ describe('useSuspenseQuery', () => { { cache, mocks } ); - expect(screen.queryByText('loading')).not.toBeInTheDocument(); expect(result.current).toEqual({ data: { greeting: 'hello from cache' }, variables: {}, @@ -1124,6 +1124,7 @@ describe('useSuspenseQuery', () => { }); expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(0); expect(renders.frames).toEqual([ { data: { greeting: 'hello from cache' }, variables: {} }, { data: { greeting: 'Hello' }, variables: {} }, From 274df39f31cdf4f5a6b68069bfa7501565f9537a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 12:53:19 -0700 Subject: [PATCH 061/159] Add ability to only suspend on initial fetch --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 176 ++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 21 ++- 2 files changed, 188 insertions(+), 9 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index f6812cfb562..6098e76c90d 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -451,6 +451,50 @@ describe('useSuspenseQuery', () => { ]); }); + it('returns previous data on refetch when changing variables and using a "cache-first" and an "initial" suspense policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'cache-first', + suspensePolicy: 'initial', + variables: { id }, + }), + { mocks, initialProps: { id: '1' } } + ); + + expect(renders.suspenseCount).toBe(1); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' }, + }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change variables + // 4. Unsuspend and return results from refetch + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[1].result, variables: { id: '2' } }, + ]); + }); + it('re-suspends the component when changing queries and using a "cache-first" fetch policy', async () => { const query1: TypedDocumentNode<{ hello: string }> = gql` query Query1 { @@ -646,6 +690,50 @@ describe('useSuspenseQuery', () => { ]); }); + it('returns previous data on refetch when changing variables and using a "network-only" and an "initial" suspense policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'network-only', + suspensePolicy: 'initial', + variables: { id }, + }), + { mocks, initialProps: { id: '1' } } + ); + + expect(renders.suspenseCount).toBe(1); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' }, + }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change variables + // 4. Unsuspend and return results from refetch + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[1].result, variables: { id: '2' } }, + ]); + }); + it('re-suspends the component when changing queries and using a "network-only" fetch policy', async () => { const query1: TypedDocumentNode<{ hello: string }> = gql` query Query1 { @@ -843,6 +931,50 @@ describe('useSuspenseQuery', () => { ]); }); + it('returns previous data on refetch when changing variables and using a "no-cache" and an "initial" suspense policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'no-cache', + suspensePolicy: 'initial', + variables: { id }, + }), + { mocks, initialProps: { id: '1' } } + ); + + expect(renders.suspenseCount).toBe(1); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' }, + }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change variables + // 4. Unsuspend and return results from refetch + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[1].result, variables: { id: '2' } }, + ]); + }); + it('re-suspends the component when changing queries and using a "no-cache" fetch policy', async () => { const query1: TypedDocumentNode<{ hello: string }> = gql` query Query1 { @@ -1043,6 +1175,50 @@ describe('useSuspenseQuery', () => { ]); }); + it('returns previous data on refetch when changing variables and using a "cache-and-network" and an "initial" suspense policy', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'cache-and-network', + suspensePolicy: 'initial', + variables: { id }, + }), + { mocks, initialProps: { id: '1' } } + ); + + expect(renders.suspenseCount).toBe(1); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' }, + }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change variables + // 4. Unsuspend and return results from refetch + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[1].result, variables: { id: '2' } }, + ]); + }); + it('re-suspends the component when changing queries and using a "cache-and-network" fetch policy', async () => { const query1: TypedDocumentNode<{ hello: string }> = gql` query Query1 { diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 5e18a867912..c1a1fcc7cd2 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -48,15 +48,18 @@ export function useSuspenseQuery_experimental< ): UseSuspenseQueryResult { const suspenseCache = useSuspenseCache(); const hasRunValidations = useRef(false); - const watchQueryOptions: WatchQueryOptions = useDeepMemo( - () => ({ - ...options, - query, - fetchPolicy: options.fetchPolicy || DEFAULT_FETCH_POLICY, - notifyOnNetworkStatusChange: true, - }), - [options, query] - ); + const watchQueryOptions: WatchQueryOptions = + useDeepMemo(() => { + const { suspensePolicy = DEFAULT_SUSPENSE_POLICY, ...watchQueryOptions } = + options; + + return { + ...watchQueryOptions, + query, + fetchPolicy: options.fetchPolicy || DEFAULT_FETCH_POLICY, + notifyOnNetworkStatusChange: suspensePolicy === 'always', + }; + }, [options, query]); const client = useApolloClient(options.client); const { variables } = watchQueryOptions; From 047448d064a0afd4a7c84338adb6b24b6ac10064 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 13:00:09 -0700 Subject: [PATCH 062/159] Add missing imports and missed rename --- src/react/hooks/useSuspenseQuery.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index c1a1fcc7cd2..c0142de0831 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -12,6 +12,8 @@ import { DocumentNode, OperationVariables, TypedDocumentNode, + WatchQueryOptions, + WatchQueryFetchPolicy, } from '../../core'; import { invariant } from '../../utilities/globals'; import { useApolloClient } from './useApolloClient'; @@ -20,8 +22,6 @@ import { SuspenseQueryHookOptions } from '../types/types'; import { useSuspenseCache } from './useSuspenseCache'; import { useSyncExternalStore } from './useSyncExternalStore'; -type FetchPolicy = SuspenseQueryHookOptions['fetchPolicy']; - export interface UseSuspenseQueryResult< TData = any, TVariables = OperationVariables @@ -30,14 +30,15 @@ export interface UseSuspenseQueryResult< variables: TVariables; } -const SUPPORTED_FETCH_POLICIES: FetchPolicy[] = [ +const SUPPORTED_FETCH_POLICIES: WatchQueryFetchPolicy[] = [ 'cache-first', 'network-only', 'no-cache', 'cache-and-network', ]; -const DEFAULT_FETCH_POLICY: FetchPolicy = 'cache-first'; +const DEFAULT_FETCH_POLICY = 'cache-first'; +const DEFAULT_SUSPENSE_POLICY = 'always'; export function useSuspenseQuery_experimental< TData = any, @@ -94,7 +95,7 @@ export function useSuspenseQuery_experimental< // that always fetch (e.g. 'network-only'). Instead, we set the cache // policy to `cache-only` to prevent the network request until the // subscription is created, then reset it back to its original. - const originalFetchPolicy = opts.fetchPolicy; + const originalFetchPolicy = watchQueryOptions.fetchPolicy; if (cacheEntry?.resolved) { observable.options.fetchPolicy = 'cache-only'; From 11e9f7dddd4335d153da6b3fe4c0b5189a0e4f94 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 15:12:59 -0700 Subject: [PATCH 063/159] Add test to verify no-cache maintains result on rerender --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 6098e76c90d..1d67a86738e 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1062,6 +1062,40 @@ describe('useSuspenseQuery', () => { expect(cachedData).toEqual({ greeting: 'hello from cache' }); }); + it('maintains results when rerendering a query using a "no-cache" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + const { result, rerender, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), + { cache, mocks } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + variables: {}, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { data: { greeting: 'Hello' }, variables: {} }, + ]); + + rerender(); + + expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + expect(renders.count).toBe(3); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { data: { greeting: 'Hello' }, variables: {} }, + { data: { greeting: 'Hello' }, variables: {} }, + ]); + }); + it('ensures data is fetched is the correct amount of times when using a "no-cache" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); From df6d041bf90b7a2bad693f1100d5cd87d21be366 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 15:46:28 -0700 Subject: [PATCH 064/159] Use fetch policy from client default options if available --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 51 ++++++++++++++++--- src/react/hooks/useSuspenseQuery.ts | 9 ++-- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 1d67a86738e..0e6cdd31474 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -18,7 +18,7 @@ import { Observable, TypedDocumentNode, } from '../../../core'; -import { MockedProvider, MockedResponse } from '../../../testing'; +import { MockedProvider, MockedResponse, MockLink } from '../../../testing'; import { ApolloProvider } from '../../context'; import { SuspenseCache } from '../../cache'; import { useSuspenseQuery_experimental as useSuspenseQuery } from '../useSuspenseQuery'; @@ -27,6 +27,7 @@ type RenderSuspenseHookOptions< Props, TSerializedCache = {} > = RenderHookOptions & { + client?: ApolloClient; link?: ApolloLink; cache?: ApolloCache; mocks?: MockedResponse[]; @@ -50,13 +51,20 @@ function renderSuspenseHook( const { cache, + client, link, mocks = [], - wrapper = ({ children }) => ( - - }>{children} - - ), + wrapper = ({ children }) => { + return client ? ( + + }>{children} + + ) : ( + + }>{children} + + ); + }, ...renderHookOptions } = options; @@ -1409,4 +1417,35 @@ describe('useSuspenseQuery', () => { expect(cachedData).toEqual(mocks[0].result.data); }); + + it('uses the default fetch policy from the client when none provided in options', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + const client = new ApolloClient({ + cache, + link: new MockLink(mocks), + defaultOptions: { + watchQuery: { + fetchPolicy: 'network-only', + }, + }, + }); + + cache.writeQuery({ query, data: { greeting: 'hello from cache' } }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query), + { client } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([{ ...mocks[0].result, variables: {} }]); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index c0142de0831..72ad6b188a7 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -49,6 +49,7 @@ export function useSuspenseQuery_experimental< ): UseSuspenseQueryResult { const suspenseCache = useSuspenseCache(); const hasRunValidations = useRef(false); + const client = useApolloClient(options.client); const watchQueryOptions: WatchQueryOptions = useDeepMemo(() => { const { suspensePolicy = DEFAULT_SUSPENSE_POLICY, ...watchQueryOptions } = @@ -57,11 +58,13 @@ export function useSuspenseQuery_experimental< return { ...watchQueryOptions, query, - fetchPolicy: options.fetchPolicy || DEFAULT_FETCH_POLICY, + fetchPolicy: + options.fetchPolicy || + client.defaultOptions.watchQuery?.fetchPolicy || + DEFAULT_FETCH_POLICY, notifyOnNetworkStatusChange: suspensePolicy === 'always', }; - }, [options, query]); - const client = useApolloClient(options.client); + }, [options, query, client]); const { variables } = watchQueryOptions; if (!hasRunValidations.current) { From 65169f629f137dd469bd38ae07de17d82ae722a6 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 15:47:29 -0700 Subject: [PATCH 065/159] Ensure SuspensePolicy type is exported from types --- src/react/types/types.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 3bbe0744aa9..2f1e66f817b 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -92,6 +92,15 @@ export interface LazyQueryHookOptions< TVariables = OperationVariables > extends Omit, 'skip'> {} +/** + * suspensePolicy determines how suspense behaves for a refetch. The options are: + * - always (default): Re-suspend a component when a refetch occurs + * - initial: Only suspend on the first fetch + */ +export type SuspensePolicy = + | 'always' + | 'initial' + export interface SuspenseQueryHookOptions< TData = any, TVariables = OperationVariables @@ -106,6 +115,7 @@ export interface SuspenseQueryHookOptions< | 'no-cache' | 'cache-and-network' >; + suspensePolicy?: SuspensePolicy; } /** From 0671b79fc2a03725172af99f65a546be062617b3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 16:14:04 -0700 Subject: [PATCH 066/159] Add ability to use default variables from client --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 30 +++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 19 ++++++++---- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 0e6cdd31474..8089a169c6f 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1448,4 +1448,34 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([{ ...mocks[0].result, variables: {} }]); }); + + it('uses default variables from the client when none provided in options', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + defaultOptions: { + watchQuery: { + variables: { id: '2' }, + }, + }, + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query), + { client } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + variables: { id: '2' }, + }); + }); + + expect(renders.frames).toEqual([ + { ...mocks[1].result, variables: { id: '2' } }, + ]); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 72ad6b188a7..6772ff6f7f9 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -52,17 +52,26 @@ export function useSuspenseQuery_experimental< const client = useApolloClient(options.client); const watchQueryOptions: WatchQueryOptions = useDeepMemo(() => { - const { suspensePolicy = DEFAULT_SUSPENSE_POLICY, ...watchQueryOptions } = - options; + const { + fetchPolicy, + suspensePolicy = DEFAULT_SUSPENSE_POLICY, + variables, + ...watchQueryOptions + } = options; + + const { + watchQuery: defaultOptions = Object.create( + null + ) as Partial, + } = client.defaultOptions; return { ...watchQueryOptions, query, fetchPolicy: - options.fetchPolicy || - client.defaultOptions.watchQuery?.fetchPolicy || - DEFAULT_FETCH_POLICY, + fetchPolicy || defaultOptions.fetchPolicy || DEFAULT_FETCH_POLICY, notifyOnNetworkStatusChange: suspensePolicy === 'always', + variables: variables || defaultOptions.variables, }; }, [options, query, client]); const { variables } = watchQueryOptions; From d68f226336cd094393bcfa6d352b927145610228 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 16:15:01 -0700 Subject: [PATCH 067/159] Remove unnecessary console.log --- src/react/hooks/useSuspenseQuery.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 6772ff6f7f9..e2966bb448b 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -155,7 +155,6 @@ export function useSuspenseQuery_experimental< default: { if (!cacheEntry) { const promise = observable.reobserve(watchQueryOptions); - promise.then((data) => console.log('resolve', data)); cacheEntry = suspenseCache.setVariables( observable, watchQueryOptions.variables, From 06753a332982a2f47f7b4d5a72065b71a4209f14 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 16:19:52 -0700 Subject: [PATCH 068/159] Simplify test that uses custom wrapper --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 8089a169c6f..3c7d8aebd49 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -393,17 +393,9 @@ describe('useSuspenseQuery', () => { cache: new InMemoryCache(), }); - const suspenseCache = new SuspenseCache(); - const { result, renders } = renderSuspenseHook( () => useSuspenseQuery(query, { client: localClient }), - { - wrapper: ({ children }) => ( - - {children} - - ), - } + { client: globalClient } ); await waitFor(() => From 802601951ab23aacb01477c8d28024733fc27b8d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 16:44:24 -0700 Subject: [PATCH 069/159] Add test and implementation to merge global and local variables --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 81 +++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 4 +- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 3c7d8aebd49..3abf9fd5d91 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1470,4 +1470,85 @@ describe('useSuspenseQuery', () => { { ...mocks[1].result, variables: { id: '2' } }, ]); }); + + it('merges global default variables with local variables', async () => { + const query = gql` + query MergedVariablesQuery { + vars + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink((operation) => { + return new Observable((observer) => { + observer.next({ data: { vars: operation.variables } }); + observer.complete(); + }); + }), + defaultOptions: { + watchQuery: { + variables: { source: 'global', globalOnlyVar: true }, + }, + }, + }); + + const { result, rerender, renders } = renderSuspenseHook( + ({ source }) => + useSuspenseQuery(query, { + fetchPolicy: 'network-only', + variables: { source, localOnlyVar: true }, + }), + { client, initialProps: { source: 'local' } } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + data: { + vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, + }, + variables: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, + }); + }); + + rerender({ source: 'rerender' }); + + await waitFor(() => { + expect(result.current).toEqual({ + data: { + vars: { source: 'rerender', globalOnlyVar: true, localOnlyVar: true }, + }, + variables: { + source: 'rerender', + globalOnlyVar: true, + localOnlyVar: true, + }, + }); + }); + + expect(renders.frames).toEqual([ + { + data: { + vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, + }, + variables: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, + }, + { + data: { + vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, + }, + variables: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, + }, + { + data: { + vars: { source: 'rerender', globalOnlyVar: true, localOnlyVar: true }, + }, + variables: { + source: 'rerender', + globalOnlyVar: true, + localOnlyVar: true, + }, + }, + ]); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index e2966bb448b..977f82dafae 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -71,9 +71,9 @@ export function useSuspenseQuery_experimental< fetchPolicy: fetchPolicy || defaultOptions.fetchPolicy || DEFAULT_FETCH_POLICY, notifyOnNetworkStatusChange: suspensePolicy === 'always', - variables: variables || defaultOptions.variables, + variables: { ...defaultOptions.variables, ...variables }, }; - }, [options, query, client]); + }, [options, query, client.defaultOptions.watchQuery]); const { variables } = watchQueryOptions; if (!hasRunValidations.current) { From d21bac5d89eb69627e4818c6c82a589e049cfe52 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 11 Nov 2022 17:03:22 -0700 Subject: [PATCH 070/159] Add implementation to make sure variables can be removed --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 51 +++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 3 +- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 3abf9fd5d91..25faa1837a8 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1551,4 +1551,55 @@ describe('useSuspenseQuery', () => { }, ]); }); + + it('can unset a globally defined variable', async () => { + const query = gql` + query MergedVariablesQuery { + vars + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink((operation) => { + return new Observable((observer) => { + observer.next({ data: { vars: operation.variables } }); + observer.complete(); + }); + }), + defaultOptions: { + watchQuery: { + variables: { source: 'global', globalOnlyVar: true }, + }, + }, + }); + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(query, { + variables: { source: 'local', globalOnlyVar: undefined }, + }), + { client } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + data: { vars: { source: 'local' } }, + variables: { source: 'local' }, + }); + }); + + // Check to make sure the property itself is not defined, not just set to + // undefined. Unfortunately this is not caught by toEqual as toEqual only + // checks if the values are equal, not if they have the same keys + expect(result.current.variables).not.toHaveProperty('globalOnlyVar'); + expect(result.current.data.vars).not.toHaveProperty('globalOnlyVar'); + + expect(renders.frames).toEqual([ + { + data: { vars: { source: 'local' } }, + variables: { source: 'local' }, + }, + ]); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 977f82dafae..f828842fd1d 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -16,6 +16,7 @@ import { WatchQueryFetchPolicy, } from '../../core'; import { invariant } from '../../utilities/globals'; +import { compact } from '../../utilities'; import { useApolloClient } from './useApolloClient'; import { DocumentType, verifyDocumentType } from '../parser'; import { SuspenseQueryHookOptions } from '../types/types'; @@ -71,7 +72,7 @@ export function useSuspenseQuery_experimental< fetchPolicy: fetchPolicy || defaultOptions.fetchPolicy || DEFAULT_FETCH_POLICY, notifyOnNetworkStatusChange: suspensePolicy === 'always', - variables: { ...defaultOptions.variables, ...variables }, + variables: compact({ ...defaultOptions.variables, ...variables }), }; }, [options, query, client.defaultOptions.watchQuery]); const { variables } = watchQueryOptions; From 18b853859af9a7bb7441ba6ee97143dee3ef75e2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 14 Nov 2022 11:01:02 -0700 Subject: [PATCH 071/159] Add check to ensure useSuspenseQuery suspends immediately --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 25faa1837a8..71813bd66ac 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -219,6 +219,8 @@ describe('useSuspenseQuery', () => { { mocks } ); + // ensure the hook suspends immediately + expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, From 04a900858a8c7aed5f6638b92144cebaf5f9f239 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 14 Nov 2022 11:02:46 -0700 Subject: [PATCH 072/159] Add check to ensure suspense cache is defined --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 71813bd66ac..7c143c846d5 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -328,6 +328,7 @@ describe('useSuspenseQuery', () => { ); expect(client.getObservableQueries().size).toBe(1); + expect(suspenseCache.getQuery(query)).toBeDefined(); unmount(); @@ -371,11 +372,12 @@ describe('useSuspenseQuery', () => { // Because they are the same query, the 2 components use the same observable // in the suspense cache expect(client.getObservableQueries().size).toBe(1); + expect(suspenseCache.getQuery(query)).toBeDefined(); unmount(); expect(client.getObservableQueries().size).toBe(1); - expect(suspenseCache.getQuery(query)).not.toBeUndefined(); + expect(suspenseCache.getQuery(query)).toBeDefined(); }); it('allows the client to be overridden', async () => { From bd0d13727e5b38010baf948b800a59375e60c1c7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 14 Nov 2022 11:05:36 -0700 Subject: [PATCH 073/159] Minor tweak to test name --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 7c143c846d5..d5489ef8dca 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -455,7 +455,7 @@ describe('useSuspenseQuery', () => { ]); }); - it('returns previous data on refetch when changing variables and using a "cache-first" and an "initial" suspense policy', async () => { + it('returns previous data on refetch when changing variables and using a "cache-first" with an "initial" suspense policy', async () => { const { query, mocks } = useVariablesQueryCase(); const { result, rerender, renders } = renderSuspenseHook( @@ -694,7 +694,7 @@ describe('useSuspenseQuery', () => { ]); }); - it('returns previous data on refetch when changing variables and using a "network-only" and an "initial" suspense policy', async () => { + it('returns previous data on refetch when changing variables and using a "network-only" with an "initial" suspense policy', async () => { const { query, mocks } = useVariablesQueryCase(); const { result, rerender, renders } = renderSuspenseHook( @@ -935,7 +935,7 @@ describe('useSuspenseQuery', () => { ]); }); - it('returns previous data on refetch when changing variables and using a "no-cache" and an "initial" suspense policy', async () => { + it('returns previous data on refetch when changing variables and using a "no-cache" with an "initial" suspense policy', async () => { const { query, mocks } = useVariablesQueryCase(); const { result, rerender, renders } = renderSuspenseHook( @@ -1213,7 +1213,7 @@ describe('useSuspenseQuery', () => { ]); }); - it('returns previous data on refetch when changing variables and using a "cache-and-network" and an "initial" suspense policy', async () => { + it('returns previous data on refetch when changing variables and using a "cache-and-network" with an "initial" suspense policy', async () => { const { query, mocks } = useVariablesQueryCase(); const { result, rerender, renders } = renderSuspenseHook( From 9048d21a70e1e44f77999d75b9d99ea56a4328ae Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 14 Nov 2022 12:18:53 -0700 Subject: [PATCH 074/159] Add tests to ensure useSuspenseQuery responds to cache updates --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index d5489ef8dca..0d7557fb6f4 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -142,6 +142,10 @@ function useVariablesQueryCase() { return { query, mocks }; } +function wait(delay: number) { + return new Promise((resolve) => setTimeout(resolve, delay)); +} + describe('useSuspenseQuery', () => { it('validates the GraphQL query as a query', () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); @@ -650,6 +654,39 @@ describe('useSuspenseQuery', () => { expect(cachedData).toEqual(mocks[0].result.data); }); + it('responds to cache updates when using a "cache-first" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'cache-first' }), + { cache, mocks } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + cache.writeQuery({ + query, + data: { greeting: 'Updated hello' }, + }); + + await wait(10); + + expect(result.current).toEqual({ + data: { greeting: 'Updated hello' }, + variables: {}, + }); + expect(renders.suspenseCount).toBe(1); + expect(renders.count).toBe(3); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: {} }, + { data: { greeting: 'Updated hello' }, variables: {} }, + ]); + }); + it('re-suspends the component when changing variables and using a "network-only" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -891,6 +928,39 @@ describe('useSuspenseQuery', () => { expect(cachedData).toEqual(mocks[0].result.data); }); + it('responds to cache updates when using a "network-only" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'network-only' }), + { cache, mocks } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + cache.writeQuery({ + query, + data: { greeting: 'Updated hello' }, + }); + + await wait(10); + + expect(result.current).toEqual({ + data: { greeting: 'Updated hello' }, + variables: {}, + }); + expect(renders.suspenseCount).toBe(1); + expect(renders.count).toBe(3); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: {} }, + { data: { greeting: 'Updated hello' }, variables: {} }, + ]); + }); + it('re-suspends the component when changing variables and using a "no-cache" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -1169,6 +1239,36 @@ describe('useSuspenseQuery', () => { expect(cachedData).toBeNull(); }); + it('does not respond to cache updates when using a "no-cache" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), + { cache, mocks } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + cache.writeQuery({ + query, + data: { greeting: 'Updated hello' }, + }); + + await wait(10); + + expect(result.current).toEqual({ + ...mocks[0].result, + variables: {}, + }); + expect(renders.suspenseCount).toBe(1); + expect(renders.count).toBe(2); + expect(renders.frames).toEqual([{ ...mocks[0].result, variables: {} }]); + }); + it('re-suspends the component when changing variables and using a "cache-and-network" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -1414,6 +1514,39 @@ describe('useSuspenseQuery', () => { expect(cachedData).toEqual(mocks[0].result.data); }); + it('responds to cache updates when using a "cache-and-network" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'cache-and-network' }), + { cache, mocks } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + cache.writeQuery({ + query, + data: { greeting: 'Updated hello' }, + }); + + await wait(10); + + expect(result.current).toEqual({ + data: { greeting: 'Updated hello' }, + variables: {}, + }); + expect(renders.suspenseCount).toBe(1); + expect(renders.count).toBe(3); + expect(renders.frames).toEqual([ + { ...mocks[0].result, variables: {} }, + { data: { greeting: 'Updated hello' }, variables: {} }, + ]); + }); + it('uses the default fetch policy from the client when none provided in options', async () => { const { query, mocks } = useSimpleQueryCase(); From b6ffe3f5731f2b16f53c5c8130eb6fba04568524 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 14 Nov 2022 13:45:34 -0700 Subject: [PATCH 075/159] Add test to ensure context is pased to link chain --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 0d7557fb6f4..a191398a850 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1739,4 +1739,39 @@ describe('useSuspenseQuery', () => { }, ]); }); + + it('passes context to the link', async () => { + const query = gql` + query ContextQuery { + context + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink((operation) => { + return new Observable((observer) => { + const { valueA, valueB } = operation.getContext(); + + observer.next({ data: { context: { valueA, valueB } } }); + observer.complete(); + }); + }), + }); + + const { result } = renderSuspenseHook( + () => + useSuspenseQuery(query, { + context: { valueA: 'A', valueB: 'B' }, + }), + { client } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + data: { context: { valueA: 'A', valueB: 'B' } }, + variables: {}, + }); + }); + }); }); From 0ad0f8f4c85c41c9c88414d3d47a4d8ad434c171 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 14 Nov 2022 14:44:45 -0700 Subject: [PATCH 076/159] Throw errors when the result is an error --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 68 ++++++++++++++++++- src/react/hooks/useSuspenseQuery.ts | 4 ++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index a191398a850..356db82e3d8 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -5,6 +5,7 @@ import { waitFor, RenderHookOptions, } from '@testing-library/react'; +import { ErrorBoundary, ErrorBoundaryProps } from 'react-error-boundary'; import { InvariantError } from 'ts-invariant'; import { equal } from '@wry/equality'; @@ -22,6 +23,7 @@ import { MockedProvider, MockedResponse, MockLink } from '../../../testing'; import { ApolloProvider } from '../../context'; import { SuspenseCache } from '../../cache'; import { useSuspenseQuery_experimental as useSuspenseQuery } from '../useSuspenseQuery'; +import { GraphQLError } from 'graphql'; type RenderSuspenseHookOptions< Props, @@ -34,6 +36,7 @@ type RenderSuspenseHookOptions< }; interface Renders { + errorCount: number; suspenseCount: number; count: number; frames: Result[]; @@ -49,6 +52,14 @@ function renderSuspenseHook( return
loading
; } + const errorBoundaryProps: ErrorBoundaryProps = { + fallbackRender: () => { + renders.errorCount++; + + return
Error
; + }, + }; + const { cache, client, @@ -57,11 +68,15 @@ function renderSuspenseHook( wrapper = ({ children }) => { return client ? ( - }>{children} + + }>{children} + ) : ( - }>{children} + + }>{children} + ); }, @@ -69,6 +84,7 @@ function renderSuspenseHook( } = options; const renders: Renders = { + errorCount: 0, suspenseCount: 0, count: 0, frames: [], @@ -111,6 +127,35 @@ function useSimpleQueryCase() { return { query, mocks }; } +function useErrorCase( + { + error = new GraphQLError('error'), + errors, + }: { + error?: Error; + errors?: GraphQLError[]; + } = Object.create(null) +) { + const query = gql` + query WillThrow { + notUsed + } + `; + + const errorResult = Array.isArray(errors) + ? { result: { errors } } + : { error }; + + const mocks = [ + { + request: { query }, + ...errorResult, + }, + ]; + + return { query, mocks }; +} + function useVariablesQueryCase() { const CHARACTERS = ['Spider-Man', 'Black Widow', 'Iron Man', 'Hulk']; @@ -1774,4 +1819,23 @@ describe('useSuspenseQuery', () => { }); }); }); + + it('throws errors by default', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { query, mocks } = useErrorCase({ + error: new GraphQLError('test error'), + }); + + const { renders } = renderSuspenseHook(() => useSuspenseQuery(query), { + mocks, + }); + + await waitFor(() => expect(renders.errorCount).toBe(1)); + + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([]); + + consoleSpy.mockRestore(); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index f828842fd1d..021f7bbd847 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -169,6 +169,10 @@ export function useSuspenseQuery_experimental< } } + if (result.error) { + throw result.error; + } + useEffect(() => { if ( watchQueryOptions.variables !== previousOptsRef.current?.variables || From 3ca8a6ed819f8e61658390e041c98d283819cf71 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 14 Nov 2022 15:17:49 -0700 Subject: [PATCH 077/159] Add react-error-boundary for tests --- package-lock.json | 13 +++++++------ package.json | 1 + 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14cf95a0d3c..bce3ea8d133 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "react-17": "npm:react@^17", "react-dom": "18.2.0", "react-dom-17": "npm:react-dom@^17", + "react-error-boundary": "^3.1.4", "recast": "0.21.5", "resolve": "1.22.1", "rimraf": "3.0.2", @@ -7115,9 +7116,9 @@ } }, "node_modules/react-error-boundary": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.3.tgz", - "integrity": "sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", "dev": true, "dependencies": { "@babel/runtime": "^7.12.5" @@ -14326,9 +14327,9 @@ } }, "react-error-boundary": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.3.tgz", - "integrity": "sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", "dev": true, "requires": { "@babel/runtime": "^7.12.5" diff --git a/package.json b/package.json index e45efbbbbe9..9e8ed4c6e94 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "react-17": "npm:react@^17", "react-dom": "18.2.0", "react-dom-17": "npm:react-dom@^17", + "react-error-boundary": "^3.1.4", "recast": "0.21.5", "resolve": "1.22.1", "rimraf": "3.0.2", From 48834a853468dd4ba1718cdeb0d0973c1af86998 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 14 Nov 2022 15:36:53 -0700 Subject: [PATCH 078/159] Add support for ignore and none error policies --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 51 +++++++++++++++++-- src/react/hooks/useSuspenseQuery.ts | 20 +++++++- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 356db82e3d8..dbf3b3de3bf 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -53,11 +53,8 @@ function renderSuspenseHook( } const errorBoundaryProps: ErrorBoundaryProps = { - fallbackRender: () => { - renders.errorCount++; - - return
Error
; - }, + fallback:
Error
, + onError: () => renders.errorCount++, }; const { @@ -1838,4 +1835,48 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); + + it('throws when errorPolicy is set to "none"', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { query, mocks } = useErrorCase({ + error: new GraphQLError('test error'), + }); + + const { renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'none' }), + { mocks } + ); + + await waitFor(() => expect(renders.errorCount).toBe(1)); + + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([]); + + consoleSpy.mockRestore(); + }); + + it('does not throw when errorPolicy is set to "ignore"', async () => { + const { query, mocks } = useErrorCase({ + error: new GraphQLError('test error'), + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'ignore' }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + data: undefined, + error: undefined, + variables: {}, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([{ data: undefined, variables: {} }]); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 021f7bbd847..dbd5befd0e9 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -8,8 +8,10 @@ import { } from 'react'; import { equal } from '@wry/equality'; import { + ApolloError, ApolloQueryResult, DocumentNode, + NetworkStatus, OperationVariables, TypedDocumentNode, WatchQueryOptions, @@ -28,6 +30,7 @@ export interface UseSuspenseQueryResult< TVariables = OperationVariables > { data: TData; + error: ApolloError | undefined; variables: TVariables; } @@ -40,6 +43,7 @@ const SUPPORTED_FETCH_POLICIES: WatchQueryFetchPolicy[] = [ const DEFAULT_FETCH_POLICY = 'cache-first'; const DEFAULT_SUSPENSE_POLICY = 'always'; +const DEFAULT_ERROR_POLICY = 'none'; export function useSuspenseQuery_experimental< TData = any, @@ -54,6 +58,7 @@ export function useSuspenseQuery_experimental< const watchQueryOptions: WatchQueryOptions = useDeepMemo(() => { const { + errorPolicy, fetchPolicy, suspensePolicy = DEFAULT_SUSPENSE_POLICY, variables, @@ -69,6 +74,8 @@ export function useSuspenseQuery_experimental< return { ...watchQueryOptions, query, + errorPolicy: + errorPolicy || defaultOptions.errorPolicy || DEFAULT_ERROR_POLICY, fetchPolicy: fetchPolicy || defaultOptions.fetchPolicy || DEFAULT_FETCH_POLICY, notifyOnNetworkStatusChange: suspensePolicy === 'always', @@ -143,6 +150,16 @@ export function useSuspenseQuery_experimental< () => resultRef.current! ); + // Sometimes the observable reports a network status of error even + // when our error policy is set to ignore. This patches the network status + // to avoid a rerender when the observable first subscribes and gets back a + // ready network status. + if ( + result.networkStatus === NetworkStatus.error && + watchQueryOptions.errorPolicy === 'ignore' + ) { + result.networkStatus = NetworkStatus.ready; + } if (result.loading) { switch (watchQueryOptions.fetchPolicy) { @@ -169,7 +186,7 @@ export function useSuspenseQuery_experimental< } } - if (result.error) { + if (result.error && watchQueryOptions.errorPolicy === 'none') { throw result.error; } @@ -192,6 +209,7 @@ export function useSuspenseQuery_experimental< return useMemo(() => { return { data: result.data, + error: observable.options.errorPolicy === 'all' ? result.error : void 0, variables: observable.variables as TVariables, }; }, [result, observable]); From f57d89963f6d8bfda38f4f78c7fb599f6244dad7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 14 Nov 2022 18:15:28 -0700 Subject: [PATCH 079/159] Better test setup to distinguish between network errors and graphql errors --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 80 ++++++++++++------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index dbf3b3de3bf..7963d5a0b07 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -6,6 +6,7 @@ import { RenderHookOptions, } from '@testing-library/react'; import { ErrorBoundary, ErrorBoundaryProps } from 'react-error-boundary'; +import { GraphQLError } from 'graphql'; import { InvariantError } from 'ts-invariant'; import { equal } from '@wry/equality'; @@ -13,17 +14,18 @@ import { gql, ApolloCache, ApolloClient, + ApolloError, ApolloLink, DocumentNode, InMemoryCache, Observable, TypedDocumentNode, } from '../../../core'; +import { compact } from '../../../utilities'; import { MockedProvider, MockedResponse, MockLink } from '../../../testing'; import { ApolloProvider } from '../../context'; import { SuspenseCache } from '../../cache'; import { useSuspenseQuery_experimental as useSuspenseQuery } from '../useSuspenseQuery'; -import { GraphQLError } from 'graphql'; type RenderSuspenseHookOptions< Props, @@ -36,6 +38,7 @@ type RenderSuspenseHookOptions< }; interface Renders { + errors: Error[]; errorCount: number; suspenseCount: number; count: number; @@ -54,7 +57,10 @@ function renderSuspenseHook( const errorBoundaryProps: ErrorBoundaryProps = { fallback:
Error
, - onError: () => renders.errorCount++, + onError: (error) => { + renders.errorCount++; + renders.errors.push(error); + }, }; const { @@ -81,6 +87,7 @@ function renderSuspenseHook( } = options; const renders: Renders = { + errors: [], errorCount: 0, suspenseCount: 0, count: 0, @@ -124,33 +131,37 @@ function useSimpleQueryCase() { return { query, mocks }; } -function useErrorCase( +interface ErrorCaseData { + currentUser: { + id: string; + name: string | null; + }; +} + +function useErrorCase( { - error = new GraphQLError('error'), - errors, + data, + networkError, + graphQLErrors, }: { - error?: Error; - errors?: GraphQLError[]; + data?: TData; + networkError?: Error; + graphQLErrors?: GraphQLError[]; } = Object.create(null) ) { - const query = gql` - query WillThrow { - notUsed + const query: TypedDocumentNode = gql` + query MyQuery { + greeting } `; - const errorResult = Array.isArray(errors) - ? { result: { errors } } - : { error }; - - const mocks = [ - { - request: { query }, - ...errorResult, - }, - ]; + const mock: MockedResponse = compact({ + request: { query }, + result: (data || graphQLErrors) && compact({ data, errors: graphQLErrors }), + error: networkError, + }); - return { query, mocks }; + return { query, mocks: [mock] }; } function useVariablesQueryCase() { @@ -1817,11 +1828,11 @@ describe('useSuspenseQuery', () => { }); }); - it('throws errors by default', async () => { + it('throws network errors by default', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const { query, mocks } = useErrorCase({ - error: new GraphQLError('test error'), + networkError: new Error('Could not fetch'), }); const { renders } = renderSuspenseHook(() => useSuspenseQuery(query), { @@ -1830,17 +1841,24 @@ describe('useSuspenseQuery', () => { await waitFor(() => expect(renders.errorCount).toBe(1)); + expect(renders.errors.length).toBe(1); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([]); + const [error] = renders.errors as ApolloError[]; + + expect(error).toBeInstanceOf(ApolloError); + expect(error.networkError).toEqual(new Error('Could not fetch')); + expect(error.graphQLErrors).toEqual([]); + consoleSpy.mockRestore(); }); - it('throws when errorPolicy is set to "none"', async () => { + it('throws network errors when errorPolicy is set to "none"', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const { query, mocks } = useErrorCase({ - error: new GraphQLError('test error'), + networkError: new Error('Could not fetch'), }); const { renders } = renderSuspenseHook( @@ -1850,15 +1868,22 @@ describe('useSuspenseQuery', () => { await waitFor(() => expect(renders.errorCount).toBe(1)); + expect(renders.errors.length).toBe(1); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([]); + const [error] = renders.errors as ApolloError[]; + + expect(error).toBeInstanceOf(ApolloError); + expect(error.networkError).toEqual(new Error('Could not fetch')); + expect(error.graphQLErrors).toEqual([]); + consoleSpy.mockRestore(); }); - it('does not throw when errorPolicy is set to "ignore"', async () => { + it('does not throw or return network errors when errorPolicy is set to "ignore"', async () => { const { query, mocks } = useErrorCase({ - error: new GraphQLError('test error'), + networkError: new Error('Could not fetch'), }); const { result, renders } = renderSuspenseHook( @@ -1875,6 +1900,7 @@ describe('useSuspenseQuery', () => { }); expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([{ data: undefined, variables: {} }]); From 980ccc585a6270a08c4428aaabd96a1f2cff2933 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 15 Nov 2022 10:22:46 -0700 Subject: [PATCH 080/159] Add implementation to work with errorPolicy = "all" --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 37 +++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 10 ++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 7963d5a0b07..45d37dfd81e 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1905,4 +1905,41 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([{ data: undefined, variables: {} }]); }); + + it('does not throw but returns network errors when errorPolicy is set to "all"', async () => { + const networkError = new Error('Could not fetch'); + + const { query, mocks } = useErrorCase({ networkError }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'all' }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + data: undefined, + error: new ApolloError({ networkError }), + variables: {}, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { + data: undefined, + error: new ApolloError({ networkError }), + variables: {}, + }, + ]); + + const { error } = result.current; + + expect(error).toBeInstanceOf(ApolloError); + expect(error!.networkError).toEqual(networkError); + expect(error!.graphQLErrors).toEqual([]); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index dbd5befd0e9..5cec693ca74 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -82,7 +82,7 @@ export function useSuspenseQuery_experimental< variables: compact({ ...defaultOptions.variables, ...variables }), }; }, [options, query, client.defaultOptions.watchQuery]); - const { variables } = watchQueryOptions; + const { errorPolicy, variables } = watchQueryOptions; if (!hasRunValidations.current) { validateOptions(watchQueryOptions); @@ -151,12 +151,12 @@ export function useSuspenseQuery_experimental< ); // Sometimes the observable reports a network status of error even - // when our error policy is set to ignore. This patches the network status - // to avoid a rerender when the observable first subscribes and gets back a - // ready network status. + // when our error policy is set to ignore or all. + // This patches the network status to avoid a rerender when the observable + // first subscribes and gets back a ready network status. if ( result.networkStatus === NetworkStatus.error && - watchQueryOptions.errorPolicy === 'ignore' + (errorPolicy === 'ignore' || errorPolicy === 'all') ) { result.networkStatus = NetworkStatus.ready; } From 233987626aa1e7894e773d3985a7faffce3088fa Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 15 Nov 2022 10:51:07 -0700 Subject: [PATCH 081/159] Handle GraphQL errors with all error policies --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 127 +++++++++++++++++- src/react/hooks/useSuspenseQuery.ts | 12 +- 2 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 45d37dfd81e..7328308cd5d 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1854,6 +1854,34 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); + it('throws graphql errors by default', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { query, mocks } = useErrorCase({ + graphQLErrors: [new GraphQLError('`id` should not be null')], + }); + + const { renders } = renderSuspenseHook(() => useSuspenseQuery(query), { + mocks, + }); + + await waitFor(() => expect(renders.errorCount).toBe(1)); + + expect(renders.errors.length).toBe(1); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([]); + + const [error] = renders.errors as ApolloError[]; + + expect(error).toBeInstanceOf(ApolloError); + expect(error.networkError).toBeNull(); + expect(error.graphQLErrors).toEqual([ + new GraphQLError('`id` should not be null'), + ]); + + consoleSpy.mockRestore(); + }); + it('throws network errors when errorPolicy is set to "none"', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); @@ -1881,6 +1909,35 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); + it('throws graphql errors when errorPolicy is set to "none"', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { query, mocks } = useErrorCase({ + graphQLErrors: [new GraphQLError('`id` should not be null')], + }); + + const { renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'none' }), + { mocks } + ); + + await waitFor(() => expect(renders.errorCount).toBe(1)); + + expect(renders.errors.length).toBe(1); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([]); + + const [error] = renders.errors as ApolloError[]; + + expect(error).toBeInstanceOf(ApolloError); + expect(error.networkError).toBeNull(); + expect(error.graphQLErrors).toEqual([ + new GraphQLError('`id` should not be null'), + ]); + + consoleSpy.mockRestore(); + }); + it('does not throw or return network errors when errorPolicy is set to "ignore"', async () => { const { query, mocks } = useErrorCase({ networkError: new Error('Could not fetch'), @@ -1903,10 +1960,39 @@ describe('useSuspenseQuery', () => { expect(renders.errors).toEqual([]); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([{ data: undefined, variables: {} }]); + expect(renders.frames).toEqual([ + { data: undefined, error: undefined, variables: {} }, + ]); }); - it('does not throw but returns network errors when errorPolicy is set to "all"', async () => { + it('does not throw or return graphql errors when errorPolicy is set to "ignore"', async () => { + const { query, mocks } = useErrorCase({ + graphQLErrors: [new GraphQLError('`id` should not be null')], + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'ignore' }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + data: undefined, + error: undefined, + variables: {}, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { data: undefined, error: undefined, variables: {} }, + ]); + }); + + it('does not throw and returns network errors when errorPolicy is set to "all"', async () => { const networkError = new Error('Could not fetch'); const { query, mocks } = useErrorCase({ networkError }); @@ -1942,4 +2028,41 @@ describe('useSuspenseQuery', () => { expect(error!.networkError).toEqual(networkError); expect(error!.graphQLErrors).toEqual([]); }); + + it('does not throw and returns graphql errors when errorPolicy is set to "all"', async () => { + const graphQLError = new GraphQLError('`id` should not be null'); + + const { query, mocks } = useErrorCase({ graphQLErrors: [graphQLError] }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'all' }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + data: undefined, + error: new ApolloError({ graphQLErrors: [graphQLError] }), + variables: {}, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { + data: undefined, + error: new ApolloError({ graphQLErrors: [graphQLError] }), + variables: {}, + }, + ]); + + const { error } = result.current; + + expect(error).toBeInstanceOf(ApolloError); + expect(error!.networkError).toBeNull(); + expect(error!.graphQLErrors).toEqual([graphQLError]); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 5cec693ca74..c4321041486 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -18,7 +18,7 @@ import { WatchQueryFetchPolicy, } from '../../core'; import { invariant } from '../../utilities/globals'; -import { compact } from '../../utilities'; +import { compact, isNonEmptyArray } from '../../utilities'; import { useApolloClient } from './useApolloClient'; import { DocumentType, verifyDocumentType } from '../parser'; import { SuspenseQueryHookOptions } from '../types/types'; @@ -209,10 +209,10 @@ export function useSuspenseQuery_experimental< return useMemo(() => { return { data: result.data, - error: observable.options.errorPolicy === 'all' ? result.error : void 0, + error: errorPolicy === 'all' ? toApolloError(result) : void 0, variables: observable.variables as TVariables, }; - }, [result, observable]); + }, [result, observable, errorPolicy]); } function validateOptions(options: WatchQueryOptions) { @@ -229,6 +229,12 @@ function validateFetchPolicy(fetchPolicy: WatchQueryFetchPolicy) { ); } +function toApolloError(result: ApolloQueryResult) { + return isNonEmptyArray(result.errors) + ? new ApolloError({ graphQLErrors: result.errors }) + : result.error; +} + function useDeepMemo(memoFn: () => TValue, deps: DependencyList) { const ref = useRef<{ deps: DependencyList; value: TValue }>(); From 4006da88ae7cb8d104891a16d38b55507405323d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 15 Nov 2022 15:55:17 -0700 Subject: [PATCH 082/159] Add error to result checked in tests --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 228 +++++++++++++----- 1 file changed, 162 insertions(+), 66 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 7328308cd5d..97b3537e21f 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -281,12 +281,16 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: {}, }); }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: {} }, + ]); }); it('suspends a query with variables and returns results', async () => { @@ -300,12 +304,16 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + ]); }); it('returns the same results for the same variables', async () => { @@ -319,6 +327,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); @@ -328,8 +337,8 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(3); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[0].result, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, ]); }); @@ -346,6 +355,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); @@ -464,7 +474,7 @@ describe('useSuspenseQuery', () => { ); expect(renders.frames).toEqual([ - { data: { greeting: 'local hello' }, variables: {} }, + { data: { greeting: 'local hello' }, error: undefined, variables: {} }, ]); }); @@ -484,6 +494,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); @@ -493,6 +504,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, + error: undefined, variables: { id: '2' }, }); }); @@ -506,9 +518,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[1].result, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); @@ -529,6 +541,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); @@ -538,6 +551,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, + error: undefined, variables: { id: '2' }, }); }); @@ -550,9 +564,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(4); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[1].result, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); @@ -587,13 +601,21 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); await waitFor(() => { - expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); }); rerender({ query: query2 }); await waitFor(() => { - expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: {}, + }); }); // Renders: @@ -605,9 +627,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: {} }, - { ...mocks[0].result, variables: {} }, - { ...mocks[1].result, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[1].result, error: undefined, variables: {} }, ]); }); @@ -628,13 +650,18 @@ describe('useSuspenseQuery', () => { expect(result.current).toEqual({ data: { greeting: 'hello from cache' }, + error: undefined, variables: {}, }); expect(renders.count).toBe(1); expect(renders.suspenseCount).toBe(0); expect(renders.frames).toEqual([ - { data: { greeting: 'hello from cache' }, variables: {} }, + { + data: { greeting: 'hello from cache' }, + error: undefined, + variables: {}, + }, ]); }); @@ -730,13 +757,14 @@ describe('useSuspenseQuery', () => { expect(result.current).toEqual({ data: { greeting: 'Updated hello' }, + error: undefined, variables: {}, }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(3); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: {} }, - { data: { greeting: 'Updated hello' }, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { data: { greeting: 'Updated hello' }, error: undefined, variables: {} }, ]); }); @@ -756,6 +784,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); @@ -765,6 +794,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, + error: undefined, variables: { id: '2' }, }); }); @@ -778,9 +808,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[1].result, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); @@ -801,6 +831,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); @@ -810,6 +841,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, + error: undefined, variables: { id: '2' }, }); }); @@ -822,9 +854,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(4); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[1].result, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); @@ -859,13 +891,21 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); await waitFor(() => { - expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); }); rerender({ query: query2 }); await waitFor(() => { - expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: {}, + }); }); // Renders: @@ -877,9 +917,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: {} }, - { ...mocks[0].result, variables: {} }, - { ...mocks[1].result, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[1].result, error: undefined, variables: {} }, ]); }); @@ -901,6 +941,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: {}, }); }); @@ -908,7 +949,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { data: { greeting: 'Hello' }, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); }); @@ -1004,13 +1045,14 @@ describe('useSuspenseQuery', () => { expect(result.current).toEqual({ data: { greeting: 'Updated hello' }, + error: undefined, variables: {}, }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(3); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: {} }, - { data: { greeting: 'Updated hello' }, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { data: { greeting: 'Updated hello' }, error: undefined, variables: {} }, ]); }); @@ -1030,6 +1072,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); @@ -1039,6 +1082,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, + error: undefined, variables: { id: '2' }, }); }); @@ -1052,9 +1096,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[1].result, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); @@ -1075,6 +1119,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); @@ -1084,6 +1129,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, + error: undefined, variables: { id: '2' }, }); }); @@ -1096,9 +1142,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(4); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[1].result, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); @@ -1133,13 +1179,21 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); await waitFor(() => { - expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); }); rerender({ query: query2 }); await waitFor(() => { - expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: {}, + }); }); // Renders: @@ -1151,9 +1205,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: {} }, - { ...mocks[0].result, variables: {} }, - { ...mocks[1].result, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[1].result, error: undefined, variables: {} }, ]); }); @@ -1175,6 +1229,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: {}, }); }); @@ -1184,7 +1239,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { data: { greeting: 'Hello' }, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); expect(cachedData).toEqual({ greeting: 'hello from cache' }); }); @@ -1202,6 +1257,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: {}, }); }); @@ -1209,17 +1265,21 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { data: { greeting: 'Hello' }, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); rerender(); - expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); expect(renders.count).toBe(3); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { data: { greeting: 'Hello' }, variables: {} }, - { data: { greeting: 'Hello' }, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); }); @@ -1315,11 +1375,14 @@ describe('useSuspenseQuery', () => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: {}, }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); - expect(renders.frames).toEqual([{ ...mocks[0].result, variables: {} }]); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: {} }, + ]); }); it('re-suspends the component when changing variables and using a "cache-and-network" fetch policy', async () => { @@ -1338,6 +1401,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); @@ -1347,6 +1411,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, + error: undefined, variables: { id: '2' }, }); }); @@ -1360,9 +1425,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[1].result, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); @@ -1383,6 +1448,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, + error: undefined, variables: { id: '1' }, }); }); @@ -1392,6 +1458,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, + error: undefined, variables: { id: '2' }, }); }); @@ -1404,9 +1471,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(4); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[0].result, variables: { id: '1' } }, - { ...mocks[1].result, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); @@ -1442,13 +1509,21 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); await waitFor(() => { - expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); }); rerender({ query: query2 }); await waitFor(() => { - expect(result.current).toEqual({ ...mocks[1].result, variables: {} }); + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: {}, + }); }); // Renders: @@ -1460,9 +1535,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: {} }, - { ...mocks[0].result, variables: {} }, - { ...mocks[1].result, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[1].result, error: undefined, variables: {} }, ]); }); @@ -1483,18 +1558,27 @@ describe('useSuspenseQuery', () => { expect(result.current).toEqual({ data: { greeting: 'hello from cache' }, + error: undefined, variables: {}, }); await waitFor(() => { - expect(result.current).toEqual({ ...mocks[0].result, variables: {} }); + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); }); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(0); expect(renders.frames).toEqual([ - { data: { greeting: 'hello from cache' }, variables: {} }, - { data: { greeting: 'Hello' }, variables: {} }, + { + data: { greeting: 'hello from cache' }, + error: undefined, + variables: {}, + }, + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); }); @@ -1590,13 +1674,14 @@ describe('useSuspenseQuery', () => { expect(result.current).toEqual({ data: { greeting: 'Updated hello' }, + error: undefined, variables: {}, }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(3); expect(renders.frames).toEqual([ - { ...mocks[0].result, variables: {} }, - { data: { greeting: 'Updated hello' }, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { data: { greeting: 'Updated hello' }, error: undefined, variables: {} }, ]); }); @@ -1628,7 +1713,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([{ ...mocks[0].result, variables: {} }]); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: {} }, + ]); }); it('uses default variables from the client when none provided in options', async () => { @@ -1652,12 +1739,13 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ ...mocks[1].result, + error: undefined, variables: { id: '2' }, }); }); expect(renders.frames).toEqual([ - { ...mocks[1].result, variables: { id: '2' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); @@ -1697,6 +1785,7 @@ describe('useSuspenseQuery', () => { data: { vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, + error: undefined, variables: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }); }); @@ -1708,6 +1797,7 @@ describe('useSuspenseQuery', () => { data: { vars: { source: 'rerender', globalOnlyVar: true, localOnlyVar: true }, }, + error: undefined, variables: { source: 'rerender', globalOnlyVar: true, @@ -1721,18 +1811,21 @@ describe('useSuspenseQuery', () => { data: { vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, + error: undefined, variables: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, { data: { vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, + error: undefined, variables: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, { data: { vars: { source: 'rerender', globalOnlyVar: true, localOnlyVar: true }, }, + error: undefined, variables: { source: 'rerender', globalOnlyVar: true, @@ -1775,6 +1868,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ data: { vars: { source: 'local' } }, + error: undefined, variables: { source: 'local' }, }); }); @@ -1788,6 +1882,7 @@ describe('useSuspenseQuery', () => { expect(renders.frames).toEqual([ { data: { vars: { source: 'local' } }, + error: undefined, variables: { source: 'local' }, }, ]); @@ -1823,6 +1918,7 @@ describe('useSuspenseQuery', () => { await waitFor(() => { expect(result.current).toEqual({ data: { context: { valueA: 'A', valueB: 'B' } }, + error: undefined, variables: {}, }); }); From 2d85accdbd10e3db1b8b328e7d218147f1474bd2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 16 Nov 2022 10:20:29 -0700 Subject: [PATCH 083/159] Add tests to ensure partial data results are returned with errors --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 97b3537e21f..10fe7c783a5 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -151,7 +151,10 @@ function useErrorCase( ) { const query: TypedDocumentNode = gql` query MyQuery { - greeting + currentUser { + id + name + } } `; @@ -2088,6 +2091,34 @@ describe('useSuspenseQuery', () => { ]); }); + it('returns partial data results and throws away errors when errorPolicy is set to "ignore"', async () => { + const { query, mocks } = useErrorCase({ + data: { currentUser: { id: '1', name: null } }, + graphQLErrors: [new GraphQLError('`name` could not be found')], + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'ignore' }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + data: { currentUser: { id: '1', name: null } }, + error: undefined, + variables: {}, + }); + }); + + expect(renders.frames).toEqual([ + { + data: { currentUser: { id: '1', name: null } }, + error: undefined, + variables: {}, + }, + ]); + }); + it('does not throw and returns network errors when errorPolicy is set to "all"', async () => { const networkError = new Error('Could not fetch'); @@ -2161,4 +2192,36 @@ describe('useSuspenseQuery', () => { expect(error!.networkError).toBeNull(); expect(error!.graphQLErrors).toEqual([graphQLError]); }); + + it('returns partial data and keeps errors when errorPolicy is set to "all"', async () => { + const graphQLError = new GraphQLError('`name` could not be found'); + + const { query, mocks } = useErrorCase({ + data: { currentUser: { id: '1', name: null } }, + graphQLErrors: [graphQLError], + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'all' }), + { mocks } + ); + + const expectedError = new ApolloError({ graphQLErrors: [graphQLError] }); + + await waitFor(() => { + expect(result.current).toEqual({ + data: { currentUser: { id: '1', name: null } }, + error: expectedError, + variables: {}, + }); + }); + + expect(renders.frames).toEqual([ + { + data: { currentUser: { id: '1', name: null } }, + error: expectedError, + variables: {}, + }, + ]); + }); }); From cf5c2f52b95ad0a18becb29c6f6a02a3517eee0f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 16 Nov 2022 12:41:00 -0700 Subject: [PATCH 084/159] Add test to ensure error is persisted between renders --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 10fe7c783a5..98309bee9d9 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -2224,4 +2224,27 @@ describe('useSuspenseQuery', () => { }, ]); }); + + it('persists errors between rerenders when errorPolicy is set to "all"', async () => { + const graphQLError = new GraphQLError('`name` could not be found'); + + const { query, mocks } = useErrorCase({ + graphQLErrors: [graphQLError], + }); + + const { result, rerender } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'all' }), + { mocks } + ); + + const expectedError = new ApolloError({ graphQLErrors: [graphQLError] }); + + await waitFor(() => { + expect(result.current.error).toEqual(expectedError); + }); + + rerender(); + + expect(result.current.error).toEqual(expectedError); + }); }); From 528fb4d7d877a17e95593fb930bd349c3fc60b5b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 16 Nov 2022 13:06:21 -0700 Subject: [PATCH 085/159] Add tests to ensure multiple GraphQL errors are handled --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 98309bee9d9..019031b588b 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -2037,6 +2037,36 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); + it('handles multiple graphql errors when errorPolicy is set to "none"', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const graphQLErrors = [ + new GraphQLError('Fool me once'), + new GraphQLError('Fool me twice'), + ]; + + const { query, mocks } = useErrorCase({ graphQLErrors }); + + const { renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'none' }), + { mocks } + ); + + await waitFor(() => expect(renders.errorCount).toBe(1)); + + expect(renders.errors.length).toBe(1); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([]); + + const [error] = renders.errors as ApolloError[]; + + expect(error).toBeInstanceOf(ApolloError); + expect(error!.networkError).toBeNull(); + expect(error!.graphQLErrors).toEqual(graphQLErrors); + + consoleSpy.mockRestore(); + }); + it('does not throw or return network errors when errorPolicy is set to "ignore"', async () => { const { query, mocks } = useErrorCase({ networkError: new Error('Could not fetch'), @@ -2119,6 +2149,36 @@ describe('useSuspenseQuery', () => { ]); }); + it('throws away multiple graphql errors when errorPolicy is set to "ignore"', async () => { + const { query, mocks } = useErrorCase({ + graphQLErrors: [ + new GraphQLError('Fool me once'), + new GraphQLError('Fool me twice'), + ], + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'ignore' }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + data: undefined, + error: undefined, + variables: {}, + }); + }); + + expect(renders.frames).toEqual([ + { + data: undefined, + error: undefined, + variables: {}, + }, + ]); + }); + it('does not throw and returns network errors when errorPolicy is set to "all"', async () => { const networkError = new Error('Could not fetch'); @@ -2193,6 +2253,48 @@ describe('useSuspenseQuery', () => { expect(error!.graphQLErrors).toEqual([graphQLError]); }); + it('handles multiple graphql errors when errorPolicy is set to "all"', async () => { + const graphQLErrors = [ + new GraphQLError('Fool me once'), + new GraphQLError('Fool me twice'), + ]; + + const { query, mocks } = useErrorCase({ graphQLErrors }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'all' }), + { mocks } + ); + + const expectedError = new ApolloError({ graphQLErrors }); + + await waitFor(() => { + expect(result.current).toEqual({ + data: undefined, + error: expectedError, + variables: {}, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { + data: undefined, + error: expectedError, + variables: {}, + }, + ]); + + const { error } = result.current; + + expect(error).toBeInstanceOf(ApolloError); + expect(error!.networkError).toBeNull(); + expect(error!.graphQLErrors).toEqual(graphQLErrors); + }); + it('returns partial data and keeps errors when errorPolicy is set to "all"', async () => { const graphQLError = new GraphQLError('`name` could not be found'); From f6e09649e45adad9b7e038f81ccc927714776d42 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 16 Nov 2022 13:27:13 -0700 Subject: [PATCH 086/159] Add test to ensure errors are removed when changing variables --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 019031b588b..845aa872e60 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -2349,4 +2349,136 @@ describe('useSuspenseQuery', () => { expect(result.current.error).toEqual(expectedError); }); + + it('clears errors when changing variables and errorPolicy is set to "all"', async () => { + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const graphQLErrors = [new GraphQLError('Could not fetch user 1')]; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + errors: graphQLErrors, + }, + }, + { + request: { query, variables: { id: '2' } }, + result: { + data: { user: { id: '2', name: 'Captain Marvel' } }, + }, + }, + ]; + + const { result, renders, rerender } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { errorPolicy: 'all', variables: { id } }), + { mocks, initialProps: { id: '1' } } + ); + + const expectedError = new ApolloError({ graphQLErrors }); + + await waitFor(() => { + expect(result.current).toEqual({ + data: undefined, + error: expectedError, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: { id: '2' }, + }); + }); + + expect(renders.count).toBe(5); + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toEqual([ + { data: undefined, error: expectedError, variables: { id: '1' } }, + { data: undefined, error: expectedError, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + ]); + }); + + it('clears errors when changing variables and errorPolicy is set to "all" with an "initial" suspensePolicy', async () => { + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const graphQLErrors = [new GraphQLError('Could not fetch user 1')]; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + errors: graphQLErrors, + }, + }, + { + request: { query, variables: { id: '2' } }, + result: { + data: { user: { id: '2', name: 'Captain Marvel' } }, + }, + }, + ]; + + const { result, renders, rerender } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + errorPolicy: 'all', + suspensePolicy: 'initial', + variables: { id }, + }), + { mocks, initialProps: { id: '1' } } + ); + + const expectedError = new ApolloError({ graphQLErrors }); + + await waitFor(() => { + expect(result.current).toEqual({ + data: undefined, + error: expectedError, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: { id: '2' }, + }); + }); + + expect(renders.count).toBe(4); + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { data: undefined, error: expectedError, variables: { id: '1' } }, + { data: undefined, error: expectedError, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + ]); + }); }); From cbbbe896286e643a81242f19562c544585e4e099 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 16 Nov 2022 14:55:48 -0700 Subject: [PATCH 087/159] Add test to ensure canonizeResults works as expected --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 845aa872e60..fc7d74e94be 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -370,6 +370,109 @@ describe('useSuspenseQuery', () => { expect(result.current).toBe(previousResult); }); + it('enables canonical results when canonizeResults is "true"', async () => { + interface Result { + __typename: string; + value: number; + } + + const cache = new InMemoryCache({ + typePolicies: { + Result: { + keyFields: false, + }, + }, + }); + + const query: TypedDocumentNode<{ results: Result[] }> = gql` + query { + results { + value + } + } + `; + + const results: Result[] = [ + { __typename: 'Result', value: 0 }, + { __typename: 'Result', value: 1 }, + { __typename: 'Result', value: 1 }, + { __typename: 'Result', value: 2 }, + { __typename: 'Result', value: 3 }, + { __typename: 'Result', value: 5 }, + ]; + + cache.writeQuery({ + query, + data: { results }, + }); + + const { result } = renderSuspenseHook( + () => useSuspenseQuery(query, { canonizeResults: true }), + { cache } + ); + + const { data } = result.current; + const resultSet = new Set(data.results); + const values = Array.from(resultSet).map((item) => item.value); + + expect(data).toEqual({ results }); + expect(data.results.length).toBe(6); + expect(resultSet.size).toBe(5); + expect(values).toEqual([0, 1, 2, 3, 5]); + }); + + it("can disable canonical results when the cache's canonizeResults setting is true", async () => { + interface Result { + __typename: string; + value: number; + } + + const cache = new InMemoryCache({ + canonizeResults: true, + typePolicies: { + Result: { + keyFields: false, + }, + }, + }); + + const query: TypedDocumentNode<{ results: Result[] }> = gql` + query { + results { + value + } + } + `; + + const results: Result[] = [ + { __typename: 'Result', value: 0 }, + { __typename: 'Result', value: 1 }, + { __typename: 'Result', value: 1 }, + { __typename: 'Result', value: 2 }, + { __typename: 'Result', value: 3 }, + { __typename: 'Result', value: 5 }, + ]; + + cache.writeQuery({ + query, + data: { results }, + }); + + const { result } = renderSuspenseHook( + () => useSuspenseQuery(query, { canonizeResults: false }), + { cache } + ); + + const { data } = result.current; + const resultSet = new Set(data.results); + const values = Array.from(resultSet).map((item) => item.value); + + expect(data).toEqual({ results }); + expect(data.results.length).toBe(6); + expect(resultSet.size).toBe(6); + expect(values).toEqual([0, 1, 1, 2, 3, 5]); + }); + it('tears down the query on unmount', async () => { const { query, mocks } = useSimpleQueryCase(); From 3d09030bae4cc3028f1b9afae2263842137a593d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 16 Nov 2022 15:11:32 -0700 Subject: [PATCH 088/159] Bump wait time to help with flaky test --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index fc7d74e94be..d32ae54a602 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -859,7 +859,7 @@ describe('useSuspenseQuery', () => { data: { greeting: 'Updated hello' }, }); - await wait(10); + await wait(20); expect(result.current).toEqual({ data: { greeting: 'Updated hello' }, From 1998491dfd189b6e6a503c49b1a6c03e3c431099 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 16 Nov 2022 15:41:39 -0700 Subject: [PATCH 089/159] Add ability to return partial result data from the cache --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 396 ++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 7 +- 2 files changed, 401 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index d32ae54a602..5cdb03e0f8d 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -771,6 +771,146 @@ describe('useSuspenseQuery', () => { ]); }); + it('does not suspend when partial data is in the cache and using a "cache-first" fetch policy with returnPartialData', async () => { + const fullQuery = gql` + query { + character { + id + name + } + } + `; + + const partialQuery = gql` + query { + character { + id + } + } + `; + + const mocks = [ + { + request: { query: fullQuery }, + result: { data: { character: { id: '1', name: 'Doctor Strange' } } }, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: '1' } }, + }); + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(fullQuery, { + fetchPolicy: 'cache-first', + returnPartialData: true, + }), + { cache, mocks } + ); + + expect(renders.suspenseCount).toBe(0); + expect(result.current).toEqual({ + data: { character: { id: '1' } }, + error: undefined, + variables: {}, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(0); + expect(renders.frames).toEqual([ + { data: { character: { id: '1' } }, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + ]); + }); + + it('suspends and does not use partial data when changing variables and using a "cache-first" fetch policy with returnPartialData', async () => { + const { query: fullQuery, mocks } = useVariablesQueryCase(); + + const partialQuery = gql` + query ($id: ID!) { + character(id: $id) { + id + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: '1' } }, + variables: { id: '1' }, + }); + + const { result, renders, rerender } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(fullQuery, { + fetchPolicy: 'cache-first', + returnPartialData: true, + variables: { id }, + }), + { cache, mocks, initialProps: { id: '1' } } + ); + + expect(renders.suspenseCount).toBe(0); + expect(result.current).toEqual({ + data: { character: { id: '1' } }, + error: undefined, + variables: { id: '1' }, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: { id: '2' }, + }); + }); + + expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { + data: { character: { id: '1' } }, + error: undefined, + variables: { id: '1' }, + }, + { + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }, + { + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + ]); + }); + it('ensures data is fetched is the correct amount of times when using a "cache-first" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -1059,6 +1199,64 @@ describe('useSuspenseQuery', () => { ]); }); + it('suspends when partial data is in the cache and using a "network-only" fetch policy with returnPartialData', async () => { + const fullQuery = gql` + query { + character { + id + name + } + } + `; + + const partialQuery = gql` + query { + character { + id + } + } + `; + + const mocks = [ + { + request: { query: fullQuery }, + result: { data: { character: { id: '1', name: 'Doctor Strange' } } }, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: '1' } }, + }); + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(fullQuery, { + fetchPolicy: 'network-only', + returnPartialData: true, + }), + { cache, mocks } + ); + + expect(renders.suspenseCount).toBe(1); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: {} }, + ]); + }); + it('ensures data is fetched is the correct amount of times when using a "network-only" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -1389,6 +1587,64 @@ describe('useSuspenseQuery', () => { ]); }); + it('suspends when partial data is in the cache and using a "no-cache" fetch policy with returnPartialData', async () => { + const fullQuery = gql` + query { + character { + id + name + } + } + `; + + const partialQuery = gql` + query { + character { + id + } + } + `; + + const mocks = [ + { + request: { query: fullQuery }, + result: { data: { character: { id: '1', name: 'Doctor Strange' } } }, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: '1' } }, + }); + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(fullQuery, { + fetchPolicy: 'no-cache', + returnPartialData: true, + }), + { cache, mocks } + ); + + expect(renders.suspenseCount).toBe(1); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: {} }, + ]); + }); + it('ensures data is fetched is the correct amount of times when using a "no-cache" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); @@ -1688,6 +1944,146 @@ describe('useSuspenseQuery', () => { ]); }); + it('does not suspend when partial data is in the cache and using a "cache-and-network" fetch policy with returnPartialData', async () => { + const fullQuery = gql` + query { + character { + id + name + } + } + `; + + const partialQuery = gql` + query { + character { + id + } + } + `; + + const mocks = [ + { + request: { query: fullQuery }, + result: { data: { character: { id: '1', name: 'Doctor Strange' } } }, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: '1' } }, + }); + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(fullQuery, { + fetchPolicy: 'cache-and-network', + returnPartialData: true, + }), + { cache, mocks } + ); + + expect(renders.suspenseCount).toBe(0); + expect(result.current).toEqual({ + data: { character: { id: '1' } }, + error: undefined, + variables: {}, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(0); + expect(renders.frames).toEqual([ + { data: { character: { id: '1' } }, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + ]); + }); + + it('suspends and does not use partial data when changing variables and using a "cache-and-network" fetch policy with returnPartialData', async () => { + const { query: fullQuery, mocks } = useVariablesQueryCase(); + + const partialQuery = gql` + query ($id: ID!) { + character(id: $id) { + id + } + } + `; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: '1' } }, + variables: { id: '1' }, + }); + + const { result, renders, rerender } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(fullQuery, { + fetchPolicy: 'cache-and-network', + returnPartialData: true, + variables: { id }, + }), + { cache, mocks, initialProps: { id: '1' } } + ); + + expect(renders.suspenseCount).toBe(0); + expect(result.current).toEqual({ + data: { character: { id: '1' } }, + error: undefined, + variables: { id: '1' }, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: { id: '2' }, + }); + }); + + expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { + data: { character: { id: '1' } }, + error: undefined, + variables: { id: '1' }, + }, + { + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }, + { + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + ]); + }); + it('ensures data is fetched is the correct amount of times when using a "cache-and-network" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index c4321041486..85990920eb5 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -82,7 +82,7 @@ export function useSuspenseQuery_experimental< variables: compact({ ...defaultOptions.variables, ...variables }), }; }, [options, query, client.defaultOptions.watchQuery]); - const { errorPolicy, variables } = watchQueryOptions; + const { errorPolicy, returnPartialData, variables } = watchQueryOptions; if (!hasRunValidations.current) { validateOptions(watchQueryOptions); @@ -161,7 +161,10 @@ export function useSuspenseQuery_experimental< result.networkStatus = NetworkStatus.ready; } - if (result.loading) { + const returnPartialResults = + returnPartialData && result.partial && result.data; + + if (result.loading && !returnPartialResults) { switch (watchQueryOptions.fetchPolicy) { case 'cache-and-network': { if (!result.partial) { From 541aaad686dcfbe82996c6c354a8d289e6aa9a53 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 16 Nov 2022 15:59:36 -0700 Subject: [PATCH 090/159] Add additional supported options to suspense query hook type --- src/react/types/types.ts | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 2f1e66f817b..2eb8bacdd4d 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -18,6 +18,7 @@ import { WatchQueryOptions, WatchQueryFetchPolicy, } from '../../core'; +import { NextFetchPolicyContext } from '../../core/watchQueryOptions'; /* Common types */ @@ -101,20 +102,34 @@ export type SuspensePolicy = | 'always' | 'initial' +export type SuspenseQueryHookFetchPolicy = Extract< + WatchQueryFetchPolicy, + | 'cache-first' + | 'network-only' + | 'no-cache' + | 'cache-and-network' +>; + export interface SuspenseQueryHookOptions< TData = any, TVariables = OperationVariables > extends Pick< QueryHookOptions, - 'client' | 'variables' | 'errorPolicy' | 'context' | 'fetchPolicy' + | 'client' + | 'variables' + | 'errorPolicy' + | 'context' + | 'canonizeResults' + | 'returnPartialData' + | 'refetchWritePolicy' > { - fetchPolicy?: Extract< - WatchQueryFetchPolicy, - | 'cache-first' - | 'network-only' - | 'no-cache' - | 'cache-and-network' - >; + fetchPolicy?: SuspenseQueryHookFetchPolicy; + nextFetchPolicy?: + | SuspenseQueryHookFetchPolicy + | (( + currentFetchPolicy: SuspenseQueryHookFetchPolicy, + context: NextFetchPolicyContext + ) => SuspenseQueryHookFetchPolicy); suspensePolicy?: SuspensePolicy; } From 3b7a87cc1079139cbe17c14fbca78169408f22d8 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 16 Nov 2022 17:47:35 -0700 Subject: [PATCH 091/159] Refactor to use it.each to reduce duplication of some tests --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 1511 +++++------------ 1 file changed, 417 insertions(+), 1094 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 5cdb03e0f8d..b14d3624541 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -25,6 +25,7 @@ import { compact } from '../../../utilities'; import { MockedProvider, MockedResponse, MockLink } from '../../../testing'; import { ApolloProvider } from '../../context'; import { SuspenseCache } from '../../cache'; +import { SuspenseQueryHookFetchPolicy } from '../../../react'; import { useSuspenseQuery_experimental as useSuspenseQuery } from '../useSuspenseQuery'; type RenderSuspenseHookOptions< @@ -584,161 +585,6 @@ describe('useSuspenseQuery', () => { ]); }); - it('re-suspends the component when changing variables and using a "cache-first" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - const { result, rerender, renders } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'cache-first', - variables: { id }, - }), - { mocks, initialProps: { id: '1' } } - ); - - expect(renders.suspenseCount).toBe(1); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: { id: '1' }, - }); - }); - - rerender({ id: '2' }); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - error: undefined, - variables: { id: '2' }, - }); - }); - - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change variables - // 4. Initiate refetch and suspend - // 5. Unsuspend and return results from refetch - expect(renders.count).toBe(5); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, - ]); - }); - - it('returns previous data on refetch when changing variables and using a "cache-first" with an "initial" suspense policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - const { result, rerender, renders } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'cache-first', - suspensePolicy: 'initial', - variables: { id }, - }), - { mocks, initialProps: { id: '1' } } - ); - - expect(renders.suspenseCount).toBe(1); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: { id: '1' }, - }); - }); - - rerender({ id: '2' }); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - error: undefined, - variables: { id: '2' }, - }); - }); - - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change variables - // 4. Unsuspend and return results from refetch - expect(renders.count).toBe(4); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, - ]); - }); - - it('re-suspends the component when changing queries and using a "cache-first" fetch policy', async () => { - const query1: TypedDocumentNode<{ hello: string }> = gql` - query Query1 { - hello - } - `; - - const query2: TypedDocumentNode<{ world: string }> = gql` - query Query2 { - world - } - `; - - const mocks = [ - { - request: { query: query1 }, - result: { data: { hello: 'query1' } }, - }, - { - request: { query: query2 }, - result: { data: { world: 'query2' } }, - }, - ]; - - const { result, rerender, renders } = renderSuspenseHook( - ({ query }) => useSuspenseQuery(query, { fetchPolicy: 'cache-first' }), - { mocks, initialProps: { query: query1 as DocumentNode } } - ); - - expect(renders.suspenseCount).toBe(1); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); - }); - - rerender({ query: query2 }); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - error: undefined, - variables: {}, - }); - }); - - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change queries - // 4. Initiate refetch and suspend - // 5. Unsuspend and return results from refetch - expect(renders.count).toBe(5); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: {} }, - { ...mocks[0].result, error: undefined, variables: {} }, - { ...mocks[1].result, error: undefined, variables: {} }, - ]); - }); - it('does not suspend when data is in the cache and using a "cache-first" fetch policy', async () => { const { query, mocks } = useSimpleQueryCase(); @@ -911,699 +757,53 @@ describe('useSuspenseQuery', () => { ]); }); - it('ensures data is fetched is the correct amount of times when using a "cache-first" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - let fetchCount = 0; - - const link = new ApolloLink((operation) => { - return new Observable((observer) => { - fetchCount++; - - const mock = mocks.find(({ request }) => - equal(request.variables, operation.variables) - ); - - if (!mock) { - throw new Error('Could not find mock for operation'); - } - - observer.next(mock.result!); - observer.complete(); - }); - }); - - const { result, rerender } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'cache-first', - variables: { id }, - }), - { link, initialProps: { id: '1' } } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); - - expect(fetchCount).toBe(1); - - rerender({ id: '2' }); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[1].result.data); - }); - - expect(fetchCount).toBe(2); - }); - - it('writes to the cache when using a "cache-first" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - const cache = new InMemoryCache(); - - const { result } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'cache-first', - variables: { id }, - }), - { cache, mocks, initialProps: { id: '1' } } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); - - const cachedData = cache.readQuery({ query, variables: { id: '1' } }); - - expect(cachedData).toEqual(mocks[0].result.data); - }); - - it('responds to cache updates when using a "cache-first" fetch policy', async () => { + it('suspends when data is in the cache and using a "network-only" fetch policy', async () => { const { query, mocks } = useSimpleQueryCase(); const cache = new InMemoryCache(); - const { result, renders } = renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: 'cache-first' }), - { cache, mocks } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); - cache.writeQuery({ query, - data: { greeting: 'Updated hello' }, - }); - - await wait(20); - - expect(result.current).toEqual({ - data: { greeting: 'Updated hello' }, - error: undefined, - variables: {}, + data: { greeting: 'hello from cache' }, }); - expect(renders.suspenseCount).toBe(1); - expect(renders.count).toBe(3); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: {} }, - { data: { greeting: 'Updated hello' }, error: undefined, variables: {} }, - ]); - }); - - it('re-suspends the component when changing variables and using a "network-only" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - const { result, rerender, renders } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'network-only', - variables: { id }, - }), - { mocks, initialProps: { id: '1' } } + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'network-only' }), + { cache, mocks } ); - expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, - }); - }); - - rerender({ id: '2' }); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - error: undefined, - variables: { id: '2' }, + variables: {}, }); }); - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change variables - // 4. Initiate refetch and suspend - // 5. Unsuspend and return results from refetch - expect(renders.count).toBe(5); - expect(renders.suspenseCount).toBe(2); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); }); - it('returns previous data on refetch when changing variables and using a "network-only" with an "initial" suspense policy', async () => { - const { query, mocks } = useVariablesQueryCase(); + it('suspends when partial data is in the cache and using a "network-only" fetch policy with returnPartialData', async () => { + const fullQuery = gql` + query { + character { + id + name + } + } + `; - const { result, rerender, renders } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'network-only', - suspensePolicy: 'initial', - variables: { id }, - }), - { mocks, initialProps: { id: '1' } } - ); - - expect(renders.suspenseCount).toBe(1); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: { id: '1' }, - }); - }); - - rerender({ id: '2' }); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - error: undefined, - variables: { id: '2' }, - }); - }); - - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change variables - // 4. Unsuspend and return results from refetch - expect(renders.count).toBe(4); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, - ]); - }); - - it('re-suspends the component when changing queries and using a "network-only" fetch policy', async () => { - const query1: TypedDocumentNode<{ hello: string }> = gql` - query Query1 { - hello - } - `; - - const query2: TypedDocumentNode<{ world: string }> = gql` - query Query2 { - world - } - `; - - const mocks = [ - { - request: { query: query1 }, - result: { data: { hello: 'query1' } }, - }, - { - request: { query: query2 }, - result: { data: { world: 'query2' } }, - }, - ]; - - const { result, rerender, renders } = renderSuspenseHook( - ({ query }) => useSuspenseQuery(query, { fetchPolicy: 'network-only' }), - { mocks, initialProps: { query: query1 as DocumentNode } } - ); - - expect(renders.suspenseCount).toBe(1); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); - }); - - rerender({ query: query2 }); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - error: undefined, - variables: {}, - }); - }); - - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change queries - // 4. Initiate refetch and suspend - // 5. Unsuspend and return results from refetch - expect(renders.count).toBe(5); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: {} }, - { ...mocks[0].result, error: undefined, variables: {} }, - { ...mocks[1].result, error: undefined, variables: {} }, - ]); - }); - - it('suspends when data is in the cache and using a "network-only" fetch policy', async () => { - const { query, mocks } = useSimpleQueryCase(); - - const cache = new InMemoryCache(); - - cache.writeQuery({ - query, - data: { greeting: 'hello from cache' }, - }); - - const { result, renders } = renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: 'network-only' }), - { cache, mocks } - ); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); - }); - - expect(renders.count).toBe(2); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, - ]); - }); - - it('suspends when partial data is in the cache and using a "network-only" fetch policy with returnPartialData', async () => { - const fullQuery = gql` - query { - character { - id - name - } - } - `; - - const partialQuery = gql` - query { - character { - id - } - } - `; - - const mocks = [ - { - request: { query: fullQuery }, - result: { data: { character: { id: '1', name: 'Doctor Strange' } } }, - }, - ]; - - const cache = new InMemoryCache(); - - cache.writeQuery({ - query: partialQuery, - data: { character: { id: '1' } }, - }); - - const { result, renders } = renderSuspenseHook( - () => - useSuspenseQuery(fullQuery, { - fetchPolicy: 'network-only', - returnPartialData: true, - }), - { cache, mocks } - ); - - expect(renders.suspenseCount).toBe(1); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); - }); - - expect(renders.count).toBe(2); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: {} }, - ]); - }); - - it('ensures data is fetched is the correct amount of times when using a "network-only" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - let fetchCount = 0; - - const link = new ApolloLink((operation) => { - return new Observable((observer) => { - fetchCount++; - - const mock = mocks.find(({ request }) => - equal(request.variables, operation.variables) - ); - - if (!mock) { - throw new Error('Could not find mock for operation'); - } - - observer.next(mock.result); - observer.complete(); - }); - }); - - const { result, rerender } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'network-only', - variables: { id }, - }), - { link, initialProps: { id: '1' } } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); - - expect(fetchCount).toBe(1); - - rerender({ id: '2' }); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[1].result.data); - }); - - expect(fetchCount).toBe(2); - }); - - it('writes to the cache when using a "network-only" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - const cache = new InMemoryCache(); - - const { result } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'network-only', - variables: { id }, - }), - { cache, mocks, initialProps: { id: '1' } } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); - - const cachedData = cache.readQuery({ query, variables: { id: '1' } }); - - expect(cachedData).toEqual(mocks[0].result.data); - }); - - it('responds to cache updates when using a "network-only" fetch policy', async () => { - const { query, mocks } = useSimpleQueryCase(); - - const cache = new InMemoryCache(); - - const { result, renders } = renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: 'network-only' }), - { cache, mocks } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); - - cache.writeQuery({ - query, - data: { greeting: 'Updated hello' }, - }); - - await wait(10); - - expect(result.current).toEqual({ - data: { greeting: 'Updated hello' }, - error: undefined, - variables: {}, - }); - expect(renders.suspenseCount).toBe(1); - expect(renders.count).toBe(3); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: {} }, - { data: { greeting: 'Updated hello' }, error: undefined, variables: {} }, - ]); - }); - - it('re-suspends the component when changing variables and using a "no-cache" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - const { result, rerender, renders } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'no-cache', - variables: { id }, - }), - { mocks, initialProps: { id: '1' } } - ); - - expect(renders.suspenseCount).toBe(1); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: { id: '1' }, - }); - }); - - rerender({ id: '2' }); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - error: undefined, - variables: { id: '2' }, - }); - }); - - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change variables - // 4. Initiate refetch and suspend - // 5. Unsuspend and return results from refetch - expect(renders.count).toBe(5); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, - ]); - }); - - it('returns previous data on refetch when changing variables and using a "no-cache" with an "initial" suspense policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - const { result, rerender, renders } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'no-cache', - suspensePolicy: 'initial', - variables: { id }, - }), - { mocks, initialProps: { id: '1' } } - ); - - expect(renders.suspenseCount).toBe(1); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: { id: '1' }, - }); - }); - - rerender({ id: '2' }); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - error: undefined, - variables: { id: '2' }, - }); - }); - - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change variables - // 4. Unsuspend and return results from refetch - expect(renders.count).toBe(4); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, - ]); - }); - - it('re-suspends the component when changing queries and using a "no-cache" fetch policy', async () => { - const query1: TypedDocumentNode<{ hello: string }> = gql` - query Query1 { - hello - } - `; - - const query2: TypedDocumentNode<{ world: string }> = gql` - query Query2 { - world - } - `; - - const mocks = [ - { - request: { query: query1 }, - result: { data: { hello: 'query1' } }, - }, - { - request: { query: query2 }, - result: { data: { world: 'query2' } }, - }, - ]; - - const { result, rerender, renders } = renderSuspenseHook( - ({ query }) => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), - { mocks, initialProps: { query: query1 as DocumentNode } } - ); - - expect(renders.suspenseCount).toBe(1); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); - }); - - rerender({ query: query2 }); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - error: undefined, - variables: {}, - }); - }); - - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change queries - // 4. Initiate refetch and suspend - // 5. Unsuspend and return results from refetch - expect(renders.count).toBe(5); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: {} }, - { ...mocks[0].result, error: undefined, variables: {} }, - { ...mocks[1].result, error: undefined, variables: {} }, - ]); - }); - - it('suspends and does not overwrite cache when data is in the cache and using a "no-cache" fetch policy', async () => { - const { query, mocks } = useSimpleQueryCase(); - - const cache = new InMemoryCache(); - - cache.writeQuery({ - query, - data: { greeting: 'hello from cache' }, - }); - - const { result, renders } = renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), - { cache, mocks } - ); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); - }); - - const cachedData = cache.readQuery({ query }); - - expect(renders.count).toBe(2); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, - ]); - expect(cachedData).toEqual({ greeting: 'hello from cache' }); - }); - - it('maintains results when rerendering a query using a "no-cache" fetch policy', async () => { - const { query, mocks } = useSimpleQueryCase(); - - const cache = new InMemoryCache(); - - const { result, rerender, renders } = renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), - { cache, mocks } - ); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); - }); - - expect(renders.count).toBe(2); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, - ]); - - rerender(); - - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); - expect(renders.count).toBe(3); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, - ]); - }); - - it('suspends when partial data is in the cache and using a "no-cache" fetch policy with returnPartialData', async () => { - const fullQuery = gql` - query { - character { - id - name - } - } - `; - - const partialQuery = gql` - query { - character { - id - } - } - `; + const partialQuery = gql` + query { + character { + id + } + } + `; const mocks = [ { @@ -1614,292 +814,164 @@ describe('useSuspenseQuery', () => { const cache = new InMemoryCache(); - cache.writeQuery({ - query: partialQuery, - data: { character: { id: '1' } }, - }); - - const { result, renders } = renderSuspenseHook( - () => - useSuspenseQuery(fullQuery, { - fetchPolicy: 'no-cache', - returnPartialData: true, - }), - { cache, mocks } - ); - - expect(renders.suspenseCount).toBe(1); - - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); - }); - - expect(renders.count).toBe(2); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: {} }, - ]); - }); - - it('ensures data is fetched is the correct amount of times when using a "no-cache" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - let fetchCount = 0; - - const link = new ApolloLink((operation) => { - return new Observable((observer) => { - fetchCount++; - - const mock = mocks.find(({ request }) => - equal(request.variables, operation.variables) - ); - - if (!mock) { - throw new Error('Could not find mock for operation'); - } - - observer.next(mock.result); - observer.complete(); - }); - }); - - const { result, rerender } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'no-cache', - variables: { id }, - }), - { link, initialProps: { id: '1' } } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); - - expect(fetchCount).toBe(1); - - rerender({ id: '2' }); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[1].result.data); - }); - - expect(fetchCount).toBe(2); - }); - - it('does not write to the cache when using a "no-cache" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - const cache = new InMemoryCache(); - - const { result } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'no-cache', - variables: { id }, - }), - { cache, mocks, initialProps: { id: '1' } } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); - - const cachedData = cache.readQuery({ query, variables: { id: '1' } }); - - expect(cachedData).toBeNull(); - }); - - it('does not respond to cache updates when using a "no-cache" fetch policy', async () => { - const { query, mocks } = useSimpleQueryCase(); - - const cache = new InMemoryCache(); - - const { result, renders } = renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), - { cache, mocks } - ); - - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); - - cache.writeQuery({ - query, - data: { greeting: 'Updated hello' }, - }); - - await wait(10); - - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); - expect(renders.suspenseCount).toBe(1); - expect(renders.count).toBe(2); - expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: {} }, - ]); - }); - - it('re-suspends the component when changing variables and using a "cache-and-network" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); + cache.writeQuery({ + query: partialQuery, + data: { character: { id: '1' } }, + }); - const { result, rerender, renders } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'cache-and-network', - variables: { id }, + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(fullQuery, { + fetchPolicy: 'network-only', + returnPartialData: true, }), - { mocks, initialProps: { id: '1' } } + { cache, mocks } ); expect(renders.suspenseCount).toBe(1); + await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, + variables: {}, }); }); - rerender({ id: '2' }); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: {} }, + ]); + }); + + it('suspends and does not overwrite cache when data is in the cache and using a "no-cache" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { greeting: 'hello from cache' }, + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), + { cache, mocks } + ); await waitFor(() => { expect(result.current).toEqual({ - ...mocks[1].result, + ...mocks[0].result, error: undefined, - variables: { id: '2' }, + variables: {}, }); }); - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change variables - // 4. Initiate refetch and suspend - // 5. Unsuspend and return results from refetch - expect(renders.count).toBe(5); - expect(renders.suspenseCount).toBe(2); + const cachedData = cache.readQuery({ query }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); + expect(cachedData).toEqual({ greeting: 'hello from cache' }); }); - it('returns previous data on refetch when changing variables and using a "cache-and-network" with an "initial" suspense policy', async () => { - const { query, mocks } = useVariablesQueryCase(); + it('maintains results when rerendering a query using a "no-cache" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); const { result, rerender, renders } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'cache-and-network', - suspensePolicy: 'initial', - variables: { id }, - }), - { mocks, initialProps: { id: '1' } } + () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), + { cache, mocks } ); - expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, + variables: {}, }); }); - rerender({ id: '2' }); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, + ]); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[1].result, - error: undefined, - variables: { id: '2' }, - }); - }); + rerender(); - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change variables - // 4. Unsuspend and return results from refetch - expect(renders.count).toBe(4); + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); + expect(renders.count).toBe(3); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); }); - it('re-suspends the component when changing queries and using a "cache-and-network" fetch policy', async () => { - const query1: TypedDocumentNode<{ hello: string }> = gql` - query Query1 { - hello + it('suspends when partial data is in the cache and using a "no-cache" fetch policy with returnPartialData', async () => { + const fullQuery = gql` + query { + character { + id + name + } } `; - const query2: TypedDocumentNode<{ world: string }> = gql` - query Query2 { - world + const partialQuery = gql` + query { + character { + id + } } `; const mocks = [ { - request: { query: query1 }, - result: { data: { hello: 'query1' } }, - }, - { - request: { query: query2 }, - result: { data: { world: 'query2' } }, + request: { query: fullQuery }, + result: { data: { character: { id: '1', name: 'Doctor Strange' } } }, }, ]; - const { result, rerender, renders } = renderSuspenseHook( - ({ query }) => - useSuspenseQuery(query, { fetchPolicy: 'cache-and-network' }), - { mocks, initialProps: { query: query1 as DocumentNode } } - ); + const cache = new InMemoryCache(); - expect(renders.suspenseCount).toBe(1); - await waitFor(() => { - expect(result.current).toEqual({ - ...mocks[0].result, - error: undefined, - variables: {}, - }); + cache.writeQuery({ + query: partialQuery, + data: { character: { id: '1' } }, }); - rerender({ query: query2 }); + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(fullQuery, { + fetchPolicy: 'no-cache', + returnPartialData: true, + }), + { cache, mocks } + ); + + expect(renders.suspenseCount).toBe(1); await waitFor(() => { expect(result.current).toEqual({ - ...mocks[1].result, + ...mocks[0].result, error: undefined, variables: {}, }); }); - // Renders: - // 1. Initate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change queries - // 4. Initiate refetch and suspend - // 5. Unsuspend and return results from refetch - expect(renders.count).toBe(5); - expect(renders.suspenseCount).toBe(2); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); expect(renders.frames).toEqual([ { ...mocks[0].result, error: undefined, variables: {} }, - { ...mocks[0].result, error: undefined, variables: {} }, - { ...mocks[1].result, error: undefined, variables: {} }, ]); }); @@ -2084,63 +1156,94 @@ describe('useSuspenseQuery', () => { ]); }); - it('ensures data is fetched is the correct amount of times when using a "cache-and-network" fetch policy', async () => { - const { query, mocks } = useVariablesQueryCase(); - - let fetchCount = 0; - - const link = new ApolloLink((operation) => { - return new Observable((observer) => { - fetchCount++; + it.each([ + 'cache-first', + 'network-only', + 'no-cache', + 'cache-and-network', + ])( + 'returns previous data on refetch when changing variables and using a "%s" with an "initial" suspense policy', + async (fetchPolicy) => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy, + suspensePolicy: 'initial', + variables: { id }, + }), + { mocks, initialProps: { id: '1' } } + ); - const mock = mocks.find(({ request }) => - equal(request.variables, operation.variables) - ); + expect(renders.suspenseCount).toBe(1); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); - if (!mock) { - throw new Error('Could not find mock for operation'); - } + rerender({ id: '2' }); - observer.next(mock.result); - observer.complete(); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: { id: '2' }, + }); }); - }); - const { result, rerender } = renderSuspenseHook( - ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'cache-and-network', - variables: { id }, - }), - { link, initialProps: { id: '1' } } - ); + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change variables + // 4. Unsuspend and return results from refetch + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + ]); + } + ); - await waitFor(() => { - expect(result.current.data).toEqual(mocks[0].result.data); - }); + it.each([ + 'cache-first', + 'network-only', + 'cache-and-network', + ])( + 'writes to the cache when using a "%s" fetch policy', + async (fetchPolicy) => { + const { query, mocks } = useVariablesQueryCase(); - expect(fetchCount).toBe(1); + const cache = new InMemoryCache(); - rerender({ id: '2' }); + const { result } = renderSuspenseHook( + ({ id }) => useSuspenseQuery(query, { fetchPolicy, variables: { id } }), + { cache, mocks, initialProps: { id: '1' } } + ); - await waitFor(() => { - expect(result.current.data).toEqual(mocks[1].result.data); - }); + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); - expect(fetchCount).toBe(2); - }); + const cachedData = cache.readQuery({ query, variables: { id: '1' } }); + + expect(cachedData).toEqual(mocks[0].result.data); + } + ); - it('writes to the cache when using a "cache-and-network" fetch policy', async () => { + it('does not write to the cache when using a "no-cache" fetch policy', async () => { const { query, mocks } = useVariablesQueryCase(); const cache = new InMemoryCache(); const { result } = renderSuspenseHook( ({ id }) => - useSuspenseQuery(query, { - fetchPolicy: 'cache-and-network', - variables: { id }, - }), + useSuspenseQuery(query, { fetchPolicy: 'no-cache', variables: { id } }), { cache, mocks, initialProps: { id: '1' } } ); @@ -2150,16 +1253,61 @@ describe('useSuspenseQuery', () => { const cachedData = cache.readQuery({ query, variables: { id: '1' } }); - expect(cachedData).toEqual(mocks[0].result.data); + expect(cachedData).toBeNull(); }); - it('responds to cache updates when using a "cache-and-network" fetch policy', async () => { + it.each([ + 'cache-first', + 'network-only', + 'cache-and-network', + ])( + 'responds to cache updates when using a "%s" fetch policy', + async (fetchPolicy) => { + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { cache, mocks } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + cache.writeQuery({ + query, + data: { greeting: 'Updated hello' }, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + data: { greeting: 'Updated hello' }, + error: undefined, + variables: {}, + }); + }); + expect(renders.suspenseCount).toBe(1); + expect(renders.count).toBe(3); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: {} }, + { + data: { greeting: 'Updated hello' }, + error: undefined, + variables: {}, + }, + ]); + } + ); + + it('does not respond to cache updates when using a "no-cache" fetch policy', async () => { const { query, mocks } = useSimpleQueryCase(); const cache = new InMemoryCache(); const { result, renders } = renderSuspenseHook( - () => useSuspenseQuery(query, { fetchPolicy: 'cache-and-network' }), + () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), { cache, mocks } ); @@ -2172,21 +1320,196 @@ describe('useSuspenseQuery', () => { data: { greeting: 'Updated hello' }, }); - await wait(10); + await wait(100); expect(result.current).toEqual({ - data: { greeting: 'Updated hello' }, + ...mocks[0].result, error: undefined, variables: {}, }); expect(renders.suspenseCount).toBe(1); - expect(renders.count).toBe(3); + expect(renders.count).toBe(2); expect(renders.frames).toEqual([ { ...mocks[0].result, error: undefined, variables: {} }, - { data: { greeting: 'Updated hello' }, error: undefined, variables: {} }, ]); }); + it.each([ + 'cache-first', + 'network-only', + 'no-cache', + 'cache-and-network', + ])( + 're-suspends the component when changing variables and using a "%s" fetch policy', + async (fetchPolicy) => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => useSuspenseQuery(query, { fetchPolicy, variables: { id } }), + { mocks, initialProps: { id: '1' } } + ); + + expect(renders.suspenseCount).toBe(1); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: { id: '2' }, + }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change variables + // 4. Initiate refetch and suspend + // 5. Unsuspend and return results from refetch + expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + ]); + } + ); + + it.each([ + 'cache-first', + 'network-only', + 'no-cache', + 'cache-and-network', + ])( + 're-suspends the component when changing queries and using a "%s" fetch policy', + async (fetchPolicy) => { + const query1: TypedDocumentNode<{ hello: string }> = gql` + query Query1 { + hello + } + `; + + const query2: TypedDocumentNode<{ world: string }> = gql` + query Query2 { + world + } + `; + + const mocks = [ + { + request: { query: query1 }, + result: { data: { hello: 'query1' } }, + }, + { + request: { query: query2 }, + result: { data: { world: 'query2' } }, + }, + ]; + + const { result, rerender, renders } = renderSuspenseHook( + ({ query }) => useSuspenseQuery(query, { fetchPolicy }), + { mocks, initialProps: { query: query1 as DocumentNode } } + ); + + expect(renders.suspenseCount).toBe(1); + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[0].result, + error: undefined, + variables: {}, + }); + }); + + rerender({ query: query2 }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mocks[1].result, + error: undefined, + variables: {}, + }); + }); + + // Renders: + // 1. Initate fetch and suspend + // 2. Unsuspend and return results from initial fetch + // 3. Change queries + // 4. Initiate refetch and suspend + // 5. Unsuspend and return results from refetch + expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toEqual([ + { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[1].result, error: undefined, variables: {} }, + ]); + } + ); + + // Due to the way the suspense hook works, we don't subscribe to the observable + // until after we have suspended. Once an observable is subscribed, it calls + // `reobserve` which has the potential to kick off a network request. We want + // to ensure we don't accidentally kick off the network request more than + // necessary after a component has been suspended. + it.each([ + 'cache-first', + 'network-only', + 'no-cache', + 'cache-and-network', + ])( + 'ensures data is fetched the correct amount of times when changing variables and using a "%s" fetch policy', + async (fetchPolicy) => { + const { query, mocks } = useVariablesQueryCase(); + + let fetchCount = 0; + + const link = new ApolloLink((operation) => { + return new Observable((observer) => { + fetchCount++; + + const mock = mocks.find(({ request }) => + equal(request.variables, operation.variables) + ); + + if (!mock) { + throw new Error('Could not find mock for operation'); + } + + observer.next(mock.result); + observer.complete(); + }); + }); + + const { result, rerender } = renderSuspenseHook( + ({ id }) => useSuspenseQuery(query, { fetchPolicy, variables: { id } }), + { link, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + expect(fetchCount).toBe(1); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[1].result.data); + }); + + expect(fetchCount).toBe(2); + } + ); + it('uses the default fetch policy from the client when none provided in options', async () => { const { query, mocks } = useSimpleQueryCase(); From f339d3a83c74cbb05469fe1043842c374e36d385 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 17 Nov 2022 15:40:08 -0700 Subject: [PATCH 092/159] Use client.writeQuery in test since its more synchronous --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index b14d3624541..d1bd6b8adac 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1265,18 +1265,21 @@ describe('useSuspenseQuery', () => { async (fetchPolicy) => { const { query, mocks } = useSimpleQueryCase(); - const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); const { result, renders } = renderSuspenseHook( () => useSuspenseQuery(query, { fetchPolicy }), - { cache, mocks } + { client } ); await waitFor(() => { expect(result.current.data).toEqual(mocks[0].result.data); }); - cache.writeQuery({ + client.writeQuery({ query, data: { greeting: 'Updated hello' }, }); @@ -1304,22 +1307,26 @@ describe('useSuspenseQuery', () => { it('does not respond to cache updates when using a "no-cache" fetch policy', async () => { const { query, mocks } = useSimpleQueryCase(); - const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); const { result, renders } = renderSuspenseHook( () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), - { cache, mocks } + { client } ); await waitFor(() => { expect(result.current.data).toEqual(mocks[0].result.data); }); - cache.writeQuery({ + client.writeQuery({ query, data: { greeting: 'Updated hello' }, }); + // Wait for a while to ensure no updates happen asynchronously await wait(100); expect(result.current).toEqual({ @@ -1525,7 +1532,7 @@ describe('useSuspenseQuery', () => { }, }); - cache.writeQuery({ query, data: { greeting: 'hello from cache' } }); + client.writeQuery({ query, data: { greeting: 'hello from cache' } }); const { result, renders } = renderSuspenseHook( () => useSuspenseQuery(query), From aa035cb32ca1c96d7773752dabaeef5a0fc2c776 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 17 Nov 2022 16:49:29 -0700 Subject: [PATCH 093/159] Switch to toMatchObject expectation to check subset of return value --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 171 +++++++++--------- 1 file changed, 86 insertions(+), 85 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index d1bd6b8adac..0486943d35d 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -283,7 +283,7 @@ describe('useSuspenseQuery', () => { // ensure the hook suspends immediately expect(renders.suspenseCount).toBe(1); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, @@ -292,7 +292,7 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: {} }, ]); }); @@ -306,7 +306,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: { id: '1' }, @@ -315,7 +315,7 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: { id: '1' } }, ]); }); @@ -329,7 +329,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: { id: '1' }, @@ -340,7 +340,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(3); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: { id: '1' } }, { ...mocks[0].result, error: undefined, variables: { id: '1' } }, ]); @@ -357,7 +357,7 @@ describe('useSuspenseQuery', () => { expect(screen.getByText('loading')).toBeInTheDocument(); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: { id: '1' }, @@ -580,7 +580,7 @@ describe('useSuspenseQuery', () => { expect(result.current.data).toEqual({ greeting: 'local hello' }) ); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { greeting: 'local hello' }, error: undefined, variables: {} }, ]); }); @@ -600,7 +600,7 @@ describe('useSuspenseQuery', () => { { cache, mocks } ); - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { greeting: 'hello from cache' }, error: undefined, variables: {}, @@ -608,7 +608,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(1); expect(renders.suspenseCount).toBe(0); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { greeting: 'hello from cache' }, error: undefined, @@ -659,14 +659,14 @@ describe('useSuspenseQuery', () => { ); expect(renders.suspenseCount).toBe(0); - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { character: { id: '1' } }, error: undefined, variables: {}, }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, @@ -675,7 +675,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(0); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { character: { id: '1' } }, error: undefined, variables: {} }, { ...mocks[0].result, error: undefined, variables: {} }, ]); @@ -711,14 +711,14 @@ describe('useSuspenseQuery', () => { ); expect(renders.suspenseCount).toBe(0); - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { character: { id: '1' } }, error: undefined, variables: { id: '1' }, }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: { id: '1' }, @@ -728,7 +728,7 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, variables: { id: '2' }, @@ -737,7 +737,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { character: { id: '1' } }, error: undefined, @@ -773,7 +773,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, @@ -782,7 +782,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); }); @@ -831,7 +831,7 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, @@ -840,7 +840,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: {} }, ]); }); @@ -861,7 +861,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, @@ -872,7 +872,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); expect(cachedData).toEqual({ greeting: 'hello from cache' }); @@ -889,7 +889,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, @@ -898,20 +898,20 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); rerender(); - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, }); expect(renders.count).toBe(3); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { greeting: 'Hello' }, error: undefined, variables: {} }, { data: { greeting: 'Hello' }, error: undefined, variables: {} }, ]); @@ -961,7 +961,7 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, @@ -970,7 +970,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: {} }, ]); }); @@ -990,14 +990,14 @@ describe('useSuspenseQuery', () => { { cache, mocks } ); - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { greeting: 'hello from cache' }, error: undefined, variables: {}, }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, @@ -1006,7 +1006,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(0); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { greeting: 'hello from cache' }, error: undefined, @@ -1058,14 +1058,14 @@ describe('useSuspenseQuery', () => { ); expect(renders.suspenseCount).toBe(0); - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { character: { id: '1' } }, error: undefined, variables: {}, }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, @@ -1074,7 +1074,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(0); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { character: { id: '1' } }, error: undefined, variables: {} }, { ...mocks[0].result, error: undefined, variables: {} }, ]); @@ -1110,14 +1110,14 @@ describe('useSuspenseQuery', () => { ); expect(renders.suspenseCount).toBe(0); - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { character: { id: '1' } }, error: undefined, variables: { id: '1' }, }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: { id: '1' }, @@ -1127,7 +1127,7 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, variables: { id: '2' }, @@ -1136,7 +1136,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { character: { id: '1' } }, error: undefined, @@ -1178,7 +1178,7 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: { id: '1' }, @@ -1188,7 +1188,7 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, variables: { id: '2' }, @@ -1202,7 +1202,7 @@ describe('useSuspenseQuery', () => { // 4. Unsuspend and return results from refetch expect(renders.count).toBe(4); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: { id: '1' } }, { ...mocks[0].result, error: undefined, variables: { id: '1' } }, { ...mocks[1].result, error: undefined, variables: { id: '2' } }, @@ -1285,7 +1285,7 @@ describe('useSuspenseQuery', () => { }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { greeting: 'Updated hello' }, error: undefined, variables: {}, @@ -1293,7 +1293,7 @@ describe('useSuspenseQuery', () => { }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(3); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: {} }, { data: { greeting: 'Updated hello' }, @@ -1329,14 +1329,14 @@ describe('useSuspenseQuery', () => { // Wait for a while to ensure no updates happen asynchronously await wait(100); - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: {} }, ]); }); @@ -1358,7 +1358,7 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: { id: '1' }, @@ -1368,7 +1368,7 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, variables: { id: '2' }, @@ -1383,7 +1383,7 @@ describe('useSuspenseQuery', () => { // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: { id: '1' } }, { ...mocks[0].result, error: undefined, variables: { id: '1' } }, { ...mocks[1].result, error: undefined, variables: { id: '2' } }, @@ -1429,7 +1429,7 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, variables: {}, @@ -1439,7 +1439,7 @@ describe('useSuspenseQuery', () => { rerender({ query: query2 }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, variables: {}, @@ -1454,7 +1454,7 @@ describe('useSuspenseQuery', () => { // 5. Unsuspend and return results from refetch expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: {} }, { ...mocks[0].result, error: undefined, variables: {} }, { ...mocks[1].result, error: undefined, variables: {} }, @@ -1545,7 +1545,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: {} }, ]); }); @@ -1569,14 +1569,14 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, variables: { id: '2' }, }); }); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); @@ -1613,7 +1613,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, @@ -1625,7 +1625,7 @@ describe('useSuspenseQuery', () => { rerender({ source: 'rerender' }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { vars: { source: 'rerender', globalOnlyVar: true, localOnlyVar: true }, }, @@ -1638,7 +1638,7 @@ describe('useSuspenseQuery', () => { }); }); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, @@ -1698,7 +1698,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { vars: { source: 'local' } }, error: undefined, variables: { source: 'local' }, @@ -1706,12 +1706,13 @@ describe('useSuspenseQuery', () => { }); // Check to make sure the property itself is not defined, not just set to - // undefined. Unfortunately this is not caught by toEqual as toEqual only - // checks if the values are equal, not if they have the same keys + // undefined. Unfortunately this is not caught by toMatchObject as + // toMatchObject only checks a if the subset of options are equal, not if + // they have strictly the same keys and values. expect(result.current.variables).not.toHaveProperty('globalOnlyVar'); expect(result.current.data.vars).not.toHaveProperty('globalOnlyVar'); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { vars: { source: 'local' } }, error: undefined, @@ -1748,7 +1749,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { context: { valueA: 'A', valueB: 'B' } }, error: undefined, variables: {}, @@ -1907,7 +1908,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: undefined, error: undefined, variables: {}, @@ -1918,7 +1919,7 @@ describe('useSuspenseQuery', () => { expect(renders.errors).toEqual([]); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: undefined, error: undefined, variables: {} }, ]); }); @@ -1934,7 +1935,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: undefined, error: undefined, variables: {}, @@ -1945,7 +1946,7 @@ describe('useSuspenseQuery', () => { expect(renders.errors).toEqual([]); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: undefined, error: undefined, variables: {} }, ]); }); @@ -1962,14 +1963,14 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { currentUser: { id: '1', name: null } }, error: undefined, variables: {}, }); }); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { currentUser: { id: '1', name: null } }, error: undefined, @@ -1992,14 +1993,14 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: undefined, error: undefined, variables: {}, }); }); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: undefined, error: undefined, @@ -2019,7 +2020,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: undefined, error: new ApolloError({ networkError }), variables: {}, @@ -2030,7 +2031,7 @@ describe('useSuspenseQuery', () => { expect(renders.errors).toEqual([]); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: undefined, error: new ApolloError({ networkError }), @@ -2056,7 +2057,7 @@ describe('useSuspenseQuery', () => { ); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: undefined, error: new ApolloError({ graphQLErrors: [graphQLError] }), variables: {}, @@ -2067,7 +2068,7 @@ describe('useSuspenseQuery', () => { expect(renders.errors).toEqual([]); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: undefined, error: new ApolloError({ graphQLErrors: [graphQLError] }), @@ -2098,7 +2099,7 @@ describe('useSuspenseQuery', () => { const expectedError = new ApolloError({ graphQLErrors }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: undefined, error: expectedError, variables: {}, @@ -2109,7 +2110,7 @@ describe('useSuspenseQuery', () => { expect(renders.errors).toEqual([]); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: undefined, error: expectedError, @@ -2140,14 +2141,14 @@ describe('useSuspenseQuery', () => { const expectedError = new ApolloError({ graphQLErrors: [graphQLError] }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: { currentUser: { id: '1', name: null } }, error: expectedError, variables: {}, }); }); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: { currentUser: { id: '1', name: null } }, error: expectedError, @@ -2215,7 +2216,7 @@ describe('useSuspenseQuery', () => { const expectedError = new ApolloError({ graphQLErrors }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: undefined, error: expectedError, variables: { id: '1' }, @@ -2225,7 +2226,7 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, variables: { id: '2' }, @@ -2236,7 +2237,7 @@ describe('useSuspenseQuery', () => { expect(renders.errorCount).toBe(0); expect(renders.errors).toEqual([]); expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: undefined, error: expectedError, variables: { id: '1' } }, { data: undefined, error: expectedError, variables: { id: '1' } }, { ...mocks[1].result, error: undefined, variables: { id: '2' } }, @@ -2283,7 +2284,7 @@ describe('useSuspenseQuery', () => { const expectedError = new ApolloError({ graphQLErrors }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ data: undefined, error: expectedError, variables: { id: '1' }, @@ -2293,7 +2294,7 @@ describe('useSuspenseQuery', () => { rerender({ id: '2' }); await waitFor(() => { - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, variables: { id: '2' }, @@ -2304,7 +2305,7 @@ describe('useSuspenseQuery', () => { expect(renders.errorCount).toBe(0); expect(renders.errors).toEqual([]); expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toEqual([ + expect(renders.frames).toMatchObject([ { data: undefined, error: expectedError, variables: { id: '1' } }, { data: undefined, error: expectedError, variables: { id: '1' } }, { ...mocks[1].result, error: undefined, variables: { id: '2' } }, From 565c1eb575a11322c079cf8b7190b57bf3bdc146 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 17 Nov 2022 17:54:55 -0700 Subject: [PATCH 094/159] Implement refetch ability with useSuspenseQuery --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 171 ++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 19 +- 2 files changed, 186 insertions(+), 4 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 0486943d35d..8b52bfb6303 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -2311,4 +2311,175 @@ describe('useSuspenseQuery', () => { { ...mocks[1].result, error: undefined, variables: { id: '2' } }, ]); }); + + it('re-suspends when calling `refetch`', async () => { + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel (updated)' } }, + }, + }, + ]; + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { id: '1' } }), + { mocks, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch(); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '1' } }, + ]); + }); + + it('re-suspends when calling `refetch` with new variables', async () => { + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '2' } }, + result: { + data: { user: { id: '2', name: 'Captain America' } }, + }, + }, + ]; + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { id: '1' } }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch({ id: '2' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + variables: { id: '2' }, + }); + }); + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + ]); + }); + + it('does not suspend and returns previous data when calling `refetch` and using an "initial" suspensePolicy', async () => { + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel (updated)' } }, + }, + }, + ]; + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(query, { + suspensePolicy: 'initial', + variables: { id: '1' }, + }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch(); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + expect(renders.count).toBe(3); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '1' } }, + ]); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 85990920eb5..2273b933f98 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -21,7 +21,10 @@ import { invariant } from '../../utilities/globals'; import { compact, isNonEmptyArray } from '../../utilities'; import { useApolloClient } from './useApolloClient'; import { DocumentType, verifyDocumentType } from '../parser'; -import { SuspenseQueryHookOptions } from '../types/types'; +import { + SuspenseQueryHookOptions, + ObservableQueryFields, +} from '../types/types'; import { useSuspenseCache } from './useSuspenseCache'; import { useSyncExternalStore } from './useSyncExternalStore'; @@ -32,6 +35,7 @@ export interface UseSuspenseQueryResult< data: TData; error: ApolloError | undefined; variables: TVariables; + refetch: ObservableQueryFields['refetch']; } const SUPPORTED_FETCH_POLICIES: WatchQueryFetchPolicy[] = [ @@ -82,7 +86,7 @@ export function useSuspenseQuery_experimental< variables: compact({ ...defaultOptions.variables, ...variables }), }; }, [options, query, client.defaultOptions.watchQuery]); - const { errorPolicy, returnPartialData, variables } = watchQueryOptions; + const { errorPolicy, returnPartialData } = watchQueryOptions; if (!hasRunValidations.current) { validateOptions(watchQueryOptions); @@ -103,7 +107,7 @@ export function useSuspenseQuery_experimental< resultRef.current = observable.getCurrentResult(); } - let cacheEntry = suspenseCache.getVariables(observable, variables); + let cacheEntry = suspenseCache.getVariables(observable, observable.variables); const result = useSyncExternalStore( useCallback( @@ -178,7 +182,7 @@ export function useSuspenseQuery_experimental< const promise = observable.reobserve(watchQueryOptions); cacheEntry = suspenseCache.setVariables( observable, - watchQueryOptions.variables, + observable.variables, promise ); } @@ -214,6 +218,13 @@ export function useSuspenseQuery_experimental< data: result.data, error: errorPolicy === 'all' ? toApolloError(result) : void 0, variables: observable.variables as TVariables, + refetch: (variables?: Partial) => { + const promise = observable.refetch(variables); + + suspenseCache.setVariables(observable, observable.variables, promise); + + return promise; + }, }; }, [result, observable, errorPolicy]); } From a95a19e9324ccb8c6508a8521f29df4312f766cd Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 18 Nov 2022 14:28:09 -0700 Subject: [PATCH 095/159] Minor tweaks to test setup --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 8b52bfb6303..5d5db359016 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -56,12 +56,12 @@ function renderSuspenseHook( return
loading
; } - const errorBoundaryProps: ErrorBoundaryProps = { - fallback:
Error
, - onError: (error) => { - renders.errorCount++; - renders.errors.push(error); - }, + const renders: Renders = { + errors: [], + errorCount: 0, + suspenseCount: 0, + count: 0, + frames: [], }; const { @@ -70,6 +70,14 @@ function renderSuspenseHook( link, mocks = [], wrapper = ({ children }) => { + const errorBoundaryProps: ErrorBoundaryProps = { + fallback:
Error
, + onError: (error) => { + renders.errorCount++; + renders.errors.push(error); + }, + }; + return client ? ( @@ -87,14 +95,6 @@ function renderSuspenseHook( ...renderHookOptions } = options; - const renders: Renders = { - errors: [], - errorCount: 0, - suspenseCount: 0, - count: 0, - frames: [], - }; - const result = renderHook( (props) => { renders.count++; From db0152812be5aedd20d2073fa3730b5af45388d9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 18 Nov 2022 15:12:39 -0700 Subject: [PATCH 096/159] Add implementation to throw an error after refetch returns error --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 58 +++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 24 +++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 5d5db359016..ffad3ad2cff 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -2482,4 +2482,62 @@ describe('useSuspenseQuery', () => { { ...mocks[1].result, error: undefined, variables: { id: '1' } }, ]); }); + + it('throws errors when errors are returned after calling `refetch`', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + errors: [new GraphQLError('Something went wrong')], + }, + }, + ]; + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { id: '1' } }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch(); + + await waitFor(() => { + expect(renders.errorCount).toBe(1); + }); + + expect(renders.errors).toEqual([ + new ApolloError({ + graphQLErrors: [new GraphQLError('Something went wrong')], + }), + ]); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + ]); + + consoleSpy.mockRestore(); + }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 2273b933f98..e96318bf058 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -125,7 +125,7 @@ export function useSuspenseQuery_experimental< observable.options.fetchPolicy = 'cache-only'; } - const subscription = observable.subscribe(() => { + function onNext() { const previousResult = resultRef.current!; const result = observable.getCurrentResult(); @@ -139,7 +139,25 @@ export function useSuspenseQuery_experimental< resultRef.current = result; forceUpdate(); - }); + } + + function onError() { + const previousResult = resultRef.current!; + const result = observable.getCurrentResult(); + + if ( + previousResult.loading === result.loading && + previousResult.networkStatus === result.networkStatus && + equal(previousResult.data, result.data) + ) { + return; + } + + resultRef.current = result; + forceUpdate(); + } + + let subscription = observable.subscribe(onNext, onError); observable.options.fetchPolicy = originalFetchPolicy; @@ -193,7 +211,7 @@ export function useSuspenseQuery_experimental< } } - if (result.error && watchQueryOptions.errorPolicy === 'none') { + if (result.error && errorPolicy === 'none') { throw result.error; } From 35354c27a08fca51a2bd570bbcd1fc736a21c9d7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 18 Nov 2022 15:25:53 -0700 Subject: [PATCH 097/159] Add tests to check error behavior when calling refetch --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index ffad3ad2cff..5f981f681d4 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -2540,4 +2540,122 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); + + it('ignores errors returned after calling `refetch` when errorPolicy is set to "ignore"', async () => { + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + errors: [new GraphQLError('Something went wrong')], + }, + }, + ]; + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(query, { + errorPolicy: 'ignore', + variables: { id: '1' }, + }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch(); + + await wait(100); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + ]); + }); + + it('returns errors after calling `refetch` when errorPolicy is set to "all"', async () => { + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + errors: [new GraphQLError('Something went wrong')], + }, + }, + ]; + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(query, { + errorPolicy: 'all', + variables: { id: '1' }, + }), + { mocks } + ); + + const expectedError = new ApolloError({ + graphQLErrors: [new GraphQLError('Something went wrong')], + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch(); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: expectedError, + variables: { id: '1' }, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: expectedError, variables: { id: '1' } }, + ]); + }); }); From f34040fe93294f5c949bc005b0ff43e59582b619 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 18 Nov 2022 15:35:50 -0700 Subject: [PATCH 098/159] Add test to validate partial data results for refetch --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 5f981f681d4..ab2ebfd3ed8 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -2658,4 +2658,73 @@ describe('useSuspenseQuery', () => { { ...mocks[0].result, error: expectedError, variables: { id: '1' } }, ]); }); + + it('handles partial data results after calling `refetch` when errorPolicy is set to "all"', async () => { + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: null } }, + errors: [new GraphQLError('Something went wrong')], + }, + }, + ]; + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(query, { + errorPolicy: 'all', + variables: { id: '1' }, + }), + { mocks } + ); + + const expectedError = new ApolloError({ + graphQLErrors: [new GraphQLError('Something went wrong')], + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch(); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: mocks[1].result.data, + error: expectedError, + variables: { id: '1' }, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { + data: mocks[1].result.data, + error: expectedError, + variables: { id: '1' }, + }, + ]); + }); }); From 13cd91c4bba5e261a68c939dcc990f54931d45e2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 18 Nov 2022 17:17:56 -0700 Subject: [PATCH 099/159] Add support for fetchMore with useSuspenseQuery --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 229 +++++++++++++++++- src/react/hooks/useSuspenseQuery.ts | 8 + 2 files changed, 236 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index ab2ebfd3ed8..15eb1634321 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -21,7 +21,7 @@ import { Observable, TypedDocumentNode, } from '../../../core'; -import { compact } from '../../../utilities'; +import { compact, concatPagination } from '../../../utilities'; import { MockedProvider, MockedResponse, MockLink } from '../../../testing'; import { ApolloProvider } from '../../context'; import { SuspenseCache } from '../../cache'; @@ -132,6 +132,42 @@ function useSimpleQueryCase() { return { query, mocks }; } +function usePaginatedCase() { + interface QueryData { + letters: { + name: string; + position: string; + }[]; + } + + interface Variables { + limit?: number; + offset?: number; + } + + const query: TypedDocumentNode = gql` + query letters($limit: Int, $offset: Int) { + letters(limit: $limit) { + letter + position + } + } + `; + + const data = 'ABCDEFG' + .split('') + .map((letter, index) => ({ letter, position: index + 1 })); + + const link = new ApolloLink((operation) => { + const { offset = 0, limit = 2 } = operation.variables; + const letters = data.slice(offset, offset + limit); + + return Observable.of({ data: { letters } }); + }); + + return { query, link, data }; +} + interface ErrorCaseData { currentUser: { id: string; @@ -2727,4 +2763,195 @@ describe('useSuspenseQuery', () => { }, ]); }); + + it('re-suspends when calling `fetchMore` with different variables', async () => { + const { data, query, link } = usePaginatedCase(); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { limit: 2 } }), + { link } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(0, 2) }, + error: undefined, + variables: { limit: 2 }, + }); + }); + + result.current.fetchMore({ variables: { offset: 2 } }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(2, 4) }, + error: undefined, + variables: { limit: 2 }, + }); + }); + + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { + data: { letters: data.slice(0, 2) }, + error: undefined, + variables: { limit: 2 }, + }, + { + data: { letters: data.slice(2, 4) }, + error: undefined, + variables: { limit: 2 }, + }, + ]); + }); + + it('does not re-suspend when calling `fetchMore` with different variables while using an "initial" suspense policy', async () => { + const { data, query, link } = usePaginatedCase(); + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(query, { + suspensePolicy: 'initial', + variables: { limit: 2 }, + }), + { link } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(0, 2) }, + error: undefined, + variables: { limit: 2 }, + }); + }); + + result.current.fetchMore({ variables: { offset: 2 } }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(2, 4) }, + error: undefined, + variables: { limit: 2 }, + }); + }); + + expect(renders.count).toBe(3); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { + data: { letters: data.slice(0, 2) }, + error: undefined, + variables: { limit: 2 }, + }, + { + data: { letters: data.slice(2, 4) }, + error: undefined, + variables: { limit: 2 }, + }, + ]); + }); + + it('properly uses `updateQuery` when calling `fetchMore`', async () => { + const { data, query, link } = usePaginatedCase(); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { limit: 2 } }), + { link } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(0, 2) }, + error: undefined, + variables: { limit: 2 }, + }); + }); + + result.current.fetchMore({ + variables: { offset: 2 }, + updateQuery: (prev, { fetchMoreResult }) => ({ + letters: prev.letters.concat(fetchMoreResult.letters), + }), + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(0, 4) }, + error: undefined, + variables: { limit: 2 }, + }); + }); + + expect(renders.frames).toMatchObject([ + { + data: { letters: data.slice(0, 2) }, + error: undefined, + variables: { limit: 2 }, + }, + { + data: { letters: data.slice(0, 4) }, + error: undefined, + variables: { limit: 2 }, + }, + ]); + }); + + it('properly uses cache field policies when calling `fetchMore` without `updateQuery`', async () => { + const { data, query, link } = usePaginatedCase(); + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + letters: concatPagination(), + }, + }, + }, + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { limit: 2 } }), + { cache, link } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(0, 2) }, + error: undefined, + variables: { limit: 2 }, + }); + }); + + result.current.fetchMore({ variables: { offset: 2 } }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(0, 4) }, + error: undefined, + variables: { limit: 2 }, + }); + }); + + expect(renders.frames).toMatchObject([ + { + data: { letters: data.slice(0, 2) }, + error: undefined, + variables: { limit: 2 }, + }, + { + data: { letters: data.slice(0, 4) }, + error: undefined, + variables: { limit: 2 }, + }, + ]); + }); + + it.todo('tears down subscription when throwing an error'); + it.todo('removes the query from the suspense cache when throwing an error'); + it.todo('does not oversubscribe when suspending multiple times'); + it.todo('applies nextFetchPolicy after initial suspense'); + it.todo('handles nextFetchPolicy as a function after initial suspense'); + it.todo('honors refetchWritePolicy set to "overwrite"'); + it.todo('honors refetchWritePolicy set to "merge"'); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index e96318bf058..1d52e00ee9e 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -35,6 +35,7 @@ export interface UseSuspenseQueryResult< data: TData; error: ApolloError | undefined; variables: TVariables; + fetchMore: ObservableQueryFields['fetchMore']; refetch: ObservableQueryFields['refetch']; } @@ -236,6 +237,13 @@ export function useSuspenseQuery_experimental< data: result.data, error: errorPolicy === 'all' ? toApolloError(result) : void 0, variables: observable.variables as TVariables, + fetchMore: (options) => { + const promise = observable.fetchMore(options); + + suspenseCache.setVariables(observable, observable.variables, promise); + + return promise; + }, refetch: (variables?: Partial) => { const promise = observable.refetch(variables); From 49a22aaa26073ce066450e324729a16f0217a490 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 21 Nov 2022 16:28:26 -0700 Subject: [PATCH 100/159] Add test to validate nextFetchPolicy is applied after initial suspense --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 15eb1634321..724886d2469 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -2947,11 +2947,67 @@ describe('useSuspenseQuery', () => { ]); }); + it('applies nextFetchPolicy after initial suspense', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const cache = new InMemoryCache(); + + // network-only should bypass this cached result and suspend the component + cache.writeQuery({ + query, + data: { character: { id: '1', name: 'Cached Hulk' } }, + variables: { id: '1' }, + }); + + // cache-first should read from this result on the rerender + cache.writeQuery({ + query, + data: { character: { id: '2', name: 'Cached Black Widow' } }, + variables: { id: '2' }, + }); + + const { result, renders, rerender } = renderSuspenseHook( + ({ id }) => + useSuspenseQuery(query, { + fetchPolicy: 'network-only', + // There is no way to trigger a followup query using nextFetchPolicy + // when this is a string vs a function. When changing variables, + // the `fetchPolicy` is reset back to `initialFetchPolicy` before the + // request is sent, negating the `nextFetchPolicy`. Using `refetch` or + // `fetchMore` sets the `fetchPolicy` to `network-only`, which negates + // the value. Using a function seems to be the only way to force a + // `nextFetchPolicy` without resorting to lower-level methods + // (i.e. `observable.reobserve`) + nextFetchPolicy: () => 'cache-first', + variables: { id }, + }), + { cache, mocks, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { character: { id: '2', name: 'Cached Black Widow' } }, + error: undefined, + variables: { id: '2' }, + }); + }); + + expect(renders.suspenseCount).toBe(1); + }); + it.todo('tears down subscription when throwing an error'); it.todo('removes the query from the suspense cache when throwing an error'); it.todo('does not oversubscribe when suspending multiple times'); - it.todo('applies nextFetchPolicy after initial suspense'); - it.todo('handles nextFetchPolicy as a function after initial suspense'); it.todo('honors refetchWritePolicy set to "overwrite"'); it.todo('honors refetchWritePolicy set to "merge"'); }); From 2c5c9ba19f1b9cb0cedf03e4d3969b7b3ac5bbfb Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 21 Nov 2022 16:49:01 -0700 Subject: [PATCH 101/159] Add tests to verify refetchWritePolicy is honored --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 223 +++++++++++++++++- 1 file changed, 221 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 724886d2469..f102fcc4442 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -3005,9 +3005,228 @@ describe('useSuspenseQuery', () => { expect(renders.suspenseCount).toBe(1); }); + it('honors refetchWritePolicy set to "overwrite"', async () => { + const query: TypedDocumentNode< + { primes: number[] }, + { min: number; max: number } + > = gql` + query GetPrimes($min: number, $max: number) { + primes(min: $min, max: $max) + } + `; + + const mocks = [ + { + request: { query, variables: { min: 0, max: 12 } }, + result: { data: { primes: [2, 3, 5, 7, 11] } }, + }, + { + request: { query, variables: { min: 12, max: 30 } }, + result: { data: { primes: [13, 17, 19, 23, 29] } }, + delay: 10, + }, + ]; + + const mergeParams: [number[] | undefined, number[]][] = []; + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing: number[] | undefined, incoming: number[]) { + mergeParams.push([existing, incoming]); + return existing ? existing.concat(incoming) : incoming; + }, + }, + }, + }, + }, + }); + + const { result } = renderSuspenseHook( + () => + useSuspenseQuery(query, { + variables: { min: 0, max: 12 }, + refetchWritePolicy: 'overwrite', + }), + { cache, mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { min: 0, max: 12 }, + }); + }); + + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + + result.current.refetch({ min: 12, max: 30 }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + variables: { min: 12, max: 30 }, + }); + }); + + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [undefined, [13, 17, 19, 23, 29]], + ]); + }); + + it('honors refetchWritePolicy set to "merge"', async () => { + const query: TypedDocumentNode< + { primes: number[] }, + { min: number; max: number } + > = gql` + query GetPrimes($min: number, $max: number) { + primes(min: $min, max: $max) + } + `; + + const mocks = [ + { + request: { query, variables: { min: 0, max: 12 } }, + result: { data: { primes: [2, 3, 5, 7, 11] } }, + }, + { + request: { query, variables: { min: 12, max: 30 } }, + result: { data: { primes: [13, 17, 19, 23, 29] } }, + delay: 10, + }, + ]; + + const mergeParams: [number[] | undefined, number[]][] = []; + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing: number[] | undefined, incoming: number[]) { + mergeParams.push([existing, incoming]); + return existing ? existing.concat(incoming) : incoming; + }, + }, + }, + }, + }, + }); + + const { result } = renderSuspenseHook( + () => + useSuspenseQuery(query, { + variables: { min: 0, max: 12 }, + refetchWritePolicy: 'merge', + }), + { cache, mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { min: 0, max: 12 }, + }); + }); + + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + + result.current.refetch({ min: 12, max: 30 }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] }, + error: undefined, + variables: { min: 12, max: 30 }, + }); + }); + + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [ + [2, 3, 5, 7, 11], + [13, 17, 19, 23, 29], + ], + ]); + }); + + it('defaults refetchWritePolicy to "overwrite"', async () => { + const query: TypedDocumentNode< + { primes: number[] }, + { min: number; max: number } + > = gql` + query GetPrimes($min: number, $max: number) { + primes(min: $min, max: $max) + } + `; + + const mocks = [ + { + request: { query, variables: { min: 0, max: 12 } }, + result: { data: { primes: [2, 3, 5, 7, 11] } }, + }, + { + request: { query, variables: { min: 12, max: 30 } }, + result: { data: { primes: [13, 17, 19, 23, 29] } }, + delay: 10, + }, + ]; + + const mergeParams: [number[] | undefined, number[]][] = []; + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + primes: { + keyArgs: false, + merge(existing: number[] | undefined, incoming: number[]) { + mergeParams.push([existing, incoming]); + return existing ? existing.concat(incoming) : incoming; + }, + }, + }, + }, + }, + }); + + const { result } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { min: 0, max: 12 } }), + { cache, mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { min: 0, max: 12 }, + }); + }); + + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + + result.current.refetch({ min: 12, max: 30 }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + variables: { min: 12, max: 30 }, + }); + }); + + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [undefined, [13, 17, 19, 23, 29]], + ]); + }); + it.todo('tears down subscription when throwing an error'); it.todo('removes the query from the suspense cache when throwing an error'); it.todo('does not oversubscribe when suspending multiple times'); - it.todo('honors refetchWritePolicy set to "overwrite"'); - it.todo('honors refetchWritePolicy set to "merge"'); }); From 2125c1772ae1b95253e22840afb2b24965de15c7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 21 Nov 2022 18:27:53 -0700 Subject: [PATCH 102/159] Add test verifying observable is unsubscribed properly --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 139 +++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index f102fcc4442..2d16ca62cca 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1847,6 +1847,144 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); + it('tears down subscription when throwing an error', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { query, mocks } = useErrorCase({ + networkError: new Error('Could not fetch'), + }); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { renders } = renderSuspenseHook(() => useSuspenseQuery(query), { + client, + }); + + await waitFor(() => expect(renders.errorCount).toBe(1)); + + expect(client.getObservableQueries().size).toBe(0); + + consoleSpy.mockRestore(); + }); + + it('tears down subscription when throwing an error on refetch', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + errors: [new GraphQLError('Something went wrong')], + }, + }, + ]; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { id: '1' } }), + { client } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch(); + + await waitFor(() => expect(renders.errorCount).toBe(1)); + + expect(client.getObservableQueries().size).toBe(0); + + consoleSpy.mockRestore(); + }); + + // This test seems to rethrow the error somewhere which causes jest to crash. + // Ideally we are able to test this functionality, but I can't seem to get it + // to behave properly. + it.skip('tears down subscription when throwing an error on refetch when suspensePolicy is "initial"', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + errors: [new GraphQLError('Something went wrong')], + }, + }, + ]; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { result, renders } = renderSuspenseHook( + () => + useSuspenseQuery(query, { + suspensePolicy: 'initial', + variables: { id: '1' }, + }), + { client } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch(); + + await waitFor(() => expect(renders.errorCount).toBe(1)); + + expect(client.getObservableQueries().size).toBe(0); + + consoleSpy.mockRestore(); + }); + it('throws network errors when errorPolicy is set to "none"', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); @@ -3226,7 +3364,6 @@ describe('useSuspenseQuery', () => { ]); }); - it.todo('tears down subscription when throwing an error'); it.todo('removes the query from the suspense cache when throwing an error'); it.todo('does not oversubscribe when suspending multiple times'); }); From f7310e5c99596f1862ae3c3b55da726912ac86ce Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 22 Nov 2022 16:37:24 -0700 Subject: [PATCH 103/159] Ignore useSuspenseQuery tests in React 17 --- config/jest.config.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/config/jest.config.js b/config/jest.config.js index 21bbe7dad47..3c45812e17d 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -25,6 +25,13 @@ const defaults = { const ignoreTSFiles = '.ts$'; const ignoreTSXFiles = '.tsx$'; +const react17TestFileIgnoreList = [ + ignoreTSFiles, + // For now, we only support useSuspenseQuery with React 18, so no need to test + // it with React 17 + 'src/react/hooks/__tests__/useSuspenseQuery.test.tsx' +] + const react18TestFileIgnoreList = [ // ignore core tests (.ts files) as they are run separately // to avoid running them twice with both react versions @@ -68,7 +75,7 @@ const standardReact18Config = { const standardReact17Config = { ...defaults, displayName: "ReactDOM 17", - testPathIgnorePatterns: [ignoreTSFiles], + testPathIgnorePatterns: react17TestFileIgnoreList, moduleNameMapper: { "^react$": "react-17", "^react-dom$": "react-dom-17", From bb2412732a14dbec7fea84c82fd8ada035e412cb Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 23 Nov 2022 11:18:18 -0700 Subject: [PATCH 104/159] Ensure TVariables is passed to TypedDocumentNode in getQuery --- src/react/cache/SuspenseCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index cfd27cb8e20..beb65f3bd8d 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -32,7 +32,7 @@ export class SuspenseCache { TData = any, TVariables extends OperationVariables = OperationVariables >( - query: DocumentNode | TypedDocumentNode + query: DocumentNode | TypedDocumentNode ): ObservableQuery | undefined { return this.queries.get(query) as ObservableQuery; } From 3712a8cb2dd5661962cdf23459f8bb7503811c0a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 28 Nov 2022 16:51:23 -0700 Subject: [PATCH 105/159] Rename resolved property to fulfilled --- src/react/cache/SuspenseCache.ts | 6 +++--- src/react/hooks/useSuspenseQuery.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index beb65f3bd8d..541af813206 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -8,7 +8,7 @@ import { import { canonicalStringify } from '../../cache'; interface CacheEntry { - resolved: boolean; + fulfilled: boolean; promise: Promise>; } @@ -67,9 +67,9 @@ export class SuspenseCache { promise: Promise> ) { const entry: CacheEntry = { - resolved: false, + fulfilled: false, promise: promise.finally(() => { - entry.resolved = true; + entry.fulfilled = true; }), }; diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 1d52e00ee9e..64b2728e111 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -122,7 +122,7 @@ export function useSuspenseQuery_experimental< // subscription is created, then reset it back to its original. const originalFetchPolicy = watchQueryOptions.fetchPolicy; - if (cacheEntry?.resolved) { + if (cacheEntry?.fulfilled) { observable.options.fetchPolicy = 'cache-only'; } @@ -205,7 +205,7 @@ export function useSuspenseQuery_experimental< promise ); } - if (!cacheEntry.resolved) { + if (!cacheEntry.fulfilled) { throw cacheEntry.promise; } } From 118dd62e8d3959d2f5f0eed15d7af0cc446baedb Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 28 Nov 2022 18:29:29 -0700 Subject: [PATCH 106/159] Check to ensure result for same variables is the same object --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 2d16ca62cca..f7bfec01d07 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -372,8 +372,11 @@ describe('useSuspenseQuery', () => { }); }); + const previousResult = result.current; + rerender({ id: '1' }); + expect(result.current).toBe(previousResult); expect(renders.count).toBe(3); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ From 8e9d66c7303f409d031be2c6f678658394eabbb1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 28 Nov 2022 19:22:41 -0700 Subject: [PATCH 107/159] Add test to validate multiple refetches result in suspensions --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index f7bfec01d07..24bdb33b3d3 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -2600,6 +2600,78 @@ describe('useSuspenseQuery', () => { ]); }); + it('re-suspends multiple times when calling `refetch` multiple times', async () => { + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel (updated)' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel (updated again)' } }, + }, + }, + ]; + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { id: '1' } }), + { mocks, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch(); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + result.current.refetch(); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[2].result, + error: undefined, + variables: { id: '1' }, + }); + }); + + expect(renders.suspenseCount).toBe(3); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[1].result, error: undefined, variables: { id: '1' } }, + { ...mocks[2].result, error: undefined, variables: { id: '1' } }, + ]); + }); + it('does not suspend and returns previous data when calling `refetch` and using an "initial" suspensePolicy', async () => { const query = gql` query UserQuery($id: String!) { From b4d17d17bc1e817c72ff32101104c725bdd77246 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 29 Nov 2022 13:48:02 -0700 Subject: [PATCH 108/159] Allow suspenseCache in renderSuspenseHook helper --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 24bdb33b3d3..ec9a3888fd2 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -36,6 +36,7 @@ type RenderSuspenseHookOptions< link?: ApolloLink; cache?: ApolloCache; mocks?: MockedResponse[]; + suspenseCache?: SuspenseCache; }; interface Renders { @@ -69,6 +70,7 @@ function renderSuspenseHook( client, link, mocks = [], + suspenseCache = new SuspenseCache(), wrapper = ({ children }) => { const errorBoundaryProps: ErrorBoundaryProps = { fallback:
Error
, @@ -79,13 +81,18 @@ function renderSuspenseHook( }; return client ? ( - + }>{children} ) : ( - + }>{children} @@ -525,13 +532,7 @@ describe('useSuspenseQuery', () => { const { result, unmount } = renderSuspenseHook( () => useSuspenseQuery(query), - { - wrapper: ({ children }) => ( - - {children} - - ), - } + { client, suspenseCache } ); // We don't subscribe to the observable until after the component has been From 4d7fb8c7762e29f164a0b3db74f20af59bfae373 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 29 Nov 2022 14:28:04 -0700 Subject: [PATCH 109/159] Add check of render count on multiple refetch --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index ec9a3888fd2..69c0079a73f 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -2665,6 +2665,7 @@ describe('useSuspenseQuery', () => { }); }); + expect(renders.count).toBe(6); expect(renders.suspenseCount).toBe(3); expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined, variables: { id: '1' } }, From 5e950867d0253c9b053b1d36635787bd31b75f0d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 29 Nov 2022 14:38:17 -0700 Subject: [PATCH 110/159] Fix issue where a component was rendered before the promise resolved --- src/react/hooks/useSuspenseQuery.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 64b2728e111..c54d4012797 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -60,6 +60,7 @@ export function useSuspenseQuery_experimental< const suspenseCache = useSuspenseCache(); const hasRunValidations = useRef(false); const client = useApolloClient(options.client); + const isSuspendedRef = useRef(false); const watchQueryOptions: WatchQueryOptions = useDeepMemo(() => { const { @@ -139,7 +140,10 @@ export function useSuspenseQuery_experimental< } resultRef.current = result; - forceUpdate(); + + if (!isSuspendedRef.current) { + forceUpdate(); + } } function onError() { @@ -155,7 +159,10 @@ export function useSuspenseQuery_experimental< } resultRef.current = result; - forceUpdate(); + + if (!isSuspendedRef.current) { + forceUpdate(); + } } let subscription = observable.subscribe(onNext, onError); @@ -206,6 +213,7 @@ export function useSuspenseQuery_experimental< ); } if (!cacheEntry.fulfilled) { + isSuspendedRef.current = true; throw cacheEntry.promise; } } @@ -216,6 +224,13 @@ export function useSuspenseQuery_experimental< throw result.error; } + // Unlike useEffect, useLayoutEffect will run its effects again when + // resuspending a component. This ensures we can detect when we've resumed + // rendering after suspending the component. + useLayoutEffect(() => { + isSuspendedRef.current = false; + }, []); + useEffect(() => { if ( watchQueryOptions.variables !== previousOptsRef.current?.variables || From 4b6a63b17f13fb8cce12598a27a276e7d0328c78 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 29 Nov 2022 15:42:59 -0700 Subject: [PATCH 111/159] Remove variables from the result of useSuspenseQuery --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 333 ++++-------------- src/react/hooks/useSuspenseQuery.ts | 2 - 2 files changed, 76 insertions(+), 259 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 69c0079a73f..71ade4e8b95 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -329,14 +329,13 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined }, ]); }); @@ -352,14 +351,13 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined }, ]); }); @@ -375,7 +373,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -387,8 +384,8 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(3); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, ]); }); @@ -406,7 +403,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -621,7 +617,7 @@ describe('useSuspenseQuery', () => { ); expect(renders.frames).toMatchObject([ - { data: { greeting: 'local hello' }, error: undefined, variables: {} }, + { data: { greeting: 'local hello' }, error: undefined }, ]); }); @@ -643,17 +639,12 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { greeting: 'hello from cache' }, error: undefined, - variables: {}, }); expect(renders.count).toBe(1); expect(renders.suspenseCount).toBe(0); expect(renders.frames).toMatchObject([ - { - data: { greeting: 'hello from cache' }, - error: undefined, - variables: {}, - }, + { data: { greeting: 'hello from cache' }, error: undefined }, ]); }); @@ -702,22 +693,20 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { character: { id: '1' } }, error: undefined, - variables: {}, }); await waitFor(() => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); }); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(0); expect(renders.frames).toMatchObject([ - { data: { character: { id: '1' } }, error: undefined, variables: {} }, - { ...mocks[0].result, error: undefined, variables: {} }, + { data: { character: { id: '1' } }, error: undefined }, + { ...mocks[0].result, error: undefined }, ]); }); @@ -754,14 +743,12 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { character: { id: '1' } }, error: undefined, - variables: { id: '1' }, }); await waitFor(() => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -771,29 +758,16 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '2' }, }); }); expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { - data: { character: { id: '1' } }, - error: undefined, - variables: { id: '1' }, - }, - { - ...mocks[0].result, - error: undefined, - variables: { id: '1' }, - }, - { - ...mocks[0].result, - error: undefined, - variables: { id: '1' }, - }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { data: { character: { id: '1' } }, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, ]); }); @@ -816,14 +790,13 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); }); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined }, ]); }); @@ -874,14 +847,13 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); }); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined }, ]); }); @@ -904,7 +876,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); }); @@ -913,7 +884,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined }, ]); expect(cachedData).toEqual({ greeting: 'hello from cache' }); }); @@ -932,14 +903,13 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); }); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined }, ]); rerender(); @@ -947,13 +917,12 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); expect(renders.count).toBe(3); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined }, + { data: { greeting: 'Hello' }, error: undefined }, ]); }); @@ -1004,14 +973,13 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); }); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined }, ]); }); @@ -1033,14 +1001,12 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { greeting: 'hello from cache' }, error: undefined, - variables: {}, }); await waitFor(() => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); }); @@ -1050,9 +1016,8 @@ describe('useSuspenseQuery', () => { { data: { greeting: 'hello from cache' }, error: undefined, - variables: {}, }, - { data: { greeting: 'Hello' }, error: undefined, variables: {} }, + { data: { greeting: 'Hello' }, error: undefined }, ]); }); @@ -1101,22 +1066,20 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { character: { id: '1' } }, error: undefined, - variables: {}, }); await waitFor(() => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); }); expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(0); expect(renders.frames).toMatchObject([ - { data: { character: { id: '1' } }, error: undefined, variables: {} }, - { ...mocks[0].result, error: undefined, variables: {} }, + { data: { character: { id: '1' } }, error: undefined }, + { ...mocks[0].result, error: undefined }, ]); }); @@ -1153,14 +1116,12 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { character: { id: '1' } }, error: undefined, - variables: { id: '1' }, }); await waitFor(() => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -1170,29 +1131,16 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '2' }, }); }); expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { - data: { character: { id: '1' } }, - error: undefined, - variables: { id: '1' }, - }, - { - ...mocks[0].result, - error: undefined, - variables: { id: '1' }, - }, - { - ...mocks[0].result, - error: undefined, - variables: { id: '1' }, - }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { data: { character: { id: '1' } }, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, ]); }); @@ -1221,7 +1169,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -1231,7 +1178,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '2' }, }); }); @@ -1243,9 +1189,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(4); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, ]); } ); @@ -1328,18 +1274,13 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { greeting: 'Updated hello' }, error: undefined, - variables: {}, }); }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(3); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: {} }, - { - data: { greeting: 'Updated hello' }, - error: undefined, - variables: {}, - }, + { ...mocks[0].result, error: undefined }, + { data: { greeting: 'Updated hello' }, error: undefined }, ]); } ); @@ -1372,12 +1313,11 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); expect(renders.suspenseCount).toBe(1); expect(renders.count).toBe(2); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined }, ]); }); @@ -1401,7 +1341,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -1411,7 +1350,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '2' }, }); }); @@ -1424,9 +1362,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, ]); } ); @@ -1472,7 +1410,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: {}, }); }); @@ -1482,7 +1419,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: {}, }); }); @@ -1495,9 +1431,9 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(5); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: {} }, - { ...mocks[0].result, error: undefined, variables: {} }, - { ...mocks[1].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, ]); } ); @@ -1586,7 +1522,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: {} }, + { ...mocks[0].result, error: undefined }, ]); }); @@ -1612,12 +1548,11 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '2' }, }); }); expect(renders.frames).toMatchObject([ - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { ...mocks[1].result, error: undefined }, ]); }); @@ -1658,7 +1593,6 @@ describe('useSuspenseQuery', () => { vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, error: undefined, - variables: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }); }); @@ -1670,11 +1604,6 @@ describe('useSuspenseQuery', () => { vars: { source: 'rerender', globalOnlyVar: true, localOnlyVar: true }, }, error: undefined, - variables: { - source: 'rerender', - globalOnlyVar: true, - localOnlyVar: true, - }, }); }); @@ -1684,25 +1613,18 @@ describe('useSuspenseQuery', () => { vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, error: undefined, - variables: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, { data: { vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, error: undefined, - variables: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, }, { data: { vars: { source: 'rerender', globalOnlyVar: true, localOnlyVar: true }, }, error: undefined, - variables: { - source: 'rerender', - globalOnlyVar: true, - localOnlyVar: true, - }, }, ]); }); @@ -1741,7 +1663,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { vars: { source: 'local' } }, error: undefined, - variables: { source: 'local' }, }); }); @@ -1749,15 +1670,10 @@ describe('useSuspenseQuery', () => { // undefined. Unfortunately this is not caught by toMatchObject as // toMatchObject only checks a if the subset of options are equal, not if // they have strictly the same keys and values. - expect(result.current.variables).not.toHaveProperty('globalOnlyVar'); expect(result.current.data.vars).not.toHaveProperty('globalOnlyVar'); expect(renders.frames).toMatchObject([ - { - data: { vars: { source: 'local' } }, - error: undefined, - variables: { source: 'local' }, - }, + { data: { vars: { source: 'local' } }, error: undefined }, ]); }); @@ -1792,7 +1708,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { context: { valueA: 'A', valueB: 'B' } }, error: undefined, - variables: {}, }); }); }); @@ -1915,7 +1830,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -1976,7 +1890,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -2089,7 +2002,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: undefined, error: undefined, - variables: {}, }); }); @@ -2098,7 +2010,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { data: undefined, error: undefined, variables: {} }, + { data: undefined, error: undefined }, ]); }); @@ -2116,7 +2028,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: undefined, error: undefined, - variables: {}, }); }); @@ -2125,7 +2036,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { data: undefined, error: undefined, variables: {} }, + { data: undefined, error: undefined }, ]); }); @@ -2144,7 +2055,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { currentUser: { id: '1', name: null } }, error: undefined, - variables: {}, }); }); @@ -2152,7 +2062,6 @@ describe('useSuspenseQuery', () => { { data: { currentUser: { id: '1', name: null } }, error: undefined, - variables: {}, }, ]); }); @@ -2174,16 +2083,11 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: undefined, error: undefined, - variables: {}, }); }); expect(renders.frames).toMatchObject([ - { - data: undefined, - error: undefined, - variables: {}, - }, + { data: undefined, error: undefined }, ]); }); @@ -2201,7 +2105,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: undefined, error: new ApolloError({ networkError }), - variables: {}, }); }); @@ -2210,11 +2113,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { - data: undefined, - error: new ApolloError({ networkError }), - variables: {}, - }, + { data: undefined, error: new ApolloError({ networkError }) }, ]); const { error } = result.current; @@ -2238,7 +2137,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: undefined, error: new ApolloError({ graphQLErrors: [graphQLError] }), - variables: {}, }); }); @@ -2250,7 +2148,6 @@ describe('useSuspenseQuery', () => { { data: undefined, error: new ApolloError({ graphQLErrors: [graphQLError] }), - variables: {}, }, ]); @@ -2280,7 +2177,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: undefined, error: expectedError, - variables: {}, }); }); @@ -2289,11 +2185,7 @@ describe('useSuspenseQuery', () => { expect(renders.count).toBe(2); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { - data: undefined, - error: expectedError, - variables: {}, - }, + { data: undefined, error: expectedError }, ]); const { error } = result.current; @@ -2322,7 +2214,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { currentUser: { id: '1', name: null } }, error: expectedError, - variables: {}, }); }); @@ -2330,7 +2221,6 @@ describe('useSuspenseQuery', () => { { data: { currentUser: { id: '1', name: null } }, error: expectedError, - variables: {}, }, ]); }); @@ -2397,7 +2287,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: undefined, error: expectedError, - variables: { id: '1' }, }); }); @@ -2407,7 +2296,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '2' }, }); }); @@ -2416,9 +2304,9 @@ describe('useSuspenseQuery', () => { expect(renders.errors).toEqual([]); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toMatchObject([ - { data: undefined, error: expectedError, variables: { id: '1' } }, - { data: undefined, error: expectedError, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { data: undefined, error: expectedError }, + { data: undefined, error: expectedError }, + { ...mocks[1].result, error: undefined }, ]); }); @@ -2465,7 +2353,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: undefined, error: expectedError, - variables: { id: '1' }, }); }); @@ -2475,7 +2362,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '2' }, }); }); @@ -2484,9 +2370,9 @@ describe('useSuspenseQuery', () => { expect(renders.errors).toEqual([]); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { data: undefined, error: expectedError, variables: { id: '1' } }, - { data: undefined, error: expectedError, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { data: undefined, error: expectedError }, + { data: undefined, error: expectedError }, + { ...mocks[1].result, error: undefined }, ]); }); @@ -2524,7 +2410,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -2534,15 +2419,14 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '1' }, }); }); expect(renders.count).toBe(4); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, ]); }); @@ -2580,7 +2464,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -2590,14 +2473,13 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '2' }, }); }); expect(renders.count).toBe(4); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '2' } }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, ]); }); @@ -2641,7 +2523,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -2651,7 +2532,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '1' }, }); }); @@ -2661,16 +2541,15 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[2].result, error: undefined, - variables: { id: '1' }, }); }); expect(renders.count).toBe(6); expect(renders.suspenseCount).toBe(3); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '1' } }, - { ...mocks[2].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, + { ...mocks[2].result, error: undefined }, ]); }); @@ -2712,7 +2591,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -2722,15 +2600,14 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { id: '1' }, }); }); expect(renders.count).toBe(3); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[1].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, ]); }); @@ -2770,7 +2647,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -2786,7 +2662,7 @@ describe('useSuspenseQuery', () => { }), ]); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined }, ]); consoleSpy.mockRestore(); @@ -2830,7 +2706,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -2841,8 +2716,8 @@ describe('useSuspenseQuery', () => { expect(renders.errorCount).toBe(0); expect(renders.errors).toEqual([]); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, ]); }); @@ -2888,7 +2763,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -2898,15 +2772,14 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: expectedError, - variables: { id: '1' }, }); }); expect(renders.errorCount).toBe(0); expect(renders.errors).toEqual([]); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { ...mocks[0].result, error: expectedError, variables: { id: '1' } }, + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: expectedError }, ]); }); @@ -2953,7 +2826,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -2963,19 +2835,14 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: mocks[1].result.data, error: expectedError, - variables: { id: '1' }, }); }); expect(renders.errorCount).toBe(0); expect(renders.errors).toEqual([]); expect(renders.frames).toMatchObject([ - { ...mocks[0].result, error: undefined, variables: { id: '1' } }, - { - data: mocks[1].result.data, - error: expectedError, - variables: { id: '1' }, - }, + { ...mocks[0].result, error: undefined }, + { data: mocks[1].result.data, error: expectedError }, ]); }); @@ -2991,7 +2858,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { letters: data.slice(0, 2) }, error: undefined, - variables: { limit: 2 }, }); }); @@ -3001,23 +2867,14 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { letters: data.slice(2, 4) }, error: undefined, - variables: { limit: 2 }, }); }); expect(renders.count).toBe(4); expect(renders.suspenseCount).toBe(2); expect(renders.frames).toMatchObject([ - { - data: { letters: data.slice(0, 2) }, - error: undefined, - variables: { limit: 2 }, - }, - { - data: { letters: data.slice(2, 4) }, - error: undefined, - variables: { limit: 2 }, - }, + { data: { letters: data.slice(0, 2) }, error: undefined }, + { data: { letters: data.slice(2, 4) }, error: undefined }, ]); }); @@ -3037,7 +2894,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { letters: data.slice(0, 2) }, error: undefined, - variables: { limit: 2 }, }); }); @@ -3047,23 +2903,14 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { letters: data.slice(2, 4) }, error: undefined, - variables: { limit: 2 }, }); }); expect(renders.count).toBe(3); expect(renders.suspenseCount).toBe(1); expect(renders.frames).toMatchObject([ - { - data: { letters: data.slice(0, 2) }, - error: undefined, - variables: { limit: 2 }, - }, - { - data: { letters: data.slice(2, 4) }, - error: undefined, - variables: { limit: 2 }, - }, + { data: { letters: data.slice(0, 2) }, error: undefined }, + { data: { letters: data.slice(2, 4) }, error: undefined }, ]); }); @@ -3079,7 +2926,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { letters: data.slice(0, 2) }, error: undefined, - variables: { limit: 2 }, }); }); @@ -3094,21 +2940,12 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { letters: data.slice(0, 4) }, error: undefined, - variables: { limit: 2 }, }); }); expect(renders.frames).toMatchObject([ - { - data: { letters: data.slice(0, 2) }, - error: undefined, - variables: { limit: 2 }, - }, - { - data: { letters: data.slice(0, 4) }, - error: undefined, - variables: { limit: 2 }, - }, + { data: { letters: data.slice(0, 2) }, error: undefined }, + { data: { letters: data.slice(0, 4) }, error: undefined }, ]); }); @@ -3134,7 +2971,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { letters: data.slice(0, 2) }, error: undefined, - variables: { limit: 2 }, }); }); @@ -3144,21 +2980,12 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { letters: data.slice(0, 4) }, error: undefined, - variables: { limit: 2 }, }); }); expect(renders.frames).toMatchObject([ - { - data: { letters: data.slice(0, 2) }, - error: undefined, - variables: { limit: 2 }, - }, - { - data: { letters: data.slice(0, 4) }, - error: undefined, - variables: { limit: 2 }, - }, + { data: { letters: data.slice(0, 2) }, error: undefined }, + { data: { letters: data.slice(0, 4) }, error: undefined }, ]); }); @@ -3203,7 +3030,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { id: '1' }, }); }); @@ -3213,7 +3039,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { character: { id: '2', name: 'Cached Black Widow' } }, error: undefined, - variables: { id: '2' }, }); }); @@ -3272,7 +3097,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { min: 0, max: 12 }, }); }); @@ -3284,7 +3108,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { min: 12, max: 30 }, }); }); @@ -3346,7 +3169,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { min: 0, max: 12 }, }); }); @@ -3358,7 +3180,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ data: { primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] }, error: undefined, - variables: { min: 12, max: 30 }, }); }); @@ -3419,7 +3240,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[0].result, error: undefined, - variables: { min: 0, max: 12 }, }); }); @@ -3431,7 +3251,6 @@ describe('useSuspenseQuery', () => { expect(result.current).toMatchObject({ ...mocks[1].result, error: undefined, - variables: { min: 12, max: 30 }, }); }); diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index c54d4012797..3948447dbd9 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -34,7 +34,6 @@ export interface UseSuspenseQueryResult< > { data: TData; error: ApolloError | undefined; - variables: TVariables; fetchMore: ObservableQueryFields['fetchMore']; refetch: ObservableQueryFields['refetch']; } @@ -251,7 +250,6 @@ export function useSuspenseQuery_experimental< return { data: result.data, error: errorPolicy === 'all' ? toApolloError(result) : void 0, - variables: observable.variables as TVariables, fetchMore: (options) => { const promise = observable.fetchMore(options); From c99b30d4708fa9d3314f19e333e6158b329eac44 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 29 Nov 2022 15:44:02 -0700 Subject: [PATCH 112/159] Fix missing import --- src/react/hooks/useSuspenseQuery.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 3948447dbd9..ff86120354b 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -5,6 +5,7 @@ import { useMemo, useState, DependencyList, + useLayoutEffect, } from 'react'; import { equal } from '@wry/equality'; import { @@ -164,7 +165,7 @@ export function useSuspenseQuery_experimental< } } - let subscription = observable.subscribe(onNext, onError); + const subscription = observable.subscribe(onNext, onError); observable.options.fetchPolicy = originalFetchPolicy; From fb320cdf683a4c814269e41c32aa4ac66732301e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 29 Nov 2022 16:27:24 -0700 Subject: [PATCH 113/159] Always run validations in dev --- src/react/hooks/useSuspenseQuery.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index ff86120354b..8e5c0cca5e5 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -58,7 +58,6 @@ export function useSuspenseQuery_experimental< options: SuspenseQueryHookOptions = Object.create(null) ): UseSuspenseQueryResult { const suspenseCache = useSuspenseCache(); - const hasRunValidations = useRef(false); const client = useApolloClient(options.client); const isSuspendedRef = useRef(false); const watchQueryOptions: WatchQueryOptions = @@ -90,9 +89,8 @@ export function useSuspenseQuery_experimental< }, [options, query, client.defaultOptions.watchQuery]); const { errorPolicy, returnPartialData } = watchQueryOptions; - if (!hasRunValidations.current) { + if (__DEV__) { validateOptions(watchQueryOptions); - hasRunValidations.current = true; } const [observable] = useState(() => { From fe322da478fd8d8dd54d6b3a7e511aebeeec16d9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 29 Nov 2022 17:14:25 -0700 Subject: [PATCH 114/159] Add test to verify observable not over subscribed --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 71ade4e8b95..8c21889b066 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -3260,6 +3260,72 @@ describe('useSuspenseQuery', () => { ]); }); - it.todo('removes the query from the suspense cache when throwing an error'); - it.todo('does not oversubscribe when suspending multiple times'); + it('does not oversubscribe when suspending multiple times', async () => { + const query = gql` + query UserQuery($id: String!) { + user(id: $id) { + id + name + } + } + `; + + const mocks = [ + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel (updated)' } }, + }, + }, + { + request: { query, variables: { id: '1' } }, + result: { + data: { user: { id: '1', name: 'Captain Marvel (updated again)' } }, + }, + }, + ]; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { result } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { id: '1' } }), + { client, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + result.current.refetch(); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + result.current.refetch(); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[2].result, + error: undefined, + }); + }); + + expect(client.getObservableQueries().size).toBe(1); + }); }); From 29f3d393c551ab31811025f8f497c84a5a44c215 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 29 Nov 2022 17:39:26 -0700 Subject: [PATCH 115/159] Add fix for rejected promise causing issue with test --- src/react/cache/SuspenseCache.ts | 23 ++++++++++++++++--- .../hooks/__tests__/useSuspenseQuery.test.tsx | 5 +--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index 541af813206..5a8428c38df 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -68,9 +68,26 @@ export class SuspenseCache { ) { const entry: CacheEntry = { fulfilled: false, - promise: promise.finally(() => { - entry.fulfilled = true; - }), + promise: promise + .then( + (result) => { + entry.result = result; + return result; + }, + (error) => { + entry.result = { + data: undefined as any, + error, + loading: false, + networkStatus: NetworkStatus.error, + }; + + return entry.result; + } + ) + .finally(() => { + entry.fulfilled = true; + }), }; const entries = this.cache.get(observable) || new Map(); diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 8c21889b066..82bd59ea355 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1842,10 +1842,7 @@ describe('useSuspenseQuery', () => { consoleSpy.mockRestore(); }); - // This test seems to rethrow the error somewhere which causes jest to crash. - // Ideally we are able to test this functionality, but I can't seem to get it - // to behave properly. - it.skip('tears down subscription when throwing an error on refetch when suspensePolicy is "initial"', async () => { + it('tears down subscription when throwing an error on refetch when suspensePolicy is "initial"', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const query = gql` From 2c5d04422f4a7a9cd308e2f5c99a7cffe2d3cbf5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 29 Nov 2022 17:42:44 -0700 Subject: [PATCH 116/159] Simplify suspense cache entry --- src/react/cache/SuspenseCache.ts | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index 5a8428c38df..b886b7f2a06 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -64,27 +64,15 @@ export class SuspenseCache { >( observable: ObservableQuery, variables: TVariables | undefined, - promise: Promise> + promise: Promise ) { const entry: CacheEntry = { fulfilled: false, promise: promise - .then( - (result) => { - entry.result = result; - return result; - }, - (error) => { - entry.result = { - data: undefined as any, - error, - loading: false, - networkStatus: NetworkStatus.error, - }; - - return entry.result; - } - ) + .catch(() => { + // Throw away the error as we only care to track when the promise has + // been fulfilled + }) .finally(() => { entry.fulfilled = true; }), From ecda3168125fe072b0ddf416584cbdf16f035561 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 29 Nov 2022 18:18:31 -0700 Subject: [PATCH 117/159] Wrap refetch/fetchMore in act to remove warnings --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 91 +++++++++++++------ 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 82bd59ea355..eae4397b8e1 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,5 +1,6 @@ import React, { Suspense } from 'react'; import { + act, screen, renderHook, waitFor, @@ -1833,7 +1834,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => expect(renders.errorCount).toBe(1)); @@ -1890,7 +1893,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => expect(renders.errorCount).toBe(1)); @@ -2410,7 +2415,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -2464,7 +2471,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch({ id: '2' }); + act(() => { + result.current.refetch({ id: '2' }); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -2523,7 +2532,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -2532,7 +2543,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -2591,7 +2604,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -2647,7 +2662,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => { expect(renders.errorCount).toBe(1); @@ -2706,9 +2723,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); - - await wait(100); + await act(async () => { + await result.current.refetch(); + }); expect(renders.errorCount).toBe(0); expect(renders.errors).toEqual([]); @@ -2763,7 +2780,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -2826,7 +2845,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -2858,7 +2879,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.fetchMore({ variables: { offset: 2 } }); + act(() => { + result.current.fetchMore({ variables: { offset: 2 } }); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -2894,7 +2917,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.fetchMore({ variables: { offset: 2 } }); + act(() => { + result.current.fetchMore({ variables: { offset: 2 } }); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -2926,11 +2951,13 @@ describe('useSuspenseQuery', () => { }); }); - result.current.fetchMore({ - variables: { offset: 2 }, - updateQuery: (prev, { fetchMoreResult }) => ({ - letters: prev.letters.concat(fetchMoreResult.letters), - }), + act(() => { + result.current.fetchMore({ + variables: { offset: 2 }, + updateQuery: (prev, { fetchMoreResult }) => ({ + letters: prev.letters.concat(fetchMoreResult.letters), + }), + }); }); await waitFor(() => { @@ -2971,7 +2998,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.fetchMore({ variables: { offset: 2 } }); + act(() => { + result.current.fetchMore({ variables: { offset: 2 } }); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -3099,7 +3128,9 @@ describe('useSuspenseQuery', () => { expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); - result.current.refetch({ min: 12, max: 30 }); + act(() => { + result.current.refetch({ min: 12, max: 30 }); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -3171,7 +3202,9 @@ describe('useSuspenseQuery', () => { expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); - result.current.refetch({ min: 12, max: 30 }); + act(() => { + result.current.refetch({ min: 12, max: 30 }); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -3242,7 +3275,9 @@ describe('useSuspenseQuery', () => { expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); - result.current.refetch({ min: 12, max: 30 }); + act(() => { + result.current.refetch({ min: 12, max: 30 }); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -3305,7 +3340,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => { expect(result.current).toMatchObject({ @@ -3314,7 +3351,9 @@ describe('useSuspenseQuery', () => { }); }); - result.current.refetch(); + act(() => { + result.current.refetch(); + }); await waitFor(() => { expect(result.current).toMatchObject({ From 33152ce6dc29f76ec0f26a93d09d8d015fc1a15e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 11:14:57 -0700 Subject: [PATCH 118/159] Use destructured values in useEffect --- src/react/hooks/useSuspenseQuery.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 8e5c0cca5e5..ea68a816e58 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -87,7 +87,7 @@ export function useSuspenseQuery_experimental< variables: compact({ ...defaultOptions.variables, ...variables }), }; }, [options, query, client.defaultOptions.watchQuery]); - const { errorPolicy, returnPartialData } = watchQueryOptions; + const { errorPolicy, returnPartialData, variables } = watchQueryOptions; if (__DEV__) { validateOptions(watchQueryOptions); @@ -231,25 +231,22 @@ export function useSuspenseQuery_experimental< useEffect(() => { if ( - watchQueryOptions.variables !== previousOptsRef.current?.variables || - watchQueryOptions.query !== previousOptsRef.current.query + variables !== previousOptsRef.current?.variables || + query !== previousOptsRef.current.query ) { const promise = observable.reobserve(watchQueryOptions); - suspenseCache.setVariables( - observable, - watchQueryOptions.variables, - promise - ); + suspenseCache.setVariables(observable, variables, promise); previousOptsRef.current = watchQueryOptions; } - }, [watchQueryOptions.variables, watchQueryOptions.query]); + }, [variables, query]); return useMemo(() => { return { data: result.data, error: errorPolicy === 'all' ? toApolloError(result) : void 0, fetchMore: (options) => { + // console.log('fetchMore', options); const promise = observable.fetchMore(options); suspenseCache.setVariables(observable, observable.variables, promise); From 5852eda4e4fe2bae4eec2e233321de4583691c38 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 11:35:27 -0700 Subject: [PATCH 119/159] Refactor useDeepMemo into its own hook file --- src/react/hooks/internal/index.ts | 2 ++ src/react/hooks/internal/useDeepMemo.ts | 15 +++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 11 +---------- 3 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 src/react/hooks/internal/index.ts create mode 100644 src/react/hooks/internal/useDeepMemo.ts diff --git a/src/react/hooks/internal/index.ts b/src/react/hooks/internal/index.ts new file mode 100644 index 00000000000..aa70141c2c1 --- /dev/null +++ b/src/react/hooks/internal/index.ts @@ -0,0 +1,2 @@ +// These hooks are used internally and are not exported publicly by the library +export { useDeepMemo } from './useDeepMemo'; diff --git a/src/react/hooks/internal/useDeepMemo.ts b/src/react/hooks/internal/useDeepMemo.ts new file mode 100644 index 00000000000..61d4dd99e7b --- /dev/null +++ b/src/react/hooks/internal/useDeepMemo.ts @@ -0,0 +1,15 @@ +import { useRef, DependencyList } from 'react'; +import { equal } from '@wry/equality'; + +export function useDeepMemo( + memoFn: () => TValue, + deps: DependencyList +) { + const ref = useRef<{ deps: DependencyList; value: TValue }>(); + + if (!ref.current || !equal(ref.current.deps, deps)) { + ref.current = { value: memoFn(), deps }; + } + + return ref.current.value; +} diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index ea68a816e58..c196b3b7e47 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -26,6 +26,7 @@ import { SuspenseQueryHookOptions, ObservableQueryFields, } from '../types/types'; +import { useDeepMemo } from './internal'; import { useSuspenseCache } from './useSuspenseCache'; import { useSyncExternalStore } from './useSyncExternalStore'; @@ -283,13 +284,3 @@ function toApolloError(result: ApolloQueryResult) { ? new ApolloError({ graphQLErrors: result.errors }) : result.error; } - -function useDeepMemo(memoFn: () => TValue, deps: DependencyList) { - const ref = useRef<{ deps: DependencyList; value: TValue }>(); - - if (!ref.current || !equal(ref.current.deps, deps)) { - ref.current = { value: memoFn(), deps }; - } - - return ref.current.value; -} From 23a2569dc933d3b66ca48ace626cbae96018d52d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 11:45:20 -0700 Subject: [PATCH 120/159] Add tests for useDeepMemo --- .../internal/__tests__/useDeepMemo.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/react/hooks/internal/__tests__/useDeepMemo.test.ts diff --git a/src/react/hooks/internal/__tests__/useDeepMemo.test.ts b/src/react/hooks/internal/__tests__/useDeepMemo.test.ts new file mode 100644 index 00000000000..2947433b4ed --- /dev/null +++ b/src/react/hooks/internal/__tests__/useDeepMemo.test.ts @@ -0,0 +1,47 @@ +import { renderHook } from '@testing-library/react'; +import { useDeepMemo } from '../useDeepMemo'; + +describe('useDeepMemo', () => { + it('ensures the value is initialized', () => { + const { result } = renderHook(() => + useDeepMemo(() => ({ test: true }), []) + ); + + expect(result.current).toEqual({ test: true }); + }); + + it('returns memoized value when its dependencies are deeply equal', () => { + const { result, rerender } = renderHook( + ({ active, items, user }) => { + useDeepMemo(() => ({ active, items, user }), [items, name, active]); + }, + { + initialProps: { + active: true, + items: [1, 2], + user: { name: 'John Doe' }, + }, + } + ); + + const previousResult = result.current; + + rerender({ active: true, items: [1, 2], user: { name: 'John Doe' } }); + + expect(result.current).toBe(previousResult); + }); + + it('returns updated value if a dependency changes', () => { + const { result, rerender } = renderHook( + ({ items }) => useDeepMemo(() => ({ items }), [items]), + { initialProps: { items: [1] } } + ); + + const previousResult = result.current; + + rerender({ items: [1, 2] }); + + expect(result.current).not.toBe(previousResult); + expect(result.current).toEqual({ items: [1, 2] }); + }); +}); From 1ca9df118e2f404a5b7457a9e902c78b0c638bc0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 11:56:53 -0700 Subject: [PATCH 121/159] Remove unused import --- src/react/hooks/useSuspenseQuery.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index c196b3b7e47..0cd43c096da 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -4,7 +4,6 @@ import { useCallback, useMemo, useState, - DependencyList, useLayoutEffect, } from 'react'; import { equal } from '@wry/equality'; From 57f7ed08a995d8dd592bcb2dab810a6ab2764eed Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 13:29:53 -0700 Subject: [PATCH 122/159] Simplify condition for setting network status --- src/react/hooks/useSuspenseQuery.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 0cd43c096da..9c00c7e542f 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -179,13 +179,10 @@ export function useSuspenseQuery_experimental< ); // Sometimes the observable reports a network status of error even - // when our error policy is set to ignore or all. + // when our error policy is set to 'ignore' or 'all'. // This patches the network status to avoid a rerender when the observable // first subscribes and gets back a ready network status. - if ( - result.networkStatus === NetworkStatus.error && - (errorPolicy === 'ignore' || errorPolicy === 'all') - ) { + if (result.networkStatus === NetworkStatus.error && errorPolicy !== 'none') { result.networkStatus = NetworkStatus.ready; } From 993f86df792edea473eacf4a5d471877f46501de Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 13:32:29 -0700 Subject: [PATCH 123/159] Use entire watchQueryOptions in useEffect since its used --- src/react/hooks/useSuspenseQuery.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 9c00c7e542f..014e074ace1 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -87,7 +87,7 @@ export function useSuspenseQuery_experimental< variables: compact({ ...defaultOptions.variables, ...variables }), }; }, [options, query, client.defaultOptions.watchQuery]); - const { errorPolicy, returnPartialData, variables } = watchQueryOptions; + const { fetchPolicy, errorPolicy, returnPartialData } = watchQueryOptions; if (__DEV__) { validateOptions(watchQueryOptions); @@ -227,6 +227,8 @@ export function useSuspenseQuery_experimental< }, []); useEffect(() => { + const { variables, query } = watchQueryOptions; + if ( variables !== previousOptsRef.current?.variables || query !== previousOptsRef.current.query @@ -236,7 +238,7 @@ export function useSuspenseQuery_experimental< suspenseCache.setVariables(observable, variables, promise); previousOptsRef.current = watchQueryOptions; } - }, [variables, query]); + }, [watchQueryOptions]); return useMemo(() => { return { From bd825013f1abb3ec2c4eef4dde3ea7a743db5784 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 13:33:59 -0700 Subject: [PATCH 124/159] Rename ref for clarity --- src/react/hooks/useSuspenseQuery.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 014e074ace1..0b21d0920a6 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -101,7 +101,7 @@ export function useSuspenseQuery_experimental< }); const resultRef = useRef>(); - const previousOptsRef = useRef(watchQueryOptions); + const previousWatchQueryOptionsRef = useRef(watchQueryOptions); if (!resultRef.current) { resultRef.current = observable.getCurrentResult(); @@ -230,13 +230,13 @@ export function useSuspenseQuery_experimental< const { variables, query } = watchQueryOptions; if ( - variables !== previousOptsRef.current?.variables || - query !== previousOptsRef.current.query + variables !== previousWatchQueryOptionsRef.current?.variables || + query !== previousWatchQueryOptionsRef.current.query ) { const promise = observable.reobserve(watchQueryOptions); suspenseCache.setVariables(observable, variables, promise); - previousOptsRef.current = watchQueryOptions; + previousWatchQueryOptionsRef.current = watchQueryOptions; } }, [watchQueryOptions]); From 72b8014bde48386ff8f3dc54150d32bf9632f1e7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 13:34:17 -0700 Subject: [PATCH 125/159] Extract custom hook for checking whether component is suspended --- src/react/hooks/useSuspenseQuery.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 0b21d0920a6..8166c9e7fc5 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -59,7 +59,7 @@ export function useSuspenseQuery_experimental< ): UseSuspenseQueryResult { const suspenseCache = useSuspenseCache(); const client = useApolloClient(options.client); - const isSuspendedRef = useRef(false); + const isSuspendedRef = useIsSuspendedRef(); const watchQueryOptions: WatchQueryOptions = useDeepMemo(() => { const { @@ -282,3 +282,20 @@ function toApolloError(result: ApolloQueryResult) { ? new ApolloError({ graphQLErrors: result.errors }) : result.error; } + +function useIsSuspendedRef() { + const ref = useRef(false); + + // Unlike useEffect, useLayoutEffect will run its cleanup and initialization + // functions each time a component is resuspended. Using this ensures we can + // detect when a component has resumed after having been suspended. + useLayoutEffect(() => { + ref.current = false; + + return () => { + ref.current = true; + }; + }, []); + + return ref; +} From 9c596154835135b1ef1cef867bc3adf125882d5b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 13:41:24 -0700 Subject: [PATCH 126/159] Extract custom hook to get watch query options --- src/react/hooks/useSuspenseQuery.ts | 67 +++++++++++++++++------------ 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 8166c9e7fc5..b5de6c96175 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -8,6 +8,7 @@ import { } from 'react'; import { equal } from '@wry/equality'; import { + ApolloClient, ApolloError, ApolloQueryResult, DocumentNode, @@ -60,33 +61,7 @@ export function useSuspenseQuery_experimental< const suspenseCache = useSuspenseCache(); const client = useApolloClient(options.client); const isSuspendedRef = useIsSuspendedRef(); - const watchQueryOptions: WatchQueryOptions = - useDeepMemo(() => { - const { - errorPolicy, - fetchPolicy, - suspensePolicy = DEFAULT_SUSPENSE_POLICY, - variables, - ...watchQueryOptions - } = options; - - const { - watchQuery: defaultOptions = Object.create( - null - ) as Partial, - } = client.defaultOptions; - - return { - ...watchQueryOptions, - query, - errorPolicy: - errorPolicy || defaultOptions.errorPolicy || DEFAULT_ERROR_POLICY, - fetchPolicy: - fetchPolicy || defaultOptions.fetchPolicy || DEFAULT_FETCH_POLICY, - notifyOnNetworkStatusChange: suspensePolicy === 'always', - variables: compact({ ...defaultOptions.variables, ...variables }), - }; - }, [options, query, client.defaultOptions.watchQuery]); + const watchQueryOptions = useWatchQueryOptions({ query, options, client }); const { fetchPolicy, errorPolicy, returnPartialData } = watchQueryOptions; if (__DEV__) { @@ -283,6 +258,44 @@ function toApolloError(result: ApolloQueryResult) { : result.error; } +interface UseWatchQueryOptionsHookOptions { + query: DocumentNode | TypedDocumentNode; + options: SuspenseQueryHookOptions; + client: ApolloClient; +} + +function useWatchQueryOptions({ + query, + options, + client, +}: UseWatchQueryOptionsHookOptions): WatchQueryOptions< + TVariables, + TData +> { + const { watchQuery: defaultOptions } = client.defaultOptions; + + return useDeepMemo(() => { + const { + errorPolicy, + fetchPolicy, + suspensePolicy = DEFAULT_SUSPENSE_POLICY, + variables, + ...watchQueryOptions + } = options; + + return { + ...watchQueryOptions, + query, + errorPolicy: + errorPolicy || defaultOptions?.errorPolicy || DEFAULT_ERROR_POLICY, + fetchPolicy: + fetchPolicy || defaultOptions?.fetchPolicy || DEFAULT_FETCH_POLICY, + notifyOnNetworkStatusChange: suspensePolicy === 'always', + variables: compact({ ...defaultOptions?.variables, ...variables }), + }; + }, [options, query, defaultOptions]); +} + function useIsSuspendedRef() { const ref = useRef(false); From c88be2bf7af4713a9cc0e54d2010bd127dbf67e5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 13:46:19 -0700 Subject: [PATCH 127/159] Remove old logic for checking suspended ref --- src/react/hooks/useSuspenseQuery.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index b5de6c96175..3867c50c67f 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -183,7 +183,6 @@ export function useSuspenseQuery_experimental< ); } if (!cacheEntry.fulfilled) { - isSuspendedRef.current = true; throw cacheEntry.promise; } } @@ -194,13 +193,6 @@ export function useSuspenseQuery_experimental< throw result.error; } - // Unlike useEffect, useLayoutEffect will run its effects again when - // resuspending a component. This ensures we can detect when we've resumed - // rendering after suspending the component. - useLayoutEffect(() => { - isSuspendedRef.current = false; - }, []); - useEffect(() => { const { variables, query } = watchQueryOptions; From 68737fcb363881d5e56647f4685007811b5c213c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 13:48:33 -0700 Subject: [PATCH 128/159] Move validation to useWatchQueryOptions --- src/react/hooks/useSuspenseQuery.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 3867c50c67f..4787d288eb1 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -64,10 +64,6 @@ export function useSuspenseQuery_experimental< const watchQueryOptions = useWatchQueryOptions({ query, options, client }); const { fetchPolicy, errorPolicy, returnPartialData } = watchQueryOptions; - if (__DEV__) { - validateOptions(watchQueryOptions); - } - const [observable] = useState(() => { return ( suspenseCache.getQuery(query) || @@ -266,7 +262,7 @@ function useWatchQueryOptions({ > { const { watchQuery: defaultOptions } = client.defaultOptions; - return useDeepMemo(() => { + const watchQueryOptions = useDeepMemo(() => { const { errorPolicy, fetchPolicy, @@ -286,6 +282,12 @@ function useWatchQueryOptions({ variables: compact({ ...defaultOptions?.variables, ...variables }), }; }, [options, query, defaultOptions]); + + if (__DEV__) { + validateOptions(watchQueryOptions); + } + + return watchQueryOptions; } function useIsSuspendedRef() { From 87edd055adcb12063e5c5496d00d852abe1a29d9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 13:49:20 -0700 Subject: [PATCH 129/159] Use destructured value --- src/react/hooks/useSuspenseQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 4787d288eb1..084430039cf 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -161,7 +161,7 @@ export function useSuspenseQuery_experimental< returnPartialData && result.partial && result.data; if (result.loading && !returnPartialResults) { - switch (watchQueryOptions.fetchPolicy) { + switch (fetchPolicy) { case 'cache-and-network': { if (!result.partial) { break; From a1cb546fd4f42e0958130a82363ad4ba53f9382b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 15:06:42 -0700 Subject: [PATCH 130/159] Update API for suspense cache --- src/react/cache/SuspenseCache.ts | 109 +++++++++--------- .../hooks/__tests__/useSuspenseQuery.test.tsx | 8 +- src/react/hooks/useSuspenseQuery.ts | 41 ++++--- 3 files changed, 80 insertions(+), 78 deletions(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index b886b7f2a06..edc0a4eb151 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -7,66 +7,31 @@ import { } from '../../core'; import { canonicalStringify } from '../../cache'; -interface CacheEntry { +interface CacheEntry { + observable: ObservableQuery; fulfilled: boolean; promise: Promise>; } export class SuspenseCache { - private queries = new Map(); - private cache = new Map>>(); + private queries = new Map< + DocumentNode, + Map> + >(); - registerQuery< - TData = any, - TVariables extends OperationVariables = OperationVariables - >( - query: DocumentNode | TypedDocumentNode, - observable: ObservableQuery - ) { - this.queries.set(query, observable); - - return observable; - } - - getQuery< - TData = any, - TVariables extends OperationVariables = OperationVariables - >( - query: DocumentNode | TypedDocumentNode - ): ObservableQuery | undefined { - return this.queries.get(query) as ObservableQuery; - } - - deregisterQuery(query: DocumentNode | TypedDocumentNode) { - const observable = this.queries.get(query); - - if (!observable || observable.hasObservers()) { - return; - } - - this.queries.delete(query); - this.cache.delete(observable); - } - - getVariables< - TData = any, - TVariables extends OperationVariables = OperationVariables - >( - observable: ObservableQuery, - variables: TVariables | undefined - ): CacheEntry | undefined { - return this.cache.get(observable)?.get(canonicalStringify(variables)); - } - - setVariables< - TData = any, - TVariables extends OperationVariables = OperationVariables - >( - observable: ObservableQuery, + add( + query: DocumentNode | TypedDocumentNode, variables: TVariables | undefined, - promise: Promise + { + promise, + observable, + }: { promise: Promise; observable: ObservableQuery } ) { - const entry: CacheEntry = { + const variablesKey = this.getVariablesKey(variables); + const map = this.queries.get(query) || new Map(); + + const entry: CacheEntry = { + observable, fulfilled: false, promise: promise .catch(() => { @@ -78,11 +43,45 @@ export class SuspenseCache { }), }; - const entries = this.cache.get(observable) || new Map(); - entries.set(canonicalStringify(variables), entry); + map.set(variablesKey, entry); - this.cache.set(observable, entries); + this.queries.set(query, map); return entry; } + + lookup< + TData = any, + TVariables extends OperationVariables = OperationVariables + >( + query: DocumentNode | TypedDocumentNode, + variables: TVariables | undefined + ): CacheEntry | undefined { + return this.queries + .get(query) + ?.get(this.getVariablesKey(variables)) as CacheEntry; + } + + remove(query: DocumentNode, variables: OperationVariables | undefined) { + const map = this.queries.get(query); + + if (!map) { + return; + } + + const key = this.getVariablesKey(variables); + const entry = map.get(key); + + if (entry && !entry.observable.hasObservers()) { + map.delete(this.getVariablesKey(variables)); + } + + if (map.size === 0) { + this.queries.delete(query); + } + } + + private getVariablesKey(variables: OperationVariables | undefined) { + return canonicalStringify(variables || Object.create(null)); + } } diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index eae4397b8e1..9412937c2bc 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -539,12 +539,12 @@ describe('useSuspenseQuery', () => { ); expect(client.getObservableQueries().size).toBe(1); - expect(suspenseCache.getQuery(query)).toBeDefined(); + expect(suspenseCache.lookup(query, undefined)).toBeDefined(); unmount(); expect(client.getObservableQueries().size).toBe(0); - expect(suspenseCache.getQuery(query)).toBeUndefined(); + expect(suspenseCache.lookup(query, undefined)).toBeUndefined(); }); it('does not remove query from suspense cache if other queries are using it', async () => { @@ -583,12 +583,12 @@ describe('useSuspenseQuery', () => { // Because they are the same query, the 2 components use the same observable // in the suspense cache expect(client.getObservableQueries().size).toBe(1); - expect(suspenseCache.getQuery(query)).toBeDefined(); + expect(suspenseCache.lookup(query, undefined)).toBeDefined(); unmount(); expect(client.getObservableQueries().size).toBe(1); - expect(suspenseCache.getQuery(query)).toBeDefined(); + expect(suspenseCache.lookup(query, undefined)).toBeDefined(); }); it('allows the client to be overridden', async () => { diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 084430039cf..8c1d12f0cc9 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -60,26 +60,24 @@ export function useSuspenseQuery_experimental< ): UseSuspenseQueryResult { const suspenseCache = useSuspenseCache(); const client = useApolloClient(options.client); - const isSuspendedRef = useIsSuspendedRef(); const watchQueryOptions = useWatchQueryOptions({ query, options, client }); - const { fetchPolicy, errorPolicy, returnPartialData } = watchQueryOptions; + const previousWatchQueryOptionsRef = useRef(watchQueryOptions); + const isSuspendedRef = useIsSuspendedRef(); + const resultRef = useRef>(); + + const { fetchPolicy, errorPolicy, returnPartialData, variables } = + watchQueryOptions; + + let cacheEntry = suspenseCache.lookup(query, variables); const [observable] = useState(() => { - return ( - suspenseCache.getQuery(query) || - suspenseCache.registerQuery(query, client.watchQuery(watchQueryOptions)) - ); + return cacheEntry?.observable || client.watchQuery(watchQueryOptions); }); - const resultRef = useRef>(); - const previousWatchQueryOptionsRef = useRef(watchQueryOptions); - if (!resultRef.current) { resultRef.current = observable.getCurrentResult(); } - let cacheEntry = suspenseCache.getVariables(observable, observable.variables); - const result = useSyncExternalStore( useCallback( (forceUpdate) => { @@ -140,7 +138,7 @@ export function useSuspenseQuery_experimental< return () => { subscription.unsubscribe(); - suspenseCache.deregisterQuery(query); + suspenseCache.remove(query, observable.variables); }; }, [observable] @@ -172,11 +170,10 @@ export function useSuspenseQuery_experimental< default: { if (!cacheEntry) { const promise = observable.reobserve(watchQueryOptions); - cacheEntry = suspenseCache.setVariables( + cacheEntry = suspenseCache.add(query, variables, { + promise, observable, - observable.variables, - promise - ); + }); } if (!cacheEntry.fulfilled) { throw cacheEntry.promise; @@ -198,7 +195,7 @@ export function useSuspenseQuery_experimental< ) { const promise = observable.reobserve(watchQueryOptions); - suspenseCache.setVariables(observable, variables, promise); + suspenseCache.add(query, variables, { promise, observable }); previousWatchQueryOptionsRef.current = watchQueryOptions; } }, [watchQueryOptions]); @@ -211,14 +208,20 @@ export function useSuspenseQuery_experimental< // console.log('fetchMore', options); const promise = observable.fetchMore(options); - suspenseCache.setVariables(observable, observable.variables, promise); + suspenseCache.add(query, watchQueryOptions.variables, { + promise, + observable, + }); return promise; }, refetch: (variables?: Partial) => { const promise = observable.refetch(variables); - suspenseCache.setVariables(observable, observable.variables, promise); + suspenseCache.add(query, watchQueryOptions.variables, { + promise, + observable, + }); return promise; }, From 968dad9a048efecbff68a4f82f28408323d3d2de Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 15:26:09 -0700 Subject: [PATCH 131/159] Consolidate subscription handlers since they are the same implementation --- src/react/hooks/useSuspenseQuery.ts | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 8c1d12f0cc9..f1ebc32e7c3 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -94,7 +94,7 @@ export function useSuspenseQuery_experimental< observable.options.fetchPolicy = 'cache-only'; } - function onNext() { + function handleUpdate() { const previousResult = resultRef.current!; const result = observable.getCurrentResult(); @@ -113,26 +113,10 @@ export function useSuspenseQuery_experimental< } } - function onError() { - const previousResult = resultRef.current!; - const result = observable.getCurrentResult(); - - if ( - previousResult.loading === result.loading && - previousResult.networkStatus === result.networkStatus && - equal(previousResult.data, result.data) - ) { - return; - } - - resultRef.current = result; - - if (!isSuspendedRef.current) { - forceUpdate(); - } - } - - const subscription = observable.subscribe(onNext, onError); + const subscription = observable.subscribe({ + next: handleUpdate, + error: handleUpdate, + }); observable.options.fetchPolicy = originalFetchPolicy; From f0b958d2586975761704ff036ea9c57c920db3bc Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 15:43:00 -0700 Subject: [PATCH 132/159] Use variable when deleting entry from the suspense cache --- src/react/cache/SuspenseCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index edc0a4eb151..b1cd168a94f 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -73,7 +73,7 @@ export class SuspenseCache { const entry = map.get(key); if (entry && !entry.observable.hasObservers()) { - map.delete(this.getVariablesKey(variables)); + map.delete(key); } if (map.size === 0) { From 9cf1658d47fbd3866d280068fa0bb33ac6c01fdc Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 16:15:38 -0700 Subject: [PATCH 133/159] Fix issue where fetchPolicy was overwritten when using nextFetchPolicy and changing variables --- src/react/hooks/useSuspenseQuery.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index f1ebc32e7c3..665d9acd9b2 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -88,7 +88,7 @@ export function useSuspenseQuery_experimental< // that always fetch (e.g. 'network-only'). Instead, we set the cache // policy to `cache-only` to prevent the network request until the // subscription is created, then reset it back to its original. - const originalFetchPolicy = watchQueryOptions.fetchPolicy; + const originalFetchPolicy = observable.options.fetchPolicy; if (cacheEntry?.fulfilled) { observable.options.fetchPolicy = 'cache-only'; @@ -177,7 +177,7 @@ export function useSuspenseQuery_experimental< variables !== previousWatchQueryOptionsRef.current?.variables || query !== previousWatchQueryOptionsRef.current.query ) { - const promise = observable.reobserve(watchQueryOptions); + const promise = observable.reobserve({ query, variables }); suspenseCache.add(query, variables, { promise, observable }); previousWatchQueryOptionsRef.current = watchQueryOptions; From 3f86e7b7946bb90a9e852665e14789522ea7d60f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 16:41:10 -0700 Subject: [PATCH 134/159] Move logic of getting result from observable into own hook --- src/react/hooks/useSuspenseQuery.ts | 146 +++++++++++++++------------- 1 file changed, 81 insertions(+), 65 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 665d9acd9b2..b8256589d5e 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -13,6 +13,7 @@ import { ApolloQueryResult, DocumentNode, NetworkStatus, + ObservableQuery, OperationVariables, TypedDocumentNode, WatchQueryOptions, @@ -62,8 +63,6 @@ export function useSuspenseQuery_experimental< const client = useApolloClient(options.client); const watchQueryOptions = useWatchQueryOptions({ query, options, client }); const previousWatchQueryOptionsRef = useRef(watchQueryOptions); - const isSuspendedRef = useIsSuspendedRef(); - const resultRef = useRef>(); const { fetchPolicy, errorPolicy, returnPartialData, variables } = watchQueryOptions; @@ -74,62 +73,7 @@ export function useSuspenseQuery_experimental< return cacheEntry?.observable || client.watchQuery(watchQueryOptions); }); - if (!resultRef.current) { - resultRef.current = observable.getCurrentResult(); - } - - const result = useSyncExternalStore( - useCallback( - (forceUpdate) => { - // ObservableQuery will call `reobserve` as soon as the first - // subscription is created. Because we don't subscribe to the - // observable until after we've suspended via the initial fetch, we - // don't want to initiate another network request for fetch policies - // that always fetch (e.g. 'network-only'). Instead, we set the cache - // policy to `cache-only` to prevent the network request until the - // subscription is created, then reset it back to its original. - const originalFetchPolicy = observable.options.fetchPolicy; - - if (cacheEntry?.fulfilled) { - observable.options.fetchPolicy = 'cache-only'; - } - - function handleUpdate() { - const previousResult = resultRef.current!; - const result = observable.getCurrentResult(); - - if ( - previousResult.loading === result.loading && - previousResult.networkStatus === result.networkStatus && - equal(previousResult.data, result.data) - ) { - return; - } - - resultRef.current = result; - - if (!isSuspendedRef.current) { - forceUpdate(); - } - } - - const subscription = observable.subscribe({ - next: handleUpdate, - error: handleUpdate, - }); - - observable.options.fetchPolicy = originalFetchPolicy; - - return () => { - subscription.unsubscribe(); - suspenseCache.remove(query, observable.variables); - }; - }, - [observable] - ), - () => resultRef.current!, - () => resultRef.current! - ); + const result = useObservableQueryResult(observable); // Sometimes the observable reports a network status of error even // when our error policy is set to 'ignore' or 'all'. @@ -184,6 +128,12 @@ export function useSuspenseQuery_experimental< } }, [watchQueryOptions]); + useEffect(() => { + return () => { + suspenseCache.remove(query, variables); + }; + }, []); + return useMemo(() => { return { data: result.data, @@ -277,19 +227,85 @@ function useWatchQueryOptions({ return watchQueryOptions; } -function useIsSuspendedRef() { - const ref = useRef(false); +function useObservableQueryResult(observable: ObservableQuery) { + const suspenseCache = useSuspenseCache(); + const resultRef = useRef>(); + const isSuspendedRef = useRef(false); + + if (!resultRef.current) { + resultRef.current = observable.getCurrentResult(); + } + // React keeps refs and effects from useSyncExternalStore around after the + // component initially mounts even if the component re-suspends. We need to + // track when the component suspends/unsuspends to ensure we don't try and + // update the component while its suspended since the observable's + // `next` function is called before the promise resolved. + // // Unlike useEffect, useLayoutEffect will run its cleanup and initialization - // functions each time a component is resuspended. Using this ensures we can - // detect when a component has resumed after having been suspended. + // functions each time a component is resuspended. This ensures we can + // properly detect when a component has resumed after having been re-suspended. useLayoutEffect(() => { - ref.current = false; + isSuspendedRef.current = false; return () => { - ref.current = true; + isSuspendedRef.current = true; }; }, []); - return ref; + return useSyncExternalStore( + useCallback( + (forceUpdate) => { + function handleUpdate() { + const previousResult = resultRef.current!; + const result = observable.getCurrentResult(); + + if ( + previousResult.loading === result.loading && + previousResult.networkStatus === result.networkStatus && + equal(previousResult.data, result.data) + ) { + return; + } + + resultRef.current = result; + + if (!isSuspendedRef.current) { + forceUpdate(); + } + } + + // ObservableQuery will call `reobserve` as soon as the first + // subscription is created. Because we don't subscribe to the + // observable until after we've suspended via the initial fetch, we + // don't want to initiate another network request for fetch policies + // that always fetch (e.g. 'network-only'). Instead, we set the cache + // policy to `cache-only` to prevent the network request until the + // subscription is created, then reset it back to its original. + const originalFetchPolicy = observable.options.fetchPolicy; + const cacheEntry = suspenseCache.lookup( + observable.options.query, + observable.options.variables + ); + + if (cacheEntry?.fulfilled) { + observable.options.fetchPolicy = 'cache-only'; + } + + const subscription = observable.subscribe({ + next: handleUpdate, + error: handleUpdate, + }); + + observable.options.fetchPolicy = originalFetchPolicy; + + return () => { + subscription.unsubscribe(); + }; + }, + [observable] + ), + () => resultRef.current!, + () => resultRef.current! + ); } From 3f829ca8d8dcb5bdb3089935d61fa16548c505b3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 16:42:46 -0700 Subject: [PATCH 135/159] Rename isSuspendedRef to isMountedRef for better clarity --- src/react/hooks/useSuspenseQuery.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index b8256589d5e..ce517d49805 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -230,7 +230,7 @@ function useWatchQueryOptions({ function useObservableQueryResult(observable: ObservableQuery) { const suspenseCache = useSuspenseCache(); const resultRef = useRef>(); - const isSuspendedRef = useRef(false); + const isMountedRef = useRef(false); if (!resultRef.current) { resultRef.current = observable.getCurrentResult(); @@ -243,13 +243,12 @@ function useObservableQueryResult(observable: ObservableQuery) { // `next` function is called before the promise resolved. // // Unlike useEffect, useLayoutEffect will run its cleanup and initialization - // functions each time a component is resuspended. This ensures we can - // properly detect when a component has resumed after having been re-suspended. + // functions each time a component is suspended. useLayoutEffect(() => { - isSuspendedRef.current = false; + isMountedRef.current = true; return () => { - isSuspendedRef.current = true; + isMountedRef.current = false; }; }, []); @@ -270,7 +269,7 @@ function useObservableQueryResult(observable: ObservableQuery) { resultRef.current = result; - if (!isSuspendedRef.current) { + if (isMountedRef.current) { forceUpdate(); } } From b1e2223de2ea51e8a3601b33f2b7819cc5aacee0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 17:38:39 -0700 Subject: [PATCH 136/159] Use variable to reduce typing --- src/react/hooks/useSuspenseQuery.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index ce517d49805..54b0a254cc8 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -116,11 +116,9 @@ export function useSuspenseQuery_experimental< useEffect(() => { const { variables, query } = watchQueryOptions; + const previousOpts = previousWatchQueryOptionsRef.current; - if ( - variables !== previousWatchQueryOptionsRef.current?.variables || - query !== previousWatchQueryOptionsRef.current.query - ) { + if (variables !== previousOpts.variables || query !== previousOpts.query) { const promise = observable.reobserve({ query, variables }); suspenseCache.add(query, variables, { promise, observable }); From fb2c83a95918c94418c6db0509d0200d06524193 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 17:39:02 -0700 Subject: [PATCH 137/159] Always remove previous query/variables from suspense cache when they change --- src/react/hooks/useSuspenseQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 54b0a254cc8..95ea4850e55 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -130,7 +130,7 @@ export function useSuspenseQuery_experimental< return () => { suspenseCache.remove(query, variables); }; - }, []); + }, [query, variables]); return useMemo(() => { return { From c7ce42b9f601e701381cfc54ee15ba815c77980c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 17:40:38 -0700 Subject: [PATCH 138/159] Combine effects to remove suspense cache entries --- src/react/hooks/useSuspenseQuery.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 95ea4850e55..44447479807 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -119,18 +119,18 @@ export function useSuspenseQuery_experimental< const previousOpts = previousWatchQueryOptionsRef.current; if (variables !== previousOpts.variables || query !== previousOpts.query) { + suspenseCache.remove(previousOpts.query, previousOpts.variables); + const promise = observable.reobserve({ query, variables }); suspenseCache.add(query, variables, { promise, observable }); previousWatchQueryOptionsRef.current = watchQueryOptions; } - }, [watchQueryOptions]); - useEffect(() => { return () => { suspenseCache.remove(query, variables); }; - }, [query, variables]); + }, [watchQueryOptions]); return useMemo(() => { return { From f412fc40689dd6406fbcb077ff08182a95ae3e86 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 17:44:45 -0700 Subject: [PATCH 139/159] Fix regression on removing cache entry too soon --- src/react/hooks/useSuspenseQuery.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 44447479807..744c0f20676 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -126,11 +126,13 @@ export function useSuspenseQuery_experimental< suspenseCache.add(query, variables, { promise, observable }); previousWatchQueryOptionsRef.current = watchQueryOptions; } + }, [watchQueryOptions]); + useEffect(() => { return () => { suspenseCache.remove(query, variables); }; - }, [watchQueryOptions]); + }, []); return useMemo(() => { return { From 071d978ddfcd76aa199bf171c7eef3b281fe72c8 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 17:46:06 -0700 Subject: [PATCH 140/159] Allow prettier to work on internal hooks --- .prettierignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierignore b/.prettierignore index b2103c845c1..989fdb1917a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,6 +31,7 @@ src/react/* ## Allowed React Hooks !src/react/hooks/ src/react/hooks/* +!src/react/hooks/internal !src/react/hooks/useSuspenseCache.ts !src/react/hooks/useSuspenseQuery.ts From cf729e83f2230f3245ce052b45e12e1dc864bbb6 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 18:33:53 -0700 Subject: [PATCH 141/159] Add test to ensure no network request for cache-first --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 9412937c2bc..f3c1e309ba6 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -649,6 +649,36 @@ describe('useSuspenseQuery', () => { ]); }); + it('does not initiate a network request when data is in the cache and using a "cache-first" fetch policy', async () => { + let fetchCount = 0; + const { query, mocks } = useSimpleQueryCase(); + + const cache = new InMemoryCache(); + + const link = new ApolloLink(() => { + return new Observable((observer) => { + fetchCount++; + + const mock = mocks[0]; + + observer.next(mock.result); + observer.complete(); + }); + }); + + cache.writeQuery({ + query, + data: { greeting: 'hello from cache' }, + }); + + renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'cache-first' }), + { cache, link, initialProps: { id: '1' } } + ); + + expect(fetchCount).toBe(0); + }); + it('does not suspend when partial data is in the cache and using a "cache-first" fetch policy with returnPartialData', async () => { const fullQuery = gql` query { From 1154a720322909cb18fc71e708925b9052c97d56 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 18:59:10 -0700 Subject: [PATCH 142/159] Refactor logic for determining when to suspend --- src/react/hooks/useSuspenseQuery.ts | 61 +++++++++++++++-------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 744c0f20676..96b49c10c12 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -64,8 +64,7 @@ export function useSuspenseQuery_experimental< const watchQueryOptions = useWatchQueryOptions({ query, options, client }); const previousWatchQueryOptionsRef = useRef(watchQueryOptions); - const { fetchPolicy, errorPolicy, returnPartialData, variables } = - watchQueryOptions; + const { fetchPolicy, errorPolicy, variables } = watchQueryOptions; let cacheEntry = suspenseCache.lookup(query, variables); @@ -75,6 +74,10 @@ export function useSuspenseQuery_experimental< const result = useObservableQueryResult(observable); + if (result.error && errorPolicy === 'none') { + throw result.error; + } + // Sometimes the observable reports a network status of error even // when our error policy is set to 'ignore' or 'all'. // This patches the network status to avoid a rerender when the observable @@ -83,35 +86,34 @@ export function useSuspenseQuery_experimental< result.networkStatus = NetworkStatus.ready; } - const returnPartialResults = - returnPartialData && result.partial && result.data; - - if (result.loading && !returnPartialResults) { - switch (fetchPolicy) { - case 'cache-and-network': { - if (!result.partial) { - break; - } - - // fallthrough when data is not in the cache - } - default: { - if (!cacheEntry) { - const promise = observable.reobserve(watchQueryOptions); - cacheEntry = suspenseCache.add(query, variables, { - promise, - observable, - }); - } - if (!cacheEntry.fulfilled) { - throw cacheEntry.promise; - } - } + const hasFullResult = result.data && !result.partial; + const hasPartialResult = + watchQueryOptions.returnPartialData && result.partial && result.data; + + const hasUsableResult = + // When we have partial data in the cache, a network request will be kicked + // off to load the full set of data but we want to avoid suspending when the + // request is in flight. + hasPartialResult || + // `cache-and-network` kicks off a network request even with a full set of + // data in the cache, which means the loading state will be set to `true`. + // Ensure we don't suspend when this is the case. + (fetchPolicy === 'cache-and-network' && hasFullResult); + + if (result.loading && !hasUsableResult) { + // If we don't have a cache entry, yet we are in a loading state, we are on + // the first run of the hook. Kick off a network request so we can suspend + // immediately + if (!cacheEntry) { + cacheEntry = suspenseCache.add(query, variables, { + promise: observable.reobserve(watchQueryOptions), + observable, + }); } - } - if (result.error && errorPolicy === 'none') { - throw result.error; + if (!cacheEntry.fulfilled) { + throw cacheEntry.promise; + } } useEffect(() => { @@ -139,7 +141,6 @@ export function useSuspenseQuery_experimental< data: result.data, error: errorPolicy === 'all' ? toApolloError(result) : void 0, fetchMore: (options) => { - // console.log('fetchMore', options); const promise = observable.fetchMore(options); suspenseCache.add(query, watchQueryOptions.variables, { From dd9a3c37abcd10afac6bc5cd9bfd8c7ad4b21c65 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 19:00:40 -0700 Subject: [PATCH 143/159] Update bundle size --- config/bundlesize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/bundlesize.ts b/config/bundlesize.ts index b628fb88013..707e80c5618 100644 --- a/config/bundlesize.ts +++ b/config/bundlesize.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { gzipSync } from "zlib"; import bytes from "bytes"; -const gzipBundleByteLengthLimit = bytes("31.87KB"); +const gzipBundleByteLengthLimit = bytes("32.72KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength; From 7cd6fc142d0ad6a04ebf85aabb15bede06b4c66c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 20:55:00 -0700 Subject: [PATCH 144/159] Update snapshot test --- src/__tests__/__snapshots__/exports.ts.snap | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index b16e03c1870..ae513d67051 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -16,6 +16,7 @@ Array [ "NetworkStatus", "Observable", "ObservableQuery", + "SuspenseCache", "checkFetcher", "concat", "createHttpLink", @@ -59,6 +60,7 @@ Array [ "useQuery", "useReactiveVar", "useSubscription", + "useSuspenseQuery_experimental", ] `; @@ -239,6 +241,7 @@ Array [ "ApolloConsumer", "ApolloProvider", "DocumentType", + "SuspenseCache", "getApolloContext", "operationName", "parser", @@ -250,6 +253,7 @@ Array [ "useQuery", "useReactiveVar", "useSubscription", + "useSuspenseQuery_experimental", ] `; @@ -289,6 +293,7 @@ Array [ "useQuery", "useReactiveVar", "useSubscription", + "useSuspenseQuery_experimental", ] `; From 731a54fa22cf4beff27b2725ec591d1afba49024 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 30 Nov 2022 20:56:28 -0700 Subject: [PATCH 145/159] Fix typos in prettierignore --- .prettierignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.prettierignore b/.prettierignore index 989fdb1917a..9f4a4cfdcca 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,7 +6,7 @@ # history. For this reason, we have disabled prettier project-wide except for # a handful of files. # -# ONLY ADD NEWLY CREATED FILES/POTHS TO THE LIST BELOW. DO NOT ADD EXISTING +# ONLY ADD NEWLY CREATED FILES/PATHS TO THE LIST BELOW. DO NOT ADD EXISTING # PROJECT FILES. # ignores all files in /docs directory @@ -19,7 +19,7 @@ # Do not format anything automatically except files listed below /* -##### PATHS TO BE FORMATTEDi ##### +##### PATHS TO BE FORMATTED ##### !src/ src/* !src/react/ From e0f1c9742e72238e629f95385c6033da060a5025 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 1 Dec 2022 16:41:51 -0700 Subject: [PATCH 146/159] Remove the check for parent suspense cache in ApolloProvider --- src/react/context/ApolloProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/context/ApolloProvider.tsx b/src/react/context/ApolloProvider.tsx index 2fe8bc88b81..c00a0d539e6 100644 --- a/src/react/context/ApolloProvider.tsx +++ b/src/react/context/ApolloProvider.tsx @@ -25,7 +25,7 @@ export const ApolloProvider: React.FC> = ({ context = Object.assign({}, context, { client }); } - if (suspenseCache && !context.suspenseCache) { + if (suspenseCache) { context = Object.assign({}, context, { suspenseCache }); } From 4ee5a6ad1914bab2d81baeccdbcbfa38547bea3e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 1 Dec 2022 16:52:39 -0700 Subject: [PATCH 147/159] Warn when using no-cache fetch policy with returnPartialData --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 26 +++++++++++++++++++ src/react/hooks/useSuspenseQuery.ts | 18 ++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index f3c1e309ba6..65211d10782 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -958,6 +958,8 @@ describe('useSuspenseQuery', () => { }); it('suspends when partial data is in the cache and using a "no-cache" fetch policy with returnPartialData', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const fullQuery = gql` query { character { @@ -1012,6 +1014,30 @@ describe('useSuspenseQuery', () => { expect(renders.frames).toMatchObject([ { ...mocks[0].result, error: undefined }, ]); + + consoleSpy.mockRestore(); + }); + + it('warns when using returnPartialData with a "no-cache" fetch policy', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const { query, mocks } = useSimpleQueryCase(); + + renderSuspenseHook( + () => + useSuspenseQuery(query, { + fetchPolicy: 'no-cache', + returnPartialData: true, + }), + { mocks } + ); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + 'Using `returnPartialData` with a `no-cache` fetch policy has no effect. To read partial data from the cache, consider using an alternate fetch policy.' + ); + + consoleSpy.mockRestore(); }); it('does not suspend when data is in the cache and using a "cache-and-network" fetch policy', async () => { diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 96b49c10c12..50455a9dd1b 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -165,10 +165,15 @@ export function useSuspenseQuery_experimental< } function validateOptions(options: WatchQueryOptions) { - const { query, fetchPolicy = DEFAULT_FETCH_POLICY } = options; + const { + query, + fetchPolicy = DEFAULT_FETCH_POLICY, + returnPartialData, + } = options; verifyDocumentType(query, DocumentType.Query); validateFetchPolicy(fetchPolicy); + validatePartialDataReturn(fetchPolicy, returnPartialData); } function validateFetchPolicy(fetchPolicy: WatchQueryFetchPolicy) { @@ -178,6 +183,17 @@ function validateFetchPolicy(fetchPolicy: WatchQueryFetchPolicy) { ); } +function validatePartialDataReturn( + fetchPolicy: WatchQueryFetchPolicy, + returnPartialData: boolean | undefined +) { + if (fetchPolicy === 'no-cache' && returnPartialData) { + invariant.warn( + 'Using `returnPartialData` with a `no-cache` fetch policy has no effect. To read partial data from the cache, consider using an alternate fetch policy.' + ); + } +} + function toApolloError(result: ApolloQueryResult) { return isNonEmptyArray(result.errors) ? new ApolloError({ graphQLErrors: result.errors }) From 5f13ea671c6407776ea0424b5c7462fc9de9921d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 1 Dec 2022 16:56:53 -0700 Subject: [PATCH 148/159] Add test to ensure cache-first suspends with partial data --- .../hooks/__tests__/useSuspenseQuery.test.tsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 65211d10782..f742ae44c3e 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -679,6 +679,57 @@ describe('useSuspenseQuery', () => { expect(fetchCount).toBe(0); }); + it('suspends when partial data is in the cache and using a "cache-first" fetch policy', async () => { + const fullQuery = gql` + query { + character { + id + name + } + } + `; + + const partialQuery = gql` + query { + character { + id + } + } + `; + + const mocks = [ + { + request: { query: fullQuery }, + result: { data: { character: { id: '1', name: 'Doctor Strange' } } }, + }, + ]; + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query: partialQuery, + data: { character: { id: '1' } }, + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(fullQuery, { fetchPolicy: 'cache-first' }), + { cache, mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + ]); + }); + it('does not suspend when partial data is in the cache and using a "cache-first" fetch policy with returnPartialData', async () => { const fullQuery = gql` query { From 01584d521673ff69969948f91b9b74b5339df5be Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 2 Dec 2022 13:04:50 -0700 Subject: [PATCH 149/159] Add additional variable to better show intent of logic --- src/react/hooks/useSuspenseQuery.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 50455a9dd1b..610a840a608 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -64,7 +64,8 @@ export function useSuspenseQuery_experimental< const watchQueryOptions = useWatchQueryOptions({ query, options, client }); const previousWatchQueryOptionsRef = useRef(watchQueryOptions); - const { fetchPolicy, errorPolicy, variables } = watchQueryOptions; + const { fetchPolicy, errorPolicy, returnPartialData, variables } = + watchQueryOptions; let cacheEntry = suspenseCache.lookup(query, variables); @@ -87,14 +88,14 @@ export function useSuspenseQuery_experimental< } const hasFullResult = result.data && !result.partial; - const hasPartialResult = - watchQueryOptions.returnPartialData && result.partial && result.data; + const hasPartialResult = result.partial && result.data; + const usePartialResult = returnPartialData && hasPartialResult; const hasUsableResult = // When we have partial data in the cache, a network request will be kicked // off to load the full set of data but we want to avoid suspending when the // request is in flight. - hasPartialResult || + usePartialResult || // `cache-and-network` kicks off a network request even with a full set of // data in the cache, which means the loading state will be set to `true`. // Ensure we don't suspend when this is the case. From 13f5f2d496ffdf9005f51c234272b072b48583a0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 2 Dec 2022 21:33:04 -0700 Subject: [PATCH 150/159] Add option to watchQuery that disables fetch on the first subscription --- src/core/ObservableQuery.ts | 4 +++- src/core/watchQueryOptions.ts | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 1bc872da4c6..3fa3c6be091 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -110,6 +110,8 @@ export class ObservableQuery< options: WatchQueryOptions; }) { super((observer: Observer>) => { + const { fetchOnFirstSubscribe = true } = options + // Zen Observable has its own error function, so in order to log correctly // we need to provide a custom error callback. try { @@ -132,7 +134,7 @@ export class ObservableQuery< // Initiate observation of this query if it hasn't been reported to // the QueryManager yet. - if (first) { + if (first && fetchOnFirstSubscribe) { // Blindly catching here prevents unhandled promise rejections, // and is safe because the ObservableQuery handles this error with // this.observer.error, so we're not just swallowing the error by diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 676dc7253de..4053fbe236a 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -145,6 +145,12 @@ export interface WatchQueryOptions * behavior, for backwards compatibility with Apollo Client 3.x. */ refetchWritePolicy?: RefetchWritePolicy; + + /** + * Determines whether the observable should execute a request when the first + * observer subscribes to it. + */ + fetchOnFirstSubscribe?: boolean } export interface NextFetchPolicyContext { From 563d2875b4c41950b5537d2c8e965d4e85639d26 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 2 Dec 2022 21:36:22 -0700 Subject: [PATCH 151/159] Make useSuspenseQuery more robust by removing some patch cases by setting fetchOnFirstSubscribe to false --- src/react/hooks/useSuspenseQuery.ts | 67 +++++++++-------------------- 1 file changed, 20 insertions(+), 47 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 610a840a608..3eaf24f2431 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -12,7 +12,6 @@ import { ApolloError, ApolloQueryResult, DocumentNode, - NetworkStatus, ObservableQuery, OperationVariables, TypedDocumentNode, @@ -79,30 +78,8 @@ export function useSuspenseQuery_experimental< throw result.error; } - // Sometimes the observable reports a network status of error even - // when our error policy is set to 'ignore' or 'all'. - // This patches the network status to avoid a rerender when the observable - // first subscribes and gets back a ready network status. - if (result.networkStatus === NetworkStatus.error && errorPolicy !== 'none') { - result.networkStatus = NetworkStatus.ready; - } - - const hasFullResult = result.data && !result.partial; - const hasPartialResult = result.partial && result.data; - const usePartialResult = returnPartialData && hasPartialResult; - - const hasUsableResult = - // When we have partial data in the cache, a network request will be kicked - // off to load the full set of data but we want to avoid suspending when the - // request is in flight. - usePartialResult || - // `cache-and-network` kicks off a network request even with a full set of - // data in the cache, which means the loading state will be set to `true`. - // Ensure we don't suspend when this is the case. - (fetchPolicy === 'cache-and-network' && hasFullResult); - - if (result.loading && !hasUsableResult) { - // If we don't have a cache entry, yet we are in a loading state, we are on + if (result.loading) { + // If we don't have a cache entry, but we are in a loading state, we are on // the first run of the hook. Kick off a network request so we can suspend // immediately if (!cacheEntry) { @@ -112,7 +89,20 @@ export function useSuspenseQuery_experimental< }); } - if (!cacheEntry.fulfilled) { + const hasFullResult = result.data && !result.partial; + const usePartialResult = returnPartialData && result.partial && result.data; + + const hasUsableResult = + // When we have partial data in the cache, a network request will be kicked + // off to load the full set of data. Avoid suspending when the request is + // in flight to return the partial data immediately. + usePartialResult || + // `cache-and-network` kicks off a network request even with a full set of + // data in the cache, which means the loading state will be set to `true`. + // Avoid suspending in this case. + (fetchPolicy === 'cache-and-network' && hasFullResult); + + if (!hasUsableResult && !cacheEntry.fulfilled) { throw cacheEntry.promise; } } @@ -217,7 +207,9 @@ function useWatchQueryOptions({ > { const { watchQuery: defaultOptions } = client.defaultOptions; - const watchQueryOptions = useDeepMemo(() => { + const watchQueryOptions = useDeepMemo< + WatchQueryOptions + >(() => { const { errorPolicy, fetchPolicy, @@ -228,6 +220,7 @@ function useWatchQueryOptions({ return { ...watchQueryOptions, + fetchOnFirstSubscribe: false, query, errorPolicy: errorPolicy || defaultOptions?.errorPolicy || DEFAULT_ERROR_POLICY, @@ -246,7 +239,6 @@ function useWatchQueryOptions({ } function useObservableQueryResult(observable: ObservableQuery) { - const suspenseCache = useSuspenseCache(); const resultRef = useRef>(); const isMountedRef = useRef(false); @@ -292,30 +284,11 @@ function useObservableQueryResult(observable: ObservableQuery) { } } - // ObservableQuery will call `reobserve` as soon as the first - // subscription is created. Because we don't subscribe to the - // observable until after we've suspended via the initial fetch, we - // don't want to initiate another network request for fetch policies - // that always fetch (e.g. 'network-only'). Instead, we set the cache - // policy to `cache-only` to prevent the network request until the - // subscription is created, then reset it back to its original. - const originalFetchPolicy = observable.options.fetchPolicy; - const cacheEntry = suspenseCache.lookup( - observable.options.query, - observable.options.variables - ); - - if (cacheEntry?.fulfilled) { - observable.options.fetchPolicy = 'cache-only'; - } - const subscription = observable.subscribe({ next: handleUpdate, error: handleUpdate, }); - observable.options.fetchPolicy = originalFetchPolicy; - return () => { subscription.unsubscribe(); }; From f7356575e4b86bf4bf097ded40de5c7abef8f204 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 2 Dec 2022 21:53:33 -0700 Subject: [PATCH 152/159] Add comment explaining use of fetchOnFirstSubscribe --- src/react/hooks/useSuspenseQuery.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 3eaf24f2431..06eabbf40de 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -220,13 +220,21 @@ function useWatchQueryOptions({ return { ...watchQueryOptions, - fetchOnFirstSubscribe: false, query, errorPolicy: errorPolicy || defaultOptions?.errorPolicy || DEFAULT_ERROR_POLICY, fetchPolicy: fetchPolicy || defaultOptions?.fetchPolicy || DEFAULT_FETCH_POLICY, notifyOnNetworkStatusChange: suspensePolicy === 'always', + // By default, `ObservableQuery` will run `reobserve` the first time + // something `subscribe`s to the observable, which kicks off a network + // request. This creates a problem for suspense because we need to begin + // fetching the data immediately so we can throw the promise on the first + // render. Since we don't subscribe until after we've unsuspended, we need + // to avoid kicking off another network request for the same data we just + // fetched. This option toggles that behavior off to avoid the `reobserve` + // when the observable is first subscribed to. + fetchOnFirstSubscribe: false, variables: compact({ ...defaultOptions?.variables, ...variables }), }; }, [options, query, defaultOptions]); From be9595fe7e2808c5583b3ed8677274ca15e519d8 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Sat, 3 Dec 2022 21:18:09 -0700 Subject: [PATCH 153/159] Move promise inline --- src/react/hooks/useSuspenseQuery.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index 06eabbf40de..775229d81cb 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -114,9 +114,11 @@ export function useSuspenseQuery_experimental< if (variables !== previousOpts.variables || query !== previousOpts.query) { suspenseCache.remove(previousOpts.query, previousOpts.variables); - const promise = observable.reobserve({ query, variables }); + suspenseCache.add(query, variables, { + promise: observable.reobserve({ query, variables }), + observable, + }); - suspenseCache.add(query, variables, { promise, observable }); previousWatchQueryOptionsRef.current = watchQueryOptions; } }, [watchQueryOptions]); From ba70c6c66b61a9c61b470cd97bf61a0ae7318eec Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 8 Dec 2022 14:40:23 -0700 Subject: [PATCH 154/159] Add changeset --- .changeset/small-timers-shake.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/small-timers-shake.md diff --git a/.changeset/small-timers-shake.md b/.changeset/small-timers-shake.md new file mode 100644 index 00000000000..f37c10ca874 --- /dev/null +++ b/.changeset/small-timers-shake.md @@ -0,0 +1,5 @@ +--- +'@apollo/client': minor +--- + +Add support for React suspense with a new `useSuspenseQuery` hook. From f4e0ece2d90be0d73f3f9027d4e397d10c2ce1f3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 8 Dec 2022 15:12:13 -0700 Subject: [PATCH 155/159] Update bundlesize --- config/bundlesize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/bundlesize.ts b/config/bundlesize.ts index 707e80c5618..4a738d0ff20 100644 --- a/config/bundlesize.ts +++ b/config/bundlesize.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { gzipSync } from "zlib"; import bytes from "bytes"; -const gzipBundleByteLengthLimit = bytes("32.72KB"); +const gzipBundleByteLengthLimit = bytes("32.79KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength; From 8a8d4540417753ed6476cc168c083403e477f03c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 8 Dec 2022 15:20:29 -0700 Subject: [PATCH 156/159] Update package-lock.json --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index bce3ea8d133..42a2881b78c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6913,9 +6913,9 @@ } }, "node_modules/prettier": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", - "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", + "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", "dev": true, "bin": { "prettier": "bin-prettier.js" @@ -14179,9 +14179,9 @@ "dev": true }, "prettier": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", - "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", + "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", "dev": true }, "pretty-format": { From 23aa7fa8f791b3e7d3f68c5da89fdd9eb8c435a3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 9 Dec 2022 14:12:36 -0700 Subject: [PATCH 157/159] Fix typos in test comments --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index f742ae44c3e..6f5a8b68162 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1290,7 +1290,7 @@ describe('useSuspenseQuery', () => { }); // Renders: - // 1. Initate fetch and suspend + // 1. Initiate fetch and suspend // 2. Unsuspend and return results from initial fetch // 3. Change variables // 4. Unsuspend and return results from refetch @@ -1462,7 +1462,7 @@ describe('useSuspenseQuery', () => { }); // Renders: - // 1. Initate fetch and suspend + // 1. Initiate fetch and suspend // 2. Unsuspend and return results from initial fetch // 3. Change variables // 4. Initiate refetch and suspend @@ -1531,7 +1531,7 @@ describe('useSuspenseQuery', () => { }); // Renders: - // 1. Initate fetch and suspend + // 1. Initiate fetch and suspend // 2. Unsuspend and return results from initial fetch // 3. Change queries // 4. Initiate refetch and suspend From 5ae71384a5c5459c9f1c275affbe426283c0d50d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 9 Dec 2022 14:13:57 -0700 Subject: [PATCH 158/159] Remove inaccurate comment --- src/react/hooks/__tests__/useSuspenseQuery.test.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 6f5a8b68162..00f548d0b91 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1546,11 +1546,6 @@ describe('useSuspenseQuery', () => { } ); - // Due to the way the suspense hook works, we don't subscribe to the observable - // until after we have suspended. Once an observable is subscribed, it calls - // `reobserve` which has the potential to kick off a network request. We want - // to ensure we don't accidentally kick off the network request more than - // necessary after a component has been suspended. it.each([ 'cache-first', 'network-only', From 52129387a16205fd7dab8c36443160210809ff01 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 9 Dec 2022 14:14:20 -0700 Subject: [PATCH 159/159] Pin patch-package to a specific version --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 42a2881b78c..7359c111fa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "jest-environment-jsdom": "29.3.1", "jest-junit": "15.0.0", "lodash": "4.17.21", - "patch-package": "^6.5.0", + "patch-package": "6.5.0", "prettier": "2.7.1", "react": "18.2.0", "react-17": "npm:react@^17", diff --git a/package.json b/package.json index 9e8ed4c6e94..52f5920a5e4 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "jest-environment-jsdom": "29.3.1", "jest-junit": "15.0.0", "lodash": "4.17.21", - "patch-package": "^6.5.0", + "patch-package": "6.5.0", "prettier": "2.7.1", "react": "18.2.0", "react-17": "npm:react@^17",