Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): useFragment hook (BETA) #3570

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from 11 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
1 change: 1 addition & 0 deletions examples/with-defer-stream-directives/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const cache = cacheExchange({
});

const client = new Client({
suspense: true,
url: 'http://localhost:3004/graphql',
exchanges: [cache, fetchExchange],
});
Expand Down
21 changes: 13 additions & 8 deletions examples/with-defer-stream-directives/src/Songs.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { gql, useQuery } from 'urql';
import React, { Suspense } from 'react';
import { gql, useQuery, useFragment } from 'urql';

const SecondVerseFragment = gql`
fragment secondVerseFields on Song {
Expand All @@ -13,9 +13,6 @@ const SONGS_QUERY = gql`
firstVerse
...secondVerseFields @defer
}
alphabet @stream(initialCount: 3) {
char
}
}

${SecondVerseFragment}
Expand All @@ -25,11 +22,22 @@ const Song = React.memo(function Song({ song }) {
return (
<section>
<p>{song.firstVerse}</p>
<Suspense fallback={'Loading song 2...'}>
<DeferredSong data={song} />
</Suspense>
<p>{song.secondVerse}</p>
</section>
);
});

const DeferredSong = ({ data }) => {
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
const result = useFragment({
query: SecondVerseFragment,
data,
});
return <p>{result.secondVerse}</p>;
};

