diff --git a/.changeset/react-deferred-fragments.md b/.changeset/react-deferred-fragments.md
new file mode 100644
index 0000000000..09d94cadbc
--- /dev/null
+++ b/.changeset/react-deferred-fragments.md
@@ -0,0 +1,5 @@
+---
+'urql': minor
+---
+
+Add a beta React `useFragment` hook that masks fragment data and suspends while streamed `@defer` selections are incomplete. Deferred tracking happens in `@urql/core`'s `Client`, so nested Suspense boundaries resolve directly from later query results during client and server rendering, and outside of Suspense the hook updates through `makeFragmentSource` when deferred patches arrive.
diff --git a/docs/basics/typescript-integration.md b/docs/basics/typescript-integration.md
index 86634a5f13..2ba79b78dd 100644
--- a/docs/basics/typescript-integration.md
+++ b/docs/basics/typescript-integration.md
@@ -189,10 +189,10 @@ GraphQL Code Generator generates type helpers to type your component props based
Again, here is an example with the React bindings:
```tsx
-import { FragmentType, useFragment } from './gql/fragment-masking';
+import { useFragment } from 'urql';
+import type { FragmentType } from './gql/fragment-masking';
import { graphql } from '../src/gql';
-// again, we use the generated `graphql()` function to write GraphQL documents đź‘€
export const FilmFragment = graphql(/* GraphQL */ `
fragment FilmItem on Film {
id
@@ -202,26 +202,88 @@ export const FilmFragment = graphql(/* GraphQL */ `
}
`);
-const Film = (props: {
- // `film` property has the correct type 🎉
- film: FragmentType;
-}) => {
- // `film` is of type `FilmFragment`, with no extraneous properties ⚡️
- const film = useFragment(FilmFragment, props.film);
- return (
+const Film = (props: { film: FragmentType }) => {
+ const { data: film } = useFragment({
+ fragment: FilmFragment,
+ data: props.film,
+ });
+
+ return film ? (
{film.title}
{film.releaseDate}
- );
+ ) : null;
};
export default Film;
```
-_Examples with Vue are available [in the GraphQL Code Generator repository](https://github.com/dotansimha/graphql-code-generator/tree/master/examples/vue/urql)_.
+The `FragmentType` reference and the fragment's result type are intentionally
+separate. `useFragment` accepts the generated reference as its input and infers
+its returned `data` from `FilmFragment`. This also allows an incremental
+fragment reference to be passed before an `@defer` patch has arrived; with
+Suspense enabled, the hook waits for that patch.
+
+GraphQL Code Generator calls its generated unmasking helper `useFragment` by
+default, but that helper isn't a React hook. To avoid a naming collision, name
+it `readFragment` (or `getFragmentData`) in your Codegen configuration:
+
+```ts
+presetConfig: {
+ fragmentMasking: {
+ unmaskFunctionName: 'readFragment',
+ },
+},
+```
+
+The generated `readFragment(Fragment, data)` helper may still be used before
+calling urql's hook for non-deferred data. It is not required: passing the
+fragment reference directly is preferred for `@defer`, since Codegen's
+incremental reference is not considered fully readable until its patch arrives.
+
+For a deferred fragment, type the component input from the parent query field so
+its incremental state is retained:
+
+```tsx
+const Film = (props: { film: NonNullable }) => {
+ const { data: film } = useFragment({
+ fragment: FilmFragment,
+ data: props.film,
+ });
+ // ...
+};
+```
+
+### Using gql.tada fragment references
+
+gql.tada's opaque `FragmentOf` references are also accepted directly:
+
+```tsx
+import { useFragment } from 'urql';
+import { graphql, type FragmentOf } from 'gql.tada';
+
+const FilmFragment = graphql(`
+ fragment FilmItem on Film {
+ id
+ title
+ releaseDate
+ }
+`);
+
+const Film = (props: { film: FragmentOf }) => {
+ const { data: film } = useFragment({
+ fragment: FilmFragment,
+ data: props.film,
+ });
+ return film ?
{film.title}
: null;
+};
+```
-You will notice that our `` component leverages 2 imports from our generated code (from `../src/gql`): the `FragmentType` type helper and the `useFragment()` function.
+You may equivalently pass
+`readFragment(FilmFragment, props.film)` as `data`. Both gql.tada and GraphQL
+Code Generator's readers preserve `null` and `undefined`, which `useFragment`
+also returns unchanged. For deferred fields, pass the opaque/incremental
+reference directly so the hook can suspend until the streamed patch arrives.
-- we use `FragmentType` to get the corresponding Fragment TypeScript type
-- later on, we use `useFragment()` to retrieve the properly film property
+_Examples with Vue are available [in the GraphQL Code Generator repository](https://github.com/dotansimha/graphql-code-generator/tree/master/examples/vue/urql)._
diff --git a/packages/react-urql/package.json b/packages/react-urql/package.json
index d6e9698e7d..2827567773 100644
--- a/packages/react-urql/package.json
+++ b/packages/react-urql/package.json
@@ -60,6 +60,7 @@
"react": ">= 16.8.0"
},
"dependencies": {
+ "@0no-co/graphql.web": "^1.0.13",
"@urql/core": "workspace:^6.0.3",
"wonka": "^6.3.2"
},
diff --git a/packages/react-urql/src/hooks/cache.ts b/packages/react-urql/src/hooks/cache.ts
index 1888799a47..df01b3dce2 100644
--- a/packages/react-urql/src/hooks/cache.ts
+++ b/packages/react-urql/src/hooks/cache.ts
@@ -1,4 +1,5 @@
import { pipe, subscribe } from 'wonka';
+import type { FragmentDefinitionNode } from '@0no-co/graphql.web';
import type { Client, OperationResult } from '@urql/core';
type CacheEntry = OperationResult | Promise | undefined;
@@ -10,7 +11,21 @@ interface Cache {
dispose(key: number): void;
}
+/** A pending suspense {@link Promise} that the `useFragment` hook throws.
+ *
+ * @internal
+ */
+export type FragmentPromise = Promise & {
+ _resolve: () => void;
+};
+
+type FragmentCache = WeakMap<
+ FragmentDefinitionNode,
+ WeakMap
}>
+
+
+
+ );
+ };
+
+ const html = await renderToString(, async () => {
+ // Initial payload: non-deferred fields only, `title` is still pending.
+ results.next({
+ operation,
+ data: { song: { __typename: 'Song', id: '1' } },
+ hasNext: true,
+ stale: false,
+ });
+
+ // Let React render the shell with the boundary's fallback before the
+ // deferred patch arrives, so the child genuinely suspends.
+ await sleep(20);
+
+ // The deferred patch streams in; the query stream resolves the promise.
+ results.next({
+ operation,
+ data: { song: { __typename: 'Song', id: '1', title: 'Hello' } },
+ hasNext: false,
+ stale: false,
+ });
+ });
+
+ // The non-deferred field is in the shell, and the deferred fragment was
+ // streamed in and resolved server-side — without any parent rerender.
+ expect(html).toContain('Song');
+ expect(html).toContain('Hello');
+ });
+
+ it('streams a nested deferred fragment', async () => {
+ const { client, results } = makeStreamedClient();
+ setClient(client);
+
+ const query = `
+ query {
+ post {
+ id
+ __typename
+ ...PostFields @defer
+ }
+ }
+
+ fragment PostFields on Post {
+ author {
+ __typename
+ ...AuthorFields @defer
+ }
+ }
+
+ fragment AuthorFields on Author {
+ name
+ }
+ `;
+
+ const operation = client.createRequestOperation(
+ 'query',
+ createRequest(query, undefined)
+ );
+
+ const Author = ({ data }: { data: any }) => {
+ const fragment = useFragment({
+ fragment: `fragment AuthorFields on Author { name }`,
+ data,
+ context: { suspense: true },
+ });
+ return {fragment.data.name};
+ };
+
+ const Page = () => {
+ const [result] = useQuery({
+ query,
+ context: { suspense: true },
+ });
+
+ const post = result.data.post;
+ return (
+ loading-post}>
+
+
+ );
+ };
+
+ const PostBody = ({ post }: { post: any }) => {
+ const fragment = useFragment({
+ fragment: `
+ fragment PostFields on Post {
+ author {
+ __typename
+ ...AuthorFields @defer
+ }
+ }
+
+ fragment AuthorFields on Author {
+ name
+ }
+ `,
+ data: post,
+ context: { suspense: true },
+ });
+ return (
+ loading-author}>
+
+
+ );
+ };
+
+ const html = await renderToString(, async () => {
+ results.next({
+ operation,
+ data: { post: { __typename: 'Post', id: '1' } },
+ hasNext: true,
+ stale: false,
+ });
+ await sleep(20);
+ results.next({
+ operation,
+ data: {
+ post: {
+ __typename: 'Post',
+ id: '1',
+ author: { __typename: 'Author' },
+ },
+ },
+ hasNext: true,
+ stale: false,
+ });
+ await sleep(20);
+ results.next({
+ operation,
+ data: {
+ post: {
+ __typename: 'Post',
+ id: '1',
+ author: { __typename: 'Author', name: 'Jovi' },
+ },
+ },
+ hasNext: false,
+ stale: false,
+ });
+ });
+
+ expect(html).toContain('Jovi');
+ });
+});
diff --git a/packages/react-urql/src/hooks/useFragment.test.tsx b/packages/react-urql/src/hooks/useFragment.test.tsx
new file mode 100644
index 0000000000..d63b867acc
--- /dev/null
+++ b/packages/react-urql/src/hooks/useFragment.test.tsx
@@ -0,0 +1,946 @@
+// @vitest-environment jsdom
+
+import { vi, expect, it, describe, beforeEach } from 'vitest';
+
+vi.mock('../context', () => {
+ const state = { current: {} };
+
+ return {
+ useClient: () => state.current,
+ __setClient(client: unknown) {
+ state.current = client as {};
+ },
+ };
+});
+
+import React, { Suspense } from 'react';
+import { renderHook, render, act, cleanup } from '@testing-library/react';
+import { filter, makeSubject, merge, pipe, subscribe } from 'wonka';
+import { createClient, createRequest } from '@urql/core';
+import type { Exchange, TypedDocumentNode } from '@urql/core';
+
+import { getFragmentPromise } from './cache';
+import { useFragment } from './useFragment';
+import type { UseFragmentState } from './useFragment';
+import { useQuery } from './useQuery';
+import * as context from '../context';
+
+const { useClient } = context;
+const setClient = (client: unknown) => (context as any).__setClient(client);
+
+/** Creates a real `Client` whose exchange issues results from a subject.
+ *
+ * @remarks
+ * Deferred tracking lives in the `Client`'s result pipeline, so tests that
+ * stream `@defer` results must run through a real client rather than mocking
+ * `executeQuery`.
+ */
+const makeStreamedClient = () => {
+ 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',
+ suspense: true,
+ exchanges: [exchange],
+ });
+ return { client, results };
+};
+
+const mockQuery = `
+ fragment TodoFields on Todo {
+ id
+ name
+ __typename
+ }
+`;
+
+type FilmData = { __typename: 'Film'; id: string; title: string };
+declare const tadaFragmentRefs: unique symbol;
+type TadaFragmentOf = {
+ readonly [tadaFragmentRefs]: { FilmItem: 'Film' };
+};
+type Incremental =
+ | Data
+ | { [Key in keyof Data]?: Key extends '__typename' ? Data[Key] : never };
+type CodegenFragmentType = {
+ ' $fragmentRefs'?: { FilmItemFragment: Data };
+};
+
+// Compile-time coverage for gql.tada's opaque FragmentOf shape and GraphQL
+// Code Generator's incremental FragmentType shape. The fragment document alone
+// determines the returned data type; the masked input has its own generic.
+const useCheckFragmentReferenceInterop = (
+ tadaFragment: TypedDocumentNode,
+ codegenFragment: TypedDocumentNode,
+ tada: TadaFragmentOf | null | undefined,
+ codegen:
+ | CodegenFragmentType>
+ | Record
+) => {
+ const tadaResult: UseFragmentState = useFragment({
+ fragment: tadaFragment,
+ data: tada,
+ });
+ const codegenResult: UseFragmentState = useFragment({
+ fragment: codegenFragment,
+ data: codegen,
+ });
+ return [tadaResult, codegenResult];
+};
+void useCheckFragmentReferenceInterop;
+
+beforeEach(() => {
+ cleanup();
+ // Reset to a fresh mock client (and its per-client caches) between tests.
+ setClient({});
+});
+
+describe('useFragment masking', () => {
+ it('should correctly mask data', () => {
+ const { result } = renderHook(
+ ({ fragment }) =>
+ useFragment({
+ fragment,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: 'Learn urql',
+ completed: true,
+ },
+ }),
+ { initialProps: { fragment: mockQuery } }
+ );
+
+ expect(result.current).toEqual({
+ fetching: false,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: 'Learn urql',
+ },
+ });
+ });
+
+ it('should correctly take a named fragment to mask data', () => {
+ const { result } = renderHook(() =>
+ useFragment({
+ fragment: `fragment x on X { foo bar } fragment TodoFields on Todo { id name __typename }`,
+ name: 'TodoFields',
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: 'Learn urql',
+ completed: true,
+ },
+ })
+ );
+
+ expect(result.current).toEqual({
+ fetching: false,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: 'Learn urql',
+ },
+ });
+ });
+
+ it('updates the masked data when the fragment name changes', () => {
+ const query = `
+ fragment TodoIdentity on Todo { id __typename }
+ fragment TodoDetails on Todo { name __typename }
+ `;
+ const data = {
+ __typename: 'Todo',
+ id: '1',
+ name: 'Learn urql',
+ };
+
+ const { result, rerender } = renderHook(
+ ({ name }) => useFragment({ fragment: query, name, data }),
+ { initialProps: { name: 'TodoIdentity' } }
+ );
+
+ expect(result.current).toEqual({
+ fetching: false,
+ data: { __typename: 'Todo', id: '1' },
+ });
+
+ rerender({ name: 'TodoDetails' });
+
+ expect(result.current).toEqual({
+ fetching: false,
+ data: { __typename: 'Todo', name: 'Learn urql' },
+ });
+ });
+
+ it('should correctly mask data w/ null attribute', () => {
+ const { result } = renderHook(() =>
+ useFragment({
+ fragment: mockQuery,
+ data: { __typename: 'Todo', id: '1', name: null, completed: true },
+ })
+ );
+
+ expect(result.current).toEqual({
+ fetching: false,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ },
+ });
+ });
+
+ it('should correctly indicate loading w/ undefined attribute', () => {
+ const { result } = renderHook(() =>
+ useFragment({
+ fragment: mockQuery,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: undefined,
+ completed: true,
+ },
+ })
+ );
+
+ expect(result.current).toEqual({
+ fetching: true,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ },
+ });
+ });
+
+ it('should correctly mask data w/ nested object', () => {
+ const { result } = renderHook(() =>
+ useFragment({
+ fragment: `
+ fragment TodoFields on Todo {
+ id
+ name
+ __typename
+ author { id name __typename }
+ }`,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ completed: true,
+ author: {
+ id: '1',
+ name: 'Jovi',
+ __typename: 'Author',
+ awardWinner: true,
+ },
+ },
+ })
+ );
+
+ expect(result.current).toEqual({
+ fetching: false,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ author: {
+ __typename: 'Author',
+ id: '1',
+ name: 'Jovi',
+ },
+ },
+ });
+ });
+
+ it('should correctly mask data w/ nested selection that is null', () => {
+ const { result } = renderHook(() =>
+ useFragment({
+ fragment: `
+ fragment TodoFields on Todo {
+ id
+ name
+ __typename
+ author { id name __typename }
+ }`,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ completed: true,
+ author: null,
+ },
+ })
+ );
+
+ expect(result.current).toEqual({
+ fetching: false,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ author: null,
+ },
+ });
+ });
+
+ it('should preserve null items in nullable lists', () => {
+ const { result } = renderHook(() =>
+ useFragment({
+ fragment: `
+ fragment TodoFields on Todo {
+ id
+ __typename
+ assignees { id name __typename }
+ }`,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ assignees: [
+ null,
+ {
+ __typename: 'User',
+ id: '2',
+ name: 'Jovi',
+ role: 'admin',
+ },
+ ],
+ },
+ })
+ );
+
+ expect(result.current).toEqual({
+ fetching: false,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ assignees: [
+ null,
+ {
+ __typename: 'User',
+ id: '2',
+ name: 'Jovi',
+ },
+ ],
+ },
+ });
+ });
+
+ it('should correctly mark loading w/ nested selection that is undefined', () => {
+ const { result } = renderHook(() =>
+ useFragment({
+ fragment: `
+ fragment TodoFields on Todo {
+ id
+ name
+ __typename
+ author { id name __typename }
+ }`,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ completed: true,
+ author: undefined,
+ },
+ })
+ );
+
+ expect(result.current).toEqual({
+ fetching: true,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ },
+ });
+ });
+
+ it('should correctly mark resolved w/ deferred nested fragment-selection that is undefined', () => {
+ const { result } = renderHook(() =>
+ useFragment({
+ fragment: `
+ fragment TodoFields on Todo {
+ id
+ name
+ __typename
+ ...AuthorFields @defer
+ }
+
+ fragment AuthorFields on Todo { author { id name __typename } }
+ `,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ completed: true,
+ author: undefined,
+ },
+ })
+ );
+
+ expect(result.current).toEqual({
+ fetching: false,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ },
+ });
+ });
+
+ it('should correctly mark loading w/ non-deferred nested fragment-selection that is undefined', () => {
+ const { result } = renderHook(() =>
+ useFragment({
+ fragment: `
+ fragment TodoFields on Todo {
+ id
+ name
+ __typename
+ ...AuthorFields
+ }
+
+ fragment AuthorFields on Todo { author { id name __typename } }
+ `,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ completed: true,
+ author: undefined,
+ },
+ })
+ );
+
+ expect(result.current).toEqual({
+ fetching: true,
+ data: {
+ __typename: 'Todo',
+ id: '1',
+ name: null,
+ },
+ });
+ });
+
+ it('returns null data without masking when data is null', () => {
+ const { result } = renderHook(() =>
+ useFragment({ fragment: mockQuery, data: null })
+ );
+
+ expect(result.current).toEqual({ fetching: false, data: null });
+ });
+
+ it('returns undefined data without masking when data is undefined', () => {
+ const { result } = renderHook(() =>
+ useFragment({ fragment: mockQuery, data: undefined })
+ );
+
+ expect(result.current).toEqual({ fetching: false, data: undefined });
+ });
+});
+
+describe('useFragment suspense', () => {
+ const SongFields = `fragment SongFields on Song { id title __typename }`;
+
+ const Song = ({ data }: { data: any }) => {
+ const result = useFragment({
+ fragment: SongFields,
+ data,
+ context: { suspense: true },
+ });
+ return
{result.data ? result.data.title : 'no-title'}
;
+ };
+
+ it('suspends while a deferred field is missing, then renders once it arrives', async () => {
+ const incomplete = { __typename: 'Song', id: '1', title: undefined };
+ const complete = { __typename: 'Song', id: '1', title: 'Hello' };
+
+ const view = render(
+ loading}>
+
+
+ );
+
+ // The fragment isn't fulfilled yet, so the boundary shows its fallback.
+ expect(view.container.textContent).toBe('loading');
+
+ // The deferred patch arrives: the parent re-renders with the merged data.
+ view.rerender(
+ loading}>
+
+
+ );
+ // Flush the resolution of the suspense promise inside act(...).
+ await act(async () => {});
+
+ expect(view.container.textContent).toBe('Hello');
+ });
+
+ it('does not share suspense promises between unidentified objects', () => {
+ const client = useClient() as any;
+ const first = { title: undefined };
+ const second = { title: undefined };
+ const request = createRequest(SongFields, {});
+ const fragment = request.query.definitions[0] as any;
+
+ render(
+ loading}>
+
+
+ );
+ const firstPromise = getFragmentPromise(client, fragment, first);
+
+ render(
+ loading}>
+
+
+ );
+ const secondPromise = getFragmentPromise(client, fragment, second);
+
+ expect(firstPromise).toBeInstanceOf(Promise);
+ expect(secondPromise).toBeInstanceOf(Promise);
+ expect(secondPromise).not.toBe(firstPromise);
+ });
+
+ it('scopes suspense promises by fragment name', () => {
+ const client = useClient() as any;
+ const query = `
+ fragment SongTitle on Song { title }
+ fragment SongArtist on Song { artist }
+ `;
+ const data = {
+ __typename: 'Song',
+ id: '1',
+ title: undefined,
+ artist: undefined,
+ };
+ const request = createRequest(query, {});
+ const fragments = request.query.definitions;
+ const fragmentByName = (name: string) =>
+ fragments.find(
+ fragment => 'name' in fragment && fragment.name?.value === name
+ ) as any;
+ const Fragment = ({ name }: { name: string }) => {
+ useFragment({
+ fragment: query,
+ name,
+ data,
+ context: { suspense: true },
+ });
+ return null;
+ };
+
+ render(
+ loading}>
+
+
+ );
+ const titlePromise = getFragmentPromise(
+ client,
+ fragmentByName('SongTitle'),
+ data
+ );
+
+ render(
+ loading}>
+
+
+ );
+ const artistPromise = getFragmentPromise(
+ client,
+ fragmentByName('SongArtist'),
+ data
+ );
+
+ expect(titlePromise).toBeInstanceOf(Promise);
+ expect(artistPromise).toBeInstanceOf(Promise);
+ expect(artistPromise).not.toBe(titlePromise);
+ });
+
+ it('does not suspend when the data is already complete', () => {
+ const complete = { __typename: 'Song', id: '2', title: 'World' };
+
+ const view = render(
+ loading}>
+
+
+ );
+
+ expect(view.container.textContent).toBe('World');
+ });
+
+ it('does not re-suspend for an entity it has already committed', async () => {
+ const view = render(
+ loading}>
+
+
+ );
+ await act(async () => {});
+ expect(view.container.textContent).toBe('Hello');
+
+ // A refetch streams again and the deferred field is missing once more;
+ // the boundary must not fall back but keep showing the committed data.
+ view.rerender(
+ loading}>
+
+
+ );
+ await act(async () => {});
+ expect(view.container.textContent).toBe('Hello');
+
+ view.rerender(
+ loading}>
+
+
+ );
+ await act(async () => {});
+ expect(view.container.textContent).toBe('World');
+ });
+
+ it('suspends again when moving to a different entity', async () => {
+ const view = render(
+ loading}>
+
+
+ );
+ await act(async () => {});
+ expect(view.container.textContent).toBe('Hello');
+
+ view.rerender(
+ loading}>
+
+
+ );
+ // React keeps the previous children hidden next to the fallback while an
+ // already-revealed boundary re-suspends during an update.
+ expect(view.container.textContent).toContain('loading');
+ });
+
+ it('applies a refetch patch to a committed fragment without re-suspending', async () => {
+ const { client, results } = makeStreamedClient();
+ setClient(client);
+
+ const query = `
+ query {
+ song {
+ id
+ __typename
+ ...SongFields @defer
+ }
+ }
+
+ fragment SongFields on Song {
+ id
+ title
+ __typename
+ }
+ `;
+
+ const operation = client.createRequestOperation(
+ 'query',
+ createRequest(query, undefined)
+ );
+
+ pipe(
+ client.executeRequestOperation(operation),
+ subscribe(() => {
+ /*noop*/
+ })
+ );
+
+ // The first stream has completed; the fragment commits its data.
+ const complete = {
+ song: { __typename: 'Song', id: '1', title: 'Hello' },
+ };
+ results.next({ operation, data: complete, hasNext: false, stale: false });
+
+ const view = render(
+ loading}>
+
+
+ );
+ await act(async () => {});
+ expect(view.container.textContent).toBe('Hello');
+
+ // A refetch streams again: its initial payload misses the deferred
+ // field, but the committed boundary keeps rendering the previous data.
+ const partial = { song: { __typename: 'Song', id: '1' } };
+ act(() => {
+ results.next({ operation, data: partial, hasNext: true, stale: false });
+ });
+ view.rerender(
+ loading}>
+
+
+ );
+ await act(async () => {});
+ expect(view.container.textContent).toBe('Hello');
+
+ // The deferred patch resolves through the fragment source, updating the
+ // committed boundary in place without a parent rerender.
+ act(() => {
+ results.next({
+ operation,
+ data: { song: { __typename: 'Song', id: '1', title: 'World' } },
+ hasNext: false,
+ stale: false,
+ });
+ });
+ await act(async () => {});
+ expect(view.container.textContent).toBe('World');
+ });
+
+ it('suspends siblings independently by their entity identity', async () => {
+ const view = render(
+ loading}>
+
+
+
+ );
+
+ // One sibling is still pending, so the shared boundary shows the fallback.
+ expect(view.container.textContent).toBe('loading');
+
+ view.rerender(
+ loading}>
+
+
+
+ );
+ await act(async () => {});
+
+ expect(view.container.textContent).toBe('AB');
+ });
+
+ it('suspends siblings without entity IDs independently', async () => {
+ const Title = ({ data }: { data: any }) => {
+ const result = useFragment({
+ fragment: `fragment TitleFields on Song { title __typename }`,
+ data,
+ context: { suspense: true },
+ });
+ return
;
+ };
+
+ const view = render();
+ expect(view.container.textContent).toBe('fetching');
+
+ // The deferred patch resolves the sidecar promise; the hook updates
+ // through the fragment source without any parent rerender.
+ act(() => {
+ results.next({
+ operation,
+ data: { song: { __typename: 'Song', id: '1', title: 'Hello' } },
+ hasNext: false,
+ stale: false,
+ });
+ });
+ await act(async () => {});
+
+ expect(view.container.textContent).toBe('Hello');
+ });
+});
diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts
new file mode 100644
index 0000000000..a92b092257
--- /dev/null
+++ b/packages/react-urql/src/hooks/useFragment.ts
@@ -0,0 +1,300 @@
+/* eslint-disable react-hooks/exhaustive-deps */
+
+import * as React from 'react';
+import { pipe, subscribe } from 'wonka';
+import type { FragmentDefinitionNode } from '@0no-co/graphql.web';
+import { Kind } 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';
+
+import { hasDepsChanged } from './state';
+
+/** 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.
+ *
+ * @example
+ * ```ts
+ * const result = useFragment({
+ * fragment,
+ * data,
+ * context: { suspense: true },
+ * });
+ * ```
+ */
+ 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 fragment your {@link useFragment} hook is reading.
+ *
+ * @remarks
+ * `UseFragmentState` is returned by {@link useFragment} and
+ * gives you the masked data for the fragment.
+ */
+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;
+
+/** 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';
+ *
+ * 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 fragment = React.useMemo(() => {
+ return request.query.definitions.find(
+ x =>
+ x.kind === Kind.FRAGMENT_DEFINITION &&
+ ((args.name && x.name.value === args.name) || !args.name)
+ ) as FragmentDefinitionNode | undefined;
+ }, [request.query, args.name]);
+
+ if (!fragment) {
+ throw new Error(
+ `Passed document did not contain a fragment definition${
+ args.name ? ` for "${args.name}"` : ''
+ }.`
+ );
+ }
+
+ const fragments = React.useMemo(
+ () => getFragments(request.query.definitions),
+ [request.query]
+ );
+
+ // 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 = React.useRef | null>(null);
+
+ const getSnapshot = React.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 React 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] = React.useState(
+ () => [getSnapshot(args.data, suspense), deps] as const
+ );
+
+ const currentResult = state[0];
+ if (hasDepsChanged(state[1], deps)) {
+ setState([getSnapshot(args.data, suspense), deps]);
+ }
+
+ React.useEffect(() => {
+ if (!currentResult.fetching && args.data != null) {
+ committedRef.current = {
+ fragment,
+ key: getEntityKey(args.data),
+ data: currentResult.data || null,
+ };
+ }
+ });
+
+ React.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..9a47bc485f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -400,6 +400,9 @@ importers:
packages/react-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