Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

(experimental) - Add full support for multipart/mixed defer and stream directive responses (#1854)

* Add initial (isomorphic) incremental multipart support

This adds support for "multipart/mixed" GraphQL results, which
are used in delivering defer/stream responses.

See: https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md
See: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html

* Add comment header for meros licensing

WIP: This is temporary and we may swap out the wording here

* Fix related persistedFetchExchange test

* Add "No Content" exception to multipart without chunks

* Add errors merging to result's mergeResultPatch

* add example to test the functionality

* Add WIP implementation for defer in Graphcache

* Remove ctx.partial exception from Graphcache

* Add defer exception to Graphcache's write operation

* Replace defer FieldNode marking with ref

* Add initial layer preservation for deferred operations

* Handle deferred results in dedupExchange

* Handle noop updates for deferred operations in Graphcache

* Fix up execute exchange typings

* Restore failing test case for write.ts

* Add initial tests for deferred data layering

* Update tests and snapshots

* Fix issues in fetchSource splitting

* Update ssrExchange to handle "hasNext" deferred results

* Add test for deferred queries to Graphcache

* Add async iterator support to @urql/exchange-execute

* Update name of example

* Add changesets

* add test for fetchSource

* use correct dep

* add tests for defer

* Update Comparison docs

* Format src/operations/query.ts

* Simplify mergeResultPatch implementation

* Update example to include Graphcache

Co-authored-by: Jovi De Croock <decroockjovi@gmail.com>

authored by

Phil Pluckthun
Jovi De Croock
and committed by
GitHub
d8bcc88a 6cc8a24c

+1414 -213
+7
.changeset/tall-books-develop.md
··· 1 + --- 2 + '@urql/exchange-graphcache': minor 3 + '@urql/core': minor 4 + --- 5 + 6 + Add **experimental** support for `@defer` and `@stream` responses for GraphQL. This implements the ["GraphQL Defer and Stream Directives"](https://github.com/graphql/graphql-spec/blob/4fd39e0/rfcs/DeferStream.md) and ["Incremental Delivery over HTTP"](https://github.com/graphql/graphql-over-http/blob/290b0e2/rfcs/IncrementalDelivery.md) specifications. If a GraphQL API supports `multipart/mixed` responses for deferred and streamed delivery of GraphQL results, `@urql/core` (and all its derived fetch implementations) will attempt to stream results. This is _only supported_ on browsers [supporting streamed fetch responses](https://developer.mozilla.org/en-US/docs/Web/API/Response/body), which excludes IE11. 7 + The implementation of streamed multipart responses is derived from [`meros` by `@maraisr`](https://github.com/maraisr/meros), and is subject to change if the RFCs end up changing.
+5
.changeset/unlucky-dogs-repair.md
··· 1 + --- 2 + '@urql/exchange-execute': minor 3 + --- 4 + 5 + Support async iterated results, including subscriptions via `AsyncIterator` support and `@defer` / `@stream` if the appropriate version of `graphql` is used, e.g. `15.4.0-experimental-stream-defer.1`.
+1 -1
docs/comparison.md
··· 51 51 | Persisted Queries | ✅ `@urql/exchange-persisted-fetch` | ✅ `apollo-link-persisted-queries` | ✅ | 52 52 | Batched Queries | 🛑 | ✅ `apollo-link-batch-http` | 🟡 `react-relay-network-layer` | 53 53 | Live Queries | 🛑 | 🛑 | ✅ | 54 - | Defer & Stream Directives | 🛑 | 🛑 | ✅ | 54 + | Defer & Stream Directives | ✅ | 🛑 | 🟡 (unreleased) | 55 55 | Switching to `GET` method | ✅ | ✅ | 🟡 `react-relay-network-layer` | 56 56 | File Uploads | ✅ `@urql/exchange-multipart-fetch` | 🟡 `apollo-upload-client` | 🛑 | 57 57 | Retrying Failed Queries | ✅ `@urql/exchange-retry` | ✅ `apollo-link-retry` | ✅ `DefaultNetworkLayer` |
+15 -14
examples/README.md
··· 1 - | Example | Description | 2 - | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | 3 - | [`with-react`](./with-react) | Shows a basic query in `urql` with React. | 4 - | [`with-react-native`](./with-react-native) | Shows a basic query in `urql` with React Native. | 5 - | [`with-svelte`](./with-svelte) | Shows a basic query in `@urql/svelte` with Svelte. | 6 - | [`with-vue3`](./with-vue3) | Shows a basic query in `@urql/vue` with Vue 3. | 7 - | [`with-next`](./with-next) | Shows some examples with `next-urql` in Next.js with the default, `getStaticProps` and `getServerSideProps`. | 8 - | [`with-pagination`](./with-pagination) | Shows how to generically set up infinite pagination with `urql` in UI code. | 9 - | [`with-apq`](./with-apq) | Shows Automatic Persisted Queries with `@urql/exchange-persisted-fetch`. | 10 - | [`with-graphcache-updates`](./with-graphcache-updates) | Shows manual cache updates with `@urql/exchange-graphcache`. | 11 - | [`with-graphcache-pagination`](./with-graphcache-pagination) | Shows the automatic infinite pagination helpers from `@urql/exchange-graphcache`. | 12 - | [`with-multipart`](./with-multipart) | Shows file upload support with `@urql/exchange-multipart-fetch`. | 13 - | [`with-refresh-auth`](./with-refresh-auth) | Shows an example of authentication with refresh tokens using `@urql/exchange-auth`. | 14 - | [`with-retry`](./with-retry) | Shows how to set up `@urql/exchange-retry` for retrying failed operations. | 1 + | Example | Description | 2 + | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | 3 + | [`with-react`](./with-react) | Shows a basic query in `urql` with React. | 4 + | [`with-react-native`](./with-react-native) | Shows a basic query in `urql` with React Native. | 5 + | [`with-svelte`](./with-svelte) | Shows a basic query in `@urql/svelte` with Svelte. | 6 + | [`with-vue3`](./with-vue3) | Shows a basic query in `@urql/vue` with Vue 3. | 7 + | [`with-next`](./with-next) | Shows some examples with `next-urql` in Next.js with the default, `getStaticProps` and `getServerSideProps`. | 8 + | [`with-pagination`](./with-pagination) | Shows how to generically set up infinite pagination with `urql` in UI code. | 9 + | [`with-apq`](./with-apq) | Shows Automatic Persisted Queries with `@urql/exchange-persisted-fetch`. | 10 + | [`with-graphcache-updates`](./with-graphcache-updates) | Shows manual cache updates with `@urql/exchange-graphcache`. | 11 + | [`with-graphcache-pagination`](./with-graphcache-pagination) | Shows the automatic infinite pagination helpers from `@urql/exchange-graphcache`. | 12 + | [`with-multipart`](./with-multipart) | Shows file upload support with `@urql/exchange-multipart-fetch`. | 13 + | [`with-refresh-auth`](./with-refresh-auth) | Shows an example of authentication with refresh tokens using `@urql/exchange-auth`. | 14 + | [`with-retry`](./with-retry) | Shows how to set up `@urql/exchange-retry` for retrying failed operations. | 15 + | [`with-defer-stream-directives`](./with-defer-stream-directives) | Demonstrates `urql` and `@urql/exchange-graphcache` with built-in support for `@defer` and `@stream`. |
+20
examples/with-defer-stream-directives/README.md
··· 1 + # With `@defer` / `@stream` Directives 2 + 3 + This example shows `urql` in use [with `@defer` and `@stream` 4 + directives](https://graphql.org/blog/2020-12-08-improving-latency-with-defer-and-stream-directives) 5 + in GraphQL. 6 + 7 + To run this example install dependencies and run the `start` script: 8 + 9 + ```sh 10 + yarn install 11 + yarn run start 12 + # or 13 + npm install 14 + npm run start 15 + ``` 16 + 17 + This example contains: 18 + 19 + - The `urql` bindings and a React app with a client set up in [`src/App.jsx`](src/App.jsx) 20 + - A local `polka` server set up to test deferred and streamed results in [`server/`](server/).
+27
examples/with-defer-stream-directives/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>with-defer-stream-directives</title> 7 + <style> 8 + body { 9 + margin: 0; 10 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 11 + 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 12 + 'Helvetica Neue', sans-serif; 13 + -webkit-font-smoothing: antialiased; 14 + -moz-osx-font-smoothing: grayscale; 15 + } 16 + 17 + code { 18 + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 19 + monospace; 20 + } 21 + </style> 22 + </head> 23 + <body> 24 + <div id="root"></div> 25 + <script type="module" src="/src/index.jsx"></script> 26 + </body> 27 + </html>
+25
examples/with-defer-stream-directives/package.json
··· 1 + { 2 + "name": "with-defer-stream-directives", 3 + "version": "0.0.0", 4 + "private": true, 5 + "scripts": { 6 + "start": "concurrently -k \"vite\" \"node server/index.js\"" 7 + }, 8 + "dependencies": { 9 + "@urql/core": "^2.3.0", 10 + "@urql/exchange-graphcache": "^4.3.0", 11 + "graphql": "15.4.0-experimental-stream-defer.1", 12 + "react": "^17.0.2", 13 + "react-dom": "^17.0.2", 14 + "urql": "^2.0.2" 15 + }, 16 + "devDependencies": { 17 + "@polka/parse": "^1.0.0-next.0", 18 + "@vitejs/plugin-react-refresh": "^1.3.3", 19 + "concurrently": "^6.2.1", 20 + "cors": "^2.8.5", 21 + "graphql-helix": "^1.7.0", 22 + "polka": "^0.5.2", 23 + "vite": "^2.2.4" 24 + } 25 + }
+129
examples/with-defer-stream-directives/server/index.js
··· 1 + // Credits to https://github.com/maraisr/meros/blob/main/examples/relay-with-helix/server.js 2 + 3 + /* eslint-disable @typescript-eslint/no-var-requires, es5/no-generators, no-console */ 4 + const polka = require('polka'); 5 + const { json } = require('@polka/parse'); 6 + const cors = require('cors')(); 7 + const { getGraphQLParameters, processRequest } = require('graphql-helix'); 8 + const { 9 + GraphQLList, 10 + GraphQLObjectType, 11 + GraphQLSchema, 12 + GraphQLString, 13 + } = require('graphql'); 14 + 15 + const schema = new GraphQLSchema({ 16 + query: new GraphQLObjectType({ 17 + name: 'Query', 18 + fields: () => ({ 19 + alphabet: { 20 + type: new GraphQLList( 21 + new GraphQLObjectType({ 22 + name: 'Alphabet', 23 + fields: { 24 + char: { 25 + type: GraphQLString, 26 + }, 27 + }, 28 + }) 29 + ), 30 + resolve: async function* () { 31 + for (let letter = 65; letter <= 90; letter++) { 32 + await new Promise(resolve => setTimeout(resolve, 500)); 33 + yield { char: String.fromCharCode(letter) }; 34 + } 35 + }, 36 + }, 37 + song: { 38 + type: new GraphQLObjectType({ 39 + name: 'Song', 40 + fields: () => ({ 41 + firstVerse: { 42 + type: GraphQLString, 43 + resolve: () => "Now I know my ABC's.", 44 + }, 45 + secondVerse: { 46 + type: GraphQLString, 47 + resolve: () => 48 + new Promise(resolve => 49 + setTimeout( 50 + () => resolve("Next time won't you sing with me?"), 51 + 5000 52 + ) 53 + ), 54 + }, 55 + }), 56 + }), 57 + resolve: () => 58 + new Promise(resolve => setTimeout(() => resolve('goodbye'), 1000)), 59 + }, 60 + }), 61 + }), 62 + }); 63 + 64 + polka() 65 + .use(cors, json()) 66 + .use('/graphql', async (req, res) => { 67 + const request = { 68 + body: req.body, 69 + headers: req.headers, 70 + method: req.method, 71 + query: req.query, 72 + }; 73 + 74 + let { operationName, query, variables } = getGraphQLParameters(request); 75 + 76 + // BEWARE HACKS 77 + query = query.replace('initial_count', 'initialCount'); 78 + 79 + const result = await processRequest({ 80 + operationName, 81 + query, 82 + variables, 83 + request, 84 + schema, 85 + }); 86 + 87 + if (result.type === 'RESPONSE') { 88 + result.headers.forEach(({ name, value }) => res.setHeader(name, value)); 89 + res.writeHead(result.status, { 90 + 'Content-Type': 'application/json', 91 + }); 92 + res.end(JSON.stringify(result.payload)); 93 + } else if (result.type === 'MULTIPART_RESPONSE') { 94 + res.writeHead(200, { 95 + Connection: 'keep-alive', 96 + 'Content-Type': 'multipart/mixed; boundary="-"', 97 + 'Transfer-Encoding': 'chunked', 98 + }); 99 + 100 + req.on('close', () => { 101 + result.unsubscribe(); 102 + }); 103 + 104 + res.write('---'); 105 + 106 + await result.subscribe(result => { 107 + const chunk = Buffer.from(JSON.stringify(result), 'utf8'); 108 + const data = [ 109 + '', 110 + 'Content-Type: application/json; charset=utf-8', 111 + '', 112 + chunk, 113 + ]; 114 + 115 + if (result.hasNext) { 116 + data.push('---'); 117 + } 118 + 119 + res.write(data.join('\r\n')); 120 + }); 121 + 122 + res.write('\r\n-----\r\n'); 123 + res.end(); 124 + } 125 + }) 126 + .listen(3004, err => { 127 + if (err) throw err; 128 + console.log(`> Running on localhost:3004`); 129 + });
+34
examples/with-defer-stream-directives/src/App.jsx
··· 1 + import React from 'react'; 2 + import { 3 + createClient, 4 + Provider, 5 + dedupExchange, 6 + debugExchange, 7 + fetchExchange, 8 + } from 'urql'; 9 + 10 + import { cacheExchange } from '@urql/exchange-graphcache'; 11 + 12 + import Songs from './Songs'; 13 + 14 + const cache = cacheExchange({ 15 + keys: { 16 + Alphabet: data => data.char, 17 + Song: () => null, 18 + }, 19 + }); 20 + 21 + const client = createClient({ 22 + url: 'http://localhost:3004/graphql', 23 + exchanges: [dedupExchange, cache, debugExchange, fetchExchange], 24 + }); 25 + 26 + function App() { 27 + return ( 28 + <Provider value={client}> 29 + <Songs /> 30 + </Provider> 31 + ); 32 + } 33 + 34 + export default App;
+54
examples/with-defer-stream-directives/src/Songs.jsx
··· 1 + import React from 'react'; 2 + import { gql, useQuery } from 'urql'; 3 + 4 + const SecondVerseFragment = gql` 5 + fragment secondVerseFields on Song { 6 + secondVerse 7 + } 8 + `; 9 + 10 + const SONGS_QUERY = gql` 11 + query App_Query { 12 + song { 13 + firstVerse 14 + ...secondVerseFields @defer 15 + } 16 + alphabet @stream(initial_count: 3) { 17 + char 18 + } 19 + } 20 + 21 + ${SecondVerseFragment} 22 + `; 23 + 24 + const Song = React.memo(function Song({ song }) { 25 + return ( 26 + <section> 27 + <p>{song.firstVerse}</p> 28 + <p>{song.secondVerse}</p> 29 + </section> 30 + ); 31 + }); 32 + 33 + const LocationsList = () => { 34 + const [result] = useQuery({ 35 + query: SONGS_QUERY, 36 + }); 37 + 38 + const { data } = result; 39 + 40 + return ( 41 + <div> 42 + {data && ( 43 + <> 44 + <Song song={data.song} /> 45 + {data.alphabet.map(i => ( 46 + <div key={i.char}>{i.char}</div> 47 + ))} 48 + </> 49 + )} 50 + </div> 51 + ); 52 + }; 53 + 54 + export default LocationsList;
+11
examples/with-defer-stream-directives/src/index.jsx
··· 1 + import React from 'react'; 2 + import { render } from 'react-dom'; 3 + 4 + import App from './App'; 5 + 6 + render( 7 + <React.StrictMode> 8 + <App /> 9 + </React.StrictMode>, 10 + document.getElementById('root') 11 + );
+7
examples/with-defer-stream-directives/vite.config.js
··· 1 + import { defineConfig } from 'vite'; 2 + import reactRefresh from '@vitejs/plugin-react-refresh'; 3 + 4 + // https://vitejs.dev/config/ 5 + export default defineConfig({ 6 + plugins: [reactRefresh()], 7 + });
+1
exchanges/execute/src/execute.test.ts
··· 148 148 expect(response).toEqual({ 149 149 operation: queryOperation, 150 150 data: mockHttpResponseData, 151 + hasNext: false, 151 152 }); 152 153 }); 153 154 });
+82 -30
exchanges/execute/src/execute.ts
··· 1 1 import { 2 + Source, 2 3 pipe, 3 4 share, 4 5 filter, 5 - fromPromise, 6 6 takeUntil, 7 - onEnd, 8 7 mergeMap, 9 8 merge, 9 + make, 10 10 } from 'wonka'; 11 11 12 12 import { ··· 18 18 19 19 import { 20 20 Exchange, 21 + ExecutionResult, 21 22 makeResult, 22 23 makeErrorResult, 24 + mergeResultPatch, 23 25 Operation, 26 + OperationResult, 24 27 getOperationName, 25 28 } from '@urql/core'; 26 29 ··· 32 35 typeResolver?: GraphQLTypeResolver<any, any>; 33 36 } 34 37 38 + type ExecuteParams = Parameters<typeof execute>; 39 + 40 + const asyncIterator = 41 + typeof Symbol !== 'undefined' ? Symbol.asyncIterator : null; 42 + 43 + const makeExecuteSource = ( 44 + operation: Operation, 45 + args: ExecuteParams 46 + ): Source<OperationResult> => { 47 + return make<OperationResult>(observer => { 48 + let ended = false; 49 + 50 + Promise.resolve() 51 + .then(() => { 52 + if (ended) return; 53 + return execute(...args) as any; 54 + }) 55 + .then((result: ExecutionResult | AsyncIterable<ExecutionResult>) => { 56 + if (ended || !result) { 57 + return; 58 + } else if (!asyncIterator || !result[asyncIterator]) { 59 + observer.next(makeResult(operation, result as ExecutionResult)); 60 + return; 61 + } 62 + 63 + const iterator: AsyncIterator<ExecutionResult> = result[ 64 + asyncIterator! 65 + ](); 66 + let prevResult: OperationResult | null = null; 67 + 68 + function next({ 69 + done, 70 + value, 71 + }: { 72 + done?: boolean; 73 + value: ExecutionResult; 74 + }) { 75 + if (value) { 76 + observer.next( 77 + (prevResult = prevResult 78 + ? mergeResultPatch(prevResult, value) 79 + : makeResult(operation, value)) 80 + ); 81 + } 82 + 83 + if (!done && !ended) { 84 + return iterator.next().then(next); 85 + } 86 + } 87 + 88 + return iterator.next().then(next); 89 + }) 90 + .then(() => { 91 + observer.complete(); 92 + }) 93 + .catch(error => { 94 + observer.next(makeErrorResult(operation, error)); 95 + observer.complete(); 96 + }); 97 + 98 + return () => { 99 + ended = true; 100 + }; 101 + }); 102 + }; 103 + 35 104 /** Exchange for executing queries locally on a schema using graphql-js. */ 36 105 export const executeExchange = ({ 37 106 schema, ··· 57 126 58 127 const calculatedContext = 59 128 typeof context === 'function' ? context(operation) : context; 60 - 61 - let ended = false; 62 - 63 - const result = Promise.resolve() 64 - .then(() => { 65 - if (ended) return; 66 - 67 - return execute( 68 - schema, 69 - operation.query, 70 - rootValue, 71 - calculatedContext, 72 - operation.variables, 73 - getOperationName(operation.query), 74 - fieldResolver, 75 - typeResolver 76 - ); 77 - }) 78 - .then(result => makeResult(operation, result)) 79 - .catch(err => makeErrorResult(operation, err)); 80 - 81 129 return pipe( 82 - fromPromise(result), 83 - onEnd(() => { 84 - ended = true; 85 - }), 130 + makeExecuteSource(operation, [ 131 + schema, 132 + operation.query, 133 + rootValue, 134 + calculatedContext, 135 + operation.variables, 136 + getOperationName(operation.query), 137 + fieldResolver, 138 + typeResolver, 139 + ]), 86 140 takeUntil(teardown$) 87 141 ); 88 142 }) ··· 90 144 91 145 const forwardedOps$ = pipe( 92 146 sharedOps$, 93 - filter((operation: Operation) => { 94 - return operation.kind !== 'query' && operation.kind !== 'mutation'; 95 - }), 147 + filter(operation => operation.kind === 'teardown'), 96 148 forward 97 149 ); 98 150
+33 -6
exchanges/graphcache/src/ast/traversal.ts
··· 2 2 SelectionNode, 3 3 DocumentNode, 4 4 OperationDefinitionNode, 5 + FragmentSpreadNode, 6 + InlineFragmentNode, 5 7 valueFromASTUntyped, 6 8 Kind, 7 9 } from 'graphql'; ··· 42 44 return fragments; 43 45 }; 44 46 47 + /** Resolves @include and @skip directives to determine whether field is included. */ 45 48 export const shouldInclude = ( 46 49 node: SelectionNode, 47 50 vars: Variables 48 51 ): boolean => { 49 - const { directives } = node; 50 - if (!directives) return true; 51 - 52 52 // Finds any @include or @skip directive that forces the node to be skipped 53 - for (let i = 0, l = directives.length; i < l; i++) { 54 - const directive = directives[i]; 53 + for (let i = 0; node.directives && i < node.directives.length; i++) { 54 + const directive = node.directives[i]; 55 55 const name = getName(directive); 56 - 57 56 if ( 58 57 (name === 'include' || name === 'skip') && 59 58 directive.arguments && ··· 69 68 70 69 return true; 71 70 }; 71 + 72 + /** Resolves @defer directive to determine whether a fragment is potentially skipped. */ 73 + export const isDeferred = ( 74 + node: FragmentSpreadNode | InlineFragmentNode, 75 + vars: Variables 76 + ): boolean => { 77 + for (let i = 0; node.directives && i < node.directives.length; i++) { 78 + const directive = node.directives[i]; 79 + const name = getName(directive); 80 + if (name === 'defer') { 81 + for ( 82 + let j = 0; 83 + directive.arguments && j < directive.arguments.length; 84 + j++ 85 + ) { 86 + const argument = directive.arguments[i]; 87 + if (getName(argument) === 'if') { 88 + // Return whether `@defer(if: )` is enabled 89 + return !!valueFromASTUntyped(argument.value, vars); 90 + } 91 + } 92 + 93 + return true; 94 + } 95 + } 96 + 97 + return false; 98 + };
+132
exchanges/graphcache/src/cacheExchange.test.ts
··· 2126 2126 2127 2127 expect(data).toHaveProperty('node.name', 'subscription b'); 2128 2128 }); 2129 + 2130 + it('applies deferred results to previous layers', () => { 2131 + let normalData: any; 2132 + let deferredData: any; 2133 + let combinedData: any; 2134 + 2135 + const client = createClient({ url: 'http://0.0.0.0' }); 2136 + const { source: ops$, next: nextOp } = makeSubject<Operation>(); 2137 + const { source: res$, next: nextRes } = makeSubject<OperationResult>(); 2138 + 2139 + jest.spyOn(client, 'reexecuteOperation').mockImplementation(nextOp); 2140 + 2141 + const normalQuery = gql` 2142 + { 2143 + node { 2144 + id 2145 + name 2146 + } 2147 + } 2148 + `; 2149 + 2150 + const deferredQuery = gql` 2151 + { 2152 + ... @defer { 2153 + deferred { 2154 + id 2155 + name 2156 + } 2157 + } 2158 + } 2159 + `; 2160 + 2161 + const combinedQuery = gql` 2162 + { 2163 + node { 2164 + id 2165 + name 2166 + } 2167 + ... @defer { 2168 + deferred { 2169 + id 2170 + name 2171 + } 2172 + } 2173 + } 2174 + `; 2175 + 2176 + const forward = (ops$: Source<Operation>): Source<OperationResult> => 2177 + merge([ 2178 + pipe( 2179 + ops$, 2180 + filter(() => false) 2181 + ) as any, 2182 + res$, 2183 + ]); 2184 + 2185 + pipe( 2186 + cacheExchange()({ forward, client, dispatchDebug })(ops$), 2187 + tap(result => { 2188 + if (result.operation.kind === 'query') { 2189 + if (result.operation.key === 1) { 2190 + deferredData = result.data; 2191 + } else if (result.operation.key === 42) { 2192 + combinedData = result.data; 2193 + } else { 2194 + normalData = result.data; 2195 + } 2196 + } 2197 + }), 2198 + publish 2199 + ); 2200 + 2201 + const combinedOp = client.createRequestOperation('query', { 2202 + key: 42, 2203 + query: combinedQuery, 2204 + }); 2205 + const deferredOp = client.createRequestOperation('query', { 2206 + key: 1, 2207 + query: deferredQuery, 2208 + }); 2209 + const normalOp = client.createRequestOperation('query', { 2210 + key: 2, 2211 + query: normalQuery, 2212 + }); 2213 + 2214 + nextOp(combinedOp); 2215 + nextOp(deferredOp); 2216 + nextOp(normalOp); 2217 + 2218 + nextRes({ 2219 + operation: deferredOp, 2220 + data: { 2221 + __typename: 'Query', 2222 + }, 2223 + hasNext: true, 2224 + }); 2225 + 2226 + expect(deferredData).toHaveProperty('deferred', undefined); 2227 + 2228 + nextRes({ 2229 + operation: normalOp, 2230 + data: { 2231 + __typename: 'Query', 2232 + node: { 2233 + __typename: 'Node', 2234 + id: 2, 2235 + name: 'normal', 2236 + }, 2237 + }, 2238 + }); 2239 + 2240 + expect(normalData).toHaveProperty('node.id', 2); 2241 + expect(combinedData).toHaveProperty('deferred', undefined); 2242 + expect(combinedData).toHaveProperty('node.id', 2); 2243 + 2244 + nextRes({ 2245 + operation: deferredOp, 2246 + data: { 2247 + __typename: 'Query', 2248 + deferred: { 2249 + __typename: 'Node', 2250 + id: 1, 2251 + name: 'deferred', 2252 + }, 2253 + }, 2254 + hasNext: true, 2255 + }); 2256 + 2257 + expect(deferredData).toHaveProperty('deferred.id', 1); 2258 + expect(combinedData).toHaveProperty('deferred.id', 1); 2259 + expect(combinedData).toHaveProperty('node.id', 2); 2260 + }); 2129 2261 });
+3 -2
exchanges/graphcache/src/cacheExchange.ts
··· 198 198 const dependencies = optimisticKeysToDependencies.get(key); 199 199 collectPendingOperations(pendingOperations, dependencies); 200 200 optimisticKeysToDependencies.delete(key); 201 - } else { 202 - reserveLayer(store.data, operation.key); 203 201 } 202 + 203 + reserveLayer(store.data, operation.key, result.hasNext); 204 204 205 205 let queryDependencies: void | Dependencies; 206 206 let data: Data | null = result.data; ··· 322 322 outcome === 'partial') 323 323 ) { 324 324 result.stale = true; 325 + result.hasNext = true; 325 326 if (!isBlockedByOptimisticUpdate(dependencies)) { 326 327 client.reexecuteOperation( 327 328 toRequestPolicy(operation, 'network-only')
+11 -8
exchanges/graphcache/src/operations/query.ts
··· 44 44 makeContext, 45 45 updateContext, 46 46 getFieldError, 47 + deferRef, 47 48 } from './shared'; 48 49 49 50 import { ··· 152 153 153 154 // Check for any referential changes in the field's value 154 155 hasChanged = hasChanged || dataFieldValue !== output[fieldAlias]; 155 - output[fieldAlias] = dataFieldValue!; 156 + if (dataFieldValue !== undefined) output[fieldAlias] = dataFieldValue!; 156 157 157 158 // After processing the field, remove the current alias from the path again 158 159 ctx.__internal.path.pop(); ··· 416 417 // Now that dataFieldValue has been retrieved it'll be set on data 417 418 // If it's uncached (undefined) but nullable we can continue assembling 418 419 // a partial query result 419 - if ( 420 + if (dataFieldValue === undefined && deferRef.current) { 421 + // The field is undelivered and uncached, but is included in a deferred fragment 422 + hasFields = true; 423 + } else if ( 420 424 dataFieldValue === undefined && 421 425 ((store.schema && isFieldNullable(store.schema, typename, fieldName)) || 422 426 !!getFieldError(ctx)) ··· 424 428 // The field is uncached or has errored, so it'll be set to null and skipped 425 429 hasPartials = true; 426 430 dataFieldValue = null; 431 + } else if (dataFieldValue === undefined) { 432 + // If the field isn't deferred or partial then we have to abort 433 + ctx.__internal.path.pop(); 434 + return undefined; 427 435 } else { 428 436 // Otherwise continue as usual 429 437 hasFields = hasFields || fieldName !== '__typename'; ··· 431 439 432 440 // After processing the field, remove the current alias from the path again 433 441 ctx.__internal.path.pop(); 434 - // Return undefined immediately when a field is uncached 435 - if (dataFieldValue === undefined) { 436 - return undefined; 437 - } 438 - 439 442 // Check for any referential changes in the field's value 440 443 hasChanged = hasChanged || dataFieldValue !== output[fieldAlias]; 441 - output[fieldAlias] = dataFieldValue; 444 + if (dataFieldValue !== undefined) output[fieldAlias] = dataFieldValue; 442 445 } 443 446 444 447 ctx.partial = ctx.partial || hasPartials;
+13 -3
exchanges/graphcache/src/operations/shared.ts
··· 7 7 } from 'graphql'; 8 8 9 9 import { 10 + isDeferred, 10 11 isInlineFragment, 11 12 getTypeCondition, 12 13 getSelectionSet, ··· 18 19 import { warn, pushDebugNode, popDebugNode } from '../helpers/help'; 19 20 import { hasField } from '../store/data'; 20 21 import { Store, keyOfField } from '../store'; 22 + 21 23 import { getFieldArguments, shouldInclude, isInterfaceOfType } from '../ast'; 22 24 23 25 import { ··· 49 51 } 50 52 51 53 export const contextRef: { current: Context | null } = { current: null }; 54 + export const deferRef: { current: boolean } = { current: false }; 52 55 53 56 // Checks whether the current data field is a cache miss because of a GraphQLError 54 57 export const getFieldError = (ctx: Context): GraphQLError | undefined => ··· 155 158 select: SelectionSet, 156 159 ctx: Context 157 160 ): SelectionIterator => { 161 + let childDeferred = false; 158 162 let childIterator: SelectionIterator | void; 159 163 let index = 0; 160 164 161 165 return function next() { 162 - if (childIterator !== undefined) { 166 + if (!deferRef.current && childDeferred) deferRef.current = childDeferred; 167 + 168 + if (childIterator) { 163 169 const node = childIterator(); 164 - if (node !== undefined) { 170 + if (node != null) { 165 171 return node; 166 172 } 167 173 168 174 childIterator = undefined; 175 + childDeferred = false; 169 176 if (process.env.NODE_ENV !== 'production') { 170 177 popDebugNode(); 171 178 } ··· 190 197 entityKey, 191 198 ctx.variables 192 199 ); 193 - 194 200 if (isMatching) { 195 201 if (process.env.NODE_ENV !== 'production') { 196 202 pushDebugNode(typename, fragmentNode); 197 203 } 204 + 205 + childDeferred = !!isDeferred(node, ctx.variables); 206 + if (!deferRef.current && childDeferred) 207 + deferRef.current = childDeferred; 198 208 199 209 return (childIterator = makeSelectionIterator( 200 210 typename,
+11 -3
exchanges/graphcache/src/operations/write.ts
··· 44 44 makeContext, 45 45 updateContext, 46 46 getFieldError, 47 + deferRef, 47 48 } from './shared'; 48 49 49 50 export interface WriteResult { ··· 204 205 const fieldAlias = getFieldAlias(node); 205 206 let fieldValue = data[fieldAlias]; 206 207 208 + // Development check of undefined fields 207 209 if (process.env.NODE_ENV !== 'production') { 208 - if (!isRoot && fieldValue === undefined) { 210 + if (!isRoot && fieldValue === undefined && !deferRef.current) { 209 211 const advice = ctx.optimistic 210 212 ? '\nYour optimistic result may be missing a field!' 211 213 : ''; ··· 231 233 } 232 234 } 233 235 234 - // We simply skip all typenames fields and assume they've already been written above 235 - if (fieldName === '__typename') continue; 236 + if ( 237 + // Skip typename fields and assume they've already been written above 238 + fieldName === '__typename' || 239 + // Fields marked as deferred that aren't defined must be skipped 240 + (fieldValue === undefined && deferRef.current) 241 + ) { 242 + continue; 243 + } 236 244 237 245 // Add the current alias to the walked path before processing the field's value 238 246 ctx.__internal.path.push(fieldAlias);
+81
exchanges/graphcache/src/store/data.test.ts
··· 475 475 expect([...data.commutativeKeys]).toEqual([1]); 476 476 }); 477 477 }); 478 + 479 + describe('deferred changes', () => { 480 + it('keeps a deferred layer around until completion', () => { 481 + // initially it's unknown whether a layer is deferred 482 + InMemoryData.reserveLayer(data, 1); 483 + InMemoryData.reserveLayer(data, 2); 484 + 485 + InMemoryData.reserveLayer(data, 2); 486 + InMemoryData.initDataState('write', data, 2); 487 + InMemoryData.writeRecord('Query', 'index', 2); 488 + InMemoryData.clearDataState(); 489 + 490 + // The layer is marked as deferred via re-reserving it 491 + InMemoryData.reserveLayer(data, 1, true); 492 + InMemoryData.initDataState('write', data, 1); 493 + InMemoryData.clearDataState(); 494 + 495 + InMemoryData.initDataState('read', data, null); 496 + expect(InMemoryData.readRecord('Query', 'index')).toBe(2); 497 + 498 + // The layers must not be squashed 499 + expect(data.optimisticOrder).toEqual([2, 1]); 500 + 501 + // A future response may then clear the layer 502 + InMemoryData.reserveLayer(data, 1, false); 503 + InMemoryData.initDataState('write', data, 1); 504 + InMemoryData.writeRecord('Query', 'index', 1); 505 + InMemoryData.clearDataState(); 506 + 507 + // The layers must then be squashed 508 + expect(data.optimisticOrder).toEqual([]); 509 + }); 510 + 511 + it('keeps a deferred layer around even if it is the lowest', () => { 512 + // initially it's unknown whether a layer is deferred 513 + InMemoryData.reserveLayer(data, 1); 514 + InMemoryData.reserveLayer(data, 2); 515 + 516 + InMemoryData.reserveLayer(data, 2, true); 517 + InMemoryData.initDataState('write', data, 2); 518 + InMemoryData.writeRecord('Query', 'index', 2); 519 + InMemoryData.clearDataState(); 520 + 521 + InMemoryData.initDataState('read', data, null); 522 + expect(InMemoryData.readRecord('Query', 'index')).toBe(2); 523 + 524 + // The layers must not be squashed 525 + expect(data.optimisticOrder).toEqual([2, 1]); 526 + 527 + // A future response may not clear the layer 528 + InMemoryData.reserveLayer(data, 1); 529 + InMemoryData.initDataState('write', data, 1); 530 + InMemoryData.writeRecord('Query', 'index', 1); 531 + InMemoryData.clearDataState(); 532 + 533 + // The layers must then be squashed 534 + expect(data.optimisticOrder).toEqual([2]); 535 + InMemoryData.noopDataState(data, 2, false); 536 + expect(data.optimisticOrder).toEqual([]); 537 + }); 538 + 539 + it('unmarks deferred layers when they receive a noop write', () => { 540 + // initially it's unknown whether a layer is deferred 541 + InMemoryData.reserveLayer(data, 1); 542 + InMemoryData.reserveLayer(data, 2); 543 + 544 + InMemoryData.reserveLayer(data, 2); 545 + InMemoryData.initDataState('write', data, 2); 546 + InMemoryData.writeRecord('Query', 'index', 2); 547 + InMemoryData.clearDataState(); 548 + 549 + // The layer is marked as deferred via re-reserving it 550 + InMemoryData.reserveLayer(data, 1, true); 551 + InMemoryData.initDataState('write', data, 1); 552 + InMemoryData.clearDataState(); 553 + 554 + // The layer is then receiving a noop write 555 + InMemoryData.noopDataState(data, 1, false); 556 + expect(data.optimisticOrder).toEqual([]); 557 + }); 558 + });
+18 -2
exchanges/graphcache/src/store/data.ts
··· 47 47 records: NodeMap<EntityField>; 48 48 /** A map of entity links which are connections from one entity to another (key-value entries per entity) */ 49 49 links: NodeMap<Link>; 50 + /** A set of Query operation keys that are in-flight and deferred/streamed */ 51 + deferredKeys: Set<number>; 50 52 /** A set of Query operation keys that are in-flight and awaiting a result */ 51 53 commutativeKeys: Set<number>; 52 54 /** The order of optimistic layers */ ··· 150 152 while ( 151 153 --i >= 0 && 152 154 data.refLock[data.optimisticOrder[i]] && 153 - data.commutativeKeys.has(data.optimisticOrder[i]) 155 + data.commutativeKeys.has(data.optimisticOrder[i]) && 156 + !data.deferredKeys.has(data.optimisticOrder[i]) 154 157 ) { 155 158 squashLayer(data.optimisticOrder[i]); 156 159 } ··· 184 187 layerKey: number | null, 185 188 isOptimistic?: boolean 186 189 ) => { 190 + if (layerKey && !isOptimistic) data.deferredKeys.delete(layerKey); 187 191 initDataState('read', data, layerKey, isOptimistic); 188 192 clearDataState(); 189 193 }; ··· 222 226 refLock: makeDict(), 223 227 links: makeNodeMap(), 224 228 records: makeNodeMap(), 229 + deferredKeys: new Set(), 225 230 commutativeKeys: new Set(), 226 231 optimisticOrder: [], 227 232 storage: null, ··· 499 504 }; 500 505 501 506 /** Reserves an optimistic layer and preorders it */ 502 - export const reserveLayer = (data: InMemoryData, layerKey: number) => { 507 + export const reserveLayer = ( 508 + data: InMemoryData, 509 + layerKey: number, 510 + hasNext?: boolean 511 + ) => { 503 512 const index = data.optimisticOrder.indexOf(layerKey); 504 513 if (index === -1) { 505 514 // The new layer needs to be reserved in front of all other commutative ··· 515 524 data.optimisticOrder.unshift(layerKey); 516 525 } 517 526 527 + if (hasNext) { 528 + data.deferredKeys.add(layerKey); 529 + } else { 530 + data.deferredKeys.delete(layerKey); 531 + } 532 + 518 533 data.commutativeKeys.add(layerKey); 519 534 }; 520 535 ··· 537 552 delete data.refLock[layerKey]; 538 553 delete data.records.optimistic[layerKey]; 539 554 delete data.links.optimistic[layerKey]; 555 + data.deferredKeys.delete(layerKey); 540 556 } 541 557 }; 542 558
+3
exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
··· 339 339 }, 340 340 "error": undefined, 341 341 "extensions": undefined, 342 + "hasNext": false, 342 343 "operation": Object { 343 344 "context": Object { 344 345 "fetchOptions": [MockFunction] { ··· 513 514 }, 514 515 "error": undefined, 515 516 "extensions": undefined, 517 + "hasNext": false, 516 518 "operation": Object { 517 519 "context": Object { 518 520 "fetchOptions": [MockFunction] { ··· 663 665 }, 664 666 "error": undefined, 665 667 "extensions": undefined, 668 + "hasNext": false, 666 669 "operation": Object { 667 670 "context": Object { 668 671 "fetchOptions": [MockFunction] {
+30 -40
exchanges/persisted-fetch/src/persistedFetchExchange.test.ts
··· 2 2 * @jest-environment node 3 3 */ 4 4 5 - import { 6 - empty, 7 - fromValue, 8 - fromArray, 9 - pipe, 10 - Source, 11 - subscribe, 12 - toPromise, 13 - } from 'wonka'; 5 + import { empty, fromValue, fromArray, pipe, Source, toPromise } from 'wonka'; 14 6 15 7 import { DocumentNode, print } from 'graphql'; 16 8 import { Client, OperationResult } from '@urql/core'; 17 9 18 - import { queryOperation, mutationOperation } from './test-utils'; 10 + import { queryOperation } from './test-utils'; 19 11 import { hash } from './sha256'; 20 12 import { persistedFetchExchange } from './persistedFetchExchange'; 21 13 ··· 43 35 }; 44 36 45 37 fetch.mockResolvedValueOnce({ 46 - json: () => expected, 38 + json: () => Promise.resolve(expected), 47 39 }); 48 40 49 41 const actual = await pipe( ··· 69 61 }; 70 62 71 63 fetch 72 - .mockResolvedValueOnce({ json: () => expectedMiss }) 73 - .mockResolvedValueOnce({ json: () => expectedRetry }); 64 + .mockResolvedValueOnce({ 65 + json: () => Promise.resolve(expectedMiss), 66 + }) 67 + .mockResolvedValueOnce({ 68 + json: () => Promise.resolve(expectedRetry), 69 + }); 74 70 75 71 const actual = await pipe( 76 72 fromValue(queryOperation), ··· 96 92 }; 97 93 98 94 fetch 99 - .mockResolvedValueOnce({ json: () => expectedMiss }) 100 - .mockResolvedValueOnce({ json: () => expectedRetry }); 95 + .mockResolvedValueOnce({ 96 + json: () => Promise.resolve(expectedMiss), 97 + }) 98 + .mockResolvedValueOnce({ 99 + json: () => Promise.resolve(expectedRetry), 100 + }); 101 101 102 102 const actual = await pipe( 103 103 fromValue(queryOperation), ··· 125 125 }; 126 126 127 127 fetch 128 - .mockResolvedValueOnce({ json: () => expectedMiss }) 129 - .mockResolvedValueOnce({ json: () => expectedRetry }) 130 - .mockResolvedValueOnce({ json: () => expectedRetry }); 128 + .mockResolvedValueOnce({ 129 + json: () => Promise.resolve(expectedMiss), 130 + }) 131 + .mockResolvedValueOnce({ 132 + json: () => Promise.resolve(expectedRetry), 133 + }) 134 + .mockResolvedValueOnce({ 135 + json: () => Promise.resolve(expectedRetry), 136 + }); 131 137 132 138 const actual = await pipe( 133 139 fromArray([queryOperation, queryOperation]), ··· 141 147 expect(actual.data).not.toBeUndefined(); 142 148 }); 143 149 144 - it('ignores mutations', async () => { 145 - const result = jest.fn(); 146 - fetch.mockResolvedValueOnce(undefined); 147 - 148 - pipe( 149 - fromValue(mutationOperation), 150 - persistedFetchExchange()(exchangeArgs), 151 - subscribe(result) 152 - ); 153 - 154 - await Promise.resolve(); 155 - 156 - expect(result).toHaveBeenCalledTimes(0); 157 - expect(fetch).toHaveBeenCalledTimes(0); 158 - }); 159 - 160 150 it('correctly generates an SHA256 hash', async () => { 161 151 const expected = { 162 152 data: { ··· 164 154 }, 165 155 }; 166 156 167 - fetch.mockResolvedValueOnce({ 168 - json: () => expected, 157 + fetch.mockResolvedValue({ 158 + json: () => Promise.resolve(expected), 169 159 }); 170 160 171 161 const queryHash = await hash(print(queryOperation.query)); ··· 180 170 181 171 const body = JSON.parse(fetch.mock.calls[0][1].body); 182 172 183 - expect(queryHash).toMatchInlineSnapshot( 184 - `"bfa84414672fe625d36f2d2a52e1d3c1e71c5a01e79599c320db7656d6f014d4"` 173 + expect(queryHash).toBe( 174 + 'bfa84414672fe625d36f2d2a52e1d3c1e71c5a01e79599c320db7656d6f014d4' 185 175 ); 186 176 187 177 expect(body).toMatchObject({ ··· 202 192 }; 203 193 204 194 fetch.mockResolvedValueOnce({ 205 - json: () => expected, 195 + json: () => Promise.resolve(expected), 206 196 }); 207 197 208 198 const hashFn = jest.fn((_input: string, _doc: DocumentNode) => { ··· 246 236 }; 247 237 248 238 fetch.mockResolvedValueOnce({ 249 - json: () => expected, 239 + json: () => Promise.resolve(expected), 250 240 }); 251 241 252 242 const hashFn = jest.fn(() => Promise.resolve(''));
+1
packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
··· 339 339 }, 340 340 "error": undefined, 341 341 "extensions": undefined, 342 + "hasNext": false, 342 343 "operation": Object { 343 344 "context": Object { 344 345 "fetchOptions": [MockFunction] {
+1
packages/core/src/exchanges/__snapshots__/subscription.test.ts.snap
··· 5 5 "data": Object {}, 6 6 "error": undefined, 7 7 "extensions": undefined, 8 + "hasNext": false, 8 9 "operation": Object { 9 10 "context": Object { 10 11 "fetchOptions": Object {
+4 -2
packages/core/src/exchanges/dedup.ts
··· 30 30 return !isInFlight; 31 31 }; 32 32 33 - const afterOperationResult = ({ operation }: OperationResult) => { 34 - inFlightKeys.delete(operation.key); 33 + const afterOperationResult = ({ operation, hasNext }: OperationResult) => { 34 + if (!hasNext) { 35 + inFlightKeys.delete(operation.key); 36 + } 35 37 }; 36 38 37 39 return ops$ => {
+31 -1
packages/core/src/exchanges/ssr.test.ts
··· 19 19 20 20 beforeEach(() => { 21 21 input = makeSubject<Operation>(); 22 - output = jest.fn(); 22 + output = jest.fn(operation => ({ operation })); 23 23 forward = ops$ => pipe(ops$, map(output)); 24 24 client = { suspense: true } as any; 25 25 exchangeInput = { forward, client }; 26 + }); 27 + 28 + afterEach(() => { 29 + output.mockClear(); 26 30 }); 27 31 28 32 it('caches query results correctly', () => { ··· 158 162 expect(Object.keys(data).length).toBe(1); 159 163 expect(output).not.toHaveBeenCalled(); 160 164 expect(onPush).toHaveBeenCalledWith(queryResponse); 165 + }); 166 + 167 + it('resolves deferred, cached query results correctly', () => { 168 + const onPush = jest.fn(); 169 + 170 + const ssr = ssrExchange({ 171 + isClient: true, 172 + initialState: { 173 + [queryOperation.key]: { 174 + hasNext: true, 175 + ...(serializedQueryResponse as any), 176 + }, 177 + }, 178 + }); 179 + 180 + const { source: ops$, next } = input; 181 + const exchange = ssr(exchangeInput)(ops$); 182 + 183 + pipe(exchange, forEach(onPush)); 184 + next(queryOperation); 185 + 186 + const data = ssr.extractData(); 187 + expect(Object.keys(data).length).toBe(1); 188 + expect(output).toHaveBeenCalledTimes(1); 189 + expect(onPush).toHaveBeenCalledTimes(2); 190 + expect(onPush.mock.calls[1][0]).toEqual({ hasNext: true, ...queryResponse }); 161 191 }); 162 192 163 193 it('deletes cached results in non-suspense environments', async () => {
+23 -36
packages/core/src/exchanges/ssr.ts
··· 5 5 import { reexecuteOperation } from './cache'; 6 6 7 7 export interface SerializedResult { 8 + hasNext?: boolean; 8 9 data?: string | undefined; // JSON string of data 9 10 error?: { 10 11 graphQLErrors: Array<Partial<GraphQLError> | string>; ··· 29 30 extractData(): SSRData; 30 31 } 31 32 32 - const shouldSkip = ({ kind }: Operation) => 33 - kind !== 'subscription' && kind !== 'query'; 34 - 35 33 /** Serialize an OperationResult to plain JSON */ 36 34 const serializeResult = ({ 35 + hasNext, 37 36 data, 38 37 error, 39 38 }: OperationResult): SerializedResult => { 40 39 const result: SerializedResult = {}; 41 - 42 - if (data !== undefined) { 43 - result.data = JSON.stringify(data); 44 - } 40 + if (data !== undefined) result.data = JSON.stringify(data); 41 + if (hasNext) result.hasNext = true; 45 42 46 43 if (error) { 47 44 result.error = { ··· 68 65 const deserializeResult = ( 69 66 operation: Operation, 70 67 result: SerializedResult 71 - ): OperationResult => { 72 - const { error, data: dataJson } = result; 73 - 74 - const deserialized: OperationResult = { 75 - operation, 76 - data: dataJson ? JSON.parse(dataJson) : undefined, 77 - extensions: undefined, 78 - error: error 79 - ? new CombinedError({ 80 - networkError: error.networkError 81 - ? new Error(error.networkError) 82 - : undefined, 83 - graphQLErrors: 84 - error.graphQLErrors && error.graphQLErrors.length 85 - ? error.graphQLErrors 86 - : undefined, 87 - }) 88 - : undefined, 89 - }; 90 - 91 - return deserialized; 92 - }; 68 + ): OperationResult => ({ 69 + operation, 70 + data: result.data ? JSON.parse(result.data) : undefined, 71 + extensions: undefined, 72 + error: result.error 73 + ? new CombinedError({ 74 + networkError: result.error.networkError 75 + ? new Error(result.error.networkError) 76 + : undefined, 77 + graphQLErrors: result.error.graphQLErrors, 78 + }) 79 + : undefined, 80 + hasNext: result.hasNext, 81 + }); 93 82 94 83 /** The ssrExchange can be created to capture data during SSR and also to rehydrate it on the client */ 95 84 export const ssrExchange = (params?: SSRExchangeParams): SSRExchange => { ··· 111 100 } 112 101 }; 113 102 114 - const isCached = (operation: Operation) => { 115 - return !shouldSkip(operation) && data[operation.key] != null; 116 - }; 117 - 118 103 // The SSR Exchange is a temporary cache that can populate results into data for suspense 119 104 // On the client it can be used to retrieve these temporary results from a rehydrated cache 120 105 const ssr: SSRExchange = ({ client, forward }) => ops$ => { ··· 129 114 130 115 let forwardedOps$ = pipe( 131 116 sharedOps$, 132 - filter(op => !isCached(op)), 117 + filter( 118 + operation => !data[operation.key] || !!data[operation.key]!.hasNext 119 + ), 133 120 forward 134 121 ); 135 122 ··· 137 124 // it once, cachedOps$ needs to be merged after forwardedOps$ 138 125 let cachedOps$ = pipe( 139 126 sharedOps$, 140 - filter(op => isCached(op)), 127 + filter(operation => !!data[operation.key]), 141 128 map(op => { 142 129 const serialized = data[op.key]!; 143 130 const result = deserializeResult(op, serialized); ··· 156 143 forwardedOps$, 157 144 tap((result: OperationResult) => { 158 145 const { operation } = result; 159 - if (!shouldSkip(operation)) { 146 + if (operation.kind !== 'mutation') { 160 147 const serialized = serializeResult(result); 161 148 data[operation.key] = serialized; 162 149 }
+1 -1
packages/core/src/exchanges/subscription.ts
··· 45 45 46 46 export type SubscriptionForwarder = ( 47 47 operation: SubscriptionOperation 48 - ) => ObservableLike<ExecutionResult & { extensions?: Record<string, any> }>; 48 + ) => ObservableLike<ExecutionResult>; 49 49 50 50 /** This is called to create a subscription and needs to be hooked up to a transport client. */ 51 51 export interface SubscriptionExchangeOpts {
+1
packages/core/src/index.ts
··· 11 11 createRequest, 12 12 makeResult, 13 13 makeErrorResult, 14 + mergeResultPatch, 14 15 formatDocument, 15 16 maskTypename, 16 17 makeOperation,
+2
packages/core/src/internal/__snapshots__/fetchSource.test.ts.snap
··· 489 489 }, 490 490 "error": undefined, 491 491 "extensions": undefined, 492 + "hasNext": false, 492 493 "operation": Object { 493 494 "context": Object { 494 495 "fetchOptions": Object { ··· 653 654 }, 654 655 "error": undefined, 655 656 "extensions": undefined, 657 + "hasNext": false, 656 658 "operation": Object { 657 659 "context": Object { 658 660 "fetch": [MockFunction] {
+389 -5
packages/core/src/internal/fetchSource.test.ts
··· 1 - import { pipe, subscribe, toPromise } from 'wonka'; 1 + import { pipe, scan, subscribe, toPromise } from 'wonka'; 2 2 3 - import { queryOperation } from '../test-utils'; 3 + import { queryOperation, context } from '../test-utils'; 4 4 import { makeFetchSource } from './fetchSource'; 5 + import { gql } from '../gql'; 6 + import { OperationResult, Operation } from '../types'; 7 + import { makeOperation } from '../utils'; 5 8 6 9 const fetch = (global as any).fetch as jest.Mock; 7 10 const abort = jest.fn(); ··· 16 19 }; 17 20 }); 18 21 19 - afterEach(() => { 22 + beforeEach(() => { 20 23 fetch.mockClear(); 21 24 abort.mockClear(); 22 25 }); ··· 125 128 126 129 describe('on teardown', () => { 127 130 it('does not start the outgoing request on immediate teardowns', () => { 128 - fetch.mockRejectedValueOnce(abortError); 131 + fetch.mockRejectedValue(abortError); 129 132 130 133 const { unsubscribe } = pipe( 131 134 makeFetchSource(queryOperation, 'https://test.com/graphql', {}), ··· 138 141 }); 139 142 140 143 it('aborts the outgoing request', async () => { 141 - fetch.mockRejectedValueOnce(abortError); 144 + fetch.mockRejectedValue(abortError); 142 145 143 146 const { unsubscribe } = pipe( 144 147 makeFetchSource(queryOperation, 'https://test.com/graphql', {}), ··· 152 155 expect(abort).toHaveBeenCalledTimes(1); 153 156 }); 154 157 }); 158 + 159 + describe('on multipart/mixed', () => { 160 + const wrap = (json: object) => 161 + '\r\n' + 162 + 'Content-Type: application/json; charset=utf-8\r\n\r\n' + 163 + JSON.stringify(json) + 164 + '\r\n---'; 165 + 166 + it('listens for more responses (stream)', async () => { 167 + fetch.mockResolvedValue({ 168 + status: 200, 169 + headers: { 170 + get() { 171 + return 'multipart/mixed'; 172 + }, 173 + }, 174 + body: { 175 + getReader: function () { 176 + let cancelled = false; 177 + const results = [ 178 + { 179 + done: false, 180 + value: Buffer.from('\r\n---'), 181 + }, 182 + { 183 + done: false, 184 + value: Buffer.from( 185 + wrap({ 186 + hasNext: true, 187 + data: { 188 + author: { 189 + id: '1', 190 + name: 'Steve', 191 + __typename: 'Author', 192 + todos: [{ id: '1', text: 'stream', __typename: 'Todo' }], 193 + }, 194 + }, 195 + }) 196 + ), 197 + }, 198 + { 199 + done: false, 200 + value: Buffer.from( 201 + wrap({ 202 + path: ['author', 'todos', 1], 203 + data: { id: '2', text: 'defer', __typename: 'Todo' }, 204 + hasNext: true, 205 + }) 206 + ), 207 + }, 208 + { 209 + done: false, 210 + value: Buffer.from(wrap({ hasNext: false }) + '--'), 211 + }, 212 + { done: true }, 213 + ]; 214 + let count = 0; 215 + return { 216 + cancel: function () { 217 + cancelled = true; 218 + }, 219 + read: function () { 220 + if (cancelled) throw new Error('No'); 221 + 222 + return Promise.resolve(results[count++]); 223 + }, 224 + }; 225 + }, 226 + }, 227 + }); 228 + 229 + const streamedQueryOperation: Operation = makeOperation( 230 + 'query', 231 + { 232 + query: gql` 233 + query { 234 + author { 235 + id 236 + name 237 + todos @stream { 238 + id 239 + text 240 + } 241 + } 242 + } 243 + `, 244 + variables: {}, 245 + key: 1, 246 + }, 247 + context 248 + ); 249 + 250 + const chunks: OperationResult[] = await pipe( 251 + makeFetchSource(streamedQueryOperation, 'https://test.com/graphql', {}), 252 + scan((prev: OperationResult[], item) => [...prev, item], []), 253 + toPromise 254 + ); 255 + 256 + expect(chunks.length).toEqual(3); 257 + 258 + expect(chunks[0].data).toEqual({ 259 + author: { 260 + id: '1', 261 + name: 'Steve', 262 + __typename: 'Author', 263 + todos: [{ id: '1', text: 'stream', __typename: 'Todo' }], 264 + }, 265 + }); 266 + 267 + expect(chunks[1].data).toEqual({ 268 + author: { 269 + id: '1', 270 + name: 'Steve', 271 + __typename: 'Author', 272 + todos: [ 273 + { id: '1', text: 'stream', __typename: 'Todo' }, 274 + { id: '2', text: 'defer', __typename: 'Todo' }, 275 + ], 276 + }, 277 + }); 278 + 279 + expect(chunks[2].data).toEqual({ 280 + author: { 281 + id: '1', 282 + name: 'Steve', 283 + __typename: 'Author', 284 + todos: [ 285 + { id: '1', text: 'stream', __typename: 'Todo' }, 286 + { id: '2', text: 'defer', __typename: 'Todo' }, 287 + ], 288 + }, 289 + }); 290 + }); 291 + 292 + it('listens for more responses (defer)', async () => { 293 + fetch.mockResolvedValue({ 294 + status: 200, 295 + headers: { 296 + get() { 297 + return 'multipart/mixed'; 298 + }, 299 + }, 300 + body: { 301 + getReader: function () { 302 + let cancelled = false; 303 + const results = [ 304 + { 305 + done: false, 306 + value: Buffer.from('\r\n---'), 307 + }, 308 + { 309 + done: false, 310 + value: Buffer.from( 311 + wrap({ 312 + hasNext: true, 313 + data: { 314 + author: { 315 + id: '1', 316 + __typename: 'Author', 317 + }, 318 + }, 319 + }) 320 + ), 321 + }, 322 + { 323 + done: false, 324 + value: Buffer.from( 325 + wrap({ 326 + path: ['author'], 327 + data: { name: 'Steve' }, 328 + hasNext: true, 329 + }) 330 + ), 331 + }, 332 + { 333 + done: false, 334 + value: Buffer.from(wrap({ hasNext: false }) + '--'), 335 + }, 336 + { done: true }, 337 + ]; 338 + let count = 0; 339 + return { 340 + cancel: function () { 341 + cancelled = true; 342 + }, 343 + read: function () { 344 + if (cancelled) throw new Error('No'); 345 + 346 + return Promise.resolve(results[count++]); 347 + }, 348 + }; 349 + }, 350 + }, 351 + }); 352 + 353 + const AuthorFragment = gql` 354 + fragment authorFields on Author { 355 + name 356 + } 357 + `; 358 + 359 + const streamedQueryOperation: Operation = makeOperation( 360 + 'query', 361 + { 362 + query: gql` 363 + query { 364 + author { 365 + id 366 + ...authorFields @defer 367 + } 368 + } 369 + 370 + ${AuthorFragment} 371 + `, 372 + variables: {}, 373 + key: 1, 374 + }, 375 + context 376 + ); 377 + 378 + const chunks: OperationResult[] = await pipe( 379 + makeFetchSource(streamedQueryOperation, 'https://test.com/graphql', {}), 380 + scan((prev: OperationResult[], item) => [...prev, item], []), 381 + toPromise 382 + ); 383 + 384 + expect(chunks.length).toEqual(3); 385 + 386 + expect(chunks[0].data).toEqual({ 387 + author: { 388 + id: '1', 389 + __typename: 'Author', 390 + }, 391 + }); 392 + 393 + expect(chunks[1].data).toEqual({ 394 + author: { 395 + id: '1', 396 + name: 'Steve', 397 + __typename: 'Author', 398 + }, 399 + }); 400 + 401 + expect(chunks[2].data).toEqual({ 402 + author: { 403 + id: '1', 404 + name: 'Steve', 405 + __typename: 'Author', 406 + }, 407 + }); 408 + }); 409 + 410 + it('listens for more responses (defer-neted)', async () => { 411 + fetch.mockResolvedValue({ 412 + status: 200, 413 + headers: { 414 + get() { 415 + return 'multipart/mixed'; 416 + }, 417 + }, 418 + body: { 419 + getReader: function () { 420 + let cancelled = false; 421 + const results = [ 422 + { 423 + done: false, 424 + value: Buffer.from('\r\n---'), 425 + }, 426 + { 427 + done: false, 428 + value: Buffer.from( 429 + wrap({ 430 + hasNext: true, 431 + data: { 432 + author: { 433 + id: '1', 434 + name: 'Steve', 435 + address: { 436 + country: 'UK', 437 + __typename: 'Address', 438 + }, 439 + __typename: 'Author', 440 + }, 441 + }, 442 + }) 443 + ), 444 + }, 445 + { 446 + done: false, 447 + value: Buffer.from( 448 + wrap({ 449 + path: ['author', 'address'], 450 + data: { street: 'home' }, 451 + hasNext: true, 452 + }) 453 + ), 454 + }, 455 + { 456 + done: false, 457 + value: Buffer.from(wrap({ hasNext: false }) + '--'), 458 + }, 459 + { done: true }, 460 + ]; 461 + let count = 0; 462 + return { 463 + cancel: function () { 464 + cancelled = true; 465 + }, 466 + read: function () { 467 + if (cancelled) throw new Error('No'); 468 + 469 + return Promise.resolve(results[count++]); 470 + }, 471 + }; 472 + }, 473 + }, 474 + }); 475 + 476 + const AddressFragment = gql` 477 + fragment addressFields on Address { 478 + street 479 + } 480 + `; 481 + 482 + const streamedQueryOperation: Operation = makeOperation( 483 + 'query', 484 + { 485 + query: gql` 486 + query { 487 + author { 488 + id 489 + address { 490 + id 491 + country 492 + ...addressFields @defer 493 + } 494 + } 495 + } 496 + 497 + ${AddressFragment} 498 + `, 499 + variables: {}, 500 + key: 1, 501 + }, 502 + context 503 + ); 504 + 505 + const chunks: OperationResult[] = await pipe( 506 + makeFetchSource(streamedQueryOperation, 'https://test.com/graphql', {}), 507 + scan((prev: OperationResult[], item) => [...prev, item], []), 508 + toPromise 509 + ); 510 + 511 + expect(chunks.length).toEqual(3); 512 + 513 + expect(chunks[0].data).toEqual({ 514 + author: { 515 + id: '1', 516 + name: 'Steve', 517 + address: { 518 + country: 'UK', 519 + __typename: 'Address', 520 + }, 521 + __typename: 'Author', 522 + }, 523 + }); 524 + 525 + expect(chunks[1].data).toEqual({ 526 + author: { 527 + id: '1', 528 + name: 'Steve', 529 + address: { 530 + country: 'UK', 531 + street: 'home', 532 + __typename: 'Address', 533 + }, 534 + __typename: 'Author', 535 + }, 536 + }); 537 + }); 538 + });
+131 -42
packages/core/src/internal/fetchSource.ts
··· 1 + import { Source, make } from 'wonka'; 1 2 import { Operation, OperationResult } from '../types'; 2 - import { makeResult, makeErrorResult } from '../utils'; 3 - import { make } from 'wonka'; 3 + import { makeResult, makeErrorResult, mergeResultPatch } from '../utils'; 4 + 5 + const asyncIterator = 6 + typeof Symbol !== 'undefined' ? Symbol.asyncIterator : null; 7 + const decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null; 8 + const jsonHeaderRe = /content-type:[^\r\n]*application\/json/i; 9 + const boundaryHeaderRe = /boundary="?([^=";]+)"?/i; 4 10 5 - const executeFetch = ( 11 + type ChunkData = { done: false; value: Buffer | Uint8Array } | { done: true }; 12 + 13 + const toString = (input: Buffer | ArrayBuffer): string => 14 + typeof Buffer !== 'undefined' && Buffer.isBuffer(input) 15 + ? input.toString() 16 + : decoder!.decode(input); 17 + 18 + // DERIVATIVE: Copyright (c) 2021 Marais Rossouw <hi@marais.io> 19 + // See: https://github.com/maraisr/meros/blob/219fe95/src/browser.ts 20 + const executeIncrementalFetch = ( 21 + onResult: (result: OperationResult) => void, 6 22 operation: Operation, 7 - url: string, 8 - fetchOptions: RequestInit 9 - ): Promise<OperationResult> => { 10 - const fetcher = operation.context.fetch; 23 + response: Response 24 + ): Promise<void> => { 25 + // NOTE: Guarding against fetch polyfills here 26 + const contentType = 27 + (response.headers && response.headers.get('Content-Type')) || ''; 28 + if (!/multipart\/mixed/i.test(contentType)) { 29 + return response.json().then(payload => { 30 + onResult(makeResult(operation, payload, response)); 31 + }); 32 + } 33 + 34 + let boundary = '---'; 35 + const boundaryHeader = contentType.match(boundaryHeaderRe); 36 + if (boundaryHeader) boundary = '--' + boundaryHeader[1]; 37 + 38 + let read: () => Promise<ChunkData>; 39 + let cancel = () => { 40 + /*noop*/ 41 + }; 42 + if (asyncIterator && response[asyncIterator]) { 43 + const iterator = response[asyncIterator](); 44 + read = iterator.next.bind(iterator); 45 + } else if ('body' in response && response.body) { 46 + const reader = response.body.getReader(); 47 + cancel = reader.cancel.bind(reader); 48 + read = reader.read.bind(reader); 49 + } else { 50 + throw new TypeError('Streaming requests unsupported'); 51 + } 11 52 12 - let statusNotOk = false; 13 - let response: Response; 53 + let buffer = ''; 54 + let isPreamble = true; 55 + let nextResult: OperationResult | null = null; 56 + let prevResult: OperationResult | null = null; 14 57 15 - return (fetcher || fetch)(url, fetchOptions) 16 - .then((res: Response) => { 17 - response = res; 18 - statusNotOk = 19 - res.status < 200 || 20 - res.status >= (fetchOptions.redirect === 'manual' ? 400 : 300); 21 - return res.json(); 22 - }) 23 - .then((result: any) => { 24 - if (!('data' in result) && !('errors' in result)) { 25 - throw new Error('No Content'); 58 + function next(data: ChunkData): Promise<void> | void { 59 + if (!data.done) { 60 + const chunk = toString(data.value); 61 + let boundaryIndex = chunk.indexOf(boundary); 62 + if (boundaryIndex > -1) { 63 + boundaryIndex += buffer.length; 64 + } else { 65 + boundaryIndex = buffer.indexOf(boundary); 26 66 } 27 67 28 - return makeResult(operation, result, response); 29 - }) 30 - .catch((error: Error) => { 31 - if (error.name !== 'AbortError') { 32 - return makeErrorResult( 33 - operation, 34 - statusNotOk ? new Error(response.statusText) : error, 35 - response 36 - ); 68 + buffer += chunk; 69 + while (boundaryIndex > -1) { 70 + const current = buffer.slice(0, boundaryIndex); 71 + const next = buffer.slice(boundaryIndex + boundary.length); 72 + 73 + if (isPreamble) { 74 + isPreamble = false; 75 + } else { 76 + const headersEnd = current.indexOf('\r\n\r\n') + 4; 77 + const headers = current.slice(0, headersEnd); 78 + const body = current.slice(headersEnd, current.lastIndexOf('\r\n')); 79 + 80 + let payload: any; 81 + if (jsonHeaderRe.test(headers)) { 82 + try { 83 + payload = JSON.parse(body); 84 + nextResult = prevResult = prevResult 85 + ? mergeResultPatch(prevResult, payload, response) 86 + : makeResult(operation, payload, response); 87 + } catch (_error) {} 88 + } 89 + 90 + if (next.slice(0, 2) === '--' || (payload && !payload.hasNext)) { 91 + if (!prevResult) 92 + return onResult(makeResult(operation, {}, response)); 93 + break; 94 + } 95 + } 96 + 97 + buffer = next; 98 + boundaryIndex = buffer.indexOf(boundary); 37 99 } 38 - }) as Promise<OperationResult>; 100 + } 101 + 102 + if (nextResult) { 103 + onResult(nextResult); 104 + nextResult = null; 105 + } 106 + 107 + if (!data.done && (!prevResult || prevResult.hasNext)) { 108 + return read().then(next); 109 + } 110 + } 111 + 112 + return read().then(next).finally(cancel); 39 113 }; 40 114 41 115 export const makeFetchSource = ( 42 116 operation: Operation, 43 117 url: string, 44 118 fetchOptions: RequestInit 45 - ) => { 119 + ): Source<OperationResult> => { 120 + const maxStatus = fetchOptions.redirect === 'manual' ? 400 : 300; 121 + const fetcher = operation.context.fetch; 122 + 46 123 return make<OperationResult>(({ next, complete }) => { 47 124 const abortController = 48 125 typeof AbortController !== 'undefined' ? new AbortController() : null; 126 + if (abortController) { 127 + fetchOptions.signal = abortController.signal; 128 + } 49 129 50 130 let ended = false; 131 + let statusNotOk = false; 132 + let response: Response; 51 133 52 134 Promise.resolve() 53 135 .then(() => { 54 - if (ended) { 55 - return; 56 - } else if (abortController) { 57 - fetchOptions.signal = abortController.signal; 58 - } 136 + if (ended) return; 137 + return (fetcher || fetch)(url, fetchOptions); 138 + }) 139 + .then((_response: Response | void) => { 140 + if (!_response) return; 141 + response = _response; 142 + statusNotOk = response.status < 200 || response.status >= maxStatus; 143 + return executeIncrementalFetch(next, operation, response); 144 + }) 145 + .then(complete) 146 + .catch((error: Error) => { 147 + if (error.name !== 'AbortError') { 148 + const result = makeErrorResult( 149 + operation, 150 + statusNotOk ? new Error(response.statusText) : error, 151 + response 152 + ); 59 153 60 - return executeFetch(operation, url, fetchOptions); 61 - }) 62 - .then((result: OperationResult | undefined) => { 63 - if (!ended) { 64 - ended = true; 65 - if (result) next(result); 154 + next(result); 66 155 complete(); 67 156 } 68 157 });
+1 -1
packages/core/src/test-utils/samples.ts
··· 9 9 } from '../types'; 10 10 import { makeOperation } from '../utils'; 11 11 12 - const context: OperationContext = { 12 + export const context: OperationContext = { 13 13 fetchOptions: { 14 14 method: 'POST', 15 15 },
+16 -2
packages/core/src/types.ts
··· 1 1 import { TypedDocumentNode } from '@graphql-typed-document-node/core'; 2 - import { DocumentNode } from 'graphql'; 2 + import { GraphQLError, DocumentNode } from 'graphql'; 3 3 import { Source } from 'wonka'; 4 4 import { Client } from './client'; 5 5 import { CombinedError } from './utils/error'; 6 6 7 - export { ExecutionResult } from 'graphql'; 7 + export type ExecutionResult = 8 + | { 9 + errors?: Array<string | Partial<GraphQLError> | Error>; 10 + data?: null | Record<string, any>; 11 + extensions?: Record<string, any>; 12 + hasNext?: boolean; 13 + } 14 + | { 15 + errors?: Array<string | Partial<GraphQLError> | Error>; 16 + data: any; 17 + path: (string | number)[]; 18 + hasNext?: boolean; 19 + }; 8 20 9 21 export type PromisifiedSource<T = any> = Source<T> & { 10 22 toPromise: () => Promise<T>; ··· 71 83 extensions?: Record<string, any>; 72 84 /** Optional stale flag added by exchanges that return stale results. */ 73 85 stale?: boolean; 86 + /** Optional hasNext flag indicating deferred/streamed results are following. */ 87 + hasNext?: boolean; 74 88 } 75 89 76 90 /** Input parameters for to an Exchange factory function. */
+60 -14
packages/core/src/utils/result.ts
··· 1 - import { Operation, OperationResult } from '../types'; 1 + import { ExecutionResult, Operation, OperationResult } from '../types'; 2 2 import { CombinedError } from './error'; 3 3 4 4 export const makeResult = ( 5 5 operation: Operation, 6 - result: any, 6 + result: ExecutionResult, 7 7 response?: any 8 - ): OperationResult => ({ 9 - operation, 10 - data: result.data, 11 - error: Array.isArray(result.errors) 12 - ? new CombinedError({ 13 - graphQLErrors: result.errors, 14 - response, 15 - }) 16 - : undefined, 17 - extensions: 18 - (typeof result.extensions === 'object' && result.extensions) || undefined, 19 - }); 8 + ): OperationResult => { 9 + if ((!('data' in result) && !('errors' in result)) || 'path' in result) { 10 + throw new Error('No Content'); 11 + } 12 + 13 + return { 14 + operation, 15 + data: result.data, 16 + error: Array.isArray(result.errors) 17 + ? new CombinedError({ 18 + graphQLErrors: result.errors, 19 + response, 20 + }) 21 + : undefined, 22 + extensions: 23 + (typeof result.extensions === 'object' && result.extensions) || undefined, 24 + hasNext: !!result.hasNext, 25 + }; 26 + }; 27 + 28 + export const mergeResultPatch = ( 29 + prevResult: OperationResult, 30 + patch: ExecutionResult, 31 + response?: any 32 + ): OperationResult => { 33 + const result = { ...prevResult }; 34 + result.hasNext = !!patch.hasNext; 35 + 36 + if (!('path' in patch)) { 37 + if ('data' in patch) result.data = patch.data; 38 + return result; 39 + } 40 + 41 + if (Array.isArray(patch.errors)) { 42 + result.error = new CombinedError({ 43 + graphQLErrors: result.error 44 + ? [...result.error.graphQLErrors, ...patch.errors] 45 + : patch.errors, 46 + response, 47 + }); 48 + } 49 + 50 + let part: Record<string, any> | Array<any> = (result.data = { 51 + ...result.data, 52 + }); 53 + 54 + let i = 0; 55 + let prop: string | number; 56 + while (i < patch.path.length) { 57 + prop = patch.path[i++]; 58 + part = part[prop] = Array.isArray(part[prop]) 59 + ? [...part[prop]] 60 + : { ...part[prop] }; 61 + } 62 + 63 + Object.assign(part, patch.data); 64 + return result; 65 + }; 20 66 21 67 export const makeErrorResult = ( 22 68 operation: Operation,