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

feat(core): Deprecate the dedupExchange and absorb hasNext checks into Client (#3058)

Changed files
+50 -180
.changeset
packages
+10
.changeset/wise-cherries-juggle.md
···
+
---
+
'@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`.
+1 -1
packages/core/src/client.test.ts
···
return merge([
pipe(
ops$,
-
map(op => ({ data: 1, operation: op })),
+
map(op => ({ hasNext: true, data: 1, operation: op })),
take(1)
),
never,
+36 -22
packages/core/src/client.ts
···
Source,
take,
takeUntil,
+
takeWhile,
publish,
subscribe,
switchMap,
···
// 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<Operation>();
+
const operations = makeSubject<Operation>();
+
+
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()))
···
);
}
+
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)
)
),
···
fromValue(result),
// Mark a result as stale when a new operation is sent for it
pipe(
-
operations$,
+
operations.source,
filter(
op =>
op.kind === 'query' &&
···
}),
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
···
return make<OperationResult>(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);
}
···
client,
dispatchDebug,
forward: fallbackExchange({ dispatchDebug }),
-
})(operations$)
+
})(operations.source)
);
// Prevent the `results$` exchange pipeline from being closed by active
-104
packages/core/src/exchanges/dedup.test.ts
···
-
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<Operation>;
-
-
beforeEach(() => {
-
shouldRespond = false;
-
forwardedOperations = [];
-
input = makeSubject<Operation>();
-
-
// Collect all forwarded operations
-
const forward = (s: Source<Operation>) => {
-
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);
-
});
+3 -53
packages/core/src/exchanges/dedup.ts
···
-
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<number>();
-
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$);