From e241b808c1b6b1aa82d72c1438e1764a1fad9ddd Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Mon, 27 Feb 2023 22:32:10 -0500
Subject: [PATCH 1/4] Add WeakRef and FinalizationRegistry to createCache
---
.../src/routes/examples/streaming-cache.tsx | 1 -
packages/suspense/src/cache/createCache.ts | 70 +++++++++++++++----
.../src/cache/createSingleEntryCache.ts | 16 ++---
.../suspense/src/utils/WeakRefMap.test.ts | 39 +++++++++++
packages/suspense/src/utils/WeakRefMap.ts | 52 ++++++++++++++
packages/suspense/src/utils/stringify.ts | 20 ++++++
tsconfig.json | 2 +-
7 files changed, 176 insertions(+), 24 deletions(-)
create mode 100644 packages/suspense/src/utils/WeakRefMap.test.ts
create mode 100644 packages/suspense/src/utils/WeakRefMap.ts
create mode 100644 packages/suspense/src/utils/stringify.ts
diff --git a/packages/suspense-website/src/routes/examples/streaming-cache.tsx b/packages/suspense-website/src/routes/examples/streaming-cache.tsx
index 1652f2a..004711d 100644
--- a/packages/suspense-website/src/routes/examples/streaming-cache.tsx
+++ b/packages/suspense-website/src/routes/examples/streaming-cache.tsx
@@ -22,7 +22,6 @@ export default function Route() {
to render a larger data set as it incrementally loads.
Click the "start demo" button to fetch user data in the cache.
- {/*
*/}
diff --git a/packages/suspense/src/cache/createCache.ts b/packages/suspense/src/cache/createCache.ts
index 0a7a732..911e172 100644
--- a/packages/suspense/src/cache/createCache.ts
+++ b/packages/suspense/src/cache/createCache.ts
@@ -17,16 +17,27 @@ import {
import { assertPendingRecord } from "../utils/assertPendingRecord";
import { isThenable } from "../utils/isThenable";
import { isPendingRecord } from "../utils/isPendingRecord";
+import { WeakRefMap } from "../utils/WeakRefMap";
+
+export type CreateCacheOptions, Value> = {
+ config?: {
+ useWeakRef?: boolean;
+ };
+ debugLabel?: string;
+ getKey?: (...params: Params) => string;
+ load: (...params: [...Params, CacheLoadOptions]) => Thenable | Value;
+};
// Enable to help with debugging in dev
const DEBUG_LOG_IN_DEV = false;
-export function createCache, Value>(options: {
- load: (...params: [...Params, CacheLoadOptions]) => Thenable | Value;
- getKey?: (...params: Params) => string;
- debugLabel?: string;
-}): Cache {
- const { debugLabel, getKey = defaultGetKey, load } = options;
+const WearRefMapSymbol = Symbol.for("WearRefMap");
+
+export function createCache, Value>(
+ options: CreateCacheOptions
+): Cache {
+ const { config = {}, debugLabel, getKey = defaultGetKey, load } = options;
+ const { useWeakRef = true } = config;
const debugLogInDev = (debug: string, params?: Params, ...args: any[]) => {
if (DEBUG_LOG_IN_DEV && process.env.NODE_ENV === "development") {
@@ -47,6 +58,10 @@ export function createCache, Value>(options: {
const recordMap = new Map>();
const subscriberMap = new Map>();
+ const weakRefMap = new WeakRefMap((cacheKey: string) => {
+ recordMap.delete(cacheKey);
+ subscriberMap.delete(cacheKey);
+ });
function abort(...params: Params): boolean {
const cacheKey = getKey(...params);
@@ -55,6 +70,7 @@ export function createCache, Value>(options: {
debugLogInDev("abort()", params);
recordMap.delete(cacheKey);
+ weakRefMap.delete(cacheKey);
record.value.abortController.abort();
@@ -70,9 +86,11 @@ export function createCache, Value>(options: {
const cacheKey = getKey(...params);
const record: Record = {
status: STATUS_RESOLVED,
- value,
+ value: null,
};
+ writeRecordValue(record, value, ...params);
+
debugLogInDev("cache()", params, value);
recordMap.set(cacheKey, record);
@@ -83,6 +101,8 @@ export function createCache, Value>(options: {
debugLogInDev(`evict()`, params);
+ weakRefMap.delete(cacheKey);
+
return recordMap.delete(cacheKey);
}
@@ -140,7 +160,7 @@ export function createCache, Value>(options: {
} else if (record.status !== STATUS_RESOLVED) {
throw Error(`Record found with status "${record.status}"`);
} else {
- return record.value;
+ return readRecordValue(record, ...params);
}
}
@@ -149,7 +169,7 @@ export function createCache, Value>(options: {
const record = recordMap.get(cacheKey);
if (record?.status === STATUS_RESOLVED) {
- return record.value;
+ return readRecordValue(record, ...params);
}
}
@@ -165,7 +185,7 @@ export function createCache, Value>(options: {
case STATUS_PENDING:
return record.value.deferred;
case STATUS_RESOLVED:
- return record.value;
+ return readRecordValue(record, ...params);
case STATUS_REJECTED:
throw record.value;
}
@@ -174,7 +194,7 @@ export function createCache, Value>(options: {
function fetchSuspense(...params: Params): Value {
const record = getOrCreateRecord(...params);
if (record.status === STATUS_RESOLVED) {
- return record.value;
+ return readRecordValue(record, ...params);
} else if (isPendingRecord(record)) {
throw record.value.deferred;
} else {
@@ -210,7 +230,8 @@ export function createCache, Value>(options: {
if (!abortSignal.aborted) {
record.status = STATUS_RESOLVED;
- record.value = value;
+
+ writeRecordValue(record, value, ...params);
deferred.resolve(value);
}
@@ -228,6 +249,16 @@ export function createCache, Value>(options: {
}
}
+ function readRecordValue(record: Record, ...params: Params): Value {
+ const value = record.value;
+ if (value === WearRefMapSymbol) {
+ const cacheKey = getKey(...params);
+ return weakRefMap.get(cacheKey);
+ } else {
+ return value;
+ }
+ }
+
function subscribeToStatus(
callback: StatusCallback,
...params: Params
@@ -255,6 +286,21 @@ export function createCache, Value>(options: {
}
}
+ function writeRecordValue(
+ record: Record,
+ value: Value,
+ ...params: Params
+ ): void {
+ if (useWeakRef && value != null && typeof value === "object") {
+ record.value = WearRefMapSymbol;
+
+ const cacheKey = getKey(...params);
+ weakRefMap.set(cacheKey, value);
+ } else {
+ record.value = value;
+ }
+ }
+
return {
abort,
cache,
diff --git a/packages/suspense/src/cache/createSingleEntryCache.ts b/packages/suspense/src/cache/createSingleEntryCache.ts
index e036599..78c32c5 100644
--- a/packages/suspense/src/cache/createSingleEntryCache.ts
+++ b/packages/suspense/src/cache/createSingleEntryCache.ts
@@ -1,20 +1,16 @@
-import { createCache } from "./createCache";
-import { Cache, CacheLoadOptions, Thenable } from "../types";
+import { createCache, CreateCacheOptions } from "./createCache";
+import { Cache } from "../types";
const key = Symbol.for("createSingleEntryCache").toString();
-export function createSingleEntryCache<
- Params extends Array,
- Value
->(options: {
- load: (...params: [...Params, CacheLoadOptions]) => Thenable | Value;
- debugLabel?: string;
-}): Cache {
+export function createSingleEntryCache, Value>(
+ options: Omit, "getKey">
+): Cache {
if (options.hasOwnProperty("getKey")) {
throw Error("createSingleEntryCache does not support a getKey option");
}
- return createCache({
+ return createCache({
getKey: () => key,
...options,
});
diff --git a/packages/suspense/src/utils/WeakRefMap.test.ts b/packages/suspense/src/utils/WeakRefMap.test.ts
new file mode 100644
index 0000000..807e633
--- /dev/null
+++ b/packages/suspense/src/utils/WeakRefMap.test.ts
@@ -0,0 +1,39 @@
+import { WeakRefMap } from "./WeakRefMap";
+
+describe("WeakRefMap", () => {
+ it("should implement a basic Map-like API", () => {
+ const finalizer = jest.fn();
+
+ const foo = { foo: true };
+ const bar = { bar: true };
+
+ const map = new WeakRefMap(finalizer);
+ map.set("foo", foo);
+ map.set("bar", bar);
+
+ expect(map.size()).toBe(2);
+ expect(map.has("foo")).toBe(true);
+ expect(map.has("bar")).toBe(true);
+ expect(map.get("foo")).toBe(foo);
+ expect(map.get("bar")).toBe(bar);
+
+ expect(map.delete("foo")).toBe(true);
+ expect(map.size()).toBe(1);
+ expect(map.has("foo")).toBe(false);
+ expect(map.get("foo")).toBe(undefined);
+ expect(map.has("bar")).toBe(true);
+ expect(map.get("bar")).toBe(bar);
+
+ map.set("bar", foo);
+ expect(map.size()).toBe(1);
+ expect(map.has("bar")).toBe(true);
+ expect(map.get("bar")).toBe(foo);
+
+ map.delete("bar");
+ expect(map.size()).toBe(0);
+ expect(map.has("foo")).toBe(false);
+ expect(map.has("bar")).toBe(false);
+ expect(map.get("foo")).toBe(undefined);
+ expect(map.get("bar")).toBe(undefined);
+ });
+});
diff --git a/packages/suspense/src/utils/WeakRefMap.ts b/packages/suspense/src/utils/WeakRefMap.ts
new file mode 100644
index 0000000..afe3fd5
--- /dev/null
+++ b/packages/suspense/src/utils/WeakRefMap.ts
@@ -0,0 +1,52 @@
+export type FinalizerCallback = (key: Key) => void;
+
+export class WeakRefMap {
+ private finalizationRegistry: FinalizationRegistry;
+ private map: Map>;
+
+ constructor(finalizerCallback: FinalizerCallback) {
+ this.finalizationRegistry = new FinalizationRegistry((key) => {
+ this.map.delete(key);
+
+ finalizerCallback(key);
+ });
+ this.map = new Map>();
+ }
+
+ delete(key: Key): boolean {
+ const weakRef = this.map.get(key);
+
+ const result = this.map.delete(key);
+
+ if (weakRef) {
+ const value = weakRef.deref();
+ if (value != null) {
+ this.finalizationRegistry.unregister(value);
+ }
+ }
+
+ return result;
+ }
+
+ get(key: Key): Value | undefined {
+ const weakRef = this.map.get(key);
+
+ return weakRef ? weakRef.deref() : undefined;
+ }
+
+ has(key: Key): boolean {
+ return this.map.has(key);
+ }
+
+ size(): number {
+ return this.map.size;
+ }
+
+ set(key: Key, value: Value): void {
+ this.map.set(key, new WeakRef(value));
+
+ if (value != null) {
+ this.finalizationRegistry.register(value, key, value);
+ }
+ }
+}
diff --git a/packages/suspense/src/utils/stringify.ts b/packages/suspense/src/utils/stringify.ts
new file mode 100644
index 0000000..f6508d1
--- /dev/null
+++ b/packages/suspense/src/utils/stringify.ts
@@ -0,0 +1,20 @@
+// Convenience function to JSON-stringify values that may contain circular references.
+export function stringify(value: any, space?: string | number): string {
+ const cache: any[] = [];
+
+ return JSON.stringify(
+ value,
+ (key, value) => {
+ if (typeof value === "object" && value !== null) {
+ if (cache.includes(value)) {
+ return "[Circular]";
+ }
+
+ cache.push(value);
+ }
+
+ return value;
+ },
+ space
+ );
+}
diff --git a/tsconfig.json b/tsconfig.json
index b1c6651..d1a1681 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"esModuleInterop": true,
- "lib": ["ES2015", "DOM"],
+ "lib": ["DOM", "ES2015", "ES2021.WeakRef"],
"module": "es2020",
"moduleResolution": "node",
"noImplicitAny": true,
From b5807ec1933d40dc906f7d7142b04a6208f7aa6b Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Tue, 28 Feb 2023 08:56:19 -0500
Subject: [PATCH 2/4] Add WeakRefMap GC test
---
.../workflows/{jest.yml => jest.browser.yml} | 2 +-
.github/workflows/jest.node.yml | 15 ++++
packages/suspense/jest.config.node.js | 5 ++
packages/suspense/package.json | 2 +
.../src/cache/createCache.test.node.ts | 80 +++++++++++++++++++
...RefMap.test.ts => WeakRefMap.test.node.ts} | 26 +++++-
packages/suspense/src/utils/test.ts | 22 +++++
7 files changed, 148 insertions(+), 4 deletions(-)
rename .github/workflows/{jest.yml => jest.browser.yml} (89%)
create mode 100644 .github/workflows/jest.node.yml
create mode 100644 packages/suspense/jest.config.node.js
create mode 100644 packages/suspense/src/cache/createCache.test.node.ts
rename packages/suspense/src/utils/{WeakRefMap.test.ts => WeakRefMap.test.node.ts} (62%)
create mode 100644 packages/suspense/src/utils/test.ts
diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.browser.yml
similarity index 89%
rename from .github/workflows/jest.yml
rename to .github/workflows/jest.browser.yml
index cde522e..418259e 100644
--- a/.github/workflows/jest.yml
+++ b/.github/workflows/jest.browser.yml
@@ -2,7 +2,7 @@ name: "Jest unit tests"
on: [pull_request]
jobs:
tests_e2e:
- name: Run unit tests with Jest
+ name: Run Jest (browser based) unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
diff --git a/.github/workflows/jest.node.yml b/.github/workflows/jest.node.yml
new file mode 100644
index 0000000..470e5da
--- /dev/null
+++ b/.github/workflows/jest.node.yml
@@ -0,0 +1,15 @@
+name: "Jest unit tests"
+on: [pull_request]
+jobs:
+ tests_e2e:
+ name: Run Jest (node based) unit tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ - name: Initialize Yarn and install package dependencies
+ run: yarn
+ - name: Build NPM package
+ run: yarn prerelease
+ - name: Run tests
+ run: cd packages/suspense && yarn test:node
\ No newline at end of file
diff --git a/packages/suspense/jest.config.node.js b/packages/suspense/jest.config.node.js
new file mode 100644
index 0000000..ce2aff1
--- /dev/null
+++ b/packages/suspense/jest.config.node.js
@@ -0,0 +1,5 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+ preset: "ts-jest",
+ testMatch: ["**/*.test.node.{ts,tsx}"],
+};
diff --git a/packages/suspense/package.json b/packages/suspense/package.json
index d2e4de5..5000e0e 100644
--- a/packages/suspense/package.json
+++ b/packages/suspense/package.json
@@ -15,6 +15,8 @@
"scripts": {
"build": "parcel build",
"test": "jest",
+ "test:node": "node --expose-gc node_modules/.bin/jest --config=jest.config.node.js",
+ "test:node:watch": "node --expose-gc node_modules/.bin/jest --config=jest.config.node.js --watch",
"test:watch": "jest --watch",
"watch": "parcel watch"
},
diff --git a/packages/suspense/src/cache/createCache.test.node.ts b/packages/suspense/src/cache/createCache.test.node.ts
new file mode 100644
index 0000000..0a66636
--- /dev/null
+++ b/packages/suspense/src/cache/createCache.test.node.ts
@@ -0,0 +1,80 @@
+import { createCache } from "./createCache";
+import { Cache, CacheLoadOptions } from "../types";
+import { WeakRefMap } from "../utils/WeakRefMap";
+import { requestGC, waitForGC } from "../utils/test";
+
+describe("createCache", () => {
+ let cache: Cache<[string], Object>;
+ let fetch: jest.Mock | Object, [string, CacheLoadOptions]>;
+
+ afterEach(async () => {
+ await runAndWaitForGC();
+ });
+
+ beforeEach(() => {
+ fetch = jest.fn();
+ fetch.mockImplementation((key: string) => {
+ if (key.startsWith("async")) {
+ return Promise.resolve(key);
+ } else if (key.startsWith("error")) {
+ return Promise.reject(key);
+ } else {
+ return key;
+ }
+ });
+ });
+
+ it("should use WeakRefs if requested", async () => {
+ cache = createCache<[string], Object>({
+ config: { useWeakRef: true },
+ load: fetch,
+ });
+
+ cache.cache(createObject(), "one");
+ cache.cache(createObject(), "two");
+ cache.cache(createObject(), "three");
+
+ expect(cache.getValueIfCached("one")).not.toBeUndefined();
+ expect(cache.getValueIfCached("two")).not.toBeUndefined();
+ expect(cache.getValueIfCached("three")).not.toBeUndefined();
+
+ await runAndWaitForGC();
+
+ expect(cache.getValueIfCached("one")).toBeUndefined();
+ expect(cache.getValueIfCached("two")).toBeUndefined();
+ expect(cache.getValueIfCached("three")).toBeUndefined();
+ });
+
+ it("should not use WeakRefs if requested", async () => {
+ cache = createCache<[string], Object>({
+ config: { useWeakRef: false },
+ load: fetch,
+ });
+
+ cache.cache(createObject(), "one");
+ cache.cache(createObject(), "two");
+
+ expect(cache.getValueIfCached("one")).not.toBeUndefined();
+ expect(cache.getValueIfCached("two")).not.toBeUndefined();
+
+ await runAndWaitForGC();
+
+ expect(cache.getValueIfCached("one")).not.toBeUndefined();
+ expect(cache.getValueIfCached("two")).not.toBeUndefined();
+ });
+});
+
+function createObject(): Object {
+ return {};
+}
+
+async function runAndWaitForGC() {
+ const finalizer = jest.fn();
+
+ const map = new WeakRefMap(finalizer);
+ map.set("control", createObject());
+
+ requestGC();
+
+ await waitForGC(() => finalizer.mock.calls.length > 0);
+}
diff --git a/packages/suspense/src/utils/WeakRefMap.test.ts b/packages/suspense/src/utils/WeakRefMap.test.node.ts
similarity index 62%
rename from packages/suspense/src/utils/WeakRefMap.test.ts
rename to packages/suspense/src/utils/WeakRefMap.test.node.ts
index 807e633..b21da23 100644
--- a/packages/suspense/src/utils/WeakRefMap.test.ts
+++ b/packages/suspense/src/utils/WeakRefMap.test.node.ts
@@ -1,13 +1,19 @@
+import { requestGC, waitForGC } from "./test";
import { WeakRefMap } from "./WeakRefMap";
describe("WeakRefMap", () => {
- it("should implement a basic Map-like API", () => {
- const finalizer = jest.fn();
+ let finalizer: jest.Mock>;
+ let map: WeakRefMap;
+
+ beforeEach(() => {
+ finalizer = jest.fn();
+ map = new WeakRefMap(finalizer);
+ });
+ it("should implement a basic Map-like API", () => {
const foo = { foo: true };
const bar = { bar: true };
- const map = new WeakRefMap(finalizer);
map.set("foo", foo);
map.set("bar", bar);
@@ -36,4 +42,18 @@ describe("WeakRefMap", () => {
expect(map.get("foo")).toBe(undefined);
expect(map.get("bar")).toBe(undefined);
});
+
+ it("should call the finalizer function when a value is garbage collected", async () => {
+ map.set("foo", { foo: true });
+ map.set("bar", { bar: true });
+
+ finalizer.mockReset();
+
+ requestGC();
+
+ await waitForGC(() => finalizer.mock.calls.length === 2);
+
+ expect(finalizer).toHaveBeenCalledWith("foo");
+ expect(finalizer).toHaveBeenCalledWith("bar");
+ });
});
diff --git a/packages/suspense/src/utils/test.ts b/packages/suspense/src/utils/test.ts
new file mode 100644
index 0000000..dfbc7c2
--- /dev/null
+++ b/packages/suspense/src/utils/test.ts
@@ -0,0 +1,22 @@
+export function requestGC(): void {
+ // Node --expose-gc flag
+ global.gc();
+}
+
+export async function wait(delay: number): Promise {
+ await new Promise((resolve) => setTimeout(resolve, delay));
+}
+
+export async function waitForGC(
+ condition: () => boolean,
+ timeoutInSeconds: number = 5
+): Promise {
+ const startTime = process.hrtime();
+ do {
+ global.gc();
+ await wait(0);
+ } while (
+ condition() === false &&
+ process.hrtime(startTime)[0] < timeoutInSeconds
+ );
+}
From 6bdfb5b3ba5e440577ffb4d402762fabf70b201f Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Tue, 28 Feb 2023 15:30:50 -0500
Subject: [PATCH 3/4] WeakRefMap timing and key conflict value edge case
handling
---
.../src/cache/createCache.test.node.ts | 22 ++------
.../src/utils/WeakRefMap.test.node.ts | 24 ++++++---
packages/suspense/src/utils/WeakRefMap.ts | 31 ++++++++----
packages/suspense/src/utils/test.ts | 50 +++++++++++++++----
4 files changed, 82 insertions(+), 45 deletions(-)
diff --git a/packages/suspense/src/cache/createCache.test.node.ts b/packages/suspense/src/cache/createCache.test.node.ts
index 0a66636..eb7b5e1 100644
--- a/packages/suspense/src/cache/createCache.test.node.ts
+++ b/packages/suspense/src/cache/createCache.test.node.ts
@@ -1,16 +1,11 @@
import { createCache } from "./createCache";
import { Cache, CacheLoadOptions } from "../types";
-import { WeakRefMap } from "../utils/WeakRefMap";
import { requestGC, waitForGC } from "../utils/test";
describe("createCache", () => {
let cache: Cache<[string], Object>;
let fetch: jest.Mock | Object, [string, CacheLoadOptions]>;
- afterEach(async () => {
- await runAndWaitForGC();
- });
-
beforeEach(() => {
fetch = jest.fn();
fetch.mockImplementation((key: string) => {
@@ -38,7 +33,8 @@ describe("createCache", () => {
expect(cache.getValueIfCached("two")).not.toBeUndefined();
expect(cache.getValueIfCached("three")).not.toBeUndefined();
- await runAndWaitForGC();
+ await requestGC();
+ await waitForGC();
expect(cache.getValueIfCached("one")).toBeUndefined();
expect(cache.getValueIfCached("two")).toBeUndefined();
@@ -57,7 +53,8 @@ describe("createCache", () => {
expect(cache.getValueIfCached("one")).not.toBeUndefined();
expect(cache.getValueIfCached("two")).not.toBeUndefined();
- await runAndWaitForGC();
+ await requestGC();
+ await waitForGC();
expect(cache.getValueIfCached("one")).not.toBeUndefined();
expect(cache.getValueIfCached("two")).not.toBeUndefined();
@@ -67,14 +64,3 @@ describe("createCache", () => {
function createObject(): Object {
return {};
}
-
-async function runAndWaitForGC() {
- const finalizer = jest.fn();
-
- const map = new WeakRefMap(finalizer);
- map.set("control", createObject());
-
- requestGC();
-
- await waitForGC(() => finalizer.mock.calls.length > 0);
-}
diff --git a/packages/suspense/src/utils/WeakRefMap.test.node.ts b/packages/suspense/src/utils/WeakRefMap.test.node.ts
index b21da23..d36f663 100644
--- a/packages/suspense/src/utils/WeakRefMap.test.node.ts
+++ b/packages/suspense/src/utils/WeakRefMap.test.node.ts
@@ -17,26 +17,22 @@ describe("WeakRefMap", () => {
map.set("foo", foo);
map.set("bar", bar);
- expect(map.size()).toBe(2);
expect(map.has("foo")).toBe(true);
expect(map.has("bar")).toBe(true);
expect(map.get("foo")).toBe(foo);
expect(map.get("bar")).toBe(bar);
expect(map.delete("foo")).toBe(true);
- expect(map.size()).toBe(1);
expect(map.has("foo")).toBe(false);
expect(map.get("foo")).toBe(undefined);
expect(map.has("bar")).toBe(true);
expect(map.get("bar")).toBe(bar);
map.set("bar", foo);
- expect(map.size()).toBe(1);
expect(map.has("bar")).toBe(true);
expect(map.get("bar")).toBe(foo);
map.delete("bar");
- expect(map.size()).toBe(0);
expect(map.has("foo")).toBe(false);
expect(map.has("bar")).toBe(false);
expect(map.get("foo")).toBe(undefined);
@@ -49,11 +45,27 @@ describe("WeakRefMap", () => {
finalizer.mockReset();
- requestGC();
-
+ await requestGC();
await waitForGC(() => finalizer.mock.calls.length === 2);
expect(finalizer).toHaveBeenCalledWith("foo");
expect(finalizer).toHaveBeenCalledWith("bar");
});
+
+ it("should unregister a value when a key is updated", async () => {
+ map.set("test", { label: "one" });
+
+ expect(finalizer).not.toHaveBeenCalled();
+
+ const value = { label: "two" };
+ map.set("test", value);
+
+ await requestGC();
+ await waitForGC(() => finalizer.mock.calls.length > 0);
+
+ expect(finalizer).toHaveBeenCalledWith("test");
+
+ expect(map.has("test")).toBe(true);
+ expect(map.get("test")).toBe(value);
+ });
});
diff --git a/packages/suspense/src/utils/WeakRefMap.ts b/packages/suspense/src/utils/WeakRefMap.ts
index afe3fd5..7139b5a 100644
--- a/packages/suspense/src/utils/WeakRefMap.ts
+++ b/packages/suspense/src/utils/WeakRefMap.ts
@@ -1,29 +1,35 @@
export type FinalizerCallback = (key: Key) => void;
export class WeakRefMap {
+ private finalizerCallback: FinalizerCallback;
private finalizationRegistry: FinalizationRegistry;
private map: Map>;
constructor(finalizerCallback: FinalizerCallback) {
+ this.finalizerCallback = finalizerCallback;
+
+ this.map = new Map>();
this.finalizationRegistry = new FinalizationRegistry((key) => {
this.map.delete(key);
finalizerCallback(key);
});
- this.map = new Map>();
}
- delete(key: Key): boolean {
+ private unregister(key: Key): void {
const weakRef = this.map.get(key);
-
- const result = this.map.delete(key);
-
if (weakRef) {
const value = weakRef.deref();
if (value != null) {
this.finalizationRegistry.unregister(value);
}
}
+ }
+
+ delete(key: Key): boolean {
+ const result = this.map.delete(key);
+
+ this.unregister(key);
return result;
}
@@ -35,14 +41,19 @@ export class WeakRefMap {
}
has(key: Key): boolean {
- return this.map.has(key);
- }
-
- size(): number {
- return this.map.size;
+ // Handle timing edge case in case value has been GC'ed
+ // but FinalizationRegistry callback has not yet run.
+ return this.map.has(key) && this.map.get(key).deref() != null;
}
set(key: Key, value: Value): void {
+ if (this.map.has(key)) {
+ this.unregister(key);
+
+ // FinalizationRegistry won't trigger if we unregister.
+ this.finalizerCallback(key);
+ }
+
this.map.set(key, new WeakRef(value));
if (value != null) {
diff --git a/packages/suspense/src/utils/test.ts b/packages/suspense/src/utils/test.ts
index dfbc7c2..4310866 100644
--- a/packages/suspense/src/utils/test.ts
+++ b/packages/suspense/src/utils/test.ts
@@ -1,22 +1,50 @@
-export function requestGC(): void {
+export async function requestGC() {
+ await wait(100);
+
// Node --expose-gc flag
global.gc();
+
+ await wait(100);
}
export async function wait(delay: number): Promise {
await new Promise((resolve) => setTimeout(resolve, delay));
}
+export async function waitUntil(
+ conditional: () => boolean,
+ timeout: number = 5000
+) {
+ let startTime = performance.now();
+ do {
+ if (conditional()) {
+ return;
+ }
+
+ await wait(100);
+ } while (startTime + timeout > performance.now());
+
+ const elapsed = performance.now() - startTime;
+
+ throw Error(`Condition not met within ${elapsed}ms`);
+}
+
export async function waitForGC(
- condition: () => boolean,
- timeoutInSeconds: number = 5
+ conditional: () => boolean = () => true,
+ timeout: number = 5000
): Promise {
- const startTime = process.hrtime();
- do {
- global.gc();
- await wait(0);
- } while (
- condition() === false &&
- process.hrtime(startTime)[0] < timeoutInSeconds
- );
+ const finalizer = jest.fn();
+ const finalizationRegistry = new FinalizationRegistry(finalizer);
+ finalizationRegistry.register({}, "control");
+
+ await requestGC();
+
+ // Ensure some GC has occurred
+ await waitUntil(() => finalizer.mock.calls.length > 0);
+
+ expect(finalizer).toHaveBeenCalledTimes(1);
+ expect(finalizer).toHaveBeenCalledWith("control");
+
+ // Ensure additional constraints are met
+ await waitUntil(conditional, timeout);
}
From 8abaa514011b0c8725ac07772176aa355aaf1ec5 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Tue, 28 Feb 2023 15:51:16 -0500
Subject: [PATCH 4/4] Added useWeakRef flag to docs
---
packages/suspense-website/index.tsx | 22 ++++++---
.../src/components/Note.module.css | 11 +++++
.../createCache/cacheWithoutWeakRef.ts | 11 +++++
.../suspense-website/src/examples/index.ts | 6 +++
packages/suspense-website/src/routes/Home.tsx | 18 ++++---
.../src/routes/api/createCache.tsx | 11 ++++-
.../suspense-website/src/routes/config.ts | 12 +++--
.../src/routes/examples/memory-management.tsx | 49 +++++++++++++++++++
8 files changed, 120 insertions(+), 20 deletions(-)
create mode 100644 packages/suspense-website/src/examples/createCache/cacheWithoutWeakRef.ts
create mode 100644 packages/suspense-website/src/routes/examples/memory-management.tsx
diff --git a/packages/suspense-website/index.tsx b/packages/suspense-website/index.tsx
index 3ae4639..6c58a7b 100644
--- a/packages/suspense-website/index.tsx
+++ b/packages/suspense-website/index.tsx
@@ -6,10 +6,11 @@ import {
CREATE_DEFERRED,
CREATE_SINGLE_ENTRY_CACHE,
CREATE_STREAMING_CACHE,
- EXAMPLE_ABORT_A_REQUEST,
- EXAMPLE_FETCH_WITH_STATUS,
- EXAMPLE_MUTATING_A_CACHE_VALUE,
- EXAMPLE_STREAMING_CACHE,
+ GUIDE_ABORT_A_REQUEST,
+ GUIDE_FETCH_WITH_STATUS,
+ GUIDE_MEMORY_MANAGEMENT,
+ GUIDE_MUTATING_A_CACHE_VALUE,
+ GUIDE_STREAMING_CACHE,
IS_THENNABLE,
USE_CACHE_STATUS,
USE_STREAMING_CACHE,
@@ -25,6 +26,7 @@ import PageNotFoundRoute from "./src/routes/PageNotFound";
import UseCacheStatusRoute from "./src/routes/api/useCacheStatus";
import UseStreamingValuesRoute from "./src/routes/api/useStreamingValues";
import AbortingRequestRoute from "./src/routes/examples/aborting-a-request";
+import MemoryManagementRoute from "./src/routes/examples/memory-management";
import MutatingCacheValueRoute from "./src/routes/examples/mutating-a-cache-value";
import RenderingStatusWhileFetchingRoute from "./src/routes/examples/rendering-status-while-fetching";
import CreatingStreamingCacheRoute from "./src/routes/examples/streaming-cache";
@@ -51,19 +53,23 @@ root.render(
element={}
/>
}
/>
}
/>
}
+ />
+ }
/>
}
/>
} />
diff --git a/packages/suspense-website/src/components/Note.module.css b/packages/suspense-website/src/components/Note.module.css
index 076e389..ed4ba24 100644
--- a/packages/suspense-website/src/components/Note.module.css
+++ b/packages/suspense-website/src/components/Note.module.css
@@ -55,6 +55,17 @@
flex: 0 0 1.75rem !important;
}
+.Content {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.Content * {
+ line-height: 1;
+ margin: 0;
+}
+
.Note[data-type="note"] .Content,
.Note[data-type="quote"] .Content {
line-height: 1rem;
diff --git a/packages/suspense-website/src/examples/createCache/cacheWithoutWeakRef.ts b/packages/suspense-website/src/examples/createCache/cacheWithoutWeakRef.ts
new file mode 100644
index 0000000..c27f827
--- /dev/null
+++ b/packages/suspense-website/src/examples/createCache/cacheWithoutWeakRef.ts
@@ -0,0 +1,11 @@
+import { CacheLoadOptions, createCache } from "suspense";
+
+const load = async () => null as any;
+
+// REMOVE_BEFORE
+
+createCache<[userId: string], JSON>({
+ config: { useWeakRef: false },
+ load,
+ // ...
+});
diff --git a/packages/suspense-website/src/examples/index.ts b/packages/suspense-website/src/examples/index.ts
index 368d8a7..70f804d 100644
--- a/packages/suspense-website/src/examples/index.ts
+++ b/packages/suspense-website/src/examples/index.ts
@@ -31,6 +31,12 @@ const createCache = {
cacheWithSignal: processExample(
readFileSync(join(__dirname, "createCache", "cacheWithSignal.ts"), "utf8")
),
+ cacheWithoutWeakRef: processExample(
+ readFileSync(
+ join(__dirname, "createCache", "cacheWithoutWeakRef.ts"),
+ "utf8"
+ )
+ ),
evict: processExample(
readFileSync(join(__dirname, "createCache", "evict.ts"), "utf8")
),
diff --git a/packages/suspense-website/src/routes/Home.tsx b/packages/suspense-website/src/routes/Home.tsx
index 83a97b8..1e6fa4b 100644
--- a/packages/suspense-website/src/routes/Home.tsx
+++ b/packages/suspense-website/src/routes/Home.tsx
@@ -10,9 +10,10 @@ import {
CREATE_DEFERRED,
CREATE_SINGLE_ENTRY_CACHE,
CREATE_STREAMING_CACHE,
- EXAMPLE_ABORT_A_REQUEST,
- EXAMPLE_FETCH_WITH_STATUS,
- EXAMPLE_STREAMING_CACHE,
+ GUIDE_ABORT_A_REQUEST,
+ GUIDE_FETCH_WITH_STATUS,
+ GUIDE_MEMORY_MANAGEMENT,
+ GUIDE_STREAMING_CACHE,
IS_THENNABLE,
USE_CACHE_STATUS,
USE_STREAMING_CACHE,
@@ -93,19 +94,24 @@ export default function Route() {
diff --git a/packages/suspense-website/src/routes/api/createCache.tsx b/packages/suspense-website/src/routes/api/createCache.tsx
index 12a7cc9..b8b2ed9 100644
--- a/packages/suspense-website/src/routes/api/createCache.tsx
+++ b/packages/suspense-website/src/routes/api/createCache.tsx
@@ -5,7 +5,11 @@ import { createCache } from "../../examples";
import Header from "../../components/Header";
import SubHeading from "../../components/SubHeading";
import { Link } from "react-router-dom";
-import { IS_THENNABLE, USE_CACHE_STATUS } from "../config";
+import {
+ GUIDE_MEMORY_MANAGEMENT,
+ IS_THENNABLE,
+ USE_CACHE_STATUS,
+} from "../config";
import Note from "../../components/Note";
import { ExternalLink } from "../../components/ExternalLink";
@@ -48,6 +52,11 @@ export default function Route() {
that's provided to support cancellation.
+
+ Caches use WeakRef
and FinalizationRegistry
{" "}
+ by default,{" "}
+ but this is configurable.
+
diff --git a/packages/suspense-website/src/routes/config.ts b/packages/suspense-website/src/routes/config.ts
index 408b733..9ad14f4 100644
--- a/packages/suspense-website/src/routes/config.ts
+++ b/packages/suspense-website/src/routes/config.ts
@@ -1,3 +1,4 @@
+// API
export const CREATE_CACHE = "/createCache";
export const CREATE_DEFERRED = "/createDeferred";
export const CREATE_SINGLE_ENTRY_CACHE = "/createSingleEntryCache";
@@ -6,8 +7,9 @@ export const IS_THENNABLE = "/isThenable";
export const USE_CACHE_STATUS = "/useCacheStatus";
export const USE_STREAMING_CACHE = "/useStreamingValues";
-// Examples
-export const EXAMPLE_ABORT_A_REQUEST = "/example/abort-a-request";
-export const EXAMPLE_FETCH_WITH_STATUS = "/example/rendering-cache-status";
-export const EXAMPLE_MUTATING_A_CACHE_VALUE = "/example/mutating-a-cache-value";
-export const EXAMPLE_STREAMING_CACHE = "/example/writing-a-streaming-cache";
+// Guides
+export const GUIDE_ABORT_A_REQUEST = "/guide/abort-a-request";
+export const GUIDE_FETCH_WITH_STATUS = "/guide/rendering-cache-status";
+export const GUIDE_MEMORY_MANAGEMENT = "/guide/memory-management";
+export const GUIDE_MUTATING_A_CACHE_VALUE = "/guide/mutating-a-cache-value";
+export const GUIDE_STREAMING_CACHE = "/guide/writing-a-streaming-cache";
diff --git a/packages/suspense-website/src/routes/examples/memory-management.tsx b/packages/suspense-website/src/routes/examples/memory-management.tsx
new file mode 100644
index 0000000..7775774
--- /dev/null
+++ b/packages/suspense-website/src/routes/examples/memory-management.tsx
@@ -0,0 +1,49 @@
+import { Link } from "react-router-dom";
+import Block from "../../components/Block";
+import Code from "../../components/Code";
+import Container from "../../components/Container";
+import { ExternalLink } from "../../components/ExternalLink";
+import Header from "../../components/Header";
+import Note from "../../components/Note";
+import { createCache } from "../../examples";
+import { CREATE_CACHE } from "../config";
+
+export default function Route() {
+ return (
+
+
+
+
+
+
+ Caches created with{" "}
+
+ createCache
+
{" "}
+ use{" "}
+
+
+ WeakRef
+
+
{" "}
+ and{" "}
+
+
+ FinalizationRegistry
+
+
{" "}
+ APIs to avoid leaking memory. This behavior can be disabled using the{" "}
+ useWeakRef
configuration flag.
+
+
+
+ Caches that don't use weak refs may leak memory over time.
+
+ To avoid this, use the evict
method to remove entries
+ once you are done using them.
+
+
+
+
+ );
+}