diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10a8ad06134..8cb4393ddad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
## Apollo Client 3.7.0 (in development)
+### New Features
+
+- Implement `useFragment` hook, which represents a lightweight live binding into the `ApolloCache`, and never triggers network requests of its own.
+ [@benjamn](https://github.com/benjamn) in [#8782](https://github.com/apollographql/apollo-client/pull/8782)
+
- Replace `concast.cleanup` method with simpler `concast.beforeNext` API, which promises to call the given callback function just before the next result/error is delivered. In addition, `concast.removeObserver` no longer takes a `quietly?: boolean` parameter, since that parameter was partly responsible for cleanup callbacks sometimes not getting called.
[@benjamn](https://github.com/benjamn) in [#9718](https://github.com/apollographql/apollo-client/pull/9718)
diff --git a/package.json b/package.json
index a9c514089d7..47780dab285 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,7 @@
{
"name": "apollo-client",
"path": "./dist/apollo-client.min.cjs",
- "maxSize": "29.5kB"
+ "maxSize": "29.8kB"
}
],
"engines": {
diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap
index aa50e65fc3a..eaf287ba424 100644
--- a/src/__tests__/__snapshots__/exports.ts.snap
+++ b/src/__tests__/__snapshots__/exports.ts.snap
@@ -53,6 +53,7 @@ Array [
"throwServerError",
"toPromise",
"useApolloClient",
+ "useFragment",
"useLazyQuery",
"useMutation",
"useQuery",
@@ -242,6 +243,7 @@ Array [
"parser",
"resetApolloContext",
"useApolloClient",
+ "useFragment",
"useLazyQuery",
"useMutation",
"useQuery",
@@ -280,6 +282,7 @@ Array [
exports[`exports of public entry points @apollo/client/react/hooks 1`] = `
Array [
"useApolloClient",
+ "useFragment",
"useLazyQuery",
"useMutation",
"useQuery",
diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts
index 6f830f9abd9..4086d34a45e 100644
--- a/src/cache/core/types/Cache.ts
+++ b/src/cache/core/types/Cache.ts
@@ -28,7 +28,7 @@ export namespace Cache {
export interface DiffOptions<
TData = any,
TVariables = any,
- > extends ReadOptions {
+ > extends Omit, "rootId"> {
// The DiffOptions interface is currently just an alias for
// ReadOptions, though DiffOptions used to be responsible for
// declaring the returnPartialData option.
@@ -37,7 +37,7 @@ export namespace Cache {
export interface WatchOptions<
TData = any,
TVariables = any,
- > extends ReadOptions {
+ > extends DiffOptions {
watcher?: object;
immediate?: boolean;
callback: WatchCallback;
diff --git a/src/cache/core/types/common.ts b/src/cache/core/types/common.ts
index 24b8ef4bb5f..142f1844d17 100644
--- a/src/cache/core/types/common.ts
+++ b/src/cache/core/types/common.ts
@@ -28,7 +28,18 @@ export class MissingFieldError {
public readonly path: MissingTree | Array,
public readonly query: DocumentNode,
public readonly variables?: Record,
- ) {}
+ ) {
+ if (Array.isArray(this.path)) {
+ this.missing = this.message;
+ for (let i = this.path.length - 1; i >= 0; --i) {
+ this.missing = { [this.path[i]]: this.missing };
+ }
+ } else {
+ this.missing = this.path;
+ }
+ }
+
+ public readonly missing: MissingTree;
}
export interface FieldSpecifier {
diff --git a/src/cache/index.ts b/src/cache/index.ts
index 5778a1d2c69..b99823f7212 100644
--- a/src/cache/index.ts
+++ b/src/cache/index.ts
@@ -4,6 +4,7 @@ export { Transaction, ApolloCache } from './core/cache';
export { Cache } from './core/types/Cache';
export { DataProxy } from './core/types/DataProxy';
export {
+ MissingTree,
MissingFieldError,
ReadFieldOptions
} from './core/types/common';
diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts
index 6ce2a71d677..69a497f332a 100644
--- a/src/cache/inmemory/inMemoryCache.ts
+++ b/src/cache/inmemory/inMemoryCache.ts
@@ -120,7 +120,7 @@ export class InMemoryCache extends ApolloCache {
// currently using a data store that can track cache dependencies.
const store = c.optimistic ? this.optimisticData : this.data;
if (supportsResultCaching(store)) {
- const { optimistic, rootId, variables } = c;
+ const { optimistic, id, variables } = c;
return store.makeCacheKey(
c.query,
// Different watches can have the same query, optimistic
@@ -130,7 +130,7 @@ export class InMemoryCache extends ApolloCache {
// separation is to include c.callback in the cache key for
// maybeBroadcastWatch calls. See issue #5733.
c.callback,
- canonicalStringify({ optimistic, rootId, variables }),
+ canonicalStringify({ optimistic, id, variables }),
);
}
}
diff --git a/src/react/hooks/__tests__/useFragment.test.tsx b/src/react/hooks/__tests__/useFragment.test.tsx
new file mode 100644
index 00000000000..ee3abc5da7a
--- /dev/null
+++ b/src/react/hooks/__tests__/useFragment.test.tsx
@@ -0,0 +1,767 @@
+import * as React from "react";
+import { render, waitFor } from "@testing-library/react";
+import { renderHook } from '@testing-library/react-hooks';
+import { act } from "react-dom/test-utils";
+
+import { useFragment } from "../useFragment";
+import { MockedProvider } from "../../../testing";
+import { InMemoryCache, gql, TypedDocumentNode, Reference } from "../../../core";
+import { useQuery } from "../useQuery";
+
+describe("useFragment", () => {
+ it("is importable and callable", () => {
+ expect(typeof useFragment).toBe("function");
+ });
+
+ type Item = {
+ __typename: string;
+ id: number;
+ text?: string;
+ };
+
+ const ListFragment: TypedDocumentNode = gql`
+ fragment ListFragment on Query {
+ list {
+ id
+ }
+ # Used to make sure ListFragment got used, even if the id field of the
+ # nested list items is provided by other means.
+ extra
+ }
+ `;
+
+ const ItemFragment: TypedDocumentNode- = gql`
+ fragment ItemFragment on Item {
+ text
+ }
+ `;
+
+ interface QueryData {
+ list: Item[];
+ }
+
+ interface QueryDataWithExtra extends QueryData {
+ extra: string;
+ }
+
+ it("can rerender individual list elements", async () => {
+ const cache = new InMemoryCache({
+ typePolicies: {
+ Item: {
+ fields: {
+ text(existing, { readField }) {
+ return existing || `Item #${readField("id")}`;
+ },
+ },
+ },
+ },
+ });
+
+ const listQuery: TypedDocumentNode = gql`
+ query {
+ list {
+ id
+ }
+ }
+ `;
+
+ cache.writeQuery({
+ query: listQuery,
+ data: {
+ list: [
+ { __typename: "Item", id: 1 },
+ { __typename: "Item", id: 2 },
+ { __typename: "Item", id: 5 },
+ ],
+ },
+ })
+
+ const renders: string[] = [];
+
+ function List() {
+ renders.push("list");
+ const { loading, data } = useQuery(listQuery);
+ expect(loading).toBe(false);
+ return (
+
+ {data!.list.map(item => - )}
+
+ );
+ }
+
+ function Item(props: { id: number }) {
+ renders.push("item " + props.id);
+ const { complete, data } = useFragment({
+ fragment: ItemFragment,
+ fragmentName: "ItemFragment",
+ from: {
+ __typename: "Item",
+ id: props.id,
+ },
+ });
+ return {complete ? data!.text : "incomplete"};
+ }
+
+ const { getAllByText } = render(
+
+
+
+ );
+
+ function getItemTexts() {
+ return getAllByText(/^Item/).map(
+ li => li.firstChild!.textContent
+ );
+ }
+
+ await waitFor(() => {
+ expect(getItemTexts()).toEqual([
+ "Item #1",
+ "Item #2",
+ "Item #5",
+ ]);
+ });
+
+ expect(renders).toEqual([
+ "list",
+ "item 1",
+ "item 2",
+ "item 5",
+ ]);
+
+ act(() => {
+ cache.writeFragment({
+ fragment: ItemFragment,
+ data: {
+ __typename: "Item",
+ id: 2,
+ text: "Item #2 updated",
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(getItemTexts()).toEqual([
+ "Item #1",
+ "Item #2 updated",
+ "Item #5",
+ ]);
+ });
+
+ expect(renders).toEqual([
+ "list",
+ "item 1",
+ "item 2",
+ "item 5",
+ // Only the second item should have re-rendered.
+ "item 2",
+ ]);
+
+ act(() => {
+ cache.modify({
+ fields: {
+ list(list: Reference[], { readField }) {
+ return [
+ ...list,
+ cache.writeFragment({
+ fragment: ItemFragment,
+ data: {
+ __typename: "Item",
+ id: 3,
+ text: "Item #3 from cache.modify",
+ },
+ })!,
+ cache.writeFragment({
+ fragment: ItemFragment,
+ data: {
+ __typename: "Item",
+ id: 4,
+ text: "Item #4 from cache.modify",
+ },
+ })!,
+ ].sort((ref1, ref2) => (
+ readField- ("id", ref1)! -
+ readField
- ("id", ref2)!
+ ));
+ },
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(getItemTexts()).toEqual([
+ "Item #1",
+ "Item #2 updated",
+ "Item #3 from cache.modify",
+ "Item #4 from cache.modify",
+ "Item #5",
+ ]);
+ });
+
+ expect(renders).toEqual([
+ "list",
+ "item 1",
+ "item 2",
+ "item 5",
+ "item 2",
+ // This is what's new:
+ "list",
+ "item 1",
+ "item 2",
+ "item 3",
+ "item 4",
+ "item 5",
+ ]);
+
+ act(() => {
+ cache.writeFragment({
+ fragment: ItemFragment,
+ data: {
+ __typename: "Item",
+ id: 4,
+ text: "Item #4 updated",
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(getItemTexts()).toEqual([
+ "Item #1",
+ "Item #2 updated",
+ "Item #3 from cache.modify",
+ "Item #4 updated",
+ "Item #5",
+ ]);
+ });
+
+ expect(renders).toEqual([
+ "list",
+ "item 1",
+ "item 2",
+ "item 5",
+ "item 2",
+ "list",
+ "item 1",
+ "item 2",
+ "item 3",
+ "item 4",
+ "item 5",
+ // Only the fourth item should have re-rendered.
+ "item 4",
+ ]);
+
+ expect(cache.extract()).toEqual({
+ "Item:1": {
+ __typename: "Item",
+ id: 1,
+ },
+ "Item:2": {
+ __typename: "Item",
+ id: 2,
+ text: "Item #2 updated",
+ },
+ "Item:3": {
+ __typename: "Item",
+ id: 3,
+ text: "Item #3 from cache.modify",
+ },
+ "Item:4": {
+ __typename: "Item",
+ id: 4,
+ text: "Item #4 updated",
+ },
+ "Item:5": {
+ __typename: "Item",
+ id: 5,
+ },
+ ROOT_QUERY: {
+ __typename: "Query",
+ list: [
+ { __ref: "Item:1" },
+ { __ref: "Item:2" },
+ { __ref: "Item:3" },
+ { __ref: "Item:4" },
+ { __ref: "Item:5" },
+ ],
+ },
+ __META: {
+ extraRootIds: [
+ "Item:2",
+ "Item:3",
+ "Item:4",
+ ],
+ },
+ });
+ });
+
+ it("List can use useFragment with ListFragment", async () => {
+ const cache = new InMemoryCache({
+ typePolicies: {
+ Item: {
+ fields: {
+ text(existing, { readField }) {
+ return existing || `Item #${readField("id")}`;
+ },
+ },
+ },
+ },
+ });
+
+ const listQuery: TypedDocumentNode = gql`
+ query {
+ ...ListFragment
+ list {
+ ...ItemFragment
+ }
+ }
+ ${ListFragment}
+ ${ItemFragment}
+ `;
+
+ cache.writeQuery({
+ query: listQuery,
+ data: {
+ list: [
+ { __typename: "Item", id: 1 },
+ { __typename: "Item", id: 2 },
+ { __typename: "Item", id: 5 },
+ ],
+ extra: "from ListFragment",
+ },
+ })
+
+ const renders: string[] = [];
+
+ function List() {
+ renders.push("list");
+ const { complete, data } = useFragment({
+ fragment: ListFragment,
+ from: { __typename: "Query" },
+ });
+ expect(complete).toBe(true);
+ return (
+
+ {data!.list.map(item => - )}
+
+ );
+ }
+
+ function Item(props: { id: number }) {
+ renders.push("item " + props.id);
+ const { complete, data } = useFragment({
+ fragment: ItemFragment,
+ from: {
+ __typename: "Item",
+ id: props.id,
+ },
+ });
+ return {complete ? data!.text : "incomplete"};
+ }
+
+ const { getAllByText } = render(
+
+
+
+ );
+
+ function getItemTexts() {
+ return getAllByText(/^Item/).map(
+ li => li.firstChild!.textContent
+ );
+ }
+
+ await waitFor(() => {
+ expect(getItemTexts()).toEqual([
+ "Item #1",
+ "Item #2",
+ "Item #5",
+ ]);
+ });
+
+ expect(renders).toEqual([
+ "list",
+ "item 1",
+ "item 2",
+ "item 5",
+ ]);
+
+ act(() => {
+ cache.writeFragment({
+ fragment: ItemFragment,
+ data: {
+ __typename: "Item",
+ id: 2,
+ text: "Item #2 updated",
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(getItemTexts()).toEqual([
+ "Item #1",
+ "Item #2 updated",
+ "Item #5",
+ ]);
+ });
+
+ expect(renders).toEqual([
+ "list",
+ "item 1",
+ "item 2",
+ "item 5",
+ // Only the second item should have re-rendered.
+ "item 2",
+ ]);
+
+ act(() => {
+ cache.modify({
+ fields: {
+ list(list: Reference[], { readField }) {
+ return [
+ ...list,
+ cache.writeFragment({
+ fragment: ItemFragment,
+ data: {
+ __typename: "Item",
+ id: 3,
+ },
+ })!,
+ cache.writeFragment({
+ fragment: ItemFragment,
+ data: {
+ __typename: "Item",
+ id: 4,
+ },
+ })!,
+ ].sort((ref1, ref2) => (
+ readField- ("id", ref1)! -
+ readField
- ("id", ref2)!
+ ));
+ },
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(getItemTexts()).toEqual([
+ "Item #1",
+ "Item #2 updated",
+ "Item #3",
+ "Item #4",
+ "Item #5",
+ ]);
+ });
+
+ expect(renders).toEqual([
+ "list",
+ "item 1",
+ "item 2",
+ "item 5",
+ "item 2",
+ // This is what's new:
+ "list",
+ "item 1",
+ "item 2",
+ "item 3",
+ "item 4",
+ "item 5",
+ ]);
+
+ act(() => {
+ cache.writeFragment({
+ fragment: ItemFragment,
+ data: {
+ __typename: "Item",
+ id: 4,
+ text: "Item #4 updated",
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(getItemTexts()).toEqual([
+ "Item #1",
+ "Item #2 updated",
+ "Item #3",
+ "Item #4 updated",
+ "Item #5",
+ ]);
+ });
+
+ expect(renders).toEqual([
+ "list",
+ "item 1",
+ "item 2",
+ "item 5",
+ "item 2",
+ "list",
+ "item 1",
+ "item 2",
+ "item 3",
+ "item 4",
+ "item 5",
+ // Only the fourth item should have re-rendered.
+ "item 4",
+ ]);
+
+ expect(cache.extract()).toEqual({
+ "Item:1": {
+ __typename: "Item",
+ id: 1,
+ },
+ "Item:2": {
+ __typename: "Item",
+ id: 2,
+ text: "Item #2 updated",
+ },
+ "Item:3": {
+ __typename: "Item",
+ id: 3,
+ },
+ "Item:4": {
+ __typename: "Item",
+ id: 4,
+ text: "Item #4 updated",
+ },
+ "Item:5": {
+ __typename: "Item",
+ id: 5,
+ },
+ ROOT_QUERY: {
+ __typename: "Query",
+ list: [
+ { __ref: "Item:1" },
+ { __ref: "Item:2" },
+ { __ref: "Item:3" },
+ { __ref: "Item:4" },
+ { __ref: "Item:5" },
+ ],
+ extra: "from ListFragment",
+ },
+ __META: {
+ extraRootIds: [
+ "Item:2",
+ "Item:3",
+ "Item:4",
+ ],
+ },
+ });
+ });
+
+ it("useFragment(...).missing is a tree describing missing fields", async () => {
+ const cache = new InMemoryCache({
+ typePolicies: {
+ Query: {
+ fields: {
+ list(items: Reference[] | undefined, { canRead }) {
+ // This filtering happens by default currently in the StoreReader
+ // execSubSelectedArrayImpl method, but I am beginning to question
+ // the wisdom of that automatic filtering. In case we end up
+ // changing the default behavior in the future, I've encoded the
+ // filtering explicitly here, so this test won't be broken.
+ return items && items.filter(canRead);
+ },
+ }
+ }
+ }
+ });
+
+ const wrapper = ({ children }: any) => (
+ {children}
+ );
+
+ const ListAndItemFragments: TypedDocumentNode = gql`
+ fragment ListFragment on Query {
+ list {
+ id
+ ...ItemFragment
+ }
+ }
+ ${ItemFragment}
+ `;
+
+ const ListQuery: TypedDocumentNode = gql`
+ query ListQuery {
+ list {
+ id
+ }
+ }
+ `;
+
+ const ListQueryWithText: TypedDocumentNode = gql`
+ query ListQuery {
+ list {
+ id
+ text
+ }
+ }
+ `;
+
+ const { result: renderResult } = renderHook(
+ () => useFragment({
+ fragment: ListAndItemFragments,
+ fragmentName: "ListFragment",
+ from: { __typename: "Query" },
+ returnPartialData: true,
+ }),
+ { wrapper },
+ );
+
+ function checkHistory(expectedResultCount: number) {
+ // Temporarily disabling this check until we can come up with a better
+ // (more opt-in) system than result.previousResult.previousResult...
+
+ // function historyToArray(
+ // result: UseFragmentResult,
+ // ): UseFragmentResult[] {
+ // const array = result.previousResult
+ // ? historyToArray(result.previousResult)
+ // : [];
+ // array.push(result);
+ // return array;
+ // }
+ // const all = historyToArray(renderResult.current);
+ // expect(all.length).toBe(expectedResultCount);
+ // expect(all).toEqual(renderResult.all);
+
+ // if (renderResult.current.complete) {
+ // expect(renderResult.current).toBe(
+ // renderResult.current.lastCompleteResult
+ // );
+ // } else {
+ // expect(renderResult.current).not.toBe(
+ // renderResult.current.lastCompleteResult
+ // );
+ // }
+ }
+
+ expect(renderResult.current.complete).toBe(false);
+ expect(renderResult.current.data).toEqual({}); // TODO Should be undefined?
+ expect(renderResult.current.missing).toEqual({
+ list: "Can't find field 'list' on ROOT_QUERY object",
+ });
+
+ checkHistory(1);
+
+ const data125 = {
+ list: [
+ { __typename: "Item", id: 1 },
+ { __typename: "Item", id: 2 },
+ { __typename: "Item", id: 5 },
+ ],
+ };
+
+ await act(async () => {
+ cache.writeQuery({
+ query: ListQuery,
+ data: data125,
+ });
+ });
+
+ expect(renderResult.current.complete).toBe(false);
+ expect(renderResult.current.data).toEqual(data125);
+ expect(renderResult.current.missing).toEqual({
+ list: {
+ // Even though Query.list is actually an array in the data, data paths
+ // through this array leading to missing fields potentially involve only
+ // a small/sparse subset of the array's indexes, so we use objects for
+ // the entire MissingTree, to avoid having to worry about sparse arrays.
+ // This also means there's no missing.list.length property, which is
+ // good because "length" could be a name of an actual field that's
+ // missing, and it's somewhat unclear what the length of a sparse array
+ // should be, whereas object keys have a less ambiguous interpretation.
+ 0: { text: "Can't find field 'text' on Item:1 object" },
+ 1: { text: "Can't find field 'text' on Item:2 object" },
+ 2: { text: "Can't find field 'text' on Item:5 object" },
+ },
+ });
+
+ checkHistory(2);
+
+ const data182WithText = {
+ list: [
+ { __typename: "Item", id: 1, text: "oyez1" },
+ { __typename: "Item", id: 8, text: "oyez8" },
+ { __typename: "Item", id: 2, text: "oyez2" },
+ ],
+ };
+
+ await act(async () => {
+ cache.writeQuery({
+ query: ListQueryWithText,
+ data: data182WithText,
+ });
+ });
+
+ expect(renderResult.current.complete).toBe(true);
+ expect(renderResult.current.data).toEqual(data182WithText);
+ expect(renderResult.current.missing).toBeUndefined();
+
+ checkHistory(3);
+
+ await act(async () => cache.batch({
+ update(cache) {
+ cache.evict({
+ id: cache.identify({
+ __typename: "Item",
+ id: 8,
+ }),
+ });
+
+ cache.evict({
+ id: cache.identify({
+ __typename: "Item",
+ id: 2,
+ }),
+ fieldName: "text",
+ });
+ },
+ }));
+
+ expect(renderResult.current.complete).toBe(false);
+ expect(renderResult.current.data).toEqual({
+ list: [
+ { __typename: "Item", id: 1, text: "oyez1" },
+ { __typename: "Item", id: 2 },
+ ],
+ });
+ expect(renderResult.current.missing).toEqual({
+ // TODO Figure out why Item:8 is not represented here. Likely because of
+ // auto-filtering of dangling references from arrays, but that should
+ // still be reflected here, if possible.
+ list: {
+ 1: {
+ text: "Can't find field 'text' on Item:2 object",
+ },
+ },
+ });
+
+ checkHistory(4);
+
+ expect(cache.extract()).toEqual({
+ "Item:1": {
+ __typename: "Item",
+ id: 1,
+ text: "oyez1",
+ },
+ "Item:2": {
+ __typename: "Item",
+ id: 2,
+ },
+ "Item:5": {
+ __typename: "Item",
+ id: 5,
+ },
+ ROOT_QUERY: {
+ __typename: "Query",
+ list: [
+ { __ref: "Item:1" },
+ { __ref: "Item:8" },
+ { __ref: "Item:2" },
+ ],
+ },
+ });
+
+ expect(cache.gc().sort()).toEqual(["Item:5"]);
+ });
+});
diff --git a/src/react/hooks/index.ts b/src/react/hooks/index.ts
index 5201dc6e645..b7e45dfeda8 100644
--- a/src/react/hooks/index.ts
+++ b/src/react/hooks/index.ts
@@ -6,3 +6,4 @@ export * from './useMutation';
export { useQuery } from './useQuery';
export * from './useSubscription';
export * from './useReactiveVar';
+export * from './useFragment';
diff --git a/src/react/hooks/useFragment.ts b/src/react/hooks/useFragment.ts
new file mode 100644
index 00000000000..80e1b4965c3
--- /dev/null
+++ b/src/react/hooks/useFragment.ts
@@ -0,0 +1,114 @@
+import { useRef } from "react";
+import { equal } from "@wry/equality";
+
+import { mergeDeepArray } from "../../utilities";
+import {
+ Cache,
+ Reference,
+ StoreObject,
+ MissingTree,
+} from "../../cache";
+
+import { useApolloClient } from "./useApolloClient";
+import { useSyncExternalStore } from "./useSyncExternalStore";
+
+export interface UseFragmentOptions
+extends Omit<
+ Cache.DiffOptions,
+ | "id"
+ | "query"
+ | "optimistic"
+>, Omit<
+ Cache.ReadFragmentOptions,
+ | "id"
+> {
+ from: StoreObject | Reference | string;
+ // Override this field to make it optional (default: true).
+ optimistic?: boolean;
+}
+
+// Since the above definition of UseFragmentOptions can be hard to parse without
+// help from TypeScript/VSCode, here are the intended fields and their types.
+// Uncomment this code to check that it's consistent with the definition above.
+//
+// export interface UseFragmentOptions {
+// from: string | StoreObject | Reference;
+// fragment: DocumentNode | TypedDocumentNode;
+// fragmentName?: string;
+// optimistic?: boolean;
+// variables?: TVars;
+// previousResult?: any;
+// returnPartialData?: boolean;
+// canonizeResults?: boolean;
+// }
+
+export interface UseFragmentResult {
+ data: TData | undefined;
+ complete: boolean;
+ missing?: MissingTree;
+ previousResult?: UseFragmentResult;
+ lastCompleteResult?: UseFragmentResult;
+}
+
+export function useFragment(
+ options: UseFragmentOptions,
+): UseFragmentResult {
+ const { cache } = useApolloClient();
+
+ const {
+ fragment,
+ fragmentName,
+ from,
+ optimistic = true,
+ ...rest
+ } = options;
+
+ const diffOptions: Cache.DiffOptions = {
+ ...rest,
+ id: typeof from === "string" ? from : cache.identify(from),
+ query: cache["getFragmentDoc"](fragment, fragmentName),
+ optimistic,
+ };
+
+ const resultRef = useRef>();
+ let latestDiff = cache.diff(diffOptions);
+
+ return useSyncExternalStore(
+ forceUpdate => {
+ let immediate = true;
+ return cache.watch({
+ ...diffOptions,
+ immediate,
+ callback(diff) {
+ if (!immediate && !equal(diff, latestDiff)) {
+ resultRef.current = diffToResult(latestDiff = diff);
+ forceUpdate();
+ }
+ immediate = false;
+ },
+ });
+ },
+
+ () => {
+ return resultRef.current ||
+ (resultRef.current = diffToResult(latestDiff));
+ },
+ );
+}
+
+function diffToResult(
+ diff: Cache.DiffResult,
+): UseFragmentResult {
+ const result: UseFragmentResult = {
+ data: diff.result,
+ complete: !!diff.complete,
+ };
+
+ if (diff.missing) {
+ result.missing = mergeDeepArray(
+ diff.missing.map(error => error.missing),
+ );
+ }
+
+ return result;
+}