Skip to content

Commit

Permalink
useImperativeCacheValue supports async mutations made with useCacheMu…
Browse files Browse the repository at this point in the history
…tation

Specifically, it returns a previously resolved value when status is _pending_ due to a mutation
  • Loading branch information
bvaughn committed Jun 27, 2023
1 parent 0cde1aa commit 36fb6d8
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 18 deletions.
68 changes: 66 additions & 2 deletions packages/suspense/src/hooks/useCacheMutation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
import { createRoot } from "react-dom/client";
import { act } from "react-dom/test-utils";

import { Component, PropsWithChildren, ReactNode } from "react";
import { useImperativeCacheValue } from "..";
import { createCache } from "../cache/createCache";
import { Cache, CacheLoadOptions, Deferred, Status } from "../types";
import { createDeferred } from "../utils/createDeferred";
import { useCacheStatus } from "./useCacheStatus";
import { MutationApi, useCacheMutation } from "./useCacheMutation";
import { Component, PropsWithChildren, ReactNode } from "react";
import { useCacheStatus } from "./useCacheStatus";

type Props = { cacheKey: string };
type Rendered = { status: Status; value: string };
Expand Down Expand Up @@ -515,6 +516,69 @@ describe("useCacheMutation", () => {
);
});
});

describe("useImperativeCacheValue", () => {
beforeEach(() => {
Component = ({ cacheKey }: Props) => {
mutationApi[cacheKey] = useCacheMutation(cache);

const { status, value } = useImperativeCacheValue(cache, cacheKey);

mostRecentRenders[cacheKey] = {
status,
value,
};

return value;
};
});

it("should refetch values after a sync mutation", async () => {
await mount();

expect(JSON.stringify(mostRecentRenders)).toMatchInlineSnapshot(
`"{"one":{"status":"resolved","value":"one"},"two":{"status":"resolved","value":"two"}}"`
);

act(() => {
mutationApi.two!.mutateSync(["two"], "mutated-two");
});

expect(JSON.stringify(mostRecentRenders)).toMatchInlineSnapshot(
`"{"one":{"status":"resolved","value":"one"},"two":{"status":"resolved","value":"mutated-two"}}"`
);
});

it("should refetch values after an async mutation", async () => {
await mount();

expect(JSON.stringify(mostRecentRenders)).toMatchInlineSnapshot(
`"{"one":{"status":"resolved","value":"one"},"two":{"status":"resolved","value":"two"}}"`
);

let pendingDeferred: Deferred<string> | null = null;

await act(async () => {
// Don't wait for the mutation API to resolve; we want to test the in-between state too
mutationApi.two!.mutateAsync(["two"], async () => {
pendingDeferred = createDeferred<string>();
return pendingDeferred.promise;
});
});

expect(JSON.stringify(mostRecentRenders)).toMatchInlineSnapshot(
`"{"one":{"status":"resolved","value":"one"},"two":{"status":"pending","value":"two"}}"`
);

await act(async () => {
pendingDeferred!.resolve("mutated-two");
});

expect(JSON.stringify(mostRecentRenders)).toMatchInlineSnapshot(
`"{"one":{"status":"resolved","value":"one"},"two":{"status":"resolved","value":"mutated-two"}}"`
);
});
});
});

