From 987a6f24b3fd453dafdbc03ae2de610a8c4cd257 Mon Sep 17 00:00:00 2001 From: Alec Aivazis Date: Sun, 9 Apr 2023 08:36:17 -0700 Subject: [PATCH] Suspense integration for React bindings (#1044) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .changeset/config.json | 2 +- .changeset/popular-rocks-join.md | 5 + .eslintrc.cjs | 2 +- e2e/react-vite/.gitignore | 24 + e2e/react-vite/houdini.config.js | 36 + e2e/react-vite/index.html | 13 + e2e/react-vite/package.json | 33 + e2e/react-vite/public/vite.svg | 1 + e2e/react-vite/src/App.css | 42 + e2e/react-vite/src/App.tsx | 69 + e2e/react-vite/src/assets/react.svg | 1 + e2e/react-vite/src/client.ts | 13 + e2e/react-vite/src/index.css | 69 + e2e/react-vite/src/main.tsx | 10 + e2e/react-vite/src/vite-env.d.ts | 1 + e2e/react-vite/tsconfig.json | 25 + e2e/react-vite/tsconfig.node.json | 9 + e2e/react-vite/vite.config.ts | 16 + package.json | 2 +- packages/houdini-react/package.json | 7 +- packages/houdini-react/src/plugin/extract.ts | 56 +- packages/houdini-react/src/plugin/index.ts | 183 +- .../houdini-react/src/plugin/transform.ts | 167 +- .../houdini-react/src/runtime/context.tsx | 12 + .../houdini-react/src/runtime/hooks/index.ts | 9 + .../src/runtime/hooks/useDeepCompareEffect.ts | 89 + .../src/runtime/hooks/useDocumentHandle.ts | 162 ++ .../src/runtime/hooks/useDocumentStore.ts | 57 + .../runtime/hooks/useDocumentSubscription.ts | 52 + .../src/runtime/hooks/useFragment.ts | 100 + .../src/runtime/hooks/useFragmentHandle.ts | 46 + .../src/runtime/hooks/useHoudiniClient.ts | 13 + .../src/runtime/hooks/useIsMounted.ts | 14 + .../src/runtime/hooks/useMutation.ts | 46 + .../src/runtime/hooks/useQuery.ts | 17 + .../src/runtime/hooks/useQueryHandle.ts | 183 ++ .../src/runtime/hooks/useSubscription.ts | 12 + .../runtime/hooks/useSubscriptionHandle.ts | 33 + packages/houdini-react/src/runtime/index.ts | 18 +- .../houdini-react/src/runtime/lib/cache.ts | 103 + .../src/runtime/stores/index.ts | 2 +- .../src/runtime/stores/pagination/fetch.ts | 7 - .../src/runtime/stores/pagination/fragment.ts | 16 +- .../src/runtime/stores/pagination/offset.ts | 110 - .../src/runtime/stores/pagination/query.ts | 24 +- .../src/runtime/stores/query.ts | 84 +- packages/houdini-svelte/src/runtime/types.ts | 94 +- .../src/codegen/generators/indexFile/index.ts | 4 - .../src/codegen/generators/runtime/index.ts | 14 +- .../generators/typescript/documentTypes.ts | 84 +- .../generators/typescript/typescript.test.ts | 1917 +++++++++++++++++ packages/houdini/src/codegen/index.ts | 8 +- packages/houdini/src/lib/config.ts | 15 + packages/houdini/src/lib/deepMerge.ts | 2 +- packages/houdini/src/lib/parse.ts | 17 +- packages/houdini/src/lib/types.ts | 6 +- .../src/runtime/client/documentStore.ts | 26 +- packages/houdini/src/runtime/client/index.ts | 2 +- .../src/runtime/client/plugins/cache.ts | 7 +- .../src/runtime/client/plugins/fragment.ts | 17 +- .../src/runtime/client/plugins/query.ts | 3 +- .../src/runtime/lib}/pageInfo.test.ts | 0 .../src/runtime/lib}/pageInfo.ts | 11 +- .../src/runtime/lib/pagination.ts} | 187 +- packages/houdini/src/runtime/lib/types.ts | 56 + pnpm-lock.yaml | 360 +++- site/src/routes/api/codegen-plugins/+page.svx | 25 + tsconfig.json | 5 +- 68 files changed, 4255 insertions(+), 600 deletions(-) create mode 100644 .changeset/popular-rocks-join.md create mode 100644 e2e/react-vite/.gitignore create mode 100644 e2e/react-vite/houdini.config.js create mode 100644 e2e/react-vite/index.html create mode 100644 e2e/react-vite/package.json create mode 100644 e2e/react-vite/public/vite.svg create mode 100644 e2e/react-vite/src/App.css create mode 100644 e2e/react-vite/src/App.tsx create mode 100644 e2e/react-vite/src/assets/react.svg create mode 100644 e2e/react-vite/src/client.ts create mode 100644 e2e/react-vite/src/index.css create mode 100644 e2e/react-vite/src/main.tsx create mode 100644 e2e/react-vite/src/vite-env.d.ts create mode 100644 e2e/react-vite/tsconfig.json create mode 100644 e2e/react-vite/tsconfig.node.json create mode 100644 e2e/react-vite/vite.config.ts create mode 100644 packages/houdini-react/src/runtime/context.tsx create mode 100644 packages/houdini-react/src/runtime/hooks/index.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useDeepCompareEffect.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useDocumentHandle.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useDocumentStore.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useDocumentSubscription.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useFragment.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useFragmentHandle.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useHoudiniClient.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useIsMounted.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useMutation.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useQuery.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useQueryHandle.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useSubscription.ts create mode 100644 packages/houdini-react/src/runtime/hooks/useSubscriptionHandle.ts create mode 100644 packages/houdini-react/src/runtime/lib/cache.ts delete mode 100644 packages/houdini-svelte/src/runtime/stores/pagination/fetch.ts delete mode 100644 packages/houdini-svelte/src/runtime/stores/pagination/offset.ts rename packages/{houdini-svelte/src/runtime/stores/pagination => houdini/src/runtime/lib}/pageInfo.test.ts (100%) rename packages/{houdini-svelte/src/runtime/stores/pagination => houdini/src/runtime/lib}/pageInfo.ts (83%) rename packages/{houdini-svelte/src/runtime/stores/pagination/cursor.ts => houdini/src/runtime/lib/pagination.ts} (61%) diff --git a/.changeset/config.json b/.changeset/config.json index c147ab0bc..6dcfc13e5 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,5 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["e2e-api", "e2e-next", "e2e-kit", "e2e-svelte", "site"] + "ignore": ["e2e-api", "e2e-next", "e2e-kit", "e2e-svelte", "site", "e2e-react-vite"] } diff --git a/.changeset/popular-rocks-join.md b/.changeset/popular-rocks-join.md new file mode 100644 index 000000000..0e48d6941 --- /dev/null +++ b/.changeset/popular-rocks-join.md @@ -0,0 +1,5 @@ +--- +'houdini-react': minor +--- + +Add suspense integration diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 34f3c2300..f5a6e9694 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -52,5 +52,5 @@ module.exports = { sourceType: 'module', ecmaVersion: 2020, }, - plugins: ['unused-imports'], + plugins: ['unused-imports', 'react', 'react-hooks'], } diff --git a/e2e/react-vite/.gitignore b/e2e/react-vite/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/e2e/react-vite/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/e2e/react-vite/houdini.config.js b/e2e/react-vite/houdini.config.js new file mode 100644 index 000000000..73097954b --- /dev/null +++ b/e2e/react-vite/houdini.config.js @@ -0,0 +1,36 @@ +/// +/// + +/** @type {import('houdini').ConfigFile} */ +const config = { + schemaPath: '../_api/*.graphql', + defaultPartial: true, + scalars: { + DateTime: { + type: 'Date', + // turn the api's response into that type + unmarshal(val) { + return new Date(val) + }, + // turn the value into something the API can use + marshal(val) { + return val.getTime() + }, + }, + File: { + type: 'File', + }, + }, + + types: { + RentedBook: { + keys: ['userId', 'bookId'], + }, + }, + + plugins: { + 'houdini-react': {}, + }, +} + +export default config diff --git a/e2e/react-vite/index.html b/e2e/react-vite/index.html new file mode 100644 index 000000000..e8d73f738 --- /dev/null +++ b/e2e/react-vite/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/e2e/react-vite/package.json b/e2e/react-vite/package.json new file mode 100644 index 000000000..8c01bb424 --- /dev/null +++ b/e2e/react-vite/package.json @@ -0,0 +1,33 @@ +{ + "name": "e2e-react-vite", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build:": "cd ../../ && ((run build && cd -) || (cd - && exit 1))", + "build:dev": "pnpm build: && pnpm dev", + "build:test": "pnpm build: && pnpm test", + "build:build": "pnpm build: && pnpm build", + "web": "vite ", + "api": "cross-env TZ=utc e2e-api", + "dev": "concurrently \"pnpm run web\" \"pnpm run api\" -n \"web,api\" -c \"green,magenta\"", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "houdini": "workspace:^", + "houdini-react": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.27", + "@types/react-dom": "^18.0.10", + "@vitejs/plugin-react": "^3.1.0", + "concurrently": "7.1.0", + "cross-env": "^7.0.3", + "e2e-api": "workspace:^", + "typescript": "^4.9.3", + "vite": "^4.1.0" + } +} diff --git a/e2e/react-vite/public/vite.svg b/e2e/react-vite/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/e2e/react-vite/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/e2e/react-vite/src/App.css b/e2e/react-vite/src/App.css new file mode 100644 index 000000000..df674c0d8 --- /dev/null +++ b/e2e/react-vite/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/e2e/react-vite/src/App.tsx b/e2e/react-vite/src/App.tsx new file mode 100644 index 000000000..850199333 --- /dev/null +++ b/e2e/react-vite/src/App.tsx @@ -0,0 +1,69 @@ +import { useQuery, useFragment, graphql, HoudiniProvider, type UserInfo } from '$houdini' +import * as React from 'react' + +import client from './client' + +export default function App() { + return ( + + + + + + ) +} + +function Query() { + const [variables, setVariables] = React.useState({ id: '1' }) + const [pending, start] = React.useTransition() + + const data = useQuery( + graphql(` + query MyQuery($id: ID!) { + user(id: $id, snapshot: "react-vite-e2e") { + id + ...UserInfo + } + } + `), + variables + ) + + return ( + <> +
+ + + {pending && 'pending'} +
+ + + + ) +} + +function Fragment({ user }: { user: UserInfo }) { + const data = useFragment( + user, + graphql(` + fragment UserInfo on User { + id + name + } + `) + ) + + return ( +
+ {data.id}: {data.name} +
+ ) +} diff --git a/e2e/react-vite/src/assets/react.svg b/e2e/react-vite/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/e2e/react-vite/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/e2e/react-vite/src/client.ts b/e2e/react-vite/src/client.ts new file mode 100644 index 000000000..066fdb2b2 --- /dev/null +++ b/e2e/react-vite/src/client.ts @@ -0,0 +1,13 @@ +import { HoudiniClient } from '$houdini' + +// Export the Houdini client +export default new HoudiniClient({ + url: 'http://localhost:4000/graphql', + plugins: [ + () => ({ + network(ctx, { next }) { + setTimeout(() => next(ctx), 500) + }, + }), + ], +}) diff --git a/e2e/react-vite/src/index.css b/e2e/react-vite/src/index.css new file mode 100644 index 000000000..7b9240bec --- /dev/null +++ b/e2e/react-vite/src/index.css @@ -0,0 +1,69 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/e2e/react-vite/src/main.tsx b/e2e/react-vite/src/main.tsx new file mode 100644 index 000000000..52607b195 --- /dev/null +++ b/e2e/react-vite/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +) diff --git a/e2e/react-vite/src/vite-env.d.ts b/e2e/react-vite/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/e2e/react-vite/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/e2e/react-vite/tsconfig.json b/e2e/react-vite/tsconfig.json new file mode 100644 index 000000000..b04ec87c5 --- /dev/null +++ b/e2e/react-vite/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "paths": { + "$houdini": ["./$houdini"], + "$houdini/*": ["./$houdini/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/e2e/react-vite/tsconfig.node.json b/e2e/react-vite/tsconfig.node.json new file mode 100644 index 000000000..d3bf4b829 --- /dev/null +++ b/e2e/react-vite/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/e2e/react-vite/vite.config.ts b/e2e/react-vite/vite.config.ts new file mode 100644 index 000000000..3197a37f6 --- /dev/null +++ b/e2e/react-vite/vite.config.ts @@ -0,0 +1,16 @@ +import react from '@vitejs/plugin-react' +import { path } from 'houdini' +import houdini from 'houdini/vite' +import { defineConfig } from 'vite' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [houdini(), react()], + // TODO: the vite plugin should do this + resolve: { + alias: { + $houdini: path.resolve('.', '/$houdini'), + '$houdini/*': path.resolve('.', '/$houdini', '*'), + }, + }, +}) diff --git a/package.json b/package.json index d7775f7f5..32753bf2e 100755 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "tests:ui": "vitest --ui --coverage", "test": "pnpm run tests", "build:all": "turbo build", - "build": "turbo build --filter=\"./packages/*\"", + "build": "turbo run build --filter=\"./packages/*\"", "dev": "turbo dev --filter=\"./packages/*\"", "compile:all": "turbo compile", "compile": "turbo compile --filter=\"./packages/*\"", diff --git a/packages/houdini-react/package.json b/packages/houdini-react/package.json index c108387ad..eb9dc4d21 100644 --- a/packages/houdini-react/package.json +++ b/packages/houdini-react/package.json @@ -28,10 +28,15 @@ }, "dependencies": { "@babel/parser": "^7.19.3", + "@types/react": "^18.0.28", + "@types/rollup": "^0.54.0", "estraverse": "^5.3.0", "graphql": "^15.8.0", "houdini": "workspace:^", - "recast": "^0.23.1" + "react": "^18.2.0", + "recast": "^0.23.1", + "rollup": "^3.7.4", + "use-deep-compare-effect": "^1.8.1" }, "files": [ "build" diff --git a/packages/houdini-react/src/plugin/extract.ts b/packages/houdini-react/src/plugin/extract.ts index 36ffa739a..c808a6fe5 100644 --- a/packages/houdini-react/src/plugin/extract.ts +++ b/packages/houdini-react/src/plugin/extract.ts @@ -1,41 +1,35 @@ -import { parse } from '@babel/parser' import type { Config } from 'houdini' -import * as recast from 'recast' +import { find_graphql, parseJS } from 'houdini' -export function extractDocuments({ content }: { config: Config; content: string }) { +export async function extractDocuments({ + content, + config, + filepath, +}: { + config: Config + content: string + filepath: string +}) { // the documents we've found const documents: string[] = [] - // parse the content and look for an invocation of the graphql function - const parsed = parse(content, { - plugins: ['typescript', 'jsx'], - sourceType: 'module', - }).program - - recast.visit(parsed, { - visitCallExpression(node) { - const { value } = node - // we only care about invocations of the graphql function - if (value.callee.type === 'Identifier' && value.callee.name !== 'query') { - return this.traverse(node) - } - - // the argument passed to the graphql function should be a string - // with the document body - if (value.arguments.length !== 1) { - return this.traverse(node) - } - const argument = value.arguments[0] + // only consider tsx and jsx files + if (!filepath.endsWith('.tsx') && !filepath.endsWith('.jsx')) { + return [] + } - // we need to support template literals as well as strings - if (argument.type === 'TemplateLiteral' && argument.quasis.length === 1) { - documents.push(argument.quasis[0].value.raw) - } else if (argument.type === 'StringLiteral') { - documents.push(argument.value) - } + // parse the content + const parsed = await parseJS(content, { + plugins: ['typescript', 'jsx'], + }) + if (!parsed?.script) { + return [] + } - // we're done - return false + // use the houdini utility to search for the graphql functions + await find_graphql(config, parsed.script, { + tag(tag) { + documents.push(tag.tagContent) }, }) diff --git a/packages/houdini-react/src/plugin/index.ts b/packages/houdini-react/src/plugin/index.ts index 53f407b0a..71f38fef2 100644 --- a/packages/houdini-react/src/plugin/index.ts +++ b/packages/houdini-react/src/plugin/index.ts @@ -1,4 +1,6 @@ -import { plugin } from 'houdini' +import { ArtifactKind, plugin, fragmentKey } from 'houdini' +import type { ArtifactKinds, Document, Config } from 'houdini' +import path from 'node:path' import { extractDocuments } from './extract' import { transformFile } from './transform' @@ -9,13 +11,192 @@ const HoudiniReactPlugin = plugin('houdini-react', async () => ({ // add the jsx extensions extensions: ['.jsx', '.tsx'], + // include the runtime + includeRuntime: { + esm: '../runtime-esm', + commonjs: '../runtime-cjs', + }, + + // we need to add overloaded definitions for every hook that + // returns the appropriate type for each document + transformRuntime: (docs) => { + // we need to group every document by type + const documents: { [Kind in ArtifactKinds]?: Document[] } = {} + for (const doc of docs) { + if (!doc.generateStore) { + continue + } + if (!documents[doc.kind]) { + documents[doc.kind] = [] + } + documents[doc.kind]!.push(doc) + } + + return { + 'hooks/useQuery.d.ts': ({ config, content }) => + addOverload({ + config, + content, + name: 'useQuery', + documents: documents[ArtifactKind.Query] ?? [], + importIdentifiers: (doc) => [`${doc.name}$result`, `${doc.name}$input`], + signature: (doc) => + `export function useQuery(document: { artifact: { name : "${doc.name}" } }, variables?: ${doc.name}$input, config?: UseQueryConfig): ${doc.name}$result`, + }), + 'hooks/useQueryHandle.d.ts': ({ config, content }) => + addOverload({ + config, + content, + name: 'useQueryHandle', + documents: documents[ArtifactKind.Query] ?? [], + preamble: 'import { DocumentHandle } from "./useDocumentHandle"', + importIdentifiers: (doc) => [ + `${doc.name}$result`, + `${doc.name}$artifact`, + `${doc.name}$input`, + ], + signature: (doc) => + `export function useQueryHandle(document: { artifact: { name : "${doc.name}" } }, variables?: ${doc.name}$input, config?: UseQueryConfig): DocumentHandle<${doc.name}$artifact, ${doc.name}$result, ${doc.name}$input>`, + }), + 'hooks/useFragment.d.ts': ({ config, content }) => + addOverload({ + config, + content, + name: 'useFragment', + documents: documents[ArtifactKind.Fragment] ?? [], + importIdentifiers: (doc) => [`${doc.name}$data`], + signature: (doc) => + `export function useFragment(reference: { readonly "${fragmentKey}": { ${doc.name}: any } }, document: { artifact: { name : "${doc.name}" } }): ${doc.name}$data +export function useFragment(reference: { readonly "${fragmentKey}": { ${doc.name}: any } } | null, document: { artifact: { name : "${doc.name}" } }): ${doc.name}$data | null`, + }), + 'hooks/useFragmentHandle.d.ts': ({ config, content }) => + addOverload({ + config, + content, + name: 'useFragmentHandle', + documents: documents[ArtifactKind.Fragment] ?? [], + preamble: 'import { DocumentHandle } from "./useDocumentHandle"', + importIdentifiers: (doc) => [ + `${doc.name}$data`, + `${doc.name}$artifact`, + `${doc.name}$input`, + ], + signature: (doc) => + `export function useFragmentHandle(reference: { readonly "${fragmentKey}": { ${doc.name}: any } }, document: { artifact: { name : "${doc.name}" } }): DocumentHandle<${doc.name}$artifact, ${doc.name}$result, ${doc.name}$input> +export function useFragmentHandle(reference: { readonly "${fragmentKey}": { ${doc.name}: any } } | null, document: { artifact: { name : "${doc.name}" } }): DocumentHandle<${doc.name}$artifact, ${doc.name}$result | null, ${doc.name}$input>`, + }), + 'hooks/useMutation.d.ts': ({ config, content }) => + addOverload({ + config, + content, + name: 'useMutation', + documents: documents[ArtifactKind.Mutation] ?? [], + importIdentifiers: (doc) => [ + `${doc.name}$result`, + `${doc.name}$input`, + `${doc.name}$optimistic`, + ], + signature: (doc) => + `export function useMutation(document: { artifact: { name : "${doc.name}" } }): [boolean, MutationHandler<${doc.name}$result, ${doc.name}$input, ${doc.name}$optimistic>]`, + }), + 'hooks/useSubscription.d.ts': ({ config, content }) => + addOverload({ + config, + content, + name: 'useSubscription', + documents: documents[ArtifactKind.Subscription] ?? [], + importIdentifiers: (doc) => [`${doc.name}$result`, `${doc.name}$input`], + signature: (doc) => + `export function useSubscription(document: { artifact: { name : "${doc.name}" } }, variables?: ${doc.name}$input): ${doc.name}$result`, + }), + 'hooks/useSubscriptionHandle.d.ts': ({ config, content }) => + addOverload({ + config, + content, + name: 'useSubscriptionHandle', + documents: documents[ArtifactKind.Subscription] ?? [], + importIdentifiers: (doc) => [`${doc.name}$result`, `${doc.name}$input`], + signature: (doc) => + `export function useSubscriptionHandle(document: { artifact: { name : "${doc.name}" } }, variables?: ${doc.name}$input): SubscriptionHandle<${doc.name}$result, ${doc.name}$input>`, + }), + } + }, + + // transform the type definitions to have overloaded signatures for + // every document in the project + // we need to teach the codegen how to get graphql documents from jsx files extractDocuments, // convert the graphql template tags into references to their artifact transformFile, + + graphqlTagReturn({ config, document: doc, ensureImport: ensure_import }) { + // if we're supposed to generate a store then add an overloaded declaration + if (doc.generateStore) { + const variableName = `${doc.name}$artifact` + + ensure_import({ + identifier: variableName, + module: config.artifactImportPath(doc.name).replaceAll('$houdini', '..'), + }) + + // and use the store as the return value + return `{ artifact: ${variableName} }` + } + }, })) +function addOverload({ + config, + content, + name, + documents, + signature, + preamble, + importIdentifiers, +}: { + config: Config + content: string + name: string + documents: Document[] + signature: (doc: Document) => string + preamble?: string + importIdentifiers: (doc: Document) => string[] +}): string { + // find the index of the function's definition + let definitionIndex = content.indexOf(`export declare function ${name}`) + if (definitionIndex === -1) { + return content + } + + // lets start off by importing all of the necessary types for each artifact + const docImports = documents + .filter((doc) => doc.generateStore) + .map( + (doc) => ` +import type { ${importIdentifiers(doc).join(', ')} } from '${path.relative( + path.relative( + config.projectRoot, + path.join(config.pluginRuntimeDirectory('houdini-react'), 'hooks') + ), + config.artifactImportPath(doc.name) + )}' + ` + ) + .join('\n') + + return `${docImports} +${preamble ?? ''} +${ + content.slice(0, definitionIndex) + + documents.map(signature).join('\n') + + '\n' + + content.slice(definitionIndex) +} +` +} + export default HoudiniReactPlugin export type HoudiniReactPluginConfig = {} diff --git a/packages/houdini-react/src/plugin/transform.ts b/packages/houdini-react/src/plugin/transform.ts index c60738e76..be0d97726 100644 --- a/packages/houdini-react/src/plugin/transform.ts +++ b/packages/houdini-react/src/plugin/transform.ts @@ -1,133 +1,68 @@ -import { parse } from '@babel/parser' -import * as graphql from 'graphql' +import { ArtifactKind, ensureArtifactImport, find_graphql, parseJS } from 'houdini' import type { TransformPage } from 'houdini/vite' import * as recast from 'recast' +import type { SourceMapInput } from 'rollup' -export function transformFile(page: TransformPage): { code: string } { +const AST = recast.types.builders + +export async function transformFile( + page: TransformPage +): Promise<{ code: string; map?: SourceMapInput }> { + // only consider jsx or tsx files for now + if (!page.filepath.endsWith('.tsx') && !page.filepath.endsWith('.jsx')) { + return { code: page.content, map: page.map } + } // parse the content and look for an invocation of the graphql function - const parsed = parse(page.content, { + const parsed = await parseJS(page.content, { plugins: ['typescript', 'jsx'], sourceType: 'module', - }).program - - recast.visit(parsed, { - visitCallExpression(node) { - const { value } = node - // we only care about invocations of the graphql function - if ( - !value.callee.name || - (value.callee.type === 'Identifier' && value.callee.name !== 'query') - ) { - return this.traverse(node) - } - - // the argument passed to the graphql function should be a string - // with the document body - if (value.arguments.length !== 1) { - return this.traverse(node) - } - const argument = value.arguments[0] - - // extract the query from template literals as well as strings - let query = '' - if (argument.type === 'TemplateLiteral' && argument.quasis.length === 1) { - query = argument.quasis[0].value.raw - } else if (argument.type === 'StringLiteral') { - query = argument.value - } + }) + if (!parsed) { + return { code: page.content, map: page.map } + } - // we want to replace the template tag with an import to the appropriate - // artifact - let name = page.config.documentName(graphql.parse(query)) - let artifact_name = ensureArtifactImport({ + // for now, just replace them with a string + await find_graphql(page.config, parsed?.script, { + tag({ node, artifact, parsedDocument }) { + const artifactID = ensureArtifactImport({ config: page.config, - artifact: { name }, - body: parsed.body, + artifact, + body: parsed.script.body, }) - node.replace( - AST.callExpression(AST.identifier('query'), [AST.identifier(artifact_name)]) - ) + // we are going to replace the query with an object + const properties = [ + AST.objectProperty(AST.stringLiteral('artifact'), AST.identifier(artifactID)), + ] - return false - }, - }) + // if the query is paginated or refetchable then we need to add a reference to the refetch artifact + if (page.config.needsRefetchArtifact(parsedDocument)) { + // if the document is a query then we should use it as the refetch artifact + let refetchArtifactName = artifactID + if (artifact.kind !== ArtifactKind.Query) { + refetchArtifactName = page.config.paginationQueryName(artifact.name) - return recast.print(parsed) -} - -const AST = recast.types.builders - -type Statement = recast.types.namedTypes.Statement -type ImportDeclaration = recast.types.namedTypes.ImportDeclaration - -export function ensureArtifactImport({ - config, - artifact, - body, - local, - withExtension, -}: { - config: any - artifact: { name: string } - body: Statement[] - local?: string - withExtension?: boolean -}) { - return ensureImports({ - body, - sourceModule: config.artifactImportPath(artifact.name) + (withExtension ? '.js' : ''), - import: local || `_${artifact.name}Artifact`, - }) -} - -export function ensureImports<_Count extends string[] | string>({ - body, - import: importID, - sourceModule, - importKind, -}: { - body: Statement[] - import: _Count - sourceModule: string - importKind?: 'value' | 'type' -}): _Count { - const idList = Array.isArray(importID) ? importID : [importID] + ensureArtifactImport({ + config: page.config, + artifact: { + name: refetchArtifactName, + }, + body: parsed.script.body, + }) + } - // figure out the list of things to import - const toImport = idList.filter( - (identifier) => - !body.find( - (statement) => - statement.type === 'ImportDeclaration' && - (statement as ImportDeclaration).specifiers!.find( - (importSpecifier) => - (importSpecifier.type === 'ImportSpecifier' && - importSpecifier.imported.type === 'Identifier' && - importSpecifier.imported.name === identifier && - importSpecifier.local!.name === identifier) || - (importSpecifier.type === 'ImportDefaultSpecifier' && - importSpecifier.local!.type === 'Identifier' && - importSpecifier.local!.name === identifier) + properties.push( + AST.objectProperty( + AST.stringLiteral('refetchArtifact'), + AST.identifier(refetchArtifactName) ) - ) - ) + ) + } - // add the import if it doesn't exist, add it - if (toImport.length > 0) { - body.unshift({ - type: 'ImportDeclaration', - // @ts-ignore - source: AST.stringLiteral(sourceModule), - // @ts-ignore - specifiers: toImport.map((identifier) => - !Array.isArray(importID) - ? AST.importDefaultSpecifier(AST.identifier(identifier)) - : AST.importSpecifier(AST.identifier(identifier), AST.identifier(identifier)) - ), - importKind, - }) - } + // replace the graphql function with the object + node.replaceWith(AST.objectExpression(properties)) + }, + }) - return Array.isArray(importID) ? toImport : toImport[0] + return recast.print(parsed.script) } diff --git a/packages/houdini-react/src/runtime/context.tsx b/packages/houdini-react/src/runtime/context.tsx new file mode 100644 index 000000000..e7ec1baa7 --- /dev/null +++ b/packages/houdini-react/src/runtime/context.tsx @@ -0,0 +1,12 @@ +import { HoudiniClient } from '$houdini/runtime' +import * as React from 'react' + +export const HoudiniContext = React.createContext(null) + +export const HoudiniProvider = ({ + client, + children, +}: { + client: HoudiniClient + children: React.ReactNode +}) => {children} diff --git a/packages/houdini-react/src/runtime/hooks/index.ts b/packages/houdini-react/src/runtime/hooks/index.ts new file mode 100644 index 000000000..45ddd3434 --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/index.ts @@ -0,0 +1,9 @@ +export { useQuery } from './useQuery' +export { useQueryHandle } from './useQueryHandle' +export { useFragment } from './useFragment' +export { useFragmentHandle } from './useFragmentHandle' +export { useMutation } from './useMutation' +export { useSubscription } from './useSubscription' + +export { type DocumentHandle } from './useDocumentHandle' +export { type UseQueryConfig } from './useQueryHandle' diff --git a/packages/houdini-react/src/runtime/hooks/useDeepCompareEffect.ts b/packages/houdini-react/src/runtime/hooks/useDeepCompareEffect.ts new file mode 100644 index 000000000..bbc2ebe44 --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useDeepCompareEffect.ts @@ -0,0 +1,89 @@ +import { deepEquals } from '$houdini/runtime/lib/deepEquals' +import * as React from 'react' + +// This file is largely a copy and paste from Kent C. Dodd's use-deep-compare-effect (appropriate license at the bottom). +// It has been copied locally in order to avoid any awkward third party peer dependencies +// on generated files (which would make the install annoying). The deep equals library has +// also been changed to use one that was already included in the runtime (avoiding the extra bundle size) + +type UseEffectParams = Parameters +type EffectCallback = UseEffectParams[0] +type DependencyList = UseEffectParams[1] +// yes, I know it's void, but I like what this communicates about +// the intent of these functions: It's just like useEffect +type UseEffectReturn = ReturnType + +function checkDeps(deps: DependencyList) { + if (!deps || !deps.length) { + throw new Error( + 'useDeepCompareEffect should not be used with no dependencies. Use React.useEffect instead.' + ) + } + if (deps.every(isPrimitive)) { + throw new Error( + 'useDeepCompareEffect should not be used with dependencies that are all primitive values. Use React.useEffect instead.' + ) + } +} + +function isPrimitive(val: unknown) { + return val == null || /^[sbn]/.test(typeof val) +} + +/** + * @param value the value to be memoized (usually a dependency list) + * @returns a memoized version of the value as long as it remains deeply equal + */ +export function useDeepCompareMemoize(value: T) { + const ref = React.useRef(value) + const signalRef = React.useRef(0) + + if (!deepEquals(value, ref.current)) { + ref.current = value + signalRef.current += 1 + } + + return React.useMemo(() => ref.current, [signalRef.current]) +} + +function useDeepCompareEffect( + callback: EffectCallback, + dependencies: DependencyList +): UseEffectReturn { + if (process.env.NODE_ENV !== 'production') { + checkDeps(dependencies) + } + return React.useEffect(callback, useDeepCompareMemoize(dependencies)) +} + +export function useDeepCompareEffectNoCheck( + callback: EffectCallback, + dependencies: DependencyList +): UseEffectReturn { + return React.useEffect(callback, useDeepCompareMemoize(dependencies)) +} + +export default useDeepCompareEffect + +/** +The MIT License (MIT) +Copyright (c) 2020 Kent C. Dodds + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ diff --git a/packages/houdini-react/src/runtime/hooks/useDocumentHandle.ts b/packages/houdini-react/src/runtime/hooks/useDocumentHandle.ts new file mode 100644 index 000000000..fe7ef1b23 --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useDocumentHandle.ts @@ -0,0 +1,162 @@ +import type { DocumentStore } from '$houdini/runtime/client' +import { extractPageInfo } from '$houdini/runtime/lib/pageInfo' +import { cursorHandlers, offsetHandlers } from '$houdini/runtime/lib/pagination' +import { ArtifactKind } from '$houdini/runtime/lib/types' +import type { + GraphQLObject, + CursorHandlers, + OffsetHandlers, + PageInfo, + FetchFn, + QueryResult, + DocumentArtifact, + QueryArtifact, +} from '$houdini/runtime/lib/types' +import React from 'react' + +export function useDocumentHandle< + _Artifact extends QueryArtifact, + _Data extends GraphQLObject, + _Input extends Record +>({ + artifact, + observer, + storeValue, +}: { + artifact: DocumentArtifact + observer: DocumentStore<_Data, _Input> + storeValue: QueryResult<_Data, _Input> +}): DocumentHandle<_Artifact, _Data, _Input> & { fetch: FetchFn<_Data, _Input> } { + const [forwardPending, setForwardPending] = React.useState(false) + const [backwardPending, setBackwardPending] = React.useState(false) + + // @ts-expect-error: avoiding an as DocumentHandle<_Artifact, _Data, _Input> + return React.useMemo>(() => { + const wrapLoad = <_Result>( + setLoading: (val: boolean) => void, + fn: (value: any) => Promise<_Result> + ) => { + return async (value: any) => { + setLoading(true) + const result = await fn(value) + setLoading(false) + return result + } + } + + const fetchQuery: FetchFn<_Data, _Input> = (args) => observer.send(args) + + // only consider paginated queries + if (artifact.kind !== ArtifactKind.Query || !artifact.refetch?.paginated) { + return { + data: storeValue.data, + fetch: fetchQuery, + partial: storeValue.partial, + } + } + + // TODO: session + const getSession = async () => ({} as App.Session) + + // if the artifact supports cursor pagination, then add the cursor handlers + if (artifact.refetch.method === 'cursor') { + const handlers = cursorHandlers<_Data, _Input>({ + artifact, + getState: () => storeValue.data, + getVariables: () => storeValue.variables!, + fetch: fetchQuery, + fetchUpdate: (args, updates) => { + return observer.send({ + ...args, + cacheParams: { + disableSubscriptions: true, + applyUpdates: updates, + ...args?.cacheParams, + }, + }) + }, + getSession, + }) + + return { + data: storeValue.data, + fetch: handlers.fetch, + partial: storeValue.partial, + loadNext: wrapLoad(setForwardPending, handlers.loadNextPage), + loadNextPending: forwardPending, + loadPrevious: wrapLoad(setBackwardPending, handlers.loadPreviousPage), + loadPreviousPending: backwardPending, + pageInfo: extractPageInfo(storeValue.data, artifact.refetch!.path), + } + } + + if (artifact.refetch.method === 'offset') { + const handlers = offsetHandlers({ + artifact, + getState: () => storeValue.data, + getVariables: () => storeValue.variables!, + storeName: artifact.name, + fetch: fetchQuery, + fetchUpdate: async (args, updates = ['append']) => { + return observer.send({ + ...args, + cacheParams: { + disableSubscriptions: true, + applyUpdates: updates, + ...args?.cacheParams, + }, + }) + }, + + // TODO: session + getSession: async () => ({} as App.Session), + }) + + return { + data: storeValue.data, + fetch: handlers.fetch, + partial: storeValue.partial, + loadNext: wrapLoad(setForwardPending, handlers.loadNextPage), + loadNextPending: forwardPending, + } + } + + // we don't want to add anything + return { + data: storeValue.data, + fetch: fetchQuery, + refetch: fetchQuery, + partial: storeValue.partial, + } + }, [artifact, observer, storeValue, true, true]) +} + +export type DocumentHandle< + _Artifact extends QueryArtifact, + _Data extends GraphQLObject = GraphQLObject, + _Input extends {} = [] +> = { + data: _Data + partial: boolean +} & RefetchHandlers<_Artifact, _Data, _Input> + +type RefetchHandlers<_Artifact extends QueryArtifact, _Data extends GraphQLObject, _Input> = + // we need to add different methods if the artifact supports cursor pagination + _Artifact extends { + refetch: { paginated: true; method: 'cursor' } + } + ? { + loadNext: CursorHandlers<_Data, _Input>['loadNextPage'] + loadNextPending: boolean + loadPrevious: CursorHandlers<_Data, _Input>['loadPreviousPage'] + loadPreviousPending: boolean + pageInfo: PageInfo + } + : // offset pagination + _Artifact extends { refetch: { paginated: true; method: 'offset' } } + ? { + loadNext: OffsetHandlers<_Data, _Input>['loadNextPage'] + loadNextPending: boolean + } + : // the artifact does not support a known pagination method, don't add anything + {} diff --git a/packages/houdini-react/src/runtime/hooks/useDocumentStore.ts b/packages/houdini-react/src/runtime/hooks/useDocumentStore.ts new file mode 100644 index 000000000..c151a030c --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useDocumentStore.ts @@ -0,0 +1,57 @@ +import type { DocumentArtifact, QueryResult } from '$houdini/lib/types' +import type { DocumentStore, ObserveParams } from '$houdini/runtime/client' +import type { GraphQLObject } from 'houdini' +import * as React from 'react' + +import { useHoudiniClient } from './useHoudiniClient' +import { useIsMountedRef } from './useIsMounted' + +export type UseDocumentStoreParams< + _Artifact extends DocumentArtifact, + _Data extends GraphQLObject +> = { + artifact: _Artifact +} & Partial> + +export function useDocumentStore< + _Data extends GraphQLObject = GraphQLObject, + _Input extends {} = {}, + _Artifact extends DocumentArtifact = DocumentArtifact +>({ + artifact, + ...observeParams +}: UseDocumentStoreParams<_Artifact, _Data>): [ + QueryResult<_Data, _Input>, + DocumentStore<_Data, _Input>, + (store: DocumentStore<_Data, _Input>) => void +] { + const client = useHoudiniClient() + const isMountedRef = useIsMountedRef() + + // hold onto an observer we'll use + let [observer, setObserver] = React.useState(() => + client.observe<_Data, _Input>({ + artifact, + ...observeParams, + }) + ) + + const box = React.useRef(observer.state) + + const subscribe: any = React.useCallback( + (fn: () => void) => { + return observer.subscribe((val) => { + box.current = val + if (isMountedRef.current) { + fn() + } + }) + }, + [observer] + ) + + // get a safe reference to the cache + const storeValue = React.useSyncExternalStore(subscribe, () => box.current) + + return [storeValue!, observer, setObserver] +} diff --git a/packages/houdini-react/src/runtime/hooks/useDocumentSubscription.ts b/packages/houdini-react/src/runtime/hooks/useDocumentSubscription.ts new file mode 100644 index 000000000..fa30c6f3f --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useDocumentSubscription.ts @@ -0,0 +1,52 @@ +import type { DocumentArtifact, QueryResult } from '$houdini/lib/types' +import type { DocumentStore, SendParams } from '$houdini/runtime/client' +import type { GraphQLObject } from 'houdini' + +import useDeepCompareEffect from './useDeepCompareEffect' +import { useDocumentStore, type UseDocumentStoreParams } from './useDocumentStore' + +export function useDocumentSubscription< + _Artifact extends DocumentArtifact = DocumentArtifact, + _Data extends GraphQLObject = GraphQLObject, + _Input extends {} = {} +>({ + artifact, + variables, + send, + ...observeParams +}: UseDocumentStoreParams<_Artifact, _Data> & { + variables: _Input + send?: Partial +}): [ + QueryResult<_Data, _Input> & { parent?: string | null }, + DocumentStore<_Data, _Input>, + (store: DocumentStore<_Data, _Input>) => void +] { + const [storeValue, observer, setObserver] = useDocumentStore<_Data, _Input>({ + artifact, + ...observeParams, + }) + + // whenever the variables change, we need to retrigger the query + useDeepCompareEffect(() => { + observer.send({ + variables, + // TODO: session/metadata + session: {}, + metadata: {}, + ...send, + }) + return () => { + observer.cleanup() + } + }, [observer, variables ?? {}, send ?? {}]) + + return [ + { + parent: send?.stuff?.parentID, + ...storeValue, + }, + observer, + setObserver, + ] +} diff --git a/packages/houdini-react/src/runtime/hooks/useFragment.ts b/packages/houdini-react/src/runtime/hooks/useFragment.ts new file mode 100644 index 000000000..244cb59c9 --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useFragment.ts @@ -0,0 +1,100 @@ +import cache from '$houdini/runtime/cache' +import { deepEquals } from '$houdini/runtime/lib/deepEquals' +import { fragmentKey } from '$houdini/runtime/lib/types' +import type { GraphQLObject, FragmentArtifact } from '$houdini/runtime/lib/types' +import * as React from 'react' + +import { useDeepCompareMemoize } from './useDeepCompareEffect' +import { useDocumentSubscription } from './useDocumentSubscription' + +export function useFragment< + _Data extends GraphQLObject, + _ReferenceType extends {}, + _Input extends {} = {} +>( + reference: _Data | { [fragmentKey]: _ReferenceType } | null, + document: { artifact: FragmentArtifact } +) { + // get the fragment reference info + const { parent, variables } = fragmentReference<_Data, _Input, _ReferenceType>( + reference, + document + ) + + // if we got this far then we are safe to use the fields on the object + let cachedValue = reference as _Data | null + + // on the client, we want to ensure that we apply masking to the initial value by + // loading the value from cache + if (reference && parent) { + cachedValue = cache.read({ + selection: document.artifact.selection, + parent, + variables, + }).data as _Data + } + + const observeParams = { + artifact: document.artifact, + variables, + initialValue: cachedValue, + send: { + stuff: { + parentID: parent, + }, + // setup = true? + // we don't need to do the first read because we + // have an initial value... + // does Boolean(initialValue) === { setup: true } + }, + } + + // we're ready to setup the live document + const [storeValue] = useDocumentSubscription(observeParams) + + // the parent has changed, we need to use initialValue for this render + // if we don't, then there is a very brief flash where we will show the old data + // before the store has had a chance to update + const lastReference = React.useRef<{ parent: string; variables: _Input } | null>(null) + return React.useMemo(() => { + // if the parent reference has changed we need to always prefer the cached value + const parentChange = + storeValue.parent !== parent || + !deepEquals({ parent, variables }, lastReference.current) + if (parentChange) { + // make sure we keep track of the last reference we used + lastReference.current = { parent, variables: { ...variables } } + + // and use the cached value + return cachedValue + } + + return storeValue.data + }, [ + useDeepCompareMemoize({ + parent, + variables, + cachedValue, + storeValue: storeValue.data, + storeParent: storeValue.parent, + }), + ]) +} + +export function fragmentReference<_Data extends GraphQLObject, _Input, _ReferenceType extends {}>( + reference: _Data | { [fragmentKey]: _ReferenceType } | null, + document: { artifact: FragmentArtifact } +): { variables: _Input; parent: string } { + // @ts-expect-error: typescript can't guarantee that the fragment key is defined + // but if its not, then the fragment wasn't mixed into the right thing + // the variables for the fragment live on the initial value's $fragment key + const { variables, parent } = reference?.[fragmentKey]?.[document.artifact.name] ?? {} + if (reference && fragmentKey in reference && (!variables || !parent)) { + console.warn( + `⚠️ Parent does not contain the information for this fragment. Something is wrong. +Please ensure that you have passed a record that has ${document.artifact.name} mixed into it.` + ) + } + + return { variables, parent } +} diff --git a/packages/houdini-react/src/runtime/hooks/useFragmentHandle.ts b/packages/houdini-react/src/runtime/hooks/useFragmentHandle.ts new file mode 100644 index 000000000..ea9a269fb --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useFragmentHandle.ts @@ -0,0 +1,46 @@ +import type { + GraphQLObject, + FragmentArtifact, + QueryArtifact, + fragmentKey, +} from '$houdini/runtime/lib/types' + +import { useDocumentHandle, type DocumentHandle } from './useDocumentHandle' +import { useDocumentStore } from './useDocumentStore' +import { fragmentReference, useFragment } from './useFragment' + +// useFragmentHandle is just like useFragment except it also returns an imperative handle +// that users can use to interact with the fragment +export function useFragmentHandle< + _Artifact extends FragmentArtifact, + _Data extends GraphQLObject, + _ReferenceType extends {}, + _PaginationArtifact extends QueryArtifact, + _Input extends {} = {} +>( + reference: _Data | { [fragmentKey]: _ReferenceType } | null, + document: { artifact: FragmentArtifact; refetchArtifact?: QueryArtifact } +): DocumentHandle<_PaginationArtifact, _Data, _Input> { + // get the fragment values + const data = useFragment<_Data, _ReferenceType, _Input>(reference, document) + + // look at the fragment reference to get the variables + const { variables } = fragmentReference<_Data, _Input, _ReferenceType>(reference, document) + + // use the pagination fragment for meta data if it exists. + // if we pass this a fragment artifact, it won't add any data + const [handleValue, handleObserver] = useDocumentStore<_Data, _Input>({ + artifact: document.refetchArtifact ?? document.artifact, + }) + const handle = useDocumentHandle<_PaginationArtifact, _Data, _Input>({ + observer: handleObserver, + storeValue: handleValue, + artifact: document.refetchArtifact ?? document.artifact, + }) + + return { + ...handle, + variables, + data, + } +} diff --git a/packages/houdini-react/src/runtime/hooks/useHoudiniClient.ts b/packages/houdini-react/src/runtime/hooks/useHoudiniClient.ts new file mode 100644 index 000000000..98bde76a7 --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useHoudiniClient.ts @@ -0,0 +1,13 @@ +import type { HoudiniClient } from '$houdini/runtime/client' +import * as React from 'react' + +import { HoudiniContext } from '../context' + +export function useHoudiniClient(): HoudiniClient { + const client = React.useContext(HoudiniContext) + if (!client) { + throw new Error('Could not find client') + } + + return client +} diff --git a/packages/houdini-react/src/runtime/hooks/useIsMounted.ts b/packages/houdini-react/src/runtime/hooks/useIsMounted.ts new file mode 100644 index 000000000..e97f373e5 --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useIsMounted.ts @@ -0,0 +1,14 @@ +import { useRef, useEffect } from 'react' + +export function useIsMountedRef(): { current: boolean } { + const isMountedRef = useRef(true) + + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) + + return isMountedRef +} diff --git a/packages/houdini-react/src/runtime/hooks/useMutation.ts b/packages/houdini-react/src/runtime/hooks/useMutation.ts new file mode 100644 index 000000000..bea6b3429 --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useMutation.ts @@ -0,0 +1,46 @@ +import type { MutationArtifact, GraphQLObject, QueryResult } from '$houdini/runtime/lib/types' + +import { useDocumentStore } from './useDocumentStore' + +export type MutationHandler<_Result, _Input, _Optimistic extends GraphQLObject> = (args: { + variables: _Input + + metadata?: App.Metadata + fetch?: typeof globalThis.fetch + optimisticResponse?: _Optimistic +}) => Promise> + +export function useMutation< + _Result extends GraphQLObject, + _Input extends {}, + _Optimistic extends GraphQLObject +>({ + artifact, +}: { + artifact: MutationArtifact +}): [boolean, MutationHandler<_Result, _Input, _Optimistic>] { + // build the live document we'll use to send values + const [storeValue, observer] = useDocumentStore<_Result, _Input>({ artifact }) + + // grab the pending state from the document store + const pending = storeValue.fetching + + // sending the mutation just means invoking the observer's send method + const mutate: MutationHandler<_Result, _Input, _Optimistic> = ({ + metadata, + fetch, + variables, + ...mutationConfig + }) => + observer.send({ + variables, + metadata, + // TODO: session/metadata + session: {}, + stuff: { + ...mutationConfig, + }, + }) + + return [pending, mutate] +} diff --git a/packages/houdini-react/src/runtime/hooks/useQuery.ts b/packages/houdini-react/src/runtime/hooks/useQuery.ts new file mode 100644 index 000000000..6e9641f50 --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useQuery.ts @@ -0,0 +1,17 @@ +import type { GraphQLObject, QueryArtifact } from '$houdini/runtime/lib/types' + +import type { UseQueryConfig } from './useQueryHandle' +import { useQueryHandle } from './useQueryHandle' + +export function useQuery< + _Artifact extends QueryArtifact, + _Data extends GraphQLObject = GraphQLObject, + _Input extends {} = [] +>( + document: { artifact: QueryArtifact }, + variables: any = null, + config: UseQueryConfig = {} +): _Data { + const { data } = useQueryHandle<_Artifact, _Data, _Input>(document, variables, config) + return data +} diff --git a/packages/houdini-react/src/runtime/hooks/useQueryHandle.ts b/packages/houdini-react/src/runtime/hooks/useQueryHandle.ts new file mode 100644 index 000000000..91be3432d --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useQueryHandle.ts @@ -0,0 +1,183 @@ +import type { GraphQLObject, CachePolicies, QueryArtifact } from '$houdini/runtime/lib/types' +import React from 'react' + +import { createCache } from '../lib/cache' +import type { DocumentHandle } from './useDocumentHandle' +import { useDocumentHandle } from './useDocumentHandle' +import { useHoudiniClient } from './useHoudiniClient' +import { useIsMountedRef } from './useIsMounted' + +// Suspense requires a way to throw a promise that resolves to a place +// we can put when we go back on a susequent render. This means that we have to have +// a stable way to identify _this_ useQueryHandle called. +// For now, we're going to compute an identifier based on the name of the artifact +// and the variables that we were given. +// +// - If we have a cached promise that's pending, we should just throw that promise +// - If we have a cached promise that's been resolved, we should return that value +// +// When the Component unmounts, we need to remove the entry from the cache (so we can load again) + +const promiseCache = createCache() +type QuerySuspenseUnit = { + resolve: () => void + resolved?: DocumentHandle + then: (val: any) => any +} + +export function useQueryHandle< + _Artifact extends QueryArtifact, + _Data extends GraphQLObject = GraphQLObject, + _Input extends {} = [] +>( + { artifact }: { artifact: QueryArtifact }, + variables: any = null, + config: UseQueryConfig = {} +): DocumentHandle<_Artifact, _Data, _Input> { + // figure out the identifier so we know what to look for + const identifier = queryIdentifier({ artifact, variables, config }) + + // see if we have an entry in the cache for the identifier + const suspenseValue = promiseCache.get(identifier) + + const client = useHoudiniClient() + + const isMountedRef = useIsMountedRef() + + // hold onto an observer we'll use + let [observer] = React.useState( + client.observe<_Data, _Input>({ + artifact, + initialValue: (suspenseValue?.resolved?.data ?? {}) as _Data, + }) + ) + + // a ref flag we'll enable before throwing so that we don't update while suspend + const suspenseTracker = React.useRef(false) + + // a stable box to put the store's value + const box = React.useRef(observer.state) + + // a stable subscribe function for the document store + const subscribe: any = React.useCallback( + (fn: () => void) => { + return observer.subscribe((val) => { + box.current = val + if (isMountedRef.current && !suspenseTracker.current) { + fn() + } + }) + }, + [observer] + ) + + // get a safe reference to the cache + const storeValue = React.useSyncExternalStore(subscribe, () => box.current) + + // compute the imperative handle for this artifact + const handle = useDocumentHandle<_Artifact, _Data, _Input>({ + artifact, + observer, + storeValue, + }) + + // if the identifier changes, we need to remove the old identifier from the + // suspense cache + React.useEffect(() => { + return () => { + promiseCache.delete(identifier) + } + }, [identifier]) + + // when we unmount, we need to clean up + React.useEffect(() => { + return () => { + observer.cleanup() + } + }, [observer]) + + // if the promise has resolved, let's use that for our first render + let result = storeValue.data + + if (!suspenseValue) { + // we are going to cache the promise and then throw it + // when it resolves the cached value will be updated + // and it will be picked up in the next render + let resolve: () => void = () => {} + const loadPromise = new Promise((r) => (resolve = r)) + + const suspenseUnit: QuerySuspenseUnit = { + then: loadPromise.then.bind(loadPromise), + resolve, + // @ts-ignore + variables, + } + + // @ts-ignore + promiseCache.set(identifier, suspenseUnit) + + // the suspense unit gives react something to hold onto + // and it acts as a place for us to register a callback on + // send to update the cache before resolving the suspense + handle + .fetch({ + variables, + // @ts-ignore: this is actually allowed... 🤫 + stuff: { + silenceLoading: true, + }, + }) + .then((value) => { + // the final value + suspenseUnit.resolved = { + ...handle, + data: value.data, + partia: value.partial, + artifact, + } + + suspenseUnit.resolve() + }) + suspenseTracker.current = true + throw suspenseUnit + } + + // if the promise is still pending, we're still waiting + if (!result && suspenseValue && !suspenseValue.resolved) { + suspenseTracker.current = true + throw suspenseValue + } + + // make sure we prefer the latest store value instead of the initial version we loaded on mount + if (!result && suspenseValue?.resolved) { + return suspenseValue.resolved as DocumentHandle<_Artifact, _Data, _Input> + } + + return { + ...handle, + variables: storeValue.variables, + data: result, + } +} + +export type UseQueryConfig = { + policy?: CachePolicies + metadata?: App.Metadata + fetchKey?: any +} + +function queryIdentifier(args: { + artifact: QueryArtifact + fetchKey?: number + variables: {} + config: UseQueryConfig +}): string { + // make sure there is always a fetchKey + args.fetchKey ??= 0 + + // pull the common stuff out + const { artifact, variables, fetchKey } = args + + // a query identifier is a mix of its name, arguments, and the fetch key + return [artifact.name, JSON.stringify(variables), fetchKey].join('@@') +} diff --git a/packages/houdini-react/src/runtime/hooks/useSubscription.ts b/packages/houdini-react/src/runtime/hooks/useSubscription.ts new file mode 100644 index 000000000..801876e11 --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useSubscription.ts @@ -0,0 +1,12 @@ +import type { SubscriptionArtifact, GraphQLObject } from '$houdini/runtime/lib/types' + +import { useSubscriptionHandle } from './useSubscriptionHandle' + +// a hook to subscribe to a subscription artifact +export function useSubscription<_Result extends GraphQLObject, _Input extends {}>( + document: { artifact: SubscriptionArtifact }, + variables: _Input +) { + const { data } = useSubscriptionHandle(document, variables) + return data +} diff --git a/packages/houdini-react/src/runtime/hooks/useSubscriptionHandle.ts b/packages/houdini-react/src/runtime/hooks/useSubscriptionHandle.ts new file mode 100644 index 000000000..17b6371a3 --- /dev/null +++ b/packages/houdini-react/src/runtime/hooks/useSubscriptionHandle.ts @@ -0,0 +1,33 @@ +import type { SubscriptionArtifact, GraphQLObject } from '$houdini/runtime/lib/types' + +import { useDocumentSubscription } from './useDocumentSubscription' + +export type SubscriptionHandle<_Result extends GraphQLObject, _Input extends {} | null> = { + data: _Result | null + errors: { message: string }[] | null + variables: _Input + listen: (args: { variables?: _Input }) => void + unlisten: () => void + fetching: boolean +} + +// a hook to subscribe to a subscription artifact +export function useSubscriptionHandle<_Result extends GraphQLObject, _Input extends {}>( + { artifact }: { artifact: SubscriptionArtifact }, + variables: _Input +) { + // a subscription is basically just a live document + const [storeValue, observer] = useDocumentSubscription({ + artifact, + variables, + }) + + return { + data: storeValue.data, + errors: storeValue.errors, + fetching: storeValue.fetching, + variables, + unlisten: observer.cleanup, + listen: observer.send, + } +} diff --git a/packages/houdini-react/src/runtime/index.ts b/packages/houdini-react/src/runtime/index.ts index 5176f122c..800261bee 100644 --- a/packages/houdini-react/src/runtime/index.ts +++ b/packages/houdini-react/src/runtime/index.ts @@ -1,16 +1,2 @@ -import { HoudiniClient } from '$houdini/runtime/client' -import type { QueryArtifact } from 'houdini' - -const client = new HoudiniClient({ - url: 'http://localhost:4000/graphql', -}) - -export async function query(artifact: QueryArtifact, variables?: any) { - const observer = client.observe({ artifact }) - - const result = await observer.send({ - variables, - }) - - return [result] -} +export * from './hooks' +export { HoudiniProvider } from './context' diff --git a/packages/houdini-react/src/runtime/lib/cache.ts b/packages/houdini-react/src/runtime/lib/cache.ts new file mode 100644 index 000000000..5205f9dc2 --- /dev/null +++ b/packages/houdini-react/src/runtime/lib/cache.ts @@ -0,0 +1,103 @@ +/** + * This file is a copy and paste of a very simple and effective LRU cache + * using javascript maps. It was copied under the MIT license found at the + * bottom of the page. + */ +export interface Cache { + get(key: string): T | null + set(key: string, value: T): void + has(key: string): boolean + delete(key: string): void + size(): number + capacity(): number + clear(): void +} + +/** + * JS maps (both plain objects and Map) maintain key insertion + * order, which means there is an easy way to simulate LRU behavior + * that should also perform quite well: + * + * To insert a new value, first delete the key from the inner _map, + * then _map.set(k, v). By deleting and reinserting, you ensure that the + * map sees the key as the last inserted key. + * + * Get does the same: if the key is present, delete and reinsert it. + */ +class LRUCache implements Cache { + _capacity: number + _map: Map + + constructor(capacity: number = 1000) { + this._capacity = capacity + this._map = new Map() + } + + set(key: string, value: T): void { + this._map.delete(key) + this._map.set(key, value) + if (this._map.size > this._capacity) { + const firstKey = this._map.keys().next() + if (!firstKey.done) { + this._map.delete(firstKey.value) + } + } + } + + get(key: string): T | null { + const value = this._map.get(key) + if (value != null) { + this._map.delete(key) + this._map.set(key, value) + } + return value ?? null + } + + has(key: string): boolean { + return this._map.has(key) + } + + delete(key: string): void { + this._map.delete(key) + } + + size(): number { + return this._map.size + } + + capacity(): number { + return this._capacity - this._map.size + } + + clear(): void { + this._map.clear() + } +} + +export function createCache(capacity: number = 1000): LRUCache { + return new LRUCache(capacity) +} + +/** +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ diff --git a/packages/houdini-svelte/src/runtime/stores/index.ts b/packages/houdini-svelte/src/runtime/stores/index.ts index f55a9bb65..25b3c6daa 100644 --- a/packages/houdini-svelte/src/runtime/stores/index.ts +++ b/packages/houdini-svelte/src/runtime/stores/index.ts @@ -2,4 +2,4 @@ export * from './pagination' export { FragmentStore } from './fragment' export { SubscriptionStore } from './subscription' export { MutationStore, type MutationConfig } from './mutation' -export { QueryStore, type QueryStoreFetchParams } from './query' +export { QueryStore } from './query' diff --git a/packages/houdini-svelte/src/runtime/stores/pagination/fetch.ts b/packages/houdini-svelte/src/runtime/stores/pagination/fetch.ts deleted file mode 100644 index ee9ae57ac..000000000 --- a/packages/houdini-svelte/src/runtime/stores/pagination/fetch.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { GraphQLObject, QueryResult } from '$houdini/runtime/lib/types' - -import type { QueryStoreFetchParams } from '../query' - -export type FetchFn<_Data extends GraphQLObject, _Input = any> = ( - params?: QueryStoreFetchParams<_Data, _Input> -) => Promise> diff --git a/packages/houdini-svelte/src/runtime/stores/pagination/fragment.ts b/packages/houdini-svelte/src/runtime/stores/pagination/fragment.ts index 28e527783..73ab77d8c 100644 --- a/packages/houdini-svelte/src/runtime/stores/pagination/fragment.ts +++ b/packages/houdini-svelte/src/runtime/stores/pagination/fragment.ts @@ -1,11 +1,15 @@ import type { DocumentStore } from '$houdini/runtime/client' import { getCurrentConfig, keyFieldsForType } from '$houdini/runtime/lib/config' import { siteURL } from '$houdini/runtime/lib/constants' +import { extractPageInfo } from '$houdini/runtime/lib/pageInfo' +import { cursorHandlers, offsetHandlers } from '$houdini/runtime/lib/pagination' import type { FragmentArtifact, GraphQLObject, HoudiniFetchContext, QueryArtifact, + PageInfo, + CursorHandlers, } from '$houdini/runtime/lib/types' import { CompiledFragmentKind } from '$houdini/runtime/lib/types' import type { Readable, Subscriber } from 'svelte/store' @@ -13,12 +17,9 @@ import { derived, get } from 'svelte/store' import { getClient, initClient } from '../../client' import { getSession } from '../../session' -import type { CursorHandlers, OffsetFragmentStoreInstance } from '../../types' +import type { OffsetFragmentStoreInstance } from '../../types' import { FragmentStore } from '../fragment' import type { StoreConfig } from '../query' -import { cursorHandlers } from './cursor' -import { offsetHandlers } from './offset' -import { extractPageInfo, type PageInfo } from './pageInfo' type FragmentStoreConfig<_Data extends GraphQLObject, _Input> = StoreConfig< _Data, @@ -120,11 +121,8 @@ export class FragmentStoreCursor< return { kind: CompiledFragmentKind, - data: derived(store, ($value) => $value), subscribe: subscribe, - fetching: derived([paginationStore], ([$store]) => $store.fetching), fetch: handlers.fetch, - pageInfo: handlers.pageInfo, // add the pagination handlers loadNextPage: handlers.loadNextPage, @@ -173,8 +171,7 @@ export class FragmentStoreCursor< }, }) }, - initialValue, - storeName: this.name, + getSession, }) } } @@ -231,6 +228,7 @@ export class FragmentStoreOffset< }, }) }, + getSession, storeName: this.name, }) diff --git a/packages/houdini-svelte/src/runtime/stores/pagination/offset.ts b/packages/houdini-svelte/src/runtime/stores/pagination/offset.ts deleted file mode 100644 index bb961b430..000000000 --- a/packages/houdini-svelte/src/runtime/stores/pagination/offset.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { SendParams } from '$houdini/runtime/client/documentStore' -import { CachePolicy } from '$houdini/runtime/lib' -import { deepEquals } from '$houdini/runtime/lib/deepEquals' -import type { GraphQLObject, QueryArtifact, QueryResult } from '$houdini/runtime/lib/types' - -import { getSession } from '../../session' -import type { QueryStoreFetchParams } from '../query' -import { fetchParams } from '../query' -import type { FetchFn } from './fetch' -import { countPage, missingPageSizeError } from './pageInfo' - -export function offsetHandlers<_Data extends GraphQLObject, _Input extends {}>({ - artifact, - storeName, - getState, - getVariables, - fetch: parentFetch, - fetchUpdate: parentFetchUpdate, -}: { - artifact: QueryArtifact - fetch: FetchFn<_Data, _Input> - fetchUpdate: (arg: SendParams) => ReturnType> - storeName: string - getState: () => _Data | null - getVariables: () => _Input -}) { - // we need to track the most recent offset for this handler - let getOffset = () => - (artifact.refetch?.start as number) || - countPage(artifact.refetch!.path, getState()) || - artifact.refetch!.pageSize - - let currentOffset = getOffset() ?? 0 - - return { - loadNextPage: async ({ - limit, - offset, - fetch, - metadata, - }: { - limit?: number - offset?: number - fetch?: typeof globalThis.fetch - metadata?: {} - } = {}) => { - // build up the variables to pass to the query - const queryVariables: Record = { - ...getVariables(), - offset: offset ?? getOffset(), - } - if (limit || limit === 0) { - queryVariables.limit = limit - } - - // if we made it this far without a limit argument and there's no default page size, - // they made a mistake - if (!queryVariables.limit && !artifact.refetch!.pageSize) { - throw missingPageSizeError('loadNextPage') - } - - // Get the Pagination Mode - let isSinglePage = artifact.refetch?.mode === 'SinglePage' - - // send the query - const targetFetch = isSinglePage ? parentFetch : parentFetchUpdate - await targetFetch({ - variables: queryVariables as _Input, - fetch, - metadata, - policy: isSinglePage ? artifact.policy : CachePolicy.NetworkOnly, - session: await getSession(), - }) - - // add the page size to the offset so we load the next page next time - const pageSize = queryVariables.limit || artifact.refetch!.pageSize - currentOffset = offset + pageSize - }, - async fetch( - args?: QueryStoreFetchParams<_Data, _Input> - ): Promise> { - const { params } = await fetchParams(artifact, storeName, args) - - const { variables } = params ?? {} - - // if the input is different than the query variables then we just do everything like normal - if (variables && !deepEquals(getVariables(), variables)) { - return parentFetch.call(this, params) - } - - // we are updating the current set of items, count the number of items that currently exist - // and ask for the full data set - const count = currentOffset || getOffset() - - // build up the variables to pass to the query - const queryVariables: Record = {} - - // if there are more records than the first page, we need fetch to load everything - if (!artifact.refetch!.pageSize || count > artifact.refetch!.pageSize) { - queryVariables.limit = count - } - - // send the query - return await parentFetch.call(this, { - ...params, - variables: queryVariables as _Input, - }) - }, - } -} diff --git a/packages/houdini-svelte/src/runtime/stores/pagination/query.ts b/packages/houdini-svelte/src/runtime/stores/pagination/query.ts index 4b0a7188b..9946b8727 100644 --- a/packages/houdini-svelte/src/runtime/stores/pagination/query.ts +++ b/packages/houdini-svelte/src/runtime/stores/pagination/query.ts @@ -1,20 +1,26 @@ -import type { GraphQLObject, QueryArtifact, QueryResult } from '$houdini/runtime/lib/types' +import { extractPageInfo } from '$houdini/runtime/lib/pageInfo' +import { cursorHandlers, offsetHandlers } from '$houdini/runtime/lib/pagination' +import type { + GraphQLObject, + QueryArtifact, + QueryResult, + CursorHandlers, + OffsetHandlers, + PageInfo, +} from '$houdini/runtime/lib/types' import { get, derived } from 'svelte/store' import type { Subscriber } from 'svelte/store' import { getClient, initClient } from '../../client' -import type { CursorHandlers, OffsetHandlers } from '../../types' +import { getSession } from '../../session' import type { ClientFetchParams, LoadEventFetchParams, QueryStoreFetchParams, RequestEventFetchParams, - StoreConfig, -} from '../query' +} from '../../types' +import type { StoreConfig } from '../query' import { QueryStore } from '../query' -import { cursorHandlers } from './cursor' -import { offsetHandlers } from './offset' -import { extractPageInfo, type PageInfo } from './pageInfo' export type CursorStoreResult<_Data extends GraphQLObject, _Input extends {}> = QueryResult< _Data, @@ -49,11 +55,10 @@ export class QueryStoreCursor<_Data extends GraphQLObject, _Input extends {}> ex this.#_handlers = cursorHandlers<_Data, _Input>({ artifact: this.artifact, - initialValue: get(this.observer).data, getState: () => get(this.observer).data, getVariables: () => get(this.observer).variables!, - storeName: this.name, fetch: super.fetch.bind(this), + getSession: getSession, fetchUpdate: async (args, updates) => { return paginationObserver.send({ ...args, @@ -145,6 +150,7 @@ export class QueryStoreOffset<_Data extends GraphQLObject, _Input extends {}> ex fetch: super.fetch, getState: () => get(this.observer).data, getVariables: () => get(this.observer).variables!, + getSession: getSession, fetchUpdate: async (args) => { await initClient() return paginationObserver.send({ diff --git a/packages/houdini-svelte/src/runtime/stores/query.ts b/packages/houdini-svelte/src/runtime/stores/query.ts index 5e3a2eb98..3d036f223 100644 --- a/packages/houdini-svelte/src/runtime/stores/query.ts +++ b/packages/houdini-svelte/src/runtime/stores/query.ts @@ -2,20 +2,25 @@ import type { FetchContext } from '$houdini/runtime/client/plugins/fetch' import * as log from '$houdini/runtime/lib/log' import type { GraphQLObject, - HoudiniFetchContext, MutationArtifact, QueryArtifact, QueryResult, CachePolicies, } from '$houdini/runtime/lib/types' import { ArtifactKind, CachePolicy, CompiledQueryKind } from '$houdini/runtime/lib/types' -import type { LoadEvent, RequestEvent } from '@sveltejs/kit' +import type { LoadEvent } from '@sveltejs/kit' import { get } from 'svelte/store' import type { PluginArtifactData } from '../../plugin/artifactData' import { clientStarted, isBrowser } from '../adapter' import { initClient } from '../client' import { getSession } from '../session' +import type { + ClientFetchParams, + LoadEventFetchParams, + QueryStoreFetchParams, + RequestEventFetchParams, +} from '../types' import { BaseStore } from './base' export class QueryStore<_Data extends GraphQLObject, _Input extends {}> extends BaseStore< @@ -216,78 +221,3 @@ export async function load(${log.yellow('event')}: LoadEvent) { // in a server-side mutation: await mutation.mutate({ ... }, ${log.yellow('{ event }')}) ` - -type FetchGlobalParams<_Data extends GraphQLObject, _Input> = { - variables?: _Input - - /** - * The policy to use when performing the fetch. If set to CachePolicy.NetworkOnly, - * a request will always be sent, even if the variables are the same as the last call - * to fetch. - */ - policy?: CachePolicies - - /** - * An object that will be passed to the fetch function. - * You can do what you want with it! - */ - // @ts-ignore - metadata?: App.Metadata - - /** - * Set to true if you want the promise to pause while it's resolving. - * Only enable this if you know what you are doing. This will cause route - * transitions to pause while loading data. - */ - blocking?: boolean - - /** - * A function to call after the fetch happens (whether fake or not) - */ - then?: (val: _Data | null) => void | Promise -} - -export type LoadEventFetchParams<_Data extends GraphQLObject, _Input> = FetchGlobalParams< - _Data, - _Input -> & { - /** - * Directly the `even` param coming from the `load` function - */ - event?: LoadEvent -} - -export type RequestEventFetchParams<_Data extends GraphQLObject, _Input> = FetchGlobalParams< - _Data, - _Input -> & { - /** - * A RequestEvent should be provided when the store is being used in an endpoint. - * When this happens, fetch also needs to be provided - */ - event?: RequestEvent - /** - * The fetch function to use when using this store in an endpoint. - */ - fetch?: LoadEvent['fetch'] -} - -export type ClientFetchParams<_Data extends GraphQLObject, _Input> = FetchGlobalParams< - _Data, - _Input -> & { - /** - * An object containing all of the current info necessary for a - * client-side fetch. Must be called in component initialization with - * something like this: `const context = getHoudiniFetchContext()` - */ - context?: HoudiniFetchContext -} - -export type QueryStoreFetchParams<_Data extends GraphQLObject, _Input> = - | QueryStoreLoadParams<_Data, _Input> - | ClientFetchParams<_Data, _Input> - -export type QueryStoreLoadParams<_Data extends GraphQLObject, _Input> = - | LoadEventFetchParams<_Data, _Input> - | RequestEventFetchParams<_Data, _Input> diff --git a/packages/houdini-svelte/src/runtime/types.ts b/packages/houdini-svelte/src/runtime/types.ts index 8d0336bca..77664dbd3 100644 --- a/packages/houdini-svelte/src/runtime/types.ts +++ b/packages/houdini-svelte/src/runtime/types.ts @@ -3,12 +3,14 @@ import type { CompiledFragmentKind, QueryResult, GraphQLObject, + CursorHandlers, + OffsetHandlers, + PageInfo, + HoudiniFetchContext, + FetchParams, } from '$houdini/runtime/lib/types' -import type { LoadEvent } from '@sveltejs/kit' -import type { Readable, Writable } from 'svelte/store' - -import type { QueryStoreFetchParams } from './stores' -import type { PageInfo } from './stores/pagination/pageInfo' +import type { LoadEvent, RequestEvent } from '@sveltejs/kit' +import type { Readable } from 'svelte/store' export type QueryInputs<_Data> = FetchQueryResult<_Data> & { variables: { [key: string]: any } } @@ -69,33 +71,61 @@ export type OffsetFragmentStoreInstance<_Data extends GraphQLObject, _Input> = { fetching: Readable } & OffsetHandlers<_Data, _Input> -export type CursorHandlers<_Data extends GraphQLObject, _Input> = { - loadNextPage: (args?: { - first?: number - after?: string - fetch?: typeof globalThis.fetch - metadata?: {} - }) => Promise - loadPreviousPage: (args?: { - last?: number - before?: string - fetch?: typeof globalThis.fetch - metadata?: {} - }) => Promise - pageInfo: Writable - fetch( - args?: QueryStoreFetchParams<_Data, _Input> | undefined - ): Promise> +type FetchGlobalParams<_Data extends GraphQLObject, _Input> = FetchParams<_Input> & { + /** + * Set to true if you want the promise to pause while it's resolving. + * Only enable this if you know what you are doing. This will cause route + * transitions to pause while loading data. + */ + blocking?: boolean + + /** + * A function to call after the fetch happens (whether fake or not) + */ + then?: (val: _Data | null) => void | Promise +} + +export type LoadEventFetchParams<_Data extends GraphQLObject, _Input> = FetchGlobalParams< + _Data, + _Input +> & { + /** + * Directly the `even` param coming from the `load` function + */ + event?: LoadEvent } -export type OffsetHandlers<_Data extends GraphQLObject, _Input> = { - loadNextPage: (args?: { - limit?: number - offset?: number - metadata?: {} - fetch?: typeof globalThis.fetch - }) => Promise - fetch( - args?: QueryStoreFetchParams<_Data, _Input> | undefined - ): Promise> +export type RequestEventFetchParams<_Data extends GraphQLObject, _Input> = FetchGlobalParams< + _Data, + _Input +> & { + /** + * A RequestEvent should be provided when the store is being used in an endpoint. + * When this happens, fetch also needs to be provided + */ + event?: RequestEvent + /** + * The fetch function to use when using this store in an endpoint. + */ + fetch?: LoadEvent['fetch'] } + +export type ClientFetchParams<_Data extends GraphQLObject, _Input> = FetchGlobalParams< + _Data, + _Input +> & { + /** + * An object containing all of the current info necessary for a + * client-side fetch. Must be called in component initialization with + * something like this: `const context = getHoudiniFetchContext()` + */ + context?: HoudiniFetchContext +} + +export type QueryStoreFetchParams<_Data extends GraphQLObject, _Input> = + | QueryStoreLoadParams<_Data, _Input> + | ClientFetchParams<_Data, _Input> + +export type QueryStoreLoadParams<_Data extends GraphQLObject, _Input> = + | LoadEventFetchParams<_Data, _Input> + | RequestEventFetchParams<_Data, _Input> diff --git a/packages/houdini/src/codegen/generators/indexFile/index.ts b/packages/houdini/src/codegen/generators/indexFile/index.ts index 26d471951..8973d7de6 100644 --- a/packages/houdini/src/codegen/generators/indexFile/index.ts +++ b/packages/houdini/src/codegen/generators/indexFile/index.ts @@ -55,10 +55,6 @@ export default async function writeIndexFile(config: Config, docs: Document[]) { module: relative(config.pluginRuntimeDirectory(plugin.name)), }) } - - if (!plugin.indexFile) { - continue - } } // write the index file that exports the runtime diff --git a/packages/houdini/src/codegen/generators/runtime/index.ts b/packages/houdini/src/codegen/generators/runtime/index.ts index 17a8ee314..e1bcae766 100644 --- a/packages/houdini/src/codegen/generators/runtime/index.ts +++ b/packages/houdini/src/codegen/generators/runtime/index.ts @@ -49,14 +49,18 @@ ${exportStatement('config')} }), ...config.plugins .filter((plugin) => plugin.includeRuntime) - .map((plugin) => generatePluginRuntime(config, plugin)), + .map((plugin) => generatePluginRuntime(config, docs, plugin)), generatePluginIndex({ config, exportStatement: exportStar }), ]) await generateGraphqlReturnTypes(config, docs) } -async function generatePluginRuntime(config: Config, plugin: Config['plugins'][number]) { +async function generatePluginRuntime( + config: Config, + docs: Document[], + plugin: Config['plugins'][number] +) { if (houdini_mode.is_testing || !plugin.includeRuntime) { return } @@ -80,12 +84,16 @@ async function generatePluginRuntime(config: Config, plugin: Config['plugins'][n // copy the runtime const pluginDir = config.pluginRuntimeDirectory(plugin.name) + let transformMap = plugin.transformRuntime ?? {} + if (transformMap && typeof transformMap === 'function') { + transformMap = transformMap(docs) + } await fs.mkdirp(pluginDir) await fs.recursiveCopy( runtime_path, pluginDir, Object.fromEntries( - Object.entries(plugin.transformRuntime ?? {}).map(([key, value]) => [ + Object.entries(transformMap).map(([key, value]) => [ path.join(runtime_path, key), (content) => value({ config, content }), ]) diff --git a/packages/houdini/src/codegen/generators/typescript/documentTypes.ts b/packages/houdini/src/codegen/generators/typescript/documentTypes.ts index c627e721f..5c5f06259 100644 --- a/packages/houdini/src/codegen/generators/typescript/documentTypes.ts +++ b/packages/houdini/src/codegen/generators/typescript/documentTypes.ts @@ -1,5 +1,5 @@ import { logCyan, logGreen } from '@kitql/helper' -import type { StatementKind } from 'ast-types/lib/gen/kinds' +import type { ExpressionKind, StatementKind, TSTypeKind } from 'ast-types/lib/gen/kinds' import type * as graphql from 'graphql' import * as recast from 'recast' @@ -7,6 +7,7 @@ import type { Config, Document } from '../../../lib' import { HoudiniError, siteURL, fs, path } from '../../../lib' import { fragmentArgumentsDefinitions } from '../../transforms/fragmentVariables' import { flattenSelections } from '../../utils' +import { serializeValue } from '../artifacts/utils' import { addReferencedInputTypes } from './addReferencedInputTypes' import { fragmentKey, inlineType } from './inlineType' import { tsTypeReference } from './typeReference' @@ -42,6 +43,7 @@ export async function generateDocumentTypes(config: Config, docs: Document[]) { name, filename, generateArtifact: generateArtifact, + artifact, }) => { if (!generateArtifact) { return @@ -95,6 +97,17 @@ export async function generateDocumentTypes(config: Config, docs: Document[]) { ) } + // add the document's artifact as the file's default export + program.body.push( + // the typescript AST representing a default export in typescript + AST.exportNamedDeclaration( + AST.tsTypeAliasDeclaration( + AST.identifier(`${name}$artifact`), + convertToTs(serializeValue(artifact)) + ) + ) + ) + // write the file contents await fs.writeFile(typeDefPath, recast.print(program).code) @@ -131,18 +144,17 @@ export async function generateDocumentTypes(config: Config, docs: Document[]) { const exportStarFrom = ({ module }: { module: string }) => `\nexport * from "${module}"\n` let indexContent = recast.print(typeIndex).code for (const plugin of config.plugins) { - if (!plugin.indexFile) { - continue + if (plugin.indexFile) { + indexContent = plugin.indexFile({ + config, + content: indexContent, + exportDefaultAs, + exportStarFrom, + pluginRoot: config.pluginDirectory(plugin.name), + typedef: true, + documents: docs, + }) } - indexContent = plugin.indexFile({ - config, - content: indexContent, - exportDefaultAs, - exportStarFrom, - pluginRoot: config.pluginDirectory(plugin.name), - typedef: true, - documents: docs, - }) // if the plugin generated a runtime if (plugin.includeRuntime) { @@ -186,6 +198,54 @@ For more information, please visit this link: ${siteURL}/api/config#custom-scala } } +function convertToTs(source: ExpressionKind): TSTypeKind { + // convert the values of objects + if (source.type === 'ObjectExpression') { + return AST.tsTypeLiteral( + source.properties.reduce( + (props, prop) => { + if ( + prop.type !== 'ObjectProperty' || + (prop.key.type !== 'StringLiteral' && prop.key.type === 'Identifier') + ) { + return props + } + + return [ + ...props, + AST.tsPropertySignature( + prop.key, + AST.tsTypeAnnotation(convertToTs(prop.value as ExpressionKind)) + ), + ] + }, + [] + ) + ) + } + + // convert every element in an array + if (source.type === 'ArrayExpression') { + return AST.tsTupleType( + source.elements.map((element) => convertToTs(element as ExpressionKind)) + ) + } + + // handle literal types + if (source.type === 'Literal' && typeof source.value === 'boolean') { + return AST.tsLiteralType(AST.booleanLiteral(source.value)) + } + if (source.type === 'Literal' && typeof source.value === 'number') { + return AST.tsLiteralType(AST.numericLiteral(source.value)) + } + if (source.type === 'Literal' && typeof source.value === 'string') { + return AST.tsLiteralType(AST.stringLiteral(source.value)) + } + + // @ts-ignore + return AST.tsLiteralType(source) +} + async function generateOperationTypeDefs( config: Config, filepath: string, diff --git a/packages/houdini/src/codegen/generators/typescript/typescript.test.ts b/packages/houdini/src/codegen/generators/typescript/typescript.test.ts index 321aa2884..051f9f9f9 100644 --- a/packages/houdini/src/codegen/generators/typescript/typescript.test.ts +++ b/packages/houdini/src/codegen/generators/typescript/typescript.test.ts @@ -126,6 +126,53 @@ describe('typescript', function () { readonly nickname: string | null; readonly enumValue: ValueOf | null; }; + + export type TestFragment$artifact = { + "name": "TestFragment"; + "kind": "HoudiniFragment"; + "hash": "fec5e49042a021e67a5f04a339f3729793fedbf7df83c2119a6ad932f91727f8"; + "raw": \`fragment TestFragment on User { + firstName + nickname + enumValue + id + __typename + } + \`; + "rootType": "User"; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "nickname": { + "type": "String"; + "keyRaw": "nickname"; + "nullable": true; + "visible": true; + }; + "enumValue": { + "type": "MyEnum"; + "keyRaw": "enumValue"; + "nullable": true; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "pluginData": {}; + }; `) }) @@ -163,6 +210,58 @@ describe('typescript', function () { readonly age: number | null; } | null; }; + + export type TestFragment$artifact = { + "name": "TestFragment"; + "kind": "HoudiniFragment"; + "hash": "03f5a3344390dfa1b642c7038bbdb5f6bfadbb645886b0d1ce658fc77e90668e"; + "raw": \`fragment TestFragment on Query { + user(id: $name) { + age + id + } + __typename + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user(id: $name)"; + "nullable": true; + "selection": { + "fields": { + "age": { + "type": "Int"; + "keyRaw": "age"; + "nullable": true; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "pluginData": {}; + "input": { + "fields": { + "name": "ID"; + }; + "types": {}; + }; + }; `) }) @@ -200,6 +299,58 @@ describe('typescript', function () { readonly age: number | null; } | null; }; + + export type TestFragment$artifact = { + "name": "TestFragment"; + "kind": "HoudiniFragment"; + "hash": "9c72207c02a37626ffd0f6397ab2a573d88486792caad6f52c785555a6a6343b"; + "raw": \`fragment TestFragment on Query { + user(id: $name) { + age + id + } + __typename + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user(id: $name)"; + "nullable": true; + "selection": { + "fields": { + "age": { + "type": "Int"; + "keyRaw": "age"; + "nullable": true; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "pluginData": {}; + "input": { + "fields": { + "name": "ID"; + }; + "types": {}; + }; + }; `) }) @@ -236,6 +387,63 @@ describe('typescript', function () { readonly firstName: string; } | null; }; + + export type TestFragment$artifact = { + "name": "TestFragment"; + "kind": "HoudiniFragment"; + "hash": "c05ae5e22dd26c00fbc088277ca96ded6e01a0a6c540eb040ee91d10655b4575"; + "raw": \`fragment TestFragment on User { + firstName + parent { + firstName + id + } + id + __typename + } + \`; + "rootType": "User"; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "parent": { + "type": "User"; + "keyRaw": "parent"; + "nullable": true; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "pluginData": {}; + }; `) }) @@ -273,6 +481,60 @@ describe('typescript', function () { readonly id: string; readonly weight: number | null; }; + + export type TestFragment$artifact = { + "name": "TestFragment"; + "kind": "HoudiniFragment"; + "hash": "268c6ce8de2ed68662e4da519dc541b6aae4232671449895e23cee88b25120cd"; + "raw": \`fragment TestFragment on User { + firstName + admin + age + id + weight + __typename + } + \`; + "rootType": "User"; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "admin": { + "type": "Boolean"; + "keyRaw": "admin"; + "nullable": true; + "visible": true; + }; + "age": { + "type": "Int"; + "keyRaw": "age"; + "nullable": true; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "weight": { + "type": "Float"; + "keyRaw": "weight"; + "nullable": true; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "pluginData": {}; + }; `) }) @@ -309,6 +571,63 @@ describe('typescript', function () { readonly firstName: string; } | null)[] | null; }; + + export type TestFragment$artifact = { + "name": "TestFragment"; + "kind": "HoudiniFragment"; + "hash": "60afdef644bced9aae26a086b7dbf33dc7b8b51c45ddc2fc4571a0bb72f2d660"; + "raw": \`fragment TestFragment on User { + firstName + friends { + firstName + id + } + id + __typename + } + \`; + "rootType": "User"; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "friends": { + "type": "User"; + "keyRaw": "friends"; + "nullable": true; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "pluginData": {}; + }; `) }) @@ -340,6 +659,47 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "75599daf9b89690cac0df70674158875f4908fd3405b0a12510bb0803161dd01"; + "raw": \`query MyQuery { + user { + firstName + id + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user"; + "nullable": true; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -376,6 +736,47 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "802f49d218bd0db35a4f4eec151e9db62490086a3e61818659f7a283548427b6"; + "raw": \`query MyQuery { + users { + firstName + id + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "users": { + "type": "User"; + "keyRaw": "users"; + "nullable": true; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -415,6 +816,54 @@ describe('typescript', function () { id: string; enum?: ValueOf | null | undefined; }; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "d4c90d48b80c460939fd7dca6b99122fdd276812c20176b37679cfaf08c2efcf"; + "raw": \`query MyQuery($id: ID!, $enum: MyEnum) { + user(id: $id, enumArg: $enum) { + firstName + id + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user(enumArg: $enum, id: $id)"; + "nullable": true; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "input": { + "fields": { + "id": "ID"; + "enum": "MyEnum"; + }; + "types": {}; + }; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -458,6 +907,64 @@ describe('typescript', function () { }; export type MyTestQuery$input = null; + + export type MyTestQuery$artifact = { + "name": "MyTestQuery"; + "kind": "HoudiniQuery"; + "hash": "a628c9dfeecde5337a5439aee8f7c4d0111783f9fd456841a54f485db49f756d"; + "raw": \`query MyTestQuery { + entity { + ... on Node { + id + } + __typename + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "entity": { + "type": "Entity"; + "keyRaw": "entity"; + "selection": { + "abstractFields": { + "fields": { + "Node": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "typeMap": { + "Cat": "Node"; + "User": "Node"; + }; + }; + "fields": { + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "abstract": true; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -543,6 +1050,80 @@ describe('typescript', function () { readonly firstName?: string; } | null; }; + + export type MyMutation$artifact = { + "name": "MyMutation"; + "kind": "HoudiniMutation"; + "hash": "ca24beb22d7dfdbf5e50bc7ca446037f0812deeca00c13d3ce745a9f492f69b7"; + "raw": \`mutation MyMutation($filter: UserFilter, $filterList: [UserFilter!]!, $id: ID!, $firstName: String!, $admin: Boolean, $age: Int, $weight: Float) { + doThing( + filter: $filter + list: $filterList + id: $id + firstName: $firstName + admin: $admin + age: $age + weight: $weight + ) { + firstName + id + } + } + \`; + "rootType": "Mutation"; + "selection": { + "fields": { + "doThing": { + "type": "User"; + "keyRaw": "doThing(admin: $admin, age: $age, filter: $filter, firstName: $firstName, id: $id, list: $filterList, weight: $weight)"; + "nullable": true; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "input": { + "fields": { + "filter": "UserFilter"; + "filterList": "UserFilter"; + "id": "ID"; + "firstName": "String"; + "admin": "Boolean"; + "age": "Int"; + "weight": "Float"; + }; + "types": { + "NestedUserFilter": { + "id": "ID"; + "firstName": "String"; + "admin": "Boolean"; + "age": "Int"; + "weight": "Float"; + }; + "UserFilter": { + "middle": "NestedUserFilter"; + "listRequired": "String"; + "nullList": "String"; + "recursive": "UserFilter"; + "enum": "MyEnum"; + }; + }; + }; + }; `) }) @@ -601,6 +1182,59 @@ describe('typescript', function () { readonly firstName?: string; } | null; }; + + export type MyMutation$artifact = { + "name": "MyMutation"; + "kind": "HoudiniMutation"; + "hash": "32e4d8c37e92a71ccb13fe49e001735829f33af4f42687fb138c6547c4cc4749"; + "raw": \`mutation MyMutation { + doThing(list: [], id: "1", firstName: "hello") { + firstName + ...TestFragment + id + } + } + + fragment TestFragment on User { + firstName + id + __typename + } + \`; + "rootType": "Mutation"; + "selection": { + "fields": { + "doThing": { + "type": "User"; + "keyRaw": "doThing(firstName: \\"hello\\", id: \\"1\\", list: [])"; + "nullable": true; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + }; + }; + "fragments": { + "TestFragment": {}; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + }; `) }) @@ -655,6 +1289,68 @@ describe('typescript', function () { export type MyQuery$input = { filter: UserFilter; }; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "3db625201d1054e50b978a3a87dc954aef3b8284070cdd0c1f667a2ccada232f"; + "raw": \`query MyQuery($filter: UserFilter!) { + user(filter: $filter) { + firstName + id + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user(filter: $filter)"; + "nullable": true; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "input": { + "fields": { + "filter": "UserFilter"; + }; + "types": { + "NestedUserFilter": { + "id": "ID"; + "firstName": "String"; + "admin": "Boolean"; + "age": "Int"; + "weight": "Float"; + }; + "UserFilter": { + "middle": "NestedUserFilter"; + "listRequired": "String"; + "nullList": "String"; + "recursive": "UserFilter"; + "enum": "MyEnum"; + }; + }; + }; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -714,6 +1410,59 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "e07594825d2da2a5eaae6efa277dc4dfd0f3416a08827dfc505c88a0c5650068"; + "raw": \`query MyQuery { + user { + ...Foo + id + } + } + + fragment Foo on User { + firstName + id + __typename + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user"; + "nullable": true; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + }; + }; + "fragments": { + "Foo": {}; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -753,6 +1502,60 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "e07594825d2da2a5eaae6efa277dc4dfd0f3416a08827dfc505c88a0c5650068"; + "raw": \`query MyQuery { + user { + ...Foo + id + } + } + + fragment Foo on User { + firstName + id + __typename + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user"; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + "fragments": { + "Foo": {}; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -801,6 +1604,82 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "07886277853f6b1ef2d195030db19d20fc69006937a247cfebe208017c289335"; + "raw": \`query MyQuery { + nodes { + ... on User { + id + } + ... on Cat { + id + } + id + __typename + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "nodes": { + "type": "Node"; + "keyRaw": "nodes"; + "selection": { + "abstractFields": { + "fields": { + "User": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + "Cat": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "typeMap": {}; + }; + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "abstract": true; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -849,6 +1728,77 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "b5705e689c230262aea65553c5366ca16aa85ffb6be532a5a83fe0c29319b632"; + "raw": \`query MyQuery { + entities { + ... on User { + id + } + ... on Cat { + id + } + __typename + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "entities": { + "type": "Entity"; + "keyRaw": "entities"; + "nullable": true; + "selection": { + "abstractFields": { + "fields": { + "User": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + "Cat": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "typeMap": {}; + }; + "fields": { + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "abstract": true; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -900,6 +1850,94 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "21a52db58ea979321a735e60ac37878e5675cc15589e1f54cd6ea8ad51a9c359"; + "raw": \`query MyQuery { + nodes { + id + ... on User { + firstName + id + } + ... on Cat { + kitty + id + } + __typename + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "nodes": { + "type": "Node"; + "keyRaw": "nodes"; + "selection": { + "abstractFields": { + "fields": { + "User": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + "Cat": { + "kitty": { + "type": "Boolean"; + "keyRaw": "kitty"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "typeMap": {}; + }; + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "abstract": true; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -952,6 +1990,97 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "c25bd32ae0bce403ccc8003f26d309858560a9a931b3994b25ea90a5e78f12e3"; + "raw": \`query MyQuery { + entities { + ... on Animal { + isAnimal + } + ... on User { + firstName + id + } + ... on Cat { + kitty + id + } + __typename + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "entities": { + "type": "Entity"; + "keyRaw": "entities"; + "nullable": true; + "selection": { + "abstractFields": { + "fields": { + "User": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + "Cat": { + "kitty": { + "type": "Boolean"; + "keyRaw": "kitty"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "isAnimal": { + "type": "Boolean"; + "keyRaw": "isAnimal"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "typeMap": {}; + }; + "fields": { + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "abstract": true; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -1010,6 +2139,40 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "87075eb7baf6814d4d50014581034fbdaa8ba700214a568670261e5b428597a0"; + "raw": \`query MyQuery { + allItems { + createdAt + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "allItems": { + "type": "TodoItem"; + "keyRaw": "allItems"; + "selection": { + "fields": { + "createdAt": { + "type": "DateTime"; + "keyRaw": "createdAt"; + "visible": true; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -1072,6 +2235,46 @@ describe('typescript', function () { export type MyQuery$input = { date: Date; }; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "dabe70c216f3d5d1c8acddccf9e2d8b14257649861747f941b9c64cfd6311022"; + "raw": \`query MyQuery($date: DateTime!) { + allItems(createdAt: $date) { + createdAt + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "allItems": { + "type": "TodoItem"; + "keyRaw": "allItems(createdAt: $date)"; + "selection": { + "fields": { + "createdAt": { + "type": "DateTime"; + "keyRaw": "createdAt"; + "visible": true; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "input": { + "fields": { + "date": "DateTime"; + }; + "types": {}; + }; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -1113,6 +2316,53 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "a173496c64d9d074e03787939bc79e046f8362a606857fa387447c5f3a250ab7"; + "raw": \`query MyQuery { + listOfLists { + firstName + nickname + id + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "listOfLists": { + "type": "User"; + "keyRaw": "listOfLists"; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "nickname": { + "type": "String"; + "keyRaw": "nickname"; + "nullable": true; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -1157,6 +2407,76 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "38d331a2d7f0611a5ed00d57a0f3b4ef5c5cde865f69cb1b09559db85211ea2e"; + "raw": \`query MyQuery { + user { + parent { + firstName + firstName + id + } + parent { + nickname + id + } + id + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user"; + "nullable": true; + "selection": { + "fields": { + "parent": { + "type": "User"; + "keyRaw": "parent"; + "nullable": true; + "selection": { + "fields": { + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "nickname": { + "type": "String"; + "keyRaw": "nickname"; + "nullable": true; + "visible": true; + }; + }; + }; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -1262,6 +2582,96 @@ describe('typescript', function () { readonly id?: string; } | null; }; + + export type MyMutation$artifact = { + "name": "MyMutation"; + "kind": "HoudiniMutation"; + "hash": "609440bd4c48c2082cadf4d900ab6f3636c576ce469a14c00383896c0a093d36"; + "raw": \`mutation MyMutation($filter: UserFilter, $filterList: [UserFilter!]!, $id: ID!, $firstName: String!, $admin: Boolean, $age: Int, $weight: Float) { + doThing( + filter: $filter + list: $filterList + id: $id + firstName: $firstName + admin: $admin + age: $age + weight: $weight + ) { + ...My_Users_remove + ...My_Users_insert + id + } + } + + fragment My_Users_remove on User { + id + } + + fragment My_Users_insert on User { + id + } + \`; + "rootType": "Mutation"; + "selection": { + "fields": { + "doThing": { + "type": "User"; + "keyRaw": "doThing(admin: $admin, age: $age, filter: $filter, firstName: $firstName, id: $id, list: $filterList, weight: $weight)"; + "nullable": true; + "operations": [{ + "action": "remove"; + "list": "My_Users"; + }, { + "action": "insert"; + "list": "My_Users"; + "position": "last"; + }]; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + "fragments": { + "My_Users_remove": {}; + "My_Users_insert": {}; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "input": { + "fields": { + "filter": "UserFilter"; + "filterList": "UserFilter"; + "id": "ID"; + "firstName": "String"; + "admin": "Boolean"; + "age": "Int"; + "weight": "Float"; + }; + "types": { + "NestedUserFilter": { + "id": "ID"; + "firstName": "String"; + "admin": "Boolean"; + "age": "Int"; + "weight": "Float"; + }; + "UserFilter": { + "middle": "NestedUserFilter"; + "listRequired": "String"; + "nullList": "String"; + "recursive": "UserFilter"; + "enum": "MyEnum"; + }; + }; + }; + }; `) }) @@ -1333,6 +2743,100 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "53a8f654096f13904d5dc6300ce727748bf3ed0388e19984ef8ac7dc17515385"; + "raw": \`query MyQuery { + user { + ...UserBase + ...UserMore + id + } + } + + fragment UserBase on User { + id + firstName + __typename + } + + fragment UserMore on User { + friends { + ...UserBase + id + } + id + __typename + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user"; + "nullable": true; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + "friends": { + "type": "User"; + "keyRaw": "friends"; + "nullable": true; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + "fragments": { + "UserBase": {}; + }; + }; + "visible": true; + }; + }; + "fragments": { + "UserBase": {}; + "UserMore": {}; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -1398,6 +2902,97 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "c387cedf90314a9b3fb48a4d2f47ed1ba1c4e12e3da2eb9ac6fc1220adfa2e8d"; + "raw": \`query MyQuery { + user { + ...UserBase + ...UserMore + id + } + } + + fragment UserBase on User { + id + firstName + __typename + } + + fragment UserMore on User { + friends { + ...UserBase + id + } + id + __typename + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user"; + "nullable": true; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + "friends": { + "type": "User"; + "keyRaw": "friends"; + "nullable": true; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + }; + }; + "fragments": { + "UserBase": {}; + }; + }; + }; + }; + "fragments": { + "UserBase": {}; + "UserMore": {}; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) expect( @@ -1421,6 +3016,61 @@ describe('typescript', function () { }; } | null)[] | null; }; + + export type UserMore$artifact = { + "name": "UserMore"; + "kind": "HoudiniFragment"; + "hash": "dd15a529e927b628fe52e5479f698a5b3a65bae59380ff08e767cfb8bdf1c745"; + "raw": \`fragment UserMore on User { + friends { + ...UserBase + id + } + id + __typename + } + + fragment UserBase on User { + id + firstName + __typename + } + \`; + "rootType": "User"; + "selection": { + "fields": { + "friends": { + "type": "User"; + "keyRaw": "friends"; + "nullable": true; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + }; + "fragments": { + "UserBase": {}; + }; + }; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "pluginData": {}; + }; `) }) @@ -1491,6 +3141,99 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "ec1246bc6b1083a74cefb176a260fbc3171a820cffd8e9cd747cdce7488d3665"; + "raw": \`query MyQuery { + user { + ...UserBase + ...UserMore + id + } + } + + fragment UserBase on User { + id + firstName + __typename + } + + fragment UserMore on User { + friends { + ...UserBase + id + } + id + __typename + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user"; + "nullable": true; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + "friends": { + "type": "User"; + "keyRaw": "friends"; + "nullable": true; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + "fragments": { + "UserBase": {}; + }; + }; + "visible": true; + }; + }; + "fragments": { + "UserBase": {}; + "UserMore": {}; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) expect( @@ -1516,6 +3259,71 @@ describe('typescript', function () { }; } | null)[] | null; }; + + export type UserMore$artifact = { + "name": "UserMore"; + "kind": "HoudiniFragment"; + "hash": "dd15a529e927b628fe52e5479f698a5b3a65bae59380ff08e767cfb8bdf1c745"; + "raw": \`fragment UserMore on User { + friends { + ...UserBase + id + } + id + __typename + } + + fragment UserBase on User { + id + firstName + __typename + } + \`; + "rootType": "User"; + "selection": { + "fields": { + "friends": { + "type": "User"; + "keyRaw": "friends"; + "nullable": true; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + "fragments": { + "UserBase": {}; + }; + }; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "pluginData": {}; + }; `) }) @@ -1553,6 +3361,72 @@ describe('typescript', function () { }; export type MyQuery$input = null; + + export type MyQuery$artifact = { + "name": "MyQuery"; + "kind": "HoudiniQuery"; + "hash": "2d7de7172ca60367f8319c3e20c939584616da3953e8723a9c5bf55117a24897"; + "raw": \`query MyQuery { + user { + id + firstName @include(if: true) + admin @skip(if: true) + } + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user"; + "nullable": true; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "directives": [{ + "name": "include"; + "arguments": { + "if": { + "kind": "BooleanValue"; + "value": true; + }; + }; + }]; + "visible": true; + }; + "admin": { + "type": "Boolean"; + "keyRaw": "admin"; + "directives": [{ + "name": "skip"; + "arguments": { + "if": { + "kind": "BooleanValue"; + "value": true; + }; + }; + }]; + "nullable": true; + "visible": true; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; `) }) @@ -1614,5 +3488,48 @@ test('overlapping fragments', async function () { UserMore: {}; }; }; + + export type UserBase$artifact = { + "name": "UserBase"; + "kind": "HoudiniFragment"; + "hash": "05ec5090d31f77c3f2bdcbd26aff116588f63d4b3789ae752759dd172974a628"; + "raw": \`fragment UserBase on User { + id + firstName + ...UserMore + __typename + } + + fragment UserMore on User { + id + firstName + __typename + } + \`; + "rootType": "User"; + "selection": { + "fields": { + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + "fragments": { + "UserMore": {}; + }; + }; + "pluginData": {}; + }; `) }) diff --git a/packages/houdini/src/codegen/index.ts b/packages/houdini/src/codegen/index.ts index 6538d6d30..f17b939b6 100755 --- a/packages/houdini/src/codegen/index.ts +++ b/packages/houdini/src/codegen/index.ts @@ -100,6 +100,9 @@ export async function runPipeline(config: Config, docs: Document[]) { generators.indexFile, // typescript generator needs to go after the runtime one // so that the imperative cache definitions always survive + // this also ensures that we have the artifact generated already + // which lets us define it as the default export for the appropriate + // definition file. generators.typescript, generators.persistOutput, generators.definitions, @@ -261,7 +264,10 @@ async function collectDocuments(config: Config): Promise { } } } catch (err) { - throw new HoudiniError({ ...(err as HoudiniError), filepath }) + throw { + message: (err as Error).message, + filepath, + } } }) ) diff --git a/packages/houdini/src/lib/config.ts b/packages/houdini/src/lib/config.ts index d4bac5bb8..6782b1014 100644 --- a/packages/houdini/src/lib/config.ts +++ b/packages/houdini/src/lib/config.ts @@ -679,6 +679,21 @@ export class Config { ) } + // we need a function that walks down a graphql query and detects the use of a directive config.paginateDirective + needsRefetchArtifact(document: graphql.DocumentNode) { + let needsArtifact = false + + graphql.visit(document, { + Directive: (node) => { + if ([this.paginateDirective].includes(node.name.value)) { + needsArtifact = true + } + }, + }) + + return needsArtifact + } + #fragmentVariableMaps: Record registerFragmentVariablesHash({ hash, diff --git a/packages/houdini/src/lib/deepMerge.ts b/packages/houdini/src/lib/deepMerge.ts index 409d3143e..c201cf0e3 100644 --- a/packages/houdini/src/lib/deepMerge.ts +++ b/packages/houdini/src/lib/deepMerge.ts @@ -16,7 +16,7 @@ export function deepMerge(filepath: string, ...targets: T[]): T { } catch (e) { throw new HoudiniError({ filepath, - message: 'could not merge: ' + targets, + message: 'could not merge: ' + JSON.stringify(targets, null, 4), description: (e as Error).message, }) } diff --git a/packages/houdini/src/lib/parse.ts b/packages/houdini/src/lib/parse.ts index 60e88e278..95ae28e9c 100644 --- a/packages/houdini/src/lib/parse.ts +++ b/packages/houdini/src/lib/parse.ts @@ -1,17 +1,22 @@ -import { parse as parseJavascript } from '@babel/parser' +import { parse as parseJavascript, type ParserOptions } from '@babel/parser' +import { deepMerge } from './deepMerge' import type { Maybe, Script } from './types' export type ParsedFile = Maybe<{ script: Script; start: number; end: number }> -export async function parseJS(str: string): Promise { +export async function parseJS(str: string, config?: Partial): Promise { + const defaultConfig: ParserOptions = { + plugins: ['typescript'], + sourceType: 'module', + } return { start: 0, // @ts-ignore - script: parseJavascript(str || '', { - plugins: ['typescript'], - sourceType: 'module', - }).program, + script: parseJavascript( + str || '', + config ? deepMerge('', defaultConfig, config) : defaultConfig + ).program, end: str.length, } } diff --git a/packages/houdini/src/lib/types.ts b/packages/houdini/src/lib/types.ts index 051610eb5..31d5df229 100644 --- a/packages/houdini/src/lib/types.ts +++ b/packages/houdini/src/lib/types.ts @@ -135,7 +135,11 @@ export type PluginHooks = { * Transform the plugin's runtime while houdini is copying it . * You must have passed a value to includeRuntime for this hook to matter. */ - transformRuntime?: Record string> + transformRuntime?: + | Record string> + | (( + docs: Document[] + ) => Record string>) /** * An module with an default export that sets configuration values. diff --git a/packages/houdini/src/runtime/client/documentStore.ts b/packages/houdini/src/runtime/client/documentStore.ts index 61f054b92..0556fcc8e 100644 --- a/packages/houdini/src/runtime/client/documentStore.ts +++ b/packages/houdini/src/runtime/client/documentStore.ts @@ -40,6 +40,9 @@ export class DocumentStore< // we need the last context value we've seen in order to pass it during cleanup #lastContext: ClientPluginContext | null = null + // a reference to the earliest resolving open promise that the store has sent + pendingPromise: { then: (val: any) => void } | null = null + constructor({ artifact, plugins, @@ -137,7 +140,7 @@ export class DocumentStore< context = context.apply(draft, false) // walk through the plugins to get the first result - return await new Promise>((resolve, reject) => { + const promise = new Promise>((resolve, reject) => { // the initial state of the iterator const state: IteratorState = { setup, @@ -148,14 +151,21 @@ export class DocumentStore< resolved: false, resolve, reject, + then: (...args) => promise.then(...args), }, // patch the context with new variables context, } + if (this.pendingPromise === null) { + this.pendingPromise = state.promise + } + // start walking down the chain this.#step('forward', state) }) + + return await promise } async cleanup() { @@ -346,6 +356,12 @@ export class DocumentStore< ) } + // don't update the store if the final value is partial and we aren't supposed to send one back, don't update anything + if (!ctx.silenceEcho || value.data !== this.state.data) { + // the latest value should be written to the store + this.set(value) + } + // if the promise hasn't been resolved yet, do it if (!ctx.promise.resolved) { ctx.promise.resolve(value) @@ -356,13 +372,6 @@ export class DocumentStore< this.#lastContext = ctx.context.draft() this.#lastVariables = this.#lastContext.stuff.inputs.marshaled - - // if the final value is partial and we aren't supposed to send one back, don't update anything - if (ctx.silenceEcho && value.data === this.state.data) { - return - } - // the latest value should be written to the store - this.set(value) } } @@ -515,6 +524,7 @@ type IteratorState = { resolved: boolean resolve(val: any): void reject(val: any): void + then(val: any): any } } diff --git a/packages/houdini/src/runtime/client/index.ts b/packages/houdini/src/runtime/client/index.ts index 06864656a..5b207e610 100644 --- a/packages/houdini/src/runtime/client/index.ts +++ b/packages/houdini/src/runtime/client/index.ts @@ -16,7 +16,7 @@ import { import pluginsFromPlugins from './plugins/injectedPlugins' // export the plugin constructors -export { DocumentStore, type ClientPlugin } from './documentStore' +export { DocumentStore, type ClientPlugin, type SendParams } from './documentStore' export { fetch, mutation, query, subscription } from './plugins' type ConstructorArgs = { diff --git a/packages/houdini/src/runtime/client/plugins/cache.ts b/packages/houdini/src/runtime/client/plugins/cache.ts index 5df0a7f21..1e1238605 100644 --- a/packages/houdini/src/runtime/client/plugins/cache.ts +++ b/packages/houdini/src/runtime/client/plugins/cache.ts @@ -25,7 +25,8 @@ export const cachePolicy = // enforce cache policies for queries if ( enabled && - artifact.kind === ArtifactKind.Query && + (artifact.kind === ArtifactKind.Query || + artifact.kind === ArtifactKind.Fragment) && !ctx.cacheParams?.disableRead ) { // this function is called as the first step in requesting data. If the policy prefers @@ -98,7 +99,9 @@ export const cachePolicy = // if we got this far, we are resolving something against the network // dont set the fetching state to true if we accepted a cache value - setFetching(!useCache) + if (!ctx.stuff?.silenceLoading) { + setFetching(!useCache) + } // move on return next(ctx) diff --git a/packages/houdini/src/runtime/client/plugins/fragment.ts b/packages/houdini/src/runtime/client/plugins/fragment.ts index e328a5f42..6fbcd8aa8 100644 --- a/packages/houdini/src/runtime/client/plugins/fragment.ts +++ b/packages/houdini/src/runtime/client/plugins/fragment.ts @@ -1,4 +1,5 @@ import cache from '../../cache' +import { deepEquals } from '../../lib/deepEquals' import { type SubscriptionSpec, ArtifactKind, DataSource } from '../../lib/types' import type { ClientPlugin } from '../documentStore' import { documentPlugin } from '../utils' @@ -9,6 +10,9 @@ export const fragment: ClientPlugin = documentPlugin(ArtifactKind.Fragment, func // track the bits of state we need to hold onto let subscriptionSpec: SubscriptionSpec | null = null + // we need to track the last parents and variables used so we can re-subscribe + let lastReference: { parent: string; variables: any } | null = null + return { // establish the cache subscription start(ctx, { next, resolve, variablesChanged, marshalVariables }) { @@ -17,8 +21,17 @@ export const fragment: ClientPlugin = documentPlugin(ArtifactKind.Fragment, func return next(ctx) } + // the object describing the current parent reference + const currentReference = { + parent: ctx.stuff.parentID, + variables: marshalVariables(ctx), + } + // if the variables have changed we need to setup a new subscription with the cache - if (variablesChanged(ctx) && !ctx.cacheParams?.disableSubscriptions) { + if ( + !ctx.cacheParams?.disableSubscriptions && + (!deepEquals(lastReference, currentReference) || variablesChanged(ctx)) + ) { // if the variables changed we need to unsubscribe from the old fields and // listen to the new ones if (subscriptionSpec) { @@ -49,6 +62,8 @@ export const fragment: ClientPlugin = documentPlugin(ArtifactKind.Fragment, func // make sure we subscribe to the new values cache.subscribe(subscriptionSpec, variables) + + lastReference = currentReference } // we're done diff --git a/packages/houdini/src/runtime/client/plugins/query.ts b/packages/houdini/src/runtime/client/plugins/query.ts index adb8d88fa..3e14031c5 100644 --- a/packages/houdini/src/runtime/client/plugins/query.ts +++ b/packages/houdini/src/runtime/client/plugins/query.ts @@ -35,11 +35,12 @@ export const query: ClientPlugin = documentPlugin(ArtifactKind.Query, function ( // track the new variables lastVariables = { ...marshalVariables(ctx) } + const variables = lastVariables // save the new subscription spec subscriptionSpec = { rootType: ctx.artifact.rootType, selection: ctx.artifact.selection, - variables: () => lastVariables, + variables: () => variables, set: (newValue) => { resolve(ctx, { data: newValue, diff --git a/packages/houdini-svelte/src/runtime/stores/pagination/pageInfo.test.ts b/packages/houdini/src/runtime/lib/pageInfo.test.ts similarity index 100% rename from packages/houdini-svelte/src/runtime/stores/pagination/pageInfo.test.ts rename to packages/houdini/src/runtime/lib/pageInfo.test.ts diff --git a/packages/houdini-svelte/src/runtime/stores/pagination/pageInfo.ts b/packages/houdini/src/runtime/lib/pageInfo.ts similarity index 83% rename from packages/houdini-svelte/src/runtime/stores/pagination/pageInfo.ts rename to packages/houdini/src/runtime/lib/pageInfo.ts index 2acbbf666..ce8837e53 100644 --- a/packages/houdini-svelte/src/runtime/stores/pagination/pageInfo.ts +++ b/packages/houdini/src/runtime/lib/pageInfo.ts @@ -1,17 +1,10 @@ -import { siteURL } from '$houdini/runtime/lib/constants' -import type { GraphQLObject } from '$houdini/runtime/lib/types' +import { siteURL } from './constants' +import type { GraphQLObject, PageInfo } from './types' export function nullPageInfo(): PageInfo { return { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false } } -export type PageInfo = { - startCursor: string | null - endCursor: string | null - hasNextPage: boolean - hasPreviousPage: boolean -} - export function missingPageSizeError(fnName: string) { return { message: `${fnName} is missing the required page arguments. For more information, please visit this link: ${siteURL}/guides/pagination`, diff --git a/packages/houdini-svelte/src/runtime/stores/pagination/cursor.ts b/packages/houdini/src/runtime/lib/pagination.ts similarity index 61% rename from packages/houdini-svelte/src/runtime/stores/pagination/cursor.ts rename to packages/houdini/src/runtime/lib/pagination.ts index 3a458ea32..f77107f2e 100644 --- a/packages/houdini-svelte/src/runtime/stores/pagination/cursor.ts +++ b/packages/houdini/src/runtime/lib/pagination.ts @@ -1,38 +1,32 @@ -import type { SendParams } from '$houdini/runtime/client/documentStore' -import { CachePolicy } from '$houdini/runtime/lib' -import { getCurrentConfig } from '$houdini/runtime/lib/config' -import { siteURL } from '$houdini/runtime/lib/constants' -import { deepEquals } from '$houdini/runtime/lib/deepEquals' -import type { GraphQLObject, QueryArtifact, QueryResult } from '$houdini/runtime/lib/types' -import { writable } from 'svelte/store' - -import { getSession } from '../../session' -import type { CursorHandlers } from '../../types' -import type { QueryStoreFetchParams } from '../query' -import { fetchParams } from '../query' -import type { FetchFn } from './fetch' -import type { PageInfo } from './pageInfo' +import type { SendParams } from '../client/documentStore' +import { getCurrentConfig } from './config' +import { deepEquals } from './deepEquals' import { countPage, extractPageInfo, missingPageSizeError } from './pageInfo' +import { CachePolicy } from './types' +import type { + CursorHandlers, + FetchFn, + GraphQLObject, + QueryArtifact, + QueryResult, + FetchParams, +} from './types' export function cursorHandlers<_Data extends GraphQLObject, _Input extends Record>({ artifact, - storeName, - initialValue, fetchUpdate: parentFetchUpdate, fetch: parentFetch, getState, getVariables, + getSession, }: { artifact: QueryArtifact - storeName: string - fetch: FetchFn<_Data, _Input> getState: () => _Data | null getVariables: () => _Input - initialValue: _Data | null + getSession: () => Promise + fetch: FetchFn<_Data, _Input> fetchUpdate: (arg: SendParams, updates: string[]) => ReturnType> }): CursorHandlers<_Data, _Input> { - const pageInfo = writable(extractPageInfo(initialValue, artifact.refetch!.path)) - // dry up the page-loading logic const loadPage = async ({ pageSizeVar, @@ -66,8 +60,7 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Recor let isSinglePage = artifact.refetch?.mode === 'SinglePage' // send the query - const targetFetch = isSinglePage ? parentFetch : parentFetchUpdate - const { data } = await targetFetch( + await (isSinglePage ? parentFetch : parentFetchUpdate)( { variables: loadVariables, fetch, @@ -77,25 +70,6 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Recor }, isSinglePage ? [] : [where === 'start' ? 'prepend' : 'append'] ) - - // if the query is embedded in a node field (paginated fragments) - // make sure we look down one more for the updated page info - const resultPath = [...artifact.refetch!.path] - if (artifact.refetch!.embedded) { - const { targetType } = artifact.refetch! - // make sure we have a type config for the pagination target type - if (!config.types?.[targetType]?.resolve) { - throw new Error( - `Missing type resolve configuration for ${targetType}. For more information, see ${siteURL}/guides/pagination#paginated-fragments` - ) - } - - // make sure that we pull the value out of the correct query field - resultPath.unshift(config.types[targetType].resolve!.queryField) - } - - // we need to find the connection object holding the current page info - pageInfo.set(extractPageInfo(data, resultPath)) } const getPageInfo = () => { @@ -114,12 +88,6 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Recor fetch?: typeof globalThis.fetch metadata?: {} } = {}) => { - if (artifact.refetch?.direction === 'backward') { - console.warn(`⚠️ ${storeName}.loadNextPage was called but it does not support forwards pagination. -If you think this is an error, please open an issue on GitHub`) - return - } - // we need to find the connection object holding the current page info const currentPageInfo = getPageInfo() // if there is no next page, we're done @@ -156,12 +124,6 @@ If you think this is an error, please open an issue on GitHub`) fetch?: typeof globalThis.fetch metadata?: {} } = {}) => { - if (artifact.refetch?.direction === 'forward') { - console.warn(`⚠️ ${storeName}.loadPreviousPage was called but it does not support backwards pagination. -If you think this is an error, please open an issue on GitHub`) - return - } - // we need to find the connection object holding the current page info const currentPageInfo = getPageInfo() @@ -188,18 +150,12 @@ If you think this is an error, please open an issue on GitHub`) where: 'start', }) }, - pageInfo, - async fetch( - args?: QueryStoreFetchParams<_Data, _Input> - ): Promise> { - // validate and prepare the request context for the current environment (client vs server) - const { params } = await fetchParams(artifact, storeName, args) - - const { variables } = params ?? {} + async fetch(args?: FetchParams<_Input>): Promise> { + const { variables } = args ?? {} // if the input is different than the query variables then we just do everything like normal if (variables && !deepEquals(getVariables(), variables)) { - return await parentFetch(params) + return await parentFetch(args) } // we need to find the connection object holding the current page info @@ -207,7 +163,7 @@ If you think this is an error, please open an issue on GitHub`) var currentPageInfo = extractPageInfo(getState(), artifact.refetch!.path) } catch { // if there was any issue getting the page info, just fetch like normal - return await parentFetch(params) + return await parentFetch(args) } // build up the variables to pass to the query @@ -259,14 +215,109 @@ Make sure to pass a cursor value by hand that includes the current set (ie the e // send the query const result = await parentFetch({ - ...params, + ...args, variables: queryVariables as _Input, }) - // keep the page info store up to date - pageInfo.set(extractPageInfo(result.data, artifact.refetch!.path)) - return result }, } } + +export function offsetHandlers<_Data extends GraphQLObject, _Input extends {}>({ + artifact, + storeName, + getState, + getVariables, + fetch: parentFetch, + fetchUpdate: parentFetchUpdate, + getSession, +}: { + artifact: QueryArtifact + fetch: FetchFn<_Data, _Input> + fetchUpdate: (arg: SendParams) => ReturnType> + storeName: string + getState: () => _Data | null + getVariables: () => _Input + getSession: () => Promise +}) { + // we need to track the most recent offset for this handler + let getOffset = () => + (artifact.refetch?.start as number) || + countPage(artifact.refetch!.path, getState()) || + artifact.refetch!.pageSize + + let currentOffset = getOffset() ?? 0 + + return { + loadNextPage: async ({ + limit, + offset, + fetch, + metadata, + }: { + limit?: number + offset?: number + fetch?: typeof globalThis.fetch + metadata?: {} + } = {}) => { + // build up the variables to pass to the query + const queryVariables: Record = { + ...getVariables(), + offset: offset ?? getOffset(), + } + if (limit || limit === 0) { + queryVariables.limit = limit + } + + // if we made it this far without a limit argument and there's no default page size, + // they made a mistake + if (!queryVariables.limit && !artifact.refetch!.pageSize) { + throw missingPageSizeError('loadNextPage') + } + + // Get the Pagination Mode + let isSinglePage = artifact.refetch?.mode === 'SinglePage' + + // send the query + const targetFetch = isSinglePage ? parentFetch : parentFetchUpdate + await targetFetch({ + variables: queryVariables as _Input, + fetch, + metadata, + policy: isSinglePage ? artifact.policy : CachePolicy.NetworkOnly, + session: await getSession(), + }) + + // add the page size to the offset so we load the next page next time + const pageSize = queryVariables.limit || artifact.refetch!.pageSize + currentOffset = offset + pageSize + }, + async fetch(params: FetchParams<_Input> = {}): Promise> { + const { variables } = params + + // if the input is different than the query variables then we just do everything like normal + if (variables && !deepEquals(getVariables(), variables)) { + return parentFetch.call(this, params) + } + + // we are updating the current set of items, count the number of items that currently exist + // and ask for the full data set + const count = currentOffset || getOffset() + + // build up the variables to pass to the query + const queryVariables: Record = {} + + // if there are more records than the first page, we need fetch to load everything + if (!artifact.refetch!.pageSize || count > artifact.refetch!.pageSize) { + queryVariables.limit = count + } + + // send the query + return await parentFetch.call(this, { + ...params, + variables: queryVariables as _Input, + }) + }, + } +} diff --git a/packages/houdini/src/runtime/lib/types.ts b/packages/houdini/src/runtime/lib/types.ts index 9c7f6669a..44f194ca1 100644 --- a/packages/houdini/src/runtime/lib/types.ts +++ b/packages/houdini/src/runtime/lib/types.ts @@ -28,6 +28,7 @@ declare global { } optimisticResponse?: GraphQLObject parentID?: string + silenceLoading?: boolean } } } @@ -253,6 +254,61 @@ export type ValueNode = export type ValueMap = Record +export type FetchParams<_Input> = { + variables?: _Input + + /** + * The policy to use when performing the fetch. If set to CachePolicy.NetworkOnly, + * a request will always be sent, even if the variables are the same as the last call + * to fetch. + */ + policy?: CachePolicies + + /** + * An object that will be passed to the fetch function. + * You can do what you want with it! + */ + // @ts-ignore + metadata?: App.Metadata +} + +export type FetchFn<_Data extends GraphQLObject, _Input = any> = ( + params?: FetchParams<_Input> +) => Promise> + +export type CursorHandlers<_Data extends GraphQLObject, _Input> = { + loadNextPage: (args?: { + first?: number + after?: string + fetch?: typeof globalThis.fetch + metadata?: {} + }) => Promise + loadPreviousPage: (args?: { + last?: number + before?: string + fetch?: typeof globalThis.fetch + metadata?: {} + }) => Promise + fetch(args?: FetchParams<_Input> | undefined): Promise> +} + +export type OffsetHandlers<_Data extends GraphQLObject, _Input> = { + loadNextPage: (args?: { + limit?: number + offset?: number + metadata?: {} + fetch?: typeof globalThis.fetch + }) => Promise + fetch(args?: FetchParams<_Input> | undefined): Promise> +} + +export type PageInfo = { + startCursor: string | null + endCursor: string | null + hasNextPage: boolean + hasPreviousPage: boolean +} + interface IntValueNode { readonly kind: 'IntValue' readonly value: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf9945f25..168a64e5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,10 +56,10 @@ importers: dependencies: '@kitql/helper': 0.5.0 fs-extra: 10.1.0 - graphql: 15.8.0 - graphql-relay: 0.10.0_graphql@15.8.0 - graphql-ws: 5.11.2_graphql@15.8.0 - graphql-yoga: 3.4.0_l5ldqtd2ymgrkm4qlxjuv5xwgy + graphql: 16.6.0 + graphql-relay: 0.10.0_graphql@16.6.0 + graphql-ws: 5.11.2_graphql@16.6.0 + graphql-yoga: 3.4.0_graphql@16.6.0 ws: 8.11.0 e2e/kit: @@ -146,6 +146,35 @@ importers: houdini-react: link:../../packages/houdini-react typescript: 4.9.4 + e2e/react-vite: + specifiers: + '@types/react': ^18.0.27 + '@types/react-dom': ^18.0.10 + '@vitejs/plugin-react': ^3.1.0 + concurrently: 7.1.0 + cross-env: ^7.0.3 + e2e-api: workspace:^ + houdini: workspace:^ + houdini-react: workspace:^ + react: ^18.2.0 + react-dom: ^18.2.0 + typescript: ^4.9.3 + vite: ^4.1.0 + dependencies: + houdini: link:../../packages/houdini + houdini-react: link:../../packages/houdini-react + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + devDependencies: + '@types/react': 18.0.33 + '@types/react-dom': 18.0.11 + '@vitejs/plugin-react': 3.1.0_vite@4.1.4 + concurrently: 7.1.0 + cross-env: 7.0.3 + e2e-api: link:../_api + typescript: 4.9.4 + vite: 4.1.4 + e2e/svelte: specifiers: '@kitql/helper': ^0.5.0 @@ -293,18 +322,28 @@ importers: '@babel/parser': ^7.19.3 '@types/estraverse': ^5.1.2 '@types/next': ^9.0.0 + '@types/react': ^18.0.28 + '@types/rollup': ^0.54.0 estraverse: ^5.3.0 graphql: ^15.8.0 houdini: workspace:^ next: ^13.0.1 + react: ^18.2.0 recast: ^0.23.1 + rollup: ^3.7.4 scripts: workspace:^ + use-deep-compare-effect: ^1.8.1 dependencies: '@babel/parser': 7.20.7 + '@types/react': 18.0.33 + '@types/rollup': 0.54.0 estraverse: 5.3.0 graphql: 15.8.0 houdini: link:../houdini + react: 18.2.0 recast: 0.23.1 + rollup: 3.14.0 + use-deep-compare-effect: 1.8.1_react@18.2.0 devDependencies: '@types/estraverse': 5.1.2 '@types/next': 9.0.0_biqbaboplfbrettd7655fr4n2y @@ -441,6 +480,15 @@ packages: dependencies: '@babel/highlight': 7.18.6 + /@babel/code-frame/7.21.4: + resolution: + { + integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==, + } + engines: { node: '>=6.9.0' } + dependencies: + '@babel/highlight': 7.18.6 + /@babel/compat-data/7.20.10: resolution: { @@ -461,7 +509,7 @@ packages: '@babel/helper-compilation-targets': 7.20.7_@babel+core@7.17.8 '@babel/helper-module-transforms': 7.20.11 '@babel/helpers': 7.20.7 - '@babel/parser': 7.20.7 + '@babel/parser': 7.21.4 '@babel/template': 7.20.7 '@babel/traverse': 7.20.10 '@babel/types': 7.20.7 @@ -498,7 +546,6 @@ packages: semver: 6.3.0 transitivePeerDependencies: - supports-color - dev: false /@babel/generator/7.17.7: resolution: @@ -519,8 +566,20 @@ packages: } engines: { node: '>=6.9.0' } dependencies: - '@babel/types': 7.20.7 + '@babel/types': 7.21.4 + '@jridgewell/gen-mapping': 0.3.2 + jsesc: 2.5.2 + + /@babel/generator/7.21.4: + resolution: + { + integrity: sha512-NieM3pVIYW2SwGzKoqfPrQsf4xGs9M9AIG3ThppsSRmO+m7eQhmI6amajKMUeIO37wFfsvnvcxQFx6x6iqxDnA==, + } + engines: { node: '>=6.9.0' } + dependencies: + '@babel/types': 7.21.4 '@jridgewell/gen-mapping': 0.3.2 + '@jridgewell/trace-mapping': 0.3.17 jsesc: 2.5.2 /@babel/helper-compilation-targets/7.20.7_@babel+core@7.17.8: @@ -555,7 +614,6 @@ packages: browserslist: 4.21.4 lru-cache: 5.1.1 semver: 6.3.0 - dev: false /@babel/helper-environment-visitor/7.18.9: resolution: @@ -572,7 +630,17 @@ packages: engines: { node: '>=6.9.0' } dependencies: '@babel/template': 7.20.7 - '@babel/types': 7.20.7 + '@babel/types': 7.21.4 + + /@babel/helper-function-name/7.21.0: + resolution: + { + integrity: sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==, + } + engines: { node: '>=6.9.0' } + dependencies: + '@babel/template': 7.20.7 + '@babel/types': 7.21.4 /@babel/helper-hoist-variables/7.18.6: resolution: @@ -581,7 +649,7 @@ packages: } engines: { node: '>=6.9.0' } dependencies: - '@babel/types': 7.20.7 + '@babel/types': 7.21.4 /@babel/helper-module-imports/7.18.6: resolution: @@ -590,7 +658,7 @@ packages: } engines: { node: '>=6.9.0' } dependencies: - '@babel/types': 7.20.7 + '@babel/types': 7.21.4 /@babel/helper-module-transforms/7.20.11: resolution: @@ -605,8 +673,8 @@ packages: '@babel/helper-split-export-declaration': 7.18.6 '@babel/helper-validator-identifier': 7.19.1 '@babel/template': 7.20.7 - '@babel/traverse': 7.20.10 - '@babel/types': 7.20.7 + '@babel/traverse': 7.21.4 + '@babel/types': 7.21.4 transitivePeerDependencies: - supports-color @@ -616,7 +684,6 @@ packages: integrity: sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==, } engines: { node: '>=6.9.0' } - dev: false /@babel/helper-simple-access/7.20.2: resolution: @@ -625,7 +692,7 @@ packages: } engines: { node: '>=6.9.0' } dependencies: - '@babel/types': 7.20.7 + '@babel/types': 7.21.4 /@babel/helper-split-export-declaration/7.18.6: resolution: @@ -634,7 +701,7 @@ packages: } engines: { node: '>=6.9.0' } dependencies: - '@babel/types': 7.20.7 + '@babel/types': 7.21.4 /@babel/helper-string-parser/7.19.4: resolution: @@ -665,8 +732,8 @@ packages: engines: { node: '>=6.9.0' } dependencies: '@babel/template': 7.20.7 - '@babel/traverse': 7.20.10 - '@babel/types': 7.20.7 + '@babel/traverse': 7.21.4 + '@babel/types': 7.21.4 transitivePeerDependencies: - supports-color @@ -702,6 +769,16 @@ packages: dependencies: '@babel/types': 7.20.7 + /@babel/parser/7.21.4: + resolution: + { + integrity: sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==, + } + engines: { node: '>=6.0.0' } + hasBin: true + dependencies: + '@babel/types': 7.21.4 + /@babel/plugin-syntax-async-generators/7.8.4_@babel+core@7.20.7: resolution: { @@ -873,6 +950,32 @@ packages: '@babel/helper-plugin-utils': 7.20.2 dev: false + /@babel/plugin-transform-react-jsx-self/7.21.0_@babel+core@7.20.7: + resolution: + { + integrity: sha512-f/Eq+79JEu+KUANFks9UZCcvydOOGMgF7jBrcwjHa5jTZD8JivnhCJYvmlhR/WTXBWonDExPoW0eO/CR4QJirA==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.20.7 + '@babel/helper-plugin-utils': 7.20.2 + dev: true + + /@babel/plugin-transform-react-jsx-source/7.19.6_@babel+core@7.20.7: + resolution: + { + integrity: sha512-RpAi004QyMNisst/pvSanoRdJ4q+jMCWyk9zdw/CyLB9j8RXEahodR6l2GyttDRyEVWZtbN+TpLiHJ3t34LbsQ==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.20.7 + '@babel/helper-plugin-utils': 7.20.2 + dev: true + /@babel/runtime-corejs3/7.20.7: resolution: { @@ -892,7 +995,6 @@ packages: engines: { node: '>=6.9.0' } dependencies: regenerator-runtime: 0.13.11 - dev: true /@babel/template/7.20.7: resolution: @@ -901,9 +1003,9 @@ packages: } engines: { node: '>=6.9.0' } dependencies: - '@babel/code-frame': 7.18.6 - '@babel/parser': 7.20.7 - '@babel/types': 7.20.7 + '@babel/code-frame': 7.21.4 + '@babel/parser': 7.21.4 + '@babel/types': 7.21.4 /@babel/traverse/7.17.3: resolution: @@ -918,7 +1020,7 @@ packages: '@babel/helper-function-name': 7.19.0 '@babel/helper-hoist-variables': 7.18.6 '@babel/helper-split-export-declaration': 7.18.6 - '@babel/parser': 7.20.7 + '@babel/parser': 7.21.4 '@babel/types': 7.20.7 debug: 4.3.4 globals: 11.12.0 @@ -933,14 +1035,34 @@ packages: } engines: { node: '>=6.9.0' } dependencies: - '@babel/code-frame': 7.18.6 - '@babel/generator': 7.20.7 + '@babel/code-frame': 7.21.4 + '@babel/generator': 7.21.4 '@babel/helper-environment-visitor': 7.18.9 '@babel/helper-function-name': 7.19.0 '@babel/helper-hoist-variables': 7.18.6 '@babel/helper-split-export-declaration': 7.18.6 - '@babel/parser': 7.20.7 - '@babel/types': 7.20.7 + '@babel/parser': 7.21.4 + '@babel/types': 7.21.4 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + /@babel/traverse/7.21.4: + resolution: + { + integrity: sha512-eyKrRHKdyZxqDm+fV1iqL9UAHMoIg0nDaGqfIOd8rKH17m5snv7Gn4qgjBoFfLz9APvjFU/ICT00NVCv1Epp8Q==, + } + engines: { node: '>=6.9.0' } + dependencies: + '@babel/code-frame': 7.21.4 + '@babel/generator': 7.21.4 + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-function-name': 7.21.0 + '@babel/helper-hoist-variables': 7.18.6 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/parser': 7.21.4 + '@babel/types': 7.21.4 debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: @@ -968,6 +1090,17 @@ packages: '@babel/helper-validator-identifier': 7.19.1 to-fast-properties: 2.0.0 + /@babel/types/7.21.4: + resolution: + { + integrity: sha512-rU2oY501qDxE8Pyo7i/Orqma4ziCOrby0/9mvbDUGEfvZjb279Nk9k19e2fiCxHbRRpY2ZyrgW1eq22mvmOIzA==, + } + engines: { node: '>=6.9.0' } + dependencies: + '@babel/helper-string-parser': 7.19.4 + '@babel/helper-validator-identifier': 7.19.1 + to-fast-properties: 2.0.0 + /@bcoe/v8-coverage/0.2.3: resolution: { @@ -1252,7 +1385,7 @@ packages: tslib: 2.4.0 dev: false - /@envelop/parser-cache/5.0.4_j6i6lclrzilzz6orexmuccsxju: + /@envelop/parser-cache/5.0.4_a6sekiasy2tqr6d5gj7n2wtjli: resolution: { integrity: sha512-+kp6nzCVLYI2WQExQcE3FSy6n9ZGB5GYi+ntyjYdxaXU41U1f8RVwiLdyh0Ewn5D/s/zaLin09xkFKITVSAKDw==, @@ -1262,7 +1395,7 @@ packages: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: '@envelop/core': 3.0.4 - graphql: 15.8.0 + graphql: 16.6.0 lru-cache: 6.0.0 tslib: 2.5.0 dev: false @@ -1276,7 +1409,7 @@ packages: tslib: 2.5.0 dev: false - /@envelop/validation-cache/5.0.5_j6i6lclrzilzz6orexmuccsxju: + /@envelop/validation-cache/5.0.5_a6sekiasy2tqr6d5gj7n2wtjli: resolution: { integrity: sha512-69sq5H7hvxE+7VV60i0bgnOiV1PX9GEJHKrBrVvyEZAXqYojKO3DP9jnLGryiPgVaBjN5yw12ge0l0s2gXbolQ==, @@ -1286,7 +1419,7 @@ packages: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: '@envelop/core': 3.0.4 - graphql: 15.8.0 + graphql: 16.6.0 lru-cache: 6.0.0 tslib: 2.5.0 dev: false @@ -1610,7 +1743,7 @@ packages: - supports-color dev: true - /@graphql-tools/executor/0.0.12_graphql@15.8.0: + /@graphql-tools/executor/0.0.12_graphql@16.6.0: resolution: { integrity: sha512-bWpZcYRo81jDoTVONTnxS9dDHhEkNVjxzvFCH4CRpuyzD3uL+5w3MhtxIh24QyWm4LvQ4f+Bz3eMV2xU2I5+FA==, @@ -1618,10 +1751,10 @@ packages: peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: - '@graphql-tools/utils': 9.1.4_graphql@15.8.0 - '@graphql-typed-document-node/core': 3.1.1_graphql@15.8.0 + '@graphql-tools/utils': 9.1.4_graphql@16.6.0 + '@graphql-typed-document-node/core': 3.1.1_graphql@16.6.0 '@repeaterjs/repeater': 3.0.4 - graphql: 15.8.0 + graphql: 16.6.0 tslib: 2.5.0 value-or-promise: 1.0.12 dev: false @@ -1639,6 +1772,19 @@ packages: tslib: 2.5.0 dev: false + /@graphql-tools/merge/8.3.14_graphql@16.6.0: + resolution: + { + integrity: sha512-zV0MU1DnxJLIB0wpL4N3u21agEiYFsjm6DI130jqHpwF0pR9HkF+Ni65BNfts4zQelP0GjkHltG+opaozAJ1NA==, + } + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/utils': 9.1.3_graphql@16.6.0 + graphql: 16.6.0 + tslib: 2.5.0 + dev: false + /@graphql-tools/schema/9.0.12_graphql@15.8.0: resolution: { @@ -1654,6 +1800,21 @@ packages: value-or-promise: 1.0.11 dev: false + /@graphql-tools/schema/9.0.12_graphql@16.6.0: + resolution: + { + integrity: sha512-DmezcEltQai0V1y96nwm0Kg11FDS/INEFekD4nnVgzBqawvznWqK6D6bujn+cw6kivoIr3Uq//QmU/hBlBzUlQ==, + } + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/merge': 8.3.14_graphql@16.6.0 + '@graphql-tools/utils': 9.1.3_graphql@16.6.0 + graphql: 16.6.0 + tslib: 2.4.1 + value-or-promise: 1.0.11 + dev: false + /@graphql-tools/utils/9.1.3_graphql@15.8.0: resolution: { @@ -1666,7 +1827,19 @@ packages: tslib: 2.5.0 dev: false - /@graphql-tools/utils/9.1.4_graphql@15.8.0: + /@graphql-tools/utils/9.1.3_graphql@16.6.0: + resolution: + { + integrity: sha512-bbJyKhs6awp1/OmP+WKA1GOyu9UbgZGkhIj5srmiMGLHohEOKMjW784Sk0BZil1w2x95UPu0WHw6/d/HVCACCg==, + } + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + graphql: 16.6.0 + tslib: 2.5.0 + dev: false + + /@graphql-tools/utils/9.1.4_graphql@16.6.0: resolution: { integrity: sha512-hgIeLt95h9nQgQuzbbdhuZmh+8WV7RZ/6GbTj6t3IU4Zd2zs9yYJ2jgW/krO587GMOY8zCwrjNOMzD40u3l7Vg==, @@ -1674,11 +1847,11 @@ packages: peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: - graphql: 15.8.0 + graphql: 16.6.0 tslib: 2.5.0 dev: false - /@graphql-typed-document-node/core/3.1.1_graphql@15.8.0: + /@graphql-typed-document-node/core/3.1.1_graphql@16.6.0: resolution: { integrity: sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg==, @@ -1686,7 +1859,7 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - graphql: 15.8.0 + graphql: 16.6.0 dev: false /@graphql-yoga/subscription/3.1.0: @@ -2736,7 +2909,6 @@ packages: { integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==, } - dev: true /@types/pug/2.0.6: resolution: @@ -2744,6 +2916,15 @@ packages: integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==, } + /@types/react-dom/18.0.11: + resolution: + { + integrity: sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==, + } + dependencies: + '@types/react': 18.0.33 + dev: true + /@types/react/18.0.26: resolution: { @@ -2755,6 +2936,26 @@ packages: csstype: 3.1.1 dev: true + /@types/react/18.0.33: + resolution: + { + integrity: sha512-sHxzVxeanvQyQ1lr8NSHaj0kDzcNiGpILEVt69g9S31/7PfMvNCKLKcsHw4lYKjs3cGNJjXSP4mYzX43QlnjNA==, + } + dependencies: + '@types/prop-types': 15.7.5 + '@types/scheduler': 0.16.2 + csstype: 3.1.1 + + /@types/rollup/0.54.0: + resolution: + { + integrity: sha512-oeYztLHhQ98jnr+u2cs1c3tHOGtpzrm9DJlIdEjznwoXWidUbrI+X6ib7zCkPIbB7eJ7VbbKNQ5n/bPnSg6Naw==, + } + deprecated: This is a stub types definition for rollup (https://github.com/rollup/rollup). rollup provides its own type definitions, so you don't need @types/rollup installed! + dependencies: + rollup: 3.14.0 + dev: false + /@types/sass/1.43.1: resolution: { @@ -2768,7 +2969,6 @@ packages: { integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==, } - dev: true /@types/semver/6.2.3: resolution: @@ -3081,6 +3281,25 @@ packages: } dev: false + /@vitejs/plugin-react/3.1.0_vite@4.1.4: + resolution: + { + integrity: sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g==, + } + engines: { node: ^14.18.0 || >=16.0.0 } + peerDependencies: + vite: ^4.1.0-beta.0 + dependencies: + '@babel/core': 7.20.7 + '@babel/plugin-transform-react-jsx-self': 7.21.0_@babel+core@7.20.7 + '@babel/plugin-transform-react-jsx-source': 7.19.6_@babel+core@7.20.7 + magic-string: 0.27.0 + react-refresh: 0.14.0 + vite: 4.1.4 + transitivePeerDependencies: + - supports-color + dev: true + /@vitest/coverage-c8/0.28.3_@vitest+ui@0.28.3: resolution: { @@ -4323,7 +4542,6 @@ packages: { integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==, } - dev: true /csv-generate/3.4.3: resolution: @@ -4933,7 +5151,6 @@ packages: integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==, } engines: { node: '>=6' } - dev: true /detect-indent/6.1.0: resolution: @@ -7106,7 +7323,7 @@ packages: } dev: true - /graphql-relay/0.10.0_graphql@15.8.0: + /graphql-relay/0.10.0_graphql@16.6.0: resolution: { integrity: sha512-44yBuw2/DLNEiMypbNZBt1yMDbBmyVPVesPywnteGGALiBmdyy1JP8jSg8ClLePg8ZZxk0O4BLhd1a6U/1jDOQ==, @@ -7115,19 +7332,7 @@ packages: peerDependencies: graphql: ^16.2.0 dependencies: - graphql: 15.8.0 - dev: false - - /graphql-ws/5.11.2_graphql@15.8.0: - resolution: - { - integrity: sha512-4EiZ3/UXYcjm+xFGP544/yW1+DVI8ZpKASFbzrV5EDTFWJp0ZvLl4Dy2fSZAzz9imKp5pZMIcjB0x/H69Pv/6w==, - } - engines: { node: '>=10' } - peerDependencies: - graphql: '>=0.11 <=16' - dependencies: - graphql: 15.8.0 + graphql: 16.6.0 dev: false /graphql-ws/5.11.2_graphql@16.6.0: @@ -7142,7 +7347,7 @@ packages: graphql: 16.6.0 dev: false - /graphql-yoga/3.4.0_l5ldqtd2ymgrkm4qlxjuv5xwgy: + /graphql-yoga/3.4.0_graphql@16.6.0: resolution: { integrity: sha512-Cjx60mmpoK1qL/sLdM285VdAOQyJBKLuC6oMZrfO8QleneNtu0nDOM6Efv5m0IrRYSONEMtIYA7eNr0u/cCBfg==, @@ -7151,19 +7356,19 @@ packages: graphql: ^15.2.0 || ^16.0.0 dependencies: '@envelop/core': 3.0.4 - '@envelop/parser-cache': 5.0.4_j6i6lclrzilzz6orexmuccsxju - '@envelop/validation-cache': 5.0.5_j6i6lclrzilzz6orexmuccsxju - '@graphql-tools/executor': 0.0.12_graphql@15.8.0 - '@graphql-tools/schema': 9.0.12_graphql@15.8.0 - '@graphql-tools/utils': 9.1.3_graphql@15.8.0 + '@envelop/parser-cache': 5.0.4_a6sekiasy2tqr6d5gj7n2wtjli + '@envelop/validation-cache': 5.0.5_a6sekiasy2tqr6d5gj7n2wtjli + '@graphql-tools/executor': 0.0.12_graphql@16.6.0 + '@graphql-tools/schema': 9.0.12_graphql@16.6.0 + '@graphql-tools/utils': 9.1.3_graphql@16.6.0 '@graphql-yoga/subscription': 3.1.0 + '@types/node': 18.11.15 '@whatwg-node/fetch': 0.6.2 '@whatwg-node/server': 0.5.8_@types+node@18.11.15 dset: 3.1.2 - graphql: 15.8.0 + graphql: 16.6.0 tslib: 2.4.1 transitivePeerDependencies: - - '@types/node' - encoding dev: false @@ -10121,7 +10326,7 @@ packages: integrity: sha512-bt48RDBy2eIwZPrkgbcwHtb51mj2nKvHOPMaSH2IsWiv7lOG9k9zhaRzpDZafrk05ajMc3cu+lSQYYOfH2DkVQ==, } engines: { node: '>=6.4.0' } - deprecated: < 19.2.0 is no longer supported + deprecated: < 19.4.0 is no longer supported requiresBuild: true dependencies: debug: 4.3.4 @@ -10226,6 +10431,14 @@ packages: } dev: false + /react-refresh/0.14.0: + resolution: + { + integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==, + } + engines: { node: '>=0.10.0' } + dev: true + /react/18.2.0: resolution: { @@ -10327,7 +10540,6 @@ packages: { integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==, } - dev: true /regexp-tree/0.1.24: resolution: @@ -12041,6 +12253,20 @@ packages: braces: 3.0.2 dev: false + /use-deep-compare-effect/1.8.1_react@18.2.0: + resolution: + { + integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==, + } + engines: { node: '>=10', npm: '>=6' } + peerDependencies: + react: '>=16.13' + dependencies: + '@babel/runtime': 7.20.7 + dequal: 2.0.3 + react: 18.2.0 + dev: false + /util-deprecate/1.0.2: resolution: { diff --git a/site/src/routes/api/codegen-plugins/+page.svx b/site/src/routes/api/codegen-plugins/+page.svx index ab8f7ce2f..9934f6a30 100644 --- a/site/src/routes/api/codegen-plugins/+page.svx +++ b/site/src/routes/api/codegen-plugins/+page.svx @@ -593,6 +593,7 @@ export default plugin('plugin_name', async () => { ### `transformRuntime` - Type: `Record string>` + or `(docs: Document[]) => Record string>` Transforms the plugin's runtime while houdini is copying it. The keys of the object are paths in your runtime (relative to the `includeRuntime` @@ -621,6 +622,30 @@ export default plugin('plugin_name', async () => { }) ``` +If you need to transform files based on the documents in your application, +you can also pass a function that returns an object. This function will +be called with the list of documents in your application. + +```typescript:title=plugin_name/src/index.ts +import { plugin } from 'houdini' + +const plugin_variable = "1234" + +export default plugin('plugin_name', async () => { + return { + // this must be set + includeRuntime: "../runtime", + + // replace value in {pluginRoot}/lib/constants.js + transformRuntime: (docs) => { + ['lib/constants.js']: ({ content }) { + return content.replace("LENGTH", docs.length) + } + } + } +}) +``` + ### `indexFile` - Type: `(args: IndexFileArgs) => string` diff --git a/tsconfig.json b/tsconfig.json index d898b4499..be58df0db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "exclude": ["node_modules"], - "include": ["packages/**/*.ts"], + "include": ["packages/**/*.ts", "packages/houdini-react/src/runtime/context.tsx"], "compilerOptions": { "moduleResolution": "node", "module": "es2020", @@ -22,6 +22,7 @@ "paths": { "$houdini": ["../houdini/src"], "$houdini/*": ["../houdini/src/*"] - } + }, + "jsx": "react" } }