diff --git a/README.md b/README.md index b1e68e6..d5bd4b3 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ Hook-based library for simple yet flexible data fetching and display in React ap - Supports chaining API multiple dependent API requests. - Opt-in fine-grained control of a response cache, with clear identification of stale responses and their age. -- **TODO**: A global cache, - which can persist across screen unmounts. +- Opt-in global cache, + which persists responses across screen unmounts. ## Installation @@ -124,10 +124,24 @@ or in the top level `ApiReadProvider` `config` prop to set the defaults for all - **`reader`**: `(path: string) => Promise` - A reader function for your app's API, as described above. +- **`staleWhileMounted?`**: `boolean` - + If `true`, responses are kept in a global cache owned by the `ApiReadProvider`, + and a hook mounting on a path which is already in the cache + immediately returns the cached response (marked stale, with a `staleReason` of `'mounted'`) + while the fresh response is fetched in the background. + This avoids a loading state when the user returns to a screen they've already visited. + Cached responses are dropped when the path is invalidated, + and updated in place when the path is mutated, + so an unmounted screen can't be brought back with data you've already replaced. + Note that this option has no effect without an `ApiReadProvider`, which owns the cache. + The cache currently has no eviction policy: + it holds one entry per path read for the lifetime of the provider, + so avoid enabling it globally in an app which reads an unbounded number of distinct paths. - **`staleWhileInvalidated?`**: `boolean` - If `true`, then when an API response is considered invalidated (either because you manually invalidated it, or it's become too old), the stale (invalidated) response will continue to be returned by the `useApiRead` hook. + A response this hook read itself takes precedence over anything in the global cache. - **`staleWhileError?`**: `boolean` - If `true`, then when an API response returns an error, if there was a previous response returned by this hook, @@ -158,7 +172,7 @@ The following properties tell you about the data returned: this can be used to display a relevant error message to the user. - **`stale`**: `boolean` - Will be set to `true` if the current `data` being returned is considered stale. -- **`staleReason`**: `null | 'invalidated' | 'error'` - +- **`staleReason`**: `null | 'mounted' | 'invalidated' | 'error'` - If a stale response is being returned (`stale: true`), indicates why. - **`receivedAt`**: `null | number` - diff --git a/package.json b/package.json index fb5e199..12b3d90 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "react": "*" }, "jest": { - "preset": "react-native", "modulePathIgnorePatterns": [ "/example/node_modules", "/lib/" diff --git a/src/ApiReadContext.tsx b/src/ApiReadContext.tsx index f133f43..729c163 100644 --- a/src/ApiReadContext.tsx +++ b/src/ApiReadContext.tsx @@ -11,7 +11,25 @@ type MountedEntries = { }; }; +export type CacheEntry = { data: unknown; receivedAt: number }; +type Cache = { [path: string]: CacheEntry }; + +type PathPredicate = (path: string) => boolean; + +function exactly(search: string): PathPredicate { + return function predicate(path) { + return path === search; + }; +} + +function matching(search: string | RegExp): PathPredicate { + return function predicate(path) { + return Boolean(path.match(search)); + }; +} + export const ApiReadContext = React.createContext<{ + hasProvider: boolean; config: ReadConfig; addMountedEntry: ( key: string, @@ -20,14 +38,21 @@ export const ApiReadContext = React.createContext<{ mutate: (mutator: Mutator) => void ) => void; removeMountedEntry: (key: string) => void; + getCached: (path: string) => CacheEntry | undefined; + setCached: (path: string, data: unknown, receivedAt: number) => void; + clearCached: (path: string) => void; invalidateExact: (search: string) => void; invalidateMatching: (search: string | RegExp) => void; mutateExact: (search: string, mutator: Mutator) => void; mutateMatching: (search: string | RegExp, mutator: Mutator) => void; }>({ + hasProvider: false, config: {}, addMountedEntry: () => {}, removeMountedEntry: () => {}, + getCached: () => undefined, + setCached: () => {}, + clearCached: () => {}, invalidateExact: () => {}, invalidateMatching: () => {}, mutateExact: () => {}, @@ -41,6 +66,7 @@ type Props = { export function ApiReadProvider({ config, children }: Props) { const mountedEntriesRef = React.useRef({}); + const cacheRef = React.useRef({}); const addMountedEntry = React.useCallback(function addMountedEntry( key: string, @@ -59,58 +85,100 @@ export function ApiReadProvider({ config, children }: Props) { }, []); - const invalidateExact = React.useCallback(function invalidatExact( - search: string + const getCached = React.useCallback(function getCached( + path: string + ): CacheEntry | undefined { + return cacheRef.current[path]; + }, + []); + + const setCached = React.useCallback(function setCached( + path: string, + data: unknown, + receivedAt: number ): void { - for (const entry of Object.values(mountedEntriesRef.current)) { - if (entry.path === search) { - entry.invalidate(); - } - } + cacheRef.current[path] = { data, receivedAt }; }, []); - const invalidateMatching = React.useCallback(function invalidateMatching( - search: string | RegExp + const clearCached = React.useCallback(function clearCached( + path: string ): void { - for (const entry of Object.values(mountedEntriesRef.current)) { - if (entry.path.match(search)) { - entry.invalidate(); - } - } + delete cacheRef.current[path]; }, []); - const mutateExact = React.useCallback(function mutateExact( - search: string, - mutator: Mutator + // Invalidation drops cached responses as well as notifying mounted hooks, so + // that a path which is remounted later can't be served data already known to + // be out of date. + const invalidateWhere = React.useCallback(function invalidateWhere( + predicate: PathPredicate ): void { + for (const path of Object.keys(cacheRef.current)) { + if (predicate(path)) delete cacheRef.current[path]; + } for (const entry of Object.values(mountedEntriesRef.current)) { - if (entry.path === search) { - entry.mutate(mutator); - } + if (predicate(entry.path)) entry.invalidate(); } }, []); - const mutateMatching = React.useCallback(function mutateMatching( - search: string | RegExp, + // Mounted hooks write their mutated data back to the cache, so applying the + // mutator to a path which is both mounted and cached converges on the same + // result rather than applying twice. + const mutateWhere = React.useCallback(function mutateWhere( + predicate: PathPredicate, mutator: Mutator ): void { - for (const entry of Object.values(mountedEntriesRef.current)) { - if (entry.path.match(search)) { - entry.mutate(mutator); + for (const [path, cached] of Object.entries(cacheRef.current)) { + if (predicate(path)) { + cacheRef.current[path] = { ...cached, data: mutator(cached.data) }; } } + for (const entry of Object.values(mountedEntriesRef.current)) { + if (predicate(entry.path)) entry.mutate(mutator); + } }, []); + const invalidateExact = React.useCallback( + function invalidateExact(search: string): void { + invalidateWhere(exactly(search)); + }, + [invalidateWhere] + ); + + const invalidateMatching = React.useCallback( + function invalidateMatching(search: string | RegExp): void { + invalidateWhere(matching(search)); + }, + [invalidateWhere] + ); + + const mutateExact = React.useCallback( + function mutateExact(search: string, mutator: Mutator): void { + mutateWhere(exactly(search), mutator); + }, + [mutateWhere] + ); + + const mutateMatching = React.useCallback( + function mutateMatching(search: string | RegExp, mutator: Mutator): void { + mutateWhere(matching(search), mutator); + }, + [mutateWhere] + ); + return ( > = {}): State { + return { + ...initialState('/own', { data: { value: 2 }, receivedAt: 200 }), + ...overrides, + }; +} + +function readRequest( + state: State, + config: ReadConfig, + payload: { path: string | null; pathChanged: boolean; cached?: CacheEntry } +) { + return reducer(state, { + type: 'READ_REQUEST', + payload: { + config, + path: payload.path, + pathChanged: payload.pathChanged, + cached: payload.cached, + }, + }); +} + +describe('initialState', () => { + it('seeds from a cache entry', () => { + expect(initialState('/a', cached)).toEqual({ + data: { value: 1 }, + dataPath: '/a', + error: undefined, + staleReason: 'mounted', + receivedAt: 100, + }); + }); + + it('is empty without a cache entry', () => { + expect(initialState('/a', undefined)).toEqual({ + data: undefined, + dataPath: null, + error: undefined, + staleReason: null, + receivedAt: null, + }); + }); +}); + +describe('READ_REQUEST', () => { + it('seeds a changed path from the cache when staleWhileMounted is on', () => { + const next = readRequest( + ownState(), + { staleWhileMounted: true }, + { + path: '/a', + pathChanged: true, + cached, + } + ); + expect(next).toEqual({ + data: { value: 1 }, + dataPath: '/a', + error: undefined, + staleReason: 'mounted', + receivedAt: 100, + }); + }); + + it('ignores the cache when staleWhileMounted is off', () => { + const next = readRequest( + ownState(), + {}, + { + path: '/a', + pathChanged: true, + cached, + } + ); + expect(next.data).toBeUndefined(); + expect(next.staleReason).toBeNull(); + }); + + it('clears data for a changed path with no cache entry', () => { + const next = readRequest( + ownState(), + { staleWhileMounted: true }, + { + path: '/a', + pathChanged: true, + } + ); + expect(next.data).toBeUndefined(); + expect(next.dataPath).toBeNull(); + }); + + it('prefers the instance own data over the cache', () => { + const next = readRequest( + ownState(), + { staleWhileMounted: true, staleWhileInvalidated: true }, + { path: '/own', pathChanged: false, cached } + ); + expect(next).toEqual({ + data: { value: 2 }, + dataPath: '/own', + error: undefined, + staleReason: 'invalidated', + receivedAt: 200, + }); + }); + + it('falls back to the cache when the instance has no data of its own', () => { + const empty = initialState('/a', undefined); + const next = readRequest( + empty, + { staleWhileMounted: true, staleWhileInvalidated: true }, + { path: '/a', pathChanged: false, cached } + ); + expect(next.data).toEqual({ value: 1 }); + expect(next.staleReason).toBe('mounted'); + }); +}); + +describe('READ_SUCCESS', () => { + it('records the path the data was read for', () => { + const next = reducer(initialState('/a', cached), { + type: 'READ_SUCCESS', + payload: { config: {}, data: { value: 3 }, path: '/a', receivedAt: 300 }, + }); + expect(next).toEqual({ + data: { value: 3 }, + dataPath: '/a', + error: undefined, + staleReason: null, + receivedAt: 300, + }); + }); +}); + +describe('READ_FAILURE', () => { + const error = new Error('nope'); + + it('reports an error staleReason when preserving stale data', () => { + const next = reducer(ownState(), { + type: 'READ_FAILURE', + payload: { config: { staleWhileError: true }, error }, + }); + expect(next).toEqual({ + data: { value: 2 }, + dataPath: '/own', + error, + staleReason: 'error', + receivedAt: 200, + }); + }); + + it('clears data when staleWhileError is off', () => { + const next = reducer(ownState(), { + type: 'READ_FAILURE', + payload: { config: {}, error }, + }); + expect(next).toEqual({ + data: undefined, + dataPath: null, + error, + staleReason: null, + receivedAt: null, + }); + }); +}); + +describe('MUTATED_DATA', () => { + it('keeps the path and receivedAt of the mutated response', () => { + const next = reducer(ownState(), { + type: 'MUTATED_DATA', + payload: { data: { value: 9 } }, + }); + expect(next).toEqual({ + data: { value: 9 }, + dataPath: '/own', + error: undefined, + staleReason: 'mounted', + receivedAt: 200, + }); + }); +}); diff --git a/src/core/reducer.ts b/src/core/reducer.ts index 306e489..2588b0f 100644 --- a/src/core/reducer.ts +++ b/src/core/reducer.ts @@ -1,8 +1,15 @@ -import { ReadConfig, StaleReason } from '../types'; import { Reducer, Dispatch, ReducerAction } from 'react'; -type State = { +import { CacheEntry } from '../ApiReadContext'; +import { ReadConfig, StaleReason } from '../types'; + +export type State = { data: T | undefined; + /** + * Path the current `data` was read for, so that data belonging to a previous + * path isn't written back to the cache under a newly requested path. + */ + dataPath: string | null; error: Error | undefined; staleReason: null | StaleReason; receivedAt: null | number; @@ -11,11 +18,21 @@ type State = { type Action = | { type: 'READ_REQUEST'; - payload: { config: ReadConfig; pathChanged: boolean }; + payload: { + config: ReadConfig; + path: string | null; + pathChanged: boolean; + cached: CacheEntry | undefined; + }; } | { type: 'READ_SUCCESS'; - payload: { data: T; receivedAt: number; config: ReadConfig }; + payload: { + data: T; + path: string | null; + receivedAt: number; + config: ReadConfig; + }; } | { type: 'READ_FAILURE'; payload: { error: Error; config: ReadConfig } } | { type: 'MORE_DATA'; payload: { data: T } } @@ -24,37 +41,84 @@ type Action = export type ReaderReducer = Reducer, Action>; export type ReaderDispatch = Dispatch>>; +function emptyState(): State { + return { + data: undefined, + dataPath: null, + error: undefined, + staleReason: null, + receivedAt: null, + }; +} + +/** + * The cache is keyed by path rather than by response type, so the caller's `T` + * can only be applied here. + */ +function cachedState(path: string | null, cached: CacheEntry): State { + return { + data: cached.data as T, + dataPath: path, + error: undefined, + staleReason: 'mounted', + receivedAt: cached.receivedAt, + }; +} + +export function initialState( + path: string | null, + cached: CacheEntry | undefined +): State { + return cached ? cachedState(path, cached) : emptyState(); +} + export default function reducer( state: State, action: Action ): State { switch (action.type) { case 'READ_REQUEST': { - const allowStale = - action.payload.config.staleWhileInvalidated && - !action.payload.pathChanged; - const preservingStale = allowStale && state.data !== undefined; - return { - error: undefined, - data: allowStale ? state.data : undefined, - staleReason: preservingStale ? 'invalidated' : null, - receivedAt: preservingStale ? state.receivedAt : null, - }; + const { config, path, pathChanged, cached } = action.payload; + + // Data this instance already read takes precedence over the cache, as + // it's at least as fresh and reflects any local mutations. + const keepingOwnData = + Boolean(config.staleWhileInvalidated) && + !pathChanged && + state.data !== undefined; + if (keepingOwnData) { + return { + data: state.data, + dataPath: state.dataPath, + error: undefined, + staleReason: 'invalidated', + receivedAt: state.receivedAt, + }; + } + + if (config.staleWhileMounted && cached) { + return cachedState(path, cached); + } + + return emptyState(); } case 'READ_SUCCESS': return { data: action.payload.data, + dataPath: action.payload.path, error: undefined, staleReason: null, receivedAt: action.payload.receivedAt, }; case 'READ_FAILURE': { - const allowStale = action.payload.config.staleWhileError; - const preservingStale = allowStale && state.data !== undefined; + const preservingStale = + Boolean(action.payload.config.staleWhileError) && + state.data !== undefined; return { + data: preservingStale ? state.data : undefined, + dataPath: preservingStale ? state.dataPath : null, error: action.payload.error, - data: allowStale ? state.data : undefined, - staleReason: preservingStale ? 'invalidated' : null, + staleReason: preservingStale ? 'error' : null, receivedAt: preservingStale ? state.receivedAt : null, }; } diff --git a/src/core/use-config.ts b/src/core/use-config.ts index b4eac8b..7432d3a 100644 --- a/src/core/use-config.ts +++ b/src/core/use-config.ts @@ -15,16 +15,20 @@ export default function useConfig(options: ReadConfig = {}): ReadConfig { options.staleWhileError ?? context.config.staleWhileError, staleWhileInvalidated: options.staleWhileInvalidated ?? context.config.staleWhileInvalidated, + staleWhileMounted: + options.staleWhileMounted ?? context.config.staleWhileMounted, invalidateAge: options.invalidateAge ?? context.config.invalidateAge, }), [ context.config.reader, context.config.staleWhileError, context.config.staleWhileInvalidated, + context.config.staleWhileMounted, context.config.invalidateAge, options.reader, options.staleWhileError, options.staleWhileInvalidated, + options.staleWhileMounted, options.invalidateAge, ] ); diff --git a/src/types.ts b/src/types.ts index 64e420a..62f94c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,15 +3,14 @@ export type Reader = (path: string) => Promise; export type ReadConfig = { reader?: Reader; - // Meaningless until global cache - // staleWhileMounted?: boolean; + staleWhileMounted?: boolean; staleWhileInvalidated?: boolean; staleWhileError?: boolean; invalidateAge?: number; }; -export type StaleReason = /* 'mounted' | */ 'invalidated' | 'error'; +export type StaleReason = 'mounted' | 'invalidated' | 'error'; export type ReadResult = { data: T | undefined; diff --git a/src/use-api-read.ts b/src/use-api-read.ts index 9bbad64..99a4406 100644 --- a/src/use-api-read.ts +++ b/src/use-api-read.ts @@ -8,7 +8,7 @@ import { import { ApiReadContext } from './ApiReadContext'; import { ReadConfig, ReadResult } from './types'; -import reducer, { ReaderReducer } from './core/reducer'; +import reducer, { initialState, ReaderReducer } from './core/reducer'; import useConfig from './core/use-config'; import usePrevious from './core/use-previous'; import useReadMore from './core/use-read-more'; @@ -18,21 +18,42 @@ export default function useApiRead( options: ReadConfig = {} ): ReadResult { const context = useContext(ApiReadContext); + const { + hasProvider, + addMountedEntry, + removeMountedEntry, + getCached, + setCached, + clearCached, + } = context; const config = useConfig(options); - const { reader, invalidateAge } = config; + const { reader, invalidateAge, staleWhileMounted } = config; - const [state, dispatch] = useReducer>(reducer, { - data: undefined, - error: undefined, - staleReason: null, - receivedAt: null, - }); + const readCache = useCallback( + function readCache(cachePath: string | null) { + if (!staleWhileMounted || cachePath === null) return undefined; + return getCached(cachePath); + }, + [getCached, staleWhileMounted] + ); + + const [state, dispatch] = useReducer, null>( + reducer, + null, + () => initialState(path, readCache(path)) + ); const [invalidateToken, setInvalidateToken] = useState(0); - const invalidate = useCallback(function invalidate() { - setInvalidateToken(Math.random()); - }, []); + const invalidate = useCallback( + function invalidate() { + // Data known to be out of date must not survive in the cache to be + // served to a later mount. + if (path !== null) clearCached(path); + setInvalidateToken(Math.random()); + }, + [clearCached, path] + ); const mutate = useCallback( function mutate(mutator: (data: T) => T) { @@ -47,11 +68,18 @@ export default function useApiRead( const [instanceKey] = useState(Math.random().toString()); useEffect(() => { if (path === null) return; - context.addMountedEntry(instanceKey, path, invalidate, mutate); + addMountedEntry(instanceKey, path, invalidate, mutate); return () => { - context.removeMountedEntry(instanceKey); + removeMountedEntry(instanceKey); }; - }, [context, instanceKey, path, invalidate, mutate]); + }, [ + addMountedEntry, + removeMountedEntry, + instanceKey, + path, + invalidate, + mutate, + ]); // Effect: Perform the API request const previousPath = usePrevious(path); @@ -60,7 +88,10 @@ export default function useApiRead( async function readRequest() { const pathChanged = previousPath !== path; - dispatch({ type: 'READ_REQUEST', payload: { config, pathChanged } }); + dispatch({ + type: 'READ_REQUEST', + payload: { config, path, pathChanged, cached: readCache(path) }, + }); // Intentionally bail out, to allow user to e.g. wait on result from a // prior API request @@ -81,6 +112,7 @@ export default function useApiRead( payload: { config, data, + path, receivedAt: Math.floor(Date.now() / 1000), }, }); @@ -89,7 +121,7 @@ export default function useApiRead( if (!ignore) { dispatch({ type: 'READ_FAILURE', - payload: { config, error: error as any }, + payload: { config, error: error as Error }, }); } } @@ -101,7 +133,24 @@ export default function useApiRead( }; // intentionally omitting previousPath // eslint-disable-next-line react-hooks/exhaustive-deps - }, [path, reader, config, invalidateToken]); + }, [path, reader, config, invalidateToken, readCache]); + + // Effect: Write successful reads back to the global cache + useEffect(() => { + if (!staleWhileMounted || path === null) return; + if (state.data === undefined || state.receivedAt === null) return; + // A path change re-renders before the request effect resets the state, so + // the previous path's data is briefly still present. + if (state.dataPath !== path) return; + setCached(path, state.data, state.receivedAt); + }, [ + setCached, + staleWhileMounted, + path, + state.data, + state.dataPath, + state.receivedAt, + ]); // Effect: Manage invalidateAge timeout useEffect(() => { @@ -113,6 +162,20 @@ export default function useApiRead( return () => clearTimeout(timeoutId); }, [invalidateAge, state.data, state.staleReason, invalidate]); + // Effect: Warn about a silently ineffective config + useEffect(() => { + if ( + staleWhileMounted && + !hasProvider && + process.env.NODE_ENV !== 'production' + ) { + console.warn( + 'api-read-hook: `staleWhileMounted` has no effect without an ' + + ', which owns the cache.' + ); + } + }, [staleWhileMounted, hasProvider]); + const { readMore, loadingMore, moreError } = useReadMore(reader, dispatch); return {