Skip to content

Commit c3572e8

Browse files
committed
stop resolvers after execution ends (#4263)
depends on #4267 addresses: #3792
1 parent b0b8abe commit c3572e8

2 files changed

Lines changed: 117 additions & 0 deletions

File tree

src/execution/__tests__/nonnull-test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
66

77
import { invariant } from '../../jsutils/invariant.js';
88
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js';
9+
import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js';
910

1011
import { parse } from '../../language/parser.js';
1112

@@ -663,6 +664,111 @@ describe('Execute: handles non-nullable types', () => {
663664
}
664665
expectJSON(initialErrors).toDeepEqual(result.errors);
665666
});
667+
668+
it('suppresses a later error after a parent has been nulled', async () => {
669+
const query = `
670+
{
671+
syncNest {
672+
syncNonNull
673+
promise
674+
}
675+
}
676+
`;
677+
678+
const nonNullDeferred = promiseWithResolvers<unknown>();
679+
const promiseDeferred = promiseWithResolvers<unknown>();
680+
681+
const resultPromise = executeQuery(query, {
682+
syncNest: {
683+
syncNonNull: () => nonNullDeferred.promise,
684+
promise: () => promiseDeferred.promise,
685+
},
686+
});
687+
688+
nonNullDeferred.reject(syncNonNullError);
689+
690+
// give first error a chance to propagate
691+
await resolveOnNextTick();
692+
await resolveOnNextTick();
693+
await resolveOnNextTick();
694+
695+
promiseDeferred.reject(promiseError);
696+
697+
const result = await resultPromise;
698+
699+
expectJSON(result).toDeepEqual({
700+
data: { syncNest: null },
701+
errors: [
702+
{
703+
message: syncNonNullError.message,
704+
path: ['syncNest', 'syncNonNull'],
705+
locations: [{ line: 4, column: 13 }],
706+
},
707+
],
708+
});
709+
});
710+
});
711+
712+
describe('cancellation with null bubbling', () => {
713+
function nestedPromise(n: number): string {
714+
return n > 0 ? `promiseNest { ${nestedPromise(n - 1)} }` : 'promise';
715+
}
716+
717+
it('returns an single error without cancellation', async () => {
718+
const query = `
719+
{
720+
promiseNonNull,
721+
${nestedPromise(4)}
722+
}
723+
`;
724+
725+
const result = await executeQuery(query, throwingData);
726+
expectJSON(result).toDeepEqual({
727+
data: null,
728+
errors: [
729+
// does not include syncNullError because result returns prior to it being added
730+
{
731+
message: 'promiseNonNull',
732+
path: ['promiseNonNull'],
733+
locations: [{ line: 3, column: 11 }],
734+
},
735+
],
736+
});
737+
});
738+
739+
it('stops running despite error', async () => {
740+
const query = `
741+
{
742+
promiseNonNull,
743+
${nestedPromise(10)}
744+
}
745+
`;
746+
747+
let counter = 0;
748+
const rootValue = {
749+
...throwingData,
750+
promiseNest() {
751+
return new Promise((resolve) => {
752+
counter++;
753+
resolve(rootValue);
754+
});
755+
},
756+
};
757+
const result = await executeQuery(query, rootValue);
758+
expectJSON(result).toDeepEqual({
759+
data: null,
760+
errors: [
761+
{
762+
message: 'promiseNonNull',
763+
path: ['promiseNonNull'],
764+
locations: [{ line: 3, column: 11 }],
765+
},
766+
],
767+
});
768+
const counterAtExecutionEnd = counter;
769+
await resolveOnNextTick();
770+
expect(counter).to.equal(counterAtExecutionEnd);
771+
});
666772
});
667773

668774
describe('Handles non-null argument', () => {

src/execution/execute.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export interface ExecutionContext {
184184
validatedExecutionArgs: ValidatedExecutionArgs;
185185
collectedErrors: CollectedErrors;
186186
promiseCanceller: PromiseCanceller | undefined;
187+
completed: boolean;
187188
}
188189

189190
/**
@@ -283,6 +284,7 @@ export function executeQueryOrMutationOrSubscriptionEvent(
283284
promiseCanceller: abortSignal
284285
? new PromiseCanceller(abortSignal)
285286
: undefined,
287+
completed: false,
286288
};
287289
try {
288290
const {
@@ -358,6 +360,7 @@ function buildResponse(
358360
exeContext: ExecutionContext,
359361
data: ObjMap<unknown> | null,
360362
): ExecutionResult {
363+
exeContext.completed = true;
361364
exeContext.promiseCanceller?.disconnect();
362365
const errors = exeContext.collectedErrors.errors;
363366
return errors.length === 0 ? { data } : { errors, data };
@@ -752,6 +755,10 @@ function handleFieldError(
752755
fieldDetailsList: FieldDetailsList,
753756
path: Path,
754757
): void {
758+
if (exeContext.completed) {
759+
return;
760+
}
761+
755762
const error = locatedError(
756763
rawError,
757764
toNodes(fieldDetailsList),
@@ -1306,6 +1313,10 @@ function completeObjectValue(
13061313
path: Path,
13071314
result: unknown,
13081315
): PromiseOrValue<ObjMap<unknown>> {
1316+
if (exeContext.completed) {
1317+
throw new Error('Completed, aborting.');
1318+
}
1319+
13091320
// If there is an isTypeOf predicate function, call it with the
13101321
// current result. If isTypeOf returns false, then raise an error rather
13111322
// than continuing execution.

0 commit comments

Comments
 (0)