diff --git a/.changeset/wise-cherries-juggle.md b/.changeset/wise-cherries-juggle.md new file mode 100644 index 0000000000..310f8c76ef --- /dev/null +++ b/.changeset/wise-cherries-juggle.md @@ -0,0 +1,10 @@ +--- +'@urql/core': minor +--- + +Deprecate the `dedupExchange`. The functionality of deduplicating queries and subscriptions has now been moved into and absorbed by the `Client`. + +Previously, the `Client` already started doing some work to share results between +queries, and to avoid dispatching operations as needed. It now only dispatches operations +strictly when the `dedupExchange` would allow so as well, moving its logic into the +`Client`. diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 62e740c9f3..417ce7abd8 100755 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -909,7 +909,7 @@ describe('shared sources behavior', () => { return merge([ pipe( ops$, - map(op => ({ data: 1, operation: op })), + map(op => ({ hasNext: true, data: 1, operation: op })), take(1) ), never, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 21a584e8a0..bb83a5dba8 100755 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -12,6 +12,7 @@ import { Source, take, takeUntil, + takeWhile, publish, subscribe, switchMap, @@ -566,13 +567,20 @@ export const Client: new (opts: ClientOptions) => Client = function Client( // This subject forms the input of operations; executeOperation may be // called to dispatch a new operation on the subject - const { source: operations$, next: nextOperation } = makeSubject(); + const operations = makeSubject(); + + function nextOperation(operation: Operation) { + const prevReplay = replays.get(operation.key); + if (operation.kind === 'mutation' || !prevReplay || !prevReplay.hasNext) + operations.next(operation); + } // We define a queued dispatcher on the subject, which empties the queue when it's // activated to allow `reexecuteOperation` to be trampoline-scheduled let isOperationBatchActive = false; function dispatchOperation(operation?: Operation | void) { if (operation) nextOperation(operation); + if (!isOperationBatchActive) { isOperationBatchActive = true; while (isOperationBatchActive && (operation = queue.shift())) @@ -602,21 +610,33 @@ export const Client: new (opts: ClientOptions) => Client = function Client( ); } + if (operation.kind !== 'query') { + result$ = pipe( + result$, + onStart(() => { + nextOperation(operation); + }) + ); + } + // A mutation is always limited to just a single result and is never shared if (operation.kind === 'mutation') { - return pipe( + return pipe(result$, take(1)); + } + + if (operation.kind === 'subscription') { + result$ = pipe( result$, - onStart(() => nextOperation(operation)), - take(1) + takeWhile(result => !!result.hasNext) ); } - const source = pipe( + return pipe( result$, // End the results stream when an active teardown event is sent takeUntil( pipe( - operations$, + operations.source, filter(op => op.kind === 'teardown' && op.key === operation.key) ) ), @@ -629,7 +649,7 @@ export const Client: new (opts: ClientOptions) => Client = function Client( fromValue(result), // Mark a result as stale when a new operation is sent for it pipe( - operations$, + operations.source, filter( op => op.kind === 'query' && @@ -656,15 +676,13 @@ export const Client: new (opts: ClientOptions) => Client = function Client( }), share ); - - return source; }; const instance: Client = this instanceof Client ? this : Object.create(Client.prototype); const client: Client = Object.assign(instance, { suspense: !!opts.suspense, - operations$, + operations$: operations.source, reexecuteOperation(operation: Operation) { // Reexecute operation only if any subscribers are still subscribed to the @@ -708,33 +726,29 @@ export const Client: new (opts: ClientOptions) => Client = function Client( return make(observer => { let source = active.get(operation.key); - if (!source) { active.set(operation.key, (source = makeResultSource(operation))); } - const isNetworkOperation = - operation.context.requestPolicy === 'cache-and-network' || - operation.context.requestPolicy === 'network-only'; - return pipe( source, onStart(() => { const prevReplay = replays.get(operation.key); - - if (operation.kind === 'subscription') { - return dispatchOperation(operation); + const isNetworkOperation = + operation.context.requestPolicy === 'cache-and-network' || + operation.context.requestPolicy === 'network-only'; + if (operation.kind !== 'query') { + return; } else if (isNetworkOperation) { dispatchOperation(operation); + if (prevReplay && !prevReplay.hasNext) prevReplay.stale = true; } if ( prevReplay != null && prevReplay === replays.get(operation.key) ) { - observer.next( - isNetworkOperation ? { ...prevReplay, stale: true } : prevReplay - ); + observer.next(prevReplay); } else if (!isNetworkOperation) { dispatchOperation(operation); } @@ -824,7 +838,7 @@ export const Client: new (opts: ClientOptions) => Client = function Client( client, dispatchDebug, forward: fallbackExchange({ dispatchDebug }), - })(operations$) + })(operations.source) ); // Prevent the `results$` exchange pipeline from being closed by active diff --git a/packages/core/src/exchanges/dedup.test.ts b/packages/core/src/exchanges/dedup.test.ts deleted file mode 100644 index a8e9c23cae..0000000000 --- a/packages/core/src/exchanges/dedup.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - filter, - makeSubject, - map, - pipe, - publish, - Source, - Subject, -} from 'wonka'; -import { vi, expect, it, beforeEach } from 'vitest'; - -import { - mutationOperation, - queryOperation, - queryResponse, -} from '../test-utils'; -import { Operation } from '../types'; -import { dedupExchange } from './dedup'; -import { makeOperation } from '../utils'; - -const dispatchDebug = vi.fn(); -let shouldRespond = false; -let exchangeArgs; -let forwardedOperations: Operation[]; -let input: Subject; - -beforeEach(() => { - shouldRespond = false; - forwardedOperations = []; - input = makeSubject(); - - // Collect all forwarded operations - const forward = (s: Source) => { - return pipe( - s, - map(op => { - forwardedOperations.push(op); - return queryResponse; - }), - filter(() => !!shouldRespond) - ); - }; - - exchangeArgs = { forward, client: {}, dispatchDebug }; -}); - -it('forwards query operations correctly', async () => { - const { source: ops$, next, complete } = input; - const exchange = dedupExchange(exchangeArgs)(ops$); - - publish(exchange); - next(queryOperation); - complete(); - expect(forwardedOperations.length).toBe(1); -}); - -it('forwards only non-pending query operations', async () => { - shouldRespond = false; // We filter out our mock responses - const { source: ops$, next, complete } = input; - const exchange = dedupExchange(exchangeArgs)(ops$); - - publish(exchange); - next(queryOperation); - next(queryOperation); - complete(); - expect(forwardedOperations.length).toBe(1); -}); - -it('forwards duplicate query operations as usual after they respond', async () => { - shouldRespond = true; // Response will immediately resolve - const { source: ops$, next, complete } = input; - const exchange = dedupExchange(exchangeArgs)(ops$); - - publish(exchange); - next(queryOperation); - next(queryOperation); - complete(); - expect(forwardedOperations.length).toBe(2); -}); - -it('forwards duplicate query operations after one was torn down', async () => { - shouldRespond = false; // We filter out our mock responses - const { source: ops$, next, complete } = input; - const exchange = dedupExchange(exchangeArgs)(ops$); - - publish(exchange); - next(queryOperation); - next(makeOperation('teardown', queryOperation, queryOperation.context)); - next(queryOperation); - complete(); - expect(forwardedOperations.length).toBe(3); -}); - -it('always forwards mutation operations without deduplicating them', async () => { - shouldRespond = false; // We filter out our mock responses - const { source: ops$, next, complete } = input; - const exchange = dedupExchange(exchangeArgs)(ops$); - - publish(exchange); - next(mutationOperation); - next(mutationOperation); - complete(); - expect(forwardedOperations.length).toBe(2); -}); diff --git a/packages/core/src/exchanges/dedup.ts b/packages/core/src/exchanges/dedup.ts index 41506a75b1..093a55013f 100644 --- a/packages/core/src/exchanges/dedup.ts +++ b/packages/core/src/exchanges/dedup.ts @@ -1,57 +1,7 @@ -import { filter, pipe, tap } from 'wonka'; import { Exchange } from '../types'; /** Default deduplication exchange. - * - * @remarks - * The `dedupExchange` deduplicates queries and subscriptions that are - * started with identical documents and variables by deduplicating by - * their {@link Operation.key}. - * This can prevent duplicate requests from being sent to your GraphQL API. - * - * Because this is a very safe exchange to add to any GraphQL setup, it’s - * not only the default, but we also recommend you to always keep this - * exchange added and included in your setup. - * - * Hint: In React and Vue, some common usage patterns can trigger duplicate - * operations. For instance, in React a single render will actually - * trigger two phases that execute an {@link Operation}. + * @deprecated + * This exchange's functionality is now built into the {@link Client}. */ -export const dedupExchange: Exchange = ({ forward, dispatchDebug }) => { - const inFlightKeys = new Set(); - return ops$ => - pipe( - forward( - pipe( - ops$, - filter(operation => { - if ( - operation.kind === 'teardown' || - operation.kind === 'mutation' - ) { - inFlightKeys.delete(operation.key); - return true; - } - - const isInFlight = inFlightKeys.has(operation.key); - inFlightKeys.add(operation.key); - - if (isInFlight) { - dispatchDebug({ - type: 'dedup', - message: 'An operation has been deduped.', - operation, - }); - } - - return !isInFlight; - }) - ) - ), - tap(result => { - if (!result.hasNext) { - inFlightKeys.delete(result.operation.key); - } - }) - ); -}; +export const dedupExchange: Exchange = ({ forward }) => ops$ => forward(ops$);