From 4438ab79029e3b2d4a2e368e30046d86375b0528 Mon Sep 17 00:00:00 2001 From: alessia Date: Mon, 10 Apr 2023 15:09:19 -0400 Subject: [PATCH] feat: add useBackgroundQuery and useReadQuery hooks --- .prettierignore | 2 + config/jest.config.js | 3 +- src/react/cache/QuerySubscription.ts | 1 + src/react/cache/SuspenseCache.ts | 2 +- .../__tests__/useBackgroundQuery.test.tsx | 566 ++++++++++++++++++ src/react/hooks/index.ts | 1 + src/react/hooks/useBackgroundQuery.ts | 159 +++++ 7 files changed, 732 insertions(+), 2 deletions(-) create mode 100644 src/react/hooks/__tests__/useBackgroundQuery.test.tsx create mode 100644 src/react/hooks/useBackgroundQuery.ts diff --git a/.prettierignore b/.prettierignore index d44d0d6d29d..dfc5c9cbcd6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -56,8 +56,10 @@ src/react/hooks/* !src/react/hooks/internal !src/react/hooks/useSuspenseCache.ts !src/react/hooks/useSuspenseQuery.ts +!src/react/hooks/useBackgroundQuery.ts ## Allowed React hook tests !src/react/hooks/__tests__/ src/react/hooks/__tests__/* !src/react/hooks/__tests__/useSuspenseQuery.test.tsx +!src/react/hooks/__tests__/useBackgroundQuery.test.tsx diff --git a/config/jest.config.js b/config/jest.config.js index 7c060fad20e..34b9940ea71 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -29,7 +29,8 @@ const react17TestFileIgnoreList = [ ignoreTSFiles, // For now, we only support useSuspenseQuery with React 18, so no need to test // it with React 17 - 'src/react/hooks/__tests__/useSuspenseQuery.test.tsx' + 'src/react/hooks/__tests__/useSuspenseQuery.test.tsx', + 'src/react/hooks/__tests__/useBackgroundQuery.test.tsx' ] const tsStandardConfig = { diff --git a/src/react/cache/QuerySubscription.ts b/src/react/cache/QuerySubscription.ts index 4c2503d6d01..5000bd5d242 100644 --- a/src/react/cache/QuerySubscription.ts +++ b/src/react/cache/QuerySubscription.ts @@ -27,6 +27,7 @@ export class QuerySubscription { public result: ApolloQueryResult; public readonly observable: ObservableQuery; + public version: 'main' | 'network' = 'main'; public promises: { main: Promise>; network?: Promise>; diff --git a/src/react/cache/SuspenseCache.ts b/src/react/cache/SuspenseCache.ts index b9341f85451..2edc8a5fb9f 100644 --- a/src/react/cache/SuspenseCache.ts +++ b/src/react/cache/SuspenseCache.ts @@ -50,4 +50,4 @@ export class SuspenseCache { return this.subscriptions.get(stableCacheKey)! as QuerySubscription; } -} +} \ No newline at end of file diff --git a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx new file mode 100644 index 00000000000..bd262635bf1 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx @@ -0,0 +1,566 @@ +import React, { Suspense } from 'react'; +import { render, screen, renderHook } from '@testing-library/react'; +import { ErrorBoundary, ErrorBoundaryProps } from 'react-error-boundary'; +import { + gql, + ApolloClient, + NormalizedCacheObject, + ApolloQueryResult, + TypedDocumentNode, +} from '../../../core'; +import { MockedProvider, MockLink, mockSingleLink } from '../../../testing'; +import { + useBackgroundQuery_experimental as useBackgroundQuery, + useReadQuery, +} from '../useBackgroundQuery'; +import { ApolloProvider } from '../../context'; +import { SuspenseCache } from '../../cache'; +import { InMemoryCache } from '../../../cache'; +import { QuerySubscription } from '../../cache/QuerySubscription'; + +// function wait(delay: number) { +// return new Promise((resolve) => setTimeout(resolve, delay)); +// } + +function renderIntegrationTest({ + client, + variables, +}: { + client?: ApolloClient; + variables?: Record; +} = {}) { + const query: TypedDocumentNode = gql` + query SimpleQuery { + foo { + bar + } + } + `; + + const suspenseCache = new SuspenseCache(); + const mocks = [ + { + request: { query }, + result: { data: { foo: { bar: 'hello' } } }, + }, + ]; + const _client = + client || + new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + interface Renders { + errors: Error[]; + errorCount: number; + suspenseCount: number; + count: number; + } + const renders: Renders = { + errors: [], + errorCount: 0, + suspenseCount: 0, + count: 0, + }; + const errorBoundaryProps: ErrorBoundaryProps = { + fallback:
Error
, + onError: (error) => { + renders.errorCount++; + renders.errors.push(error); + }, + }; + + interface QueryData { + foo: { bar: string }; + } + + function SuspenseFallback() { + renders.suspenseCount++; + return
loading
; + } + + function Child({ + subscription, + }: { + subscription: QuerySubscription; + }) { + const { data } = useReadQuery(subscription); + return
{data.foo.bar}
; + } + + function Parent() { + const { subscription } = useBackgroundQuery(query); + // count renders in the parent component + renders.count++; + return ; + } + + function ParentWithVariables({ + variables, + }: { + variables: Record; + }) { + const { subscription } = useBackgroundQuery(query, { variables }); + // count renders in the parent component + renders.count++; + return ; + } + + function App({ variables }: { variables?: Record }) { + return ( + + + }> + {variables ? ( + + ) : ( + + )} + + + + ); + } + + const { ...rest } = render(); + return { ...rest, query, client: _client, renders }; +} + +function renderVariablesIntegrationTest({ + variables, +}: { + variables: { id: string }; +}) { + const CHARACTERS = ['Spider-Man', 'Black Widow', 'Iron Man', 'Hulk']; + + interface QueryData { + character: { + id: string; + name: string; + }; + } + + interface QueryVariables { + id: string; + } + + const query: TypedDocumentNode = gql` + query CharacterQuery($id: ID!) { + character(id: $id) { + id + name + } + } + `; + + const mocks = CHARACTERS.map((name, index) => ({ + request: { query, variables: { id: String(index + 1) } }, + result: { data: { character: { id: String(index + 1), name } } }, + })); + const suspenseCache = new SuspenseCache(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + interface Renders { + errors: Error[]; + errorCount: number; + suspenseCount: number; + count: number; + } + const renders: Renders = { + errors: [], + errorCount: 0, + suspenseCount: 0, + count: 0, + }; + const errorBoundaryProps: ErrorBoundaryProps = { + fallback:
Error
, + onError: (error) => { + renders.errorCount++; + renders.errors.push(error); + }, + }; + + function SuspenseFallback() { + renders.suspenseCount++; + return
loading
; + } + + function Child({ + subscription, + }: { + subscription: QuerySubscription; + }) { + const result = useReadQuery(subscription); + return ( +
+ {result?.data?.character.id} - {result?.data?.character.name} +
+ ); + } + + function ParentWithVariables({ variables }: { variables: QueryVariables }) { + const { subscription } = useBackgroundQuery(query, { variables }); + // count renders in the parent component + renders.count++; + return ; + } + + function App({ variables }: { variables: QueryVariables }) { + return ( + + + }> + + + + + ); + } + + const { ...rest } = render(); + return { ...rest, query, App, client, renders }; +} + +describe('useBackgroundQuery', () => { + it('fetches a simple query with minimal config', async () => { + const query = gql` + query { + hello + } + `; + const suspenseCache = new SuspenseCache(); + const mocks = [ + { + request: { query }, + result: { data: { hello: 'world 1' } }, + }, + ]; + const { result } = renderHook(() => useBackgroundQuery(query), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + const { subscription } = result.current; + + const _result = await subscription.promises.main; + + expect(_result).toEqual({ + data: { hello: 'world 1' }, + loading: false, + networkStatus: 7, + }); + }); + + describe('fetch policy behaviors', () => { + describe.skip('cache-and-network', () => { + // TODO: should return cache data first if it exists + it('returns initial cache data followed by network data', async () => { + const query = gql` + { + hello + } + `; + const suspenseCache = new SuspenseCache(); + const cache = new InMemoryCache(); + const link = mockSingleLink({ + request: { query }, + result: { data: { hello: 'from link' } }, + delay: 20, + }); + + const client = new ApolloClient({ + link, + cache, + }); + + cache.writeQuery({ query, data: { hello: 'from cache' } }); + + const { result } = renderHook( + () => useBackgroundQuery(query, { fetchPolicy: 'cache-and-network' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + const { subscription } = result.current; + + const _result = await subscription.promises.main; + + expect(_result).toEqual({ + data: { hello: 'from link' }, + loading: false, + networkStatus: 7, + }); + }); + }); + describe('cache-first', () => { + it('all data is present in the cache, no network request is made', async () => { + const query = gql` + { + hello + } + `; + const suspenseCache = new SuspenseCache(); + const cache = new InMemoryCache(); + const link = mockSingleLink({ + request: { query }, + result: { data: { hello: 'from link' } }, + delay: 20, + }); + + const client = new ApolloClient({ + link, + cache, + }); + + cache.writeQuery({ query, data: { hello: 'from cache' } }); + + const { result } = renderHook( + () => useBackgroundQuery(query, { fetchPolicy: 'cache-first' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + const { subscription } = result.current; + + const _result = await subscription.promises.main; + + expect(_result).toEqual({ + data: { hello: 'from cache' }, + loading: false, + networkStatus: 7, + }); + }); + it('partial data is present in the cache so it is ignored and network request is made', async () => { + const query = gql` + { + hello + foo + } + `; + const suspenseCache = new SuspenseCache(); + const cache = new InMemoryCache(); + const link = mockSingleLink({ + request: { query }, + result: { data: { hello: 'from link', foo: 'bar' } }, + delay: 20, + }); + + const client = new ApolloClient({ + link, + cache, + }); + + // we expect a "Missing field 'foo' while writing result..." error + // when writing hello to the cache, so we'll silence the console.error + const originalConsoleError = console.error; + console.error = () => { + /* noop */ + }; + cache.writeQuery({ query, data: { hello: 'from cache' } }); + console.error = originalConsoleError; + + const { result } = renderHook( + () => useBackgroundQuery(query, { fetchPolicy: 'cache-first' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + const { subscription } = result.current; + + const _result = await subscription.promises.main; + + expect(_result).toEqual({ + data: { foo: 'bar', hello: 'from link' }, + loading: false, + networkStatus: 7, + }); + }); + }); + describe('network-only', () => { + it('existing data in the cache is ignored', async () => { + const query = gql` + { + hello + } + `; + const suspenseCache = new SuspenseCache(); + const cache = new InMemoryCache(); + const link = mockSingleLink({ + request: { query }, + result: { data: { hello: 'from link' } }, + delay: 20, + }); + + const client = new ApolloClient({ + link, + cache, + }); + + cache.writeQuery({ query, data: { hello: 'from cache' } }); + + const { result } = renderHook( + () => useBackgroundQuery(query, { fetchPolicy: 'network-only' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + const { subscription } = result.current; + + const _result = await subscription.promises.main; + + expect(_result).toEqual({ + data: { hello: 'from link' }, + loading: false, + networkStatus: 7, + }); + expect(client.cache.extract()).toEqual({ + ROOT_QUERY: { __typename: 'Query', hello: 'from link' }, + }); + }); + }); + describe('no-cache', () => { + it('fetches data from the network but does not update the cache', async () => { + const query = gql` + { + hello + } + `; + const suspenseCache = new SuspenseCache(); + const cache = new InMemoryCache(); + const link = mockSingleLink({ + request: { query }, + result: { data: { hello: 'from link' } }, + delay: 20, + }); + + const client = new ApolloClient({ + link, + cache, + }); + + cache.writeQuery({ query, data: { hello: 'from cache' } }); + + const { result } = renderHook( + () => useBackgroundQuery(query, { fetchPolicy: 'no-cache' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + const { subscription } = result.current; + + const _result = await subscription.promises.main; + + expect(_result).toEqual({ + data: { hello: 'from link' }, + loading: false, + networkStatus: 7, + }); + // ...but not updated in the cache + expect(client.cache.extract()).toEqual({ + ROOT_QUERY: { __typename: 'Query', hello: 'from cache' }, + }); + }); + }); + }); + + describe('integration tests', () => { + it('suspends and renders hello', async () => { + const { renders } = renderIntegrationTest(); + // ensure the hook suspends immediately + expect(renders.suspenseCount).toBe(1); + expect(screen.getByText('loading')).toBeInTheDocument(); + + // the parent component re-renders when promise fulfilled + expect(await screen.findByText('hello')).toBeInTheDocument(); + expect(renders.count).toBe(2); + }); + }); + + it('reacts to cache updates', async () => { + const { renders, client, query } = renderIntegrationTest(); + + expect(renders.suspenseCount).toBe(1); + expect(screen.getByText('loading')).toBeInTheDocument(); + + // the parent component re-renders when promise fulfilled + expect(await screen.findByText('hello')).toBeInTheDocument(); + expect(renders.count).toBe(2); + + client.writeQuery({ + query, + data: { foo: { bar: 'baz' } }, + }); + + // the parent component re-renders when promise fulfilled + expect(await screen.findByText('baz')).toBeInTheDocument(); + + expect(renders.suspenseCount).toBe(1); + + client.writeQuery({ + query, + data: { foo: { bar: 'bat' } }, + }); + + expect(await screen.findByText('bat')).toBeInTheDocument(); + + expect(renders.suspenseCount).toBe(1); + }); + + it.only('reacts to variables updates', async () => { + const { App, renders, rerender } = renderVariablesIntegrationTest({ + variables: { id: '1' }, + }); + + expect(renders.suspenseCount).toBe(1); + expect(screen.getByText('loading')).toBeInTheDocument(); + + expect(await screen.findByText('1 - Spider-Man')).toBeInTheDocument(); + + rerender(); + + expect(renders.suspenseCount).toBe(2); + expect(screen.getByText('loading')).toBeInTheDocument(); + + expect(await screen.findByText('2 - Black Widow')).toBeInTheDocument(); + }); + + // todo: do the same on refetch + // it('suspends when partial data is in the cache (test all cache policies)', async () => { + + // }); + + // TODO: refetch + startTransition aren't working together... yet :D + // it('uses useTransition to determine whether to resuspend on refetch', async () => { + + // }); +}); diff --git a/src/react/hooks/index.ts b/src/react/hooks/index.ts index 6597c734248..7ffefaf7e95 100644 --- a/src/react/hooks/index.ts +++ b/src/react/hooks/index.ts @@ -8,3 +8,4 @@ export * from './useSubscription'; export * from './useReactiveVar'; export * from './useFragment'; export * from './useSuspenseQuery'; +export * from './useBackgroundQuery'; diff --git a/src/react/hooks/useBackgroundQuery.ts b/src/react/hooks/useBackgroundQuery.ts new file mode 100644 index 00000000000..dba151684b5 --- /dev/null +++ b/src/react/hooks/useBackgroundQuery.ts @@ -0,0 +1,159 @@ +import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; +import { + ApolloClient, + DocumentNode, + OperationVariables, + TypedDocumentNode, + WatchQueryOptions, + ApolloQueryResult, + ObservableQuery, +} from '../../core'; +import { compact } from '../../utilities'; +import { invariant } from '../../utilities/globals'; +import { useApolloClient } from './useApolloClient'; +import { QuerySubscription } from '../cache/QuerySubscription'; +import { useSyncExternalStore } from './useSyncExternalStore'; +import { + SuspenseQueryHookOptions, + ObservableQueryFields, +} from '../types/types'; +import { useDeepMemo, useStrictModeSafeCleanupEffect, __use } from './internal'; +import { useSuspenseCache } from './useSuspenseCache'; +import { SuspenseCache } from '../cache'; +import { canonicalStringify } from '../../cache'; + +const DEFAULT_FETCH_POLICY = 'cache-first'; +const DEFAULT_SUSPENSE_POLICY = 'always'; +const DEFAULT_ERROR_POLICY = 'none'; + +////////////////////// +// ⌘C + ⌘P from uSQ // +////////////////////// +type FetchMoreFunction< + TData, + TVariables extends OperationVariables +> = ObservableQueryFields['fetchMore']; + +type RefetchFunction< + TData, + TVariables extends OperationVariables +> = ObservableQueryFields['refetch']; + +interface UseWatchQueryOptionsHookOptions< + TData, + TVariables extends OperationVariables +> { + query: DocumentNode | TypedDocumentNode; + options: SuspenseQueryHookOptions; + client: ApolloClient; +} + +function useTrackedSubscriptions(subscription: QuerySubscription) { + const trackedSubscriptions = useRef(new Set()); + + trackedSubscriptions.current.add(subscription); + + return function dispose() { + trackedSubscriptions.current.forEach((sub) => sub.dispose()); + }; +} + +// posible re-naming: useSuspenseWatchQueryOptions to indicate +// they're a bit more limited due to Suspense use cases +function useWatchQueryOptions({ + query, + options, + client, +}: UseWatchQueryOptionsHookOptions): WatchQueryOptions< + TVariables, + TData +> { + const { watchQuery: defaultOptions } = client.defaultOptions; + + const watchQueryOptions = useDeepMemo< + WatchQueryOptions + >(() => { + return { + ...options, + query, + notifyOnNetworkStatusChange: false, + }; + }, [options, query, defaultOptions]); + + return watchQueryOptions; +} +///////// +// End // +///////// +export interface UseBackgroundQueryResult< + TData = any, + TVariables extends OperationVariables = OperationVariables +> { + subscription: QuerySubscription; + // observable: ObservableQuery; + fetchMore: ObservableQueryFields['fetchMore']; + refetch: ObservableQueryFields['refetch']; +} + +export function useBackgroundQuery_experimental< + TData = any, + TVariables extends OperationVariables = OperationVariables +>( + query: DocumentNode | TypedDocumentNode, + options: SuspenseQueryHookOptions = Object.create(null) +): UseBackgroundQueryResult { + const suspenseCache = useSuspenseCache(); + const client = useApolloClient(options.client); + const watchQueryOptions = useWatchQueryOptions({ query, options, client }); + const { variables } = watchQueryOptions; + const { queryKey = [] } = options; + + const cacheKey = ( + [client, query, canonicalStringify(variables)] as any[] + ).concat(queryKey); + + const subscription = suspenseCache.getSubscription(cacheKey, () => + client.watchQuery(watchQueryOptions) + ); + + const dispose = useTrackedSubscriptions(subscription); + useStrictModeSafeCleanupEffect(dispose); + + const fetchMore: FetchMoreFunction = useCallback( + (options) => subscription.fetchMore(options) as any, + [subscription] + ); + + const refetch: RefetchFunction = useCallback( + (variables) => subscription.refetch(variables), + [subscription] + ); + const version = 'main'; + subscription.version = version; + return useMemo(() => { + return { + // this won't work with refetch/fetchMore... + subscription, + fetchMore, + refetch, + }; + }, [subscription, fetchMore, refetch]); +} + +export function useReadQuery(subscription: QuerySubscription) { + const [, forceUpdate] = useState(0); + const promise = + subscription.promises[subscription.version] || subscription.promises.main; + + useEffect(() => { + return subscription.listen(() => { + forceUpdate((prevState) => prevState + 1); + }); + }, [subscription]); + + const result = __use(promise); + + // TBD: refetch/fetchMore + + return result; +}