Skip to content

Commit 71a94a7

Browse files
committed
Skip deferred fragments in collectFields only when same label is used
1 parent 71606d7 commit 71a94a7

3 files changed

Lines changed: 146 additions & 16 deletions

File tree

src/execution/__tests__/collectFields-test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,26 @@ describe('collectFields', () => {
106106

107107
expect(fieldDetailsList).to.have.lengthOf(1);
108108
});
109+
110+
it('prevents infinite loop when deferred fragment is used with same label', () => {
111+
const { newDeferUsages } = collectRootFields(`
112+
query {
113+
...FragmentOne @defer(label: "Foo")
114+
}
115+
fragment FragmentOne on Query {
116+
...FragmentTwo @defer(label: "Bar")
117+
field
118+
}
119+
fragment FragmentTwo on Query {
120+
...FragmentThree @defer(label: "Baz")
121+
}
122+
fragment FragmentThree on Query {
123+
...FragmentOne @defer(label: "Qux")
124+
field
125+
}
126+
`);
127+
128+
expect(newDeferUsages).to.have.lengthOf(4);
129+
});
109130
});
110131
});

src/execution/collectFields.ts

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,19 @@ export interface FragmentDetails {
6969
variableSignatures?: ObjMap<GraphQLVariableSignature> | undefined;
7070
}
7171

