Skip to content

Commit

Permalink
Add escape hatch useCacheValue hook
Browse files Browse the repository at this point in the history
  • Loading branch information
bvaughn committed Mar 7, 2023
1 parent 91d1aa0 commit 6352a82
Show file tree
Hide file tree
Showing 12 changed files with 293 additions and 4 deletions.
3 changes: 3 additions & 0 deletions packages/suspense-website/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
IS_PROMISE_LIKE,
USE_CACHE_MUTATION,
USE_CACHE_STATUS,
USE_CACHE_VALUE,
USE_STREAMING_CACHE,
} from "./src/routes/config";

Expand All @@ -28,6 +29,7 @@ import IsPromiseLikeRoute from "./src/routes/api/isPromiseLike";
import PageNotFoundRoute from "./src/routes/PageNotFound";
import UseCacheMutationRoute from "./src/routes/api/useCacheMutation";
import UseCacheStatusRoute from "./src/routes/api/useCacheStatus";
import UseCacheValueRoute from "./src/routes/api/useCacheValue";
import UseStreamingValuesRoute from "./src/routes/api/useStreamingValues";
import AbortingRequestRoute from "./src/routes/examples/aborting-a-request";
import MemoryManagementRoute from "./src/routes/examples/memory-management";
Expand Down Expand Up @@ -83,6 +85,7 @@ root.render(
<Route path={IS_PROMISE_LIKE} element={<IsPromiseLikeRoute />} />
<Route path={USE_CACHE_MUTATION} element={<UseCacheMutationRoute />} />
<Route path={USE_CACHE_STATUS} element={<UseCacheStatusRoute />} />
<Route path={USE_CACHE_VALUE} element={<UseCacheValueRoute />} />
<Route
path={USE_STREAMING_CACHE}
element={<UseStreamingValuesRoute />}
Expand Down
7 changes: 7 additions & 0 deletions packages/suspense-website/src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ const useCacheMutation = {
),
};

const useCacheValue = {
hook: processExample(
readFileSync(join(__dirname, "useCacheValue", "hook.ts"), "utf8")
),
};

export {
createCache,
createDeferred,
Expand All @@ -205,4 +211,5 @@ export {
demos,
isPromiseLike,
useCacheMutation,
useCacheValue,
};
23 changes: 23 additions & 0 deletions packages/suspense-website/src/examples/useCacheValue/hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { userProfileCache } from "../createCache/cache";

// REMOVE_BEFORE

import {
STATUS_PENDING,
STATUS_REJECTED,
STATUS_RESOLVED,
useCacheValue,
} from "suspense";

function Example({ userId }: { userId: string }) {
const { error, status, value } = useCacheValue(userProfileCache, userId);

switch (status) {
case STATUS_PENDING:
// Rending loading UI ...
case STATUS_REJECTED:
// Render "error" ...
case STATUS_RESOLVED:
// Render "value" ...
}
}
6 changes: 6 additions & 0 deletions packages/suspense-website/src/routes/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
IS_PROMISE_LIKE,
USE_CACHE_MUTATION,
USE_CACHE_STATUS,
USE_CACHE_VALUE,
USE_STREAMING_CACHE,
} from "./config";

Expand Down Expand Up @@ -86,6 +87,11 @@ export default function Route() {
to={USE_CACHE_STATUS}
type="code"
/>
<LinkListItem
children="useCacheValue"
to={USE_CACHE_VALUE}
type="code"
/>
<LinkListItem
children="useStreamingValues"
to={USE_STREAMING_CACHE}
Expand Down
33 changes: 33 additions & 0 deletions packages/suspense-website/src/routes/api/useCacheValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Link } from "react-router-dom";
import Block from "../../components/Block";
import Code from "../../components/Code";
import Container from "../../components/Container";
import Header from "../../components/Header";
import Note from "../../components/Note";
import SubHeading from "../../components/SubHeading";
import { useCacheValue } from "../../examples";

