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.

Add DebugEvent system to urql client for devtools and debugging (#608)

* Add event support to urql client

* Allow for weaker types

* Add type safety (opt-in)

* Disable in prod

* Apply suggestions from code review

Co-Authored-By: Will Golledge <35961363+wgolledge@users.noreply.github.com>

* Add missing fixes

* Force exchange to be provided with event.

* Rename eventTarget to debugTarget

* Compile away dispatchEvent (#628)

* compile away dispatchEvent

* correct typo

* don't enforce callee name (client)

* Add optional to debugTarget type

* Rename to debugTarget in babel plugin

* rename files

* fix bug where objProperty is undefined

* fix linting

* add debugTarget to tests

* fix tests

* Add debug dispatches for fetch + fallback exchanges (#659)

* Add debug dispatching for fetchExchange

Co-authored-by: Will Golledge <wiggiumg@gmail.com>

* Add fallback exchange debugEvent dispatch

Co-authored-by: Will Golledge <wiggiumg@gmail.com>

* Amend Jovis comments

* Fix fetch tests

Co-authored-by: Will Golledge <wiggiumg@gmail.com>

* Add debugEvent dispatches for retryExchange and graphcacheExchange (#660)

* Add debugEvent dispatches for both the retryExchange and graphcacheExchange

Co-Authored-By: Jovi De Croock <jovi.decroock@formidable.com>

* Update changeset

* Append source to debug event on call

* Update dispatchEvent calls

* Isolate debug function construction to composeExchanges

* Fix cache exchange tests

* Fix graphcache exchange tests

* Fix fallback exchange tests

* Update client snapshot

* Update tests for subscription exchange

* Update tests for populate exchange

* Update tests for fetch exchange

* Update external exchanges

* Fix more tests

* Add test for dispatch function

* Add test for Target

* update withurqlclient tests

* Add babel transform changes

* update babel plugin (#671)

* update babel plugin

* add backwards compat check

* improve readability

* remove types import

* undo templating due to the addition of semicolons

Co-authored-by: Andy Richardson <andy.john.richardson@gmail.com>

* Update transform-debug-target.js

* Replace bad merge

* Delete debug.ts

* Add timestamp and uid to events

* Remove id

* Update test

* Update packages/core/src/types.ts

* Show error for successful network request with GraphQL errors

* Fix renamed variable

* Update exchanges/graphcache/src/cacheExchange.ts

* Apply suggestions from code review

* Update exchanges/retry/src/retryExchange.ts

* Use source for debug events (#710)

* Use source for debug events

* Update exposed API

* Fix type import

* Stop exposing debugTarget.dispatchDebug on Client

This makes the composition a little nicer, if we also
don't read it in composeExchanges.

* Restore backwards-compatibility by preserving fallbackExchangeIO

* Fix up unit tests

* Update changeset

The exchange packages depend on @urql/core and will bump their minimum
dependency range to include the new core version anyway.

* Update multipartFetchExchange and fix fetchSuccess condition

There's no clear "success" / "error" condition when we get a
result. A result can contain data and errors, hence we should
only log a result as a definitive error if we also don't have
data.

* Update changeset

* Fix compose.test.ts

* Update transform-debug-target transform

dispatchDebug cannot be undefined anymore (but will be a noop).

We can simply wrap the call expression in a ternary and let Terser/Closure
take care of eliminating the dead variables and assignments. This also
results in a lower chance of having a dead function stick around, which was
defined in the ObjectProperty transform.

* Update Client snapshot

* Add link to devtools to the changeset

Co-authored-by: Will Golledge <35961363+wgolledge@users.noreply.github.com>
Co-authored-by: Jovi De Croock <jovi.decroock@formidable.com>
Co-authored-by: Will Golledge <wiggiumg@gmail.com>
Co-authored-by: Phil Pluckthun <phil@kitten.sh>

+505 -112
+9
.changeset/early-camels-live.md
··· 1 + --- 2 + '@urql/exchange-graphcache': patch 3 + '@urql/exchange-multipart-fetch': patch 4 + '@urql/exchange-retry': patch 5 + '@urql/core': minor 6 + --- 7 + 8 + Add debugging events to exchanges that add more detailed information on what is happening 9 + internally, which will be displayed by devtools like the urql [Chrome / Firefox extension](https://github.com/FormidableLabs/urql-devtools)
+56 -20
exchanges/graphcache/src/cacheExchange.test.ts
··· 38 38 }, 39 39 }; 40 40 41 + const dispatchDebug = jest.fn(); 42 + 41 43 describe('data dependencies', () => { 42 44 it('writes queries to the cache', () => { 43 45 const client = createClient({ url: 'http://0.0.0.0' }); ··· 57 59 const result = jest.fn(); 58 60 const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 59 61 60 - pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish); 62 + pipe( 63 + cacheExchange({})({ forward, client, dispatchDebug })(ops$), 64 + tap(result), 65 + publish 66 + ); 61 67 62 68 next(op); 63 69 next(op); ··· 98 104 const result = jest.fn(); 99 105 const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 100 106 101 - pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish); 107 + pipe( 108 + cacheExchange({})({ forward, client, dispatchDebug })(ops$), 109 + tap(result), 110 + publish 111 + ); 102 112 103 113 next(op); 104 114 expect(response).toHaveBeenCalledTimes(0); ··· 165 175 const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 166 176 const result = jest.fn(); 167 177 168 - pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish); 178 + pipe( 179 + cacheExchange({})({ forward, client, dispatchDebug })(ops$), 180 + tap(result), 181 + publish 182 + ); 169 183 170 184 next(opOne); 171 185 expect(response).toHaveBeenCalledTimes(1); ··· 308 322 }; 309 323 310 324 pipe( 311 - cacheExchange({ updates, keys })({ forward, client })(ops$), 325 + cacheExchange({ updates, keys })({ forward, client, dispatchDebug })( 326 + ops$ 327 + ), 312 328 tap(result), 313 329 publish 314 330 ); ··· 385 401 const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 386 402 const result = jest.fn(); 387 403 388 - pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish); 404 + pipe( 405 + cacheExchange({})({ forward, client, dispatchDebug })(ops$), 406 + tap(result), 407 + publish 408 + ); 389 409 390 410 next(opOne); 391 411 expect(response).toHaveBeenCalledTimes(1); ··· 441 461 }; 442 462 443 463 pipe( 444 - cacheExchange({ updates })({ forward, client })(ops$), 464 + cacheExchange({ updates })({ forward, client, dispatchDebug })(ops$), 445 465 tap(result), 446 466 publish 447 467 ); ··· 501 521 }; 502 522 503 523 pipe( 504 - cacheExchange({ updates, optimistic })({ forward, client })(ops$), 524 + cacheExchange({ updates, optimistic })({ 525 + forward, 526 + client, 527 + dispatchDebug, 528 + })(ops$), 505 529 tap(result), 506 530 publish 507 531 ); ··· 562 586 }; 563 587 564 588 pipe( 565 - cacheExchange({ updates, optimistic })({ forward, client })(ops$), 589 + cacheExchange({ updates, optimistic })({ 590 + forward, 591 + client, 592 + dispatchDebug, 593 + })(ops$), 566 594 tap(result), 567 595 publish 568 596 ); ··· 649 677 }; 650 678 651 679 pipe( 652 - cacheExchange({ optimistic })({ forward, client })(ops$), 680 + cacheExchange({ optimistic })({ forward, client, dispatchDebug })(ops$), 653 681 tap(result), 654 682 publish 655 683 ); ··· 761 789 }; 762 790 763 791 pipe( 764 - cacheExchange({ optimistic, updates })({ forward, client })(ops$), 792 + cacheExchange({ optimistic, updates })({ 793 + forward, 794 + client, 795 + dispatchDebug, 796 + })(ops$), 765 797 tap(result), 766 798 publish 767 799 ); ··· 818 850 }, 819 851 }, 820 852 }, 821 - })({ forward, client })(ops$), 853 + })({ forward, client, dispatchDebug })(ops$), 822 854 tap(result), 823 855 publish 824 856 ); ··· 899 931 }, 900 932 }, 901 933 }, 902 - })({ forward, client })(ops$), 934 + })({ forward, client, dispatchDebug })(ops$), 903 935 tap(result), 904 936 publish 905 937 ); ··· 1049 1081 }, 1050 1082 }, 1051 1083 }, 1052 - })({ forward, client })(ops$), 1084 + })({ forward, client, dispatchDebug })(ops$), 1053 1085 tap(result), 1054 1086 publish 1055 1087 ); ··· 1180 1212 cacheExchange({ 1181 1213 // eslint-disable-next-line 1182 1214 schema: require('./test-utils/simple_schema.json'), 1183 - })({ forward, client })(ops$), 1215 + })({ forward, client, dispatchDebug })(ops$), 1184 1216 tap(result), 1185 1217 publish 1186 1218 ); ··· 1282 1314 mergeMap(result) 1283 1315 ); 1284 1316 1285 - pipe(cacheExchange()({ forward, client })(ops$), tap(output), publish); 1317 + pipe( 1318 + cacheExchange()({ forward, client, dispatchDebug })(ops$), 1319 + tap(output), 1320 + publish 1321 + ); 1286 1322 1287 1323 next(client.createRequestOperation('query', { key: 1, query })); 1288 1324 next(client.createRequestOperation('query', { key: 2, query })); ··· 1351 1387 }; 1352 1388 1353 1389 pipe( 1354 - cacheExchange({ optimistic })({ forward, client })(ops$), 1390 + cacheExchange({ optimistic })({ forward, client, dispatchDebug })(ops$), 1355 1391 tap(result => { 1356 1392 if (result.operation.operationName === 'query') { 1357 1393 data = result.data; ··· 1445 1481 ]); 1446 1482 1447 1483 pipe( 1448 - cacheExchange()({ forward, client })(ops$), 1484 + cacheExchange()({ forward, client, dispatchDebug })(ops$), 1449 1485 tap(result => { 1450 1486 if (result.operation.operationName === 'query') { 1451 1487 data = result.data; ··· 1557 1593 }; 1558 1594 1559 1595 pipe( 1560 - cacheExchange({ optimistic })({ forward, client })(ops$), 1596 + cacheExchange({ optimistic })({ forward, client, dispatchDebug })(ops$), 1561 1597 tap(result => { 1562 1598 if (result.operation.operationName === 'query') { 1563 1599 data = result.data; ··· 1642 1678 ]); 1643 1679 1644 1680 pipe( 1645 - cacheExchange()({ forward, client })(ops$), 1681 + cacheExchange()({ forward, client, dispatchDebug })(ops$), 1646 1682 tap(result => { 1647 1683 if (result.operation.operationName === 'query') { 1648 1684 data = result.data; ··· 1733 1769 ]); 1734 1770 1735 1771 pipe( 1736 - cacheExchange()({ forward, client })(ops$), 1772 + cacheExchange()({ forward, client, dispatchDebug })(ops$), 1737 1773 tap(result => { 1738 1774 if (result.operation.operationName === 'query') { 1739 1775 data = result.data;
+18 -1
exchanges/graphcache/src/cacheExchange.ts
··· 85 85 export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ 86 86 forward, 87 87 client, 88 + dispatchDebug, 88 89 }) => { 89 90 const store = new Store(opts); 90 91 ··· 305 306 res.operation.context.requestPolicy !== 'cache-only' 306 307 ); 307 308 }), 308 - map(res => addCacheOutcome(res.operation, 'miss')) 309 + map(res => { 310 + dispatchDebug({ 311 + type: 'cacheMiss', 312 + message: 'The result could not be retrieved from the cache', 313 + operation: res.operation, 314 + }); 315 + return addCacheOutcome(res.operation, 'miss'); 316 + }) 309 317 ); 310 318 311 319 // Resolve OperationResults that the cache was able to assemble completely and trigger ··· 337 345 toRequestPolicy(operation, 'network-only') 338 346 ); 339 347 } 348 + 349 + dispatchDebug({ 350 + type: 'cacheHit', 351 + message: `A requested operation was found and returned from the cache.`, 352 + operation: res.operation, 353 + data: { 354 + value: result, 355 + }, 356 + }); 340 357 341 358 return result; 342 359 }
+1
exchanges/multipart-fetch/src/multipartFetchExchange.test.ts
··· 44 44 const exchangeArgs = { 45 45 forward: () => empty as Source<OperationResult>, 46 46 client: {} as Client, 47 + dispatchDebug: jest.fn(), 47 48 }; 48 49 49 50 describe('on success', () => {
+52 -5
exchanges/multipart-fetch/src/multipartFetchExchange.ts
··· 1 1 import { Kind, DocumentNode, OperationDefinitionNode, print } from 'graphql'; 2 2 import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka'; 3 3 import { extractFiles } from 'extract-files'; 4 + 4 5 import { 6 + ExchangeInput, 5 7 Exchange, 6 8 Operation, 7 9 OperationResult, ··· 18 20 const isOperationFetchable = (operation: Operation) => 19 21 operation.operationName === 'query' || operation.operationName === 'mutation'; 20 22 21 - export const multipartFetchExchange: Exchange = ({ forward }) => ops$ => { 23 + export const multipartFetchExchange: Exchange = ({ 24 + forward, 25 + dispatchDebug, 26 + }) => ops$ => { 22 27 const sharedOps$ = share(ops$); 23 28 24 29 const fetchResults$ = pipe( ··· 36 41 createFetchSource( 37 42 operation, 38 43 operation.operationName === 'query' && 39 - !!operation.context.preferGetMethod 44 + !!operation.context.preferGetMethod, 45 + dispatchDebug 40 46 ), 41 47 takeUntil(teardown$) 42 48 ); ··· 62 68 return node && node.name ? node.name.value : null; 63 69 }; 64 70 65 - const createFetchSource = (operation: Operation, shouldUseGet: boolean) => { 71 + const createFetchSource = ( 72 + operation: Operation, 73 + shouldUseGet: boolean, 74 + dispatchDebug: ExchangeInput['dispatchDebug'] 75 + ) => { 66 76 if ( 67 77 process.env.NODE_ENV !== 'production' && 68 78 operation.operationName === 'subscription' ··· 143 153 let ended = false; 144 154 145 155 Promise.resolve() 146 - .then(() => (ended ? undefined : executeFetch(operation, fetchOptions))) 156 + .then(() => 157 + ended ? undefined : executeFetch(operation, fetchOptions, dispatchDebug) 158 + ) 147 159 .then((result: OperationResult | undefined) => { 148 160 if (!ended) { 149 161 ended = true; ··· 163 175 164 176 const executeFetch = ( 165 177 operation: Operation, 166 - opts: RequestInit 178 + opts: RequestInit, 179 + dispatchDebug: ExchangeInput['dispatchDebug'] 167 180 ): Promise<OperationResult> => { 168 181 const { url, fetch: fetcher } = operation.context; 169 182 let statusNotOk = false; 170 183 let response: Response; 171 184 185 + dispatchDebug({ 186 + type: 'fetchRequest', 187 + message: 'A fetch request is being executed.', 188 + operation, 189 + data: { 190 + url, 191 + fetchOptions: opts, 192 + }, 193 + }); 194 + 172 195 return (fetcher || fetch)(url, opts) 173 196 .then((res: Response) => { 174 197 response = res; ··· 182 205 throw new Error('No Content'); 183 206 } 184 207 208 + dispatchDebug({ 209 + type: result.errors && !result.data ? 'fetchError' : 'fetchSuccess', 210 + message: `A ${ 211 + result.errors ? 'failed' : 'successful' 212 + } fetch response has been returned.`, 213 + operation, 214 + data: { 215 + url, 216 + fetchOptions: opts, 217 + value: result, 218 + }, 219 + }); 220 + 185 221 return makeResult(operation, result, response); 186 222 }) 187 223 .catch((error: Error) => { 188 224 if (error.name !== 'AbortError') { 225 + dispatchDebug({ 226 + type: 'fetchError', 227 + message: error.name, 228 + operation, 229 + data: { 230 + url, 231 + fetchOptions: opts, 232 + value: error, 233 + }, 234 + }); 235 + 189 236 return makeErrorResult( 190 237 operation, 191 238 statusNotOk ? new Error(response.statusText) : error,
+1
exchanges/populate/src/populateExchange.test.ts
··· 66 66 const exchangeArgs = { 67 67 forward: a => a as any, 68 68 client: {} as Client, 69 + dispatchDebug: jest.fn(), 69 70 }; 70 71 71 72 describe('on mutation', () => {
+5
exchanges/retry/src/retryExchange.test.ts
··· 10 10 } from '@urql/core'; 11 11 import { retryExchange } from './retryExchange'; 12 12 13 + const dispatchDebug = jest.fn(); 14 + 13 15 beforeEach(() => { 14 16 jest.useFakeTimers(); 15 17 }); ··· 105 107 })({ 106 108 forward, 107 109 client, 110 + dispatchDebug, 108 111 })(ops$), 109 112 tap(result), 110 113 publish ··· 163 166 })({ 164 167 forward, 165 168 client, 169 + dispatchDebug, 166 170 })(ops$), 167 171 tap(result), 168 172 publish ··· 207 211 })({ 208 212 forward, 209 213 client, 214 + dispatchDebug, 210 215 })(ops$), 211 216 tap(result), 212 217 publish
+29 -8
exchanges/retry/src/retryExchange.ts
··· 41 41 const retryIf = 42 42 retryIfOption || ((err: CombinedError) => err && err.networkError); 43 43 44 - return ({ forward }) => ops$ => { 44 + return ({ forward, dispatchDebug }) => ops$ => { 45 45 const sharedOps$ = pipe(ops$, share); 46 46 const { source: retry$, next: nextRetryOperation } = makeSubject< 47 47 Operation ··· 51 51 retry$, 52 52 mergeMap((op: Operation) => { 53 53 const { key, context } = op; 54 - const retryCount = context.retryCount || 0; 54 + const retryCount = (context.retryCount || 0) + 1; 55 55 let delayAmount = context.retryDelay || MIN_DELAY; 56 56 57 57 const backoffFactor = Math.random() + 1.5; ··· 75 75 }) 76 76 ); 77 77 78 + dispatchDebug({ 79 + type: 'retryAttempt', 80 + message: `The operation has failed and a retry has been triggered (${retryCount} / ${MAX_ATTEMPTS})`, 81 + operation: op, 82 + data: { 83 + retryCount, 84 + }, 85 + }); 86 + 78 87 // Add new retryDelay and retryCount to operation 79 88 return pipe( 80 89 fromValue({ ··· 82 91 context: { 83 92 ...op.context, 84 93 retryDelay: delayAmount, 85 - retryCount: retryCount + 1, 94 + retryCount, 86 95 }, 87 96 }), 88 97 delay(delayAmount), ··· 97 106 forward, 98 107 share, 99 108 filter(res => { 109 + // Only retry if the error passes the conditional retryIf function (if passed) 110 + // or if the error contains a networkError 111 + if (!res.error || !retryIf(res.error)) { 112 + return true; 113 + } 114 + 100 115 const maxNumberAttemptsExceeded = 101 116 (res.operation.context.retryCount || 0) >= MAX_ATTEMPTS - 1; 102 - // Only retry if the error passes the conditional retryIf function (if passed) 103 - // or if the error contains a networkError 104 - if (res.error && retryIf(res.error) && !maxNumberAttemptsExceeded) { 117 + 118 + if (!maxNumberAttemptsExceeded) { 105 119 // Send failed responses to be retried by calling next on the retry$ subject 106 120 // Exclude operations that have been retried more than the specified max 107 121 nextRetryOperation(res.operation); 108 122 return false; 109 - } else { 110 - return true; 111 123 } 124 + 125 + dispatchDebug({ 126 + type: 'retryExhausted', 127 + message: 128 + 'Maximum number of retries has been reached. No further retries will be performed.', 129 + operation: res.operation, 130 + }); 131 + 132 + return true; 112 133 }) 113 134 ) as sourceT<OperationResult>; 114 135
+6 -4
exchanges/suspense/src/suspenseExchange.test.ts
··· 12 12 import { createClient, Operation, OperationResult } from 'urql'; 13 13 import { suspenseExchange } from './suspenseExchange'; 14 14 15 + const dispatchDebug = jest.fn(); 16 + 15 17 beforeEach(() => { 16 18 jest.useFakeTimers(); 17 19 }); ··· 28 30 const forward = jest.fn(() => fromArray([])); 29 31 const ops = fromArray([]); 30 32 31 - suspenseExchange({ client, forward })(ops); 33 + suspenseExchange({ client, forward, dispatchDebug })(ops); 32 34 expect(forward).toHaveBeenCalledWith(ops); 33 35 expect(warn).toHaveBeenCalled(); 34 36 warn.mockRestore(); ··· 47 49 ); 48 50 49 51 const res = pipe( 50 - suspenseExchange({ client, forward })(fromValue(operation)), 52 + suspenseExchange({ client, forward, dispatchDebug })(fromValue(operation)), 51 53 toArray 52 54 ); 53 55 ··· 69 71 const { source: ops, next: dispatch } = makeSubject<Operation>(); 70 72 71 73 pipe( 72 - suspenseExchange({ client, forward })(ops), 74 + suspenseExchange({ client, forward, dispatchDebug })(ops), 73 75 forEach(result => (prevResult = result)) 74 76 ); 75 77 ··· 99 101 const { source: ops, next: dispatch } = makeSubject<Operation>(); 100 102 101 103 pipe( 102 - suspenseExchange({ client, forward })(ops), 104 + suspenseExchange({ client, forward, dispatchDebug })(ops), 103 105 forEach(result => (prevResult = result)) 104 106 ); 105 107
+1
packages/core/src/__snapshots__/client.test.ts.snap
··· 18 18 "reexecuteOperation": [Function], 19 19 "requestPolicy": "cache-first", 20 20 "results$": [Function], 21 + "subscribeToDebugTarget": [Function], 21 22 "suspense": false, 22 23 "url": "https://hostname.com", 23 24 }
+25 -10
packages/core/src/client.ts
··· 17 17 publish, 18 18 subscribe, 19 19 map, 20 + Subscription, 20 21 } from 'wonka'; 21 22 22 - import { 23 - composeExchanges, 24 - defaultExchanges, 25 - fallbackExchangeIO, 26 - } from './exchanges'; 23 + import { composeExchanges, defaultExchanges } from './exchanges'; 24 + import { fallbackExchange } from './exchanges/fallback'; 27 25 28 26 import { 29 27 Exchange, 28 + ExchangeInput, 30 29 GraphQLRequest, 31 30 Operation, 32 31 OperationContext, ··· 34 33 OperationType, 35 34 RequestPolicy, 36 35 PromisifiedSource, 36 + DebugEvent, 37 37 } from './types'; 38 38 39 39 import { ··· 41 41 toSuspenseSource, 42 42 withPromise, 43 43 maskTypename, 44 + noop, 44 45 } from './utils'; 45 46 46 47 import { DocumentNode } from 'graphql'; ··· 73 74 74 75 /** The URQL application-wide client library. Each execute method starts a GraphQL request and returns a stream of results. */ 75 76 export class Client { 77 + // Event target for monitoring 78 + subscribeToDebugTarget?: (onEvent: (e: DebugEvent) => void) => Subscription; 79 + 76 80 // These are variables derived from ClientOptions 77 81 url: string; 78 82 fetch?: typeof fetch; ··· 94 98 throw new Error('You are creating an urql-client without a url.'); 95 99 } 96 100 101 + let dispatchDebug: ExchangeInput['dispatchDebug'] = noop; 102 + if (process.env.NODE_ENV !== 'production') { 103 + const { next, source } = makeSubject<DebugEvent>(); 104 + this.subscribeToDebugTarget = (onEvent: (e: DebugEvent) => void) => 105 + pipe(source, subscribe(onEvent)); 106 + dispatchDebug = next as ExchangeInput['dispatchDebug']; 107 + } 108 + 97 109 this.url = opts.url; 98 110 this.fetchOptions = opts.fetchOptions; 99 111 this.fetch = opts.fetch; ··· 124 136 125 137 const exchanges = 126 138 opts.exchanges !== undefined ? opts.exchanges : defaultExchanges; 139 + 127 140 // All exchange are composed into a single one and are called using the constructed client 128 141 // and the fallback exchange stream 129 - const exchange = composeExchanges(exchanges); 142 + const composedExchange = composeExchanges(exchanges); 130 143 131 - // All operations run through the exchanges in a pipeline-like fashion 132 - // and this observable then combines all their results 144 + // All exchanges receive inputs using which they can forward operations to the next exchange 145 + // and receive a stream of results in return, access the client, or dispatch debugging events 146 + // All operations then run through the Exchange IOs in a pipeline-like fashion 133 147 this.results$ = share( 134 - exchange({ 148 + composedExchange({ 135 149 client: this, 136 - forward: fallbackExchangeIO, 150 + dispatchDebug, 151 + forward: fallbackExchange({ dispatchDebug }), 137 152 })(this.operations$) 138 153 ); 139 154
+13 -9
packages/core/src/exchanges/cache.test.ts
··· 19 19 subscriptionResult, 20 20 undefinedQueryResponse, 21 21 } from '../test-utils'; 22 - import { Operation, OperationResult } from '../types'; 22 + import { Operation, OperationResult, ExchangeInput } from '../types'; 23 23 import { afterMutation, cacheExchange } from './cache'; 24 24 25 + const reexecuteOperation = jest.fn(); 26 + const dispatchDebug = jest.fn(); 27 + 25 28 let response; 26 - let exchangeArgs; 29 + let exchangeArgs: ExchangeInput; 27 30 let forwardedOperations: Operation[]; 28 - let reexecuteOperation; 29 31 let input: Subject<Operation>; 30 32 33 + beforeEach(jest.clearAllMocks); 34 + 31 35 beforeEach(() => { 32 36 response = queryResponse; 33 37 forwardedOperations = []; 34 - reexecuteOperation = jest.fn(); 35 38 input = makeSubject<Operation>(); 36 39 37 40 // Collect all forwarded operations ··· 49 52 reexecuteOperation: reexecuteOperation as any, 50 53 } as Client; 51 54 52 - exchangeArgs = { forward, client }; 55 + exchangeArgs = { forward, client, dispatchDebug }; 53 56 }); 54 57 55 58 describe('on query', () => { ··· 182 185 afterMutation( 183 186 resultCache, 184 187 operationCache, 185 - exchangeArgs.client 188 + exchangeArgs.client, 189 + dispatchDebug 186 190 )({ 187 191 ...mutationResponse, 188 192 data: { ··· 206 210 afterMutation( 207 211 resultCache, 208 212 operationCache, 209 - exchangeArgs.client 213 + exchangeArgs.client, 214 + dispatchDebug 210 215 )({ 211 216 ...mutationResponse, 212 217 operation: { ··· 250 255 beforeEach(() => { 251 256 response = undefinedQueryResponse; 252 257 forwardedOperations = []; 253 - reexecuteOperation = jest.fn(); 254 258 input = makeSubject<Operation>(); 255 259 256 260 // Collect all forwarded operations ··· 268 272 reexecuteOperation: reexecuteOperation as any, 269 273 } as Client; 270 274 271 - exchangeArgs = { forward, client }; 275 + exchangeArgs = { forward, client, dispatchDebug }; 272 276 }); 273 277 274 278 it('does not cache response', () => {
+31 -6
packages/core/src/exchanges/cache.ts
··· 2 2 import { filter, map, merge, pipe, share, tap } from 'wonka'; 3 3 4 4 import { Client } from '../client'; 5 - import { Exchange, Operation, OperationResult } from '../types'; 5 + import { Exchange, Operation, OperationResult, ExchangeInput } from '../types'; 6 6 import { 7 7 addMetadata, 8 8 collectTypesFromResponse, ··· 18 18 const shouldSkip = ({ operationName }: Operation) => 19 19 operationName !== 'mutation' && operationName !== 'query'; 20 20 21 - export const cacheExchange: Exchange = ({ forward, client }) => { 21 + export const cacheExchange: Exchange = ({ forward, client, dispatchDebug }) => { 22 22 const resultCache = new Map() as ResultCache; 23 23 const operationCache = Object.create(null) as OperationCache; 24 24 ··· 31 31 const handleAfterMutation = afterMutation( 32 32 resultCache, 33 33 operationCache, 34 - client 34 + client, 35 + dispatchDebug 35 36 ); 36 37 37 38 const handleAfterQuery = afterQuery(resultCache, operationCache); ··· 57 58 filter(op => !shouldSkip(op) && isOperationCached(op)), 58 59 map(operation => { 59 60 const cachedResult = resultCache.get(operation.key); 61 + 62 + dispatchDebug({ 63 + operation, 64 + ...(cachedResult 65 + ? { 66 + type: 'cacheHit', 67 + message: 'The result was successfully retried from the cache', 68 + } 69 + : { 70 + type: 'cacheMiss', 71 + message: 'The result could not be retrieved from the cache', 72 + }), 73 + }); 74 + 60 75 const result: OperationResult = { 61 76 ...cachedResult, 62 77 operation: addMetadata(operation, { ··· 126 141 export const afterMutation = ( 127 142 resultCache: ResultCache, 128 143 operationCache: OperationCache, 129 - client: Client 144 + client: Client, 145 + dispatchDebug: ExchangeInput['dispatchDebug'] 130 146 ) => (response: OperationResult) => { 131 147 const pendingOperations = new Set<number>(); 132 148 const { additionalTypenames } = response.operation.context; 133 149 134 - [ 150 + const typenames = [ 135 151 ...collectTypesFromResponse(response.data), 136 152 ...(additionalTypenames || []), 137 - ].forEach(typeName => { 153 + ]; 154 + 155 + dispatchDebug({ 156 + type: 'cacheInvalidation', 157 + message: `The following typenames have been invalidated: ${typenames}`, 158 + operation: response.operation, 159 + data: { typenames, response }, 160 + }); 161 + 162 + typenames.forEach(typeName => { 138 163 const operations = 139 164 operationCache[typeName] || (operationCache[typeName] = new Set()); 140 165 operations.forEach(key => {
+37 -5
packages/core/src/exchanges/compose.test.ts
··· 1 1 import { empty, Source } from 'wonka'; 2 - import { Client } from '../client'; 3 2 import { Exchange } from '../types'; 4 3 import { composeExchanges } from './compose'; 4 + import { noop } from '../utils'; 5 5 6 - const mockClient = {} as Client; 6 + const mockClient = {} as any; 7 + 8 + const forward = jest.fn(); 7 9 const noopExchange: Exchange = ({ forward }) => ops$ => forward(ops$); 8 10 9 - it('returns the first exchange if it is the only input', () => { 10 - expect(composeExchanges([noopExchange])).toBe(noopExchange); 11 + beforeEach(() => { 12 + jest.spyOn(Date, 'now').mockReturnValue(1234); 11 13 }); 12 14 13 15 it('composes exchanges correctly', () => { ··· 36 38 const exchange = composeExchanges([firstExchange, secondExchange]); 37 39 const outerFw = jest.fn(() => noopExchange) as any; 38 40 39 - exchange({ client: mockClient, forward: outerFw })(empty as Source<any>); 41 + exchange({ client: mockClient, forward: outerFw, dispatchDebug: noop })( 42 + empty as Source<any> 43 + ); 40 44 expect(outerFw).toHaveBeenCalled(); 41 45 expect(counter).toBe(4); 42 46 }); 47 + 48 + describe('on dispatchDebug', () => { 49 + it('dispatches debug event with exchange source name', () => { 50 + const dispatchDebug = jest.fn(); 51 + const debugArgs = { 52 + type: 'test', 53 + message: 'Hello', 54 + } as any; 55 + 56 + const testExchange: Exchange = ({ dispatchDebug }) => { 57 + dispatchDebug(debugArgs); 58 + return () => empty as Source<any>; 59 + }; 60 + 61 + composeExchanges([testExchange])({ 62 + client: mockClient, 63 + forward, 64 + dispatchDebug, 65 + }); 66 + 67 + expect(dispatchDebug).toBeCalledTimes(1); 68 + expect(dispatchDebug).toBeCalledWith({ 69 + ...debugArgs, 70 + timestamp: Date.now(), 71 + source: 'testExchange', 72 + }); 73 + }); 74 + });
+21 -12
packages/core/src/exchanges/compose.ts
··· 1 - import { Exchange } from '../types'; 1 + import { Exchange, ExchangeInput } from '../types'; 2 2 3 3 /** This composes an array of Exchanges into a single ExchangeIO function */ 4 - export const composeExchanges = (exchanges: Exchange[]): Exchange => { 5 - if (exchanges.length === 1) { 6 - return exchanges[0]; 7 - } 8 - 9 - return payload => { 10 - return exchanges.reduceRight((forward, exchange) => { 11 - return exchange({ client: payload.client, forward }); 12 - }, payload.forward); 13 - }; 14 - }; 4 + export const composeExchanges = (exchanges: Exchange[]) => ({ 5 + client, 6 + forward, 7 + dispatchDebug, 8 + }: ExchangeInput) => 9 + exchanges.reduceRight( 10 + (forward, exchange) => 11 + exchange({ 12 + client, 13 + forward, 14 + dispatchDebug(event) { 15 + dispatchDebug({ 16 + ...event, 17 + timestamp: Date.now(), 18 + source: exchange.name, 19 + }); 20 + }, 21 + }), 22 + forward 23 + );
+2 -2
packages/core/src/exchanges/dedup.test.ts
··· 7 7 Source, 8 8 Subject, 9 9 } from 'wonka'; 10 - import { Client } from '../client'; 11 10 import { 12 11 mutationOperation, 13 12 queryOperation, ··· 16 15 import { Operation } from '../types'; 17 16 import { dedupExchange } from './dedup'; 18 17 18 + const dispatchDebug = jest.fn(); 19 19 let shouldRespond = false; 20 20 let exchangeArgs; 21 21 let forwardedOperations: Operation[]; ··· 38 38 ); 39 39 }; 40 40 41 - exchangeArgs = { forward, subject: {} as Client }; 41 + exchangeArgs = { forward, client: {}, dispatchDebug }; 42 42 }); 43 43 44 44 it('forwards query operations correctly', async () => {
+13 -2
packages/core/src/exchanges/dedup.ts
··· 2 2 import { Exchange, Operation, OperationResult } from '../types'; 3 3 4 4 /** A default exchange for debouncing GraphQL requests. */ 5 - export const dedupExchange: Exchange = ({ forward }) => { 5 + export const dedupExchange: Exchange = ({ forward, dispatchDebug }) => { 6 6 const inFlightKeys = new Set<number>(); 7 7 8 8 const filterIncomingOperation = (operation: Operation) => { ··· 10 10 if (operationName === 'teardown') { 11 11 inFlightKeys.delete(key); 12 12 return true; 13 - } else if (operationName !== 'query' && operationName !== 'subscription') { 13 + } 14 + 15 + if (operationName !== 'query' && operationName !== 'subscription') { 14 16 return true; 15 17 } 16 18 17 19 const isInFlight = inFlightKeys.has(key); 18 20 inFlightKeys.add(key); 21 + 22 + if (isInFlight) { 23 + dispatchDebug({ 24 + type: 'dedup', 25 + message: 'An operation has been deduped.', 26 + operation, 27 + }); 28 + } 29 + 19 30 return !isInFlight; 20 31 }; 21 32
+5 -3
packages/core/src/exchanges/fallback.test.ts
··· 1 1 import { forEach, fromValue, pipe } from 'wonka'; 2 2 import { queryOperation, teardownOperation } from '../test-utils'; 3 - import { fallbackExchangeIO } from './fallback'; 3 + import { fallbackExchange } from './fallback'; 4 4 5 5 const consoleWarn = console.warn; 6 + 7 + const dispatchDebug = jest.fn(); 6 8 7 9 beforeEach(() => { 8 10 console.warn = jest.fn(); ··· 16 18 const res: any[] = []; 17 19 18 20 pipe( 19 - fallbackExchangeIO(fromValue(queryOperation)), 21 + fallbackExchange({ dispatchDebug })(fromValue(queryOperation)), 20 22 forEach(x => res.push(x)) 21 23 ); 22 24 ··· 28 30 const res: any[] = []; 29 31 30 32 pipe( 31 - fallbackExchangeIO(fromValue(teardownOperation)), 33 + fallbackExchange({ dispatchDebug })(fromValue(teardownOperation)), 32 34 forEach(x => res.push(x)) 33 35 ); 34 36
+21 -7
packages/core/src/exchanges/fallback.ts
··· 1 1 import { filter, pipe, tap } from 'wonka'; 2 - import { ExchangeIO, Operation } from '../types'; 2 + import { Operation, ExchangeIO, ExchangeInput } from '../types'; 3 + import { noop } from '../utils'; 3 4 4 5 /** This is always the last exchange in the chain; No operation should ever reach it */ 5 - export const fallbackExchangeIO: ExchangeIO = ops$ => 6 + export const fallbackExchange: ({ 7 + dispatchDebug, 8 + }: Pick<ExchangeInput, 'dispatchDebug'>) => ExchangeIO = ({ 9 + dispatchDebug, 10 + }) => ops$ => 6 11 pipe( 7 12 ops$, 8 - tap<Operation>(({ operationName }) => { 13 + tap<Operation>(operation => { 9 14 if ( 10 - operationName !== 'teardown' && 15 + operation.operationName !== 'teardown' && 11 16 process.env.NODE_ENV !== 'production' 12 17 ) { 13 - console.warn( 14 - `No exchange has handled operations of type "${operationName}". Check whether you've added an exchange responsible for these operations.` 15 - ); 18 + const message = `No exchange has handled operations of type "${operation.operationName}". Check whether you've added an exchange responsible for these operations.`; 19 + 20 + dispatchDebug({ 21 + type: 'fallbackCatch', 22 + message, 23 + operation, 24 + }); 25 + console.warn(message); 16 26 } 17 27 }), 18 28 /* All operations that skipped through the entire exchange chain should be filtered from the output */ 19 29 filter<any>(() => false) 20 30 ); 31 + 32 + export const fallbackExchangeIO: ExchangeIO = fallbackExchange({ 33 + dispatchDebug: noop, 34 + });
+6 -1
packages/core/src/exchanges/fetch.test.ts
··· 39 39 }; 40 40 41 41 const exchangeArgs = { 42 + dispatchDebug: jest.fn(), 42 43 forward: () => empty as Source<OperationResult>, 43 - client: {} as Client, 44 + client: ({ 45 + debugTarget: { 46 + dispatchEvent: jest.fn(), 47 + }, 48 + } as any) as Client, 44 49 }; 45 50 46 51 describe('on success', () => {
+48 -6
packages/core/src/exchanges/fetch.ts
··· 1 1 /* eslint-disable @typescript-eslint/no-use-before-define */ 2 2 import { Kind, DocumentNode, OperationDefinitionNode, print } from 'graphql'; 3 3 import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka'; 4 - import { Exchange, Operation, OperationResult } from '../types'; 4 + import { Exchange, Operation, OperationResult, ExchangeInput } from '../types'; 5 5 import { makeResult, makeErrorResult } from '../utils'; 6 6 7 7 interface Body { ··· 11 11 } 12 12 13 13 /** A default exchange for fetching GraphQL requests. */ 14 - export const fetchExchange: Exchange = ({ forward }) => { 14 + export const fetchExchange: Exchange = ({ forward, dispatchDebug }) => { 15 15 return ops$ => { 16 16 const sharedOps$ = share(ops$); 17 17 const fetchResults$ = pipe( ··· 33 33 createFetchSource( 34 34 operation, 35 35 operation.operationName === 'query' && 36 - !!operation.context.preferGetMethod 36 + !!operation.context.preferGetMethod, 37 + dispatchDebug 37 38 ), 38 39 takeUntil(teardown$) 39 40 ); ··· 65 66 return node ? node.name!.value : null; 66 67 }; 67 68 68 - const createFetchSource = (operation: Operation, shouldUseGet: boolean) => { 69 + const createFetchSource = ( 70 + operation: Operation, 71 + shouldUseGet: boolean, 72 + dispatchDebug: ExchangeInput['dispatchDebug'] 73 + ) => { 69 74 if ( 70 75 process.env.NODE_ENV !== 'production' && 71 76 operation.operationName === 'subscription' ··· 118 123 let ended = false; 119 124 120 125 Promise.resolve() 121 - .then(() => (ended ? undefined : executeFetch(operation, fetchOptions))) 126 + .then(() => 127 + ended ? undefined : executeFetch(operation, fetchOptions, dispatchDebug) 128 + ) 122 129 .then((result: OperationResult | undefined) => { 123 130 if (!ended) { 124 131 ended = true; ··· 138 145 139 146 const executeFetch = ( 140 147 operation: Operation, 141 - opts: RequestInit 148 + opts: RequestInit, 149 + dispatchDebug: ExchangeInput['dispatchDebug'] 142 150 ): Promise<OperationResult> => { 143 151 const { url, fetch: fetcher } = operation.context; 144 152 let statusNotOk = false; 145 153 let response: Response; 146 154 155 + dispatchDebug({ 156 + type: 'fetchRequest', 157 + message: 'A fetch request is being executed.', 158 + operation, 159 + data: { 160 + url, 161 + fetchOptions: opts, 162 + }, 163 + }); 164 + 147 165 return (fetcher || fetch)(url, opts) 148 166 .then((res: Response) => { 149 167 response = res; ··· 157 175 throw new Error('No Content'); 158 176 } 159 177 178 + dispatchDebug({ 179 + type: result.errors && !result.data ? 'fetchError' : 'fetchSuccess', 180 + message: `A ${ 181 + result.errors ? 'failed' : 'successful' 182 + } fetch response has been returned.`, 183 + operation, 184 + data: { 185 + url, 186 + fetchOptions: opts, 187 + value: result, 188 + }, 189 + }); 190 + 160 191 return makeResult(operation, result, response); 161 192 }) 162 193 .catch((error: Error) => { 163 194 if (error.name !== 'AbortError') { 195 + dispatchDebug({ 196 + type: 'fetchError', 197 + message: error.name, 198 + operation, 199 + data: { 200 + url, 201 + fetchOptions: opts, 202 + value: error, 203 + }, 204 + }); 205 + 164 206 return makeErrorResult( 165 207 operation, 166 208 statusNotOk ? new Error(response.statusText) : error,
+2
packages/core/src/exchanges/subscription.test.ts
··· 15 15 16 16 it('should return response data from forwardSubscription observable', async () => { 17 17 const exchangeArgs = { 18 + dispatchDebug: jest.fn(), 18 19 forward: () => empty as Source<OperationResult>, 19 20 client: {} as Client, 20 21 }; ··· 52 53 const unsubscribe = jest.fn(); 53 54 54 55 const exchangeArgs = { 56 + dispatchDebug: jest.fn(), 55 57 forward: () => empty as Source<OperationResult>, 56 58 client: { reexecuteOperation: reexecuteOperation as any } as Client, 57 59 };
+48 -4
packages/core/src/types.ts
··· 5 5 6 6 export { ExecutionResult } from 'graphql'; 7 7 8 - /** Utility type to Omit keys from an interface/object type */ 9 - export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; 10 - 11 8 export type PromisifiedSource<T = any> = Source<T> & { 12 9 toPromise: () => Promise<T>; 13 10 }; ··· 76 73 77 74 /** Input parameters for to an Exchange factory function. */ 78 75 export interface ExchangeInput { 76 + client: Client; 79 77 forward: ExchangeIO; 80 - client: Client; 78 + dispatchDebug: <T extends keyof DebugEventTypes | string>( 79 + t: DebugEventArg<T> 80 + ) => void; 81 81 } 82 82 83 83 /** Function responsible for listening for streamed [operations]{@link Operation}. */ ··· 85 85 86 86 /** Function responsible for receiving an observable [operation]{@link Operation} and returning a [result]{@link OperationResult}. */ 87 87 export type ExchangeIO = (ops$: Source<Operation>) => Source<OperationResult>; 88 + 89 + /** Debug event types (interfaced for declaration merging). */ 90 + export interface DebugEventTypes { 91 + // Cache exchange 92 + cacheHit: { value: any }; 93 + cacheInvalidation: { 94 + typenames: string[]; 95 + response: OperationResult; 96 + }; 97 + // Fetch exchange 98 + fetchRequest: { 99 + url: string; 100 + fetchOptions: RequestInit; 101 + }; 102 + fetchSuccess: { 103 + url: string; 104 + fetchOptions: RequestInit; 105 + value: object; 106 + }; 107 + fetchError: { 108 + url: string; 109 + fetchOptions: RequestInit; 110 + value: Error; 111 + }; 112 + // Retry exchange 113 + retryRetrying: { 114 + retryCount: number; 115 + }; 116 + } 117 + 118 + export type DebugEventArg<T extends keyof DebugEventTypes | string> = { 119 + type: T; 120 + message: string; 121 + operation: Operation; 122 + } & (T extends keyof DebugEventTypes 123 + ? { data: DebugEventTypes[T] } 124 + : { data?: any }); 125 + 126 + export type DebugEvent< 127 + T extends keyof DebugEventTypes | string = string 128 + > = DebugEventArg<T> & { 129 + timestamp: number; 130 + source: string; 131 + };
+23 -7
packages/next-urql/src/__tests__/with-urql-client.spec.tsx
··· 6 6 import { withUrqlClient, NextUrqlPageContext } from '..'; 7 7 import * as init from '../init-urql-client'; 8 8 9 + beforeEach(jest.clearAllMocks); 10 + 9 11 const MockApp: React.FC<any> = () => { 10 12 return <div />; 11 13 }; ··· 21 23 22 24 beforeAll(() => { 23 25 configure({ adapter: new Adapter() }); 24 - }); 25 - 26 - afterEach(() => { 27 - spyInitUrqlClient.mockClear(); 28 - mockMergeExchanges.mockClear(); 29 26 }); 30 27 31 28 describe('with client options', () => { ··· 108 105 }); 109 106 110 107 describe('with mergeExchanges provided', () => { 108 + const exchange = jest.fn(() => op => op); 109 + 111 110 beforeEach(() => { 111 + mockMergeExchanges.mockImplementation(() => [exchange] as any[]); 112 112 Component = withUrqlClient( 113 113 { url: 'http://localhost:3000' }, 114 114 mockMergeExchanges 115 115 )(MockApp); 116 116 }); 117 117 118 - it('should call the user-supplied mergeExchanges function', () => { 118 + it('calls the user-supplied mergeExchanges function', () => { 119 119 const tree = shallow(<Component />); 120 120 const app = tree.find(MockApp); 121 121 122 - expect(app.props().urqlClient).toBeInstanceOf(Client); 122 + const client = app.props().urqlClient; 123 + expect(client).toBeInstanceOf(Client); 123 124 expect(mockMergeExchanges).toHaveBeenCalledTimes(1); 125 + }); 126 + 127 + it('uses exchanges returned from mergeExchanges', () => { 128 + const tree = shallow(<Component />); 129 + const app = tree.find(MockApp); 130 + 131 + const client = app.props().urqlClient; 132 + client.query(` 133 + { 134 + users { 135 + id 136 + } 137 + } 138 + `); 139 + expect(exchange).toBeCalledTimes(1); 124 140 }); 125 141 }); 126 142 });
+30
scripts/babel/transform-debug-target.js
··· 1 + const dispatchProperty = 'dispatchDebug'; 2 + const visited = 'visitedByDebugTargetTransformer'; 3 + 4 + const warningDevCheckTemplate = ` 5 + process.env.NODE_ENV !== 'production' ? NODE : undefined 6 + `.trim(); 7 + 8 + const plugin = ({ template, types: t }) => { 9 + const wrapWithDevCheck = template.expression( 10 + warningDevCheckTemplate, 11 + { placeholderPattern: /^NODE$/ } 12 + ); 13 + 14 + return { 15 + visitor: { 16 + CallExpression(path) { 17 + if ( 18 + !path.node[visited] && 19 + path.node.callee && 20 + path.node.callee.name === dispatchProperty 21 + ) { 22 + path.node[visited] = true; 23 + path.replaceWith(wrapWithDevCheck({ NODE: path.node })); 24 + } 25 + } 26 + } 27 + }; 28 + }; 29 + 30 + export default plugin;
+2
scripts/rollup/plugins.js
··· 13 13 14 14 import babelPluginTransformPipe from '../babel/transform-pipe'; 15 15 import babelPluginTransformInvariant from '../babel/transform-invariant-warning'; 16 + import babelPluginTransformDebugTarget from '../babel/transform-debug-target'; 16 17 17 18 import * as settings from './settings'; 18 19 ··· 69 70 exclude: 'node_modules/**', 70 71 presets: [], 71 72 plugins: [ 73 + babelPluginTransformDebugTarget, 72 74 babelPluginTransformPipe, 73 75 babelPluginTransformInvariant, 74 76 'babel-plugin-modular-graphql',