Skip to content

Commit

Permalink
enhance: useCache() and useStatefulResource() respect invalidIfStale (#…
Browse files Browse the repository at this point in the history
…307)

BREAKING CHANGE: When invalidIfStale is true, useCache() and
useStatefulResource() will no longer return entities, even if they
are in the cache
  • Loading branch information
ntucker committed Mar 29, 2020
1 parent dbba679 commit 58f2c40
Show file tree
Hide file tree
Showing 9 changed files with 136 additions and 36 deletions.
35 changes: 34 additions & 1 deletion packages/legacy/src/__tests__/useStatefulResource.tsx
@@ -1,5 +1,9 @@
import { CoolerArticleResource } from '__tests__/common';
import {
CoolerArticleResource,
InvalidIfStaleArticleResource,
} from '__tests__/common';
import { makeRenderRestHook, makeCacheProvider } from '@rest-hooks/test';

import nock from 'nock';

import { payload, users, nested } from './fixtures';
Expand Down Expand Up @@ -93,4 +97,33 @@ describe('useStatefulResource()', () => {
});
expect(result.current.loading).toBe(false);
});

it('should not select when results are stale and invalidIfStale is true', async () => {
const realDate = global.Date.now;
Date.now = jest.fn(() => 999999999);
const { result, rerender, waitForNextUpdate } = renderRestHook(
props => {
return useStatefulResource(
InvalidIfStaleArticleResource.detailShape(),
props,
);
},
{ initialProps: { id: payload.id } as any },
);

await waitForNextUpdate();
expect(result.current.data).toBeDefined();
Date.now = jest.fn(() => 999999999 * 3);

rerender(null);
expect(result.current.data).toBeUndefined();
rerender({ id: payload.id });
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toBeDefined();
expect(result.current.loading).toBe(false);

global.Date.now = realDate;
});
});
14 changes: 8 additions & 6 deletions packages/legacy/src/index.ts
Expand Up @@ -6,6 +6,7 @@ import {
useDenormalized,
__INTERNAL__,
} from 'rest-hooks';

import { useContext } from 'react';

