Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/generalise-deferred-fragments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@urql/core': minor
---

Add beta fragment utilities to `@urql/core`. `maskFragment` masks data against a fragment selection, and `makeFragmentSource` issues masked snapshots that re-emit as `@defer`-red patches stream in. The `Client` now automatically associates stable sidecar promises with missing fields in streamed `@defer` query results and resolves them as patches arrive (or on teardown), so framework bindings can resolve Suspense boundaries directly from a query stream without any per-binding wiring.
22 changes: 22 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,16 @@ import type {
DebugEvent,
} from './types';

import type { DeferredState } from './utils';
import {
createRequest,
withPromise,
noop,
makeOperation,
getOperationType,
makeDeferredState,
resolveDeferredState,
updateDeferredResult,
} from './utils';

/** Configuration options passed when creating a new {@link Client}.
Expand Down Expand Up @@ -615,8 +619,26 @@ export const Client: new (opts: ClientOptions) => Client = function Client(
takeWhile(result => !!result.hasNext, true)
);
} else {
// Associate stable sidecar promises with missing `@defer` fields while
// results are streaming in, and resolve them as patches arrive. The
// metadata lives outside of the result data, so this is inert for
// consumers that don't read it (see `maskFragment`).
let deferredState: DeferredState | void;
result$ = pipe(
result$,
map(result => {
if (result.hasNext || deferredState) {
updateDeferredResult(
operation,
result,
deferredState || (deferredState = makeDeferredState())
);
}
return result;
}),
onEnd(() => {
if (deferredState) resolveDeferredState(deferredState);
}),
// Add `stale: true` flag when a new operation is sent for queries
switchMap(result => {
const value$ = fromValue(result);
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,11 @@ export {
makeOperation,
getOperationName,
} from './utils';

export { maskFragment, getFragments, makeFragmentSource } from './utils';

export type {
FragmentMap,
MaskFragmentResult,
FragmentSourceArgs,
} from './utils';
169 changes: 169 additions & 0 deletions packages/core/src/utils/defer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, it, expect } from 'vitest';

import { gql } from '../gql';
import { createRequest } from './request';
import {
getDeferredFieldPromise,
isDeferredPromise,
makeDeferredState,
resolveDeferredState,
updateDeferredResult,
} from './defer';

const query = gql`
query {
todo {
id
__typename
... on Todo @defer {
name
}
}
}
`;

const makeResult = (data: any, hasNext: boolean): any => ({
operation: {} as any,
data,
stale: false,
hasNext,
});

describe('isDeferredPromise', () => {
it('only matches tagged deferred promises', () => {
expect(isDeferredPromise(Promise.resolve())).toBe(false);
expect(isDeferredPromise({ then: () => {} })).toBe(false);
expect(isDeferredPromise(null)).toBe(false);
const tagged = Object.assign(Promise.resolve(), { _urqlDeferred: true });
expect(isDeferredPromise(tagged)).toBe(true);
});
});

describe('updateDeferredResult', () => {
it('keeps a stable promise outside GraphQL data while streaming', () => {
const request = createRequest(query, {});
const state = makeDeferredState();
const data = { todo: { id: '1', __typename: 'Todo' } };

const result = updateDeferredResult(request, makeResult(data, true), state);

const pending = getDeferredFieldPromise(data.todo, 'name')!;
expect(result.data).toBe(data);
expect((result.data as any).todo.name).toBeUndefined();
expect(isDeferredPromise(pending)).toBe(true);
expect(pending._resolved).toBe(false);
expect(state.promises.size).toBe(1);
});

it('resolves the same promise with the streamed-in value as patches arrive', () => {
const request = createRequest(query, {});
const state = makeDeferredState();

const data = { todo: { id: '1', __typename: 'Todo' } };
updateDeferredResult(request, makeResult(data, true), state);
const pending = getDeferredFieldPromise(data.todo, 'name')!;

updateDeferredResult(
request,
makeResult(
{ todo: { id: '1', __typename: 'Todo', name: 'Hello' } },
false
),
state
);

expect(pending._resolved).toBe(true);
expect(pending._value).toBe('Hello');
// Resolved promises are removed from the state.
expect(state.promises.size).toBe(0);
});

it('evaluates @defer and conditional directives against variables', () => {
const conditionalQuery = gql`
query ($defer: Boolean!, $include: Boolean!) {
todo {
__typename
... on Todo @defer(if: $defer) {
name @include(if: $include)
}
}
}
`;

const disabledData = { todo: { __typename: 'Todo' } };
const disabledState = makeDeferredState();
updateDeferredResult(
createRequest(conditionalQuery, { defer: false, include: true }),
makeResult(disabledData, true),
disabledState
);
expect(getDeferredFieldPromise(disabledData.todo, 'name')).toBeUndefined();

const skippedData = { todo: { __typename: 'Todo' } };
const skippedState = makeDeferredState();
updateDeferredResult(
createRequest(conditionalQuery, { defer: true, include: false }),
makeResult(skippedData, true),
skippedState
);
expect(getDeferredFieldPromise(skippedData.todo, 'name')).toBeUndefined();

const includedData = { todo: { __typename: 'Todo' } };
const includedState = makeDeferredState();
updateDeferredResult(
createRequest(conditionalQuery, { defer: true, include: true }),
makeResult(includedData, true),
includedState
);
expect(getDeferredFieldPromise(includedData.todo, 'name')).toBeDefined();
});

it('does not install a promise once the stream has ended', () => {
const request = createRequest(query, {});
const state = makeDeferredState();

const result = updateDeferredResult(
request,
makeResult({ todo: { id: '1', __typename: 'Todo' } }, false),
state
);

expect((result.data as any).todo.name).toBeUndefined();
expect(state.promises.size).toBe(0);
});

it('resolves all remaining promises when the stream ends', () => {
const request = createRequest(query, {});
const state = makeDeferredState();

const data = { todo: { id: '1', __typename: 'Todo' } };
updateDeferredResult(request, makeResult(data, true), state);
const pending = getDeferredFieldPromise(data.todo, 'name')!;
expect(pending._resolved).toBe(false);

updateDeferredResult(
request,
makeResult({ todo: { id: '1', __typename: 'Todo' } }, false),
state
);

expect(pending._resolved).toBe(true);
expect(state.promises.size).toBe(0);
});
});

describe('resolveDeferredState', () => {
it('resolves and clears every pending promise', () => {
const request = createRequest(query, {});
const state = makeDeferredState();

const data = { todo: { id: '1', __typename: 'Todo' } };
updateDeferredResult(request, makeResult(data, true), state);
const pending = getDeferredFieldPromise(data.todo, 'name')!;

resolveDeferredState(state);

expect(pending._resolved).toBe(true);
expect(state.promises.size).toBe(0);
});
});
Loading
Loading