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 +>; + interface ClientWithCache extends Client { + _fragments?: FragmentCache; _react?: Cache; } @@ -51,3 +66,52 @@ export const getCacheForClient = (client: Client): Cache => { return (client as ClientWithCache)._react!; }; + +/** 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/react-urql/src/hooks/index.ts b/packages/react-urql/src/hooks/index.ts index 58b57faae0..65b9a2ba7e 100644 --- a/packages/react-urql/src/hooks/index.ts +++ b/packages/react-urql/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useFragment'; export * from './useMutation'; export * from './useQuery'; export * from './useSubscription'; diff --git a/packages/react-urql/src/hooks/useFragment.ssr.test.tsx b/packages/react-urql/src/hooks/useFragment.ssr.test.tsx new file mode 100644 index 0000000000..3a8eb35d0d --- /dev/null +++ b/packages/react-urql/src/hooks/useFragment.ssr.test.tsx @@ -0,0 +1,289 @@ +// @vitest-environment node + +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 * as React from 'react'; +import { Suspense } from 'react'; +import { renderToPipeableStream } from 'react-dom/server'; +import { Writable } from 'stream'; +import { filter, makeSubject, merge, pipe as wonkaPipe } from 'wonka'; +import { createClient, createRequest } from '@urql/core'; +import type { Exchange } from '@urql/core'; + +import { useQuery } from './useQuery'; +import { useFragment } from './useFragment'; +import * as context from '../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 these tests + * stream results through a real client rather than mocking `executeQuery`. + */ +const makeStreamedClient = () => { + const results = makeSubject(); + const exchange: Exchange = () => ops$ => + merge([ + wonkaPipe( + 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 sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +/** Render `element` to a string via React's streaming server renderer. + * + * @remarks + * `drive` is invoked after rendering starts so the test can push results into + * the mocked query stream. The promise resolves with the fully streamed HTML, + * i.e. after every Suspense boundary has resolved. + */ +const renderToString = ( + element: React.ReactElement, + drive: () => void | Promise +): Promise => + new Promise((resolve, reject) => { + let html = ''; + const writable = new Writable({ + write(chunk, _encoding, callback) { + html += chunk.toString(); + callback(); + }, + }); + writable.on('finish', () => resolve(html)); + + const { pipe } = renderToPipeableStream(element, { + onShellReady() { + pipe(writable); + }, + onError(error) { + reject(error); + }, + }); + + Promise.resolve().then(drive).catch(reject); + }); + +beforeEach(() => { + setClient({}); +}); + +describe('useFragment SSR streaming', () => { + // This is the layout that the client-only mechanism couldn't satisfy: the + // Suspense boundary sits *below* `useQuery` and `useFragment` lives in a + // child. On the server `Page` never re-renders, so the deferred fragment can + // only resolve by reading the value off the stream-owned promise directly. + it('streams a deferred fragment whose boundary is below useQuery', async () => { + const { client, results } = makeStreamedClient(); + setClient(client); + + const query = ` + query { + song { + id + __typename + ...SongFields @defer + } + } + + fragment SongFields on Song { + title + } + `; + + // Results are matched to the hook's operation by their request key. + const operation = client.createRequestOperation( + 'query', + createRequest(query, undefined) + ); + + const Deferred = ({ data }: { data: any }) => { + const fragment = useFragment({ + fragment: `fragment SongFields on Song { title }`, + data, + context: { suspense: true }, + }); + return

{fragment.data.title}

; + }; + + const Page = () => { + const [result] = useQuery({ + query, + context: { suspense: true }, + }); + + return ( +
+ {result.data.song.__typename} + loading

}> + +
+
+ ); + }; + + 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

{result.data.title}

; + }; + const first: { __typename: string; title: string | undefined } = { + __typename: 'Song', + title: undefined, + }; + const second: { __typename: string; title: string | undefined } = { + __typename: 'Song', + title: undefined, + }; + + const view = render( + <> + loading-first

}> + + </Suspense> + <Suspense fallback={<p>loading-second</p>}> + <Title data={second} /> + </Suspense> + </> + ); + expect(view.container.textContent).toBe('loading-firstloading-second'); + + first.title = 'First'; + view.rerender( + <> + <Suspense fallback={<p>loading-first</p>}> + <Title data={first} /> + </Suspense> + <Suspense fallback={<p>loading-second</p>}> + <Title data={second} /> + </Suspense> + </> + ); + await act(async () => {}); + expect(view.container.textContent).toBe('Firstloading-second'); + }); + + it('keeps named fragments in one document independent', async () => { + const document = ` + fragment TitleFields on Song { title __typename } + fragment ArtistFields on Song { artist __typename } + `; + const data = { + __typename: 'Song', + title: undefined as string | undefined, + artist: undefined as string | undefined, + }; + const Field = ({ name }: { name: string }) => { + const result = useFragment<any>({ + fragment: document, + name, + data, + context: { suspense: true }, + }); + return <p>{result.data.title || result.data.artist}</p>; + }; + + const view = render( + <> + <Suspense fallback={<p>loading-title</p>}> + <Field name="TitleFields" /> + </Suspense> + <Suspense fallback={<p>loading-artist</p>}> + <Field name="ArtistFields" /> + </Suspense> + </> + ); + expect(view.container.textContent).toBe('loading-titleloading-artist'); + + data.title = 'Hello'; + view.rerender( + <> + <Suspense fallback={<p>loading-title</p>}> + <Field name="TitleFields" /> + </Suspense> + <Suspense fallback={<p>loading-artist</p>}> + <Field name="ArtistFields" /> + </Suspense> + </> + ); + await act(async () => {}); + expect(view.container.textContent).toBe('Helloloading-artist'); + }); + + it('resolves deferred fragment suspense from the query stream without a parent rerender', async () => { + const { client, results } = makeStreamedClient(); + setClient(client); + + const query = ` + query { + song { + id + __typename + ...SongFields @defer + } + } + + fragment SongFields on Song { + title + } + `; + + // Results are matched to the hook's operation by their request key. + const operation = client.createRequestOperation( + 'query', + createRequest(query, undefined) + ); + + const SongFromQuery = () => { + const [queryResult] = useQuery<any>({ query }); + + const fragment = useFragment<any>({ + fragment: `fragment SongFields on Song { title }`, + data: queryResult.data.song, + }); + + return <p>{fragment.data.title}</p>; + }; + + const view = render( + <Suspense fallback={<p>loading</p>}> + <SongFromQuery /> + </Suspense> + ); + + expect(view.container.textContent).toBe('loading'); + + act(() => { + results.next({ + operation, + data: { song: { __typename: 'Song', id: '1' } }, + hasNext: true, + stale: false, + }); + }); + await act(async () => {}); + + expect(view.container.textContent).toBe('loading'); + + 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'); + }); + + it('updates a non-suspense useFragment when a deferred patch arrives', async () => { + const { client, results } = makeStreamedClient(); + (client as any).suspense = false; + setClient(client); + + const query = ` + query { + song { + id + __typename + ...SongFields @defer + } + } + + fragment SongFields on Song { + title + } + `; + + const operation = client.createRequestOperation( + 'query', + createRequest(query, undefined) + ); + + // Drive the query stream directly; the client's result pipeline installs + // the sidecar promises for the missing deferred fields. + pipe( + client.executeRequestOperation(operation), + subscribe(() => { + /*noop*/ + }) + ); + + const first = { song: { __typename: 'Song', id: '1' } }; + results.next({ operation, data: first, hasNext: true, stale: false }); + + const Song = ({ data }: { data: any }) => { + const fragment = useFragment<any>({ + fragment: `fragment SongFields on Song { title }`, + data, + }); + return <p>{fragment.fetching ? 'fetching' : fragment.data.title}</p>; + }; + + const view = render(<Song data={first.song} />); + 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<Data = any, Input = Data> = { + /** 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<OperationContext>; + /** 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<Data, any>['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<Data> { + /** 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<OperationContext>) => + context && context.suspense !== undefined + ? !!context.suspense + : client.suspense; + +/** State a hook instance has last committed, used to limit suspensions. + * + * @internal + */ +interface CommittedFragment<Data> { + 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<Data = any, Input = Data>( + args: UseFragmentArgs<Data, Input> +): UseFragmentState<Data> { + 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<CommittedFragment<Data> | null>(null); + + const getSnapshot = React.useCallback( + ( + data: Input | null | undefined, + suspense: boolean + ): UseFragmentState<Data> => { + 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>( + data as Data, + fragment.selectionSet, + fragments + ); + + return { data: newResult.data, fetching: !newResult.fulfilled }; + } + + const cached = getFragmentPromise(client, fragment, data); + const newResult = maskFragment<Data>( + 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<Data, Input>({ + 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