From d19459e61d0ba13fb2987aebf9d4aee12ec163f4 Mon Sep 17 00:00:00 2001 From: James Bellenger Date: Sun, 26 Apr 2026 05:35:34 -0700 Subject: [PATCH 1/4] fix(validation): allow explicit null @defer/@stream label The DeferStreamDirectiveLabelRule rejects any label argument whose literal is not a StringValue, but `label: null` parses as a NullValueNode and incorrectly fails that check. The label argument is nullable String, and the Incremental Delivery spec RFC only requires that the label not be a variable, so an explicit null literal should be treated the same as omitting the argument. --- .../DeferStreamDirectiveLabelRule-test.ts | 33 +++++++++++++++++++ .../rules/DeferStreamDirectiveLabelRule.ts | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts b/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts index 4126c6e578..9b3e654b1c 100644 --- a/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts +++ b/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts @@ -47,6 +47,23 @@ describe('Validate: Defer/Stream directive labels', () => { `); }); + it('Defer fragment with null label', () => { + expectValid(` + { + dog { + ...dogFragmentA @defer(label: null) + ...dogFragmentB @defer(label: null) + } + } + fragment dogFragmentA on Dog { + name + } + fragment dogFragmentB on Dog { + nickname + } + `); + }); + it('Defer fragment with variable label', () => { expectErrors(` query($label: String) { @@ -125,6 +142,22 @@ describe('Validate: Defer/Stream directive labels', () => { } `); }); + it('Stream with null label', () => { + expectValid(` + { + dog { + ...dogFragment @defer + } + pets @stream(initialCount: 0) @stream(label: null) { + name + } + } + fragment dogFragment on Dog { + name + } + `); + }); + it('Stream with variable label', () => { expectErrors(` query ($label: String!) { diff --git a/src/validation/rules/DeferStreamDirectiveLabelRule.ts b/src/validation/rules/DeferStreamDirectiveLabelRule.ts index 2b6d35a816..02cb399a74 100644 --- a/src/validation/rules/DeferStreamDirectiveLabelRule.ts +++ b/src/validation/rules/DeferStreamDirectiveLabelRule.ts @@ -30,7 +30,7 @@ export function DeferStreamDirectiveLabelRule( (arg) => arg.name.value === 'label', ); const labelValue = labelArgument?.value; - if (!labelValue) { + if (!labelValue || labelValue.kind === Kind.NULL) { return; } if (labelValue.kind !== Kind.STRING) { From 83c9c88c08c7a1cb9717aa3e4402ec3cd7edf9fc Mon Sep 17 00:00:00 2001 From: James Bellenger Date: Sun, 26 Apr 2026 05:55:31 -0700 Subject: [PATCH 2/4] test: simplify null-label cases --- .../__tests__/DeferStreamDirectiveLabelRule-test.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts b/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts index 9b3e654b1c..a53f847600 100644 --- a/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts +++ b/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts @@ -52,15 +52,11 @@ describe('Validate: Defer/Stream directive labels', () => { { dog { ...dogFragmentA @defer(label: null) - ...dogFragmentB @defer(label: null) } } fragment dogFragmentA on Dog { name } - fragment dogFragmentB on Dog { - nickname - } `); }); @@ -145,16 +141,10 @@ describe('Validate: Defer/Stream directive labels', () => { it('Stream with null label', () => { expectValid(` { - dog { - ...dogFragment @defer - } - pets @stream(initialCount: 0) @stream(label: null) { + pets @stream(label: null) { name } } - fragment dogFragment on Dog { - name - } `); }); From b23ea8d0212326b404fce65c120ada5119548fb8 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 29 Apr 2026 22:50:27 +0300 Subject: [PATCH 3/4] add some execution tests --- .../incremental/__tests__/defer-test.ts | 70 +++++++++++++++++++ .../incremental/__tests__/stream-test.ts | 27 +++++++ .../__tests__/legacy-defer-test.ts | 67 ++++++++++++++++++ .../__tests__/legacy-stream-test.ts | 25 +++++++ 4 files changed, 189 insertions(+) diff --git a/src/execution/incremental/__tests__/defer-test.ts b/src/execution/incremental/__tests__/defer-test.ts index c4c325b95a..f311b1c707 100644 --- a/src/execution/incremental/__tests__/defer-test.ts +++ b/src/execution/incremental/__tests__/defer-test.ts @@ -260,6 +260,76 @@ describe('Execute: defer directive', () => { }, ]); }); + it('Returns label from defer directive', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer(label: "defer-label") + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + }, + }, + pending: [{ id: '0', path: ['hero'], label: 'defer-label' }], + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + }, + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + it('Treats null defer label the same as no label', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer(label: null) + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + pending: [{ id: '0', path: ['hero'] }], + hasNext: true, + }, + { + incremental: [ + { + data: { name: 'Luke' }, + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); it('Can disable defer using if argument', async () => { const document = parse(` query HeroNameQuery { diff --git a/src/execution/incremental/__tests__/stream-test.ts b/src/execution/incremental/__tests__/stream-test.ts index 9f92f92e60..6ccdc21228 100644 --- a/src/execution/incremental/__tests__/stream-test.ts +++ b/src/execution/incremental/__tests__/stream-test.ts @@ -326,6 +326,33 @@ describe('Execute: stream directive', () => { }, ]); }); + it('Treats null stream label the same as no label', async () => { + const document = parse( + '{ scalarList @stream(initialCount: 1, label: null) }', + ); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['apple'], + }, + pending: [{ id: '0', path: ['scalarList'] }], + hasNext: true, + }, + { + incremental: [ + { + items: ['banana', 'coconut'], + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); it('Can disable @stream using if argument', async () => { const document = parse( '{ scalarList @stream(initialCount: 0, if: false) }', diff --git a/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts b/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts index 7215674b57..1b4ba1dc37 100644 --- a/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts +++ b/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts @@ -259,6 +259,73 @@ describe('Execute: defer directive (legacy)', () => { }, ]); }); + it('Returns label from defer directive', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer(label: "defer-label") + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + }, + path: ['hero'], + label: 'defer-label', + }, + ], + hasNext: false, + }, + ]); + }); + it('Treats null defer label the same as no label', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer(label: null) + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [ + { + data: { name: 'Luke' }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); it('Can disable defer using if argument', async () => { const document = parse(` query HeroNameQuery { diff --git a/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts b/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts index ceaa05d587..1cb198fe44 100644 --- a/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts +++ b/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts @@ -284,6 +284,31 @@ describe('Execute: stream directive (legacy)', () => { }, ]); }); + it('Treats null stream label the same as no label', async () => { + const document = parse( + '{ scalarList @stream(initialCount: 1, label: null) }', + ); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['apple'], + }, + hasNext: true, + }, + { + incremental: [ + { + items: ['banana', 'coconut'], + path: ['scalarList', 1], + }, + ], + hasNext: false, + }, + ]); + }); it('Can disable @stream using if argument', async () => { const document = parse( '{ scalarList @stream(initialCount: 0, if: false) }', From e6c4488ef45f8681a1b741eef711ecae50957aa3 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 29 Apr 2026 22:53:16 +0300 Subject: [PATCH 4/4] ws --- src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts b/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts index a53f847600..6103e40c8a 100644 --- a/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts +++ b/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts @@ -147,7 +147,6 @@ describe('Validate: Defer/Stream directive labels', () => { } `); }); - it('Stream with variable label', () => { expectErrors(` query ($label: String!) {