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,