From e5bcec3e04768ba3c0e22f413af56ac2c886cf02 Mon Sep 17 00:00:00 2001
From: Jovi De Croock
Date: Sat, 22 Aug 2026 09:00:54 +0200
Subject: [PATCH 1/2] Add React deferred fragment suspense
---
.changeset/react-deferred-fragments.md | 5 +
docs/basics/typescript-integration.md | 90 +-
packages/react-urql/package.json | 1 +
packages/react-urql/src/hooks/cache.ts | 64 ++
packages/react-urql/src/hooks/index.ts | 1 +
.../src/hooks/useFragment.ssr.test.tsx | 287 ++++++
.../react-urql/src/hooks/useFragment.test.tsx | 941 ++++++++++++++++++
packages/react-urql/src/hooks/useFragment.ts | 300 ++++++
pnpm-lock.yaml | 3 +
9 files changed, 1678 insertions(+), 14 deletions(-)
create mode 100644 .changeset/react-deferred-fragments.md
create mode 100644 packages/react-urql/src/hooks/useFragment.ssr.test.tsx
create mode 100644 packages/react-urql/src/hooks/useFragment.test.tsx
create mode 100644 packages/react-urql/src/hooks/useFragment.ts
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..235fedd533
--- /dev/null
+++ b/packages/react-urql/src/hooks/useFragment.test.tsx
@@ -0,0 +1,941 @@
+// @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