diff --git a/.changeset/serious-feet-sparkle.md b/.changeset/serious-feet-sparkle.md new file mode 100644 index 00000000000..c592d656447 --- /dev/null +++ b/.changeset/serious-feet-sparkle.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +If a multipart chunk contains only `hasNext: false`, immediately complete the observable. diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index 63b24b634bf..dd7ffd9b8cf 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -1346,6 +1346,18 @@ describe('HttpLink', () => { '-----', ].join("\r\n"); + const finalChunkOnlyHasNextFalse = [ + "--graphql", + "content-type: application/json", + "", + '{"data":{"allProducts":[null,null,null]},"errors":[{"message":"Cannot return null for non-nullable field Product.nonNullErrorField."},{"message":"Cannot return null for non-nullable field Product.nonNullErrorField."},{"message":"Cannot return null for non-nullable field Product.nonNullErrorField."}],"hasNext":true}', + "--graphql", + "content-type: application/json", + "", + '{"hasNext":false}', + "--graphql--", + ].join("\r\n"); + it('whatwg stream bodies', (done) => { const stream = new ReadableStream({ async start(controller) { @@ -1418,6 +1430,73 @@ describe('HttpLink', () => { ); }); + // Verify that observable completes if final chunk does not contain + // incremental array. + it('whatwg stream bodies, final chunk of { hasNext: false }', (done) => { + const stream = new ReadableStream({ + async start(controller) { + const lines = finalChunkOnlyHasNextFalse.split("\r\n"); + try { + for (const line of lines) { + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.enqueue(line + "\r\n"); + } + } finally { + controller.close(); + } + }, + }); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ 'Content-Type': 'multipart/mixed;boundary="graphql";deferSpec=20220824' }), + })); + + const link = new HttpLink({ + fetch: fetch as any, + }); + + let i = 0; + execute(link, { query: sampleDeferredQuery }).subscribe( + result => { + try { + if (i === 0) { + expect(result).toMatchObject({ + data: { + allProducts: [ + null, + null, + null + ] + }, + // errors is also present, but for the purpose of this test + // we're not interested in its (lengthy) content. + // errors: [{...}], + hasNext: true, + }); + } + // Since the second chunk contains only hasNext: false, + // there is no next result to receive. + } catch (err) { + done(err); + } finally { + i++; + } + }, + err => { + done(err); + }, + () => { + if (i !== 1) { + done(new Error("Unexpected end to observable")); + } + + done(); + }, + ); + }); + it('node stream bodies', (done) => { const stream = Readable.from(body.split("\r\n").map((line) => line + "\r\n")); diff --git a/src/link/http/parseAndCheckHttpResponse.ts b/src/link/http/parseAndCheckHttpResponse.ts index c6a86c737aa..53e30c29082 100644 --- a/src/link/http/parseAndCheckHttpResponse.ts +++ b/src/link/http/parseAndCheckHttpResponse.ts @@ -100,6 +100,14 @@ export async function readMultipartBody< // we don't need to call observer.next as there is no data/errors observer.next?.(result); } + } else if ( + // If the chunk contains only a "hasNext: false", we can call + // observer.complete() immediately. + Object.keys(result).length === 1 && + "hasNext" in result && + !result.hasNext + ) { + observer.complete?.(); } } catch (err) { handleError(err, observer);