/** Ensure a resource is available; loading and error returned explicitly. */
Expand All @@ -15,14 +16,15 @@ export function useStatefulResource<
>(fetchShape: ReadShape<S, Params>, params: Params | null) {
const maybePromise = useRetrieve(fetchShape, params);
const state = useContext(__INTERNAL__.StateContext);
const [denormalized, ready] = useDenormalized(fetchShape, params, state);

const loading = Boolean(
!__INTERNAL__.hasUsableData(ready, fetchShape) &&
maybePromise &&
typeof maybePromise.then === 'function',
const expired = !!maybePromise && typeof maybePromise.then === 'function';
const [denormalized, ready] = useDenormalized(
fetchShape,
params,
state,
expired,
);

const loading = !__INTERNAL__.hasUsableData(ready, fetchShape) && expired;
const error = useError(fetchShape, params, ready);

return {
Expand Down
32 changes: 31 additions & 1 deletion packages/rest-hooks/src/react-integration/__tests__/useCache.tsx
@@ -1,9 +1,11 @@
import React, { useEffect } from 'react';
import {
CoolerArticleResource,
PaginatedArticleResource,
InvalidIfStaleArticleResource,
} from '__tests__/common';

import React, { useEffect } from 'react';

// relative imports to avoid circular dependency in tsconfig references
import { makeRenderRestHook, makeCacheProvider } from '../../../../test';
import { useCache } from '../hooks';
Expand Down Expand Up @@ -44,6 +46,34 @@ describe('useCache()', () => {
expect(result.current?.title).toBe(payload.title);
});

it('should not select when results are stale and invalidIfStale is true', () => {
const realDate = global.Date.now;
Date.now = jest.fn(() => 999999999);
const results = [
{
request: InvalidIfStaleArticleResource.detailShape(),
params: payload,
result: payload,
},
];
const { result, rerender } = renderRestHook(
props => {
return useCache(InvalidIfStaleArticleResource.detailShape(), props);
},
{ results, initialProps: { id: payload.id } },
);

expect(result.current).toBeDefined();
Date.now = jest.fn(() => 999999999 * 3);

rerender({ id: payload.id * 2 });
expect(result.current).toBeUndefined();
rerender({ id: payload.id });
expect(result.current).toBeUndefined();

global.Date.now = realDate;
});

it('should select paginated results', () => {
const results = [
{
Expand Down
@@ -1,17 +1,18 @@
import React, { Suspense } from 'react';
import { render } from '@testing-library/react';
import nock from 'nock';
import {
CoolerArticleResource,
UserResource,
InvalidIfStaleArticleResource,
photoShape,
} from '__tests__/common';

// relative imports to avoid circular dependency in tsconfig references
import { State } from 'rest-hooks/types';
import { initialState } from 'rest-hooks/state/reducer';

import React, { Suspense } from 'react';
import { render } from '@testing-library/react';
import nock from 'nock';

// relative imports to avoid circular dependency in tsconfig references

import {
makeRenderRestHook,
makeCacheProvider,
Expand Down
19 changes: 17 additions & 2 deletions packages/rest-hooks/src/react-integration/hooks/useCache.ts
@@ -1,12 +1,27 @@
import { StateContext } from 'rest-hooks/react-integration/context';
import { ReadShape, ParamsFromShape } from 'rest-hooks/resource';
import { useDenormalized } from 'rest-hooks/state/selectors';
import { useContext } from 'react';

import { useContext, useMemo } from 'react';

import useExpiresAt from './useExpiresAt';

/** Access a resource if it is available. */
export default function useCache<
Shape extends Pick<ReadShape<any, any>, 'getFetchKey' | 'schema'>
>(fetchShape: Shape, params: ParamsFromShape<Shape> | null) {
const expiresAt = useExpiresAt(fetchShape, params);
// This computation reflects the behavior of useResource/useRetrive
// It only changes the value when expiry or params change.
// This way, random unrelated re-renders don't cause the concept of expiry
// to change
const expired = useMemo(() => {
if (Date.now() <= expiresAt || !params) return false;
return true;
// we need to check against serialized params, since params can change frequently
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expiresAt, params && fetchShape.getFetchKey(params)]);

const state = useContext(StateContext);
return useDenormalized(fetchShape, params, state)[0];
return useDenormalized(fetchShape, params, state, expired)[0];
}
15 changes: 15 additions & 0 deletions packages/rest-hooks/src/react-integration/hooks/useExpiresAt.ts
@@ -0,0 +1,15 @@
import { ReadShape } from 'rest-hooks/resource';

import useMeta from './useMeta';

/** Returns whether the data at this url is fresh or stale */
export default function useExpiresAt<Params extends Readonly<object>>(
fetchShape: Pick<ReadShape<any, Params>, 'getFetchKey'>,
params: Params | null,
): number {
const meta = useMeta(fetchShape, params);
if (!meta) {
return 0;
}
return meta.expiresAt;
}
18 changes: 3 additions & 15 deletions packages/rest-hooks/src/react-integration/hooks/useRetrieve.ts
@@ -1,20 +1,9 @@
import { ReadShape, Schema } from 'rest-hooks/resource';

import { useMemo } from 'react';

import useFetcher from './useFetcher';
import useMeta from './useMeta';

/** Returns whether the data at this url is fresh or stale */
function useExpiresAt<Params extends Readonly<object>, S extends Schema>(
fetchShape: ReadShape<S, Params>,
params: Params | null,
): number {
const meta = useMeta(fetchShape, params);
if (!meta) {
return 0;
}
return meta.expiresAt;
}
import useExpiresAt from './useExpiresAt';

/** Request a resource if it is not in cache. */
export default function useRetrieve<
Expand All @@ -25,9 +14,8 @@ export default function useRetrieve<
const expiresAt = useExpiresAt(fetchShape, params);

return useMemo(() => {
if (Date.now() <= expiresAt) return;
// null params mean don't do anything
if (!params) return;
if (Date.now() <= expiresAt || !params) return;
return fetch(params);
// we need to check against serialized params, since params can change frequently
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
@@ -1,6 +1,7 @@
import { DispatchContext } from 'rest-hooks/react-integration/context';
import { ReadShape, Schema } from 'rest-hooks/resource';
import { SUBSCRIBE_TYPE, UNSUBSCRIBE_TYPE } from 'rest-hooks/actionTypes';

import { useContext, useEffect, useRef } from 'react';

/** Keeps a resource fresh by subscribing to updates. */
Expand Down
27 changes: 21 additions & 6 deletions packages/rest-hooks/src/state/selectors/useDenormalized.ts
Expand Up @@ -6,8 +6,10 @@ import {
DenormalizeNullable,
ParamsFromShape,
} from 'rest-hooks/resource';

import { useMemo } from 'react';

import hasUsableData from '../../react-integration/hooks/hasUsableData';
import buildInferredResults from './buildInferredResults';

/**
Expand All @@ -20,18 +22,20 @@ import buildInferredResults from './buildInferredResults';
* @returns [denormalizedValue, allEntitiesFound]
*/
export default function useDenormalized<
Shape extends Pick<ReadShape<any, any>, 'getFetchKey' | 'schema'>
Shape extends Pick<ReadShape<any, any>, 'getFetchKey' | 'schema' | 'options'>
>(
{ schema, getFetchKey }: Shape,
{ schema, getFetchKey, options }: Shape,
params: ParamsFromShape<Shape> | null,
state: State<any>,
expired = false,
): [
DenormalizeNullable<Shape['schema']>,
typeof params extends null ? false : boolean,
] {
// Select from state
const entities = state.entities;
const cacheResults = params && state.results[getFetchKey(params)];
const serializedParams = params && getFetchKey(params);

// We can grab entities without actual results if the params compute a primary key
const results = useMemo(() => {
Expand All @@ -41,15 +45,15 @@ export default function useDenormalized<
// entities[entitySchema.key] === undefined
return buildInferredResults(schema, params, state.indexes);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheResults, state.indexes, params && getFetchKey(params)]);
}, [cacheResults, state.indexes, serializedParams]);
// TODO: only update when relevant indexes change

// Compute denormalized value
const [denormalized, entitiesFound, entitiesList] = useMemo(() => {
// Warn users with bad configurations
/* istanbul ignore next */
if (process.env.NODE_ENV !== 'production' && isEntity(schema)) {
const paramEncoding = params ? getFetchKey(params) : '';
const paramEncoding = serializedParams || '';
if (Array.isArray(results)) {
throw new Error(
`url ${paramEncoding} has list results when single result is expected`,
Expand All @@ -63,7 +67,8 @@ export default function useDenormalized<
}

// second argument is false if any entities are missing
const [denormalized, entitiesFound, cache] = denormalize(
// eslint-disable-next-line prefer-const
let [denormalized, entitiesFound, cache] = denormalize(
results,
schema,
entities,
Expand All @@ -75,14 +80,24 @@ export default function useDenormalized<
.reduce((a: any[], b: any[]) => a.concat(b), [])
.join(',');

if (!hasUsableData(entitiesFound, { options }) && expired) {
denormalized = denormalize(results, schema, {})[0];
}

return [denormalized, entitiesFound, entitiesList] as [
DenormalizeNullable<Shape['schema']>,
any,
string,
];
// TODO: would be nice to make this only recompute on the entity types that are in schema
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entities, params && getFetchKey(params), results]);
}, [
entities,
serializedParams,
results,
expired,
options && options.invalidIfStale,
]);

// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => [denormalized, entitiesFound], [
Expand Down

0 comments on commit 58f2c40

Please sign in to comment.