diff --git a/.changeset/generalise-deferred-fragments.md b/.changeset/generalise-deferred-fragments.md new file mode 100644 index 0000000000..4659a387af --- /dev/null +++ b/.changeset/generalise-deferred-fragments.md @@ -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. 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 69ceb6a649..f433326823 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,3 +16,11 @@ export { makeOperation, getOperationName, } from './utils'; + +export { maskFragment, getFragments, makeFragmentSource } from './utils'; + +export type { + FragmentMap, + MaskFragmentResult, + FragmentSourceArgs, +} from './utils'; 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/fragmentSource.test.ts b/packages/core/src/utils/fragmentSource.test.ts new file mode 100644 index 0000000000..4bd88d5020 --- /dev/null +++ b/packages/core/src/utils/fragmentSource.test.ts @@ -0,0 +1,283 @@ +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..88a03ebaf4 --- /dev/null +++ b/packages/core/src/utils/fragmentSource.ts @@ -0,0 +1,114 @@ +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 1a401ed433..62d5f6667f 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 './fragmentSource'; 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; + }); +};