import { describe, it } from 'node:test';
import { assert, expect } from 'chai';
import { expectJSON } from '../../../__testUtils__/expectJSON.ts';
import { expectPromise } from '../../../__testUtils__/expectPromise.ts';
import { resolveOnNextTick } from '../../../__testUtils__/resolveOnNextTick.ts';
import { spyOnMethod } from '../../../__testUtils__/spyOn.ts';
import { promiseWithResolvers } from '../../../jsutils/promiseWithResolvers.ts';
import type { DocumentNode } from '../../../language/ast.ts';
import { parse } from '../../../language/parser.ts';
import {
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
} from '../../../type/definition.ts';
import { GraphQLID, GraphQLString } from '../../../type/scalars.ts';
import { GraphQLSchema } from '../../../type/schema.ts';
import { buildSchema } from '../../../utilities/buildASTSchema.ts';
import { experimentalExecuteIncrementally } from '../../execute.ts';
import type {
InitialIncrementalExecutionResult,
SubsequentIncrementalExecutionResult,
} from '../IncrementalExecutor.ts';
const friendType = new GraphQLObjectType({
fields: {
id: { type: GraphQLID },
name: { type: GraphQLString },
nonNullName: { type: new GraphQLNonNull(GraphQLString) },
},
name: 'Friend',
});
const friends = [
{ name: 'Han', id: 2 },
{ name: 'Leia', id: 3 },
{ name: 'C-3PO', id: 4 },
];
const deeperObject = new GraphQLObjectType({
fields: {
foo: { type: GraphQLString },
bar: { type: GraphQLString },
baz: { type: GraphQLString },
bak: { type: GraphQLString },
},
name: 'DeeperObject',
});
const nestedObject = new GraphQLObjectType({
fields: {
deeperObject: { type: deeperObject },
name: { type: GraphQLString },
},
name: 'NestedObject',
});
const anotherNestedObject = new GraphQLObjectType({
fields: {
deeperObject: { type: deeperObject },
},
name: 'AnotherNestedObject',
});
const hero = {
name: 'Luke',
id: 1,
friends,
nestedObject,
anotherNestedObject,
};
const c = new GraphQLObjectType({
fields: {
d: { type: GraphQLString },
nonNullErrorField: { type: new GraphQLNonNull(GraphQLString) },
},
name: 'c',
});
const e = new GraphQLObjectType({
fields: {
f: { type: GraphQLString },
},
name: 'e',
});
const b = new GraphQLObjectType({
fields: {
c: { type: c },
e: { type: e },
},
name: 'b',
});
const a = new GraphQLObjectType({
fields: {
b: { type: b },
someField: { type: GraphQLString },
nonNullErrorField: { type: new GraphQLNonNull(GraphQLString) },
},
name: 'a',
});
const g = new GraphQLObjectType({
fields: {
h: { type: GraphQLString },
},
name: 'g',
});
const heroType = new GraphQLObjectType({
fields: {
id: { type: GraphQLID },
name: { type: GraphQLString },
nonNullName: { type: new GraphQLNonNull(GraphQLString) },
friends: {
type: new GraphQLList(friendType),
},
nestedObject: { type: nestedObject },
anotherNestedObject: { type: anotherNestedObject },
},
name: 'Hero',
});
const query = new GraphQLObjectType({
fields: {
hero: {
type: heroType,
},
a: { type: a },
g: { type: g },
},
name: 'Query',
});
const schema = new GraphQLSchema({ query });
const cancellationSchema = buildSchema(`
type Todo {
id: ID
items: [String]
author: User
}
type User {
id: ID
name: String
}
type Query {
todo: Todo
nonNullableTodo: Todo!
blocker: String
scalarList: [String]
slowScalarList: [String]
}
type Mutation {
foo: String
bar: String
}
type Subscription {
foo: String
}
`);
async function complete(
document: DocumentNode,
rootValue: unknown = { hero },
enableEarlyExecution = false,
) {
const result = await experimentalExecuteIncrementally({
schema,
document,
rootValue,
enableEarlyExecution,
});
if ('initialResult' in result) {
const results: Array<
InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult
> = [result.initialResult];
for await (const patch of result.subsequentResults) {
results.push(patch);
}
return results;
}
return result;
}
async function completeCancellation(
document: DocumentNode,
rootValue: unknown,
abortSignal: AbortSignal,
enableEarlyExecution = false,
) {
const result = await experimentalExecuteIncrementally({
schema: cancellationSchema,
document,
rootValue,
enableEarlyExecution,
abortSignal,
});
if ('initialResult' in result) {
const results: Array<
InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult
> = [result.initialResult];
for await (const patch of result.subsequentResults) {
results.push(patch);
}
return results;
}
return result;
}
describe('Execute: defer directive', () => {
it('Can defer fragments containing scalar types', async () => {
const document = parse(`
query HeroNameQuery {
hero {
id
...NameFragment @defer
}
}
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('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 {
hero {
id
...NameFragment @defer(if: false)
}
}
fragment NameFragment on Hero {
name
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual({
data: {
hero: {
id: '1',
name: 'Luke',
},
},
});
});
it('Does not disable defer with null if argument', async () => {
const document = parse(`
query HeroNameQuery($shouldDefer: Boolean) {
hero {
id
...NameFragment @defer(if: $shouldDefer)
}
}
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('Does not execute deferred fragments early when not specified', async () => {
const document = parse(`
query HeroNameQuery {
hero {
id
...NameFragment @defer
}
}
fragment NameFragment on Hero {
name
}
`);
const order: Array<string> = [];
const result = await complete(document, {
hero: {
...hero,
id: async () => {
await resolveOnNextTick();
await resolveOnNextTick();
order.push('slow-id');
return hero.id;
},
name: () => {
order.push('fast-name');
return hero.name;
},
},
});
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,
},
]);
expect(order).to.deep.equal(['slow-id', 'fast-name']);
});
it('Does execute deferred fragments early when specified', async () => {
const document = parse(`
query HeroNameQuery {
hero {
id
...NameFragment @defer
}
}
fragment NameFragment on Hero {
name
}
`);
const order: Array<string> = [];
const result = await complete(
document,
{
hero: {
...hero,
id: async () => {
await resolveOnNextTick();
await resolveOnNextTick();
order.push('slow-id');
return hero.id;
},
name: () => {
order.push('fast-name');
return hero.name;
},
},
},
true,
);
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,
},
]);
expect(order).to.deep.equal(['fast-name', 'slow-id']);
});
it('Can defer fragments on the top level Query field', async () => {
const document = parse(`
query HeroNameQuery {
...QueryFragment @defer(label: "DeferQuery")
}
fragment QueryFragment on Query {
hero {
id
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: {},
pending: [{ id: '0', path: [], label: 'DeferQuery' }],
hasNext: true,
},
{
incremental: [
{
data: {
hero: {
id: '1',
},
},
id: '0',
},
],
completed: [{ id: '0' }],
hasNext: false,
},
]);
});
it('Can defer fragments with errors on the top level Query field', async () => {
const document = parse(`
query HeroNameQuery {
...QueryFragment @defer(label: "DeferQuery")
}
fragment QueryFragment on Query {
hero {
name
}
}
`);
const result = await complete(document, {
hero: {
...hero,
name: () => {
throw new Error('bad');
},
},
});
expectJSON(result).toDeepEqual([
{
data: {},
pending: [{ id: '0', path: [], label: 'DeferQuery' }],
hasNext: true,
},
{
incremental: [
{
data: {
hero: {
name: null,
},
},
errors: [
{
message: 'bad',
locations: [{ line: 7, column: 11 }],
path: ['hero', 'name'],
},
],
id: '0',
},
],
completed: [{ id: '0' }],
hasNext: false,
},
]);
});
it('Can defer a fragment within an already deferred fragment', async () => {
const document = parse(`
query HeroNameQuery {
hero {
...TopFragment @defer(label: "DeferTop")
}
}
fragment TopFragment on Hero {
id
...NestedFragment @defer(label: "DeferNested")
}
fragment NestedFragment on Hero {
friends {
name
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: {
hero: {},
},
pending: [{ id: '0', path: ['hero'], label: 'DeferTop' }],
hasNext: true,
},
{
pending: [{ id: '1', path: ['hero'], label: 'DeferNested' }],
incremental: [
{
data: {
id: '1',
},
id: '0',
},
{
data: {
friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }],
},
id: '1',
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: false,
},
]);
});
it('Can defer a fragment that is also not deferred, deferred fragment is first', async () => {
const document = parse(`
query HeroNameQuery {
hero {
...TopFragment @defer(label: "DeferTop")
...TopFragment
}
}
fragment TopFragment on Hero {
name
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual({
data: {
hero: {
name: 'Luke',
},
},
});
});
it('Can defer a fragment that is also not deferred, non-deferred fragment is first', async () => {
const document = parse(`
query HeroNameQuery {
hero {
...TopFragment
...TopFragment @defer(label: "DeferTop")
}
}
fragment TopFragment on Hero {
name
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual({
data: {
hero: {
name: 'Luke',
},
},
});
});
it('Can defer an inline fragment', async () => {
const document = parse(`
query HeroNameQuery {
hero {
id
... on Hero @defer(label: "InlineDeferred") {
name
}
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: { hero: { id: '1' } },
pending: [{ id: '0', path: ['hero'], label: 'InlineDeferred' }],
hasNext: true,
},
{
incremental: [{ data: { name: 'Luke' }, id: '0' }],
completed: [{ id: '0' }],
hasNext: false,
},
]);
});
it('Does not emit empty defer fragments', async () => {
const document = parse(`
query HeroNameQuery {
hero {
... @defer {
name @skip(if: true)
}
}
}
fragment TopFragment on Hero {
name
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual({
data: {
hero: {},
},
});
});
it('Emits children of empty defer fragments', async () => {
const document = parse(`
query HeroNameQuery {
hero {
... @defer {
... @defer {
name
}
}
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: {
hero: {},
},
pending: [{ id: '0', path: ['hero'] }],
hasNext: true,
},
{
incremental: [{ data: { name: 'Luke' }, id: '0' }],
completed: [{ id: '0' }],
hasNext: false,
},
]);
});
it('Can separately emit defer fragments with different labels with varying fields', async () => {
const document = parse(`
query HeroNameQuery {
hero {
... @defer(label: "DeferID") {
id
}
... @defer(label: "DeferName") {
name
}
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: {
hero: {},
},
pending: [
{ id: '0', path: ['hero'], label: 'DeferID' },
{ id: '1', path: ['hero'], label: 'DeferName' },
],
hasNext: true,
},
{
incremental: [
{
data: {
id: '1',
},
id: '0',
},
{
data: {
name: 'Luke',
},
id: '1',
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: false,
},
]);
});
it('Separately emits defer fragments with different labels with varying subfields', async () => {
const document = parse(`
query HeroNameQuery {
... @defer(label: "DeferID") {
hero {
id
}
}
... @defer(label: "DeferName") {
hero {
name
}
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: {},
pending: [
{ id: '0', path: [], label: 'DeferID' },
{ id: '1', path: [], label: 'DeferName' },
],
hasNext: true,
},
{
incremental: [
{
data: { hero: {} },
id: '0',
},
{
data: { id: '1' },
id: '0',
subPath: ['hero'],
},
{
data: { name: 'Luke' },
id: '1',
subPath: ['hero'],
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: false,
},
]);
});
it('Separately emits defer fragments with different labels with varying subfields that return promises', async () => {
const document = parse(`
query HeroNameQuery {
... @defer(label: "DeferID") {
hero {
id
}
}
... @defer(label: "DeferName") {
hero {
name
}
}
}
`);
const result = await complete(document, {
hero: {
id: () => Promise.resolve('1'),
name: () => Promise.resolve('Luke'),
},
});
expectJSON(result).toDeepEqual([
{
data: {},
pending: [
{ id: '0', path: [], label: 'DeferID' },
{ id: '1', path: [], label: 'DeferName' },
],
hasNext: true,
},
{
incremental: [
{
data: { hero: {} },
id: '0',
},
{
data: { id: '1' },
id: '0',
subPath: ['hero'],
},
{
data: { name: 'Luke' },
id: '1',
subPath: ['hero'],
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: false,
},
]);
});
it('Separately emits defer fragments with varying subfields of same priorities but different level of defers', async () => {
const document = parse(`
query HeroNameQuery {
hero {
... @defer(label: "DeferID") {
id
}
}
... @defer(label: "DeferName") {
hero {
name
}
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: {
hero: {},
},
pending: [
{ id: '0', path: ['hero'], label: 'DeferID' },
{ id: '1', path: [], label: 'DeferName' },
],
hasNext: true,
},
{
incremental: [
{
data: {
id: '1',
},
id: '0',
},
{
data: {
name: 'Luke',
},
id: '1',
subPath: ['hero'],
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: false,
},
]);
});
it('Separately emits nested defer fragments with varying subfields of same priorities but different level of defers', async () => {
const document = parse(`
query HeroNameQuery {
... @defer(label: "DeferName") {
hero {
name
... @defer(label: "DeferID") {
id
}
}
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: {},
pending: [{ id: '0', path: [], label: 'DeferName' }],
hasNext: true,
},
{
pending: [{ id: '1', path: ['hero'], label: 'DeferID' }],
incremental: [
{
data: {
hero: {
name: 'Luke',
},
},
id: '0',
},
{
data: {
id: '1',
},
id: '1',
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: false,
},
]);
});
it('Initiates deferred grouped field sets only if they have been released as pending', async () => {
const document = parse(`
query {
... @defer {
a {
... @defer {
b {
c { d }
}
}
}
}
... @defer {
a {
someField
... @defer {
b {
e { f }
}
}
}
}
}
`);
const { promise: slowFieldPromise, resolve: resolveSlowField } =
promiseWithResolvers();
const bResolvers = {
c: () => ({ d: 'd' }),
e: () => ({ f: 'f' }),
};
const cResolverSpy = spyOnMethod(bResolvers, 'c');
const eResolverSpy = spyOnMethod(bResolvers, 'e');
const executeResult = experimentalExecuteIncrementally({
schema,
document,
rootValue: {
a: {
someField: slowFieldPromise,
b: bResolvers,
},
},
enableEarlyExecution: false,
});
assert('initialResult' in executeResult);
const result1 = executeResult.initialResult;
expectJSON(result1).toDeepEqual({
data: {},
pending: [
{ id: '0', path: [] },
{ id: '1', path: [] },
],
hasNext: true,
});
const iterator = executeResult.subsequentResults[Symbol.asyncIterator]();
expect(cResolverSpy.callCount).to.equal(0);
expect(eResolverSpy.callCount).to.equal(0);
const result2 = await iterator.next();
expectJSON(result2).toDeepEqual({
value: {
pending: [{ id: '2', path: ['a'] }],
incremental: [
{
data: { a: {} },
id: '0',
},
{
data: { b: {} },
id: '2',
},
{
data: { c: { d: 'd' } },
id: '2',
subPath: ['b'],
},
],
completed: [{ id: '0' }, { id: '2' }],
hasNext: true,
},
done: false,
});
expect(cResolverSpy.callCount).to.equal(1);
expect(eResolverSpy.callCount).to.equal(0);
resolveSlowField('someField');
const result3 = await iterator.next();
expectJSON(result3).toDeepEqual({
value: {
pending: [{ id: '3', path: ['a'] }],
incremental: [
{
data: { someField: 'someField' },
id: '1',
subPath: ['a'],
},
{
data: { e: { f: 'f' } },
id: '3',
subPath: ['b'],
},
],
completed: [{ id: '1' }, { id: '3' }],
hasNext: false,
},
done: false,
});
expect(eResolverSpy.callCount).to.equal(1);
const result4 = await iterator.next();
expectJSON(result4).toDeepEqual({
value: undefined,
done: true,
});
});
it('Initiates unique deferred grouped field sets after those that are common to sibling defers', async () => {
const document = parse(`
query {
... @defer {
a {
... @defer {
b {
c { d }
}
}
}
}
... @defer {
a {
... @defer {
b {
c { d }
e { f }
}
}
}
}
}
`);
const { promise: cPromise, resolve: resolveC } =
promiseWithResolvers<void>();
const bResolvers = {
c: async () => {
await cPromise;
return { d: 'd' };
},
e: () => ({ f: 'f' }),
};
const cResolverSpy = spyOnMethod(bResolvers, 'c');
const eResolverSpy = spyOnMethod(bResolvers, 'e');
const executeResult = experimentalExecuteIncrementally({
schema,
document,
rootValue: {
a: {
b: bResolvers,
},
},
enableEarlyExecution: false,
});
assert('initialResult' in executeResult);
const result1 = executeResult.initialResult;
expectJSON(result1).toDeepEqual({
data: {},
pending: [
{ id: '0', path: [] },
{ id: '1', path: [] },
],
hasNext: true,
});
const iterator = executeResult.subsequentResults[Symbol.asyncIterator]();
expect(cResolverSpy.callCount).to.equal(0);
expect(eResolverSpy.callCount).to.equal(0);
const result2 = await iterator.next();
expectJSON(result2).toDeepEqual({
value: {
pending: [
{ id: '2', path: ['a'] },
{ id: '3', path: ['a'] },
],
incremental: [
{
data: { a: {} },
id: '0',
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: true,
},
done: false,
});
resolveC();
expect(cResolverSpy.callCount).to.equal(1);
expect(eResolverSpy.callCount).to.equal(0);
const result3 = await iterator.next();
expectJSON(result3).toDeepEqual({
value: {
incremental: [
{
data: { b: { c: { d: 'd' } } },
id: '2',
},
{
data: { e: { f: 'f' } },
id: '3',
subPath: ['b'],
},
],
completed: [{ id: '2' }, { id: '3' }],
hasNext: false,
},
done: false,
});
const result4 = await iterator.next();
expectJSON(result4).toDeepEqual({
value: undefined,
done: true,
});
});
it('Can deduplicate multiple defers on the same object', async () => {
const document = parse(`
query {
hero {
friends {
... @defer {
...FriendFrag
... @defer {
...FriendFrag
... @defer {
...FriendFrag
... @defer {
...FriendFrag
}
}
}
}
}
}
}
fragment FriendFrag on Friend {
id
name
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: { hero: { friends: [{}, {}, {}] } },
pending: [
{ id: '0', path: ['hero', 'friends', 0] },
{ id: '1', path: ['hero', 'friends', 1] },
{ id: '2', path: ['hero', 'friends', 2] },
],
hasNext: true,
},
{
incremental: [
{ data: { id: '2', name: 'Han' }, id: '0' },
{ data: { id: '3', name: 'Leia' }, id: '1' },
{ data: { id: '4', name: 'C-3PO' }, id: '2' },
],
completed: [{ id: '0' }, { id: '1' }, { id: '2' }],
hasNext: false,
},
]);
});
it('Deduplicates fields present in the initial payload', async () => {
const document = parse(`
query {
hero {
nestedObject {
deeperObject {
foo
}
}
anotherNestedObject {
deeperObject {
foo
}
}
... @defer {
nestedObject {
deeperObject {
bar
}
}
anotherNestedObject {
deeperObject {
foo
}
}
}
}
}
`);
const result = await complete(document, {
hero: {
nestedObject: { deeperObject: { foo: 'foo', bar: 'bar' } },
anotherNestedObject: { deeperObject: { foo: 'foo' } },
},
});
expectJSON(result).toDeepEqual([
{
data: {
hero: {
nestedObject: {
deeperObject: {
foo: 'foo',
},
},
anotherNestedObject: {
deeperObject: {
foo: 'foo',
},
},
},
},
pending: [{ id: '0', path: ['hero'] }],
hasNext: true,
},
{
incremental: [
{
data: { bar: 'bar' },
id: '0',
subPath: ['nestedObject', 'deeperObject'],
},
],
completed: [{ id: '0' }],
hasNext: false,
},
]);
});
it('Deduplicates fields present in a parent defer payload', async () => {
const document = parse(`
query {
hero {
... @defer {
nestedObject {
deeperObject {
foo
... @defer {
foo
bar
}
}
}
}
}
}
`);
const result = await complete(document, {
hero: { nestedObject: { deeperObject: { foo: 'foo', bar: 'bar' } } },
});
expectJSON(result).toDeepEqual([
{
data: {
hero: {},
},
pending: [{ id: '0', path: ['hero'] }],
hasNext: true,
},
{
pending: [{ id: '1', path: ['hero', 'nestedObject', 'deeperObject'] }],
incremental: [
{
data: {
nestedObject: {
deeperObject: { foo: 'foo' },
},
},
id: '0',
},
{
data: {
bar: 'bar',
},
id: '1',
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: false,
},
]);
});
it('Deduplicates fields with deferred fragments at multiple levels', async () => {
const document = parse(`
query {
hero {
nestedObject {
deeperObject {
foo
}
}
... @defer {
nestedObject {
deeperObject {
foo
bar
}
... @defer {
deeperObject {
foo
bar
baz
... @defer {
foo
bar
baz
bak
}
}
}
}
}
}
}
`);
const result = await complete(document, {
hero: {
nestedObject: {
deeperObject: { foo: 'foo', bar: 'bar', baz: 'baz', bak: 'bak' },
},
},
});
expectJSON(result).toDeepEqual([
{
data: {
hero: {
nestedObject: {
deeperObject: {
foo: 'foo',
},
},
},
},
pending: [{ id: '0', path: ['hero'] }],
hasNext: true,
},
{
pending: [
{ id: '1', path: ['hero', 'nestedObject'] },
{ id: '2', path: ['hero', 'nestedObject', 'deeperObject'] },
],
incremental: [
{
data: { bar: 'bar' },
id: '0',
subPath: ['nestedObject', 'deeperObject'],
},
{
data: { baz: 'baz' },
id: '1',
subPath: ['deeperObject'],
},
{
data: { bak: 'bak' },
id: '2',
},
],
completed: [{ id: '0' }, { id: '1' }, { id: '2' }],
hasNext: false,
},
]);
});
it('Deduplicates multiple fields from deferred fragments from different branches occurring at the same level', async () => {
const document = parse(`
query {
hero {
nestedObject {
deeperObject {
... @defer {
foo
}
}
}
... @defer {
nestedObject {
deeperObject {
... @defer {
foo
bar
}
}
}
}
}
}
`);
const result = await complete(document, {
hero: { nestedObject: { deeperObject: { foo: 'foo', bar: 'bar' } } },
});
expectJSON(result).toDeepEqual([
{
data: {
hero: {
nestedObject: {
deeperObject: {},
},
},
},
pending: [
{ id: '0', path: ['hero', 'nestedObject', 'deeperObject'] },
{ id: '1', path: ['hero', 'nestedObject', 'deeperObject'] },
],
hasNext: true,
},
{
incremental: [
{
data: {
foo: 'foo',
},
id: '0',
},
{
data: {
bar: 'bar',
},
id: '1',
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: false,
},
]);
});
it('Deduplicate fields with deferred fragments in different branches at multiple non-overlapping levels', async () => {
const document = parse(`
query {
a {
b {
c {
d
}
... @defer {
e {
f
}
}
}
}
... @defer {
a {
b {
e {
f
}
}
}
g {
h
}
}
}
`);
const result = await complete(document, {
a: {
b: {
c: { d: 'd' },
e: { f: 'f' },
},
},
g: { h: 'h' },
});
expectJSON(result).toDeepEqual([
{
data: {
a: {
b: {
c: {
d: 'd',
},
},
},
},
pending: [
{ id: '0', path: ['a', 'b'] },
{ id: '1', path: [] },
],
hasNext: true,
},
{
incremental: [
{
data: { e: { f: 'f' } },
id: '0',
},
{
data: { g: { h: 'h' } },
id: '1',
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: false,
},
]);
});
it('Correctly bundles varying subfields into incremental data records unique by defer combination, ignoring fields in a fragment masked by a parent defer', async () => {
const document = parse(`
query HeroNameQuery {
... @defer {
hero {
id
}
}
... @defer {
hero {
name
shouldBeWithNameDespiteAdditionalDefer: name
... @defer {
shouldBeWithNameDespiteAdditionalDefer: name
}
}
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: {},
pending: [
{ id: '0', path: [] },
{ id: '1', path: [] },
],
hasNext: true,
},
{
incremental: [
{
data: { hero: {} },
id: '0',
},
{
data: { id: '1' },
id: '0',
subPath: ['hero'],
},
{
data: {
name: 'Luke',
shouldBeWithNameDespiteAdditionalDefer: 'Luke',
},
id: '1',
subPath: ['hero'],
},
],
completed: [{ id: '0' }, { id: '1' }],
hasNext: false,
},
]);
});
it('Nulls cross defer boundaries, null first', async () => {
const document = parse(`
query {
... @defer {
a {
someField
b {
c {
nonNullErrorField
}
}
}
}
a {
... @defer {
b {
c {
d
}
}
}
}
}
`);
const result = await complete(document, {
a: { b: { c: { d: 'd' } }, someField: 'someField' },
});
expectJSON(result).toDeepEqual([
{
data: {
a: {},
},
pending: [
{ id: '0', path: ['a'] },
{ id: '1', path: [] },
],
hasNext: true,
},
{
incremental: [
{
data: { b: { c: {} } },
id: '0',
},
{
data: { d: 'd' },
id: '0',
subPath: ['b', 'c'],
},
],
completed: [
{
id: '1',
errors: [
{
message:
'Cannot return null for non-nullable field c.nonNullErrorField.',
locations: [{ line: 8, column: 17 }],
path: ['a', 'b', 'c', 'nonNullErrorField'],
},
],
},
{ id: '0' },
],
hasNext: false,
},
]);
});
it('Nulls cross defer boundaries, value first', async () => {
const document = parse(`
query {
... @defer {
a {
b {
c {
d
}
}
}
}
a {
... @defer {
someField
b {
c {
nonNullErrorField
}
}
}
}
}
`);
const result = await complete(document, {
a: {
b: { c: { d: 'd' }, nonNullErrorFIeld: null },
someField: 'someField',
},
});
expectJSON(result).toDeepEqual([
{
data: {
a: {},
},
pending: [
{ id: '0', path: ['a'] },
{ id: '1', path: [] },
],
hasNext: true,
},
{
incremental: [
{
data: { b: { c: {} } },
id: '0',
},
{
data: { d: 'd' },
id: '1',
subPath: ['a', 'b', 'c'],
},
],
completed: [
{ id: '1' },
{
id: '0',
errors: [
{
message:
'Cannot return null for non-nullable field c.nonNullErrorField.',
locations: [{ line: 17, column: 17 }],
path: ['a', 'b', 'c', 'nonNullErrorField'],
},
],
},
],
hasNext: false,
},
]);
});
it('Nulls cross defer boundaries, failed fragment with slower shared child execution groups', async () => {
const document = parse(`
query {
... @defer {
a {
someField
nonNullErrorField
b {
c {
d
}
}
}
}
a {
... @defer {
someField
b {
e {
f
}
}
}
}
}
`);
const result = await complete(document, {
a: {
b: { c: { d: 'd' }, e: { f: 'f' } },
someField: Promise.resolve('someField'),
},
});
expectJSON(result).toDeepEqual([
{
data: {
a: {},
},
pending: [
{ id: '0', path: ['a'] },
{ id: '1', path: [] },
],
hasNext: true,
},
{
completed: [
{
id: '1',
errors: [
{
message:
'Cannot return null for non-nullable field a.nonNullErrorField.',
locations: [{ line: 6, column: 13 }],
path: ['a', 'nonNullErrorField'],
},
],
},
],
hasNext: true,
},
{
incremental: [
{
data: { b: {}, someField: 'someField' },
id: '0',
},
{
data: { e: { f: 'f' } },
id: '0',
subPath: ['b'],
},
],
completed: [{ id: '0' }],
hasNext: false,
},
]);
});
it('Handles cancelling child deferred fragments if parent fragment fails', async () => {
const document = parse(`
query {
... @defer {
a {
someField
b {
c {
nonNullErrorField
}
}
}
... @defer {
a {
someField
}
}
}
a {
... @defer {
b {
c {
d
}
}
}
}
}
`);
const result = await complete(document, {
a: { b: { c: { d: 'd' } }, someField: 'someField' },
});
expectJSON(result).toDeepEqual([
{
data: {
a: {},
},
pending: [
{ id: '0', path: ['a'] },
{ id: '1', path: [] },
],
hasNext: true,
},
{
incremental: [
{
data: { b: { c: {} } },
id: '0',
},
{
data: { d: 'd' },
id: '0',
subPath: ['b', 'c'],
},
],
completed: [
{
id: '1',
errors: [
{
message:
'Cannot return null for non-nullable field c.nonNullErrorField.',
locations: [{ line: 8, column: 17 }],
path: ['a', 'b', 'c', 'nonNullErrorField'],
},
],
},
{ id: '0' },
],
hasNext: false,
},
]);
});
it('Handles multiple erroring deferred grouped field sets', async () => {
const document = parse(`
query {
... @defer {
a {
b {
c {
someError: nonNullErrorField
}
}
}
}
... @defer {
a {
b {
c {
anotherError: nonNullErrorField
}
}
}
}
}
`);
const result = await complete(document, {
a: {
b: { c: { nonNullErrorField: null } },
},
});
expectJSON(result).toDeepEqual([
{
data: {},
pending: [
{ id: '0', path: [] },
{ id: '1', path: [] },
],
hasNext: true,
},
{
completed: [
{
id: '0',
errors: [
{
message:
'Cannot return null for non-nullable field c.nonNullErrorField.',
locations: [{ line: 7, column: 17 }],
path: ['a', 'b', 'c', 'someError'],
},
],
},
{
id: '1',
errors: [
{
message:
'Cannot return null for non-nullable field c.nonNullErrorField.',
locations: [{ line: 16, column: 17 }],
path: ['a', 'b', 'c', 'anotherError'],
},
],
},
],
hasNext: false,
},
]);
});
it('Handles multiple erroring deferred grouped field sets for the same fragment', async () => {
const document = parse(`
query {
... @defer {
a {
b {
someC: c {
d: d
}
anotherC: c {
d: d
}
}
}
}
... @defer {
a {
b {
someC: c {
someError: nonNullErrorField
}
anotherC: c {
anotherError: nonNullErrorField
}
}
}
}
}
`);
const result = await complete(document, {
a: {
b: { c: { d: 'd', nonNullErrorField: null } },
},
});
expectJSON(result).toDeepEqual([
{
data: {},
pending: [
{ id: '0', path: [] },
{ id: '1', path: [] },
],
hasNext: true,
},
{
incremental: [
{
data: { a: { b: { someC: {}, anotherC: {} } } },
id: '0',
},
{
data: { d: 'd' },
id: '0',
subPath: ['a', 'b', 'someC'],
},
{
data: { d: 'd' },
id: '0',
subPath: ['a', 'b', 'anotherC'],
},
],
completed: [
{
id: '1',
errors: [
{
message:
'Cannot return null for non-nullable field c.nonNullErrorField.',
locations: [{ line: 19, column: 17 }],
path: ['a', 'b', 'someC', 'someError'],
},
],
},
{ id: '0' },
],
hasNext: false,
},
]);
});
it('filters a payload with a null that cannot be merged', async () => {
const document = parse(`
query {
... @defer {
a {
someField
b {
c {
nonNullErrorField
}
}
}
}
a {
... @defer {
b {
c {
d
}
}
}
}
}
`);
const result = await complete(
document,
{
a: {
b: {
c: {
d: 'd',
nonNullErrorField: async () => {
await resolveOnNextTick();
return null;
},
},
},
someField: 'someField',
},
},
true,
);
expectJSON(result).toDeepEqual([
{
data: {
a: {},
},
pending: [
{ id: '0', path: ['a'] },
{ id: '1', path: [] },
],
hasNext: true,
},
{
incremental: [
{
data: { b: { c: {} } },
id: '0',
},
{
data: { d: 'd' },
id: '0',
subPath: ['b', 'c'],
},
],
completed: [{ id: '0' }],
hasNext: true,
},
{
completed: [
{
id: '1',
errors: [
{
message:
'Cannot return null for non-nullable field c.nonNullErrorField.',
locations: [{ line: 8, column: 17 }],
path: ['a', 'b', 'c', 'nonNullErrorField'],
},
],
},
],
hasNext: false,
},
]);
});
it('Cancels deferred fields when initial result exhibits null bubbling cancelling the defer', async () => {
const document = parse(`
query {
hero {
nonNullName
... @defer {
name
}
}
}
`);
const result = await complete(
document,
{
hero: {
...hero,
nonNullName: () => null,
},
},
true,
);
expectJSON(result).toDeepEqual({
data: {
hero: null,
},
errors: [
{
message:
'Cannot return null for non-nullable field Hero.nonNullName.',
locations: [{ line: 4, column: 11 }],
path: ['hero', 'nonNullName'],
},
],
});
});
it('Cancels deferred fields when initial result exhibits null bubbling cancelling new fields', async () => {
const document = parse(`
query {
hero {
nonNullName
}
... @defer {
hero {
name
}
}
}
`);
const result = await complete(
document,
{
hero: {
...hero,
nonNullName: () => null,
},
},
true,
);
expectJSON(result).toDeepEqual({
data: {
hero: null,
},
errors: [
{
message:
'Cannot return null for non-nullable field Hero.nonNullName.',
locations: [{ line: 4, column: 11 }],
path: ['hero', 'nonNullName'],
},
],
});
});
it('Keeps deferred work outside nulled error paths', async () => {
const document = parse(`
query {
a {
... @defer {
someField
}
nonNullErrorField
}
g {
... @defer {
h
}
}
}
`);
const result = await complete(document, {
a: {
someField: 'someField',
nonNullErrorField: null,
},
g: {
h: 'value',
},
});
expectJSON(result).toDeepEqual([
{
data: {
a: null,
g: {},
},
errors: [
{
message:
'Cannot return null for non-nullable field a.nonNullErrorField.',
locations: [{ line: 7, column: 11 }],
path: ['a', 'nonNullErrorField'],
},
],
pending: [{ id: '0', path: ['g'] }],
hasNext: true,
},
{
incremental: [
{
data: {
h: 'value',
},
id: '0',
},
],
completed: [{ id: '0' }],
hasNext: false,
},
]);
});
it('Stops late initial-path completion before publishing a deferred response', async () => {
const lateSideType = new GraphQLObjectType({
name: 'LateInitialSide',
fields: {
value: { type: GraphQLString },
},
});
const lateParentType = new GraphQLObjectType({
name: 'LateInitialParent',
fields: {
boom: { type: new GraphQLNonNull(GraphQLString) },
side: { type: lateSideType },
},
});
const lateGType = new GraphQLObjectType({
name: 'LateInitialG',
fields: {
h: { type: GraphQLString },
},
});
const lateSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'LateInitialQuery',
fields: {
parent: { type: lateParentType },
g: { type: lateGType },
},
}),
});
const document = parse(`
query {
parent {
boom
side {
value
}
}
g {
... @defer {
h
}
}
}
`);
const { promise: boomPromise, reject: rejectBoom } =
promiseWithResolvers<string>();
const { promise: sidePromise, resolve: resolveSide } =
promiseWithResolvers<{
value: () => string;
}>();
const resultPromise = experimentalExecuteIncrementally({
schema: lateSchema,
document,
rootValue: {
parent: {
boom: () => boomPromise,
side: () => sidePromise,
},
g: {
h: 'value',
},
},
});
rejectBoom(new Error('boom'));
const result = await resultPromise;
assert('initialResult' in result);
expectJSON(result.initialResult).toDeepEqual({
data: {
parent: null,
g: {},
},
errors: [
{
message: 'boom',
locations: [{ line: 4, column: 11 }],
path: ['parent', 'boom'],
},
],
pending: [{ id: '0', path: ['g'] }],
hasNext: true,
});
const iterator = result.subsequentResults[Symbol.asyncIterator]();
const result2 = await iterator.next();
expectJSON(result2).toDeepEqual({
done: false,
value: {
incremental: [
{
data: {
h: 'value',
},
id: '0',
},
],
completed: [{ id: '0' }],
hasNext: false,
},
});
const lateSide = {
value: () => 'late value',
};
const lateValueSpy = spyOnMethod(lateSide, 'value');
resolveSide(lateSide);
await resolveOnNextTick();
await resolveOnNextTick();
expect(lateValueSpy.callCount).to.equal(0);
const result3 = await iterator.next();
expectJSON(result3).toDeepEqual({
value: undefined,
done: true,
});
});
it('Cancels deferred fields when deferred result exhibits null bubbling', async () => {
const document = parse(`
query {
... @defer {
hero {
nonNullName
name
}
}
}
`);
const result = await complete(
document,
{
hero: {
...hero,
nonNullName: () => null,
},
},
true,
);
expectJSON(result).toDeepEqual([
{
data: {},
pending: [{ id: '0', path: [] }],
hasNext: true,
},
{
incremental: [
{
data: {
hero: null,
},
errors: [
{
message:
'Cannot return null for non-nullable field Hero.nonNullName.',
locations: [{ line: 5, column: 13 }],
path: ['hero', 'nonNullName'],
},
],
id: '0',
},
],
completed: [{ id: '0' }],
hasNext: false,
},
]);
});
it('Stops late deferred payload completion after deferred null bubbling', async () => {
const lateSideType = new GraphQLObjectType({
name: 'LateDeferredSide',
fields: {
value: { type: GraphQLString },
},
});
const lateParentType = new GraphQLObjectType({
name: 'LateDeferredParent',
fields: {
side: { type: lateSideType },
boom: { type: new GraphQLNonNull(GraphQLString) },
},
});
const lateSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'LateDeferredQuery',
fields: {
parent: { type: lateParentType },
},
}),
});
const document = parse(`
query {
... @defer {
parent {
side {
value
}
boom
}
}
}
`);
const { promise: boomPromise, reject: rejectBoom } =
promiseWithResolvers<string>();
const { promise: sidePromise, resolve: resolveSide } =
promiseWithResolvers<{
value: () => string;
}>();
const resultPromise = experimentalExecuteIncrementally({
schema: lateSchema,
document,
rootValue: {
parent: {
side: () => sidePromise,
boom: () => boomPromise,
},
},
enableEarlyExecution: true,
});
rejectBoom(new Error('boom'));
const result = await resultPromise;
assert('initialResult' in result);
expectJSON(result.initialResult).toDeepEqual({
data: {},
pending: [{ id: '0', path: [] }],
hasNext: true,
});
const iterator = result.subsequentResults[Symbol.asyncIterator]();
const result2 = await iterator.next();
expectJSON(result2).toDeepEqual({
done: false,
value: {
incremental: [
{
data: {
parent: null,
},
errors: [
{
message: 'boom',
locations: [{ line: 8, column: 13 }],
path: ['parent', 'boom'],
},
],
id: '0',
},
],
completed: [{ id: '0' }],
hasNext: false,
},
});
const lateSide = {
value: () => 'late value',
};
const lateValueSpy = spyOnMethod(lateSide, 'value');
resolveSide(lateSide);
await resolveOnNextTick();
await resolveOnNextTick();
expect(lateValueSpy.callCount).to.equal(0);
const result3 = await iterator.next();
expectJSON(result3).toDeepEqual({
value: undefined,
done: true,
});
});
it('Deduplicates list fields', async () => {
const document = parse(`
query {
hero {
friends {
name
}
... @defer {
friends {
name
}
}
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual({
data: {
hero: {
friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }],
},
},
});
});
it('Deduplicates async iterable list fields', async () => {
const document = parse(`
query {
hero {
friends {
name
}
... @defer {
friends {
name
}
}
}
}
`);
const result = await complete(document, {
hero: {
...hero,
friends: async function* resolve() {
yield await Promise.resolve(friends[0]);
},
},
});
expectJSON(result).toDeepEqual({
data: { hero: { friends: [{ name: 'Han' }] } },
});
});
it('Deduplicates empty async iterable list fields', async () => {
const document = parse(`
query {
hero {
friends {
name
}
... @defer {
friends {
name
}
}
}
}
`);
const result = await complete(document, {
hero: {
...hero,
friends: async function* resolve() {
await resolveOnNextTick();
},
},
});
expectJSON(result).toDeepEqual({
data: { hero: { friends: [] } },
});
});
it('Does not deduplicate list fields with non-overlapping fields', async () => {
const document = parse(`
query {
hero {
friends {
name
}
... @defer {
friends {
id
}
}
}
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: {
hero: {
friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }],
},
},
pending: [{ id: '0', path: ['hero'] }],
hasNext: true,
},
{
incremental: [
{
data: { id: '2' },
id: '0',
subPath: ['friends', 0],
},
{
data: { id: '3' },
id: '0',
subPath: ['friends', 1],
},
{
data: { id: '4' },
id: '0',
subPath: ['friends', 2],
},
],
completed: [{ id: '0' }],
hasNext: false,
},
]);
});
it('Deduplicates list fields that return empty lists', async () => {
const document = parse(`
query {
hero {
friends {
name
}
... @defer {
friends {
name
}
}
}
}
`);
const result = await complete(document, {
hero: {
...hero,
friends: () => [],
},
});
expectJSON(result).toDeepEqual({
data: { hero: { friends: [] } },
});
});
it('Deduplicates null object fields', async () => {
const document = parse(`
query {
hero {
nestedObject {
name
}
... @defer {
nestedObject {
name
}
}
}
}
`);
const result = await complete(document, {
hero: {
...hero,
nestedObject: () => null,
},
});
expectJSON(result).toDeepEqual({
data: { hero: { nestedObject: null } },
});
});
it('Deduplicates promise object fields', async () => {
const document = parse(`
query {
hero {
nestedObject {
name
}
... @defer {
nestedObject {
name
}
}
}
}
`);
const result = await complete(document, {
hero: {
nestedObject: () => Promise.resolve({ name: 'foo' }),
},
});
expectJSON(result).toDeepEqual({
data: { hero: { nestedObject: { name: 'foo' } } },
});
});
it('Handles errors thrown in deferred fragments', async () => {
const document = parse(`
query HeroNameQuery {
hero {
id
...NameFragment @defer
}
}
fragment NameFragment on Hero {
name
}
`);
const result = await complete(document, {
hero: {
...hero,
name: () => {
throw new Error('bad');
},
},
});
expectJSON(result).toDeepEqual([
{
data: { hero: { id: '1' } },
pending: [{ id: '0', path: ['hero'] }],
hasNext: true,
},
{
incremental: [
{
data: { name: null },
id: '0',
errors: [
{
message: 'bad',
locations: [{ line: 9, column: 9 }],
path: ['hero', 'name'],
},
],
},
],
completed: [{ id: '0' }],
hasNext: false,
},
]);
});
it('Handles non-nullable errors thrown in deferred fragments', async () => {
const document = parse(`
query HeroNameQuery {
hero {
id
...NameFragment @defer
}
}
fragment NameFragment on Hero {
nonNullName
}
`);
const result = await complete(document, {
hero: {
...hero,
nonNullName: () => null,
},
});
expectJSON(result).toDeepEqual([
{
data: { hero: { id: '1' } },
pending: [{ id: '0', path: ['hero'] }],
hasNext: true,
},
{
completed: [
{
id: '0',
errors: [
{
message:
'Cannot return null for non-nullable field Hero.nonNullName.',
locations: [{ line: 9, column: 9 }],
path: ['hero', 'nonNullName'],
},
],
},
],
hasNext: false,
},
]);
});
it('Handles non-nullable errors thrown outside deferred fragments', async () => {
const document = parse(`
query HeroNameQuery {
hero {
nonNullName
...NameFragment @defer
}
}
fragment NameFragment on Hero {
id
}
`);
const result = await complete(document, {
hero: {
...hero,
nonNullName: () => null,
},
});
expectJSON(result).toDeepEqual({
errors: [
{
message:
'Cannot return null for non-nullable field Hero.nonNullName.',
locations: [
{
line: 4,
column: 11,
},
],
path: ['hero', 'nonNullName'],
},
],
data: {
hero: null,
},
});
});
it('Handles async non-nullable errors thrown in deferred fragments', async () => {
const document = parse(`
query HeroNameQuery {
hero {
id
...NameFragment @defer
}
}
fragment NameFragment on Hero {
nonNullName
}
`);
const result = await complete(document, {
hero: {
...hero,
nonNullName: () => Promise.resolve(null),
},
});
expectJSON(result).toDeepEqual([
{
data: { hero: { id: '1' } },
pending: [{ id: '0', path: ['hero'] }],
hasNext: true,
},
{
completed: [
{
id: '0',
errors: [
{
message:
'Cannot return null for non-nullable field Hero.nonNullName.',
locations: [{ line: 9, column: 9 }],
path: ['hero', 'nonNullName'],
},
],
},
],
hasNext: false,
},
]);
});
it('Returns payloads in correct order', async () => {
const document = parse(`
query HeroNameQuery {
hero {
id
...NameFragment @defer
}
}
fragment NameFragment on Hero {
name
friends {
...NestedFragment @defer
}
}
fragment NestedFragment on Friend {
name
}
`);
const result = await complete(document, {
hero: {
...hero,
name: async () => {
await resolveOnNextTick();
return 'slow';
},
},
});
expectJSON(result).toDeepEqual([
{
data: {
hero: { id: '1' },
},
pending: [{ id: '0', path: ['hero'] }],
hasNext: true,
},
{
pending: [
{ id: '1', path: ['hero', 'friends', 0] },
{ id: '2', path: ['hero', 'friends', 1] },
{ id: '3', path: ['hero', 'friends', 2] },
],
incremental: [
{
data: { name: 'slow', friends: [{}, {}, {}] },
id: '0',
},
{ data: { name: 'Han' }, id: '1' },
{ data: { name: 'Leia' }, id: '2' },
{ data: { name: 'C-3PO' }, id: '3' },
],
completed: [{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }],
hasNext: false,
},
]);
});
it('Returns payloads from synchronous data in correct order', async () => {
const document = parse(`
query HeroNameQuery {
hero {
id
...NameFragment @defer
}
}
fragment NameFragment on Hero {
name
friends {
...NestedFragment @defer
}
}
fragment NestedFragment on Friend {
name
}
`);
const result = await complete(document);
expectJSON(result).toDeepEqual([
{
data: {
hero: { id: '1' },
},
pending: [{ id: '0', path: ['hero'] }],
hasNext: true,
},
{
pending: [
{ id: '1', path: ['hero', 'friends', 0] },
{ id: '2', path: ['hero', 'friends', 1] },
{ id: '3', path: ['hero', 'friends', 2] },
],
incremental: [
{
data: {
name: 'Luke',
friends: [{}, {}, {}],
},
id: '0',
},
{ data: { name: 'Han' }, id: '1' },
{ data: { name: 'Leia' }, id: '2' },
{ data: { name: 'C-3PO' }, id: '3' },
],
completed: [{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }],
hasNext: false,
},
]);
});
it('Filters deferred payloads when a list item returned by an async iterable is nulled', async () => {
const document = parse(`
query {
hero {
friends {
nonNullName
...NameFragment @defer
}
}
}
fragment NameFragment on Friend {
name
}
`);
const result = await complete(document, {
hero: {
...hero,
async *friends() {
yield await Promise.resolve({
...friends[0],
nonNullName: () => Promise.resolve(null),
});
},
},
});
expectJSON(result).toDeepEqual({
data: {
hero: {
friends: [null],
},
},
errors: [
{
message:
'Cannot return null for non-nullable field Friend.nonNullName.',
locations: [{ line: 5, column: 13 }],
path: ['hero', 'friends', 0, 'nonNullName'],
},
],
});
});
it('should allow deferred execution when passed abortSignal, if not aborted', async () => {
const abortController = new AbortController();
const document = parse(`
query {
todo {
id
... on Todo @defer {
author {
id
}
}
}
}
`);
const result = await experimentalExecuteIncrementally({
schema: cancellationSchema,
document,
rootValue: {
todo: {
author: { id: '1' },
},
},
abortSignal: abortController.signal,
});
assert('initialResult' in result);
const { initialResult, subsequentResults } = result;
expectJSON(initialResult).toDeepEqual({
data: {
todo: {
id: null,
},
},
pending: [{ id: '0', path: ['todo'] }],
hasNext: true,
});
const payload1 = await subsequentResults.next();
expectJSON(payload1).toDeepEqual({
done: false,
value: {
incremental: [
{
data: { author: { id: '1' } },
id: '0',
},
],
completed: [{ id: '0' }],
hasNext: false,
},
});
});
it('should stop deferred execution when aborted', async () => {
const abortController = new AbortController();
const document = parse(`
query {
todo {
id
... on Todo @defer {
author {
id
}
}
}
}
`);
const resultPromise = experimentalExecuteIncrementally({
schema: cancellationSchema,
document,
rootValue: {
todo: async () =>
Promise.resolve({
id: '1',
author: () => expect.fail('Should not be called'),
}),
},
abortSignal: abortController.signal,
});
abortController.abort();
await expectPromise(resultPromise).toRejectWith(
'This operation was aborted',
);
});
it('should stop deferred execution when aborted mid-execution', async () => {
const abortController = new AbortController();
const document = parse(`
query {
... on Query @defer {
todo {
id
author {
id
}
}
}
}
`);
const resultPromise = completeCancellation(
document,
{
todo: () =>
Promise.resolve({
id: '1',
author: () =>
Promise.resolve(() => expect.fail('Should not be called')),
}),
},
abortController.signal,
);
abortController.abort();
await expectPromise(resultPromise).toRejectWith(
'This operation was aborted',
);
});
it('cancels pending deferred execution groups', async () => {
const abortController = new AbortController();
const { promise: slowPromise } = promiseWithResolvers<unknown>();
const document = parse('{ scalarList ... @defer { slowScalarList } }');
const result = await experimentalExecuteIncrementally({
schema: cancellationSchema,
document,
rootValue: {
scalarList: () => ['a'],
slowScalarList: () => slowPromise,
},
enableEarlyExecution: true,
abortSignal: abortController.signal,
});
assert('initialResult' in result);
const iterator = result.subsequentResults[Symbol.asyncIterator]();
abortController.abort();
await expectPromise(iterator.next()).toRejectWith(
'This operation was aborted',
);
});
it('cancels pending deferred tasks with async child stream cleanup', async () => {
const abortController = new AbortController();
const document = parse(`
query {
todo {
id
... @defer {
items @stream(initialCount: 0)
author {
id
}
}
}
blocker
}
`);
const { promise: blockerPromise, resolve: resolveBlocker } =
promiseWithResolvers<string>();
const { promise: blockerStarted, resolve: resolveBlockerStarted } =
promiseWithResolvers<void>();
const { promise: authorPromise, resolve: resolveAuthor } =
promiseWithResolvers<{ id: string }>();
const { promise: authorStarted, resolve: resolveAuthorStarted } =
promiseWithResolvers<void>();
const { promise: itemsStarted, resolve: resolveItemsStarted } =
promiseWithResolvers<void>();
const { promise: returnCleanup, resolve: resolveReturnCleanup } =
promiseWithResolvers<void>();
const asyncIterator = {
[Symbol.asyncIterator]() {
return this;
},
next() {
return new Promise<IteratorResult<string>>(() => {
});
},
async return() {
await returnCleanup;
return { value: undefined, done: true };
},
};
const sourceReturnSpy = spyOnMethod(asyncIterator, 'return');
const resultPromise = experimentalExecuteIncrementally({
schema: cancellationSchema,
document,
enableEarlyExecution: true,
abortSignal: abortController.signal,
rootValue: {
blocker() {
resolveBlockerStarted();
return blockerPromise;
},
todo: {
id: 'todo',
items() {
resolveItemsStarted();
return asyncIterator;
},
author() {
resolveAuthorStarted();
return authorPromise;
},
},
},
});
await blockerStarted;
await itemsStarted;
await authorStarted;
abortController.abort();
await resolveOnNextTick();
expect(sourceReturnSpy.callCount).to.equal(1);
await expectPromise(resultPromise).toRejectWith(
'This operation was aborted',
);
resolveReturnCleanup();
resolveAuthor({ id: 'author' });
resolveBlocker('done');
await resolveOnNextTick();
});
it('should ignore deferred payloads resolved after cancellation', async () => {
const abortController = new AbortController();
const document = parse(`
query {
todo {
id
... @defer {
author {
id
}
}
}
}
`);
const { promise: authorStarted, resolve: resolveAuthorStarted } =
promiseWithResolvers<void>();
const { promise: authorPromise, resolve: resolveAuthor } =
promiseWithResolvers<{ id: string }>();
const result = await experimentalExecuteIncrementally({
schema: cancellationSchema,
document,
abortSignal: abortController.signal,
enableEarlyExecution: true,
rootValue: {
todo: {
id: 'todo',
author() {
resolveAuthorStarted();
return authorPromise;
},
},
},
});
assert('initialResult' in result);
const iterator = result.subsequentResults[Symbol.asyncIterator]();
const nextResultPromise = iterator.next();
await authorStarted;
abortController.abort();
resolveAuthor({ id: 'author' });
await expectPromise(nextResultPromise).toRejectWith(
'This operation was aborted',
);
await expectPromise(authorPromise).toResolve();
});
it('should ignore deferred errors after cancellation', async () => {
const abortController = new AbortController();
const document = parse(`
query {
todo {
id
... @defer {
author {
id
}
}
}
}
`);
const { promise: authorStarted, resolve: resolveAuthorStarted } =
promiseWithResolvers<void>();
const { promise: authorPromise, reject: rejectAuthor } =
promiseWithResolvers<{ id: string }>();
const result = await experimentalExecuteIncrementally({
schema: cancellationSchema,
document,
abortSignal: abortController.signal,
enableEarlyExecution: true,
rootValue: {
todo: {
id: 'todo',
author() {
resolveAuthorStarted();
return authorPromise;
},
},
},
});
assert('initialResult' in result);
const iterator = result.subsequentResults[Symbol.asyncIterator]();
const nextResultPromise = iterator.next();
await authorStarted;
abortController.abort();
rejectAuthor(new Error('late error'));
await expectPromise(nextResultPromise).toRejectWith(
'This operation was aborted',
);
await expectPromise(authorPromise).toRejectWith('late error');
});
});