72+
interface VisitedFragmentNames {
73+
// Fragments that have been visited without @defer.
74+
immediateFragments: Set<string>;
75+
// Map of fragment name to a set of labels that have been visited with @defer.
76+
deferredFragments: Map<string, Set<string | null>>;
77+
}
78+
7279
interface CollectFieldsContext {
7380
schema: GraphQLSchema;
7481
fragments: ObjMap<FragmentDetails>;
7582
variableValues: VariableValues;
7683
runtimeType: GraphQLObjectType;
77-
visitedFragmentNames: Map<string, boolean>;
84+
visitedFragmentNames: VisitedFragmentNames;
7885
hideSuggestions: boolean;
7986
forbiddenDirectiveInstances: Array<DirectiveNode>;
8087
forbidSkipAndInclude: boolean;
@@ -110,7 +117,10 @@ export function collectFields(
110117
fragments,
111118
variableValues,
112119
runtimeType,
113-
visitedFragmentNames: new Map(),
120+
visitedFragmentNames: {
121+
immediateFragments: new Set(),
122+
deferredFragments: new Map(),
123+
},
114124
hideSuggestions,
115125
forbiddenDirectiveInstances: [],
116126
forbidSkipAndInclude,
@@ -151,7 +161,10 @@ export function collectSubfields(
151161
fragments,
152162
variableValues,
153163
runtimeType: returnType,
154-
visitedFragmentNames: new Map(),
164+
visitedFragmentNames: {
165+
immediateFragments: new Set(),
166+
deferredFragments: new Map(),
167+
},
155168
hideSuggestions,
156169
forbiddenDirectiveInstances: [],
157170
forbidSkipAndInclude: false,
@@ -291,26 +304,38 @@ function collectFieldsImpl(
291304
deferUsage,
292305
);
293306

294-
const visitedAsDeferred = visitedFragmentNames.get(fragName);
307+
const visitedAsImmediate =
308+
visitedFragmentNames.immediateFragments.has(fragName);
309+
310+
if (visitedAsImmediate) {
311+
// Even if the fragment is deferred, we don't need to
312+
// collect it again if it has already been visited without @defer.
313+
continue;
314+
}
295315

296316
let maybeNewDeferUsage: DeferUsage | undefined;
297-
if (!newDeferUsage) {
298-
// If this spread is not deferred, it may be skipped when already visited
299-
// as a non-deferred spread. If it was previously visited as a deferred spread,
300-
// it must be revisited.
301-
if (visitedAsDeferred === false) {
302-
continue;
317+
318+
if (newDeferUsage) {
319+
let visitedAsDeferredByLabelSet =
320+
visitedFragmentNames.deferredFragments.get(fragName);
321+
if (!visitedAsDeferredByLabelSet) {
322+
visitedAsDeferredByLabelSet = new Set();
323+
visitedFragmentNames.deferredFragments.set(
324+
fragName,
325+
visitedAsDeferredByLabelSet,
326+
);
303327
}
304-
visitedFragmentNames.set(fragName, false);
305-
maybeNewDeferUsage = deferUsage;
306-
} else {
307-
// If this spread is deferred, it can be skipped if it has already been visited.
308-
if (visitedAsDeferred !== undefined) {
328+
if (visitedAsDeferredByLabelSet.has(newDeferUsage.label ?? null)) {
329+
// If the fragment has already been visited as deferred by the same
330+
// label, skip it.
309331
continue;
310332
}
311-
visitedFragmentNames.set(fragName, true);
312333
newDeferUsages.push(newDeferUsage);
313334
maybeNewDeferUsage = newDeferUsage;
335+
visitedAsDeferredByLabelSet.add(newDeferUsage.label ?? null);
336+
} else {
337+
maybeNewDeferUsage = deferUsage;
338+
visitedFragmentNames.immediateFragments.add(fragName);
314339
}
315340

316341
const fragmentVariableSignatures = fragment.variableSignatures;

src/execution/incremental/__tests__/defer-test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,90 @@ describe('Execute: defer directive', () => {
673673
});
674674
});
675675

676+
it('Can defer same fragment with different labels', async () => {
677+
const document = parse(`
678+
query HeroNameQuery {
679+
hero {
680+
...TopFragment @defer(label: "DeferTop1")
681+
...TopFragment @defer(label: "DeferTop2")
682+
}
683+
}
684+
fragment TopFragment on Hero {
685+
name
686+
}
687+
`);
688+
const result = await complete(document);
689+
expectJSON(result).toDeepEqual([
690+
{
691+
data: { hero: {} },
692+
pending: [
693+
{ id: '0', path: ['hero'], label: 'DeferTop1' },
694+
{ id: '1', path: ['hero'], label: 'DeferTop2' },
695+
],
696+
hasNext: true,
697+
},
698+
{
699+
hasNext: false,
700+
incremental: [{ id: '0', data: { name: 'Luke' } }],
701+
completed: [{ id: '0' }, { id: '1' }],
702+
},
703+
]);
704+
});
705+
706+
it('Can skip deferred fragment if same label is used', async () => {
707+
const document = parse(`
708+
query HeroNameQuery {
709+
hero {
710+
...TopFragment @defer(label: "DeferTop")
711+
...TopFragment @defer(label: "DeferTop")
712+
}
713+
}
714+
fragment TopFragment on Hero {
715+
name
716+
}
717+
`);
718+
const result = await complete(document);
719+
expectJSON(result).toDeepEqual([
720+
{
721+
data: { hero: {} },
722+
pending: [{ id: '0', path: ['hero'], label: 'DeferTop' }],
723+
hasNext: true,
724+
},
725+
{
726+
hasNext: false,
727+
incremental: [{ id: '0', data: { name: 'Luke' } }],
728+
completed: [{ id: '0' }],
729+
},
730+
]);
731+
});
732+
733+
it('Can skip deferred fragment if no label is used', async () => {
734+
const document = parse(`
735+
query HeroNameQuery {
736+
hero {
737+
...TopFragment @defer
738+
...TopFragment @defer
739+
}
740+
}
741+
fragment TopFragment on Hero {
742+
name
743+
}
744+
`);
745+
const result = await complete(document);
746+
expectJSON(result).toDeepEqual([
747+
{
748+
data: { hero: {} },
749+
pending: [{ id: '0', path: ['hero'] }],
750+
hasNext: true,
751+
},
752+
{
753+
hasNext: false,
754+
incremental: [{ id: '0', data: { name: 'Luke' } }],
755+
completed: [{ id: '0' }],
756+
},
757+
]);
758+
});
759+
676760
it('Can defer an inline fragment', async () => {
677761
const document = parse(`
678762
query HeroNameQuery {

0 commit comments

Comments
 (0)