diff --git a/packages/suspense-website/index.tsx b/packages/suspense-website/index.tsx index 843d833..b4a7d8d 100644 --- a/packages/suspense-website/index.tsx +++ b/packages/suspense-website/index.tsx @@ -15,6 +15,7 @@ import { IS_PROMISE_LIKE, USE_CACHE_MUTATION, USE_CACHE_STATUS, + USE_CACHE_VALUE, USE_STREAMING_CACHE, } from "./src/routes/config"; @@ -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"; @@ -83,6 +85,7 @@ root.render( } /> } /> } /> + } /> } diff --git a/packages/suspense-website/src/examples/index.ts b/packages/suspense-website/src/examples/index.ts index 0fa734b..9d2d319 100644 --- a/packages/suspense-website/src/examples/index.ts +++ b/packages/suspense-website/src/examples/index.ts @@ -196,6 +196,12 @@ const useCacheMutation = { ), }; +const useCacheValue = { + hook: processExample( + readFileSync(join(__dirname, "useCacheValue", "hook.ts"), "utf8") + ), +}; + export { createCache, createDeferred, @@ -205,4 +211,5 @@ export { demos, isPromiseLike, useCacheMutation, + useCacheValue, }; diff --git a/packages/suspense-website/src/examples/useCacheValue/hook.ts b/packages/suspense-website/src/examples/useCacheValue/hook.ts new file mode 100644 index 0000000..6aa7afa --- /dev/null +++ b/packages/suspense-website/src/examples/useCacheValue/hook.ts @@ -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" ... + } +} diff --git a/packages/suspense-website/src/routes/Home.tsx b/packages/suspense-website/src/routes/Home.tsx index d60194d..9215201 100644 --- a/packages/suspense-website/src/routes/Home.tsx +++ b/packages/suspense-website/src/routes/Home.tsx @@ -19,6 +19,7 @@ import { IS_PROMISE_LIKE, USE_CACHE_MUTATION, USE_CACHE_STATUS, + USE_CACHE_VALUE, USE_STREAMING_CACHE, } from "./config"; @@ -86,6 +87,11 @@ export default function Route() { to={USE_CACHE_STATUS} type="code" /> + + +
+ + + +

+ Data can be fetched without suspending using the{" "} + useCacheValue hook. (This hook uses the imperative{" "} + readAsync API, called from an effect.) +

+ +
+ +

+ This hook exists as an escape hatch. When possible, values should be + loaded using the read API. +

+
+ + ); +} diff --git a/packages/suspense-website/src/routes/config.ts b/packages/suspense-website/src/routes/config.ts index 5341658..11cafdd 100644 --- a/packages/suspense-website/src/routes/config.ts +++ b/packages/suspense-website/src/routes/config.ts @@ -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 diff --git a/packages/suspense/CHANGELOG.md b/packages/suspense/CHANGELOG.md index 1f694df..04aa267 100644 --- a/packages/suspense/CHANGELOG.md +++ b/packages/suspense/CHANGELOG.md @@ -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` diff --git a/packages/suspense/src/cache/createCache.test.ts b/packages/suspense/src/cache/createCache.test.ts index 52e455e..74ebd34 100644 --- a/packages/suspense/src/cache/createCache.test.ts +++ b/packages/suspense/src/cache/createCache.test.ts @@ -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"); }); }); diff --git a/packages/suspense/src/cache/createCache.ts b/packages/suspense/src/cache/createCache.ts index df3c341..04cfec9 100644 --- a/packages/suspense/src/cache/createCache.ts +++ b/packages/suspense/src/cache/createCache.ts @@ -216,6 +216,8 @@ export function createCache, 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 { diff --git a/packages/suspense/src/hooks/useCacheValue.test.tsx b/packages/suspense/src/hooks/useCacheValue.test.tsx new file mode 100644 index 0000000..c17e0c1 --- /dev/null +++ b/packages/suspense/src/hooks/useCacheValue.test.tsx @@ -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 | string, [string, CacheLoadOptions]>; + let getCacheKey: jest.Mock; + let lastRenderedError: any = undefined; + let lastRenderedStatus: Status | undefined = undefined; + let lastRenderedValue: string | undefined = undefined; + let pendingDeferred: Deferred[] = []; + + 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(); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + expect(lastRenderedStatus).toBe(STATUS_PENDING); + + await act(async () => pendingDeferred[0].reject("rejected")); + + expect(lastRenderedError).toBe("rejected"); + expect(lastRenderedStatus).toBe(STATUS_REJECTED); + expect(lastRenderedValue).toBeUndefined(); + }); +}); diff --git a/packages/suspense/src/hooks/useCacheValue.ts b/packages/suspense/src/hooks/useCacheValue.ts new file mode 100644 index 0000000..a23fbac --- /dev/null +++ b/packages/suspense/src/hooks/useCacheValue.ts @@ -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 = { + error: undefined; + status: StatusResolved; + value: Value; +}; + +export function useCacheValue( + cache: Cache, + ...params: Params +): ErrorResponse | PendingResponse | ResolvedResponse { + 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, + }; + } +} diff --git a/packages/suspense/src/index.ts b/packages/suspense/src/index.ts index 3fe02a6..fd083b6 100644 --- a/packages/suspense/src/index.ts +++ b/packages/suspense/src/index.ts @@ -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";