export default function Route() {
return (
<Container>
<Block>
<Header title="useCacheValue" />
</Block>
<Block>
<SubHeading title="Loading data without Suspense" />
<p>
Data can be fetched without suspending using the{" "}
<code>useCacheValue</code> hook. (This hook uses the imperative{" "}
<code>readAsync</code> API, called from an effect.)
</p>
<Code code={useCacheValue.hook} />
</Block>
<Note type="warn">
<p>
This hook exists as an escape hatch. When possible, values should be
loaded using the <code>read</code> API.
</p>
</Note>
</Container>
);
}
1 change: 1 addition & 0 deletions packages/suspense-website/src/routes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const CREATE_STREAMING_CACHE = "/createStreamingCache";
export const IS_PROMISE_LIKE = "/isPromiseLike";
export const USE_CACHE_MUTATION = "/useCacheMutation";
export const USE_CACHE_STATUS = "/useCacheStatus";
export const USE_CACHE_VALUE = "/useCacheValue";
export const USE_STREAMING_CACHE = "/useStreamingValues";

// Guides
Expand Down
3 changes: 3 additions & 0 deletions packages/suspense/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 0.0.16
* Add `useCacheValue` hook for loading values using the imperative cache API rather than Suspense.

## 0.0.15
* Removed `Thenable` in favor of built-in `PromiseLike` since it works better with async/await types
* Replaced `isThenable` with `isPromiseLike`
Expand Down
6 changes: 2 additions & 4 deletions packages/suspense/src/cache/createCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,9 @@ describe("createCache", () => {
});

it("it should throw if value was rejected", async () => {
cache.readAsync("error");
cache.readAsync("error-expected");
await Promise.resolve();
expect(() => cache.getValue("error")).toThrow(
'Record found with status "rejected"'
);
expect(() => cache.getValue("error-expected")).toThrow("error-expected");
});
});

