From 1ef2be1c486cf24d7fedb9a80ae23580d0df3f43 Mon Sep 17 00:00:00 2001 From: Cody Olsen Date: Fri, 26 Jan 2024 11:06:44 +0100 Subject: [PATCH] fix: add guards for common Next.js App Router mistakes --- .eslintrc.cjs | 2 +- .github/workflows/ci.yml | 11 ++ package-lock.json | 376 ++++++++++++++++++++++++++++++++++-- package.json | 7 +- src/SanityClient.ts | 100 +++++++--- src/data/dataMethods.ts | 10 +- src/types.ts | 87 ++++++++- test-next/client.test-d.ts | 189 ++++++++++++++++++ test-next/tsconfig.json | 12 ++ test/client.test-d.ts | 321 ++++++++++++++++++++++++++---- test/stega/client.test-d.ts | 70 ++++--- tsconfig.json | 2 +- vite.config.ts | 5 +- vitest.browser.config.ts | 3 + vitest.edge.config.ts | 3 + vitest.next.config.ts | 18 ++ 16 files changed, 1085 insertions(+), 131 deletions(-) create mode 100644 test-next/client.test-d.ts create mode 100644 test-next/tsconfig.json create mode 100644 vitest.next.config.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ff66e4a9..d82ae508 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -35,7 +35,7 @@ module.exports = { }, }, { - files: ['test/**/*.ts'], + files: ['test/**/*.ts', 'test-next/**/*.ts'], rules: { '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-explicit-any': 'off', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 306f5af6..157a88b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,3 +173,14 @@ jobs: with: name: build-output - run: npm run test:node-runtimes + + next-runtime: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + cache: npm + node-version: lts/* + - run: npm install + - run: npm run test:next diff --git a/package-lock.json b/package-lock.json index 9523c39d..e523cf38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "happy-dom": "^12.10.3", "json-diff": "^1.0.6", "ls-engines": "^0.9.1", + "next": "^14.1.0", "nock": "^13.5.0", "prettier": "^3.2.4", "prettier-plugin-packagejson": "^2.4.9", @@ -1020,6 +1021,18 @@ "node": "*" } }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", @@ -1411,6 +1424,156 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/@next/env": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz", + "integrity": "sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==", + "dev": true + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.0.tgz", + "integrity": "sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.0.tgz", + "integrity": "sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.0.tgz", + "integrity": "sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.0.tgz", + "integrity": "sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.0.tgz", + "integrity": "sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.0.tgz", + "integrity": "sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.0.tgz", + "integrity": "sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.0.tgz", + "integrity": "sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.0.tgz", + "integrity": "sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2734,6 +2897,15 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@swc/helpers": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", + "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -3810,6 +3982,18 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dev": true, + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4007,6 +4191,12 @@ "node": ">=6" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "dev": true + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4853,6 +5043,18 @@ "node": "*" } }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -7711,6 +7913,19 @@ "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -8523,6 +8738,86 @@ "node": ">= 0.6" } }, + "node_modules/next": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/next/-/next-14.1.0.tgz", + "integrity": "sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==", + "dev": true, + "dependencies": { + "@next/env": "14.1.0", + "@swc/helpers": "0.5.2", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.1.0", + "@next/swc-darwin-x64": "14.1.0", + "@next/swc-linux-arm64-gnu": "14.1.0", + "@next/swc-linux-arm64-musl": "14.1.0", + "@next/swc-linux-x64-gnu": "14.1.0", + "@next/swc-linux-x64-musl": "14.1.0", + "@next/swc-win32-arm64-msvc": "14.1.0", + "@next/swc-win32-ia32-msvc": "14.1.0", + "@next/swc-win32-x64-msvc": "14.1.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/nock": { "version": "13.5.0", "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.0.tgz", @@ -10064,6 +10359,33 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dev": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dev": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -10454,6 +10776,16 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dev": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -10885,6 +11217,15 @@ "node": ">= 0.4" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -11054,6 +11395,29 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "dev": true, + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11661,18 +12025,6 @@ "node": ">=4" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", diff --git a/package.json b/package.json index 939f60ce..db295bdc 100644 --- a/package.json +++ b/package.json @@ -108,18 +108,22 @@ "minify": "terser -c -m -- umd/sanityClient.js > umd/sanityClient.min.js", "prepublishOnly": "npm run build", "rollup": "NODE_ENV=production rollup -c rollup.config.cjs", - "test": "vitest --typecheck", + "test": "vitest", "test:browser": "npm test -- --config ./vitest.browser.config.ts", "test:bun": "bun test runtimes/bun", "test:deno": "deno test --allow-read --allow-net --allow-env --fail-fast --import-map=runtimes/deno/import_map.json runtimes/deno", "test:deno:update_import_map": "deno run --allow-read --allow-write runtimes/deno/update_import_map.ts", "posttest:deno:update_import_map": "npx prettier --write runtimes/deno/import_map.json", "test:edge-runtime": "npm test -- --config vitest.edge.config.ts", + "test:next": "npm test -- --config ./vitest.next.config.ts", "test:node-runtimes": "node --test runtimes/node | npx faucet" }, "browserslist": "extends @sanity/browserslist-config", "prettier": { "bracketSpacing": false, + "plugins": [ + "prettier-plugin-packagejson" + ], "printWidth": 100, "semi": false, "singleQuote": true @@ -149,6 +153,7 @@ "happy-dom": "^12.10.3", "json-diff": "^1.0.6", "ls-engines": "^0.9.1", + "next": "^14.1.0", "nock": "^13.5.0", "prettier": "^3.2.4", "prettier-plugin-packagejson": "^2.4.9", diff --git a/src/SanityClient.ts b/src/SanityClient.ts index 08742076..22f47342 100644 --- a/src/SanityClient.ts +++ b/src/SanityClient.ts @@ -25,7 +25,11 @@ import type { MutationSelection, PatchOperations, PatchSelection, + QueryOptions, QueryParams, + QueryParamsLikelyByMistake, + QueryParamsParameter, + QueryParamsWithoutQueryOptions, RawQueryResponse, RawRequestOptions, SanityDocument, @@ -130,42 +134,58 @@ export class ObservableSanityClient { * * @param query - GROQ-query to perform */ - fetch(query: string): Observable + fetch< + R = Any, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Q extends never | undefined | Record = never, + >(query: string): Observable /** * Perform a GROQ-query against the configured dataset. * * @param query - GROQ-query to perform - * @param params - Query parameters - */ - fetch(query: string, params: Q): Observable - /** - * Perform a GROQ-query against the configured dataset. - * - * @param query - GROQ-query to perform - * @param params - Query parameters - * @param options - Request options + * @param params - Optional query parameters + * @param options - Optional request options */ - fetch( + fetch( query: string, - params: Q | undefined, - options: FilteredResponseQueryOptions, + params: QueryParamsParameter, + options?: FilteredResponseQueryOptions, ): Observable /** * Perform a GROQ-query against the configured dataset. * * @param query - GROQ-query to perform - * @param params - Query parameters + * @param params - Optional query parameters * @param options - Request options */ - fetch( + fetch( query: string, - params: Q | undefined, + params: QueryParamsParameter, options: UnfilteredResponseQueryOptions, ): Observable> + /** + * You're passing in query parameters to a GROQ query that looks like query options. + * This is likely a mistake, you can either: + * a) replace the second argument with an empty object, and move the options to the third argument + * ```diff + * -client.fetch(query, {cache: 'no-store'}) + * +client.fetch(query, {}, {cache: 'no-store'}) + * ``` + * b) add a generic type parameter that allows the query parameters to be passed in to silence the error + * @deprecated not actually deprecated, marking it as deprecated makes this error easier to spot + */ + fetch< + // eslint-disable-next-line @typescript-eslint/no-unused-vars + R = Any, + >( + query: string, + params: QueryParamsLikelyByMistake & QueryParams, + options?: QueryOptions, + ): unknown fetch( query: string, params?: Q, - options: FilteredResponseQueryOptions | UnfilteredResponseQueryOptions = {}, + options?: QueryOptions, ): Observable | R> { return dataMethods._fetch( this, @@ -773,25 +793,22 @@ export class SanityClient { * * @param query - GROQ-query to perform */ - fetch(query: string): Promise + fetch< + R = Any, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Q extends never | undefined | Record = never, + >(query: string): Promise /** * Perform a GROQ-query against the configured dataset. * * @param query - GROQ-query to perform * @param params - Optional query parameters + * @param options - Optional request options */ - fetch(query: string, params: Q): Promise - /** - * Perform a GROQ-query against the configured dataset. - * - * @param query - GROQ-query to perform - * @param params - Optional query parameters - * @param options - Request options - */ - fetch( + fetch( query: string, - params: Q | undefined, - options: FilteredResponseQueryOptions, + params: QueryParamsParameter, + options?: FilteredResponseQueryOptions, ): Promise /** * Perform a GROQ-query against the configured dataset. @@ -800,15 +817,34 @@ export class SanityClient { * @param params - Optional query parameters * @param options - Request options */ - fetch( + fetch( query: string, - params: Q | undefined, + params: QueryParamsParameter, options: UnfilteredResponseQueryOptions, ): Promise> + /** + * You're passing in query parameters to a GROQ query that looks like query options. + * This is likely a mistake, you can either: + * a) replace the second argument with an empty object, and move the options to the third argument + * ```diff + * -client.fetch(query, {cache: 'no-store'}) + * +client.fetch(query, {}, {cache: 'no-store'}) + * ``` + * b) add a generic type parameter that allows the query parameters to be passed in to silence the error + * @deprecated not actually deprecated, marking it as deprecated makes this error easier to spot + */ + fetch< + // eslint-disable-next-line @typescript-eslint/no-unused-vars + R = Any, + >( + query: string, + params: QueryParamsLikelyByMistake & QueryParams, + options?: QueryOptions, + ): unknown fetch( query: string, params?: Q, - options: FilteredResponseQueryOptions | UnfilteredResponseQueryOptions = {}, + options?: QueryOptions, ): Promise | R> { return lastValueFrom( dataMethods._fetch( diff --git a/src/data/dataMethods.ts b/src/data/dataMethods.ts index 2b9cfffd..fa4d6239 100644 --- a/src/data/dataMethods.ts +++ b/src/data/dataMethods.ts @@ -10,7 +10,6 @@ import type { AllDocumentsMutationOptions, Any, BaseMutationOptions, - FilteredResponseQueryOptions, FirstDocumentIdMutationOptions, FirstDocumentMutationOptions, HttpRequest, @@ -20,13 +19,12 @@ import type { MultipleMutationResult, Mutation, MutationSelection, - QueryParams, + QueryOptions, RawQueryResponse, RequestObservableOptions, RequestOptions, SanityDocument, SingleMutationResult, - UnfilteredResponseQueryOptions, } from '../types' import {getSelection} from '../util/getSelection' import * as validate from '../validators' @@ -64,13 +62,13 @@ const indexBy = (docs: Any[], attr: Any) => const getQuerySizeLimit = 11264 /** @internal */ -export function _fetch( +export function _fetch( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, _stega: InitializedStegaConfig, query: string, - _params?: Q, - options: FilteredResponseQueryOptions | UnfilteredResponseQueryOptions = {}, + _params: Q = {} as Q, + options: QueryOptions = {}, ): Observable | R> { const stega = 'stega' in options diff --git a/src/types.ts b/src/types.ts index bb3554dc..6e3802bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -435,6 +435,79 @@ export interface PatchOperations { /** @public */ export type QueryParams = {[key: string]: Any} + +/** + * Verify this type has all the same keys as QueryOptions before exporting + * @internal + */ +export type _QueryParamsLikelyByMistake = { + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + body?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + cache?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + filterResponse?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + headers?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + method?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + next?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + perspective?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + query?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + resultSourceMap?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + signal?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + stega?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + tag?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + timeout?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + token?: Any + /** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */ + useCdn?: Any +} + +/** + * It's easy to accidentally set query options such as `filterResponse`, `cache` and `next` as the second parameter in `client.fetch`, + * as that is a wide type used to set GROQ query paramaters and it accepts anything that can serialize to JSON. + * This type is used to prevent that, and will cause a type error if you try to pass a query option as the second parameter. + * If this type is `never`, it means `_QueryParamsLikelyByMistake` is missing keys from `QueryOptions`. + * @internal + */ +export type QueryParamsLikelyByMistake = + Required<_QueryParamsLikelyByMistake> extends Record + ? _QueryParamsLikelyByMistake + : never + +/** + * It's easy to accidentally set query options such as `filterResponse`, `cache` and `next` as the second parameter in `client.fetch`, + * as that is a wide type used to set GROQ query paramaters and it accepts anything that can serialize to JSON. + * This type is used to prevent that, and will cause a type error if you try to pass a query option as the second parameter. + * @internal + */ +export type QueryParamsWithoutQueryOptions = { + [K in keyof _QueryParamsLikelyByMistake]: never +} & QueryParams + +/** + * Transform a QueryParams generic type to a valid parameter type for `client.fetch + * @public + */ +export type QueryParamsParameter = + QueryParamsParameterType extends QueryParams + ? QueryParamsParameterType + : QueryParamsParameterType extends Record + ? Record + : QueryParamsParameterType extends undefined + ? undefined | Record + : never + /** @internal */ export type MutationSelection = {query: string; params?: QueryParams} | {id: string | string[]} /** @internal */ @@ -674,25 +747,29 @@ export interface ListenOptions { } /** @public */ -export interface ResponseQueryOptions extends RequestOptions { +export interface ResponseQueryOptions extends RequestOptions { perspective?: ClientPerspective resultSourceMap?: boolean | 'withKeyArraySelector' - cache?: RequestInit['cache'] - next?: T extends keyof RequestInit ? RequestInit[T] : never useCdn?: boolean stega?: boolean | StegaConfig + // The `cache` and `next` options are specific to the Next.js App Router integration + cache?: 'next' extends keyof RequestInit ? RequestInit['cache'] : never + next?: ('next' extends keyof RequestInit ? RequestInit : never)['next'] } /** @public */ -export type FilteredResponseQueryOptions = ResponseQueryOptions & { +export interface FilteredResponseQueryOptions extends ResponseQueryOptions { filterResponse?: true } /** @public */ -export type UnfilteredResponseQueryOptions = ResponseQueryOptions & { +export interface UnfilteredResponseQueryOptions extends ResponseQueryOptions { filterResponse: false } +/** @public */ +export type QueryOptions = FilteredResponseQueryOptions | UnfilteredResponseQueryOptions + /** @public */ export interface RawQueryResponse { query: string diff --git a/test-next/client.test-d.ts b/test-next/client.test-d.ts new file mode 100644 index 00000000..360e97ef --- /dev/null +++ b/test-next/client.test-d.ts @@ -0,0 +1,189 @@ +/// + +import {createClient, QueryOptions, RawQueryResponse} from '@sanity/client' +import {describe, expectTypeOf, test} from 'vitest' + +describe('client.fetch', () => { + const client = createClient({}) + test('simple query', async () => { + expectTypeOf( + await client.fetch( + '*[_type == $type]', + {type: 'post'}, + {cache: 'force-cache', next: {revalidate: 60, tags: ['post']}}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + '*[_type == $type]', + {type: 'post'}, + {filterResponse: false, cache: 'force-cache', next: {revalidate: 60, tags: ['post']}}, + ), + ).toMatchTypeOf>() + }) + test('generics', async () => { + expectTypeOf( + await client.fetch('count(*[cache == $cache])', {cache: 'invalid-cache'}), + ).not.toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[cache == $cache])', { + cache: 'force-cache', + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[cache == $cache])', + // @ts-expect-error -- should fail + {cache: 'invalid-cache'}, + ), + ).toMatchTypeOf() + + expectTypeOf( + await client.fetch('count(*[next.revalidate == $next.revalidate])', { + next: {revalidate: false}, + }), + ).not.toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[next.revalidate == $next.revalidate])', + { + next: {revalidate: false}, + }, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[next.revalidate == $next.revalidate])', + { + next: { + revalidate: 60, + // @ts-expect-error -- should fail + cache: 'invalid-cache', + }, + }, + ), + ).toMatchTypeOf() + }) + test('params can use properties that conflict with Next.js-defined properties', async () => { + // `client.fetch` has type checking to prevent the common mistake of passing `cache` and `next` options as params (2nd parameter) in Next.js projects, where they should be passed as options (the 3rd parameter) + // the below checks ensures that the type guard doesn't prevent valid calls in non-Next.js projects + expectTypeOf(await client.fetch('count(*[cache == $cache])', {})).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[cache == $cache])', {cache: 'no-store'}), + ).not.toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[cache == $cache])', { + cache: 'no-store', + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[cache == $cache])', { + // @ts-expect-error -- should fail + cache: 'invalid-value', + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[cache == $cache])', { + // @ts-expect-error -- should fail + 'invalid-key': 'no-store', + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*)', {}, {cache: 'no-store'}), + ).toMatchTypeOf() + + expectTypeOf( + await client.fetch('count(*[next.revalidate == $next.revalidate])', {next: {revalidate: 60}}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[next.revalidate == $next.revalidate])', { + next: {revalidate: 60}, + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[next.revalidate == $next.revalidate])', + {next: {revalidate: 60}}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[next.revalidate == $next.revalidate])', + { + // @ts-expect-error -- should fail + next: {revalidate: false}, + }, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[next.revalidate == $next.revalidate])', + { + // @ts-expect-error -- should fail + 'invalid-key': 'no-store', + }, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*)', {}, {next: {revalidate: 60}}), + ).toMatchTypeOf() + + expectTypeOf( + await client.fetch('count(*[next.tags == $next.tags])', {next: {tags: ['post']}}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[next.tags == $next.tags])', { + next: {tags: ['post']}, + }), + ).not.toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[next.tags == $next.tags])', { + next: {tags: ['post']}, + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[next.tags == $next.tags])', { + // @ts-expect-error -- should fail + next: {tags: 'post'}, + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[next.tags == $next.tags])', { + // @ts-expect-error -- should fail + 'invalid-key': 'no-store', + }), + ).toMatchTypeOf() + }) + + test('options for Next.js App Router are available', async () => { + expectTypeOf( + await client.fetch('*[_type == $type]', {type: 'post'}, {cache: 'no-store'}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('*[_type == $type]', {type: 'post'}, {next: {}}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('*[_type == $type]', {type: 'post'}, {next: {revalidate: 60}}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('*[_type == $type]', {type: 'post'}, {next: {revalidate: false}}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('*[_type == $type]', {type: 'post'}, {next: {tags: ['post']}}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + '*[_type == $type]', + {type: 'post'}, + {next: {revalidate: 60, tags: ['post']}}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + '*[_type == $type]', + {type: 'post'}, + {next: {revalidate: false, tags: ['post']}}, + ), + ).toMatchTypeOf() + }) +}) diff --git a/test-next/tsconfig.json b/test-next/tsconfig.json new file mode 100644 index 00000000..a8005dd8 --- /dev/null +++ b/test-next/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.settings", + "include": ["../src", "."], + "compilerOptions": { + "noEmit": true, + "module": "esnext", + "paths": { + "@sanity/client": ["../src"], + "@sanity/client/*": ["../src/*"], + }, + }, +} diff --git a/test/client.test-d.ts b/test/client.test-d.ts index d06e0170..a002d9ba 100644 --- a/test/client.test-d.ts +++ b/test/client.test-d.ts @@ -1,61 +1,310 @@ -import {ContentSourceMap, createClient} from '@sanity/client' +import {ContentSourceMap, createClient, RawQueryResponse} from '@sanity/client' import {describe, expectTypeOf, test} from 'vitest' describe('client.fetch', () => { const client = createClient({}) test('simple query', async () => { - expectTypeOf(client.fetch('*')).toMatchTypeOf>() - expectTypeOf(client.fetch('*[_type == $type]', {type: 'post'})).toMatchTypeOf>() + expectTypeOf(await client.fetch('*')).toMatchTypeOf() + expectTypeOf(await client.fetch('*', undefined)).toMatchTypeOf() + expectTypeOf(await client.fetch('*', {})).toMatchTypeOf() + expectTypeOf(await client.fetch('*[_type == $type]', {type: 'post'})).toMatchTypeOf() + expectTypeOf(await client.fetch('*', undefined, {filterResponse: false})).toMatchTypeOf< + RawQueryResponse + >() + expectTypeOf(await client.fetch('*', {}, {filterResponse: false})).toMatchTypeOf< + RawQueryResponse + >() + expectTypeOf( + await client.fetch('*[_type == $type]', {type: 'post'}, {filterResponse: false}), + ).toMatchTypeOf>() }) test('generics', async () => { - expectTypeOf(client.fetch('count(*)')).toMatchTypeOf>() + expectTypeOf(await client.fetch('count(*)')).toMatchTypeOf() expectTypeOf( - client.fetch('count(*[_type == $type])', {type: 'post'}), - ).toMatchTypeOf>() - // @ts-expect-error -- should fail - expectTypeOf(client.fetch('count(*[_type == $type])')).toMatchTypeOf< - Promise - >() + await client.fetch('count(*[_type == $type])', {type: 'post'}), + ).toMatchTypeOf() expectTypeOf( - // @ts-expect-error -- should fail - client.fetch('count(*[_type == $type])', {_type: 'post'}), - ).toMatchTypeOf>() + await client.fetch('count(*[_type == $type])', { + // @ts-expect-error -- should fail + _type: 'post', + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[_type == $type])', + // @ts-expect-error -- should fail + {type: 'post'}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[_type == $type])', + // @ts-expect-error -- should fail + {type: 'post'}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch>( + 'count(*[_type == $type])', + // @ts-expect-error -- should fail + {type: 'post'}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[_type == $type])'), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[_type == $type])', + // @ts-expect-error -- should fail + undefined, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[_type == $type])', + // @ts-expect-error -- should fail + {}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[_type == $type])'), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[_type == $type])', undefined), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[_type == $type])', {}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[_type == $type])', + // @ts-expect-error -- should fail + {type: 'post'}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch>('count(*[_type == $type])'), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch>( + 'count(*[_type == $type])', + // @ts-expect-error -- should fail + undefined, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch>('count(*[_type == $type])', {}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch>( + 'count(*[_type == $type])', + // @ts-expect-error -- should fail + {type: 'post'}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[_type == $type])', { + filterResponse: false, + }), + ).not.toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[_type == $type])', { + filterResponse: false, + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[_type == $type])', { + cache: 'no-store', + type: 'post', + }), + ).not.toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[_type == $type])', + { + cache: 'no-store', + type: 'post', + }, + ), + ).toMatchTypeOf() }) test('filterResponse: false', async () => { - expectTypeOf(client.fetch('count(*)', {}, {filterResponse: true})).toMatchTypeOf< - Promise - >() - expectTypeOf(client.fetch('count(*)', {}, {filterResponse: false})).toMatchTypeOf< - Promise<{ - result: number - ms: number - query: string - resultSourceMap?: ContentSourceMap - }> - >() expectTypeOf( - client.fetch( + await client.fetch('count(*)', {}, {filterResponse: true}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*)', {}, {filterResponse: false as const}), + ).toMatchTypeOf<{ + result: number + ms: number + query: string + resultSourceMap?: ContentSourceMap + }>() + expectTypeOf( + await client.fetch( 'count(*[_type == $type])', {type: 'post'}, {filterResponse: true}, ), - ).toMatchTypeOf>() + ).toMatchTypeOf() expectTypeOf( - client.fetch( + await client.fetch( 'count(*[_type == $type])', {type: 'post'}, {filterResponse: false}, ), - ).toMatchTypeOf< - Promise<{ - result: number - ms: number - query: string - resultSourceMap?: ContentSourceMap - }> - >() + ).toMatchTypeOf<{ + result: number + ms: number + query: string + resultSourceMap?: ContentSourceMap + }>() }) test('stega: false', async () => { - expectTypeOf(client.fetch('*', {}, {stega: false})).toMatchTypeOf>() + expectTypeOf(await client.fetch('*', {}, {stega: false})).toMatchTypeOf() + }) + test('params can use properties that conflict with Next.js-defined properties', async () => { + // `client.fetch` has type checking to prevent the common mistake of passing `cache` and `next` options as params (2nd parameter) in Next.js projects, where they should be passed as options (the 3rd parameter) + // the below checks ensures that the type guard doesn't prevent valid calls in non-Next.js projects + expectTypeOf(await client.fetch('count(*[cache == $cache])', {})).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[cache == $cache])', {cache: 'no-store'}), + ).not.toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[cache == $cache])', { + cache: 'no-store', + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[cache == $cache])', { + // @ts-expect-error -- should fail + cache: 'invalid-value', + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[cache == $cache])', { + // @ts-expect-error -- should fail + 'invalid-key': 'no-store', + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*)', + {}, + { + // @ts-expect-error -- should fail as it's not a Next.js project + cache: 'no-store', + }, + ), + ).toMatchTypeOf() + + expectTypeOf( + await client.fetch('count(*[next.revalidate == $next.revalidate])', {next: {revalidate: 60}}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[next.revalidate == $next.revalidate])', { + next: {revalidate: 60}, + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[next.revalidate == $next.revalidate])', + {next: {revalidate: 60}}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[next.revalidate == $next.revalidate])', + { + // @ts-expect-error -- should fail + next: {revalidate: false}, + }, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*[next.revalidate == $next.revalidate])', + { + // @ts-expect-error -- should fail + 'invalid-key': 'no-store', + }, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + 'count(*)', + {}, + { + // @ts-expect-error -- should fail as it's not a Next.js project + next: {revalidate: 60}, + }, + ), + ).toMatchTypeOf() + + expectTypeOf( + await client.fetch('count(*[next.tags == $next.tags])', {next: {tags: ['post']}}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[next.tags == $next.tags])', { + next: {tags: ['post']}, + }), + ).not.toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[next.tags == $next.tags])', { + next: {tags: ['post']}, + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[next.tags == $next.tags])', { + // @ts-expect-error -- should fail + next: {tags: 'post'}, + }), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*[next.tags == $next.tags])', { + // @ts-expect-error -- should fail + 'invalid-key': 'no-store', + }), + ).toMatchTypeOf() + }) + + test('options for Next.js App Router are not allowed outside Next.js', async () => { + expectTypeOf( + // @ts-expect-error -- should fail + await client.fetch('*[_type == $type]', {type: 'post'}, {cache: 'no-store'}), + ).toMatchTypeOf() + expectTypeOf( + // @ts-expect-error -- should fail + await client.fetch('*[_type == $type]', {type: 'post'}, {next: {}}), + ).toMatchTypeOf() + expectTypeOf( + // @ts-expect-error -- should fail + await client.fetch('*[_type == $type]', {type: 'post'}, {next: {revalidate: 60}}), + ).toMatchTypeOf() + expectTypeOf( + // @ts-expect-error -- should fail + await client.fetch('*[_type == $type]', {type: 'post'}, {next: {revalidate: false}}), + ).toMatchTypeOf() + expectTypeOf( + // @ts-expect-error -- should fail + await client.fetch('*[_type == $type]', {type: 'post'}, {next: {tags: ['post']}}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + '*[_type == $type]', + {type: 'post'}, + // @ts-expect-error -- should fail + {next: {revalidate: 60, tags: ['post']}}, + ), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch( + '*[_type == $type]', + {type: 'post'}, + // @ts-expect-error -- should fail + {next: {revalidate: false, tags: ['post']}}, + ), + ).toMatchTypeOf() }) }) diff --git a/test/stega/client.test-d.ts b/test/stega/client.test-d.ts index e9d246c0..a37f38a1 100644 --- a/test/stega/client.test-d.ts +++ b/test/stega/client.test-d.ts @@ -9,56 +9,54 @@ import {describe, expectTypeOf, test} from 'vitest' describe('client.fetch', () => { const client: SanityClient | SanityStegaClient = createStegaClient({}) test('simple query', async () => { - expectTypeOf(client.fetch('*')).toMatchTypeOf>() - expectTypeOf(client.fetch('*[_type == $type]', {type: 'post'})).toMatchTypeOf>() + expectTypeOf(await client.fetch('*')).toMatchTypeOf() + expectTypeOf(await client.fetch('*[_type == $type]', {type: 'post'})).toMatchTypeOf() }) test('generics', async () => { - expectTypeOf(client.fetch('count(*)')).toMatchTypeOf>() + expectTypeOf(await client.fetch('count(*)')).toMatchTypeOf() expectTypeOf( - client.fetch('count(*[_type == $type])', {type: 'post'}), - ).toMatchTypeOf>() - // @ts-expect-error -- should fail - expectTypeOf(client.fetch('count(*[_type == $type])')).toMatchTypeOf< - Promise - >() + await client.fetch('count(*[_type == $type])', {type: 'post'}), + ).toMatchTypeOf() expectTypeOf( // @ts-expect-error -- should fail - client.fetch('count(*[_type == $type])', {_type: 'post'}), - ).toMatchTypeOf>() + await client.fetch('count(*[_type == $type])'), + ).toMatchTypeOf() + expectTypeOf( + // @ts-expect-error -- should fail + await client.fetch('count(*[_type == $type])', {_type: 'post'}), + ).toMatchTypeOf() }) test('filterResponse: false', async () => { - expectTypeOf(client.fetch('count(*)', {}, {filterResponse: true})).toMatchTypeOf< - Promise - >() - expectTypeOf(client.fetch('count(*)', {}, {filterResponse: false})).toMatchTypeOf< - Promise<{ - result: number - ms: number - query: string - resultSourceMap?: ContentSourceMap - }> - >() expectTypeOf( - client.fetch( + await client.fetch('count(*)', {}, {filterResponse: true}), + ).toMatchTypeOf() + expectTypeOf( + await client.fetch('count(*)', {}, {filterResponse: false}), + ).toMatchTypeOf<{ + result: number + ms: number + query: string + resultSourceMap?: ContentSourceMap + }>() + expectTypeOf( + await client.fetch( 'count(*[_type == $type])', {type: 'post'}, {filterResponse: true}, ), - ).toMatchTypeOf>() + ).toMatchTypeOf() expectTypeOf( - client.fetch( + await client.fetch( 'count(*[_type == $type])', {type: 'post'}, {filterResponse: false}, ), - ).toMatchTypeOf< - Promise<{ - result: number - ms: number - query: string - resultSourceMap?: ContentSourceMap - }> - >() + ).toMatchTypeOf<{ + result: number + ms: number + query: string + resultSourceMap?: ContentSourceMap + }>() }) }) @@ -76,6 +74,6 @@ test('SanityClient type can be assigned to SanityStegaClient', () => { expectTypeOf(isSanityClient(createClient({}))).toMatchTypeOf() }) -// test('SanityClient type is assignable to itself on both export paths', () => { -// expectTypeOf().toMatchTypeOf() -// }) +test('SanityClient type is assignable to itself on both export paths', async () => { + expectTypeOf().toMatchTypeOf() +}) diff --git a/tsconfig.json b/tsconfig.json index 661ee831..93965533 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.settings", "include": ["./src", "./modules.d.ts", "./test"], - "exclude": ["./test-esm/test.ts"], + "exclude": ["./test-esm/test.ts", "./node_modules/next", "./test-next"], "compilerOptions": { "noEmit": true, "types": ["@edge-runtime/types"], diff --git a/vite.config.ts b/vite.config.ts index b897466c..5a499915 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,7 @@ import pkg from './package.json' export const sharedConfig: UserConfig['test'] = { // don't use vitest to run Bun and Deno tests - exclude: [...configDefaults.exclude, 'runtimes/**'], + exclude: [...configDefaults.exclude, 'runtimes/**', 'test-next/**'], // Enable rich PR failed test annotation on the CI reporters: process.env.GITHUB_ACTIONS ? ['default', new GithubActionsReporter()] : 'default', // Allow switching test runs from using the source TS or compiled ESM @@ -16,6 +16,9 @@ export const sharedConfig: UserConfig['test'] = { '@sanity/client/stega': new URL(pkg.exports['./stega'].source, import.meta.url).pathname, '@sanity/client': new URL(pkg.exports['.'].source, import.meta.url).pathname, }, + typecheck: { + enabled: true, + }, } export default defineConfig({ diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 3f59f1c1..cac51826 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -16,6 +16,9 @@ export default defineConfig({ .pathname, '@sanity/client': new URL(pkg.exports['.'].browser.source, import.meta.url).pathname, }, + typecheck: { + enabled: false, + }, }, resolve: { conditions: ['browser', 'module', 'import'], diff --git a/vitest.edge.config.ts b/vitest.edge.config.ts index 9ef91ea1..03224af6 100644 --- a/vitest.edge.config.ts +++ b/vitest.edge.config.ts @@ -16,6 +16,9 @@ export default defineConfig({ .pathname, '@sanity/client': new URL(pkg.exports['.'].browser.source, import.meta.url).pathname, }, + typecheck: { + enabled: false, + }, }, resolve: { // https://github.com/vercel/next.js/blob/95322649ffb2ad0d6423481faed188dd7b1f7ff2/packages/next/src/build/webpack-config.ts#L1079-L1084 diff --git a/vitest.next.config.ts b/vitest.next.config.ts new file mode 100644 index 00000000..cd7efb4b --- /dev/null +++ b/vitest.next.config.ts @@ -0,0 +1,18 @@ +// Runs typecheck tests with Next.js App Router typings for fetch `cache`, `next.revalidate` and `next.tags` typings + +import {configDefaults, defineConfig} from 'vitest/config' + +import {sharedConfig} from './vite.config' + +export default defineConfig({ + test: { + ...sharedConfig, + // Only run tests in the text-next directory + exclude: [...configDefaults.exclude, 'runtimes/**', 'test/**'], + typecheck: { + enabled: true, + tsconfig: 'test-next/tsconfig.json', + exclude: ['test/**'], + }, + }, +})