Skip to content

Commit

Permalink
abort promise and sync for error (#6009)
Browse files Browse the repository at this point in the history
* abort promise and sync for error

* add changeset

* test
  • Loading branch information
n1ru4l committed Mar 22, 2024
1 parent 8dbf2e4 commit 14a001e
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/ninety-rats-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@graphql-tools/executor": patch
---

correctly raise abort exception for Promise and sync execution
80 changes: 65 additions & 15 deletions packages/executor/src/execution/__tests__/abort-signal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ describe('Abort Signal', () => {
},
});

const result = await normalizedExecutor({
const result$ = normalizedExecutor({
schema,
document: parse(/* GraphQL */ `
query {
Expand All @@ -241,20 +241,7 @@ describe('Abort Signal', () => {
`),
signal: controller.signal,
});
expect(result).toMatchObject({
errors: [
{
message: 'Execution aborted',
path: ['counter'],
locations: [
{
line: 3,
column: 11,
},
],
},
],
});
await expect(result$).rejects.toMatchInlineSnapshot(`DOMException {}`);
expect(isAborted).toEqual(true);
});
it('stops pending stream execution for incremental delivery', async () => {
Expand Down Expand Up @@ -319,4 +306,67 @@ describe('Abort Signal', () => {
await expect(next$).rejects.toMatchInlineSnapshot(`DOMException {}`);
expect(isReturnInvoked).toEqual(true);
});
it('stops promise execution', async () => {
const controller = new AbortController();
const d = createDeferred();

const schema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
type Query {
number: Int!
}
`,
resolvers: {
Query: {
number: () => d.promise.then(() => 1),
},
},
});

const result$ = normalizedExecutor({
schema,
document: parse(/* GraphQL */ `
query {
number
}
`),
signal: controller.signal,
});

expect(result$).toBeInstanceOf(Promise);
controller.abort();
await expect(result$).rejects.toMatchInlineSnapshot(`DOMException {}`);
});
it('does not even try to execute if the signal is already aborted', async () => {
const controller = new AbortController();
let resolverGotInvoked = false;
const schema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
type Query {
number: Int!
}
`,
resolvers: {
Query: {
number: () => {
resolverGotInvoked = true;
return 1;
},
},
},
});
controller.abort();
expect(() =>
normalizedExecutor({
schema,
document: parse(/* GraphQL */ `
query {
number
}
`),
signal: controller.signal,
}),
).toThrowErrorMatchingInlineSnapshot(`"This operation was aborted"`);
expect(resolverGotInvoked).toBe(false);
});
});
30 changes: 29 additions & 1 deletion packages/executor/src/execution/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,10 @@ export function execute<TData = any, TVariables = any, TContext = any>(
function executeImpl<TData = any, TVariables = any, TContext = any>(
exeContext: ExecutionContext<TVariables, TContext>,
): MaybePromise<SingularExecutionResult<TData> | IncrementalExecutionResults<TData>> {
if (exeContext.signal?.aborted) {
throw exeContext.signal.reason;
}

// Return a Promise that will eventually resolve to the data described by
// The "Response" section of the GraphQL specification.
//
Expand All @@ -297,7 +301,7 @@ function executeImpl<TData = any, TVariables = any, TContext = any>(
// Errors from sub-fields of a NonNull type may propagate to the top level,
// at which point we still log the error and null the parent field, which
// in this case is the entire response.
return new ValueOrPromise(() => executeOperation<TData, TVariables, TContext>(exeContext))
const result = new ValueOrPromise(() => executeOperation<TData, TVariables, TContext>(exeContext))
.then(
data => {
const initialResult = buildResponse(data, exeContext.errors);
Expand All @@ -318,6 +322,30 @@ function executeImpl<TData = any, TVariables = any, TContext = any>(
},
)
.resolve()!;

if (!exeContext.signal || 'initialResult' in result || 'then' in result === false) {
return result;
}

let resolve: () => void;
let reject: (reason: any) => void;
const abortP = new Promise<never>((_resolve, _reject) => {
resolve = _resolve as any;
reject = _reject;
});

function abortListener() {
reject(exeContext.signal?.reason);
}

exeContext.signal.addEventListener('abort', abortListener);

result.then(() => {
exeContext.signal?.removeEventListener('abort', abortListener);
resolve();
});

return Promise.race([abortP, result]);
}

/**
Expand Down

0 comments on commit 14a001e

Please sign in to comment.