Expand Down
2 changes: 2 additions & 0 deletions packages/suspense/src/cache/createCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ export function createCache<Params extends Array<any>, Value>(

if (record == null) {
throw Error("No record found");
} else if (record.status === STATUS_REJECTED) {
throw record.value;
} else if (record.status !== STATUS_RESOLVED) {
throw Error(`Record found with status "${record.status}"`);
} else {
Expand Down
154 changes: 154 additions & 0 deletions packages/suspense/src/hooks/useCacheValue.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* @jest-environment jsdom
*/

import { createRoot } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { createCache } from "../cache/createCache";
import {
STATUS_NOT_STARTED,
STATUS_PENDING,
STATUS_REJECTED,
STATUS_RESOLVED,
} from "../constants";
import { Cache, CacheLoadOptions, Deferred, Status } from "../types";
import { createDeferred } from "../utils/createDeferred";
import { useCacheValue } from "./useCacheValue";

describe("useCacheValue", () => {
let cache: Cache<[string], string>;
let fetch: jest.Mock<Promise<string> | string, [string, CacheLoadOptions]>;
let getCacheKey: jest.Mock<string, [string]>;
let lastRenderedError: any = undefined;
let lastRenderedStatus: Status | undefined = undefined;
let lastRenderedValue: string | undefined = undefined;
let pendingDeferred: Deferred<string>[] = [];

function Component({ string }: { string: string }): any {
const result = useCacheValue(cache, string);

lastRenderedError = result.error;
lastRenderedStatus = result.status;
lastRenderedValue = result.value;

return null;
}

beforeEach(() => {
// @ts-ignore
global.IS_REACT_ACT_ENVIRONMENT = true;

fetch = jest.fn();
fetch.mockImplementation(async (key: string) => {
const deferred = createDeferred<string>();

pendingDeferred.push(deferred);

return deferred;
});

getCacheKey = jest.fn();
getCacheKey.mockImplementation((key) => key.toString());

cache = createCache<[string], string>({
debugLabel: "cache",
getKey: getCacheKey,
load: fetch,
});

lastRenderedStatus = undefined;
lastRenderedStatus = undefined;
lastRenderedValue = undefined;
pendingDeferred = [];
});

it("should return values that have already been loaded", async () => {
cache.cache("cached", "test");

const container = document.createElement("div");
const root = createRoot(container);
await act(async () => {
root.render(<Component string="test" />);
});

expect(lastRenderedError).toBeUndefined();
expect(lastRenderedStatus).toBe(STATUS_RESOLVED);
expect(lastRenderedValue).toBe("cached");
});

it("should fetch values that have not yet been fetched", async () => {
expect(cache.getStatus("test")).toBe(STATUS_NOT_STARTED);

const container = document.createElement("div");
const root = createRoot(container);
await act(async () => {
root.render(<Component string="test" />);
});

expect(pendingDeferred).toHaveLength(1);
expect(lastRenderedStatus).toBe(STATUS_PENDING);

await act(async () => pendingDeferred[0].resolve("resolved"));

expect(lastRenderedError).toBeUndefined();
expect(lastRenderedStatus).toBe(STATUS_RESOLVED);
expect(lastRenderedValue).toBe("resolved");
});

it("should handle values that are rejected", async () => {
expect(cache.getStatus("test")).toBe(STATUS_NOT_STARTED);

const container = document.createElement("div");
const root = createRoot(container);
await act(async () => {
root.render(<Component string="test" />);
});

expect(pendingDeferred).toHaveLength(1);
expect(lastRenderedStatus).toBe(STATUS_PENDING);

await act(async () => pendingDeferred[0].reject("rejected"));

expect(lastRenderedError).toBe("rejected");
expect(lastRenderedStatus).toBe(STATUS_REJECTED);
expect(lastRenderedValue).toBeUndefined();
});

it("should wait for values that have already been loaded to be resolved", async () => {
cache.readAsync("test");
expect(pendingDeferred).toHaveLength(1);

const container = document.createElement("div");
const root = createRoot(container);
await act(async () => {
root.render(<Component string="test" />);
});

expect(lastRenderedStatus).toBe(STATUS_PENDING);

await act(async () => pendingDeferred[0].resolve("resolved"));

expect(lastRenderedError).toBeUndefined();
expect(lastRenderedStatus).toBe(STATUS_RESOLVED);
expect(lastRenderedValue).toBe("resolved");
});

it("should wait for values that have already been loaded to be rejected", async () => {
cache.readAsync("test");
expect(pendingDeferred).toHaveLength(1);

const container = document.createElement("div");
const root = createRoot(container);
await act(async () => {
root.render(<Component string="test" />);
});

expect(lastRenderedStatus).toBe(STATUS_PENDING);

await act(async () => pendingDeferred[0].reject("rejected"));

expect(lastRenderedError).toBe("rejected");
expect(lastRenderedStatus).toBe(STATUS_REJECTED);
expect(lastRenderedValue).toBeUndefined();
});
});
58 changes: 58 additions & 0 deletions packages/suspense/src/hooks/useCacheValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useEffect } from "react";
import {
STATUS_NOT_STARTED,
STATUS_PENDING,
STATUS_REJECTED,
STATUS_RESOLVED,
} from "../constants";
import { Cache, StatusPending, StatusRejected, StatusResolved } from "../types";
import { useCacheStatus } from "./useCacheStatus";

export type ErrorResponse = { error: any; status: StatusRejected; value: any };
export type PendingResponse = {
error: undefined;
status: StatusPending;
value: undefined;
};
export type ResolvedResponse<Value> = {
error: undefined;
status: StatusResolved;
value: Value;
};

export function useCacheValue<Params extends any[], Value>(
cache: Cache<Params, Value>,
...params: Params
): ErrorResponse | PendingResponse | ResolvedResponse<Value> {
const status = useCacheStatus(cache, ...params);

useEffect(() => {
switch (status) {
case STATUS_NOT_STARTED:
cache.prefetch(...params);
}
}, [cache, status, ...params]);

switch (status) {
case STATUS_REJECTED:
let caught;
try {
cache.getValue(...params);
} catch (error) {
caught = error;
}
return { error: caught, status: STATUS_REJECTED, value: undefined };
case STATUS_RESOLVED:
return {
error: undefined,
status: STATUS_RESOLVED,
value: cache.getValueIfCached(...params),
};
default:
return {
error: undefined,
status: STATUS_PENDING,
value: undefined,
};
}
}
1 change: 1 addition & 0 deletions packages/suspense/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from "./cache/createSingleEntryCache";
export * from "./cache/createStreamingCache";
export * from "./hooks/useCacheMutation";
export * from "./hooks/useCacheStatus";
export * from "./hooks/useCacheValue";
export * from "./hooks/useStreamingValues";
export * from "./utils/createDeferred";
export * from "./utils/createInfallibleCache";
Expand Down

0 comments on commit 6352a82

Please sign in to comment.