type State = { errorMessage: string | null };
Expand Down
56 changes: 51 additions & 5 deletions packages/suspense/src/hooks/useImperativeCacheValue.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import { Component, PropsWithChildren } from "react";
import { createRoot } from "react-dom/client";
import { Root, createRoot } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { createCache } from "../cache/createCache";
import {
Expand All @@ -29,6 +29,7 @@ describe("useImperativeCacheValue", () => {
let lastRenderedError: any = undefined;
let lastRenderedStatus: Status | undefined = undefined;
let lastRenderedValue: string | undefined = undefined;
let root: Root | null = null;

let pendingDeferred: Deferred<Value>[] = [];

Expand All @@ -42,14 +43,26 @@ describe("useImperativeCacheValue", () => {
return null;
}

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

async function update(cacheKey = "test") {
await act(async () => {
root!.render(
<>
<ErrorBoundary>
<Component cacheKey={cacheKey} />
</ErrorBoundary>
</>
);
Expand All @@ -70,6 +83,7 @@ describe("useImperativeCacheValue", () => {
});

container = null;
root = null;

cache = createCache<[string], Value>({
load: fetch,
Expand Down Expand Up @@ -172,6 +186,38 @@ describe("useImperativeCacheValue", () => {
expect(lastRenderedValue).toBeUndefined();
});

it("should support changed cache params", async () => {
expect(cache.getStatus("test")).toBe(STATUS_NOT_FOUND);

await mount("one");

expect(lastRenderedError).toBeUndefined();
expect(lastRenderedStatus).toBe(STATUS_PENDING);
expect(lastRenderedValue).toBeUndefined();

expect(pendingDeferred).toHaveLength(1);

await act(async () => pendingDeferred[0]!.resolve({ key: "resolved-one" }));

expect(lastRenderedError).toBeUndefined();
expect(lastRenderedStatus).toBe(STATUS_RESOLVED);
expect(lastRenderedValue).toEqual({ key: "resolved-one" });

await update("two");

expect(lastRenderedError).toBeUndefined();
expect(lastRenderedStatus).toBe(STATUS_PENDING);
expect(lastRenderedValue).toBeUndefined();

expect(pendingDeferred).toHaveLength(2);

await act(async () => pendingDeferred[1]!.resolve({ key: "resolved-two" }));

expect(lastRenderedError).toBeUndefined();
expect(lastRenderedStatus).toBe(STATUS_RESOLVED);
expect(lastRenderedValue).toEqual({ key: "resolved-two" });
});

describe("getCache", () => {
it("should re-fetch a value that has been evicted by the provided cache", async () => {
// Pre-cache value
Expand Down
41 changes: 34 additions & 7 deletions packages/suspense/src/hooks/useImperativeCacheValue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import {
STATUS_NOT_FOUND,
STATUS_PENDING,
Expand All @@ -22,40 +22,67 @@ export function useImperativeCacheValue<
...params: TParams
):
| ImperativeErrorResponse
| ImperativePendingResponse
| ImperativePendingResponse<Value>
| ImperativeResolvedResponse<Value> {
const status = useCacheStatus(cache, ...params);

const [prevParams, setPrevParams] = useState<TParams | undefined>(undefined);
const [prevValue, setPrevValue] = useState<Value | undefined>(undefined);

useEffect(() => {
switch (status) {
case STATUS_NOT_FOUND:
case STATUS_NOT_FOUND: {
cache.prefetch(...params);
break;
}
case STATUS_RESOLVED: {
// Cache most recently resolved value in case of a mutation
setPrevParams(params);
setPrevValue(cache.getValue(...params));
break;
}
}
}, [cache, status, ...params]);

switch (status) {
case STATUS_REJECTED:
case STATUS_REJECTED: {
let caught;
try {
cache.getValue(...params);
} catch (error) {
caught = error;
}
return { error: caught, status: STATUS_REJECTED, value: undefined };
case STATUS_RESOLVED:
}
case STATUS_RESOLVED: {
try {
return {
error: undefined,
status: STATUS_RESOLVED,
value: cache.getValue(...params),
};
} catch (error) {}
default:
break;
}
}

let paramsHaveChanged = prevParams === undefined;
if (prevParams) {
if (prevParams.length !== params.length) {
paramsHaveChanged = true;
} else {
for (let index = 0; index < params.length; index++) {
if (prevParams[index] !== params[index]) {
paramsHaveChanged = true;
break;
}
}
}
}

return {
error: undefined,
status: STATUS_PENDING,
value: undefined,
value: paramsHaveChanged ? undefined : prevValue,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function useImperativeIntervalCacheValues<
...params: Params
):
| ImperativeIntervalErrorResponse
| ImperativeIntervalPendingResponse
| ImperativeIntervalPendingResponse<Value[]>
| ImperativeIntervalResolvedResponse<Value[]> {
const status = useIntervalCacheStatus(cache, start, end, ...params);

Expand Down
7 changes: 4 additions & 3 deletions packages/suspense/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,10 @@ export type ImperativeErrorResponse = {
status: StatusRejected;
value: any;
};
export type ImperativePendingResponse = {
export type ImperativePendingResponse<Value> = {
error: undefined;
status: StatusPending;
value: undefined;
value: Value | undefined;
};
export type ImperativeResolvedResponse<Value> = {
error: undefined;
Expand All @@ -201,7 +201,8 @@ export type ImperativeResolvedResponse<Value> = {
};

export type ImperativeIntervalErrorResponse = ImperativeErrorResponse;
export type ImperativeIntervalPendingResponse = ImperativePendingResponse;
export type ImperativeIntervalPendingResponse<Value> =
ImperativePendingResponse<Value>;
export type ImperativeIntervalResolvedResponse<Value> =
ImperativeResolvedResponse<Value> & {
isPartialResult: boolean;
Expand Down

0 comments on commit 36fb6d8

Please sign in to comment.