diff --git a/.changeset/preact-deferred-fragments.md b/.changeset/preact-deferred-fragments.md new file mode 100644 index 0000000000..3a7ee03636 --- /dev/null +++ b/.changeset/preact-deferred-fragments.md @@ -0,0 +1,5 @@ +--- +'@urql/preact': minor +--- + +Add a beta Preact `useFragment` hook that masks fragment data and suspends while streamed `@defer` selections are incomplete. Deferred tracking happens in `@urql/core`'s `Client`, so Suspense boundaries resolve from later query results without any binding wiring, and outside of Suspense the hook updates through `makeFragmentSource` when deferred patches arrive. diff --git a/packages/preact-urql/package.json b/packages/preact-urql/package.json index 95d6b8fa56..a7c3578b26 100644 --- a/packages/preact-urql/package.json +++ b/packages/preact-urql/package.json @@ -59,6 +59,7 @@ "preact": ">= 10.0.0" }, "dependencies": { + "@0no-co/graphql.web": "^1.0.13", "@urql/core": "workspace:^6.0.3", "wonka": "^6.3.2" }, diff --git a/packages/preact-urql/src/hooks/cache.ts b/packages/preact-urql/src/hooks/cache.ts new file mode 100644 index 0000000000..c928dd9ba0 --- /dev/null +++ b/packages/preact-urql/src/hooks/cache.ts @@ -0,0 +1,68 @@ +import type { FragmentDefinitionNode } from '@0no-co/graphql.web'; +import type { Client } from '@urql/core'; + +/** A pending suspense {@link Promise} that the `useFragment` hook throws. + * + * @internal + */ +export type FragmentPromise = Promise & { + _resolve: () => void; +}; + +type FragmentCache = WeakMap< + FragmentDefinitionNode, + WeakMap +>; + +interface ClientWithCache extends Client { + _fragments?: FragmentCache; +} + +/** Returns a pending fragment promise for this exact fragment and data object. + * + * @remarks + * Weak keys keep sibling objects and named fragments independent and allow + * abandoned suspended renders to be garbage-collected without an effect. + * + * @internal + */ +export const getFragmentPromise = ( + client: Client, + fragment: FragmentDefinitionNode, + data: object +): FragmentPromise | undefined => { + const cache = (client as ClientWithCache)._fragments; + const entries = cache && cache.get(fragment); + return entries && entries.get(data); +}; + +/** Stores a pending promise for this exact fragment and data object. + * + * @internal + */ +export const setFragmentPromise = ( + client: Client, + fragment: FragmentDefinitionNode, + data: object, + promise: FragmentPromise +): void => { + const target = client as ClientWithCache; + const cache = target._fragments || (target._fragments = new WeakMap()); + let entries = cache.get(fragment); + if (!entries) cache.set(fragment, (entries = new WeakMap())); + entries.set(data, promise); +}; + +/** Deletes a settled fragment promise. + * + * @internal + */ +export const deleteFragmentPromise = ( + client: Client, + fragment: FragmentDefinitionNode, + data: object +): void => { + const cache = (client as ClientWithCache)._fragments; + const entries = cache && cache.get(fragment); + if (entries) entries.delete(data); +}; diff --git a/packages/preact-urql/src/hooks/index.ts b/packages/preact-urql/src/hooks/index.ts index 67748166ed..a90380d5d0 100644 --- a/packages/preact-urql/src/hooks/index.ts +++ b/packages/preact-urql/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useFragment'; export * from './useQuery'; export * from './useMutation'; export * from './useSubscription'; diff --git a/packages/preact-urql/src/hooks/useFragment.test.tsx b/packages/preact-urql/src/hooks/useFragment.test.tsx new file mode 100644 index 0000000000..7fb22c683e --- /dev/null +++ b/packages/preact-urql/src/hooks/useFragment.test.tsx @@ -0,0 +1,342 @@ +// @vitest-environment jsdom + +import { FunctionalComponent as FC, h } from 'preact'; +import { render, cleanup, act } from '@testing-library/preact'; +import { expect, it, describe, beforeEach, afterEach } from 'vitest'; + +import { useFragment, UseFragmentState } from './useFragment'; +import { Provider } from '../context'; + +const makeClient = (overrides: Record = {}): any => ({ + suspense: false, + ...overrides, +}); + +let snapshot: UseFragmentState | undefined; + +const Probe: FC = props => { + snapshot = useFragment(props); + return null; +}; + +const renderProbe = (client: any, props: any) => + render(h(Provider, { value: client, children: [h(Probe, props)] })); + +// Renders the hook and captures either the masked state or a thrown suspense +// promise, without a Suspense boundary, so we can assert the suspense bridge. +const captureSuspense = (client: any, props: any) => { + let thrown: unknown; + let rendered: UseFragmentState | undefined; + const Catcher: FC = () => { + try { + rendered = useFragment(props); + } catch (error) { + thrown = error; + } + return null; + }; + render(h(Provider, { value: client, children: [h(Catcher, {})] })); + return { thrown, rendered }; +}; + +beforeEach(() => { + snapshot = undefined; +}); + +afterEach(() => cleanup()); + +describe('useFragment masking', () => { + it('masks data to the selected fields', () => { + renderProbe(makeClient(), { + fragment: `fragment TodoFields on Todo { id name __typename }`, + data: { + __typename: 'Todo', + id: '1', + name: 'Learn urql', + completed: true, + }, + }); + + expect(snapshot).toEqual({ + fetching: false, + data: { __typename: 'Todo', id: '1', name: 'Learn urql' }, + }); + }); + + it('takes a named fragment to mask data', () => { + renderProbe(makeClient(), { + fragment: `fragment x on X { foo } fragment TodoFields on Todo { id name __typename }`, + name: 'TodoFields', + data: { + __typename: 'Todo', + id: '1', + name: 'Learn urql', + completed: true, + }, + }); + + expect(snapshot).toEqual({ + fetching: false, + data: { __typename: 'Todo', id: '1', name: 'Learn urql' }, + }); + }); + + it('updates the masked data when the fragment name changes', () => { + const client = makeClient(); + const query = ` + fragment TodoIdentity on Todo { id __typename } + fragment TodoDetails on Todo { name __typename } + `; + const data = { + __typename: 'Todo', + id: '1', + name: 'Learn urql', + }; + const view = renderProbe(client, { + fragment: query, + name: 'TodoIdentity', + data, + }); + + expect(snapshot).toEqual({ + fetching: false, + data: { __typename: 'Todo', id: '1' }, + }); + + view.rerender( + h(Provider, { + value: client, + children: [h(Probe, { fragment: query, name: 'TodoDetails', data })], + }) + ); + + expect(snapshot).toEqual({ + fetching: false, + data: { __typename: 'Todo', name: 'Learn urql' }, + }); + }); + + it('marks fetching for a missing non-optional field', () => { + renderProbe(makeClient(), { + fragment: `fragment TodoFields on Todo { id name __typename }`, + data: { __typename: 'Todo', id: '1', name: undefined }, + }); + + expect(snapshot).toEqual({ + fetching: true, + data: { __typename: 'Todo', id: '1' }, + }); + }); + + it('treats a missing @defer-red fragment spread as fulfilled', () => { + renderProbe(makeClient(), { + fragment: ` + fragment TodoFields on Todo { + id name __typename + ...AuthorFields @defer + } + + fragment AuthorFields on Todo { author { id name __typename } } + `, + name: 'TodoFields', + data: { __typename: 'Todo', id: '1', name: null, author: undefined }, + }); + + expect(snapshot).toEqual({ + fetching: false, + data: { __typename: 'Todo', id: '1', name: null }, + }); + }); + + it('returns null data without masking when data is null', () => { + renderProbe(makeClient(), { + fragment: `fragment TodoFields on Todo { id name __typename }`, + data: null, + }); + + expect(snapshot).toEqual({ fetching: false, data: null }); + }); + + it('returns undefined data without masking when data is undefined', () => { + renderProbe(makeClient(), { + fragment: `fragment TodoFields on Todo { id name __typename }`, + data: undefined, + }); + + expect(snapshot).toEqual({ fetching: false, data: undefined }); + }); +}); + +describe('useFragment suspense', () => { + const SongFields = `fragment SongFields on Song { id title __typename }`; + + it('throws a suspense promise while a field is missing', () => { + const { thrown, rendered } = captureSuspense(makeClient(), { + fragment: SongFields, + data: { __typename: 'Song', id: '1', title: undefined }, + context: { suspense: true }, + }); + + expect(rendered).toBeUndefined(); + expect(thrown).toBeInstanceOf(Promise); + }); + + it('does not share suspense promises between unidentified objects', () => { + const client = makeClient(); + const first = captureSuspense(client, { + fragment: SongFields, + data: { title: undefined }, + context: { suspense: true }, + }); + const second = captureSuspense(client, { + fragment: SongFields, + data: { title: undefined }, + context: { suspense: true }, + }); + + expect(first.thrown).toBeInstanceOf(Promise); + expect(second.thrown).toBeInstanceOf(Promise); + expect(second.thrown).not.toBe(first.thrown); + }); + + it('scopes suspense promises by fragment name', () => { + const client = makeClient(); + const query = ` + fragment SongTitle on Song { title } + fragment SongArtist on Song { artist } + `; + const data = { + __typename: 'Song', + id: '1', + title: undefined, + artist: undefined, + }; + const title = captureSuspense(client, { + fragment: query, + name: 'SongTitle', + data, + context: { suspense: true }, + }); + const artist = captureSuspense(client, { + fragment: query, + name: 'SongArtist', + data, + context: { suspense: true }, + }); + + expect(title.thrown).toBeInstanceOf(Promise); + expect(artist.thrown).toBeInstanceOf(Promise); + expect(artist.thrown).not.toBe(title.thrown); + }); + + it('does not suspend when the data is already complete', () => { + const { thrown, rendered } = captureSuspense(makeClient(), { + fragment: SongFields, + data: { __typename: 'Song', id: '1', title: 'World' }, + context: { suspense: true }, + }); + + expect(thrown).toBeUndefined(); + expect(rendered).toEqual({ + fetching: false, + data: { __typename: 'Song', id: '1', title: 'World' }, + }); + }); + + it('does not re-suspend for an entity it has already committed', async () => { + const client = makeClient(); + let thrown: unknown; + const Catcher: FC = props => { + thrown = undefined; + try { + snapshot = useFragment(props); + } catch (error) { + thrown = error; + } + return null; + }; + const propsFor = (data: any) => ({ + fragment: SongFields, + data, + context: { suspense: true }, + }); + + const view = render( + h(Provider, { + value: client, + children: [ + h(Catcher, propsFor({ __typename: 'Song', id: '1', title: 'Hello' })), + ], + }) + ); + // Flush the commit effect that records the committed entity. + await act(async () => {}); + expect(thrown).toBeUndefined(); + + // A refetch streams again and the deferred field is missing once more; + // the hook keeps the committed data instead of re-suspending. + view.rerender( + h(Provider, { + value: client, + children: [ + h( + Catcher, + propsFor({ __typename: 'Song', id: '1', title: undefined }) + ), + ], + }) + ); + await act(async () => {}); + + expect(thrown).toBeUndefined(); + expect(snapshot).toEqual({ + fetching: true, + data: { __typename: 'Song', id: '1', title: 'Hello' }, + }); + }); + + it('suspends again when moving to a different entity', async () => { + const client = makeClient(); + let thrown: unknown; + const Catcher: FC = props => { + thrown = undefined; + try { + snapshot = useFragment(props); + } catch (error) { + thrown = error; + } + return null; + }; + const propsFor = (data: any) => ({ + fragment: SongFields, + data, + context: { suspense: true }, + }); + + const view = render( + h(Provider, { + value: client, + children: [ + h(Catcher, propsFor({ __typename: 'Song', id: '1', title: 'Hello' })), + ], + }) + ); + await act(async () => {}); + expect(thrown).toBeUndefined(); + + view.rerender( + h(Provider, { + value: client, + children: [ + h( + Catcher, + propsFor({ __typename: 'Song', id: '2', title: undefined }) + ), + ], + }) + ); + await act(async () => {}); + + expect(thrown).toBeInstanceOf(Promise); + }); +}); diff --git a/packages/preact-urql/src/hooks/useFragment.ts b/packages/preact-urql/src/hooks/useFragment.ts new file mode 100644 index 0000000000..24b177e935 --- /dev/null +++ b/packages/preact-urql/src/hooks/useFragment.ts @@ -0,0 +1,291 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + +import { + useMemo, + useCallback, + useEffect, + useRef, + useState, +} from 'preact/hooks'; +import { pipe, subscribe } from 'wonka'; +import type { FragmentDefinitionNode } from '@0no-co/graphql.web'; + +import type { + GraphQLRequestParams, + AnyVariables, + Client, + OperationContext, +} from '@urql/core'; +import { maskFragment, getFragments, makeFragmentSource } from '@urql/core'; + +import { useClient } from '../context'; +import { useRequest } from './useRequest'; +import type { FragmentPromise } from './cache'; +import { + deleteFragmentPromise, + getFragmentPromise, + setFragmentPromise, +} from './cache'; + +/** Input arguments for the {@link useFragment} hook. */ +export type UseFragmentArgs = { + /** Partial {@link OperationContext} used to configure this hook. + * + * @remarks + * Unlike {@link useQuery}, `useFragment` doesn’t execute a GraphQL operation, + * so only `context.suspense` is read here. When set, it overrides the + * {@link Client.suspense} flag for this hook and controls whether it suspends + * while a fragment’s deferred data is still incomplete. + */ + context?: Partial; + /** A GraphQL document to mask this fragment against. + * + * @remarks + * This Document should contain atleast one FragmentDefinitionNode or + * a FragmentDefinitionNode with the same name as the `name` property. + */ + fragment: GraphQLRequestParams['query']; + /** A JSON object containing this fragment's fields. + * + * @remarks + * `Input` is separate from `Data` so fragment-reference types from gql.tada + * and GraphQL Code Generator can be passed directly. `null` and `undefined` + * are returned unchanged. + */ + data: Input | null | undefined; + /** An optional name of the fragment to use from the passed Document. */ + name?: string; +}; + +/** State of the masked fragment your {@link useFragment} hook returns. */ +export interface UseFragmentState { + /** Indicates whether `useFragment` is waiting for a new result. + * + * @remarks + * When `useFragment` is masking a fragment whose data isn’t fully present + * yet — for instance while a `@defer`-red part of it is still streaming in — + * `fetching` is set to `true` until the remaining data arrives. + */ + fetching: boolean; + /** The data for the masked fragment. */ + data?: Data | null; +} + +const EMPTY_VARIABLES: AnyVariables = {}; + +const isSuspense = (client: Client, context?: Partial) => + context && context.suspense !== undefined + ? !!context.suspense + : client.suspense; + +const hasDepsChanged = (a: T, b: T) => { + for (let i = 0, l = b.length; i < l; i++) if (a[i] !== b[i]) return true; + return false; +}; + +/** State a hook instance has last committed, used to limit suspensions. + * + * @internal + */ +interface CommittedFragment { + fragment: FragmentDefinitionNode; + key: unknown; + data: Data | null; +} + +/** Returns a stable identity for the entity a fragment is read on. + * + * @remarks + * When the data is keyable (`__typename` plus `id`/`_id`), streamed refetches + * that pass a new object for the same entity share an identity. Unkeyable data + * falls back to object identity, which treats every new object as a new entity. + * + * @internal + */ +const getEntityKey = (data: any): unknown => + data && data.__typename && (data.id != null || data._id != null) + ? `${data.__typename}:${data.id != null ? data.id : data._id}` + : data; + +/** Hook to mask a GraphQL Fragment given its data. (BETA) + * + * @param args - a {@link UseFragmentArgs} object, to pass a `fragment` and `data`. + * @returns a {@link UseFragmentState} result. + * + * @remarks + * `useFragment` allows GraphQL fragments to mask their data. + * Given {@link UseFragmentArgs.fragment} and {@link UseFragmentArgs.data}, it + * returns the data selected by that fragment. + * + * Additionally, if the `suspense` option is enabled on the `Client`, + * the `useFragment` hook will suspend instead of indicating that it’s + * waiting for a result via {@link UseFragmentState.fetching}. This is useful + * to render `@defer`-red parts of a query incrementally as they stream in. + * + * @example + * ```ts + * import { gql, useFragment } from '@urql/preact'; + * + * const TodoFields = gql` + * fragment TodoFields on Todo { id name } + * `; + * + * const Todo = (props) => { + * const result = useFragment({ + * data: props.todo, + * fragment: TodoFields, + * }); + * // ... + * }; + * ``` + */ +export function useFragment( + args: UseFragmentArgs +): UseFragmentState { + const client = useClient(); + const suspense = isSuspense(client, args.context); + + const request = useRequest(args.fragment, EMPTY_VARIABLES); + + const fragments = useMemo( + () => getFragments(request.query.definitions), + [request.query] + ); + + const fragment = useMemo( + () => (args.name ? fragments[args.name] : Object.values(fragments)[0]), + [fragments, args.name] + ); + + if (!fragment) { + throw new Error( + `Passed document did not contain a fragment definition${ + args.name ? ` for "${args.name}"` : '' + }.` + ); + } + + // Tracks the entity this hook instance last committed a complete result + // for. Modelled on Relay's committed-selector check: a fragment may only + // suspend on its first render or when it moves to a different entity — + // never for an entity it has already shown, so a refetch that streams + // again can't tear a settled boundary back to its fallback. + const committedRef = useRef | null>(null); + + const getSnapshot = useCallback( + ( + data: Input | null | undefined, + suspense: boolean + ): UseFragmentState => { + if (data == null) { + return { data: data as null | undefined, fetching: false }; + } else if (typeof data !== 'object' || Array.isArray(data)) { + throw new Error('useFragment expects data to be a fragment object.'); + } else if (!suspense) { + const newResult = maskFragment( + data as Data, + fragment.selectionSet, + fragments + ); + + return { data: newResult.data, fetching: !newResult.fulfilled }; + } + + const cached = getFragmentPromise(client, fragment, data); + const newResult = maskFragment( + data as Data, + fragment.selectionSet, + fragments + ); + + if (newResult.fulfilled) { + if (cached) { + cached._resolve(); + deleteFragmentPromise(client, fragment, data); + } + return { data: newResult.data, fetching: false }; + } + + const committed = committedRef.current; + if ( + committed && + committed.fragment === fragment && + committed.key === getEntityKey(data) + ) { + // This hook has already committed this entity: render the last + // committed data with `fetching: true` instead of re-suspending, and + // let the fragment-source effect apply the streamed-in patch. + return { data: committed.data, fetching: true }; + } + + if (newResult.pending) { + // The query stream owns this promise and will resolve it directly when + // the deferred patch is merged, which also works during server streams. + throw newResult.pending; + } else if (cached) { + // We're still waiting on data and already suspended once; re-throw the + // same promise so Preact keeps showing the suspense boundary's fallback. + throw cached; + } else { + let _resolve!: () => void; + const promise = new Promise(resolve => { + _resolve = () => resolve(undefined); + }) as FragmentPromise; + promise._resolve = _resolve; + setFragmentPromise(client, fragment, data, promise); + throw promise; + } + }, + [client, fragment, fragments] + ); + + const deps = [client, request, fragment, args.data, suspense] as const; + + const [state, setState] = useState( + () => [getSnapshot(args.data, suspense), deps] as const + ); + + const currentResult = state[0]; + if (hasDepsChanged(state[1], deps)) { + setState([getSnapshot(args.data, suspense), deps]); + } + + useEffect(() => { + if (!currentResult.fetching && args.data != null) { + committedRef.current = { + fragment, + key: getEntityKey(args.data), + data: currentResult.data || null, + }; + } + }); + + useEffect(() => { + // Whenever an incomplete snapshot was rendered instead of suspending — + // always outside of suspense mode, and after a commit within it — + // subscribe to the fragment source so `@defer`-red data that streams in + // later updates this hook without a parent rerender. + if (!currentResult.fetching || args.data == null) return; + + let initial = true; + const subscription = pipe( + makeFragmentSource({ + fragment: request.query, + data: args.data, + name: fragment.name.value, + }), + subscribe(result => { + // The first snapshot mirrors the state this hook already rendered; + // later snapshots are issued when a deferred patch has arrived. + if (!initial) { + setState([{ data: result.data, fetching: !result.fulfilled }, deps]); + } + initial = false; + }) + ); + + return subscription.unsubscribe; + }, [currentResult, args.data, suspense]); + + return currentResult; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22fddc88dd..fb38e8d8de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,6 +381,9 @@ importers: packages/preact-urql: dependencies: + '@0no-co/graphql.web': + specifier: ^1.0.13 + version: 1.0.13(graphql@16.9.0) '@urql/core': specifier: workspace:^6.0.3 version: link:../core