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; +}