const LocationsList = () => {
const [result] = useQuery({
query: SONGS_QUERY,
Expand All @@ -42,9 +50,6 @@ const LocationsList = () => {
{data && (
<>
<Song song={data.song} />
{data.alphabet.map(i => (
<div key={i.char}>{i.char}</div>
))}
</>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/react-urql/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './useMutation';
export * from './useQuery';
export * from './useFragment';
export * from './useSubscription';
332 changes: 332 additions & 0 deletions packages/react-urql/src/hooks/useFragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
/* eslint-disable react-hooks/exhaustive-deps */

import * as React from 'react';
import type {
FragmentDefinitionNode,
InlineFragmentNode,
SelectionSetNode,
} from '@0no-co/graphql.web';
import { Kind } from '@0no-co/graphql.web';

import type {
GraphQLRequestParams,
AnyVariables,
Client,
OperationContext,
OperationResult,
GraphQLRequest,
} from '@urql/core';

import { useClient } from '../context';
import { useRequest } from './useRequest';
import { getCacheForClient } from './cache';

import { hasDepsChanged } from './state';

/** Input arguments for the {@link useFragment} hook. */
export type UseFragmentArgs<Data = any> = {
/** Updates the {@link OperationContext} for the executed GraphQL query operation.
*
* @remarks
* `context` may be passed to {@link useFragment}, to update the {@link OperationContext}
* of a query operation. This may be used to update the `context` that exchanges
* will receive for a single hook.
*
* Hint: This should be wrapped in a `useMemo` hook, to make sure that your
* component doesn’t infinitely update.
*
* @example
* ```ts
* const result = useFragment({
* query,
* data,
* context: useMemo(() => ({
* 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.
*/
query: GraphQLRequestParams<Data, AnyVariables>['query'];
/** A JSON object which we will extract properties from to get to the
* masked fragment.
*/
data: Data;
/** An optional name of the fragment to use from the passed Document. */
name?: string;
};

/** State of the current query, your {@link useFragment} hook is executing.
*
* @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 passed a new query and/or variables, it will
* start executing the new query operation and `fetching` is set to
* `true` until a result arrives.
*/
fetching: boolean;
/** The {@link OperationResult.data} for the masked fragment. */
data?: Data;
}

const isSuspense = (client: Client, context?: Partial<OperationContext>) =>
context && context.suspense !== undefined
? !!context.suspense
: client.suspense;

/** 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
* `useFragments` allows GraphQL fragments to mask their data.
* Given {@link UseFragmentArgs.query} and {@link UseFragmentArgs.data}, it will
* return the masked data for the fragment contained in query.
*
* 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}.
*
* @example
* ```ts
* import { gql, useFragment } from 'urql';
*
* const TodoFields = gql`
* fragment TodoFields on Todo { id name }
* `;
*
* const Todo = (props) => {
* const result = useQuery({
* data: props.todo,
* query: TodoFields,
* variables: {},
* });
* // ...
* };
* ```
*/
export function useFragment<Data>(
args: UseFragmentArgs<Data>
): UseFragmentState<Data> {
const client = useClient();
const cache = getCacheForClient(client);
const suspense = isSuspense(client, args.context);
// We use args.variables here for the key to differentiate
// in cases where components in i.e. a list
const request = useRequest(args.query, args.data as any);

const getSnapshot = React.useCallback(
(
request: GraphQLRequest<Data, AnyVariables>,
data: Data,
suspense: boolean
): UseFragmentState<Data> => {
const cached = cache.get(request.key);
if (!cached) {
const fragment = request.query.definitions.find(
x =>
x.kind === Kind.FRAGMENT_DEFINITION &&
((args.name && x.name.value === args.name) || !args.name)
) as FragmentDefinitionNode | undefined;

if (!fragment) {
throw new Error(
'Passed document did not contain a fragment definition' + args.name
? ` for ${args.name}`
: ''
);
}

const fragments = request.query.definitions.reduce((acc, frag) => {
if (frag.kind === Kind.FRAGMENT_DEFINITION) {
acc[frag.name.value] = frag;
}
return acc;
}, {});

const newResult = maskFragment<Data>(
data,
fragment.selectionSet,
fragments
);

if (newResult.fulfilled) {
cache.set(request.key, newResult.data as any);
return { data: newResult.data as any, fetching: false };
} else if (suspense) {
const promise = new Promise(() => {});
Copy link
Collaborator Author

@JoviDeCroock JoviDeCroock Apr 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Being able to resolve this Promise might be important for streamed-SSR, I am not sure 😅 the issue here being that we can't keep a stable reference to this cache-entry as we face two challenges

  • This could be a series of useFragment, think of a list of todos. This means we'd be able to create a key for the document, however we'd then share this key across all siblings which could be streamed in out of order.
  • When we add data to the key this means that when the previous entry is completed due to the missing part streaming in that we update the key to a new state. This becomes problematic as we never actually resolve the promise, storing the previous reference in a ref isn't possible as React tears down the previous state.
  • Leveraging useId is also not possible as that isn't stable across Suspense invocations

One solution I can see is deriving a data that follows along the document and purposefully omits any include/skip and defer from the data and uses that to derive the key. This would essentially be similar to creating a __typename:id as they key, which could also be relevant here.

EDIT: suggestion implemented in ea665f3

cache.set(request.key, promise);
throw promise;
} else {
return { fetching: true, data: newResult.data };
}
} else if (suspense && cached != null && 'then' in cached) {
throw cached;
}

return { fetching: false, data: (cached as OperationResult).data };
},
[cache, request]
);

// TODO: either we use request here or args.query and args.data
const deps = [client, args.context, args.data, args.query] as const;

// In essence we could opt to not use state and always get snapshot
const [state, setState] = React.useState(
() => [getSnapshot(request, args.data, suspense), deps] as const
);

const currentResult = state[0];
if (hasDepsChanged(state[1], deps)) {
setState([getSnapshot(request, args.data, suspense), deps]);
}

return currentResult;
}

const maskFragment = <Data>(
data: Data,
selectionSet: SelectionSetNode,
fragments: Record<string, FragmentDefinitionNode>
): { data: Data; fulfilled: boolean } => {
const maskedData = {};
let isDataComplete = true;
selectionSet.selections.forEach(selection => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, this would need (named) fragment spread support in before a merge. It's simply too uncommon not to compose fragments, and that requires named fragments. There's probably something to be said though about that being optimised in the future

(Note: we talked about nested signals/reactive proxies, so that could be an approach to feed information on changes in above maskFragment and to hand info to the next nested maskFragment for a different document; not in scope for the PR anyway)

Question is what full-masking (like in this implementation) would also look like given heuristic/non-heuristic fragment matching.

Last note is that we could align this a bit more to the Graphcache implementation here so it vaguely matches. Just in the overall style, so we have a bit of parity in case we decide to share code. That's optional though and not necessarily actionable, unless this gives you an idea of course

Copy link
Collaborator Author

@JoviDeCroock JoviDeCroock Apr 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few things bothering me about doing that, we should def do FragmentSpread as alluded to in the todo, however what bothers me is that these spreads could be suspended themselves. I wanted to create the assumption that a FragmentSpread with @defer wouldn't be registered in useFragment but there would be an expectation that this is passed on to a new component.

For InlineFragment I have implemented it similar to Field and defer would trigger internal to the component.

In writing this I realised that we actually can't do include/skip as it stands because we don't have access to the variables the parent-operation was executed with, this could be a limitation for now but weakens the heuristic we use to see whether something is deferred significantly.

RE the graphCache point, I can add heuristic fragment matching to this if that's desired, for now this only works on concrete matches.

EDIT: added an initial heuristic for include/skip in 6c7eb36 and naive heuristic matching in 8ccfba1

if (selection.kind === Kind.FIELD) {
const fieldAlias = selection.alias
? selection.alias.value
: selection.name.value;

const hasIncludeOrSkip =
selection.directives &&
selection.directives.some(
x => x.name.value === 'include' || x.name.value === 'skip'
);

if (selection.selectionSet) {
if (data[fieldAlias] === undefined) {
if (hasIncludeOrSkip) return;
isDataComplete = false;
} else if (data[fieldAlias] === null) {
maskedData[fieldAlias] = null;
} else if (Array.isArray(data[fieldAlias])) {
if (selection.selectionSet) {
maskedData[fieldAlias] = data[fieldAlias].map(item => {
const result = maskFragment(
item,
selection.selectionSet as SelectionSetNode,
fragments
);

if (!result.fulfilled) {
isDataComplete = false;
}

return result.data;
});
} else {
maskedData[fieldAlias] = data[fieldAlias].map(item => item);
}
} else {
if (selection.selectionSet) {
const result = maskFragment(
data[fieldAlias],
selection.selectionSet,
fragments
);

if (!result.fulfilled) {
isDataComplete = false;
}

maskedData[fieldAlias] = result.data;
} else {
maskedData[fieldAlias] = data[fieldAlias];
}
}
}
maskedData[selection.name.value] = data[selection.name.value];
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
if (isHeuristicFragmentMatch(selection, data, fragments)) {
return;
}

const result = maskFragment(data, selection.selectionSet, fragments);
// TODO: how do we handle inline-fragments with a skip/include directive?
if (!result.fulfilled) {
isDataComplete = false;
}
Object.assign(maskedData, result.data);
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
const fragment = fragments[selection.name.value];

if (
selection.directives &&
selection.directives.find(x => x.name.value === 'defer')
) {
return;
}

if (!fragment || isHeuristicFragmentMatch(fragment, data, fragments)) {
return;
}

const result = maskFragment(data, fragment.selectionSet, fragments);
// TODO: how do we handle inline-fragments with a skip/include directive?
if (!result.fulfilled) {
isDataComplete = false;
}
Object.assign(maskedData, result.data);
}
});

return { data: maskedData as Data, fulfilled: isDataComplete };
};

const isHeuristicFragmentMatch = (
fragment: InlineFragmentNode | FragmentDefinitionNode,
data: any,
fragments: Record<string, FragmentDefinitionNode>
): boolean => {
if (
!fragment.typeCondition ||
fragment.typeCondition.name.value === data.__typename
)
return true;

return fragment.selectionSet.selections.every(selection => {
if (selection.kind === Kind.FIELD) {
const fieldAlias = selection.alias
? selection.alias.value
: selection.name.value;
const couldBeExcluded =
selection.directives &&
selection.directives.some(
x =>
x.name.value === 'include' ||
x.name.value === 'skip' ||
x.name.value === 'defer'
);
return Object.hasOwn(data, fieldAlias) && !couldBeExcluded;
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
return isHeuristicFragmentMatch(selection, data, fragments);
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
const fragment = fragments[selection.name.value];
return isHeuristicFragmentMatch(fragment, data, fragments);
}
});
};