diff --git a/CHANGELOG.md b/CHANGELOG.md index d339d242cc9..cc6244d9387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - Prevent full reobservation of queries affected by optimistic mutation updates, while still delivering results from the cache.
[@benjamn](https://github.com/benjamn) in [#6854](https://github.com/apollographql/apollo-client/pull/6854) +- Implement `useReactiveVar` hook for consuming reactive variables in React components.
+ [@benjamn](https://github.com/benjamn) in [#6867](https://github.com/apollographql/apollo-client/pull/6867) + ## Apollo Client 3.1.3 ## Bug Fixes diff --git a/docs/source/local-state/managing-state-with-field-policies.mdx b/docs/source/local-state/managing-state-with-field-policies.mdx index a8d0448eeb4..e25ed7b8cec 100644 --- a/docs/source/local-state/managing-state-with-field-policies.mdx +++ b/docs/source/local-state/managing-state-with-field-policies.mdx @@ -147,15 +147,14 @@ This `read` function returns the value of our reactive variable whenever `cartIt Now, let's create a button component that enables the user to add a product to their cart: -```jsx{8}:title=AddToCartButton.js +```jsx{7}:title=AddToCartButton.js import { cartItemsVar } from './cache'; // ... other imports export function AddToCartButton({ productId }) { - const cartItems = cartItemsVar(); return (
-
@@ -167,8 +166,6 @@ On click, this button updates the value of `cartItemsVar` to append the button's Here's a `Cart` component that uses the `GET_CART_ITEMS` query and therefore refreshes automatically whenever the value of `cartItemsVar` changes: - - ```jsx:title=Cart.js export const GET_CART_ITEMS = gql` query GetCartItems { @@ -199,7 +196,32 @@ export function Cart() { } ``` - +Alternatively, you can read directly from a reactive variable using the `useReactiveVar` hook introduced in Apollo Client 3.2.0: + +```jsx:title=Cart.js +import { useReactiveVar } from '@apollo/client'; + +export function Cart() { + const cartItems = useReactiveVar(cartItemsVar); + + return ( +
+
My Cart
+ {cartItems.length === 0 ? ( +

No items in your cart

+ ) : ( + + {cartItems.map(productId => ( + + ))} + + )} +
+ ); +} +``` + +As in the earlier `useQuery` example, whenever the `cartItemsVar` variable is updated, any currently-mounted `Cart` components will rerender. Calling `cartItemsVar()` without `useReactiveVar` will not capture this dependency, so future variable updates will not rerender the component. Both of these approaches are useful in different situations. ### Storing local state in the cache diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 91326b9ce76..6f7153ca44b 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -52,6 +52,7 @@ Array [ "useLazyQuery", "useMutation", "useQuery", + "useReactiveVar", "useSubscription", ] `; @@ -216,6 +217,7 @@ Array [ "useLazyQuery", "useMutation", "useQuery", + "useReactiveVar", "useSubscription", ] `; @@ -262,6 +264,7 @@ Array [ "useLazyQuery", "useMutation", "useQuery", + "useReactiveVar", "useSubscription", ] `; diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index b495e002d51..f2b7c22b8eb 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -3,6 +3,7 @@ import gql, { disableFragmentWarnings } from 'graphql-tag'; import { stripSymbols } from '../../../utilities/testing/stripSymbols'; import { cloneDeep } from '../../../utilities/common/cloneDeep'; import { makeReference, Reference, makeVar, TypedDocumentNode, isReference } from '../../../core'; +import { Cache } from '../../../cache'; import { InMemoryCache, InMemoryCacheConfig } from '../inMemoryCache'; disableFragmentWarnings(); @@ -2495,6 +2496,105 @@ describe("ReactiveVar and makeVar", () => { }, }); }); + + it("should broadcast only once for multiple reads of same variable", () => { + const nameVar = makeVar("Ben"); + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + name() { + return nameVar(); + }, + }, + }, + }, + }); + + // TODO This should not be necessary, but cache.readQuery currently + // returns null if we read a query before writing any queries. + cache.restore({ + ROOT_QUERY: {} + }); + + const broadcast = cache["broadcastWatches"]; + let broadcastCount = 0; + cache["broadcastWatches"] = function () { + ++broadcastCount; + return broadcast.apply(this, arguments); + }; + + const query = gql` + query { + name1: name + name2: name + } + `; + + const watchDiffs: Cache.DiffResult[] = []; + cache.watch({ + query, + optimistic: true, + callback(diff) { + watchDiffs.push(diff); + }, + }); + + const benResult = cache.readQuery({ query }); + expect(benResult).toEqual({ + name1: "Ben", + name2: "Ben", + }); + + expect(watchDiffs).toEqual([]); + + expect(broadcastCount).toBe(0); + nameVar("Jenn"); + expect(broadcastCount).toBe(1); + + const jennResult = cache.readQuery({ query }); + expect(jennResult).toEqual({ + name1: "Jenn", + name2: "Jenn", + }); + + expect(watchDiffs).toEqual([ + { + complete: true, + result: { + name1: "Jenn", + name2: "Jenn", + }, + }, + ]); + + expect(broadcastCount).toBe(1); + nameVar("Hugh"); + expect(broadcastCount).toBe(2); + + const hughResult = cache.readQuery({ query }); + expect(hughResult).toEqual({ + name1: "Hugh", + name2: "Hugh", + }); + + expect(watchDiffs).toEqual([ + { + complete: true, + result: { + name1: "Jenn", + name2: "Jenn", + }, + }, + { + complete: true, + result: { + name1: "Hugh", + name2: "Hugh", + }, + }, + ]); + }); }); describe('TypedDocumentNode', () => { diff --git a/src/cache/inmemory/reactiveVars.ts b/src/cache/inmemory/reactiveVars.ts index c702104aa59..f063bb03546 100644 --- a/src/cache/inmemory/reactiveVars.ts +++ b/src/cache/inmemory/reactiveVars.ts @@ -3,7 +3,12 @@ import { dep } from "optimism"; import { InMemoryCache } from "./inMemoryCache"; import { ApolloCache } from '../../core'; -export type ReactiveVar = (newValue?: T) => T; +export interface ReactiveVar { + (newValue?: T): T; + onNextChange(listener: ReactiveListener): () => void; +} + +export type ReactiveListener = (value: T) => any; const varDep = dep>(); @@ -11,17 +16,34 @@ const varDep = dep>(); // called in Policies#readField. export const cacheSlot = new Slot>(); +// A listener function could in theory cause another listener to be added +// to the set while we're iterating over it, so it's important to commit +// to the original elements of the set before we begin iterating. See +// iterateObserversSafely for another example of this pattern. +function consumeAndIterate(set: Set, callback: (item: T) => any) { + const items: T[] = []; + set.forEach(item => items.push(item)); + set.clear(); + items.forEach(callback); +} + export function makeVar(value: T): ReactiveVar { const caches = new Set>(); + const listeners = new Set>(); - return function rv(newValue) { + const rv: ReactiveVar = function (newValue) { if (arguments.length > 0) { if (value !== newValue) { value = newValue!; + // First, invalidate any fields with custom read functions that + // consumed this variable, so query results involving those fields + // will be recomputed the next time we read them. varDep.dirty(rv); - // Trigger broadcast for any caches that were previously involved - // in reading this variable. + // Next, broadcast changes to any caches that have previously read + // from this variable. caches.forEach(broadcast); + // Finally, notify any listeners added via rv.onNextChange. + consumeAndIterate(listeners, listener => listener(value)); } } else { // When reading from the variable, obtain the current cache from @@ -34,12 +56,21 @@ export function makeVar(value: T): ReactiveVar { return value; }; + + rv.onNextChange = listener => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + return rv; } type Broadcastable = ApolloCache & { // This method is protected in InMemoryCache, which we are ignoring, but // we still want some semblance of type safety when we call it. - broadcastWatches: InMemoryCache["broadcastWatches"]; + broadcastWatches?: InMemoryCache["broadcastWatches"]; }; function broadcast(cache: Broadcastable) { diff --git a/src/react/hooks/__tests__/useReactiveVar.test.tsx b/src/react/hooks/__tests__/useReactiveVar.test.tsx new file mode 100644 index 00000000000..2cef7bfe836 --- /dev/null +++ b/src/react/hooks/__tests__/useReactiveVar.test.tsx @@ -0,0 +1,183 @@ +import React from "react"; +import { render, wait, act } from "@testing-library/react"; + +import { itAsync } from "../../../testing"; +import { makeVar } from "../../../core"; +import { useReactiveVar } from "../useReactiveVar"; + +describe("useReactiveVar Hook", () => { + itAsync("works with one component", (resolve, reject) => { + const counterVar = makeVar(0); + let renderCount = 0; + + function Component() { + const count = useReactiveVar(counterVar); + + switch (++renderCount) { + case 1: + expect(count).toBe(0); + act(() => { + counterVar(count + 1); + }); + break; + case 2: + expect(count).toBe(1); + act(() => { + counterVar(counterVar() + 2); + }); + break; + case 3: + expect(count).toBe(3); + break; + default: + reject(`too many (${renderCount}) renders`); + } + + return null; + } + + render(); + + return wait(() => { + expect(renderCount).toBe(3); + expect(counterVar()).toBe(3); + }).then(resolve, reject); + }); + + itAsync("works when two components share a variable", async (resolve, reject) => { + const counterVar = makeVar(0); + + let parentRenderCount = 0; + function Parent() { + const count = useReactiveVar(counterVar); + + switch (++parentRenderCount) { + case 1: + expect(count).toBe(0); + break; + case 2: + expect(count).toBe(1); + break; + case 3: + expect(count).toBe(11); + break; + default: + reject(`too many (${parentRenderCount}) parent renders`); + } + + return ; + } + + let childRenderCount = 0; + function Child() { + const count = useReactiveVar(counterVar); + + switch (++childRenderCount) { + case 1: + expect(count).toBe(0); + break; + case 2: + expect(count).toBe(1); + break; + case 3: + expect(count).toBe(11); + break; + default: + reject(`too many (${childRenderCount}) child renders`); + } + + return null; + } + + render(); + + await wait(() => { + expect(parentRenderCount).toBe(1); + expect(childRenderCount).toBe(1); + }); + + expect(counterVar()).toBe(0); + act(() => { + counterVar(1); + }); + + await wait(() => { + expect(parentRenderCount).toBe(2); + expect(childRenderCount).toBe(2); + }); + + expect(counterVar()).toBe(1); + act(() => { + counterVar(counterVar() + 10); + }); + + await wait(() => { + expect(parentRenderCount).toBe(3); + expect(childRenderCount).toBe(3); + }); + + expect(counterVar()).toBe(11); + + resolve(); + }); + + itAsync("does not update if component has been unmounted", (resolve, reject) => { + const counterVar = makeVar(0); + let renderCount = 0; + let attemptedUpdateAfterUnmount = false; + + function Component() { + const count = useReactiveVar(counterVar); + + switch (++renderCount) { + case 1: + expect(count).toBe(0); + act(() => { + counterVar(count + 1); + }); + break; + case 2: + expect(count).toBe(1); + act(() => { + counterVar(counterVar() + 2); + }); + break; + case 3: + expect(count).toBe(3); + setTimeout(() => { + unmount(); + setTimeout(() => { + counterVar(counterVar() * 2); + attemptedUpdateAfterUnmount = true; + }, 10); + }, 10); + break; + default: + reject(`too many (${renderCount}) renders`); + } + + return null; + } + + // To detect updates of unmounted components, we have to monkey-patch + // the console.error method. + const consoleErrorArgs: any[][] = []; + const { error } = console; + console.error = function (...args: any[]) { + consoleErrorArgs.push(args); + return error.apply(this, args); + }; + + const { unmount } = render(); + + return wait(() => { + expect(attemptedUpdateAfterUnmount).toBe(true); + }).then(() => { + expect(renderCount).toBe(3); + expect(counterVar()).toBe(6); + expect(consoleErrorArgs).toEqual([]); + }).finally(() => { + console.error = error; + }).then(resolve, reject); + }); +}); diff --git a/src/react/hooks/index.ts b/src/react/hooks/index.ts index b301bfed59d..a9a323f7fc1 100644 --- a/src/react/hooks/index.ts +++ b/src/react/hooks/index.ts @@ -3,3 +3,4 @@ export * from './useLazyQuery'; export * from './useMutation'; export * from './useQuery'; export * from './useSubscription'; +export * from './useReactiveVar'; diff --git a/src/react/hooks/useReactiveVar.ts b/src/react/hooks/useReactiveVar.ts new file mode 100644 index 00000000000..4bb277413be --- /dev/null +++ b/src/react/hooks/useReactiveVar.ts @@ -0,0 +1,15 @@ +import { useState, useEffect } from 'react'; +import { ReactiveVar } from '../../core'; + +export function useReactiveVar(rv: ReactiveVar): T { + const value = rv(); + // We don't actually care what useState thinks the value of the variable + // is, so we take only the update function from the returned array. + const mute = rv.onNextChange(useState(value)[1]); + // Once the component is unmounted, ignore future updates. Note that the + // useEffect function returns the mute function without calling it, + // allowing it to be called when the component unmounts. This is + // equivalent to useEffect(() => () => mute(), []), but shorter. + useEffect(() => mute, []); + return value; +}