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. diff --git a/.prettierignore b/.prettierignore index 7fc13da26e3..9f4a4cfdcca 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,41 @@ +##### 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/PATHS 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 FORMATTED ##### +!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/internal +!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/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 diff --git a/config/bundlesize.ts b/config/bundlesize.ts index b628fb88013..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("31.87KB"); +const gzipBundleByteLengthLimit = bytes("32.79KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength; diff --git a/config/jest.config.js b/config/jest.config.js index 9862ff2c9aa..3c45812e17d 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", }, @@ -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", diff --git a/package-lock.json b/package-lock.json index 14ad167c4f1..7359c111fa8 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", @@ -29,6 +30,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", @@ -57,10 +59,13 @@ "jest-environment-jsdom": "29.3.1", "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", "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", @@ -102,6 +107,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 +1784,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 +2278,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", @@ -2259,6 +2347,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", @@ -2943,6 +3037,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", @@ -3653,6 +3753,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", @@ -4272,6 +4381,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", @@ -4477,6 +4601,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", @@ -5726,6 +5862,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", @@ -6050,6 +6195,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", @@ -6091,6 +6245,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", @@ -6261,6 +6421,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", @@ -6400,6 +6576,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", @@ -6582,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" @@ -6785,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" @@ -8473,6 +8804,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", @@ -8536,6 +8876,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 +10256,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 +10684,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", @@ -10336,6 +10744,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", @@ -10866,6 +11280,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", @@ -11414,6 +11834,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", @@ -11863,6 +12292,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", @@ -12002,6 +12437,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", @@ -12946,6 +13390,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", @@ -13193,6 +13646,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", @@ -13222,6 +13681,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", @@ -13355,6 +13820,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", @@ -13460,6 +13935,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", @@ -13587,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": { @@ -13735,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" @@ -15025,6 +15617,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 856626daa0b..52f5920a5e4 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", @@ -102,6 +103,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", @@ -130,10 +132,13 @@ "jest-environment-jsdom": "29.3.1", "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", "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", @@ -152,5 +157,13 @@ }, "publishConfig": { "access": "public" + }, + "prettier": { + "bracketSpacing": true, + "printWidth": 80, + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5" } } 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)(() => { 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", ] `; 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(); 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 { diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts new file mode 100644 index 00000000000..b1cd168a94f --- /dev/null +++ b/src/react/cache/SuspenseCache.ts @@ -0,0 +1,87 @@ +import { + ApolloQueryResult, + DocumentNode, + ObservableQuery, + OperationVariables, + TypedDocumentNode, +} from '../../core'; +import { canonicalStringify } from '../../cache'; + +interface CacheEntry { + observable: ObservableQuery; + fulfilled: boolean; + promise: Promise>; +} + +export class SuspenseCache { + private queries = new Map< + DocumentNode, + Map> + >(); + + add( + query: DocumentNode | TypedDocumentNode, + variables: TVariables | undefined, + { + promise, + observable, + }: { promise: Promise; observable: ObservableQuery } + ) { + const variablesKey = this.getVariablesKey(variables); + const map = this.queries.get(query) || new Map(); + + const entry: CacheEntry = { + observable, + fulfilled: false, + promise: promise + .catch(() => { + // Throw away the error as we only care to track when the promise has + // been fulfilled + }) + .finally(() => { + entry.fulfilled = true; + }), + }; + + map.set(variablesKey, entry); + + 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(key); + } + + if (map.size === 0) { + this.queries.delete(query); + } + } + + private getVariablesKey(variables: OperationVariables | undefined) { + return canonicalStringify(variables || Object.create(null)); + } +} 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..c00a0d539e6 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 = 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 new file mode 100644 index 00000000000..00f548d0b91 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -0,0 +1,3469 @@ +import React, { Suspense } from 'react'; +import { + act, + screen, + renderHook, + waitFor, + 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'; + +import { + gql, + ApolloCache, + ApolloClient, + ApolloError, + ApolloLink, + DocumentNode, + InMemoryCache, + Observable, + TypedDocumentNode, +} from '../../../core'; +import { compact, concatPagination } 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< + Props, + TSerializedCache = {} +> = RenderHookOptions & { + client?: ApolloClient; + link?: ApolloLink; + cache?: ApolloCache; + mocks?: MockedResponse[]; + suspenseCache?: SuspenseCache; +}; + +interface Renders { + errors: Error[]; + errorCount: number; + suspenseCount: number; + count: number; + frames: Result[]; +} + +function renderSuspenseHook( + render: (initialProps: Props) => Result, + options: RenderSuspenseHookOptions = Object.create(null) +) { + function SuspenseFallback() { + renders.suspenseCount++; + + return
loading
; + } + + const renders: Renders = { + errors: [], + errorCount: 0, + suspenseCount: 0, + count: 0, + frames: [], + }; + + const { + cache, + client, + link, + mocks = [], + suspenseCache = new SuspenseCache(), + wrapper = ({ children }) => { + const errorBoundaryProps: ErrorBoundaryProps = { + fallback:
Error
, + onError: (error) => { + renders.errorCount++; + renders.errors.push(error); + }, + }; + + return client ? ( + + + }>{children} + + + ) : ( + + + }>{children} + + + ); + }, + ...renderHookOptions + } = options; + + const result = renderHook( + (props) => { + renders.count++; + + const result = render(props); + + renders.frames.push(result); + + return result; + }, + { ...renderHookOptions, wrapper } + ); + + 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 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; + name: string | null; + }; +} + +function useErrorCase( + { + data, + networkError, + graphQLErrors, + }: { + data?: TData; + networkError?: Error; + graphQLErrors?: GraphQLError[]; + } = Object.create(null) +) { + const query: TypedDocumentNode = gql` + query MyQuery { + currentUser { + id + name + } + } + `; + + const mock: MockedResponse = compact({ + request: { query }, + result: (data || graphQLErrors) && compact({ data, errors: graphQLErrors }), + error: networkError, + }); + + return { query, mocks: [mock] }; +} + +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 }; +} + +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(); + + const query = gql` + mutation ShouldThrow { + createException + } + `; + + expect(() => { + renderHook(() => useSuspenseQuery(query), { + wrapper: ({ children }) => {children}, + }); + }).toThrowError( + 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 } = useSimpleQueryCase(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + 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.' + ) + ); + + consoleSpy.mockRestore(); + }); + + it('ensures a valid fetch policy is used', () => { + const INVALID_FETCH_POLICIES = ['cache-only', 'standby']; + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const { query } = useSimpleQueryCase(); + + 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 () => { + const { query, mocks } = useSimpleQueryCase(); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query), + { mocks } + ); + + // ensure the hook suspends immediately + expect(renders.suspenseCount).toBe(1); + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + expect(renders.suspenseCount).toBe(1); + expect(renders.count).toBe(2); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + ]); + }); + + it('suspends a query with variables and returns results', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { variables: { id: '1' } }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + expect(renders.suspenseCount).toBe(1); + expect(renders.count).toBe(2); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + ]); + }); + + it('returns the same results for the same variables', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, rerender, renders } = renderSuspenseHook( + ({ id }) => useSuspenseQuery(query, { variables: { id } }), + { mocks, initialProps: { id: '1' } } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + 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([ + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + ]); + }); + + it('ensures result is referentially stable', async () => { + const { query, mocks } = useVariablesQueryCase(); + + const { result, rerender } = renderSuspenseHook( + ({ id }) => useSuspenseQuery(query, { variables: { id } }), + { mocks, initialProps: { id: '1' } } + ); + + expect(screen.getByText('loading')).toBeInTheDocument(); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + const previousResult = result.current; + + rerender({ id: '1' }); + + 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(); + + const client = new ApolloClient({ + link: new ApolloLink(() => Observable.of(mocks[0].result)), + cache: new InMemoryCache(), + }); + + const suspenseCache = new SuspenseCache(); + + const { result, unmount } = renderSuspenseHook( + () => useSuspenseQuery(query), + { client, suspenseCache } + ); + + // 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(mocks[0].result.data) + ); + + expect(client.getObservableQueries().size).toBe(1); + expect(suspenseCache.lookup(query, undefined)).toBeDefined(); + + unmount(); + + expect(client.getObservableQueries().size).toBe(0); + expect(suspenseCache.lookup(query, undefined)).toBeUndefined(); + }); + + it('does not remove query from suspense cache if other queries are using it', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const client = new ApolloClient({ + link: new ApolloLink(() => Observable.of(mocks[0].result)), + 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(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 + // in the suspense cache + expect(client.getObservableQueries().size).toBe(1); + expect(suspenseCache.lookup(query, undefined)).toBeDefined(); + + unmount(); + + expect(client.getObservableQueries().size).toBe(1); + expect(suspenseCache.lookup(query, undefined)).toBeDefined(); + }); + + 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 { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { client: localClient }), + { client: globalClient } + ); + + await waitFor(() => + expect(result.current.data).toEqual({ greeting: 'local hello' }) + ); + + expect(renders.frames).toMatchObject([ + { data: { greeting: 'local hello' }, error: undefined }, + ]); + }); + + 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(result.current).toMatchObject({ + data: { greeting: 'hello from cache' }, + error: undefined, + }); + + expect(renders.count).toBe(1); + expect(renders.suspenseCount).toBe(0); + expect(renders.frames).toMatchObject([ + { data: { greeting: 'hello from cache' }, error: undefined }, + ]); + }); + + 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('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 { + 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).toMatchObject({ + data: { character: { id: '1' } }, + error: undefined, + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(0); + expect(renders.frames).toMatchObject([ + { data: { character: { id: '1' } }, error: undefined }, + { ...mocks[0].result, error: undefined }, + ]); + }); + + 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).toMatchObject({ + data: { character: { id: '1' } }, + error: undefined, + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: { character: { id: '1' } }, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, + ]); + }); + + 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).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: { greeting: 'Hello' }, error: undefined }, + ]); + }); + + 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).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('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).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + const cachedData = cache.readQuery({ query }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: { greeting: 'Hello' }, error: undefined }, + ]); + 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).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: { greeting: 'Hello' }, error: undefined }, + ]); + + rerender(); + + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + expect(renders.count).toBe(3); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: { greeting: 'Hello' }, error: undefined }, + { data: { greeting: 'Hello' }, error: undefined }, + ]); + }); + + 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 { + 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).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 }, + ]); + + 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 () => { + 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(result.current).toMatchObject({ + data: { greeting: 'hello from cache' }, + error: undefined, + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(0); + expect(renders.frames).toMatchObject([ + { + data: { greeting: 'hello from cache' }, + error: undefined, + }, + { data: { greeting: 'Hello' }, error: undefined }, + ]); + }); + + 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).toMatchObject({ + data: { character: { id: '1' } }, + error: undefined, + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(0); + expect(renders.frames).toMatchObject([ + { data: { character: { id: '1' } }, error: undefined }, + { ...mocks[0].result, error: undefined }, + ]); + }); + + 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).toMatchObject({ + data: { character: { id: '1' } }, + error: undefined, + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(5); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: { character: { id: '1' } }, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, + ]); + }); + + 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' } } + ); + + expect(renders.suspenseCount).toBe(1); + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + // Renders: + // 1. Initiate 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).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, + ]); + } + ); + + 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(); + + const cache = new InMemoryCache(); + + const { result } = renderSuspenseHook( + ({ id }) => useSuspenseQuery(query, { fetchPolicy, 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('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.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 client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy }), + { client } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + client.writeQuery({ + query, + data: { greeting: 'Updated hello' }, + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { greeting: 'Updated hello' }, + error: undefined, + }); + }); + expect(renders.suspenseCount).toBe(1); + expect(renders.count).toBe(3); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { data: { greeting: 'Updated hello' }, error: undefined }, + ]); + } + ); + + it('does not respond to cache updates when using a "no-cache" fetch policy', async () => { + const { query, mocks } = useSimpleQueryCase(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { fetchPolicy: 'no-cache' }), + { client } + ); + + await waitFor(() => { + expect(result.current.data).toEqual(mocks[0].result.data); + }); + + client.writeQuery({ + query, + data: { greeting: 'Updated hello' }, + }); + + // Wait for a while to ensure no updates happen asynchronously + await wait(100); + + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + expect(renders.suspenseCount).toBe(1); + expect(renders.count).toBe(2); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + ]); + }); + + 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).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + // Renders: + // 1. Initiate 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).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, + ]); + } + ); + + 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).toMatchObject({ + ...mocks[0].result, + error: undefined, + }); + }); + + rerender({ query: query2 }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + // Renders: + // 1. Initiate 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).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, + ]); + } + ); + + 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(); + + const cache = new InMemoryCache(); + + const client = new ApolloClient({ + cache, + link: new MockLink(mocks), + defaultOptions: { + watchQuery: { + fetchPolicy: 'network-only', + }, + }, + }); + + client.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).toMatchObject([ + { ...mocks[0].result, error: undefined }, + ]); + }); + + 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).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + expect(renders.frames).toMatchObject([ + { ...mocks[1].result, error: undefined }, + ]); + }); + + 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).toMatchObject({ + data: { + vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, + }, + error: undefined, + }); + }); + + rerender({ source: 'rerender' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { + vars: { source: 'rerender', globalOnlyVar: true, localOnlyVar: true }, + }, + error: undefined, + }); + }); + + expect(renders.frames).toMatchObject([ + { + data: { + vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, + }, + error: undefined, + }, + { + data: { + vars: { source: 'local', globalOnlyVar: true, localOnlyVar: true }, + }, + error: undefined, + }, + { + data: { + vars: { source: 'rerender', globalOnlyVar: true, localOnlyVar: true }, + }, + error: undefined, + }, + ]); + }); + + 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).toMatchObject({ + data: { vars: { source: 'local' } }, + error: undefined, + }); + }); + + // Check to make sure the property itself is not defined, not just set to + // 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.data.vars).not.toHaveProperty('globalOnlyVar'); + + expect(renders.frames).toMatchObject([ + { data: { vars: { source: 'local' } }, error: undefined }, + ]); + }); + + 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).toMatchObject({ + data: { context: { valueA: 'A', valueB: 'B' } }, + error: undefined, + }); + }); + }); + + it('throws network errors by default', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { query, mocks } = useErrorCase({ + networkError: new Error('Could not fetch'), + }); + + 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).toEqual(new Error('Could not fetch')); + expect(error.graphQLErrors).toEqual([]); + + 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('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, + }); + }); + + act(() => { + result.current.refetch(); + }); + + 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 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, + }); + }); + + act(() => { + 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(); + + const { query, mocks } = useErrorCase({ + networkError: new Error('Could not fetch'), + }); + + 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).toEqual(new Error('Could not fetch')); + expect(error.graphQLErrors).toEqual([]); + + 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('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'), + }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'ignore' }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: undefined, + error: undefined, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: undefined, error: undefined }, + ]); + }); + + 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).toMatchObject({ + data: undefined, + error: undefined, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: undefined, error: undefined }, + ]); + }); + + 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).toMatchObject({ + data: { currentUser: { id: '1', name: null } }, + error: undefined, + }); + }); + + expect(renders.frames).toMatchObject([ + { + data: { currentUser: { id: '1', name: null } }, + error: undefined, + }, + ]); + }); + + 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).toMatchObject({ + data: undefined, + error: undefined, + }); + }); + + expect(renders.frames).toMatchObject([ + { data: undefined, error: undefined }, + ]); + }); + + 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 }); + + const { result, renders } = renderSuspenseHook( + () => useSuspenseQuery(query, { errorPolicy: 'all' }), + { mocks } + ); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: undefined, + error: new ApolloError({ networkError }), + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: undefined, error: new ApolloError({ networkError }) }, + ]); + + const { error } = result.current; + + expect(error).toBeInstanceOf(ApolloError); + 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).toMatchObject({ + data: undefined, + error: new ApolloError({ graphQLErrors: [graphQLError] }), + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { + data: undefined, + error: new ApolloError({ graphQLErrors: [graphQLError] }), + }, + ]); + + const { error } = result.current; + + expect(error).toBeInstanceOf(ApolloError); + expect(error!.networkError).toBeNull(); + 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).toMatchObject({ + data: undefined, + error: expectedError, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.count).toBe(2); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: undefined, error: expectedError }, + ]); + + 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'); + + 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).toMatchObject({ + data: { currentUser: { id: '1', name: null } }, + error: expectedError, + }); + }); + + expect(renders.frames).toMatchObject([ + { + data: { currentUser: { id: '1', name: null } }, + error: expectedError, + }, + ]); + }); + + 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); + }); + + 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).toMatchObject({ + data: undefined, + error: expectedError, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(5); + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { data: undefined, error: expectedError }, + { data: undefined, error: expectedError }, + { ...mocks[1].result, error: undefined }, + ]); + }); + + 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).toMatchObject({ + data: undefined, + error: expectedError, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(4); + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: undefined, error: expectedError }, + { data: undefined, error: expectedError }, + { ...mocks[1].result, error: undefined }, + ]); + }); + + 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, + }); + }); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, + ]); + }); + + 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, + }); + }); + + act(() => { + result.current.refetch({ id: '2' }); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, + ]); + }); + + 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, + }); + }); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[2].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(6); + expect(renders.suspenseCount).toBe(3); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, + { ...mocks[2].result, error: undefined }, + ]); + }); + + 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, + }); + }); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + expect(renders.count).toBe(3); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { ...mocks[1].result, error: undefined }, + ]); + }); + + 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, + }); + }); + + act(() => { + 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 }, + ]); + + 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, + }); + }); + + await act(async () => { + await result.current.refetch(); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: undefined }, + ]); + }); + + 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, + }); + }); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[0].result, + error: expectedError, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { ...mocks[0].result, error: expectedError }, + ]); + }); + + 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, + }); + }); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: mocks[1].result.data, + error: expectedError, + }); + }); + + expect(renders.errorCount).toBe(0); + expect(renders.errors).toEqual([]); + expect(renders.frames).toMatchObject([ + { ...mocks[0].result, error: undefined }, + { data: mocks[1].result.data, error: expectedError }, + ]); + }); + + 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, + }); + }); + + act(() => { + result.current.fetchMore({ variables: { offset: 2 } }); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(2, 4) }, + error: undefined, + }); + }); + + expect(renders.count).toBe(4); + expect(renders.suspenseCount).toBe(2); + expect(renders.frames).toMatchObject([ + { data: { letters: data.slice(0, 2) }, error: undefined }, + { data: { letters: data.slice(2, 4) }, error: undefined }, + ]); + }); + + 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, + }); + }); + + act(() => { + result.current.fetchMore({ variables: { offset: 2 } }); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(2, 4) }, + error: undefined, + }); + }); + + expect(renders.count).toBe(3); + expect(renders.suspenseCount).toBe(1); + expect(renders.frames).toMatchObject([ + { data: { letters: data.slice(0, 2) }, error: undefined }, + { data: { letters: data.slice(2, 4) }, error: undefined }, + ]); + }); + + 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, + }); + }); + + act(() => { + 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, + }); + }); + + expect(renders.frames).toMatchObject([ + { data: { letters: data.slice(0, 2) }, error: undefined }, + { data: { letters: data.slice(0, 4) }, error: undefined }, + ]); + }); + + 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, + }); + }); + + act(() => { + result.current.fetchMore({ variables: { offset: 2 } }); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { letters: data.slice(0, 4) }, + error: undefined, + }); + }); + + expect(renders.frames).toMatchObject([ + { data: { letters: data.slice(0, 2) }, error: undefined }, + { data: { letters: data.slice(0, 4) }, error: undefined }, + ]); + }); + + 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, + }); + }); + + rerender({ id: '2' }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + data: { character: { id: '2', name: 'Cached Black Widow' } }, + error: undefined, + }); + }); + + 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, + }); + }); + + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + + act(() => { + result.current.refetch({ min: 12, max: 30 }); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + 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, + }); + }); + + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + + act(() => { + 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, + }); + }); + + 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, + }); + }); + + expect(mergeParams).toEqual([[undefined, [2, 3, 5, 7, 11]]]); + + act(() => { + result.current.refetch({ min: 12, max: 30 }); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + expect(mergeParams).toEqual([ + [undefined, [2, 3, 5, 7, 11]], + [undefined, [13, 17, 19, 23, 29]], + ]); + }); + + 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, + }); + }); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[1].result, + error: undefined, + }); + }); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + ...mocks[2].result, + error: undefined, + }); + }); + + expect(client.getObservableQueries().size).toBe(1); + }); +}); 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'; 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] }); + }); +}); 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/useSuspenseCache.ts b/src/react/hooks/useSuspenseCache.ts new file mode 100644 index 00000000000..67bb3464b1c --- /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/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts new file mode 100644 index 00000000000..775229d81cb --- /dev/null +++ b/src/react/hooks/useSuspenseQuery.ts @@ -0,0 +1,311 @@ +import { + useRef, + useEffect, + useCallback, + useMemo, + useState, + useLayoutEffect, +} from 'react'; +import { equal } from '@wry/equality'; +import { + ApolloClient, + ApolloError, + ApolloQueryResult, + DocumentNode, + ObservableQuery, + OperationVariables, + TypedDocumentNode, + WatchQueryOptions, + WatchQueryFetchPolicy, +} from '../../core'; +import { invariant } from '../../utilities/globals'; +import { compact, isNonEmptyArray } from '../../utilities'; +import { useApolloClient } from './useApolloClient'; +import { DocumentType, verifyDocumentType } from '../parser'; +import { + SuspenseQueryHookOptions, + ObservableQueryFields, +} from '../types/types'; +import { useDeepMemo } from './internal'; +import { useSuspenseCache } from './useSuspenseCache'; +import { useSyncExternalStore } from './useSyncExternalStore'; + +export interface UseSuspenseQueryResult< + TData = any, + TVariables = OperationVariables +> { + data: TData; + error: ApolloError | undefined; + fetchMore: ObservableQueryFields['fetchMore']; + refetch: ObservableQueryFields['refetch']; +} + +const SUPPORTED_FETCH_POLICIES: WatchQueryFetchPolicy[] = [ + 'cache-first', + 'network-only', + 'no-cache', + 'cache-and-network', +]; + +const DEFAULT_FETCH_POLICY = 'cache-first'; +const DEFAULT_SUSPENSE_POLICY = 'always'; +const DEFAULT_ERROR_POLICY = 'none'; + +export function useSuspenseQuery_experimental< + TData = any, + TVariables extends OperationVariables = OperationVariables +>( + query: DocumentNode | TypedDocumentNode, + options: SuspenseQueryHookOptions = Object.create(null) +): UseSuspenseQueryResult { + const suspenseCache = useSuspenseCache(); + const client = useApolloClient(options.client); + const watchQueryOptions = useWatchQueryOptions({ query, options, client }); + const previousWatchQueryOptionsRef = useRef(watchQueryOptions); + + const { fetchPolicy, errorPolicy, returnPartialData, variables } = + watchQueryOptions; + + let cacheEntry = suspenseCache.lookup(query, variables); + + const [observable] = useState(() => { + return cacheEntry?.observable || client.watchQuery(watchQueryOptions); + }); + + const result = useObservableQueryResult(observable); + + if (result.error && errorPolicy === 'none') { + throw result.error; + } + + 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) { + cacheEntry = suspenseCache.add(query, variables, { + promise: observable.reobserve(watchQueryOptions), + observable, + }); + } + + 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; + } + } + + useEffect(() => { + const { variables, query } = watchQueryOptions; + const previousOpts = previousWatchQueryOptionsRef.current; + + if (variables !== previousOpts.variables || query !== previousOpts.query) { + suspenseCache.remove(previousOpts.query, previousOpts.variables); + + suspenseCache.add(query, variables, { + promise: observable.reobserve({ query, variables }), + observable, + }); + + previousWatchQueryOptionsRef.current = watchQueryOptions; + } + }, [watchQueryOptions]); + + useEffect(() => { + return () => { + suspenseCache.remove(query, variables); + }; + }, []); + + return useMemo(() => { + return { + data: result.data, + error: errorPolicy === 'all' ? toApolloError(result) : void 0, + fetchMore: (options) => { + const promise = observable.fetchMore(options); + + suspenseCache.add(query, watchQueryOptions.variables, { + promise, + observable, + }); + + return promise; + }, + refetch: (variables?: Partial) => { + const promise = observable.refetch(variables); + + suspenseCache.add(query, watchQueryOptions.variables, { + promise, + observable, + }); + + return promise; + }, + }; + }, [result, observable, errorPolicy]); +} + +function validateOptions(options: WatchQueryOptions) { + const { + query, + fetchPolicy = DEFAULT_FETCH_POLICY, + returnPartialData, + } = options; + + verifyDocumentType(query, DocumentType.Query); + validateFetchPolicy(fetchPolicy); + validatePartialDataReturn(fetchPolicy, returnPartialData); +} + +function validateFetchPolicy(fetchPolicy: WatchQueryFetchPolicy) { + invariant( + SUPPORTED_FETCH_POLICIES.includes(fetchPolicy), + `The fetch policy \`${fetchPolicy}\` is not supported with suspense.` + ); +} + +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 }) + : 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; + + const watchQueryOptions = useDeepMemo< + WatchQueryOptions + >(() => { + 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', + // 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]); + + if (__DEV__) { + validateOptions(watchQueryOptions); + } + + return watchQueryOptions; +} + +function useObservableQueryResult(observable: ObservableQuery) { + const resultRef = useRef>(); + const isMountedRef = 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 suspended. + useLayoutEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + }; + }, []); + + 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 (isMountedRef.current) { + forceUpdate(); + } + } + + const subscription = observable.subscribe({ + next: handleUpdate, + error: handleUpdate, + }); + + return () => { + subscription.unsubscribe(); + }; + }, + [observable] + ), + () => resultRef.current!, + () => resultRef.current! + ); +} 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, diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 200a856e559..2eb8bacdd4d 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -16,7 +16,9 @@ import { OperationVariables, InternalRefetchQueriesInclude, WatchQueryOptions, + WatchQueryFetchPolicy, } from '../../core'; +import { NextFetchPolicyContext } from '../../core/watchQueryOptions'; /* Common types */ @@ -91,6 +93,46 @@ 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 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' + | 'canonizeResults' + | 'returnPartialData' + | 'refetchWritePolicy' +> { + fetchPolicy?: SuspenseQueryHookFetchPolicy; + nextFetchPolicy?: + | SuspenseQueryHookFetchPolicy + | (( + currentFetchPolicy: SuspenseQueryHookFetchPolicy, + context: NextFetchPolicyContext + ) => SuspenseQueryHookFetchPolicy); + suspensePolicy?: SuspensePolicy; +} + /** * @deprecated TODO Delete this unused interface. */ 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;