From 6e91676146ccd6d0c6558253df782274daba51bf Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 22 Apr 2024 19:59:44 +0200 Subject: [PATCH 01/25] mvp --- packages/react-urql/src/hooks/useFragment.ts | 237 +++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 packages/react-urql/src/hooks/useFragment.ts diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts new file mode 100644 index 0000000000..1ce6bff2a2 --- /dev/null +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -0,0 +1,237 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + +import * as React from 'react'; + +import type { + GraphQLRequestParams, + AnyVariables, + Client, + DocumentInput, + OperationContext, + RequestPolicy, + OperationResult, + Operation, + GraphQLRequest, +} from '@urql/core'; + +import { useClient } from '../context'; +import { useRequest } from './useRequest'; +import { getCacheForClient } from './cache'; + +import { + deferDispatch, + initialState, + computeNextState, + hasDepsChanged, +} from './state'; +import { FragmentDefinitionNode, Kind, SelectionSetNode } from 'graphql'; + +/** Input arguments for the {@link useQuery} hook. + * + * @param query - The GraphQL query that `useQuery` executes. + */ +export type UseQueryArgs = { + context: Partial; + query: GraphQLRequestParams['query']; + data: Data; + name?: string; +}; + +/** State of the current query, your {@link useQuery} hook is executing. + * + * @remarks + * `UseQueryState` is returned (in a tuple) by {@link useQuery} and + * gives you the updating {@link OperationResult} of GraphQL queries. + * + * Even when the query and variables passed to {@link useQuery} change, + * this state preserves the prior state and sets the `fetching` flag to + * `true`. + * This allows you to display the previous state, while implementing + * a separate loading indicator separately. + */ +export interface UseFragmentState { + /** Indicates whether `useQuery` is waiting for a new result. + * + * @remarks + * When `useQuery` 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. + * + * Hint: This is subtly different than whether the query is actually + * fetching, and doesn’t indicate whether a query is being re-executed + * in the background. For this, see {@link UseQueryState.stale}. + */ + fetching: boolean; + /** The {@link OperationResult.data} for the executed query. */ + data?: Data; +} + +/** Result tuple returned by the {@link useQuery} hook. + * + * @remarks + * Similarly to a `useState` hook’s return value, + * the first element is the {@link useQuery}’s result and state, + * a {@link UseQueryState} object, + * and the second is used to imperatively re-execute the query + * via a {@link UseQueryExecute} function. + */ +export type UseQueryResponse = UseFragmentState; + +const isSuspense = (client: Client, context?: Partial) => + context && context.suspense !== undefined + ? !!context.suspense + : client.suspense; + +/** Hook to mask a GraphQL Fragment given its data. + * + * @param args - a {@link UseQueryArgs} object, to pass a `fragment` and `data`. + * @returns a {@link UseQueryResponse} tuple of a {@link UseQueryState} result, and re-execute function. + * + * @remarks + * `useQuery` allows GraphQL queries to be defined and executed. + * Given {@link UseQueryArgs.query}, it executes the GraphQL query with the + * context’s {@link Client}. + * + * The returned result updates when the `Client` has new results + * for the query, and changes when your input `args` change. + * + * Additionally, if the `suspense` option is enabled on the `Client`, + * the `useQuery` hook will suspend instead of indicating that it’s + * waiting for a result via {@link UseQueryState.fetching}. + * + * @see {@link https://urql.dev/goto/urql/docs/basics/react-preact/#queries} for `useQuery` docs. + * + * @example + * ```ts + * import { gql, useQuery } from 'urql'; + * + * const TodosQuery = gql` + * query { todos { id, title } } + * `; + * + * const Todos = () => { + * const [result, reexecuteQuery] = useQuery({ + * query: TodosQuery, + * variables: {}, + * }); + * // ... + * }; + * ``` + */ +export function useFragment( + args: UseQueryArgs +): UseQueryResponse { + const { query, data } = args; + const client = useClient(); + const cache = getCacheForClient(client); + const suspense = isSuspense(client, args.context); + const request = useRequest(query, {}); + + const getSnapshot = React.useCallback( + ( + request: GraphQLRequest, + data: Data, + suspense: boolean + ): Partial> => { + const cached = cache.get(request.key); + if (!cached) { + const fragment = request.query.definitions.find( + x => + x.kind === 'FragmentDefinition' && + ((args.name && x.name.value === args.name) || !args.name) + ); + const newResult = maskFragment( + data, + (fragment as FragmentDefinitionNode).selectionSet + ); + if (newResult == null && suspense) { + const promise = new Promise(() => {}); + 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 (cached as OperationResult) || { fetching: true }; + }, + [cache, request] + ); + + const deps = [client, request, args.context, data] as const; + + const [state, setState] = React.useState( + () => + [ + computeNextState(initialState, getSnapshot(request, data, suspense)), + deps, + ] as const + ); + + let currentResult = state[0]; + if (hasDepsChanged(state[1], deps)) { + setState([ + (currentResult = computeNextState( + state[0], + getSnapshot(request, data, suspense) + )), + deps, + ]); + } + + return currentResult; +} + +const maskFragment = ( + data: Record, + selectionSet: SelectionSetNode +): { data: Record; fulfilled: boolean } => { + const maskedData = {}; + let isDataComplete = true; + selectionSet.selections.forEach(selection => { + if (selection.kind === Kind.FIELD) { + const fieldAlias = selection.alias + ? selection.alias.value + : selection.name.value; + if (selection.selectionSet) { + if (data[fieldAlias] === undefined) { + isDataComplete = false; + } else if (data[fieldAlias] === null) { + maskedData[fieldAlias] = null; + } else if (Array.isArray(data[fieldAlias])) { + maskedData[fieldAlias] = data[fieldAlias].map(item => { + const result = maskFragment( + item, + selection.selectionSet as SelectionSetNode + ); + if (!result.fulfilled) { + isDataComplete = false; + } + return result.data; + }); + } else { + const result = maskFragment(data[fieldAlias], selection.selectionSet); + if (!result.fulfilled) { + isDataComplete = false; + } + maskedData[fieldAlias] = result.data; + } + } else { + if (data[fieldAlias] === undefined) { + isDataComplete = false; + } else if (data[fieldAlias] === null) { + maskedData[fieldAlias] = null; + } else if (Array.isArray(data[fieldAlias])) { + maskedData[fieldAlias] = data[fieldAlias].map(item => item); + } else { + maskedData[fieldAlias] = data[fieldAlias]; + } + } + maskedData[selection.name.value] = data[selection.name.value]; + } + }); + + return { data: maskedData, fulfilled: isDataComplete }; +}; From be0b26eae27b115614a445e78c1f8497c1d3b17b Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 22 Apr 2024 20:11:30 +0200 Subject: [PATCH 02/25] working mvp --- .../with-defer-stream-directives/src/App.jsx | 1 + .../src/Songs.jsx | 22 +++++++---- packages/react-urql/src/hooks/index.ts | 1 + packages/react-urql/src/hooks/useFragment.ts | 39 +++++++++---------- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/examples/with-defer-stream-directives/src/App.jsx b/examples/with-defer-stream-directives/src/App.jsx index 4e45e60579..3e29e59ded 100644 --- a/examples/with-defer-stream-directives/src/App.jsx +++ b/examples/with-defer-stream-directives/src/App.jsx @@ -13,6 +13,7 @@ const cache = cacheExchange({ }); const client = new Client({ + suspense: true, url: 'http://localhost:3004/graphql', exchanges: [cache, fetchExchange], }); diff --git a/examples/with-defer-stream-directives/src/Songs.jsx b/examples/with-defer-stream-directives/src/Songs.jsx index 9d4a76e755..880a6b2095 100644 --- a/examples/with-defer-stream-directives/src/Songs.jsx +++ b/examples/with-defer-stream-directives/src/Songs.jsx @@ -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 { @@ -13,9 +13,6 @@ const SONGS_QUERY = gql` firstVerse ...secondVerseFields @defer } - alphabet @stream(initialCount: 3) { - char - } } ${SecondVerseFragment} @@ -25,11 +22,23 @@ const Song = React.memo(function Song({ song }) { return (

{song.firstVerse}

+ + +

{song.secondVerse}

); }); +const DeferredSong = ({ data }) => { + console.log(data, SecondVerseFragment) + const result = useFragment({ + query: SecondVerseFragment, + data, + }); + return

{result.secondVerse}

; +}; + const LocationsList = () => { const [result] = useQuery({ query: SONGS_QUERY, @@ -42,9 +51,6 @@ const LocationsList = () => { {data && ( <> - {data.alphabet.map(i => ( -
{i.char}
- ))} )} diff --git a/packages/react-urql/src/hooks/index.ts b/packages/react-urql/src/hooks/index.ts index 58b57faae0..c8e2efd4c9 100644 --- a/packages/react-urql/src/hooks/index.ts +++ b/packages/react-urql/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useMutation'; export * from './useQuery'; +export * from './useFragment'; export * from './useSubscription'; diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index 1ce6bff2a2..15bd6145f2 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -1,16 +1,18 @@ /* eslint-disable react-hooks/exhaustive-deps */ import * as React from 'react'; +import type { + FragmentDefinitionNode, + SelectionSetNode, +} from '@0no-co/graphql.web'; +import { Kind } from '@0no-co/graphql.web'; import type { GraphQLRequestParams, AnyVariables, Client, - DocumentInput, OperationContext, - RequestPolicy, OperationResult, - Operation, GraphQLRequest, } from '@urql/core'; @@ -18,19 +20,13 @@ import { useClient } from '../context'; import { useRequest } from './useRequest'; import { getCacheForClient } from './cache'; -import { - deferDispatch, - initialState, - computeNextState, - hasDepsChanged, -} from './state'; -import { FragmentDefinitionNode, Kind, SelectionSetNode } from 'graphql'; +import { initialState, computeNextState, hasDepsChanged } from './state'; /** Input arguments for the {@link useQuery} hook. * * @param query - The GraphQL query that `useQuery` executes. */ -export type UseQueryArgs = { +export type UseFragmentArgs = { context: Partial; query: GraphQLRequestParams['query']; data: Data; @@ -75,7 +71,7 @@ export interface UseFragmentState { * and the second is used to imperatively re-execute the query * via a {@link UseQueryExecute} function. */ -export type UseQueryResponse = UseFragmentState; +export type UseFragmentResponse = UseFragmentState; const isSuspense = (client: Client, context?: Partial) => context && context.suspense !== undefined @@ -84,12 +80,12 @@ const isSuspense = (client: Client, context?: Partial) => /** Hook to mask a GraphQL Fragment given its data. * - * @param args - a {@link UseQueryArgs} object, to pass a `fragment` and `data`. - * @returns a {@link UseQueryResponse} tuple of a {@link UseQueryState} result, and re-execute function. + * @param args - a {@link UseFragmentArgs} object, to pass a `fragment` and `data`. + * @returns a {@link UseFragmentResponse} tuple of a {@link UseQueryState} result, and re-execute function. * * @remarks * `useQuery` allows GraphQL queries to be defined and executed. - * Given {@link UseQueryArgs.query}, it executes the GraphQL query with the + * Given {@link UseFragmentArgs.query}, it executes the GraphQL query with the * context’s {@link Client}. * * The returned result updates when the `Client` has new results @@ -119,8 +115,8 @@ const isSuspense = (client: Client, context?: Partial) => * ``` */ export function useFragment( - args: UseQueryArgs -): UseQueryResponse { + args: UseFragmentArgs +): UseFragmentResponse { const { query, data } = args; const client = useClient(); const cache = getCacheForClient(client); @@ -141,15 +137,18 @@ export function useFragment( ((args.name && x.name.value === args.name) || !args.name) ); const newResult = maskFragment( - data, + data as any, (fragment as FragmentDefinitionNode).selectionSet ); - if (newResult == null && suspense) { + 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(() => {}); cache.set(request.key, promise); throw promise; } else { - return { fetching: true, data: newResult.data }; + return { fetching: true, data: newResult.data as any }; } } else if (suspense && cached != null && 'then' in cached) { throw cached; From 65ae40e72cc7d592a82b9bc0899d39b50cff940c Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 22 Apr 2024 20:47:53 +0200 Subject: [PATCH 03/25] improvements --- packages/react-urql/src/hooks/useFragment.ts | 46 ++++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index 15bd6145f2..75954d37a9 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -114,14 +114,13 @@ const isSuspense = (client: Client, context?: Partial) => * }; * ``` */ -export function useFragment( - args: UseFragmentArgs -): UseFragmentResponse { - const { query, data } = args; +export function useFragment< + Data extends Record = Record, +>(args: UseFragmentArgs): UseFragmentResponse { const client = useClient(); const cache = getCacheForClient(client); const suspense = isSuspense(client, args.context); - const request = useRequest(query, {}); + const request = useRequest(args.query, args.data); const getSnapshot = React.useCallback( ( @@ -135,11 +134,17 @@ export function useFragment( x => x.kind === 'FragmentDefinition' && ((args.name && x.name.value === args.name) || !args.name) - ); - const newResult = maskFragment( - data as any, - (fragment as FragmentDefinitionNode).selectionSet - ); + ) as FragmentDefinitionNode | undefined; + + if (!fragment) { + throw new Error( + 'Passed document did not contain a fragment definition' + args.name + ? ` for ${args.name}` + : '' + ); + } + + const newResult = maskFragment(data, fragment.selectionSet); if (newResult.fulfilled) { cache.set(request.key, newResult.data as any); return { data: newResult.data as any, fetching: false }; @@ -148,23 +153,26 @@ export function useFragment( cache.set(request.key, promise); throw promise; } else { - return { fetching: true, data: newResult.data as any }; + return { fetching: true, data: newResult.data }; } } else if (suspense && cached != null && 'then' in cached) { throw cached; } - return (cached as OperationResult) || { fetching: true }; + return { fetching: false, data: (cached as OperationResult).data }; }, [cache, request] ); - const deps = [client, request, args.context, data] as const; + const deps = [client, request, args.context, args.data] as const; const [state, setState] = React.useState( () => [ - computeNextState(initialState, getSnapshot(request, data, suspense)), + computeNextState( + initialState, + getSnapshot(request, args.data, suspense) + ), deps, ] as const ); @@ -174,7 +182,7 @@ export function useFragment( setState([ (currentResult = computeNextState( state[0], - getSnapshot(request, data, suspense) + getSnapshot(request, args.data, suspense) )), deps, ]); @@ -183,10 +191,10 @@ export function useFragment( return currentResult; } -const maskFragment = ( - data: Record, +const maskFragment = >( + data: Data, selectionSet: SelectionSetNode -): { data: Record; fulfilled: boolean } => { +): { data: Data; fulfilled: boolean } => { const maskedData = {}; let isDataComplete = true; selectionSet.selections.forEach(selection => { @@ -232,5 +240,5 @@ const maskFragment = ( } }); - return { data: maskedData, fulfilled: isDataComplete }; + return { data: maskedData as Data, fulfilled: isDataComplete }; }; From 0c722d9747a04dcb3097553277407920d6ff24d9 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 22 Apr 2024 20:56:10 +0200 Subject: [PATCH 04/25] tsdoc --- packages/react-urql/src/hooks/useFragment.ts | 101 ++++++++++--------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index 75954d37a9..b9ae744788 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -25,54 +25,67 @@ import { initialState, computeNextState, hasDepsChanged } from './state'; /** Input arguments for the {@link useQuery} hook. * * @param query - The GraphQL query that `useQuery` executes. + * @param query - The GraphQL query that `useQuery` executes. + * @param query - The GraphQL query that `useQuery` executes. + * @param query - The GraphQL query that `useQuery` executes. */ export type UseFragmentArgs = { + /** 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; + /** 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['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. */ name?: string; }; -/** State of the current query, your {@link useQuery} hook is executing. +/** State of the current query, your {@link useFragment} hook is executing. * * @remarks - * `UseQueryState` is returned (in a tuple) by {@link useQuery} and - * gives you the updating {@link OperationResult} of GraphQL queries. - * - * Even when the query and variables passed to {@link useQuery} change, - * this state preserves the prior state and sets the `fetching` flag to - * `true`. - * This allows you to display the previous state, while implementing - * a separate loading indicator separately. + * `UseFragmentState` is returned by {@link useFragment} and + * gives you the masked data for the fragment. */ export interface UseFragmentState { - /** Indicates whether `useQuery` is waiting for a new result. + /** Indicates whether `useFragment` is waiting for a new result. * * @remarks - * When `useQuery` is passed a new query and/or variables, it will + * 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. - * - * Hint: This is subtly different than whether the query is actually - * fetching, and doesn’t indicate whether a query is being re-executed - * in the background. For this, see {@link UseQueryState.stale}. */ fetching: boolean; - /** The {@link OperationResult.data} for the executed query. */ + /** The {@link OperationResult.data} for the masked fragment. */ data?: Data; } -/** Result tuple returned by the {@link useQuery} hook. - * - * @remarks - * Similarly to a `useState` hook’s return value, - * the first element is the {@link useQuery}’s result and state, - * a {@link UseQueryState} object, - * and the second is used to imperatively re-execute the query - * via a {@link UseQueryExecute} function. - */ -export type UseFragmentResponse = UseFragmentState; - const isSuspense = (client: Client, context?: Partial) => context && context.suspense !== undefined ? !!context.suspense @@ -81,33 +94,29 @@ const isSuspense = (client: Client, context?: Partial) => /** Hook to mask a GraphQL Fragment given its data. * * @param args - a {@link UseFragmentArgs} object, to pass a `fragment` and `data`. - * @returns a {@link UseFragmentResponse} tuple of a {@link UseQueryState} result, and re-execute function. + * @returns a {@link UseFragmentState} result. * * @remarks - * `useQuery` allows GraphQL queries to be defined and executed. - * Given {@link UseFragmentArgs.query}, it executes the GraphQL query with the - * context’s {@link Client}. - * - * The returned result updates when the `Client` has new results - * for the query, and changes when your input `args` change. + * `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 `useQuery` hook will suspend instead of indicating that it’s - * waiting for a result via {@link UseQueryState.fetching}. - * - * @see {@link https://urql.dev/goto/urql/docs/basics/react-preact/#queries} for `useQuery` docs. + * the `useFragment` hook will suspend instead of indicating that it’s + * waiting for a result via {@link UseFragmentState.fetching}. * * @example * ```ts - * import { gql, useQuery } from 'urql'; + * import { gql, useFragment } from 'urql'; * - * const TodosQuery = gql` - * query { todos { id, title } } + * const TodoFields = gql` + * fragment TodoFields on Todo { id name } * `; * - * const Todos = () => { - * const [result, reexecuteQuery] = useQuery({ - * query: TodosQuery, + * const Todo = (props) => { + * const result = useQuery({ + * data: props.todo, + * query: TodoFields, * variables: {}, * }); * // ... @@ -116,7 +125,7 @@ const isSuspense = (client: Client, context?: Partial) => */ export function useFragment< Data extends Record = Record, ->(args: UseFragmentArgs): UseFragmentResponse { +>(args: UseFragmentArgs): UseFragmentState { const client = useClient(); const cache = getCacheForClient(client); const suspense = isSuspense(client, args.context); @@ -132,7 +141,7 @@ export function useFragment< if (!cached) { const fragment = request.query.definitions.find( x => - x.kind === 'FragmentDefinition' && + x.kind === Kind.FRAGMENT_DEFINITION && ((args.name && x.name.value === args.name) || !args.name) ) as FragmentDefinitionNode | undefined; From 8731db642b632c83aebe07a4c86ab6b418d7493d Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 22 Apr 2024 20:57:12 +0200 Subject: [PATCH 05/25] correction --- packages/react-urql/src/hooks/useFragment.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index b9ae744788..b0471ba770 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -22,13 +22,7 @@ import { getCacheForClient } from './cache'; import { initialState, computeNextState, hasDepsChanged } from './state'; -/** Input arguments for the {@link useQuery} hook. - * - * @param query - The GraphQL query that `useQuery` executes. - * @param query - The GraphQL query that `useQuery` executes. - * @param query - The GraphQL query that `useQuery` executes. - * @param query - The GraphQL query that `useQuery` executes. - */ +/** Input arguments for the {@link useFragment} hook. */ export type UseFragmentArgs = { /** Updates the {@link OperationContext} for the executed GraphQL query operation. * @@ -91,7 +85,7 @@ const isSuspense = (client: Client, context?: Partial) => ? !!context.suspense : client.suspense; -/** Hook to mask a GraphQL Fragment given its 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. From a318dfdb871b98b9fcb6fa1f776bf3e1227cf781 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 22 Apr 2024 21:06:00 +0200 Subject: [PATCH 06/25] add inline fragment support --- packages/react-urql/src/hooks/useFragment.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index b0471ba770..76c94de1da 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -240,6 +240,21 @@ const maskFragment = >( } } maskedData[selection.name.value] = data[selection.name.value]; + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + if ( + selection.typeCondition && + selection.typeCondition.name.value !== data.__typename + ) { + return; + } + + const result = maskFragment(data, selection.selectionSet); + if (!result.fulfilled) { + isDataComplete = false; + } + Object.assign(maskedData, result.data); + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + // TODO: do we want to support this? } }); From d9ef0cdd7548025eb5c0c32267f60d7f528e0b75 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 22 Apr 2024 21:07:44 +0200 Subject: [PATCH 07/25] logging --- examples/with-defer-stream-directives/src/Songs.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/with-defer-stream-directives/src/Songs.jsx b/examples/with-defer-stream-directives/src/Songs.jsx index 880a6b2095..a61b02a2ed 100644 --- a/examples/with-defer-stream-directives/src/Songs.jsx +++ b/examples/with-defer-stream-directives/src/Songs.jsx @@ -31,7 +31,6 @@ const Song = React.memo(function Song({ song }) { }); const DeferredSong = ({ data }) => { - console.log(data, SecondVerseFragment) const result = useFragment({ query: SecondVerseFragment, data, From 6c7eb369c60e7155c19299eadf95f36114d4aee6 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Tue, 23 Apr 2024 08:21:27 +0200 Subject: [PATCH 08/25] add include/skip --- packages/react-urql/src/hooks/useFragment.ts | 63 ++++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index 76c94de1da..f340e83220 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -147,7 +147,17 @@ export function useFragment< ); } - const newResult = maskFragment(data, fragment.selectionSet); + 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, + fragment.selectionSet, + fragments + ); if (newResult.fulfilled) { cache.set(request.key, newResult.data as any); return { data: newResult.data as any, fetching: false }; @@ -196,7 +206,8 @@ export function useFragment< const maskFragment = >( data: Data, - selectionSet: SelectionSetNode + selectionSet: SelectionSetNode, + fragments: Record ): { data: Data; fulfilled: boolean } => { const maskedData = {}; let isDataComplete = true; @@ -205,8 +216,15 @@ const maskFragment = >( 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; @@ -214,15 +232,22 @@ const maskFragment = >( maskedData[fieldAlias] = data[fieldAlias].map(item => { const result = maskFragment( item, - selection.selectionSet as SelectionSetNode + selection.selectionSet as SelectionSetNode, + fragments ); + if (!result.fulfilled) { isDataComplete = false; } + return result.data; }); } else { - const result = maskFragment(data[fieldAlias], selection.selectionSet); + const result = maskFragment( + data[fieldAlias], + selection.selectionSet, + fragments + ); if (!result.fulfilled) { isDataComplete = false; } @@ -230,6 +255,7 @@ const maskFragment = >( } } else { if (data[fieldAlias] === undefined) { + if (hasIncludeOrSkip) return; isDataComplete = false; } else if (data[fieldAlias] === null) { maskedData[fieldAlias] = null; @@ -241,6 +267,7 @@ const maskFragment = >( } maskedData[selection.name.value] = data[selection.name.value]; } else if (selection.kind === Kind.INLINE_FRAGMENT) { + // TODO: add heuristic fragment matching if ( selection.typeCondition && selection.typeCondition.name.value !== data.__typename @@ -248,13 +275,37 @@ const maskFragment = >( return; } - const result = maskFragment(data, selection.selectionSet); + 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) { - // TODO: do we want to support this? + const fragment = fragments[selection.name.value]; + + // TODO: add heuristic fragment matching + if ( + !fragment || + (fragment.typeCondition && + fragment.typeCondition.name.value !== data.__typename) + ) { + return; + } + + if ( + selection.directives && + selection.directives.find(x => x.name.value === 'defer') + ) { + 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); } }); From 88b84df9dfaeed833215c4d9f3225b0eb4dbe1a7 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Tue, 23 Apr 2024 08:25:25 +0200 Subject: [PATCH 09/25] wording change --- packages/react-urql/src/hooks/useFragment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index f340e83220..e7b283e0ba 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -57,7 +57,7 @@ export type UseFragmentArgs = { * masked fragment. */ data: Data; - /** An optional name of the fragment to use. */ + /** An optional name of the fragment to use from the passed Document. */ name?: string; }; From 8ccfba11ae45df7bf5a7f6f057f02ae3793666a2 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 09:28:02 +0200 Subject: [PATCH 10/25] cleanup and heuristic fragment matching --- packages/react-urql/src/hooks/useFragment.ts | 102 ++++++++++++------- 1 file changed, 64 insertions(+), 38 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index e7b283e0ba..2e64af673a 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -3,6 +3,7 @@ import * as React from 'react'; import type { FragmentDefinitionNode, + InlineFragmentNode, SelectionSetNode, } from '@0no-co/graphql.web'; import { Kind } from '@0no-co/graphql.web'; @@ -222,6 +223,7 @@ const maskFragment = >( selection.directives.some( x => x.name.value === 'include' || x.name.value === 'skip' ); + if (selection.selectionSet) { if (data[fieldAlias] === undefined) { if (hasIncludeOrSkip) return; @@ -229,10 +231,28 @@ const maskFragment = >( } else if (data[fieldAlias] === null) { maskedData[fieldAlias] = null; } else if (Array.isArray(data[fieldAlias])) { - maskedData[fieldAlias] = data[fieldAlias].map(item => { + 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( - item, - selection.selectionSet as SelectionSetNode, + data[fieldAlias], + selection.selectionSet, fragments ); @@ -240,38 +260,15 @@ const maskFragment = >( isDataComplete = false; } - return result.data; - }); - } else { - const result = maskFragment( - data[fieldAlias], - selection.selectionSet, - fragments - ); - if (!result.fulfilled) { - isDataComplete = false; + maskedData[fieldAlias] = result.data; + } else { + maskedData[fieldAlias] = data[fieldAlias]; } - maskedData[fieldAlias] = result.data; - } - } else { - if (data[fieldAlias] === undefined) { - if (hasIncludeOrSkip) return; - isDataComplete = false; - } else if (data[fieldAlias] === null) { - maskedData[fieldAlias] = null; - } else if (Array.isArray(data[fieldAlias])) { - maskedData[fieldAlias] = data[fieldAlias].map(item => item); - } else { - maskedData[fieldAlias] = data[fieldAlias]; } } maskedData[selection.name.value] = data[selection.name.value]; } else if (selection.kind === Kind.INLINE_FRAGMENT) { - // TODO: add heuristic fragment matching - if ( - selection.typeCondition && - selection.typeCondition.name.value !== data.__typename - ) { + if (isHeuristicFragmentMatch(selection, data, fragments)) { return; } @@ -284,19 +281,14 @@ const maskFragment = >( } else if (selection.kind === Kind.FRAGMENT_SPREAD) { const fragment = fragments[selection.name.value]; - // TODO: add heuristic fragment matching if ( - !fragment || - (fragment.typeCondition && - fragment.typeCondition.name.value !== data.__typename) + selection.directives && + selection.directives.find(x => x.name.value === 'defer') ) { return; } - if ( - selection.directives && - selection.directives.find(x => x.name.value === 'defer') - ) { + if (!fragment || isHeuristicFragmentMatch(fragment, data, fragments)) { return; } @@ -311,3 +303,37 @@ const maskFragment = >( return { data: maskedData as Data, fulfilled: isDataComplete }; }; + +const isHeuristicFragmentMatch = ( + fragment: InlineFragmentNode | FragmentDefinitionNode, + data: Record, + fragments: Record +): 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); + } + }); +}; From dadf926af720c0c95262da06ebc68d6e7291282b Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 13:10:14 +0200 Subject: [PATCH 11/25] add comments and remove code --- packages/react-urql/src/hooks/useFragment.ts | 45 +++++++++----------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index 2e64af673a..f1156dba77 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -21,7 +21,7 @@ import { useClient } from '../context'; import { useRequest } from './useRequest'; import { getCacheForClient } from './cache'; -import { initialState, computeNextState, hasDepsChanged } from './state'; +import { hasDepsChanged } from './state'; /** Input arguments for the {@link useFragment} hook. */ export type UseFragmentArgs = { @@ -68,7 +68,7 @@ export type UseFragmentArgs = { * `UseFragmentState` is returned by {@link useFragment} and * gives you the masked data for the fragment. */ -export interface UseFragmentState { +export interface UseFragmentState { /** Indicates whether `useFragment` is waiting for a new result. * * @remarks @@ -118,20 +118,22 @@ const isSuspense = (client: Client, context?: Partial) => * }; * ``` */ -export function useFragment< - Data extends Record = Record, ->(args: UseFragmentArgs): UseFragmentState { +export function useFragment( + args: UseFragmentArgs +): UseFragmentState { const client = useClient(); const cache = getCacheForClient(client); const suspense = isSuspense(client, args.context); - const request = useRequest(args.query, args.data); + // 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: Data, suspense: boolean - ): Partial> => { + ): UseFragmentState => { const cached = cache.get(request.key); if (!cached) { const fragment = request.query.definitions.find( @@ -154,11 +156,13 @@ export function useFragment< } return acc; }, {}); + const newResult = maskFragment( data, fragment.selectionSet, fragments ); + if (newResult.fulfilled) { cache.set(request.key, newResult.data as any); return { data: newResult.data as any, fetching: false }; @@ -178,34 +182,23 @@ export function useFragment< [cache, request] ); - const deps = [client, request, args.context, args.data] as const; + // 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( - () => - [ - computeNextState( - initialState, - getSnapshot(request, args.data, suspense) - ), - deps, - ] as const + () => [getSnapshot(request, args.data, suspense), deps] as const ); - let currentResult = state[0]; + const currentResult = state[0]; if (hasDepsChanged(state[1], deps)) { - setState([ - (currentResult = computeNextState( - state[0], - getSnapshot(request, args.data, suspense) - )), - deps, - ]); + setState([getSnapshot(request, args.data, suspense), deps]); } return currentResult; } -const maskFragment = >( +const maskFragment = ( data: Data, selectionSet: SelectionSetNode, fragments: Record @@ -306,7 +299,7 @@ const maskFragment = >( const isHeuristicFragmentMatch = ( fragment: InlineFragmentNode | FragmentDefinitionNode, - data: Record, + data: any, fragments: Record ): boolean => { if ( From ec4fd17267c632ac5b6ae130dec84dbc366186bc Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 14:14:46 +0200 Subject: [PATCH 12/25] hoist a few things --- packages/react-urql/src/hooks/useFragment.ts | 57 +++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index f1156dba77..abf6e0d0af 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -46,7 +46,7 @@ export type UseFragmentArgs = { * }); * ``` */ - context: Partial; + context?: Partial; /** A GraphQL document to mask this fragment against. * * @remarks @@ -109,10 +109,9 @@ const isSuspense = (client: Client, context?: Partial) => * `; * * const Todo = (props) => { - * const result = useQuery({ + * const result = useFragment({ * data: props.todo, * query: TodoFields, - * variables: {}, * }); * // ... * }; @@ -124,10 +123,37 @@ export function useFragment( const client = useClient(); const cache = getCacheForClient(client); const suspense = isSuspense(client, args.context); - // We use args.variables here for the key to differentiate + // We use args.data 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 fragments = React.useMemo(() => { + return request.query.definitions.reduce< + Record + >((acc, frag) => { + if (frag.kind === Kind.FRAGMENT_DEFINITION) { + acc[frag.name.value] = frag; + } + return acc; + }, {}); + }, [request.query]); + + 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 getSnapshot = React.useCallback( ( request: GraphQLRequest, @@ -136,27 +162,6 @@ export function useFragment( ): UseFragmentState => { 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, fragment.selectionSet, @@ -183,7 +188,7 @@ export function useFragment( ); // TODO: either we use request here or args.query and args.data - const deps = [client, args.context, args.data, args.query] as const; + const deps = [client, args.context, args.data, request.query] as const; // In essence we could opt to not use state and always get snapshot const [state, setState] = React.useState( From ce0f5514f1cedf1403a7aabd950941387ad76770 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 14:34:47 +0200 Subject: [PATCH 13/25] update condition --- packages/react-urql/src/hooks/useFragment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index abf6e0d0af..cad891af41 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -326,7 +326,7 @@ const isHeuristicFragmentMatch = ( x.name.value === 'skip' || x.name.value === 'defer' ); - return Object.hasOwn(data, fieldAlias) && !couldBeExcluded; + return data[fieldAlias] !== undefined && !couldBeExcluded; } else if (selection.kind === Kind.INLINE_FRAGMENT) { return isHeuristicFragmentMatch(selection, data, fragments); } else if (selection.kind === Kind.FRAGMENT_SPREAD) { From b9a274a2fcfcd1ccaea9b7a6adc95b7848c93717 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 14:37:14 +0200 Subject: [PATCH 14/25] include/skip on fragments --- packages/react-urql/src/hooks/useFragment.ts | 23 ++++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index cad891af41..1dafb3b740 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -211,17 +211,17 @@ const maskFragment = ( const maskedData = {}; let isDataComplete = true; selectionSet.selections.forEach(selection => { + const hasIncludeOrSkip = + selection.directives && + selection.directives.some( + x => x.name.value === 'include' || x.name.value === 'skip' + ); + 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; @@ -266,15 +266,15 @@ const maskFragment = ( } maskedData[selection.name.value] = data[selection.name.value]; } else if (selection.kind === Kind.INLINE_FRAGMENT) { - if (isHeuristicFragmentMatch(selection, data, fragments)) { + 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) { + if (!result.fulfilled && !hasIncludeOrSkip) { isDataComplete = false; } + Object.assign(maskedData, result.data); } else if (selection.kind === Kind.FRAGMENT_SPREAD) { const fragment = fragments[selection.name.value]; @@ -286,13 +286,12 @@ const maskFragment = ( return; } - if (!fragment || isHeuristicFragmentMatch(fragment, data, fragments)) { + 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) { + if (!result.fulfilled && !hasIncludeOrSkip) { isDataComplete = false; } Object.assign(maskedData, result.data); From 1187faff08495d33184e0eb369916ee5036ec1ce Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 14:40:49 +0200 Subject: [PATCH 15/25] add changeset --- .changeset/olive-shrimps-leave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/olive-shrimps-leave.md diff --git a/.changeset/olive-shrimps-leave.md b/.changeset/olive-shrimps-leave.md new file mode 100644 index 0000000000..8192b13e3a --- /dev/null +++ b/.changeset/olive-shrimps-leave.md @@ -0,0 +1,5 @@ +--- +'urql': minor +--- + +Add BETA `useFragment` hook, this hook will be able to `suspend` or indicate loading states when the data inside of said fragment is deferred. From 4aa90964829e54d77b3110b9e7c4b93d377b61a0 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 14:43:42 +0200 Subject: [PATCH 16/25] add defer handling to fragment-spread --- packages/react-urql/src/hooks/useFragment.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index 1dafb3b740..e612110c79 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -279,19 +279,16 @@ const maskFragment = ( } else if (selection.kind === Kind.FRAGMENT_SPREAD) { const fragment = fragments[selection.name.value]; - if ( + const hasDefer = selection.directives && - selection.directives.find(x => x.name.value === 'defer') - ) { - return; - } + selection.directives.find(x => x.name.value === 'defer'); if (!fragment || !isHeuristicFragmentMatch(fragment, data, fragments)) { return; } const result = maskFragment(data, fragment.selectionSet, fragments); - if (!result.fulfilled && !hasIncludeOrSkip) { + if (!result.fulfilled && !hasIncludeOrSkip && !hasDefer) { isDataComplete = false; } Object.assign(maskedData, result.data); From 17f8336d1f26636d4efe48d5bf682bbe18d35efa Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 14:51:54 +0200 Subject: [PATCH 17/25] resolve promise --- .../with-defer-stream-directives/src/App.jsx | 4 +++- .../with-defer-stream-directives/src/Songs.jsx | 6 ++++++ packages/react-urql/src/hooks/cache.ts | 10 +++++++++- packages/react-urql/src/hooks/useFragment.ts | 17 ++++++++++++++++- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/examples/with-defer-stream-directives/src/App.jsx b/examples/with-defer-stream-directives/src/App.jsx index 3e29e59ded..4043a89ca1 100644 --- a/examples/with-defer-stream-directives/src/App.jsx +++ b/examples/with-defer-stream-directives/src/App.jsx @@ -21,7 +21,9 @@ const client = new Client({ function App() { return ( - + Loading...

}> + +
); } diff --git a/examples/with-defer-stream-directives/src/Songs.jsx b/examples/with-defer-stream-directives/src/Songs.jsx index a61b02a2ed..cd5dfb202d 100644 --- a/examples/with-defer-stream-directives/src/Songs.jsx +++ b/examples/with-defer-stream-directives/src/Songs.jsx @@ -13,6 +13,9 @@ const SONGS_QUERY = gql` firstVerse ...secondVerseFields @defer } + alphabet @stream(initialCount: 3) { + char + } } ${SecondVerseFragment} @@ -50,6 +53,9 @@ const LocationsList = () => { {data && ( <> + {data.alphabet.map(i => ( +
{i.char}
+ ))} )} diff --git a/packages/react-urql/src/hooks/cache.ts b/packages/react-urql/src/hooks/cache.ts index db6ddc3b77..52a162d181 100644 --- a/packages/react-urql/src/hooks/cache.ts +++ b/packages/react-urql/src/hooks/cache.ts @@ -1,7 +1,15 @@ import { pipe, subscribe } from 'wonka'; import type { Client, OperationResult } from '@urql/core'; -type CacheEntry = OperationResult | Promise | undefined; +export type FragmentPromise = Promise & { + _resolve: () => void; + _resolved: boolean; +}; +type CacheEntry = + | OperationResult + | Promise + | FragmentPromise + | undefined; interface Cache { get(key: number): CacheEntry; diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index e612110c79..cb598cd994 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -19,6 +19,7 @@ import type { import { useClient } from '../context'; import { useRequest } from './useRequest'; +import type { FragmentPromise } from './cache'; import { getCacheForClient } from './cache'; import { hasDepsChanged } from './state'; @@ -172,7 +173,12 @@ export function useFragment( cache.set(request.key, newResult.data as any); return { data: newResult.data as any, fetching: false }; } else if (suspense) { - const promise = new Promise(() => {}); + let _resolve; + const promise = new Promise(res => { + _resolve = res; + }) as FragmentPromise; + promise._resolve = _resolve; + promise._resolved = false; cache.set(request.key, promise); throw promise; } else { @@ -182,6 +188,15 @@ export function useFragment( throw cached; } + if ( + '_resolve' in cached && + '_resolved' in cached && + !cached._resolved && + typeof cached._resolve == 'function' + ) { + cached._resolve(); + cached._resolved = true; + } return { fetching: false, data: (cached as OperationResult).data }; }, [cache, request] From d353496f91244161aa2b7653aa320ce57f13c08c Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 14:52:29 +0200 Subject: [PATCH 18/25] remove comments --- packages/react-urql/src/hooks/useFragment.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index cb598cd994..123e387a1c 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -124,8 +124,6 @@ export function useFragment( const client = useClient(); const cache = getCacheForClient(client); const suspense = isSuspense(client, args.context); - // We use args.data 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 fragments = React.useMemo(() => { @@ -202,10 +200,8 @@ export function useFragment( [cache, request] ); - // TODO: either we use request here or args.query and args.data const deps = [client, args.context, args.data, request.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 ); From 892b8c7943a7fc63b1c77362cc25e3ce423508a2 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 15:27:56 +0200 Subject: [PATCH 19/25] add support for null --- packages/react-urql/src/hooks/useFragment.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index 123e387a1c..6a261e9e4b 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -58,7 +58,7 @@ export type UseFragmentArgs = { /** A JSON object which we will extract properties from to get to the * masked fragment. */ - data: Data; + data: Data | null; /** An optional name of the fragment to use from the passed Document. */ name?: string; }; @@ -79,7 +79,7 @@ export interface UseFragmentState { */ fetching: boolean; /** The {@link OperationResult.data} for the masked fragment. */ - data?: Data; + data?: Data | null; } const isSuspense = (client: Client, context?: Partial) => @@ -124,7 +124,7 @@ export function useFragment( const client = useClient(); const cache = getCacheForClient(client); const suspense = isSuspense(client, args.context); - const request = useRequest(args.query, args.data as any); + const request = useRequest(args.query, args.data || {}); const fragments = React.useMemo(() => { return request.query.definitions.reduce< @@ -156,11 +156,15 @@ export function useFragment( const getSnapshot = React.useCallback( ( request: GraphQLRequest, - data: Data, + data: Data | null, suspense: boolean ): UseFragmentState => { const cached = cache.get(request.key); if (!cached) { + if (data === null) { + cache.set(request.key, null as any); + return { data: null, fetching: false }; + } const newResult = maskFragment( data, fragment.selectionSet, @@ -169,7 +173,7 @@ export function useFragment( if (newResult.fulfilled) { cache.set(request.key, newResult.data as any); - return { data: newResult.data as any, fetching: false }; + return { data: newResult.data, fetching: false }; } else if (suspense) { let _resolve; const promise = new Promise(res => { From 57f1a581c2d1e11d2bfc5a0a4d7b2f982432e060 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 16:00:12 +0200 Subject: [PATCH 20/25] fixes --- packages/react-urql/src/hooks/cache.ts | 45 +++++-- packages/react-urql/src/hooks/useFragment.ts | 117 +++++++++---------- 2 files changed, 90 insertions(+), 72 deletions(-) diff --git a/packages/react-urql/src/hooks/cache.ts b/packages/react-urql/src/hooks/cache.ts index 52a162d181..a05274b2e9 100644 --- a/packages/react-urql/src/hooks/cache.ts +++ b/packages/react-urql/src/hooks/cache.ts @@ -5,23 +5,23 @@ export type FragmentPromise = Promise & { _resolve: () => void; _resolved: boolean; }; -type CacheEntry = - | OperationResult - | Promise - | FragmentPromise - | undefined; - -interface Cache { - get(key: number): CacheEntry; - set(key: number, value: CacheEntry): void; + +type CacheEntry = OperationResult | Promise | undefined; + +type FragmentCacheEntry = Promise | undefined; + +interface Cache { + get(key: number): Entry; + set(key: number, value: Entry): void; dispose(key: number): void; } interface ClientWithCache extends Client { - _react?: Cache; + _fragments?: Cache; + _react?: Cache; } -export const getCacheForClient = (client: Client): Cache => { +export const getCacheForClient = (client: Client): Cache => { if (!(client as ClientWithCache)._react) { const reclaim = new Set(); const map = new Map(); @@ -43,7 +43,6 @@ export const getCacheForClient = (client: Client): Cache => { return map.get(key); }, set(key, value) { - reclaim.delete(key); map.set(key, value); }, dispose(key) { @@ -54,3 +53,25 @@ export const getCacheForClient = (client: Client): Cache => { return (client as ClientWithCache)._react!; }; + +export const getFragmentCacheForClient = ( + client: Client +): Cache => { + if (!(client as ClientWithCache)._fragments) { + const map = new Map(); + + (client as ClientWithCache)._fragments = { + get(key) { + return map.get(key); + }, + set(key, value) { + map.set(key, value); + }, + dispose(key) { + map.delete(key); + }, + }; + } + + return (client as ClientWithCache)._fragments!; +}; diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index 6a261e9e4b..d966177c29 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -13,14 +13,12 @@ import type { AnyVariables, Client, OperationContext, - OperationResult, GraphQLRequest, } from '@urql/core'; import { useClient } from '../context'; import { useRequest } from './useRequest'; -import type { FragmentPromise } from './cache'; -import { getCacheForClient } from './cache'; +import { getFragmentCacheForClient } from './cache'; import { hasDepsChanged } from './state'; @@ -78,7 +76,7 @@ export interface UseFragmentState { * `true` until a result arrives. */ fetching: boolean; - /** The {@link OperationResult.data} for the masked fragment. */ + /** The data for the masked fragment. */ data?: Data | null; } @@ -122,7 +120,7 @@ export function useFragment( args: UseFragmentArgs ): UseFragmentState { const client = useClient(); - const cache = getCacheForClient(client); + const cache = getFragmentCacheForClient(client); const suspense = isSuspense(client, args.context); const request = useRequest(args.query, args.data || {}); @@ -159,12 +157,12 @@ export function useFragment( data: Data | null, suspense: boolean ): UseFragmentState => { + if (data === null) { + return { data: null, fetching: false }; + } + const cached = cache.get(request.key); if (!cached) { - if (data === null) { - cache.set(request.key, null as any); - return { data: null, fetching: false }; - } const newResult = maskFragment( data, fragment.selectionSet, @@ -172,39 +170,41 @@ export function useFragment( ); if (newResult.fulfilled) { - cache.set(request.key, newResult.data as any); return { data: newResult.data, fetching: false }; } else if (suspense) { - let _resolve; - const promise = new Promise(res => { - _resolve = res; - }) as FragmentPromise; - promise._resolve = _resolve; - promise._resolved = false; + const promise = new Promise(() => {}); cache.set(request.key, promise); throw promise; } else { return { fetching: true, data: newResult.data }; } } else if (suspense && cached != null && 'then' in cached) { - throw cached; - } + const newResult = maskFragment( + data, + fragment.selectionSet, + fragments + ); - if ( - '_resolve' in cached && - '_resolved' in cached && - !cached._resolved && - typeof cached._resolve == 'function' - ) { - cached._resolve(); - cached._resolved = true; + if (!newResult.fulfilled) { + throw cached; + } else { + cache.dispose(request.key); + return { data: newResult.data, fetching: false }; + } } - return { fetching: false, data: (cached as OperationResult).data }; + + const newResult = maskFragment( + data, + fragment.selectionSet, + fragments + ); + + return { fetching: false, data: newResult.data }; }, [cache, request] ); - const deps = [client, args.context, args.data, request.query] as const; + const deps = [client, request, args.data, args.context] as const; const [state, setState] = React.useState( () => [getSnapshot(request, args.data, suspense), deps] as const @@ -237,35 +237,17 @@ const maskFragment = ( ? selection.alias.value : selection.name.value; - 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) { + 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( - data[fieldAlias], - selection.selectionSet, + item, + selection.selectionSet as SelectionSetNode, fragments ); @@ -273,13 +255,28 @@ const maskFragment = ( isDataComplete = false; } - maskedData[fieldAlias] = result.data; - } else { - maskedData[fieldAlias] = data[fieldAlias]; + 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; From fb6f98f798d6c0335d7179cc7bc379a67f4102e3 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 16:19:19 +0200 Subject: [PATCH 21/25] add tests --- .../react-urql/src/hooks/useFragment.test.ts | 303 ++++++++++++++++++ packages/react-urql/src/hooks/useFragment.ts | 2 +- 2 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 packages/react-urql/src/hooks/useFragment.test.ts diff --git a/packages/react-urql/src/hooks/useFragment.test.ts b/packages/react-urql/src/hooks/useFragment.test.ts new file mode 100644 index 0000000000..04428d9181 --- /dev/null +++ b/packages/react-urql/src/hooks/useFragment.test.ts @@ -0,0 +1,303 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { vi, expect, it, describe, beforeAll } from 'vitest'; + +import { useFragment } from './useFragment'; + +vi.mock('../context', () => { + const mock = {}; + + return { + useClient: () => mock, + }; +}); + +const mockQuery = ` + fragment TodoFields on Todo { + id + name + __typename + } +`; + +describe('useFragment', () => { + beforeAll(() => { + // TODO: Fix use of act() + vi.spyOn(global.console, 'error').mockImplementation(() => { + // do nothing + }); + }); + + it('should correctly mask data', () => { + const { result } = renderHook( + ({ query }) => + useFragment({ + query, + data: { + __typename: 'Todo', + id: '1', + name: 'Learn urql', + completed: true, + }, + }), + { initialProps: { query: mockQuery } } + ); + + const state = result.current; + expect(state).toEqual({ + fetching: false, + data: { + __typename: 'Todo', + id: '1', + name: 'Learn urql', + }, + }); + }); + + it('should correctly mask data w/ null attribute', () => { + const { result } = renderHook( + ({ query }) => + useFragment({ + query, + data: { __typename: 'Todo', id: '1', name: null, completed: true }, + }), + { initialProps: { query: mockQuery } } + ); + + const state = result.current; + expect(state).toEqual({ + fetching: false, + data: { + __typename: 'Todo', + id: '1', + name: null, + }, + }); + }); + + it('should correctly indicate loading w/ undefined attribute', () => { + const { result } = renderHook( + ({ query }) => + useFragment({ + query, + data: { + __typename: 'Todo', + id: '1', + name: undefined, + completed: true, + }, + }), + { initialProps: { query: mockQuery } } + ); + + const state = result.current; + expect(state).toEqual({ + fetching: true, + data: { + __typename: 'Todo', + id: '1', + }, + }); + }); + + it('should correctly mask data w/ nested object', () => { + const { result } = renderHook( + ({ query }) => + useFragment({ + query, + data: { + __typename: 'Todo', + id: '1', + name: null, + completed: true, + author: { + id: '1', + name: 'Jovi', + __typename: 'Author', + awardWinner: true, + }, + }, + }), + { + initialProps: { + query: ` + fragment TodoFields on Todo { + id + name + __typename + author { id name __typename } + }`, + }, + } + ); + + const state = result.current; + expect(state).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( + ({ query }) => + useFragment({ + query, + data: { + __typename: 'Todo', + id: '1', + name: null, + completed: true, + author: null, + }, + }), + { + initialProps: { + query: ` + fragment TodoFields on Todo { + id + name + __typename + author { id name __typename } + }`, + }, + } + ); + + const state = result.current; + expect(state).toEqual({ + fetching: false, + data: { + __typename: 'Todo', + id: '1', + name: null, + author: null, + }, + }); + }); + + it('should correctly mark loading w/ nested selection that is undefined', () => { + const { result } = renderHook( + ({ query }) => + useFragment({ + query, + data: { + __typename: 'Todo', + id: '1', + name: null, + completed: true, + author: undefined, + }, + }), + { + initialProps: { + query: ` + fragment TodoFields on Todo { + id + name + __typename + author { id name __typename } + }`, + }, + } + ); + + const state = result.current; + expect(state).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( + ({ query }) => + useFragment({ + query, + data: { + __typename: 'Todo', + id: '1', + name: null, + completed: true, + author: undefined, + }, + }), + { + initialProps: { + query: ` + fragment TodoFields on Todo { + id + name + __typename + ...AuthorFields @defer + } + + fragment AuthorFields on Todo { author { id name __typename } } + `, + }, + } + ); + + const state = result.current; + expect(state).toEqual({ + fetching: false, + data: { + __typename: 'Todo', + id: '1', + name: null, + }, + }); + }); + + it('should correctly mark resolved w/ nested fragment-selection that is undefined', () => { + const { result } = renderHook( + ({ query }) => + useFragment({ + query, + data: { + __typename: 'Todo', + id: '1', + name: null, + completed: true, + author: undefined, + }, + }), + { + initialProps: { + query: ` + fragment TodoFields on Todo { + id + name + __typename + ...AuthorFields + } + + fragment AuthorFields on Todo { author { id name __typename } } + `, + }, + } + ); + + const state = result.current; + expect(state).toEqual({ + fetching: true, + data: { + __typename: 'Todo', + id: '1', + name: null, + }, + }); + }); +}); diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index d966177c29..05f42e5a0b 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -293,7 +293,7 @@ const maskFragment = ( const hasDefer = selection.directives && - selection.directives.find(x => x.name.value === 'defer'); + selection.directives.some(x => x.name.value === 'defer'); if (!fragment || !isHeuristicFragmentMatch(fragment, data, fragments)) { return; From 1e16372da4bb9a2ff9e5c4b76353dca02d5454b9 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 23 Apr 2024 16:20:31 +0200 Subject: [PATCH 22/25] add one more test --- .../react-urql/src/hooks/useFragment.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/react-urql/src/hooks/useFragment.test.ts b/packages/react-urql/src/hooks/useFragment.test.ts index 04428d9181..48cef83067 100644 --- a/packages/react-urql/src/hooks/useFragment.test.ts +++ b/packages/react-urql/src/hooks/useFragment.test.ts @@ -53,6 +53,33 @@ describe('useFragment', () => { }); }); + it('should correctly take a different fragment to mask data', () => { + const { result } = renderHook( + () => + useFragment({ + query: `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, + }, + }), + { initialProps: { query: mockQuery } } + ); + + const state = result.current; + expect(state).toEqual({ + fetching: false, + data: { + __typename: 'Todo', + id: '1', + name: 'Learn urql', + }, + }); + }); + it('should correctly mask data w/ null attribute', () => { const { result } = renderHook( ({ query }) => From ea665f3f02edd673f7962b9dd58541661cf598a4 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Wed, 24 Apr 2024 12:52:43 +0200 Subject: [PATCH 23/25] propose solution to keying problem --- packages/react-urql/src/hooks/useFragment.ts | 65 +++++++++++++++----- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index 05f42e5a0b..c1da175ee1 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -5,6 +5,7 @@ import type { FragmentDefinitionNode, InlineFragmentNode, SelectionSetNode, + DocumentNode, } from '@0no-co/graphql.web'; import { Kind } from '@0no-co/graphql.web'; @@ -21,6 +22,7 @@ import { useRequest } from './useRequest'; import { getFragmentCacheForClient } from './cache'; import { hasDepsChanged } from './state'; +import { keyDocument } from '@urql/core/utils'; /** Input arguments for the {@link useFragment} hook. */ export type UseFragmentArgs = { @@ -122,26 +124,20 @@ export function useFragment( const client = useClient(); const cache = getFragmentCacheForClient(client); const suspense = isSuspense(client, args.context); - const request = useRequest(args.query, args.data || {}); - - const fragments = React.useMemo(() => { - return request.query.definitions.reduce< - Record - >((acc, frag) => { - if (frag.kind === Kind.FRAGMENT_DEFINITION) { - acc[frag.name.value] = frag; - } - return acc; - }, {}); - }, [request.query]); - const fragment = React.useMemo(() => { - return request.query.definitions.find( + let document: DocumentNode; + if (typeof args.query === 'string') { + document = keyDocument(args.query); + } else { + document = args.query; + } + + return document.definitions.find( x => x.kind === Kind.FRAGMENT_DEFINITION && ((args.name && x.name.value === args.name) || !args.name) ) as FragmentDefinitionNode | undefined; - }, [request.query, args.name]); + }, [args.query, args.name]); if (!fragment) { throw new Error( @@ -151,6 +147,22 @@ export function useFragment( ); } + const request = useRequest( + args.query, + getKeyForEntity(args.data, fragment.typeCondition.name.value) + ); + + const fragments = React.useMemo(() => { + return request.query.definitions.reduce< + Record + >((acc, frag) => { + if (frag.kind === Kind.FRAGMENT_DEFINITION) { + acc[frag.name.value] = frag; + } + return acc; + }, {}); + }, [request.query]); + const getSnapshot = React.useCallback( ( request: GraphQLRequest, @@ -218,6 +230,29 @@ export function useFragment( return currentResult; } +const getKeyForEntity = ( + entity: any, + typeCondition: string +): { id: string; __typename: string } | {} => { + if (!entity) return {}; + + let key = entity.id || entity._id; + const typename = entity.__typename; + + if (!key) { + const keyable = Object.keys(entity).reduce((acc, key) => { + if (typeof entity[key] === 'string' || typeof entity[key] === 'number') { + acc[key] = entity[key]; + } + + return acc; + }, {}); + key = JSON.stringify(keyable); + } + + return { id: key, __typename: typename || typeCondition }; +}; + const maskFragment = ( data: Data, selectionSet: SelectionSetNode, From 85d7ed6ffbe5152f38ba4f241f0bcd2f04e77e58 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Wed, 24 Apr 2024 13:15:43 +0200 Subject: [PATCH 24/25] use the createRequest export instead of keyDocument --- packages/react-urql/src/hooks/useFragment.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index c1da175ee1..c001b2c8c0 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -5,7 +5,6 @@ import type { FragmentDefinitionNode, InlineFragmentNode, SelectionSetNode, - DocumentNode, } from '@0no-co/graphql.web'; import { Kind } from '@0no-co/graphql.web'; @@ -16,13 +15,13 @@ import type { OperationContext, GraphQLRequest, } from '@urql/core'; +import { createRequest } from '@urql/core'; import { useClient } from '../context'; import { useRequest } from './useRequest'; import { getFragmentCacheForClient } from './cache'; import { hasDepsChanged } from './state'; -import { keyDocument } from '@urql/core/utils'; /** Input arguments for the {@link useFragment} hook. */ export type UseFragmentArgs = { @@ -125,14 +124,9 @@ export function useFragment( const cache = getFragmentCacheForClient(client); const suspense = isSuspense(client, args.context); const fragment = React.useMemo(() => { - let document: DocumentNode; - if (typeof args.query === 'string') { - document = keyDocument(args.query); - } else { - document = args.query; - } + const request = createRequest(args.query, {}); - return document.definitions.find( + return request.query.definitions.find( x => x.kind === Kind.FRAGMENT_DEFINITION && ((args.name && x.name.value === args.name) || !args.name) From 11727b66afed571414cffddb1ec7db19e9876304 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Wed, 24 Apr 2024 14:06:49 +0200 Subject: [PATCH 25/25] alternative --- packages/react-urql/src/hooks/cache.ts | 3 +- packages/react-urql/src/hooks/useFragment.ts | 64 ++++++-------------- 2 files changed, 21 insertions(+), 46 deletions(-) diff --git a/packages/react-urql/src/hooks/cache.ts b/packages/react-urql/src/hooks/cache.ts index a05274b2e9..00ef5978a2 100644 --- a/packages/react-urql/src/hooks/cache.ts +++ b/packages/react-urql/src/hooks/cache.ts @@ -3,12 +3,11 @@ import type { Client, OperationResult } from '@urql/core'; export type FragmentPromise = Promise & { _resolve: () => void; - _resolved: boolean; }; type CacheEntry = OperationResult | Promise | undefined; -type FragmentCacheEntry = Promise | undefined; +type FragmentCacheEntry = FragmentPromise | undefined; interface Cache { get(key: number): Entry; diff --git a/packages/react-urql/src/hooks/useFragment.ts b/packages/react-urql/src/hooks/useFragment.ts index c001b2c8c0..d6c0e80c31 100644 --- a/packages/react-urql/src/hooks/useFragment.ts +++ b/packages/react-urql/src/hooks/useFragment.ts @@ -15,10 +15,10 @@ import type { OperationContext, GraphQLRequest, } from '@urql/core'; -import { createRequest } from '@urql/core'; import { useClient } from '../context'; import { useRequest } from './useRequest'; +import type { FragmentPromise } from './cache'; import { getFragmentCacheForClient } from './cache'; import { hasDepsChanged } from './state'; @@ -123,9 +123,10 @@ export function useFragment( const client = useClient(); const cache = getFragmentCacheForClient(client); const suspense = isSuspense(client, args.context); - const fragment = React.useMemo(() => { - const request = createRequest(args.query, {}); + const request = useRequest(args.query, {}); + + const fragment = React.useMemo(() => { return request.query.definitions.find( x => x.kind === Kind.FRAGMENT_DEFINITION && @@ -141,11 +142,6 @@ export function useFragment( ); } - const request = useRequest( - args.query, - getKeyForEntity(args.data, fragment.typeCondition.name.value) - ); - const fragments = React.useMemo(() => { return request.query.definitions.reduce< Record @@ -165,6 +161,14 @@ export function useFragment( ): UseFragmentState => { if (data === null) { return { data: null, fetching: false }; + } else if (!suspense) { + const newResult = maskFragment( + data, + fragment.selectionSet, + fragments + ); + + return { data: newResult.data, fetching: !newResult.fulfilled }; } const cached = cache.get(request.key); @@ -177,14 +181,16 @@ export function useFragment( if (newResult.fulfilled) { return { data: newResult.data, fetching: false }; - } else if (suspense) { - const promise = new Promise(() => {}); + } else { + let _resolve; + const promise = new Promise(r => { + _resolve = r; + }) as FragmentPromise; + promise._resolve = _resolve; cache.set(request.key, promise); throw promise; - } else { - return { fetching: true, data: newResult.data }; } - } else if (suspense && cached != null && 'then' in cached) { + } else { const newResult = maskFragment( data, fragment.selectionSet, @@ -194,18 +200,11 @@ export function useFragment( if (!newResult.fulfilled) { throw cached; } else { + cached._resolve(); cache.dispose(request.key); return { data: newResult.data, fetching: false }; } } - - const newResult = maskFragment( - data, - fragment.selectionSet, - fragments - ); - - return { fetching: false, data: newResult.data }; }, [cache, request] ); @@ -224,29 +223,6 @@ export function useFragment( return currentResult; } -const getKeyForEntity = ( - entity: any, - typeCondition: string -): { id: string; __typename: string } | {} => { - if (!entity) return {}; - - let key = entity.id || entity._id; - const typename = entity.__typename; - - if (!key) { - const keyable = Object.keys(entity).reduce((acc, key) => { - if (typeof entity[key] === 'string' || typeof entity[key] === 'number') { - acc[key] = entity[key]; - } - - return acc; - }, {}); - key = JSON.stringify(keyable); - } - - return { id: key, __typename: typename || typeCondition }; -}; - const maskFragment = ( data: Data, selectionSet: SelectionSetNode,