diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index b97a11fd33..3a4f6ec1f4 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -402,6 +402,54 @@ describe('Execute: stream directive', () => { }, ]); }); + it('Can stream a field that returns a list with nested promises', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document, { + friendList: () => + friends.map((f) => ({ + name: Promise.resolve(f.name), + id: Promise.resolve(f.id), + })), + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [ + { + name: 'Luke', + id: '1', + }, + { + name: 'Han', + id: '2', + }, + ], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); it('Handles rejections in a field that returns a list of promises before initialCount is reached', async () => { const document = parse(` query { @@ -901,6 +949,55 @@ describe('Execute: stream directive', () => { }, ]); }); + it('Handles nested async errors thrown by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + friendList: () => [ + { nonNullName: Promise.resolve(friends[0].name) }, + { nonNullName: Promise.reject(new Error('Oops')) }, + { nonNullName: Promise.resolve(friends[1].name) }, + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['friendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); it('Handles async errors thrown by completeValue after initialCount is reached for a non-nullable list', async () => { const document = parse(` query { @@ -943,6 +1040,46 @@ describe('Execute: stream directive', () => { }, ]); }); + it('Handles nested async errors thrown by completeValue after initialCount is reached for a non-nullable list', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + nonNullFriendList: () => [ + { nonNullName: Promise.resolve(friends[0].name) }, + { nonNullName: Promise.reject(new Error('Oops')) }, + { nonNullName: Promise.resolve(friends[1].name) }, + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + path: ['nonNullFriendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); it('Handles async errors thrown by completeValue after initialCount is reached from async iterable', async () => { const document = parse(` query { diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 482ebec90c..655ed3b3fd 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -714,9 +714,8 @@ function executeField( const result = resolveFn(source, args, contextValue, info); - let completed; if (isPromise(result)) { - completed = result.then((resolved) => + const completed = result.then((resolved) => completeValue( exeContext, returnType, @@ -727,18 +726,26 @@ function executeField( asyncPayloadRecord, ), ); - } else { - completed = completeValue( - exeContext, - returnType, - fieldNodes, - info, - path, - result, - asyncPayloadRecord, - ); + // Note: we don't rely on a `catch` method, but we do expect "thenable" + // to take a second callback for the error case. + return completed.then(undefined, (rawError) => { + const error = locatedError(rawError, fieldNodes, pathToArray(path)); + const handledError = handleFieldError(error, returnType, errors); + filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + return handledError; + }); } + const completed = completeValue( + exeContext, + returnType, + fieldNodes, + info, + path, + result, + asyncPayloadRecord, + ); + if (isPromise(completed)) { // Note: we don't rely on a `catch` method, but we do expect "thenable" // to take a second callback for the error case. @@ -1148,31 +1155,43 @@ function completeListItemValue( itemPath: Path, asyncPayloadRecord?: AsyncPayloadRecord, ): boolean { - try { - let completedItem; - if (isPromise(item)) { - completedItem = item.then((resolved) => - completeValue( - exeContext, - itemType, - fieldNodes, - info, - itemPath, - resolved, - asyncPayloadRecord, - ), - ); - } else { - completedItem = completeValue( + if (isPromise(item)) { + const completedItem = item.then((resolved) => + completeValue( exeContext, itemType, fieldNodes, info, itemPath, - item, + resolved, asyncPayloadRecord, - ); - } + ), + ); + + // Note: we don't rely on a `catch` method, but we do expect "thenable" + // to take a second callback for the error case. + completedResults.push( + completedItem.then(undefined, (rawError) => { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const handledError = handleFieldError(error, itemType, errors); + filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + return handledError; + }), + ); + + return true; + } + + try { + const completedItem = completeValue( + exeContext, + itemType, + fieldNodes, + info, + itemPath, + item, + asyncPayloadRecord, + ); if (isPromise(completedItem)) { // Note: we don't rely on a `catch` method, but we do expect "thenable" @@ -1877,51 +1896,54 @@ function executeStreamField( parentContext, exeContext, }); - let completedItem: PromiseOrValue; - try { - try { - if (isPromise(item)) { - completedItem = item.then((resolved) => - completeValue( - exeContext, - itemType, - fieldNodes, - info, - itemPath, - resolved, - asyncPayloadRecord, - ), - ); - } else { - completedItem = completeValue( + if (isPromise(item)) { + const completedItems = item + .then((resolved) => + completeValue( exeContext, itemType, fieldNodes, info, itemPath, - item, + resolved, asyncPayloadRecord, + ), + ) + .then(undefined, (rawError) => { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const handledError = handleFieldError( + error, + itemType, + asyncPayloadRecord.errors, ); - } + filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + return handledError; + }) + .then( + (value) => [value], + (error) => { + asyncPayloadRecord.errors.push(error); + filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + return null; + }, + ); - if (isPromise(completedItem)) { - // Note: we don't rely on a `catch` method, but we do expect "thenable" - // to take a second callback for the error case. - completedItem = completedItem.then(undefined, (rawError) => { - const error = locatedError( - rawError, - fieldNodes, - pathToArray(itemPath), - ); - const handledError = handleFieldError( - error, - itemType, - asyncPayloadRecord.errors, - ); - filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); - return handledError; - }); - } + asyncPayloadRecord.addItems(completedItems); + return asyncPayloadRecord; + } + + let completedItem: PromiseOrValue; + try { + try { + completedItem = completeValue( + exeContext, + itemType, + fieldNodes, + info, + itemPath, + item, + asyncPayloadRecord, + ); } catch (rawError) { const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); completedItem = handleFieldError( @@ -1938,21 +1960,32 @@ function executeStreamField( return asyncPayloadRecord; } - let completedItems: PromiseOrValue | null>; if (isPromise(completedItem)) { - completedItems = completedItem.then( - (value) => [value], - (error) => { - asyncPayloadRecord.errors.push(error); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); - return null; - }, - ); - } else { - completedItems = [completedItem]; + const completedItems = completedItem + .then(undefined, (rawError) => { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const handledError = handleFieldError( + error, + itemType, + asyncPayloadRecord.errors, + ); + filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + return handledError; + }) + .then( + (value) => [value], + (error) => { + asyncPayloadRecord.errors.push(error); + filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + return null; + }, + ); + + asyncPayloadRecord.addItems(completedItems); + return asyncPayloadRecord; } - asyncPayloadRecord.addItems(completedItems); + asyncPayloadRecord.addItems([completedItem]); return asyncPayloadRecord; }