Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/curvy-melons-suspend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'urql': minor
---

Add a BETA `useFragment` hook to the React bindings. Given a fragment document and
a piece of `data`, it returns the data masked to that fragment. When the `Client` (or
the hook's `context`) has `suspense` enabled, it suspends while deferred fragment data
is still streaming in, and otherwise reports the in-progress state via its `fetching`
flag.

React `useQuery` now associates stable sidecar promises with missing fields inside
`@defer` selections and resolves them directly from the query stream as later results arrive.
This lets deferred fragments wake Suspense boundaries during server streams without
depending on a parent component rerender.
17 changes: 17 additions & 0 deletions .changeset/generalise-deferred-fragments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@urql/core': minor
'@urql/preact': minor
---

Generalise the deferred-fragment and fragment-masking logic behind React's BETA `useFragment`
hook into `@urql/core`, so other framework bindings can reuse it. `@urql/core` now exports — as
BETA — `maskFragment`, which masks a piece of `data` against a fragment's selection set and
reports whether it's fulfilled or still streaming in, alongside the deferred-fragment helpers
`updateDeferredResult`, `makeDeferredState`, `resolveDeferredState`, `isDeferredPromise`, and
`getDeferredCacheForClient`. Together these associate stable sidecar promises with missing fields
inside `@defer` selections and resolve them directly from a query stream without changing result data.

Add a BETA `useFragment` hook to the Preact bindings, mirroring the React hook. Given a fragment
document and a piece of `data`, it returns the data masked to that fragment. When the `Client`
(or the hook's `context`) has `suspense` enabled, it suspends while deferred fragment data is
still streaming in, and otherwise reports the in-progress state via its `fetching` flag.
90 changes: 76 additions & 14 deletions docs/basics/typescript-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -202,26 +202,88 @@ export const FilmFragment = graphql(/* GraphQL */ `
}
`);

const Film = (props: {
// `film` property has the correct type 🎉
film: FragmentType<typeof FilmFragment>;
}) => {
// `film` is of type `FilmFragment`, with no extraneous properties ⚡️
const film = useFragment(FilmFragment, props.film);
return (
const Film = (props: { film: FragmentType<typeof FilmFragment> }) => {
const { data: film } = useFragment({
fragment: FilmFragment,
data: props.film,
});

return film ? (
<div>
<h3>{film.title}</h3>
<p>{film.releaseDate}</p>
</div>
);
) : 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<FilmsQuery['film']> }) => {
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<typeof FilmFragment> }) => {
const { data: film } = useFragment({
fragment: FilmFragment,
data: props.film,
});
return film ? <h3>{film.title}</h3> : null;
};
```

You will notice that our `<Film>` component leverages 2 imports from our generated code (from `../src/gql`): the `FragmentType<T>` 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<typeof FilmFragment>` 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)._
19 changes: 19 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,22 @@ export {
makeOperation,
getOperationName,
} from './utils';

export {
maskFragment,
getFragments,
makeDeferredState,
resolveDeferredState,
isDeferredPromise,
updateDeferredResult,
makeCache,
getDeferredCacheForClient,
} from './utils';

export type {
FragmentMap,
MaskFragmentResult,
DeferredState,
DeferredPromise,
Cache,
} from './utils';
111 changes: 111 additions & 0 deletions packages/core/src/utils/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { pipe, subscribe } from 'wonka';
import type { Client } from '../client';
import type { DeferredState } from './defer';
import { resolveDeferredState } from './defer';

/** A small per-operation cache attached to a {@link Client}, keyed by an
* operation/request `key`. (BETA)
*
* @remarks
* Entries can be `dispose`d, which marks them for reclamation once the matching
* operation is torn down (when the cache is bound to a `Client`’s
* `operations$`), rather than being removed immediately.
*
* @beta
*/
export interface Cache<Entry> {
get(key: number): Entry | undefined;
set(key: number, value: Entry): void;
clear(key: number): void;
dispose(key: number): void;
}

/** Creates a {@link Cache} that optionally reclaims entries on operation teardown. (BETA)
*
* @param client - the {@link Client} whose `operations$` drives teardown-based reclamation.
* @param onClear - an optional callback invoked with an entry when it’s cleared.
* @param deferDispose - when `true`, `dispose` marks for reclamation even without a `client`.
*
* @remarks
* When a `client` is passed, this subscribes to its `operations$` stream — and
* only then; importing this module has no side effects. Disposed entries are
* removed when their operation’s `teardown` is observed; otherwise `dispose`
* clears immediately.
*
* @beta
*/
export const makeCache = <Entry>(
client?: Client,
onClear?: (value: Entry) => void,
deferDispose?: boolean
): Cache<Entry> => {
const operations$ = client && (client as Partial<Client>).operations$;
const reclaim = new Set<number>();
const map = new Map<number, Entry>();

const clear = (key: number) => {
const value = map.get(key);
if (value !== undefined && onClear) onClear(value);
reclaim.delete(key);
map.delete(key);
};

if (operations$ /* not available in mocks */) {
pipe(
operations$,
subscribe(operation => {
if (operation.kind === 'teardown' && reclaim.has(operation.key)) {
clear(operation.key);
}
})
);
}

return {
get(key) {
return map.get(key);
},
set(key, value) {
reclaim.delete(key);
map.set(key, value);
},
clear,
dispose(key) {
if (operations$ || deferDispose) {
reclaim.add(key);
} else {
clear(key);
}
},
};
};

type DeferredCacheEntry = DeferredState | undefined;

interface ClientWithDeferredCache extends Client {
_deferred?: Cache<DeferredCacheEntry>;
}

/** Returns the per-{@link Client} cache of {@link DeferredState}, creating it lazily. (BETA)
*
* @remarks
* Bindings store one {@link DeferredState} per operation here (keyed by
* `request.key`) so that the {@link DeferredPromise}s associated by
* {@link updateDeferredResult} are shared between the query stream and any
* consumer suspending on a `@defer`-red boundary. Entries are reclaimed on
* teardown, resolving any still-pending promises so no boundary stays suspended.
*
* @beta
*/
export const getDeferredCacheForClient = (
client: Client
): Cache<DeferredCacheEntry> => {
if (!(client as ClientWithDeferredCache)._deferred) {
(client as ClientWithDeferredCache)._deferred =
makeCache<DeferredCacheEntry>(client, state => {
if (state) resolveDeferredState(state);
});
}

return (client as ClientWithDeferredCache)._deferred!;
};
Loading
Loading