From 2cf22c7ccae088b107770b819d2c0019054d3c6e Mon Sep 17 00:00:00 2001 From: Alec Aivazis Date: Fri, 13 Sep 2024 11:31:28 -0700 Subject: [PATCH] Add @dedupe directive (#1352) Co-authored-by: Seppe Dekeyser --- .changeset/wild-pandas-sell.md | 7 + e2e/_api/graphql.mjs | 8 +- e2e/_api/schema.graphql | 1 + .../routes/pagination/query/dedupe/+page.gql | 15 ++ .../routes/pagination/query/dedupe/+page.tsx | 21 +++ .../routes/pagination/query/dedupe/spec.ts | 40 +++++ e2e/react/src/utils/routes.ts | 2 + .../src/runtime/hooks/useDocumentHandle.ts | 16 +- .../src/runtime/stores/pagination/query.ts | 22 ++- .../src/codegen/generators/artifacts/index.ts | 26 ++++ .../artifacts/tests/artifacts.test.ts | 147 ++++++++++++++++++ .../artifacts/tests/pagination.test.ts | 1 + .../generators/definitions/schema.test.ts | 24 +++ .../src/codegen/transforms/paginate.test.ts | 22 ++- .../src/codegen/transforms/paginate.ts | 10 ++ .../houdini/src/codegen/transforms/schema.ts | 8 + packages/houdini/src/lib/config.ts | 4 + .../src/runtime/client/documentStore.ts | 49 +++++- .../src/runtime/client/plugins/fetch.ts | 5 +- .../houdini/src/runtime/lib/pagination.ts | 35 +++-- packages/houdini/src/runtime/lib/types.ts | 6 +- site/src/routes/api/graphql-magic/+page.svx | 6 + 22 files changed, 443 insertions(+), 32 deletions(-) create mode 100644 .changeset/wild-pandas-sell.md create mode 100644 e2e/react/src/routes/pagination/query/dedupe/+page.gql create mode 100644 e2e/react/src/routes/pagination/query/dedupe/+page.tsx create mode 100644 e2e/react/src/routes/pagination/query/dedupe/spec.ts diff --git a/.changeset/wild-pandas-sell.md b/.changeset/wild-pandas-sell.md new file mode 100644 index 0000000000..93fccb5ba2 --- /dev/null +++ b/.changeset/wild-pandas-sell.md @@ -0,0 +1,7 @@ +--- +'houdini-svelte': patch +'houdini-react': patch +'houdini': patch +--- + +Add @dedupe directive diff --git a/e2e/_api/graphql.mjs b/e2e/_api/graphql.mjs index 6aadc6d1af..48d7c0b8b8 100644 --- a/e2e/_api/graphql.mjs +++ b/e2e/_api/graphql.mjs @@ -122,6 +122,7 @@ export const typeDefs = /* GraphQL */ ` before: String first: Int last: Int + delay: Int snapshot: String! ): UserConnection! usersList(limit: Int = 4, offset: Int, snapshot: String!): [User!]! @@ -450,7 +451,12 @@ export const resolvers = { } throw new GraphQLError('No authorization found', { code: 403 }) }, - usersConnection(_, args) { + usersConnection: async (_, args) => { + // simulate network delay + if (args.delay) { + await sleep(args.delay) + } + return connectionFromArray(getUserSnapshot(args.snapshot), args) }, user: async (_, args) => { diff --git a/e2e/_api/schema.graphql b/e2e/_api/schema.graphql index 21bc432e95..67a5974c3c 100644 --- a/e2e/_api/schema.graphql +++ b/e2e/_api/schema.graphql @@ -110,6 +110,7 @@ type Query { before: String first: Int last: Int + delay: Int snapshot: String! ): UserConnection! usersList(limit: Int = 4, offset: Int, snapshot: String!): [User!]! diff --git a/e2e/react/src/routes/pagination/query/dedupe/+page.gql b/e2e/react/src/routes/pagination/query/dedupe/+page.gql new file mode 100644 index 0000000000..a34c095b03 --- /dev/null +++ b/e2e/react/src/routes/pagination/query/dedupe/+page.gql @@ -0,0 +1,15 @@ +query DedupePaginationFetch { + usersConnection(first: 2, delay: 1000, snapshot: "dedupe-pagination-fetch") @paginate { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + node { + name + } + } + } +} diff --git a/e2e/react/src/routes/pagination/query/dedupe/+page.tsx b/e2e/react/src/routes/pagination/query/dedupe/+page.tsx new file mode 100644 index 0000000000..98b7f89807 --- /dev/null +++ b/e2e/react/src/routes/pagination/query/dedupe/+page.tsx @@ -0,0 +1,21 @@ +import { PageProps } from './$types' + +export default function ({ DedupePaginationFetch, DedupePaginationFetch$handle }: PageProps) { + return ( +
+
+ {DedupePaginationFetch.usersConnection.edges + .map(({ node }) => node?.name) + .join(', ')} +
+ +
+ {JSON.stringify(DedupePaginationFetch.usersConnection.pageInfo)} +
+ + +
+ ) +} diff --git a/e2e/react/src/routes/pagination/query/dedupe/spec.ts b/e2e/react/src/routes/pagination/query/dedupe/spec.ts new file mode 100644 index 0000000000..a4f1c5c996 --- /dev/null +++ b/e2e/react/src/routes/pagination/query/dedupe/spec.ts @@ -0,0 +1,40 @@ +import test, { expect, type Response } from '@playwright/test' +import { routes } from '~/utils/routes' +import { expect_1_gql, expect_to_be, goto } from '~/utils/testsHelper' + +test('pagination before previous request was finished', async ({ page }) => { + await goto(page, routes.pagination_dedupe) + + await expect_to_be(page, 'Bruce Willis, Samuel Jackson') + + // Adapted from `expect_n_gql` in lib/utils/testsHelper.ts + let nbResponses = 0 + async function fnRes(response: Response) { + if (response.url().endsWith(routes.api)) { + nbResponses++ + } + } + + page.on('response', fnRes) + + // Click the "next page" button twice + await page.click('button[id=next]') + await page.click('button[id=next]') + + // Give the query some time to execute + await page.waitForTimeout(1000) + + // Check that only one gql request happened. + expect(nbResponses).toBe(1) + + page.removeListener('response', fnRes) + + await expect_to_be(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks') + + // Fetching the 3rd page still works ok. + await expect_1_gql(page, 'button[id=next]') + await expect_to_be( + page, + 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford' + ) +}) diff --git a/e2e/react/src/utils/routes.ts b/e2e/react/src/utils/routes.ts index 7d31ca0c97..e1c114eec8 100644 --- a/e2e/react/src/utils/routes.ts +++ b/e2e/react/src/utils/routes.ts @@ -1,4 +1,5 @@ export const routes = { + api: '/_api', hello: '/hello-world', scalars: '/scalars', componentFields_simple: '/component_fields/simple', @@ -10,6 +11,7 @@ export const routes = { pagination_query_forwards: '/pagination/query/connection-forwards', pagination_query_bidirectional: '/pagination/query/connection-bidirectional', pagination_query_offset: '/pagination/query/offset', + pagination_dedupe: '/pagination/query/dedupe', pagination_query_offset_singlepage: '/pagination/query/offset-singlepage', pagination_query_offset_variable: '/pagination/query/offset-variable/2', optimistic_keys: '/optimistic-keys', diff --git a/packages/houdini-react/src/runtime/hooks/useDocumentHandle.ts b/packages/houdini-react/src/runtime/hooks/useDocumentHandle.ts index 049b8fc155..5a9684dc7b 100644 --- a/packages/houdini-react/src/runtime/hooks/useDocumentHandle.ts +++ b/packages/houdini-react/src/runtime/hooks/useDocumentHandle.ts @@ -56,9 +56,21 @@ export function useDocumentHandle< ) => { return async (value: any) => { setLoading(true) - const result = await fn(value) + let result: _Result | null = null + let err: Error | null = null + try { + result = await fn(value) + } catch (e) { + err = e as Error + } setLoading(false) - return result + // ignore abort errors when loading pages + if (err && err.name !== 'AbortError') { + throw err + } + + // we're done + return result || observer.state } } diff --git a/packages/houdini-svelte/src/runtime/stores/pagination/query.ts b/packages/houdini-svelte/src/runtime/stores/pagination/query.ts index 06b85b25b9..2c600a31d1 100644 --- a/packages/houdini-svelte/src/runtime/stores/pagination/query.ts +++ b/packages/houdini-svelte/src/runtime/stores/pagination/query.ts @@ -88,12 +88,30 @@ export class QueryStoreCursor< args?: Parameters>['loadPreviousPage']>[0] ) { const handlers = await this.#handlers() - return await handlers.loadPreviousPage(args) + try { + return await handlers.loadPreviousPage(args) + } catch (e) { + const err = e as Error + // if the error is an abort error then we don't want to throw + if (err.name === 'AbortError') { + } else { + throw err + } + } } async loadNextPage(args?: Parameters['loadNextPage']>[0]) { const handlers = await this.#handlers() - return await handlers.loadNextPage(args) + try { + return await handlers.loadNextPage(args) + } catch (e) { + const err = e as Error + // if the error is an abort error then we don't want to throw + if (err.name === 'AbortError') { + } else { + throw err + } + } } subscribe( diff --git a/packages/houdini/src/codegen/generators/artifacts/index.ts b/packages/houdini/src/codegen/generators/artifacts/index.ts index 2d2af28731..17ee695a8b 100644 --- a/packages/houdini/src/codegen/generators/artifacts/index.ts +++ b/packages/houdini/src/codegen/generators/artifacts/index.ts @@ -187,6 +187,9 @@ export default function artifactGenerator(stats: { let selectionSet: graphql.SelectionSetNode let originalSelectionSet: graphql.SelectionSetNode | null = null + // extract the deduplication behavior + let dedupe: QueryArtifact['dedupe'] + const fragmentDefinitions = doc.document.definitions .filter( (definition): definition is graphql.FragmentDefinitionNode => @@ -222,6 +225,22 @@ export default function artifactGenerator(stats: { }) } + const dedupeDirective = operation.directives?.find( + (directive) => directive.name.value === config.dedupeDirective + ) + if (dedupeDirective) { + const cancelFirstArg = dedupeDirective.arguments?.find( + (arg) => arg.name.value === 'cancelFirst' + ) + + dedupe = + cancelFirstArg && + cancelFirstArg.value.kind === 'BooleanValue' && + cancelFirstArg.value + ? 'first' + : 'last' + } + // use this selection set selectionSet = operation.selectionSet if (originalParsed.definitions[0].kind === 'OperationDefinition') { @@ -320,6 +339,13 @@ export default function artifactGenerator(stats: { const hash_value = hashPluginBaseRaw({ config, document: { ...doc, artifact } }) artifact.hash = hash_value + if ( + artifact.kind === 'HoudiniQuery' || + (artifact.kind === 'HoudiniMutation' && dedupe) + ) { + artifact.dedupe = dedupe + } + // apply the visibility mask to the artifact so that only // fields in the direct selection are visible applyMask( diff --git a/packages/houdini/src/codegen/generators/artifacts/tests/artifacts.test.ts b/packages/houdini/src/codegen/generators/artifacts/tests/artifacts.test.ts index 2ad663daf3..66006179d6 100644 --- a/packages/houdini/src/codegen/generators/artifacts/tests/artifacts.test.ts +++ b/packages/houdini/src/codegen/generators/artifacts/tests/artifacts.test.ts @@ -846,6 +846,7 @@ test('paginate over unions', async function () { }, "pluginData": {}, + "dedupe": "last", "input": { "fields": { @@ -4931,6 +4932,7 @@ describe('mutation artifacts', function () { }, "pluginData": {}, + "dedupe": "last", "input": { "fields": { @@ -5183,6 +5185,7 @@ describe('mutation artifacts', function () { }, "pluginData": {}, + "dedupe": "last", "input": { "fields": { @@ -7707,3 +7710,147 @@ describe('default arguments', function () { `) }) }) + +test('persists dedupe which', async function () { + // the config to use in tests + const config = testConfig() + // the documents to test + const docs: Document[] = [ + mockCollectedDoc(` + query FindUser @dedupe{ + usersByOffset { + name + } + } + `), + ] + + // execute the generator + await runPipeline(config, docs) + + // load the contents of the file + expect(docs[0]).toMatchInlineSnapshot(` + export default { + "name": "FindUser", + "kind": "HoudiniQuery", + "hash": "63be02f78e12d6dd155da0aac94892e700a5be1eeb66dfc2305740ce2464dd3b", + + "raw": \`query FindUser { + usersByOffset { + name + id + } + } + \`, + + "rootType": "Query", + "stripVariables": [], + + "selection": { + "fields": { + "usersByOffset": { + "type": "User", + "keyRaw": "usersByOffset", + + "selection": { + "fields": { + "name": { + "type": "String", + "keyRaw": "name", + "visible": true + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true + } + } + }, + + "visible": true + } + } + }, + + "pluginData": {}, + "dedupe": "last", + "policy": "CacheOrNetwork", + "partial": false + }; + + "HoudiniHash=752d5f5b068733a0ab1039b96b5f9d13a45a872329bca86998b1971c4ce0816b"; + `) +}) + +test('persists dedupe first', async function () { + // the config to use in tests + const config = testConfig() + // the documents to test + const docs: Document[] = [ + mockCollectedDoc(` + query FindUser @dedupe(cancelFirst: true) { + usersByOffset { + name + } + } + `), + ] + + // execute the generator + await runPipeline(config, docs) + + // load the contents of the file + expect(docs[0]).toMatchInlineSnapshot(` + export default { + "name": "FindUser", + "kind": "HoudiniQuery", + "hash": "63be02f78e12d6dd155da0aac94892e700a5be1eeb66dfc2305740ce2464dd3b", + + "raw": \`query FindUser { + usersByOffset { + name + id + } + } + \`, + + "rootType": "Query", + "stripVariables": [], + + "selection": { + "fields": { + "usersByOffset": { + "type": "User", + "keyRaw": "usersByOffset", + + "selection": { + "fields": { + "name": { + "type": "String", + "keyRaw": "name", + "visible": true + }, + + "id": { + "type": "ID", + "keyRaw": "id", + "visible": true + } + } + }, + + "visible": true + } + } + }, + + "pluginData": {}, + "dedupe": "first", + "policy": "CacheOrNetwork", + "partial": false + }; + + "HoudiniHash=3dfb64916aa4359cf85f08b3544bbc7382fd818935c5a0e92f324a2d2519c227"; + `) +}) diff --git a/packages/houdini/src/codegen/generators/artifacts/tests/pagination.test.ts b/packages/houdini/src/codegen/generators/artifacts/tests/pagination.test.ts index 067c95156b..5533d29941 100644 --- a/packages/houdini/src/codegen/generators/artifacts/tests/pagination.test.ts +++ b/packages/houdini/src/codegen/generators/artifacts/tests/pagination.test.ts @@ -781,6 +781,7 @@ test('cursor as scalar gets the right pagination query argument types', async fu }, "pluginData": {}, + "dedupe": "last", "input": { "fields": { diff --git a/packages/houdini/src/codegen/generators/definitions/schema.test.ts b/packages/houdini/src/codegen/generators/definitions/schema.test.ts index 528c219dc1..d2a2242ada 100644 --- a/packages/houdini/src/codegen/generators/definitions/schema.test.ts +++ b/packages/houdini/src/codegen/generators/definitions/schema.test.ts @@ -36,6 +36,12 @@ test('adds internal documents to schema', async function () { """@prepend is used to tell the runtime to add the result to the end of the list""" directive @prepend on FRAGMENT_SPREAD + """ + @dedupe is used to prevent an operation from running more than once at the same time. + If the cancelFirst arg is set to true, the response already in flight will be canceled instead of the second one. + """ + directive @dedupe(cancelFirst: Boolean) on QUERY | MUTATION + """@optimisticKey is used to identify a field as an optimistic key""" directive @optimisticKey on FIELD @@ -128,6 +134,12 @@ test('list operations are included', async function () { """@prepend is used to tell the runtime to add the result to the end of the list""" directive @prepend on FRAGMENT_SPREAD + """ + @dedupe is used to prevent an operation from running more than once at the same time. + If the cancelFirst arg is set to true, the response already in flight will be canceled instead of the second one. + """ + directive @dedupe(cancelFirst: Boolean) on QUERY | MUTATION + """@optimisticKey is used to identify a field as an optimistic key""" directive @optimisticKey on FIELD @@ -239,6 +251,12 @@ test('list operations are included but delete directive should not be in when we """@prepend is used to tell the runtime to add the result to the end of the list""" directive @prepend on FRAGMENT_SPREAD + """ + @dedupe is used to prevent an operation from running more than once at the same time. + If the cancelFirst arg is set to true, the response already in flight will be canceled instead of the second one. + """ + directive @dedupe(cancelFirst: Boolean) on QUERY | MUTATION + """@optimisticKey is used to identify a field as an optimistic key""" directive @optimisticKey on FIELD @@ -363,6 +381,12 @@ test("writing twice doesn't duplicate definitions", async function () { """@prepend is used to tell the runtime to add the result to the end of the list""" directive @prepend on FRAGMENT_SPREAD + """ + @dedupe is used to prevent an operation from running more than once at the same time. + If the cancelFirst arg is set to true, the response already in flight will be canceled instead of the second one. + """ + directive @dedupe(cancelFirst: Boolean) on QUERY | MUTATION + """@optimisticKey is used to identify a field as an optimistic key""" directive @optimisticKey on FIELD diff --git a/packages/houdini/src/codegen/transforms/paginate.test.ts b/packages/houdini/src/codegen/transforms/paginate.test.ts index c257f7149f..4095b16f8a 100644 --- a/packages/houdini/src/codegen/transforms/paginate.test.ts +++ b/packages/houdini/src/codegen/transforms/paginate.test.ts @@ -1061,7 +1061,7 @@ test('query with forwards cursor paginate', async function () { // load the contents of the file expect(docs[0]?.document).toMatchInlineSnapshot(` - query Users($first: Int = 10, $after: String) { + query Users($first: Int = 10, $after: String) @dedupe { usersByForwardsCursor(first: $first, after: $after) @paginate { edges { node { @@ -1108,7 +1108,7 @@ test('query with custom first args', async function () { // load the contents of the file expect(docs[0]?.document).toMatchInlineSnapshot(` - query Users($limit: Int!, $after: String) { + query Users($limit: Int!, $after: String) @dedupe { usersByForwardsCursor(first: $limit, after: $after) @paginate { edges { node { @@ -1155,7 +1155,7 @@ test('query with backwards cursor paginate', async function () { // load the contents of the file expect(docs[0]?.document).toMatchInlineSnapshot(` - query Users { + query Users @dedupe { usersByBackwardsCursor(last: 10) @paginate { edges { node { @@ -1198,12 +1198,11 @@ test('query with offset paginate', async function () { // load the contents of the file expect(docs[0]?.document).toMatchInlineSnapshot(` - query Users($limit: Int = 10, $offset: Int) { + query Users($limit: Int = 10, $offset: Int) @dedupe { usersByOffset(limit: $limit, offset: $offset) @paginate { id } } - `) }) @@ -1230,7 +1229,7 @@ test('query with backwards cursor on full paginate', async function () { // load the contents of the file expect(docs[0]?.document).toMatchInlineSnapshot(` - query Users($first: Int, $after: String, $last: Int = 10, $before: String) { + query Users($first: Int, $after: String, $last: Int = 10, $before: String) @dedupe { usersByCursor(last: $last, first: $first, after: $after, before: $before) @paginate { edges { node { @@ -1277,7 +1276,7 @@ test('query with forwards cursor on full paginate', async function () { // load the contents of the file expect(docs[0]?.document).toMatchInlineSnapshot(` - query Users($first: Int = 10, $after: String, $last: Int, $before: String) { + query Users($first: Int = 10, $after: String, $last: Int, $before: String) @dedupe { usersByCursor(first: $first, after: $after, last: $last, before: $before) @paginate { edges { node { @@ -1324,7 +1323,7 @@ test("don't generate unsupported directions", async function () { // load the contents of the file expect(docs[0]?.document).toMatchInlineSnapshot(` - query Users($first: Int = 10, $after: String) { + query Users($first: Int = 10, $after: String) @dedupe { usersByForwardsCursor(first: $first, after: $after) @paginate { edges { node { @@ -1371,7 +1370,7 @@ test("forwards cursor paginated query doesn't overlap variables", async function // load the contents of the file expect(docs[0]?.document).toMatchInlineSnapshot(` - query Users($first: Int!, $after: String, $last: Int, $before: String) { + query Users($first: Int!, $after: String, $last: Int, $before: String) @dedupe { usersByCursor(first: $first, after: $after, last: $last, before: $before) @paginate { edges { node { @@ -1418,7 +1417,7 @@ test("backwards cursor paginated query doesn't overlap variables", async functio // load the contents of the file expect(docs[0]?.document).toMatchInlineSnapshot(` - query Users($last: Int!, $first: Int, $after: String, $before: String) { + query Users($last: Int!, $first: Int, $after: String, $before: String) @dedupe { usersByCursor(last: $last, first: $first, after: $after, before: $before) @paginate { edges { node { @@ -1461,12 +1460,11 @@ test("offset paginated query doesn't overlap variables", async function () { // load the contents of the file expect(docs[0]?.document).toMatchInlineSnapshot(` - query Users($limit: Int! = 10, $offset: Int) { + query Users($limit: Int! = 10, $offset: Int) @dedupe { usersByOffset(limit: $limit, offset: $offset) @paginate { id } } - `) }) diff --git a/packages/houdini/src/codegen/transforms/paginate.ts b/packages/houdini/src/codegen/transforms/paginate.ts index eb2e730906..c73f6018eb 100644 --- a/packages/houdini/src/codegen/transforms/paginate.ts +++ b/packages/houdini/src/codegen/transforms/paginate.ts @@ -225,6 +225,16 @@ export default async function paginate(config: Config, documents: Document[]): P return { ...node, variableDefinitions: finalVariables, + directives: [ + ...(node.directives || []), + { + kind: graphql.Kind.DIRECTIVE, + name: { + kind: graphql.Kind.NAME, + value: config.dedupeDirective, + }, + }, + ], } as graphql.OperationDefinitionNode }, // if we are dealing with a fragment definition we'll need to add the arguments directive if it doesn't exist diff --git a/packages/houdini/src/codegen/transforms/schema.ts b/packages/houdini/src/codegen/transforms/schema.ts index 369cc5cbc5..6ee2b32f37 100644 --- a/packages/houdini/src/codegen/transforms/schema.ts +++ b/packages/houdini/src/codegen/transforms/schema.ts @@ -52,6 +52,14 @@ directive @${config.paginateDirective}(${config.listOrPaginateNameArg}: String, """ directive @${config.listPrependDirective} on FRAGMENT_SPREAD +""" + @${ + config.dedupeDirective + } is used to prevent an operation from running more than once at the same time. + If the cancelFirst arg is set to true, the response already in flight will be canceled instead of the second one. +""" +directive @${config.dedupeDirective}(cancelFirst: Boolean) on QUERY | MUTATION + """ @${config.optimisticKeyDirective} is used to identify a field as an optimistic key """ diff --git a/packages/houdini/src/lib/config.ts b/packages/houdini/src/lib/config.ts index 6dd0886f9c..c150c194a8 100644 --- a/packages/houdini/src/lib/config.ts +++ b/packages/houdini/src/lib/config.ts @@ -629,6 +629,10 @@ export class Config { return 'list' } + get dedupeDirective() { + return 'dedupe' + } + get optimisticKeyDirective() { return 'optimisticKey' } diff --git a/packages/houdini/src/runtime/client/documentStore.ts b/packages/houdini/src/runtime/client/documentStore.ts index 13d30d61ee..4414ec9de6 100644 --- a/packages/houdini/src/runtime/client/documentStore.ts +++ b/packages/houdini/src/runtime/client/documentStore.ts @@ -24,6 +24,8 @@ const steps = { backwards: ['end', 'afterNetwork'], } as const +let inflightRequests: Record = {} + export class DocumentStore< _Data extends GraphQLObject, _Input extends GraphQLVariables @@ -47,6 +49,10 @@ export class DocumentStore< serverSideFallback?: boolean + controllerKey(variables: any) { + return this.artifact.name + } + constructor({ artifact, plugins, @@ -131,9 +137,34 @@ export class DocumentStore< cacheParams, setup = false, silenceEcho = false, + abortController = new AbortController(), }: SendParams = {}) { + // if the document we are sending is meant to be deduped, then we need to look for an existing + // controller for the document + if ('dedupe' in this.artifact) { + // if there is already a pending request + if (inflightRequests[this.controllerKey(variables)]) { + // we have to abort _something_ + if (this.artifact.dedupe === 'first') { + // cancel the existing one + inflightRequests[this.controllerKey(variables)].abort() + // and register the new one + inflightRequests[this.controllerKey(variables)] = abortController + } + // otherwise we have to abort this one + else { + abortController.abort() + } + } + // register this abort controller as being in flight + else { + inflightRequests[this.controllerKey(variables)] = abortController + } + } + // start off with the initial context let context = new ClientPluginContextWrapper({ + abortController, config: this.#configFile!, name: this.artifact.name, text: this.artifact.raw, @@ -187,7 +218,14 @@ export class DocumentStore< this.#step('forward', state) }) - return await promise + // fire off the chain + const response = await promise + + // after the whole plugin chain, we need to clean up the in flight tracking + delete inflightRequests[this.controllerKey(variables)] + + // we're done + return response } async cleanup() { @@ -358,6 +396,13 @@ export class DocumentStore< } try { + // if the abort controller has been triggered, dont go further + if (draft.abortController.signal.aborted) { + const abortError = new Error('aborted') + abortError.name = 'AbortError' + throw abortError + } + // @ts-expect-error // invoke the target with the correct handlers const result = target(draft, handlers) @@ -635,6 +680,7 @@ export type ClientPluginContext = { metadata?: App.Metadata | null session?: App.Session | null fetchParams?: RequestInit + abortController: AbortController cacheParams?: { layer?: Layer notifySubscribers?: SubscriptionSpec[] @@ -696,4 +742,5 @@ export type SendParams = { cacheParams?: ClientPluginContext['cacheParams'] setup?: boolean silenceEcho?: boolean + abortController?: AbortController } diff --git a/packages/houdini/src/runtime/client/plugins/fetch.ts b/packages/houdini/src/runtime/client/plugins/fetch.ts index ceaeb7dcd3..e1b92ab341 100644 --- a/packages/houdini/src/runtime/client/plugins/fetch.ts +++ b/packages/houdini/src/runtime/client/plugins/fetch.ts @@ -45,7 +45,10 @@ export const fetch = (target?: RequestHandler | string): ClientPlugin => { // figure out if we need to do something special for multipart uploads const newArgs = handleMultipart(fetchParams, args) ?? args // use the new args if they exist, otherwise the old ones are good - return fetch(url, newArgs) + return fetch(url, { + ...newArgs, + signal: ctx.abortController.signal, + }) }, metadata: ctx.metadata, session: ctx.session || {}, diff --git a/packages/houdini/src/runtime/lib/pagination.ts b/packages/houdini/src/runtime/lib/pagination.ts index 2c9e0938ce..107b63f96f 100644 --- a/packages/houdini/src/runtime/lib/pagination.ts +++ b/packages/houdini/src/runtime/lib/pagination.ts @@ -1,8 +1,7 @@ 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 { CachePolicy, DataSource } from './types' import type { CursorHandlers, FetchFn, @@ -44,8 +43,6 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Graph fetch?: typeof globalThis.fetch where: 'start' | 'end' }) => { - const config = getCurrentConfig() - // build up the variables to pass to the query const loadVariables: _Input = { ...getVariables(), @@ -61,7 +58,7 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Graph let isSinglePage = artifact.refetch?.mode === 'SinglePage' // send the query - await (isSinglePage ? parentFetch : parentFetchUpdate)( + return (isSinglePage ? parentFetch : parentFetchUpdate)( { variables: loadVariables, fetch, @@ -78,7 +75,7 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Graph } return { - loadNextPage: async ({ + loadNextPage: ({ first, after, fetch, @@ -93,7 +90,15 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Graph const currentPageInfo = getPageInfo() // if there is no next page, we're done if (!currentPageInfo.hasNextPage) { - return + return Promise.resolve({ + data: getState(), + errors: null, + fetching: false, + partial: false, + stale: false, + source: DataSource.Cache, + variables: getVariables(), + }) } // only specify the page count if we're given one @@ -105,7 +110,7 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Graph } // load the page - return await loadPage({ + return loadPage({ pageSizeVar: 'first', functionName: 'loadNextPage', input, @@ -114,7 +119,7 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Graph where: 'end', }) }, - loadPreviousPage: async ({ + loadPreviousPage: ({ last, before, fetch, @@ -130,7 +135,15 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Graph // if there is no next page, we're done if (!currentPageInfo.hasPreviousPage) { - return + return Promise.resolve({ + data: getState(), + errors: null, + fetching: false, + partial: false, + stale: false, + source: DataSource.Cache, + variables: getVariables(), + }) } // only specify the page count if we're given one @@ -142,7 +155,7 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input extends Graph } // load the page - return await loadPage({ + return loadPage({ pageSizeVar: 'last', functionName: 'loadPreviousPage', input, diff --git a/packages/houdini/src/runtime/lib/types.ts b/packages/houdini/src/runtime/lib/types.ts index f7b831db3b..d4ced0ccf0 100644 --- a/packages/houdini/src/runtime/lib/types.ts +++ b/packages/houdini/src/runtime/lib/types.ts @@ -77,10 +77,12 @@ export type QueryArtifact = BaseCompiledDocument<'HoudiniQuery'> & { policy?: CachePolicies partial?: boolean enableLoadingState?: 'global' | 'local' + dedupe?: 'first' | 'last' } export type MutationArtifact = BaseCompiledDocument<'HoudiniMutation'> & { optimisticKeys?: boolean + dedupe?: 'first' | 'last' } export type FragmentArtifact = BaseCompiledDocument<'HoudiniFragment'> & { @@ -313,13 +315,13 @@ export type CursorHandlers<_Data extends GraphQLObject, _Input> = { after?: string fetch?: typeof globalThis.fetch metadata?: {} - }) => Promise + }) => Promise> loadPreviousPage: (args?: { last?: number before?: string fetch?: typeof globalThis.fetch metadata?: {} - }) => Promise + }) => Promise> fetch(args?: FetchParams<_Input> | undefined): Promise> } diff --git a/site/src/routes/api/graphql-magic/+page.svx b/site/src/routes/api/graphql-magic/+page.svx index f76938b49a..cdcbf34342 100644 --- a/site/src/routes/api/graphql-magic/+page.svx +++ b/site/src/routes/api/graphql-magic/+page.svx @@ -60,6 +60,12 @@ mutation UncompleteItem($id: ID!) { } ``` +### `@dedupe(cancelFirst: Booleam)` + +`@dedupe` lets you control wether or not multiple copies of the same operation (query or mutation) are allowed to run at the same time. +If you pass `true` for the `cancelFirst` argument then the first copy of the operation will be canceled (including any in-flight requests). +If you pass `false` (or pass nothing at all) then the second request won't trigger if there is already one pending. + ### `@when_not` `@when` provides a conditional under which the [list operation](#list-operations) should not be executed. It takes arguments that match the arguments of the field tagged with `@list` or `@paginate`. For more information, check out the [mutation docs](/api/mutation#lists)