Skip to content

Commit e5c18f6

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

3 files changed

Lines changed: 144 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: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,17 @@ export interface FragmentDetails {
6969
variableSignatures?: ObjMap<GraphQLVariableSignature> | undefined;
7070
}
7171

72+
interface VisitedFragmentNames {
73+
immediateFragments: Set<string>;
74+
deferredFragments: Map<string, Set<string>>;
75+
}
76+
7277
interface CollectFieldsContext {
7378
schema: GraphQLSchema;
7479
fragments: ObjMap<FragmentDetails>;
7580
variableValues: VariableValues;
7681
runtimeType: GraphQLObjectType;
77-
visitedFragmentNames: Map<string, boolean>;
82+
visitedFragmentNames: VisitedFragmentNames;
7883
hideSuggestions: boolean;
7984
forbiddenDirectiveInstances: Array<DirectiveNode>;
8085
forbidSkipAndInclude: boolean;
@@ -110,7 +115,10 @@ export function collectFields(
110115
fragments,
111116
variableValues,
112117
runtimeType,
113-
visitedFragmentNames: new Map(),
118+
visitedFragmentNames: {
119+
immediateFragments: new Set(),
120+
deferredFragments: new Map(),
121+
},
114122
hideSuggestions,
115123
forbiddenDirectiveInstances: [],
116124
forbidSkipAndInclude,
@@ -151,7 +159,10 @@ export function collectSubfields(
151159
fragments,
152160
variableValues,
153161
runtimeType: returnType,
154-
visitedFragmentNames: new Map(),
162+
visitedFragmentNames: {
163+
immediateFragments: new Set(),
164+
deferredFragments: new Map(),
165+
},
155166
hideSuggestions,
156167
forbiddenDirectiveInstances: [],
157168
forbidSkipAndInclude: false,
@@ -291,26 +302,38 @@ function collectFieldsImpl(
291302
deferUsage,
292303
);
293304

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

296314
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;
315+
316+
if (newDeferUsage) {
317+
let visitedAsDeferredByLabelSet =
318+
visitedFragmentNames.deferredFragments.get(fragName);
319+
if (!visitedAsDeferredByLabelSet) {
320+
visitedAsDeferredByLabelSet = new Set();
321+
visitedFragmentNames.deferredFragments.set(
322+
fragName,
323+
visitedAsDeferredByLabelSet,
324+
);
303325
}
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) {
326+
if (visitedAsDeferredByLabelSet.has(newDeferUsage.label ?? '')) {
327+
// If the fragment has already been visited as deferred by the same
328+
// label, skip it.
309329
continue;
310330
}
311-
visitedFragmentNames.set(fragName, true);
312331
newDeferUsages.push(newDeferUsage);
313332
maybeNewDeferUsage = newDeferUsage;
333+
visitedAsDeferredByLabelSet.add(newDeferUsage.label ?? '');
334+
} else {
335+
maybeNewDeferUsage = deferUsage;
336+
visitedFragmentNames.immediateFragments.add(fragName);
314337
}
315338

316339
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)