From f39fefbf1dbe6f87f98a37b84833e58ac0d94d88 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 3 Jun 2025 15:11:12 -0700 Subject: [PATCH 1/3] test: actually test use of AbortSignal in resolver The previous test didn't really make use of the fact that the AbortSignal could be used in the resolver: the only use it made of it was to call `signal.throwIfAborted` *after* the cancellable promise was already cancelled. The "This operation was aborted" message that shows up in the GraphQL response actually came from the cancellable promise, not the throwIfAborted call. You can see that because if you just replace `throwIfAborted` with throwing another error (as this commit does), the test still passed. Instead, actually make use of the AbortSignal API to observe the abort explicitly. --- src/execution/__tests__/cancellation-test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/execution/__tests__/cancellation-test.ts b/src/execution/__tests__/cancellation-test.ts index 3c2f41553f..277cadc443 100644 --- a/src/execution/__tests__/cancellation-test.ts +++ b/src/execution/__tests__/cancellation-test.ts @@ -130,9 +130,19 @@ describe('Execute: Cancellation', () => { } `); + let aborted = false; const cancellableAsyncFn = async (abortSignal: AbortSignal) => { + if (abortSignal.aborted) { + aborted = true; + } else { + abortSignal.addEventListener('abort', () => { + aborted = true; + }); + } + // We are in an async function so it gets cancelled and the field ends up + // resolving with the abort signal's error. await resolveOnNextTick(); - abortSignal.throwIfAborted(); + throw Error('some random other error that does not show up in response'); }; const resultPromise = execute({ @@ -165,6 +175,8 @@ describe('Execute: Cancellation', () => { }, ], }); + + expect(aborted).to.equal(true); }); it('should stop the execution when aborted during object field completion with a custom error', async () => { From 6d97f256abcfcc685850168add9d55d8129325cc Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 3 Jun 2025 15:14:56 -0700 Subject: [PATCH 2/3] feat: pass abortSignal to resolvers via GraphQLResolveInfo In #4261 (not yet released in v17) we made abortSignal available to resolvers via a fifth argument to the field resolver. Among other things, this means that any code that processes schemas to wrap resolvers in other functions would have to be aware of this one new feature and specially thread through the new behavior. It also changed the TypeScript signature of GraphQLFieldResolver to *require* passing the fifth argument (even if undefined). But the field resolver interface already has a place for GraphQL-JS to put a grab-bag of helpful named objects for use by resolvers: `GraphQLResolveInfo`. This PR (which is not backwards compatible with v17.0.0-alpha.8, but is backwards-compatible with v16) moves the abortSignal into `GraphQLResolveInfo`. --- src/execution/__tests__/cancellation-test.ts | 4 ++-- src/execution/__tests__/executor-test.ts | 1 + src/execution/execute.ts | 12 ++++++++---- src/type/definition.ts | 2 +- website/pages/upgrade-guides/v16-v17.mdx | 2 +- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/execution/__tests__/cancellation-test.ts b/src/execution/__tests__/cancellation-test.ts index 277cadc443..19a6dc35f8 100644 --- a/src/execution/__tests__/cancellation-test.ts +++ b/src/execution/__tests__/cancellation-test.ts @@ -151,8 +151,8 @@ describe('Execute: Cancellation', () => { abortSignal: abortController.signal, rootValue: { todo: { - id: (_args: any, _context: any, _info: any, signal: AbortSignal) => - cancellableAsyncFn(signal), + id: (_args: any, _context: any, info: { abortSignal: AbortSignal }) => + cancellableAsyncFn(info.abortSignal), }, }, }); diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index 05e1c293f9..ebd696f48e 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -213,6 +213,7 @@ describe('Execute: Handles basic execution tasks', () => { executeSync({ schema, document, rootValue, variableValues }); expect(resolvedInfo).to.have.all.keys( + 'abortSignal', 'fieldName', 'fieldNodes', 'returnType', diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 70edfeb8a8..bcf21e9737 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -875,6 +875,7 @@ function executeField( toNodes(fieldDetailsList), parentType, path, + abortSignal, ); // Get the resolve function, regardless of if its result is normal or abrupt (error). @@ -893,7 +894,7 @@ function executeField( // The resolve function's optional third argument is a context value that // is provided to every resolve function within an execution. It is commonly // used to represent an authenticated user, or request-specific caches. - const result = resolveFn(source, args, contextValue, info, abortSignal); + const result = resolveFn(source, args, contextValue, info); if (isPromise(result)) { return completePromisedValue( @@ -960,6 +961,7 @@ export function buildResolveInfo( fieldNodes: ReadonlyArray, parentType: GraphQLObjectType, path: Path, + abortSignal: AbortSignal | undefined, ): GraphQLResolveInfo { const { schema, fragmentDefinitions, rootValue, operation, variableValues } = validatedExecutionArgs; @@ -976,6 +978,7 @@ export function buildResolveInfo( rootValue, operation, variableValues, + abortSignal, }; } @@ -2079,12 +2082,12 @@ export const defaultTypeResolver: GraphQLTypeResolver = * of calling that function while passing along args and context value. */ export const defaultFieldResolver: GraphQLFieldResolver = - function (source: any, args, contextValue, info, abortSignal) { + function (source: any, args, contextValue, info) { // ensure source is a value for which property access is acceptable. if (isObjectLike(source) || typeof source === 'function') { const property = source[info.fieldName]; if (typeof property === 'function') { - return source[info.fieldName](args, contextValue, info, abortSignal); + return source[info.fieldName](args, contextValue, info); } return property; } @@ -2293,6 +2296,7 @@ function executeSubscription( fieldNodes, rootType, path, + abortSignal, ); try { @@ -2317,7 +2321,7 @@ function executeSubscription( // The resolve function's optional third argument is a context value that // is provided to every resolve function within an execution. It is commonly // used to represent an authenticated user, or request-specific caches. - const result = resolveFn(rootValue, args, contextValue, info, abortSignal); + const result = resolveFn(rootValue, args, contextValue, info); if (isPromise(result)) { const abortSignalListener = abortSignal diff --git a/src/type/definition.ts b/src/type/definition.ts index ea96be5153..4cda4de25a 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -997,7 +997,6 @@ export type GraphQLFieldResolver< args: TArgs, context: TContext, info: GraphQLResolveInfo, - abortSignal: AbortSignal | undefined, ) => TResult; export interface GraphQLResolveInfo { @@ -1011,6 +1010,7 @@ export interface GraphQLResolveInfo { readonly rootValue: unknown; readonly operation: OperationDefinitionNode; readonly variableValues: VariableValues; + readonly abortSignal: AbortSignal | undefined; } /** diff --git a/website/pages/upgrade-guides/v16-v17.mdx b/website/pages/upgrade-guides/v16-v17.mdx index 00b8a27343..df97e69606 100644 --- a/website/pages/upgrade-guides/v16-v17.mdx +++ b/website/pages/upgrade-guides/v16-v17.mdx @@ -178,7 +178,7 @@ Use the `validateInputValue` helper to retrieve the actual errors. - Added `hideSuggestions` option to `execute`/`validate`/`subscribe`/... to hide schema-suggestions in error messages - Added `abortSignal` option to `graphql()`, `execute()`, and `subscribe()` allows cancellation of these methods; - the `abortSignal` can also be passed to field resolvers to cancel asynchronous work that they initiate. + `info.abortSignal` can also be used in field resolvers to cancel asynchronous work that they initiate. - `extensions` support `symbol` keys, in addition to the normal string keys. - Added ability for resolver functions to return async iterables. - Added `perEventExecutor` execution option to allows specifying a custom executor for subscription source stream events, which can be useful for preparing a per event execution context argument. From aaf0b13e9137eb52ca698840ea41cdd8baafa30d Mon Sep 17 00:00:00 2001 From: David Glasser Date: Wed, 4 Jun 2025 07:17:23 -0700 Subject: [PATCH 3/3] Make ordering in test more consistent --- src/execution/__tests__/executor-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index ebd696f48e..e5d5eedee7 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -213,7 +213,6 @@ describe('Execute: Handles basic execution tasks', () => { executeSync({ schema, document, rootValue, variableValues }); expect(resolvedInfo).to.have.all.keys( - 'abortSignal', 'fieldName', 'fieldNodes', 'returnType', @@ -224,6 +223,7 @@ describe('Execute: Handles basic execution tasks', () => { 'rootValue', 'operation', 'variableValues', + 'abortSignal', ); const operation = document.definitions[0];