From d42fa425bd7a6f7c43ba3484965cf1ace3113d31 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Sat, 22 Aug 2026 08:59:14 +0200 Subject: [PATCH 1/3] Add deferred fragment utilities to core --- .changeset/generalise-deferred-fragments.md | 5 + packages/core/src/index.ts | 19 + packages/core/src/utils/cache.ts | 111 ++++++ packages/core/src/utils/defer.test.ts | 169 +++++++++ packages/core/src/utils/defer.ts | 343 +++++++++++++++++++ packages/core/src/utils/index.ts | 4 + packages/core/src/utils/maskFragment.test.ts | 171 +++++++++ packages/core/src/utils/maskFragment.ts | 162 +++++++++ packages/core/src/utils/selection.test.ts | 143 ++++++++ packages/core/src/utils/selection.ts | 160 +++++++++ 10 files changed, 1287 insertions(+) create mode 100644 .changeset/generalise-deferred-fragments.md create mode 100644 packages/core/src/utils/cache.ts create mode 100644 packages/core/src/utils/defer.test.ts create mode 100644 packages/core/src/utils/defer.ts create mode 100644 packages/core/src/utils/maskFragment.test.ts create mode 100644 packages/core/src/utils/maskFragment.ts create mode 100644 packages/core/src/utils/selection.test.ts create mode 100644 packages/core/src/utils/selection.ts diff --git a/.changeset/generalise-deferred-fragments.md b/.changeset/generalise-deferred-fragments.md new file mode 100644 index 0000000000..9568d2d7aa --- /dev/null +++ b/.changeset/generalise-deferred-fragments.md @@ -0,0 +1,5 @@ +--- +'@urql/core': minor +--- + +Add beta fragment-masking and deferred-result utilities to `@urql/core`. `maskFragment` masks data against a fragment selection, while the deferred-state helpers associate stable sidecar promises with missing fields in streamed `@defer` results so framework bindings can resolve Suspense boundaries directly from a query stream. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 69ceb6a649..c2aba71f6b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,3 +16,22 @@ export { makeOperation, getOperationName, } from './utils'; + +export { + maskFragment, + getFragments, + makeDeferredState, + resolveDeferredState, + isDeferredPromise, + updateDeferredResult, + makeCache, + getDeferredCacheForClient, +} from './utils'; + +export type { + FragmentMap, + MaskFragmentResult, + DeferredState, + DeferredPromise, + Cache, +} from './utils'; diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts new file mode 100644 index 0000000000..9a61eb8bda --- /dev/null +++ b/packages/core/src/utils/cache.ts @@ -0,0 +1,111 @@ +import { pipe, subscribe } from 'wonka'; +import type { Client } from '../client'; +import type { DeferredState } from './defer'; +import { resolveDeferredState } from './defer'; + +/** A small per-operation cache attached to a {@link Client}, keyed by an + * operation/request `key`. (BETA) + * + * @remarks + * Entries can be `dispose`d, which marks them for reclamation once the matching + * operation is torn down (when the cache is bound to a `Client`’s + * `operations$`), rather than being removed immediately. + * + * @beta + */ +export interface Cache { + get(key: number): Entry | undefined; + set(key: number, value: Entry): void; + clear(key: number): void; + dispose(key: number): void; +} + +/** Creates a {@link Cache} that optionally reclaims entries on operation teardown. (BETA) + * + * @param client - the {@link Client} whose `operations$` drives teardown-based reclamation. + * @param onClear - an optional callback invoked with an entry when it’s cleared. + * @param deferDispose - when `true`, `dispose` marks for reclamation even without a `client`. + * + * @remarks + * When a `client` is passed, this subscribes to its `operations$` stream — and + * only then; importing this module has no side effects. Disposed entries are + * removed when their operation’s `teardown` is observed; otherwise `dispose` + * clears immediately. + * + * @beta + */ +export const makeCache = ( + client?: Client, + onClear?: (value: Entry) => void, + deferDispose?: boolean +): Cache => { + const operations$ = client && (client as Partial).operations$; + const reclaim = new Set(); + const map = new Map(); + + const clear = (key: number) => { + const value = map.get(key); + if (value !== undefined && onClear) onClear(value); + reclaim.delete(key); + map.delete(key); + }; + + if (operations$ /* not available in mocks */) { + pipe( + operations$, + subscribe(operation => { + if (operation.kind === 'teardown' && reclaim.has(operation.key)) { + clear(operation.key); + } + }) + ); + } + + return { + get(key) { + return map.get(key); + }, + set(key, value) { + reclaim.delete(key); + map.set(key, value); + }, + clear, + dispose(key) { + if (operations$ || deferDispose) { + reclaim.add(key); + } else { + clear(key); + } + }, + }; +}; + +type DeferredCacheEntry = DeferredState | undefined; + +interface ClientWithDeferredCache extends Client { + _deferred?: Cache; +} + +/** Returns the per-{@link Client} cache of {@link DeferredState}, creating it lazily. (BETA) + * + * @remarks + * Bindings store one {@link DeferredState} per operation here (keyed by + * `request.key`) so that the {@link DeferredPromise}s associated by + * {@link updateDeferredResult} are shared between the query stream and any + * consumer suspending on a `@defer`-red boundary. Entries are reclaimed on + * teardown, resolving any still-pending promises so no boundary stays suspended. + * + * @beta + */ +export const getDeferredCacheForClient = ( + client: Client +): Cache => { + if (!(client as ClientWithDeferredCache)._deferred) { + (client as ClientWithDeferredCache)._deferred = + makeCache(client, state => { + if (state) resolveDeferredState(state); + }); + } + + return (client as ClientWithDeferredCache)._deferred!; +}; diff --git a/packages/core/src/utils/defer.test.ts b/packages/core/src/utils/defer.test.ts new file mode 100644 index 0000000000..5f7deb87af --- /dev/null +++ b/packages/core/src/utils/defer.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/utils/defer.ts b/packages/core/src/utils/defer.ts new file mode 100644 index 0000000000..0916622d55 --- /dev/null +++ b/packages/core/src/utils/defer.ts @@ -0,0 +1,343 @@ +import type { SelectionSetNode } from '@0no-co/graphql.web'; +import { Kind } from '@0no-co/graphql.web'; +import type { AnyVariables, GraphQLRequest, OperationResult } from '../types'; +import { + getFieldKey, + getFragments, + isDeferredSelection, + isHeuristicFragmentMatch, + shouldInclude, + type FragmentMap, +} from './selection'; + +/** A stable {@link Promise} associated with a field inside a `@defer`-red + * selection that hasn’t arrived yet. (BETA) + * + * @remarks + * Framework bindings can throw this promise to suspend (or read its resolved + * `_value`) so a `@defer`-red boundary can resolve directly from the query + * stream — without depending on a parent component re-render to hand fresh data + * down via props (which doesn’t happen during server streams). + * + * The promise is stored in sidecar metadata rather than in GraphQL result data, + * so an {@link OperationResult} always retains its declared data shape. + * + * @beta + */ +export type DeferredPromise = Promise & { + _resolve: (value?: unknown) => void; + _resolved?: boolean; + /** The streamed-in value, once the deferred patch has arrived. */ + _value?: unknown; + _urqlDeferred: true; +}; + +/** Per-operation state tracking the {@link DeferredPromise}s associated with a + * streamed result, keyed by their path in the result data. (BETA) + * + * @beta + */ +export interface DeferredState { + promises: Map; +} + +/** Creates an empty {@link DeferredState}. (BETA) + * + * @beta + */ +export const makeDeferredState = (): DeferredState => ({ + promises: new Map(), +}); + +/** Returns whether a value is a {@link DeferredPromise}. (BETA) + * + * @beta + */ +export const isDeferredPromise = (value: any): value is DeferredPromise => + !!value && value._urqlDeferred === true && typeof value.then === 'function'; + +/** Sidecar metadata for missing deferred fields. + * + * @remarks + * A WeakMap keeps internal promises out of user-visible GraphQL data and lets + * abandoned result objects and their promises be garbage-collected. + * + * @internal + */ +const deferredFields = new WeakMap>(); + +/** Returns the deferred promise associated with a response field, if any. + * + * @internal + */ +export const getDeferredFieldPromise = ( + data: object, + fieldKey: string +): DeferredPromise | undefined => { + const fields = deferredFields.get(data); + return fields ? fields.get(fieldKey) : undefined; +}; + +/** Associates a deferred promise with a response field without changing data. + * + * @internal + */ +export const setDeferredFieldPromise = ( + data: object, + fieldKey: string, + promise: DeferredPromise +): void => { + let fields = deferredFields.get(data); + if (!fields) deferredFields.set(data, (fields = new Map())); + fields.set(fieldKey, promise); +}; + +/** Copies deferred field associations while merging masked selections. + * + * @internal + */ +export const copyDeferredFields = (source: object, target: object): void => { + const fields = deferredFields.get(source); + if (fields) { + fields.forEach((promise, fieldKey) => { + setDeferredFieldPromise(target, fieldKey, promise); + }); + } +}; + +/** Resolves and clears every pending {@link DeferredPromise} in a + * {@link DeferredState}. (BETA) + * + * @remarks + * This is called when a stream ends (`hasNext` becomes falsy) or when the + * operation is torn down, so no boundary stays suspended indefinitely. + * + * @beta + */ +export const resolveDeferredState = (state: DeferredState): void => { + state.promises.forEach(promise => promise._resolve()); + state.promises.clear(); +}; + +const makeDeferredPromise = (): DeferredPromise => { + let resolve!: () => void; + const promise = new Promise(_resolve => { + resolve = _resolve; + }) as DeferredPromise; + + promise._resolved = false; + promise._urqlDeferred = true; + promise._resolve = (value?: unknown) => { + if (!promise._resolved) { + promise._resolved = true; + promise._value = value; + resolve(); + } + }; + + return promise; +}; + +const getPathKey = (path: readonly (string | number)[]) => JSON.stringify(path); + +const getDeferredPromise = ( + state: DeferredState, + path: readonly (string | number)[] +) => { + const key = getPathKey(path); + let promise = state.promises.get(key); + + if (!promise || promise._resolved) { + promise = makeDeferredPromise(); + state.promises.set(key, promise); + } + + return promise; +}; + +const resolveDeferredPath = ( + state: DeferredState, + path: readonly (string | number)[], + value?: unknown +) => { + const key = getPathKey(path); + const promise = state.promises.get(key); + if (promise) { + promise._resolve(value); + state.promises.delete(key); + } +}; + +const isObjectLike = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const updateArray = ( + data: readonly any[], + selectionSet: SelectionSetNode, + fragments: FragmentMap, + state: DeferredState, + variables: AnyVariables, + path: readonly (string | number)[], + isDeferred: boolean, + hasNext: boolean +): void => { + for (let i = 0, l = data.length; i < l; i++) { + updateSelectionSet( + data[i], + selectionSet, + fragments, + state, + variables, + [...path, i], + isDeferred, + hasNext + ); + } +}; + +const updateSelectionSet = ( + data: any, + selectionSet: SelectionSetNode, + fragments: FragmentMap, + state: DeferredState, + variables: AnyVariables, + path: readonly (string | number)[], + isDeferred: boolean, + hasNext: boolean +): void => { + if (!isObjectLike(data)) return; + + selectionSet.selections.forEach(selection => { + if (!shouldInclude(selection, variables)) return; + + if (selection.kind === Kind.FIELD) { + const fieldKey = getFieldKey(selection); + const fieldPath = [...path, fieldKey]; + const value = data[fieldKey]; + + if (value === undefined) { + if (isDeferred && hasNext) { + setDeferredFieldPromise( + data, + fieldKey, + getDeferredPromise(state, fieldPath) + ); + } else if (!hasNext) { + resolveDeferredPath(state, fieldPath); + } + } else { + if (selection.selectionSet && value !== null) { + if (Array.isArray(value)) { + updateArray( + value, + selection.selectionSet, + fragments, + state, + variables, + fieldPath, + isDeferred, + hasNext + ); + } else { + updateSelectionSet( + value, + selection.selectionSet, + fragments, + state, + variables, + fieldPath, + isDeferred, + hasNext + ); + } + } + + // Resolve the deferred promise for this path with the streamed-in value + // so a suspended consumer can read it without a parent rerender. + resolveDeferredPath(state, fieldPath, value); + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + if (!isHeuristicFragmentMatch(selection, data, fragments)) return; + + updateSelectionSet( + data, + selection.selectionSet, + fragments, + state, + variables, + path, + isDeferred || isDeferredSelection(selection, variables), + hasNext + ); + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragment = fragments[selection.name.value]; + if (!fragment || !isHeuristicFragmentMatch(fragment, data, fragments)) { + return; + } + + updateSelectionSet( + data, + fragment.selectionSet, + fragments, + state, + variables, + path, + isDeferred || isDeferredSelection(selection, variables), + hasNext + ); + } + }); +}; + +/** Associates and resolves {@link DeferredPromise}s for fields in a streamed + * {@link OperationResult} without changing its GraphQL data. (BETA) + * + * @remarks + * While `result.hasNext` is `true`, missing non-optional fields inside a + * `@defer`-red selection are associated with stable {@link DeferredPromise}s in + * sidecar metadata. As later patches arrive, the promises are resolved with the + * streamed-in value and removed from the operation state. When the stream ends, + * all remaining promises are resolved. + * + * Bindings call this on each result of a suspense-enabled query stream so that + * `@defer`-red fragment boundaries can resolve directly from the stream while + * `result.data` remains safe for ordinary consumers. + * + * @beta + */ +export const updateDeferredResult = < + Data = any, + Variables extends AnyVariables = AnyVariables, +>( + request: GraphQLRequest, + result: OperationResult, + state: DeferredState +): OperationResult => { + if (!result.data) { + if (!result.hasNext) resolveDeferredState(state); + return result; + } + + const operation = request.query.definitions.find( + definition => definition.kind === Kind.OPERATION_DEFINITION + ); + + if (!operation || operation.kind !== Kind.OPERATION_DEFINITION) { + if (!result.hasNext) resolveDeferredState(state); + return result; + } + + updateSelectionSet( + result.data, + operation.selectionSet, + getFragments(request.query.definitions), + state, + request.variables, + [], + false, + result.hasNext + ); + + if (!result.hasNext) resolveDeferredState(state); + return result; +}; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 1a401ed433..9387183c13 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -6,6 +6,10 @@ export * from './collectTypenames'; export * from './formatDocument'; export * from './streamUtils'; export * from './operation'; +export * from './selection'; +export * from './defer'; +export * from './maskFragment'; +export * from './cache'; export const noop = () => { /* noop */ diff --git a/packages/core/src/utils/maskFragment.test.ts b/packages/core/src/utils/maskFragment.test.ts new file mode 100644 index 0000000000..a5f5770834 --- /dev/null +++ b/packages/core/src/utils/maskFragment.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import type { + FragmentDefinitionNode, + SelectionSetNode, +} from '@0no-co/graphql.web'; +import { Kind } from '@0no-co/graphql.web'; + +import { gql } from '../gql'; +import { getFragments, type FragmentMap } from './selection'; +import { maskFragment } from './maskFragment'; + +const fromDocument = ( + source: string, + name?: string +): { selectionSet: SelectionSetNode; fragments: FragmentMap } => { + const document = gql(source); + const fragments = getFragments(document.definitions); + const fragment = document.definitions.find( + definition => + definition.kind === Kind.FRAGMENT_DEFINITION && + (!name || definition.name.value === name) + ) as FragmentDefinitionNode; + return { selectionSet: fragment.selectionSet, fragments }; +}; + +const mask = (source: string, data: any, name?: string) => { + const { selectionSet, fragments } = fromDocument(source, name); + return maskFragment(data, selectionSet, fragments); +}; + +describe('maskFragment', () => { + it('masks data to the selected fields', () => { + const result = mask(`fragment TodoFields on Todo { id name __typename }`, { + __typename: 'Todo', + id: '1', + name: 'Learn urql', + completed: true, + }); + + expect(result.fulfilled).toBe(true); + expect(result.data).toEqual({ + __typename: 'Todo', + id: '1', + name: 'Learn urql', + }); + }); + + it('preserves null fields', () => { + const result = mask(`fragment TodoFields on Todo { id name __typename }`, { + __typename: 'Todo', + id: '1', + name: null, + completed: true, + }); + + expect(result.fulfilled).toBe(true); + expect(result.data).toEqual({ __typename: 'Todo', id: '1', name: null }); + }); + + it('reports an unfulfilled result for an undefined non-optional field', () => { + const result = mask(`fragment TodoFields on Todo { id name __typename }`, { + __typename: 'Todo', + id: '1', + name: undefined, + completed: true, + }); + + expect(result.fulfilled).toBe(false); + expect(result.data).toEqual({ __typename: 'Todo', id: '1' }); + }); + + it('masks nested objects', () => { + const result = mask( + `fragment TodoFields on Todo { id __typename author { id name __typename } }`, + { + __typename: 'Todo', + id: '1', + author: { + __typename: 'Author', + id: '1', + name: 'Jovi', + awardWinner: true, + }, + } + ); + + expect(result.fulfilled).toBe(true); + expect(result.data).toEqual({ + __typename: 'Todo', + id: '1', + author: { __typename: 'Author', id: '1', name: 'Jovi' }, + }); + }); + + it('passes through a null nested selection', () => { + const result = mask( + `fragment TodoFields on Todo { id __typename author { id __typename } }`, + { __typename: 'Todo', id: '1', author: null } + ); + + expect(result.fulfilled).toBe(true); + expect(result.data).toEqual({ __typename: 'Todo', id: '1', author: null }); + }); + + it('preserves null items in nullable lists', () => { + const result = mask( + `fragment TodoFields on Todo { id __typename assignees { id name __typename } }`, + { + __typename: 'Todo', + id: '1', + assignees: [ + null, + { __typename: 'User', id: '2', name: 'Jovi', role: 'admin' }, + ], + } + ); + + expect(result.fulfilled).toBe(true); + expect(result.data).toEqual({ + __typename: 'Todo', + id: '1', + assignees: [null, { __typename: 'User', id: '2', name: 'Jovi' }], + }); + }); + + it('reports an unfulfilled result for an undefined nested selection', () => { + const result = mask( + `fragment TodoFields on Todo { id __typename author { id __typename } }`, + { __typename: 'Todo', id: '1', author: undefined } + ); + + expect(result.fulfilled).toBe(false); + expect(result.data).toEqual({ __typename: 'Todo', id: '1' }); + }); + + it('treats a missing @defer-red fragment spread as fulfilled', () => { + const result = mask( + ` + fragment TodoFields on Todo { + id name __typename + ...AuthorFields @defer + } + + fragment AuthorFields on Todo { author { id name __typename } } + `, + { __typename: 'Todo', id: '1', name: null, author: undefined }, + 'TodoFields' + ); + + expect(result.fulfilled).toBe(true); + expect(result.data).toEqual({ __typename: 'Todo', id: '1', name: null }); + }); + + it('treats a missing non-deferred fragment spread as unfulfilled', () => { + const result = mask( + ` + fragment TodoFields on Todo { + id name __typename + ...AuthorFields + } + + fragment AuthorFields on Todo { author { id name __typename } } + `, + { __typename: 'Todo', id: '1', name: null, author: undefined }, + 'TodoFields' + ); + + expect(result.fulfilled).toBe(false); + expect(result.data).toEqual({ __typename: 'Todo', id: '1', name: null }); + }); +}); diff --git a/packages/core/src/utils/maskFragment.ts b/packages/core/src/utils/maskFragment.ts new file mode 100644 index 0000000000..d91fd3455d --- /dev/null +++ b/packages/core/src/utils/maskFragment.ts @@ -0,0 +1,162 @@ +import type { SelectionSetNode } from '@0no-co/graphql.web'; +import { Kind } from '@0no-co/graphql.web'; + +import { + copyDeferredFields, + getDeferredFieldPromise, + setDeferredFieldPromise, +} from './defer'; +import { + getFieldKey, + hasDirective, + isHeuristicFragmentMatch, + isOptionalSelection, + type FragmentMap, +} from './selection'; + +/** The result of masking a piece of `data` against a selection set. (BETA) + * + * @beta + */ +export interface MaskFragmentResult { + /** The masked data, limited to the fields the selection set selects. */ + data: Data; + /** Whether every selected field is present (not still streaming in). */ + fulfilled: boolean; + /** A {@link DeferredPromise} that resolves once a still-missing `@defer`-red + * field arrives, if any. Bindings can throw this to suspend. */ + pending?: Promise; +} + +/** Masks `data` against a fragment’s selection set. (BETA) + * + * @param data - the (super-)set of data to mask. + * @param selectionSet - the {@link SelectionSetNode} to mask `data` against. + * @param fragments - a {@link FragmentMap} of fragments referenced by the selection set. + * @returns a {@link MaskFragmentResult}. + * + * @remarks + * `maskFragment` walks the selection set and returns the subset of `data` that + * the selection selects. When a non-optional field is still missing — for + * instance while a `@defer`-red part is streaming in — `fulfilled` is `false` + * and, if the query stream associated a {@link DeferredPromise} with it, that + * promise is surfaced via `pending`. + * + * Bindings decide what to do with an incomplete result: a Suspense-based + * binding throws `pending`, while others surface `fetching: !fulfilled`. + * + * Deferred promises are kept in sidecar metadata, so neither the input nor the + * returned masked GraphQL data exposes internal promise values. + * + * @beta + */ +export const maskFragment = ( + data: Data, + selectionSet: SelectionSetNode, + fragments: FragmentMap +): MaskFragmentResult => { + const maskedData = {}; + let isDataComplete = true; + let pending: Promise | undefined; + + selectionSet.selections.forEach(selection => { + const hasIncludeOrSkip = isOptionalSelection(selection); + + if (selection.kind === Kind.FIELD) { + const fieldAlias = getFieldKey(selection); + + let value = data[fieldAlias]; + const deferred = + value === undefined && data && typeof data === 'object' + ? getDeferredFieldPromise(data, fieldAlias) + : undefined; + if (deferred) { + if (!deferred._resolved) { + // The deferred patch hasn't arrived yet; surface the stream-owned + // promise so a binding can suspend without changing GraphQL data. + isDataComplete = false; + if (!pending) pending = deferred; + setDeferredFieldPromise(maskedData, fieldAlias, deferred); + return; + } + // The deferred patch has streamed in: read its value from the sidecar + // promise rather than relying on a parent rerender to pass fresh props. + value = deferred._value; + } + + if (value === undefined) { + if (hasIncludeOrSkip) return; + isDataComplete = false; + } else if (value === null) { + maskedData[fieldAlias] = null; + } else if (Array.isArray(value)) { + if (selection.selectionSet) { + maskedData[fieldAlias] = value.map(item => { + if (item === null) return null; + + const result = maskFragment( + item, + selection.selectionSet as SelectionSetNode, + fragments + ); + + if (!result.fulfilled) { + isDataComplete = false; + if (!pending) pending = result.pending; + } + + return result.data; + }); + } else { + maskedData[fieldAlias] = value.map(item => item); + } + } else { + if (selection.selectionSet) { + const result = maskFragment(value, selection.selectionSet, fragments); + + if (!result.fulfilled) { + isDataComplete = false; + if (!pending) pending = result.pending; + } + + maskedData[fieldAlias] = result.data; + } else { + maskedData[fieldAlias] = value; + } + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + if (!isHeuristicFragmentMatch(selection, data, fragments)) { + return; + } + + const hasDefer = hasDirective(selection, 'defer'); + + const result = maskFragment(data, selection.selectionSet, fragments); + if (!result.fulfilled && !hasIncludeOrSkip && !hasDefer) { + isDataComplete = false; + if (!pending) pending = result.pending; + } + + Object.assign(maskedData, result.data); + copyDeferredFields(result.data as object, maskedData); + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragment = fragments[selection.name.value]; + + const hasDefer = hasDirective(selection, 'defer'); + + if (!fragment || !isHeuristicFragmentMatch(fragment, data, fragments)) { + return; + } + + const result = maskFragment(data, fragment.selectionSet, fragments); + if (!result.fulfilled && !hasIncludeOrSkip && !hasDefer) { + isDataComplete = false; + if (!pending) pending = result.pending; + } + Object.assign(maskedData, result.data); + copyDeferredFields(result.data as object, maskedData); + } + }); + + return { data: maskedData as Data, fulfilled: isDataComplete, pending }; +}; diff --git a/packages/core/src/utils/selection.test.ts b/packages/core/src/utils/selection.test.ts new file mode 100644 index 0000000000..418b743f59 --- /dev/null +++ b/packages/core/src/utils/selection.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import type { FieldNode, FragmentDefinitionNode } from '@0no-co/graphql.web'; +import { Kind } from '@0no-co/graphql.web'; + +import { gql } from '../gql'; +import { + getFieldKey, + getFragments, + hasDirective, + isDeferredSelection, + isHeuristicFragmentMatch, + isOptionalSelection, + shouldInclude, +} from './selection'; + +const fieldsOf = (source: string): Record => { + const document = gql(source); + const fragment = document.definitions.find( + definition => definition.kind === Kind.FRAGMENT_DEFINITION + ) as FragmentDefinitionNode; + const map: Record = {}; + fragment.selectionSet.selections.forEach(selection => { + if (selection.kind === Kind.FIELD) map[getFieldKey(selection)] = selection; + }); + return map; +}; + +describe('getFragments', () => { + it('maps fragment names to their definitions', () => { + const document = gql` + fragment A on X { + id + } + fragment B on Y { + id + } + `; + const fragments = getFragments(document.definitions); + expect(Object.keys(fragments).sort()).toEqual(['A', 'B']); + expect(fragments.A.name.value).toBe('A'); + }); +}); + +describe('getFieldKey', () => { + it('returns the alias when present, otherwise the field name', () => { + const fields = fieldsOf(`fragment F on T { name alias: other }`); + expect(fields.name.name.value).toBe('name'); + expect(fields.alias.name.value).toBe('other'); + }); +}); + +describe('hasDirective', () => { + it('detects a directive by name on the raw AST', () => { + const fields = fieldsOf(`fragment F on T { a @defer b }`); + expect(hasDirective(fields.a, 'defer')).toBe(true); + expect(hasDirective(fields.b, 'defer')).toBe(false); + }); +}); + +describe('directive evaluation', () => { + it('evaluates @include and @skip against variables', () => { + const fields = fieldsOf( + `fragment F on T { a @include(if: $include) b @skip(if: $skip) }` + ); + expect(shouldInclude(fields.a, { include: true })).toBe(true); + expect(shouldInclude(fields.a, { include: false })).toBe(false); + expect(shouldInclude(fields.b, { skip: true })).toBe(false); + expect(shouldInclude(fields.b, { skip: false })).toBe(true); + }); + + it('evaluates @defer(if:) against variables', () => { + const fields = fieldsOf( + `fragment F on T { a @defer(if: $defer) b @defer }` + ); + expect(isDeferredSelection(fields.a, { defer: true })).toBe(true); + expect(isDeferredSelection(fields.a, { defer: false })).toBe(false); + expect(isDeferredSelection(fields.b, {})).toBe(true); + }); +}); + +describe('isOptionalSelection', () => { + it('is true for @include/@skip and false otherwise', () => { + const fields = fieldsOf( + `fragment F on T { a @include(if: true) b @skip(if: false) c }` + ); + expect(isOptionalSelection(fields.a)).toBe(true); + expect(isOptionalSelection(fields.b)).toBe(true); + expect(isOptionalSelection(fields.c)).toBe(false); + }); +}); + +describe('isHeuristicFragmentMatch', () => { + const fragment = getFragments(gql` + fragment AuthorFields on Author { + id + name + __typename + } + `.definitions).AuthorFields; + + it('matches when the type condition equals __typename', () => { + expect( + isHeuristicFragmentMatch(fragment, { __typename: 'Author' }, {}) + ).toBe(true); + }); + + it('matches heuristically when every selected field is present', () => { + expect( + isHeuristicFragmentMatch( + fragment, + { __typename: 'Other', id: '1', name: 'x' }, + {} + ) + ).toBe(true); + }); + + it('does not match when a selected field is missing', () => { + expect( + isHeuristicFragmentMatch(fragment, { __typename: 'Other', id: '1' }, {}) + ).toBe(false); + }); + + it('matches when conditional fields are absent or present', () => { + const conditional = getFragments(gql` + fragment NodeFields on Node { + id + name @include(if: true) + email @skip(if: false) + } + `.definitions).NodeFields; + + expect( + isHeuristicFragmentMatch(conditional, { __typename: 'User', id: '1' }, {}) + ).toBe(true); + expect( + isHeuristicFragmentMatch( + conditional, + { __typename: 'User', id: '1', name: 'Jovi', email: 'jovi@test.dev' }, + {} + ) + ).toBe(true); + }); +}); diff --git a/packages/core/src/utils/selection.ts b/packages/core/src/utils/selection.ts new file mode 100644 index 0000000000..003623c1b2 --- /dev/null +++ b/packages/core/src/utils/selection.ts @@ -0,0 +1,160 @@ +import type { + ArgumentNode, + DefinitionNode, + FieldNode, + FragmentDefinitionNode, + InlineFragmentNode, +} from '@0no-co/graphql.web'; +import { Kind, valueFromASTUntyped } from '@0no-co/graphql.web'; +import type { AnyVariables } from '../types'; + +// NOTE: `@urql/exchange-graphcache` has its own AST helpers in +// `ast/traversal.ts` (`getFragments`, `shouldInclude`, `isDeferred`, …). Those +// are variable-aware (they evaluate `@include/@skip/@defer(if:)` arguments), +// whereas these are presence-only. Unifying them is possible follow-up work but +// would change behaviour/signatures, so the two are intentionally kept separate. + +/** A mapping from fragment names to their {@link FragmentDefinitionNode}. + * + * @internal + */ +export type FragmentMap = Record; + +type Directive = { + name: { value: string }; + arguments?: readonly ArgumentNode[]; +}; + +type DirectedNode = { + directives?: readonly Directive[]; + _directives?: Record; +}; + +const getDirective = ( + node: DirectedNode, + name: string +): Directive | undefined => { + if (node._directives && node._directives[name]) { + return node._directives[name]; + } + + return node.directives && node.directives.find(x => x.name.value === name); +}; + +/** Builds a {@link FragmentMap} from a document’s definitions. + * + * @internal + */ +export const getFragments = ( + definitions: readonly DefinitionNode[] +): FragmentMap => + definitions.reduce((acc, definition) => { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + acc[definition.name.value] = definition; + } + return acc; + }, {}); + +/** Returns whether a node carries a directive by `name`. + * + * @remarks + * This checks for the directive’s presence only and does not evaluate any + * arguments (e.g. `@include(if:)`). It reads both the formatted `_directives` + * record and the raw `directives` AST list. + * + * @internal + */ +export const hasDirective = (node: DirectedNode, name: string): boolean => + !!getDirective(node, name); + +/** Evaluates `@include` and `@skip` directives for a selection. + * + * @internal + */ +export const shouldInclude = ( + node: DirectedNode, + variables: AnyVariables +): boolean => { + for (const name of ['include', 'skip']) { + const directive = getDirective(node, name); + const argument = + directive && + directive.arguments && + directive.arguments.find(x => x.name.value === 'if'); + if (argument) { + const value = !!valueFromASTUntyped(argument.value, variables || {}); + if (name === 'include' ? !value : value) return false; + } + } + + return true; +}; + +/** Evaluates whether a fragment selection's `@defer` directive is enabled. + * + * @internal + */ +export const isDeferredSelection = ( + node: DirectedNode, + variables: AnyVariables +): boolean => { + const directive = getDirective(node, 'defer'); + if (!directive) return false; + + const argument = + directive.arguments && directive.arguments.find(x => x.name.value === 'if'); + return argument + ? !!valueFromASTUntyped(argument.value, variables || {}) + : true; +}; + +/** Returns whether a node is conditionally included via `@include`/`@skip`. + * + * @internal + */ +export const isOptionalSelection = (node: DirectedNode): boolean => + hasDirective(node, 'include') || hasDirective(node, 'skip'); + +/** Returns the response key (alias or name) for a field selection. + * + * @internal + */ +export const getFieldKey = (selection: FieldNode): string => + selection.alias ? selection.alias.value : selection.name.value; + +/** Heuristically determines whether a fragment applies to a piece of `data`. + * + * @remarks + * When the fragment’s type condition can’t be matched against `data.__typename` + * directly, this falls back to checking that every non-optional, non-deferred + * field the fragment selects is already present on `data`. + * + * @internal + */ +export const isHeuristicFragmentMatch = ( + fragment: InlineFragmentNode | FragmentDefinitionNode, + data: any, + fragments: FragmentMap +): boolean => { + if ( + !fragment.typeCondition || + fragment.typeCondition.name.value === data.__typename + ) { + return true; + } + + return fragment.selectionSet.selections.every(selection => { + if (selection.kind === Kind.FIELD) { + const couldBeExcluded = + isOptionalSelection(selection) || hasDirective(selection, 'defer'); + return couldBeExcluded || data[getFieldKey(selection)] !== undefined; + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + return isHeuristicFragmentMatch(selection, data, fragments); + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragment = fragments[selection.name.value]; + return !!fragment && isHeuristicFragmentMatch(fragment, data, fragments); + } + + return true; + }); +}; From 808921fb6549d1f93d22d4075cb95d04c39fe13f Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Sat, 22 Aug 2026 14:18:46 +0200 Subject: [PATCH 2/3] Shrink fragment API surface and track deferred results in the Client The Client now associates and resolves deferred sidecar promises for streamed query results inside makeResultSource, so bindings need no per-binding wiring. The per-client deferred cache is removed, and the new makeFragmentSource primitive exposes masked fragment snapshots that re-emit as deferred patches arrive. Public exports are reduced to maskFragment, getFragments, and makeFragmentSource. Co-Authored-By: Claude Fable 5 --- .changeset/generalise-deferred-fragments.md | 2 +- packages/core/src/client.ts | 22 ++ packages/core/src/index.ts | 15 +- packages/core/src/utils/cache.ts | 111 ------- .../core/src/utils/fragmentSource.test.ts | 280 ++++++++++++++++++ packages/core/src/utils/fragmentSource.ts | 112 +++++++ packages/core/src/utils/index.ts | 2 +- 7 files changed, 418 insertions(+), 126 deletions(-) delete mode 100644 packages/core/src/utils/cache.ts create mode 100644 packages/core/src/utils/fragmentSource.test.ts create mode 100644 packages/core/src/utils/fragmentSource.ts diff --git a/.changeset/generalise-deferred-fragments.md b/.changeset/generalise-deferred-fragments.md index 9568d2d7aa..4659a387af 100644 --- a/.changeset/generalise-deferred-fragments.md +++ b/.changeset/generalise-deferred-fragments.md @@ -2,4 +2,4 @@ '@urql/core': minor --- -Add beta fragment-masking and deferred-result utilities to `@urql/core`. `maskFragment` masks data against a fragment selection, while the deferred-state helpers associate stable sidecar promises with missing fields in streamed `@defer` results so framework bindings can resolve Suspense boundaries directly from a query stream. +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. diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 3a7c0f2fde..6e707f7d18 100755 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -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}. @@ -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); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c2aba71f6b..f433326823 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,21 +17,10 @@ export { getOperationName, } from './utils'; -export { - maskFragment, - getFragments, - makeDeferredState, - resolveDeferredState, - isDeferredPromise, - updateDeferredResult, - makeCache, - getDeferredCacheForClient, -} from './utils'; +export { maskFragment, getFragments, makeFragmentSource } from './utils'; export type { FragmentMap, MaskFragmentResult, - DeferredState, - DeferredPromise, - Cache, + FragmentSourceArgs, } from './utils'; diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts deleted file mode 100644 index 9a61eb8bda..0000000000 --- a/packages/core/src/utils/cache.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { pipe, subscribe } from 'wonka'; -import type { Client } from '../client'; -import type { DeferredState } from './defer'; -import { resolveDeferredState } from './defer'; - -/** A small per-operation cache attached to a {@link Client}, keyed by an - * operation/request `key`. (BETA) - * - * @remarks - * Entries can be `dispose`d, which marks them for reclamation once the matching - * operation is torn down (when the cache is bound to a `Client`’s - * `operations$`), rather than being removed immediately. - * - * @beta - */ -export interface Cache { - get(key: number): Entry | undefined; - set(key: number, value: Entry): void; - clear(key: number): void; - dispose(key: number): void; -} - -/** Creates a {@link Cache} that optionally reclaims entries on operation teardown. (BETA) - * - * @param client - the {@link Client} whose `operations$` drives teardown-based reclamation. - * @param onClear - an optional callback invoked with an entry when it’s cleared. - * @param deferDispose - when `true`, `dispose` marks for reclamation even without a `client`. - * - * @remarks - * When a `client` is passed, this subscribes to its `operations$` stream — and - * only then; importing this module has no side effects. Disposed entries are - * removed when their operation’s `teardown` is observed; otherwise `dispose` - * clears immediately. - * - * @beta - */ -export const makeCache = ( - client?: Client, - onClear?: (value: Entry) => void, - deferDispose?: boolean -): Cache => { - const operations$ = client && (client as Partial).operations$; - const reclaim = new Set(); - const map = new Map(); - - const clear = (key: number) => { - const value = map.get(key); - if (value !== undefined && onClear) onClear(value); - reclaim.delete(key); - map.delete(key); - }; - - if (operations$ /* not available in mocks */) { - pipe( - operations$, - subscribe(operation => { - if (operation.kind === 'teardown' && reclaim.has(operation.key)) { - clear(operation.key); - } - }) - ); - } - - return { - get(key) { - return map.get(key); - }, - set(key, value) { - reclaim.delete(key); - map.set(key, value); - }, - clear, - dispose(key) { - if (operations$ || deferDispose) { - reclaim.add(key); - } else { - clear(key); - } - }, - }; -}; - -type DeferredCacheEntry = DeferredState | undefined; - -interface ClientWithDeferredCache extends Client { - _deferred?: Cache; -} - -/** Returns the per-{@link Client} cache of {@link DeferredState}, creating it lazily. (BETA) - * - * @remarks - * Bindings store one {@link DeferredState} per operation here (keyed by - * `request.key`) so that the {@link DeferredPromise}s associated by - * {@link updateDeferredResult} are shared between the query stream and any - * consumer suspending on a `@defer`-red boundary. Entries are reclaimed on - * teardown, resolving any still-pending promises so no boundary stays suspended. - * - * @beta - */ -export const getDeferredCacheForClient = ( - client: Client -): Cache => { - if (!(client as ClientWithDeferredCache)._deferred) { - (client as ClientWithDeferredCache)._deferred = - makeCache(client, state => { - if (state) resolveDeferredState(state); - }); - } - - return (client as ClientWithDeferredCache)._deferred!; -}; diff --git a/packages/core/src/utils/fragmentSource.test.ts b/packages/core/src/utils/fragmentSource.test.ts new file mode 100644 index 0000000000..caf7c90a00 --- /dev/null +++ b/packages/core/src/utils/fragmentSource.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect } from 'vitest'; +import { filter, makeSubject, merge, onEnd, pipe, subscribe } from 'wonka'; +import type { Source } from 'wonka'; + +import { gql } from '../gql'; +import { createClient } from '../client'; +import type { Exchange, OperationResult } from '../types'; +import { createRequest } from './request'; +import { makeFragmentSource } from './fragmentSource'; +import { + getDeferredFieldPromise, + makeDeferredState, + updateDeferredResult, +} from './defer'; +import type { MaskFragmentResult } from './maskFragment'; + +const collect = (source: Source>) => { + const results: MaskFragmentResult[] = []; + let completed = false; + pipe( + source, + onEnd(() => { + completed = true; + }), + subscribe(result => { + results.push(result); + }) + ); + return { results, completed: () => completed }; +}; + +describe('makeFragmentSource', () => { + const fragment = `fragment TodoFields on Todo { id name __typename }`; + + it('throws when the document contains no matching fragment', () => { + expect(() => + makeFragmentSource({ fragment: `query { todo { id } }`, data: {} }) + ).toThrow(/did not contain a fragment definition/); + expect(() => + makeFragmentSource({ fragment, data: {}, name: 'Other' }) + ).toThrow(/for "Other"/); + }); + + it('issues a fulfilled snapshot synchronously and completes', () => { + const { results, completed } = collect( + makeFragmentSource({ + fragment, + data: { __typename: 'Todo', id: '1', name: 'Learn urql', extra: true }, + }) + ); + + expect(completed()).toBe(true); + expect(results).toHaveLength(1); + expect(results[0].fulfilled).toBe(true); + expect(results[0].data).toEqual({ + __typename: 'Todo', + id: '1', + name: 'Learn urql', + }); + }); + + it('passes through null and undefined data unchanged', () => { + const seen: any[] = []; + pipe( + makeFragmentSource({ fragment, data: null }), + subscribe(result => seen.push(result)) + ); + pipe( + makeFragmentSource({ fragment, data: undefined }), + subscribe(result => seen.push(result)) + ); + + expect(seen).toEqual([ + { data: null, fulfilled: true }, + { data: undefined, fulfilled: true }, + ]); + }); + + it('selects a fragment by name', () => { + const document = ` + fragment TodoIdentity on Todo { id __typename } + fragment TodoDetails on Todo { name __typename } + `; + const seen: any[] = []; + pipe( + makeFragmentSource({ + fragment: document, + name: 'TodoDetails', + data: { __typename: 'Todo', id: '1', name: 'Learn urql' }, + }), + subscribe(result => seen.push(result)) + ); + + expect(seen[0].data).toEqual({ __typename: 'Todo', name: 'Learn urql' }); + }); + + it('re-issues snapshots as deferred patches resolve', async () => { + const query = gql` + query { + todo { + id + __typename + ... on Todo @defer { + name + } + } + } + `; + + const request = createRequest(query, {}); + const state = makeDeferredState(); + const data = { todo: { id: '1', __typename: 'Todo' } }; + + // Simulate the first streamed result: `name` is still pending. + updateDeferredResult( + request, + { operation: {} as any, data, stale: false, hasNext: true }, + state + ); + + const seen: MaskFragmentResult[] = []; + pipe( + makeFragmentSource({ + fragment: `fragment TodoFields on Todo { name }`, + data: data.todo, + }), + subscribe(result => seen.push(result)) + ); + + expect(seen).toHaveLength(1); + expect(seen[0].fulfilled).toBe(false); + expect(seen[0].pending).toBeDefined(); + + // The deferred patch arrives and resolves the sidecar promise. + updateDeferredResult( + request, + { + operation: {} as any, + data: { todo: { id: '1', __typename: 'Todo', name: 'Hello' } }, + stale: false, + hasNext: false, + }, + state + ); + await Promise.resolve(); + + expect(seen).toHaveLength(2); + expect(seen[1].fulfilled).toBe(true); + expect(seen[1].data).toEqual({ name: 'Hello' }); + }); + + it('completes without re-issuing when missing data has no pending patch', () => { + const { results, completed } = collect( + makeFragmentSource({ + fragment, + data: { __typename: 'Todo', id: '1', name: undefined }, + }) + ); + + expect(completed()).toBe(true); + expect(results).toHaveLength(1); + expect(results[0].fulfilled).toBe(false); + expect(results[0].pending).toBeUndefined(); + }); +}); + +describe('Client deferred tracking', () => { + const query = gql` + query { + todo { + id + __typename + ... on Todo @defer { + name + } + } + } + `; + + const makeDeferClient = () => { + const results = makeSubject(); + const exchange: Exchange = () => ops$ => + merge([ + pipe(ops$, filter((): boolean => false)) as any, + results.source, + ]); + const client = createClient({ + url: 'http://0.0.0.0', + exchanges: [exchange], + }); + return { client, results }; + }; + + it('associates and resolves sidecar promises on streamed query results', async () => { + const { client, results } = makeDeferClient(); + const operation = client.createRequestOperation( + 'query', + createRequest(query, undefined) + ); + + const seen: OperationResult[] = []; + pipe( + client.executeRequestOperation(operation), + subscribe(result => seen.push(result)) + ); + + const first = { todo: { id: '1', __typename: 'Todo' } }; + results.next({ + operation, + data: first, + stale: false, + hasNext: true, + }); + + expect(seen).toHaveLength(1); + const pending = getDeferredFieldPromise(first.todo, 'name')!; + expect(pending).toBeDefined(); + expect(pending._resolved).toBe(false); + + results.next({ + operation, + data: { todo: { id: '1', __typename: 'Todo', name: 'Hello' } }, + stale: false, + hasNext: false, + }); + + expect(pending._resolved).toBe(true); + expect(pending._value).toBe('Hello'); + }); + + it('resolves pending promises when the operation is torn down', () => { + const { client, results } = makeDeferClient(); + const operation = client.createRequestOperation( + 'query', + createRequest(query, undefined) + ); + + const subscription = pipe( + client.executeRequestOperation(operation), + subscribe(() => { + /*noop*/ + }) + ); + + const first = { todo: { id: '1', __typename: 'Todo' } }; + results.next({ + operation, + data: first, + stale: false, + hasNext: true, + }); + + const pending = getDeferredFieldPromise(first.todo, 'name')!; + expect(pending._resolved).toBe(false); + + subscription.unsubscribe(); + + expect(pending._resolved).toBe(true); + }); + + it('does not track results for queries without a stream', () => { + const { client, results } = makeDeferClient(); + const operation = client.createRequestOperation( + 'query', + createRequest(query, undefined) + ); + + pipe( + client.executeRequestOperation(operation), + subscribe(() => { + /*noop*/ + }) + ); + + const data = { todo: { id: '1', __typename: 'Todo' } }; + results.next({ operation, data, stale: false, hasNext: false }); + + expect(getDeferredFieldPromise(data.todo, 'name')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/utils/fragmentSource.ts b/packages/core/src/utils/fragmentSource.ts new file mode 100644 index 0000000000..c1172bee0c --- /dev/null +++ b/packages/core/src/utils/fragmentSource.ts @@ -0,0 +1,112 @@ +import type { Source } from 'wonka'; +import { make } from 'wonka'; +import type { FragmentDefinitionNode } from '@0no-co/graphql.web'; +import { Kind } from '@0no-co/graphql.web'; + +import type { GraphQLRequestParams } from '../types'; +import { createRequest } from './request'; +import { getFragments } from './selection'; +import type { MaskFragmentResult } from './maskFragment'; +import { maskFragment } from './maskFragment'; + +/** Input arguments for {@link makeFragmentSource}. (BETA) + * + * @beta + */ +export interface FragmentSourceArgs { + /** A GraphQL document containing the fragment definition to mask against. + * + * @remarks + * The document must contain at least one `FragmentDefinitionNode`. When it + * contains several, `name` selects the definition to use. + */ + fragment: GraphQLRequestParams['query']; + /** A JSON object containing this fragment's fields. + * + * @remarks + * `Input` is separate from `Data` so opaque fragment-reference types from + * gql.tada and GraphQL Code Generator can be passed directly. `null` and + * `undefined` are emitted unchanged. + */ + data: Input | null | undefined; + /** An optional name of the fragment to use from the passed document. */ + name?: string; +} + +/** Creates a {@link Source} of masked fragment snapshots for a piece of data. (BETA) + * + * @param args - a {@link FragmentSourceArgs} object, passing a `fragment` and `data`. + * @returns a Wonka {@link Source} issuing {@link MaskFragmentResult | MaskFragmentResults}. + * + * @remarks + * `makeFragmentSource` masks `data` against the fragment's selection set and + * issues the result synchronously. When the snapshot isn't `fulfilled` because + * a `@defer`-red part of the selection is still streaming in, the source stays + * open and issues a new snapshot each time a deferred patch arrives, until the + * masked data is complete. + * + * The {@link Client} associates the underlying deferred promises with streamed + * query results automatically, so this works for any data that originates from + * a streamed `@defer` query — independently of any framework bindings. + * + * The source completes after issuing a `fulfilled` snapshot, or immediately + * when missing data has no pending deferred patch (in which case nothing could + * ever resolve it). + * + * @beta + */ +export const makeFragmentSource = ( + args: FragmentSourceArgs +): Source> => { + const request = createRequest(args.fragment, {}); + + const fragment = request.query.definitions.find( + definition => + definition.kind === Kind.FRAGMENT_DEFINITION && + (!args.name || definition.name.value === args.name) + ) as FragmentDefinitionNode | undefined; + + if (!fragment) { + throw new Error( + `Passed document did not contain a fragment definition${ + args.name ? ` for "${args.name}"` : '' + }.` + ); + } + + const fragments = getFragments(request.query.definitions); + + return make>(observer => { + let ended = false; + + const update = () => { + if (ended) return; + + const result = maskFragment( + args.data as Data, + fragment.selectionSet, + fragments + ); + + observer.next(result); + if (!result.fulfilled && result.pending) { + result.pending.then(update); + } else { + observer.complete(); + } + }; + + if (args.data == null) { + observer.next({ data: args.data as Data, fulfilled: true }); + observer.complete(); + } else if (typeof args.data !== 'object' || Array.isArray(args.data)) { + throw new Error('makeFragmentSource expects data to be a fragment object.'); + } else { + update(); + } + + return () => { + ended = true; + }; + }); +}; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 9387183c13..62d5f6667f 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -9,7 +9,7 @@ export * from './operation'; export * from './selection'; export * from './defer'; export * from './maskFragment'; -export * from './cache'; +export * from './fragmentSource'; export const noop = () => { /* noop */ From 7b4cdcf95fbdfb7ccd3fbea80c22d412f92bbecb Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Wed, 9 Sep 2026 05:56:45 +0200 Subject: [PATCH 3/3] fix(core): format fragment source for CI --- packages/core/src/utils/fragmentSource.test.ts | 5 ++++- packages/core/src/utils/fragmentSource.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/fragmentSource.test.ts b/packages/core/src/utils/fragmentSource.test.ts index caf7c90a00..4bd88d5020 100644 --- a/packages/core/src/utils/fragmentSource.test.ts +++ b/packages/core/src/utils/fragmentSource.test.ts @@ -181,7 +181,10 @@ describe('Client deferred tracking', () => { const results = makeSubject(); const exchange: Exchange = () => ops$ => merge([ - pipe(ops$, filter((): boolean => false)) as any, + pipe( + ops$, + filter((): boolean => false) + ) as any, results.source, ]); const client = createClient({ diff --git a/packages/core/src/utils/fragmentSource.ts b/packages/core/src/utils/fragmentSource.ts index c1172bee0c..88a03ebaf4 100644 --- a/packages/core/src/utils/fragmentSource.ts +++ b/packages/core/src/utils/fragmentSource.ts @@ -100,7 +100,9 @@ export const makeFragmentSource = ( observer.next({ data: args.data as Data, fulfilled: true }); observer.complete(); } else if (typeof args.data !== 'object' || Array.isArray(args.data)) { - throw new Error('makeFragmentSource expects data to be a fragment object.'); + throw new Error( + 'makeFragmentSource expects data to be a fragment object.' + ); } else { update(); }