Skip to content

Commit

Permalink
createExternallyManagedCache new cacheError and cacheValue methods
Browse files Browse the repository at this point in the history
  • Loading branch information
bvaughn committed Apr 20, 2023
1 parent 7b17346 commit f301694
Show file tree
Hide file tree
Showing 10 changed files with 220 additions and 31 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { managedCache } from "./createCache";

// REMOVE_BEFORE

const error = Error("Could not load JSON with id:example");

managedCache.cacheError(error, "example");
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { managedCache } from "./createCache";

const json = {} as JSON;

// REMOVE_BEFORE

managedCache.cacheValue(json, "example");
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ import { createExternallyManagedCache } from "suspense";
const managedCache = createExternallyManagedCache<[id: string], JSON>({
getKey: ([id]) => id,
});

// REMOVE_AFTER

export { managedCache };
16 changes: 14 additions & 2 deletions packages/suspense-website/src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,21 @@ const createDeferred = {
};

const createExternallyManagedCache = {
cache: processExample(
cacheError: processExample(
readFileSync(
join(__dirname, "createExternallyManagedCache", "cacheError.ts"),
"utf8"
)
),
cacheValue: processExample(
readFileSync(
join(__dirname, "createExternallyManagedCache", "cacheValue.ts"),
"utf8"
)
),
createCache: processExample(
readFileSync(
join(__dirname, "createExternallyManagedCache", "cache.ts"),
join(__dirname, "createExternallyManagedCache", "createCache.ts"),
"utf8"
)
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,30 @@ export default function Route() {
</code>{" "}
for caches that are externally managed.
</p>
<Code code={createExternallyManagedCache.cache} />
<Note>
An "externally managed" cache is one that does not load its own data.
</Note>
<Code code={createExternallyManagedCache.createCache} />
</Block>
<Block>
<p>
Store values in an externally managed cache with the{" "}
<code>cacheValue</code> method.
</p>
<Code code={createExternallyManagedCache.cacheValue} />
</Block>
<Block>
<p>
Store errors in an externally managed cache with the{" "}
<code>cacheError</code> method.
</p>
<Code code={createExternallyManagedCache.cacheError} />
</Block>
<Note type="warn">
<>
<p>
An externally managed cache is one that does not load its own data.
</p>
<p>Data must be explicitly written by external code.</p>
</>
</Note>
</Container>
);
}
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.34
* `createExternallyManagedCache` cache updated to support caching errors (via `cacheError` method); `cache` method renamed to `cacheValue` to differentiate.

## 0.0.33
* README changes

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 @@ -33,6 +33,7 @@ export type InternalCache<Params extends Array<any>, Value> = Cache<
> & {
__createPendingMutationRecordMap: () => CacheMap<string, Record<Value>>;
__getKey: (params: Params) => string;
__getOrCreateRecord: (...params: Params) => Record<Value>;
__isImmutable: () => boolean;
__mutationAbortControllerMap: Map<string, AbortController>;
__notifySubscribers: (params: Params) => void;
Expand Down Expand Up @@ -432,6 +433,7 @@ export function createCache<Params extends Array<any>, Value>(
// Internal API (used by useCacheMutation)
__createPendingMutationRecordMap: createPendingMutationRecordMap,
__getKey: getKey,
__getOrCreateRecord: getOrCreateRecord,
__isImmutable: () => immutable,
__mutationAbortControllerMap: mutationAbortControllerMap,
__notifySubscribers: notifySubscribers,
Expand Down
113 changes: 94 additions & 19 deletions packages/suspense/src/cache/createExternallyManagedCache.test.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,118 @@
import {
STATUS_NOT_FOUND,
STATUS_PENDING,
STATUS_REJECTED,
STATUS_RESOLVED,
} from "../constants";
import { ExternallyManagedCache } from "../types";
import { createExternallyManagedCache } from "./createExternallyManagedCache";

describe("createExternallyManagedCache", () => {
it("should create a cache with a no-op load method", () => {
const cache = createExternallyManagedCache<[string], string>({
let cache: ExternallyManagedCache<[string], string>;

beforeEach(() => {
jest.useFakeTimers();

cache = createExternallyManagedCache({
debugLabel: "cache",
getKey: ([string]) => string,
timeout: 100,
timeoutMessage: "Custom timeout message",
});
});

it("should cache values", () => {
expect(cache.getValueIfCached("test")).toBeUndefined();
cache.cacheValue("value", "test");
expect(cache.getStatus("test")).toBe(STATUS_RESOLVED);
expect(cache.getValue("test")).toBe("value");
});

it("should cache errors", () => {
expect(cache.getValueIfCached("test")).toBeUndefined();
cache.cache("value", "test");
expect(cache.getValueIfCached("test")).toBe("value");
cache.cacheError("expected error", "test");
expect(cache.getStatus("test")).toBe(STATUS_REJECTED);
expect(() => cache.getValue("test")).toThrow("expected error");
});

describe("subscribeToStatus", () => {
let callback: jest.Mock;

beforeEach(() => {
callback = jest.fn();
});

it("should update when resolved", async () => {
cache.subscribeToStatus(callback, "test");

expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(STATUS_NOT_FOUND);

cache.cacheValue("value", "test");

await Promise.resolve();

expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledWith(STATUS_PENDING);
expect(callback).toHaveBeenCalledWith(STATUS_RESOLVED);
});

it("should update when rejected", async () => {
cache.subscribeToStatus(callback, "test");

expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(STATUS_NOT_FOUND);

cache.cacheError("expected error", "test");

await Promise.resolve();

expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledWith(STATUS_PENDING);
expect(callback).toHaveBeenCalledWith(STATUS_REJECTED);
});
});

describe("timeout", () => {
it("should reject after the specified timeout", async () => {
const cache = createExternallyManagedCache<[string], string>({
debugLabel: "cache",
getKey: ([string]) => string,
timeout: 100,
timeoutMessage: "Custom timeout message",
});

const promise = cache.readAsync("test");

jest.advanceTimersByTime(1_000);

await expect(promise).rejects.toEqual("Custom timeout message");
});

it("should resolve if cached before the specified timeout", async () => {
const cache = createExternallyManagedCache<[string], string>({
debugLabel: "cache",
getKey: ([string]) => string,
timeout: 100,
timeoutMessage: "Custom timeout message",
});
it("should reject if an error is cached before the specified timeout", async () => {
const promise = cache.readAsync("test");

cache.cacheError("expected error", "test");

let thrown = null;
try {
await promise;
} catch (error) {
thrown = error;
}

expect(thrown).toBe("expected error");
});

it("should resolve if an error is cached before the specified timeout", async () => {
const promise = cache.readAsync("test");

cache.cache("value", "test");
cache.cacheValue("value", "test");

await expect(promise).resolves.toEqual("value");
});

it("should wait forever if no timeout is specified", async () => {
cache = createExternallyManagedCache({
debugLabel: "cache",
getKey: ([string]) => string,
});
cache.readAsync("test");

jest.advanceTimersByTime(60_000);
});
});
});
62 changes: 56 additions & 6 deletions packages/suspense/src/cache/createExternallyManagedCache.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,73 @@
import { Cache } from "../types";
import { createCache, CreateCacheOptions } from "./createCache";
import { CacheLoadOptions, ExternallyManagedCache } from "../types";
import {
updateRecordToRejected,
updateRecordToResolved,
} from "../utils/Record";
import { isPendingRecord } from "../utils/isRecordStatus";
import { createCache, CreateCacheOptions, InternalCache } from "./createCache";

export function createExternallyManagedCache<Params extends Array<any>, Value>(
options: Omit<CreateCacheOptions<Params, Value>, "load"> & {
timeout?: number;
timeoutMessage?: string;
}
): Cache<Params, Value> {
): ExternallyManagedCache<Params, Value> {
const { timeout, timeoutMessage = "Timed out", ...rest } = options;

return createCache<Params, Value>({
const decoratedCache = createCache<Params, Value>({
...rest,
load: async () =>
load: async (params: Params, loadOptions: CacheLoadOptions) =>
new Promise((resolve, reject) => {
if (timeout != null) {
setTimeout(() => {
reject(timeoutMessage);
if (!loadOptions.signal.aborted) {
reject(timeoutMessage);
}
}, timeout);
}
}),
});

const { __getKey, __getOrCreateRecord, __notifySubscribers, __recordMap } =
decoratedCache as InternalCache<Params, Value>;

const { cache, ...api } = decoratedCache;

return {
...api,
cacheError(error, ...params) {
const key = __getKey(params);
const record = __getOrCreateRecord(...params);
if (isPendingRecord(record)) {
const { abortController, deferred } = record.data;

abortController.abort();

updateRecordToRejected(record, error);

// Don't leave any pending request hanging
deferred.reject(error);
}

__recordMap.set(key, record);
__notifySubscribers(params);
},
cacheValue(value, ...params) {
const key = __getKey(params);
const record = __getOrCreateRecord(...params);
if (isPendingRecord(record)) {
const { abortController, deferred } = record.data;

abortController.abort();

updateRecordToResolved(record, value);

// Don't leave any pending request hanging
deferred.resolve(value);
}

__recordMap.set(key, record);
__notifySubscribers(params);
},
};
}
10 changes: 10 additions & 0 deletions packages/suspense/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ export interface CacheMap<Key, Value> {
set(key: Key, value: Value): this;
}

// Externally managed cache variant

export type ExternallyManagedCache<Params extends any[], Value> = Omit<
Cache<Params, Value>,
"cache"
> & {
cacheError(error: any, ...params: Params): void;
cacheValue(value: Value, ...params: Params): void;
};

// Hook types

export type ImperativeErrorResponse = {
Expand Down

0 comments on commit f301694

Please sign in to comment.