mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { z } from "zod";
|
||||
import { usePersistedMapState, usePersistedState } from "./hooks";
|
||||
import * as PersistedState from "./persisted-state";
|
||||
|
||||
const recentIds = PersistedState.define({
|
||||
key: "test-recent-ids",
|
||||
storage: "local",
|
||||
schema: z.array(z.number()),
|
||||
default: [],
|
||||
});
|
||||
|
||||
const counts = PersistedState.defineMap({
|
||||
keyPrefix: "test-counts__",
|
||||
storage: "local",
|
||||
schema: z.number(),
|
||||
default: 0,
|
||||
});
|
||||
|
||||
function RecentIds() {
|
||||
const [ids, setIds] = usePersistedState(recentIds);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="ids">{ids.join(",")}</div>
|
||||
<button type="button" onClick={() => setIds([7])}>
|
||||
set
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIds((previous) =>
|
||||
PersistedState.prependToRecentList(previous, 8, 3),
|
||||
)
|
||||
}
|
||||
>
|
||||
prepend
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountsMirror() {
|
||||
const countsByKey = usePersistedMapState(counts);
|
||||
|
||||
return (
|
||||
<div data-testid="counts">
|
||||
{Object.entries(countsByKey)
|
||||
.map(([key, count]) => `${key}=${count}`)
|
||||
.sort()
|
||||
.join(",")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("usePersistedState", () => {
|
||||
test("reads the default when nothing is stored", async () => {
|
||||
const screen = await render(<RecentIds />);
|
||||
|
||||
await expect.element(screen.getByTestId("ids")).toHaveTextContent("");
|
||||
});
|
||||
|
||||
test("writes persist and update every subscribed component", async () => {
|
||||
const screen = await render(
|
||||
<div>
|
||||
<RecentIds />
|
||||
<RecentIds />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: "set" }).first().click();
|
||||
|
||||
for (const element of screen.getByTestId("ids").elements()) {
|
||||
expect(element).toHaveTextContent("7");
|
||||
}
|
||||
expect(window.localStorage.getItem("test-recent-ids")).toBe("[7]");
|
||||
});
|
||||
|
||||
test("updater form reads the current value", async () => {
|
||||
window.localStorage.setItem("test-recent-ids", "[1,2,3]");
|
||||
const screen = await render(<RecentIds />);
|
||||
|
||||
await screen.getByRole("button", { name: "prepend" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("ids")).toHaveTextContent("8,1,2");
|
||||
});
|
||||
|
||||
test("storage events from other tabs sync the value", async () => {
|
||||
const screen = await render(<RecentIds />);
|
||||
|
||||
window.localStorage.setItem("test-recent-ids", "[4,5]");
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "test-recent-ids" }),
|
||||
);
|
||||
|
||||
await expect.element(screen.getByTestId("ids")).toHaveTextContent("4,5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePersistedMapState", () => {
|
||||
test("reads all entries under the prefix and syncs entry writes", async () => {
|
||||
window.localStorage.setItem("test-counts__a", "1");
|
||||
const screen = await render(<CountsMirror />);
|
||||
|
||||
await expect.element(screen.getByTestId("counts")).toHaveTextContent("a=1");
|
||||
|
||||
PersistedState.writeMapEntry(counts, "b", 2);
|
||||
|
||||
await expect
|
||||
.element(screen.getByTestId("counts"))
|
||||
.toHaveTextContent("a=1,b=2");
|
||||
});
|
||||
});
|
||||
53
apps/web-react/app/modules/persisted-state/hooks.ts
Normal file
53
apps/web-react/app/modules/persisted-state/hooks.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import type {
|
||||
PersistedDefinition,
|
||||
PersistedMapDefinition,
|
||||
} from "./persisted-state";
|
||||
import * as PersistedState from "./persisted-state";
|
||||
|
||||
const SERVER_MAP_SNAPSHOT: Record<string, never> = {};
|
||||
|
||||
/**
|
||||
* Typed React state backed by a persisted definition. The value stays in sync
|
||||
* across components and browser tabs; the setter accepts a value or an updater.
|
||||
*/
|
||||
export function usePersistedState<T>(
|
||||
definition: PersistedDefinition<T>,
|
||||
): [T, (next: T | ((previous: T) => T)) => void] {
|
||||
const subscribeToDefinition = React.useCallback(
|
||||
(listener: () => void) => PersistedState.subscribe(definition, listener),
|
||||
[definition],
|
||||
);
|
||||
const value = React.useSyncExternalStore(
|
||||
subscribeToDefinition,
|
||||
() => PersistedState.read(definition),
|
||||
() => definition.default,
|
||||
);
|
||||
|
||||
const setValue = (next: T | ((previous: T) => T)) => {
|
||||
PersistedState.write(
|
||||
definition,
|
||||
typeof next === "function"
|
||||
? (next as (previous: T) => T)(PersistedState.read(definition))
|
||||
: next,
|
||||
);
|
||||
};
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
/** Read-only React view of a persisted map definition; write entries via `PersistedState.writeMapEntry`. */
|
||||
export function usePersistedMapState<T>(
|
||||
definition: PersistedMapDefinition<T>,
|
||||
): Record<string, T> {
|
||||
const subscribeToDefinition = React.useCallback(
|
||||
(listener: () => void) => PersistedState.subscribeMap(definition, listener),
|
||||
[definition],
|
||||
);
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToDefinition,
|
||||
() => PersistedState.readMap(definition),
|
||||
() => SERVER_MAP_SNAPSHOT,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect } from "vitest";
|
||||
import type {
|
||||
PersistedDefinition,
|
||||
PersistedMapDefinition,
|
||||
} from "./persisted-state";
|
||||
|
||||
/**
|
||||
* Asserts `decode(encode(x))` deep-equals `x` for every given example value,
|
||||
* and that a missing value decodes to the default. Every feature definition
|
||||
* registers a test with representative and edge-case values.
|
||||
*/
|
||||
export function assertRoundTrips<T>(
|
||||
definition: PersistedDefinition<T> | PersistedMapDefinition<T>,
|
||||
examples: NoInfer<T>[],
|
||||
) {
|
||||
expect
|
||||
.soft(definition.decode(null), "missing → default")
|
||||
.toEqual(definition.default);
|
||||
|
||||
for (const value of examples) {
|
||||
expect
|
||||
.soft(
|
||||
definition.decode(definition.encode(value)),
|
||||
`round trip of ${JSON.stringify(value)}`,
|
||||
)
|
||||
.toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Asserts that each given raw stored string decodes to the definition's default. */
|
||||
export function assertDecodesToDefault<T>(
|
||||
definition: PersistedDefinition<T> | PersistedMapDefinition<T>,
|
||||
raws: string[],
|
||||
) {
|
||||
for (const raw of raws) {
|
||||
expect
|
||||
.soft(definition.decode(raw), `${JSON.stringify(raw)} → default`)
|
||||
.toEqual(definition.default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as PersistedState from "./persisted-state";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "./persisted-state-test-utils";
|
||||
|
||||
const numberList = PersistedState.define({
|
||||
key: "test-number-list",
|
||||
storage: "local",
|
||||
schema: z.array(z.number()),
|
||||
default: [],
|
||||
});
|
||||
|
||||
const searchType = PersistedState.define({
|
||||
key: "test-search-type",
|
||||
storage: "local",
|
||||
schema: z.enum(["weapons", "users"]),
|
||||
default: "weapons",
|
||||
});
|
||||
|
||||
const dismissed = PersistedState.define({
|
||||
key: "test-dismissed",
|
||||
storage: "local",
|
||||
schema: z.boolean(),
|
||||
default: false,
|
||||
});
|
||||
|
||||
const counts = PersistedState.defineMap({
|
||||
keyPrefix: "test-counts__",
|
||||
storage: "local",
|
||||
schema: z.number(),
|
||||
default: 0,
|
||||
});
|
||||
|
||||
describe("define", () => {
|
||||
test("round-trips values", () => {
|
||||
assertRoundTrips(numberList, [[], [1, 2, 3]]);
|
||||
assertRoundTrips(searchType, ["weapons", "users"]);
|
||||
assertRoundTrips(dismissed, [true, false]);
|
||||
assertRoundTrips(counts, [0, 42]);
|
||||
});
|
||||
|
||||
test("malformed values decode to defaults", () => {
|
||||
assertDecodesToDefault(numberList, ["not json", '{"a":1}', '["a","b"]']);
|
||||
assertDecodesToDefault(searchType, ["teams", "[1,2]"]);
|
||||
assertDecodesToDefault(dismissed, ["yes"]);
|
||||
assertDecodesToDefault(counts, ["abc", "", "Infinity"]);
|
||||
});
|
||||
|
||||
test("decodes legacy plain-string values where the schema allows them", () => {
|
||||
expect(searchType.decode("users")).toBe("users");
|
||||
expect(dismissed.decode("true")).toBe(true);
|
||||
expect(counts.decode("7")).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prependToRecentList", () => {
|
||||
test("prepends a new item", () => {
|
||||
expect(PersistedState.prependToRecentList([1, 2], 3, 5)).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
test("moves an existing item to the front", () => {
|
||||
expect(PersistedState.prependToRecentList([1, 2, 3], 2, 5)).toEqual([
|
||||
2, 1, 3,
|
||||
]);
|
||||
});
|
||||
|
||||
test("caps the list length", () => {
|
||||
expect(PersistedState.prependToRecentList([1, 2, 3], 4, 3)).toEqual([
|
||||
4, 1, 2,
|
||||
]);
|
||||
});
|
||||
});
|
||||
249
apps/web-react/app/modules/persisted-state/persisted-state.ts
Normal file
249
apps/web-react/app/modules/persisted-state/persisted-state.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
const readCaches = new WeakMap<
|
||||
object,
|
||||
{ raw: string | null; value: unknown }
|
||||
>();
|
||||
const mapCaches = new WeakMap<object, Record<string, unknown>>();
|
||||
const listenersByDefinition = new WeakMap<object, Set<() => void>>();
|
||||
|
||||
const SERVER_MAP_SNAPSHOT: Record<string, never> = {};
|
||||
|
||||
type StorageKind = "local" | "session";
|
||||
|
||||
interface DefinitionBase<T> {
|
||||
storage: StorageKind;
|
||||
default: T;
|
||||
/** Decodes a raw stored string. Total: resolves to the default for missing or malformed values, never throws. */
|
||||
decode: (raw: string | null) => T;
|
||||
/** Encodes a value into its stored string form. */
|
||||
encode: (value: T) => string;
|
||||
}
|
||||
|
||||
export interface PersistedDefinition<T> extends DefinitionBase<T> {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface PersistedMapDefinition<T> extends DefinitionBase<T> {
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a client state persisted in web storage. One definition per stored
|
||||
* key drives reads, writes, cross-tab sync and the `usePersistedState` hook.
|
||||
* Decoding is total: the default resolves for missing or malformed values,
|
||||
* legacy plain-string values are accepted where the schema allows them.
|
||||
*/
|
||||
export function define<S extends z.ZodType>(options: {
|
||||
key: string;
|
||||
storage: StorageKind;
|
||||
schema: S;
|
||||
default: z.output<S>;
|
||||
}): PersistedDefinition<z.output<S>> {
|
||||
return {
|
||||
key: options.key,
|
||||
storage: options.storage,
|
||||
default: options.default,
|
||||
...codec(options.schema, options.default),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a keyed family of persisted values sharing a storage key prefix,
|
||||
* for maps whose entries are written independently (e.g. per chat room).
|
||||
*/
|
||||
export function defineMap<S extends z.ZodType>(options: {
|
||||
keyPrefix: string;
|
||||
storage: StorageKind;
|
||||
schema: S;
|
||||
default: z.output<S>;
|
||||
}): PersistedMapDefinition<z.output<S>> {
|
||||
return {
|
||||
keyPrefix: options.keyPrefix,
|
||||
storage: options.storage,
|
||||
default: options.default,
|
||||
...codec(options.schema, options.default),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the persisted value, resolving to the default on the server or when the stored value is missing or malformed. */
|
||||
export function read<T>(definition: PersistedDefinition<T>): T {
|
||||
if (typeof window === "undefined") return definition.default;
|
||||
|
||||
const raw = readRaw(definition.storage, definition.key);
|
||||
const cached = readCaches.get(definition);
|
||||
if (cached && cached.raw === raw) return cached.value as T;
|
||||
|
||||
const value = definition.decode(raw);
|
||||
readCaches.set(definition, { raw, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Writes the value to web storage and notifies this tab's subscribers (other tabs sync via the `storage` event). */
|
||||
export function write<T>(definition: PersistedDefinition<T>, value: T) {
|
||||
try {
|
||||
storageOf(definition.storage).setItem(
|
||||
definition.key,
|
||||
definition.encode(value),
|
||||
);
|
||||
} catch {
|
||||
// web storage may be unavailable
|
||||
}
|
||||
notify(definition);
|
||||
}
|
||||
|
||||
/** Subscribes to changes of the persisted value, from both this tab's writes and other tabs via the `storage` event. */
|
||||
export function subscribe<T>(
|
||||
definition: PersistedDefinition<T>,
|
||||
listener: () => void,
|
||||
) {
|
||||
const listeners = listenersOf(definition);
|
||||
listeners.add(listener);
|
||||
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (event.key !== null && event.key !== definition.key) return;
|
||||
listener();
|
||||
};
|
||||
window.addEventListener("storage", handleStorageEvent);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", handleStorageEvent);
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads all entries of a persisted map, keyed by the part of the storage key after the prefix. */
|
||||
export function readMap<T>(
|
||||
definition: PersistedMapDefinition<T>,
|
||||
): Record<string, T> {
|
||||
if (typeof window === "undefined") {
|
||||
return SERVER_MAP_SNAPSHOT as Record<string, T>;
|
||||
}
|
||||
|
||||
const cached = mapCaches.get(definition);
|
||||
if (cached) return cached as Record<string, T>;
|
||||
|
||||
const value = readMapFromStorage(definition);
|
||||
mapCaches.set(definition, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Writes one entry of a persisted map and notifies this tab's subscribers (other tabs sync via the `storage` event). */
|
||||
export function writeMapEntry<T>(
|
||||
definition: PersistedMapDefinition<T>,
|
||||
entryKey: string,
|
||||
value: T,
|
||||
) {
|
||||
try {
|
||||
storageOf(definition.storage).setItem(
|
||||
`${definition.keyPrefix}${entryKey}`,
|
||||
definition.encode(value),
|
||||
);
|
||||
} catch {
|
||||
// web storage may be unavailable
|
||||
}
|
||||
mapCaches.delete(definition);
|
||||
notify(definition);
|
||||
}
|
||||
|
||||
/** Subscribes to changes of any entry of a persisted map, from both this tab's writes and other tabs via the `storage` event. */
|
||||
export function subscribeMap<T>(
|
||||
definition: PersistedMapDefinition<T>,
|
||||
listener: () => void,
|
||||
) {
|
||||
const listeners = listenersOf(definition);
|
||||
listeners.add(listener);
|
||||
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (event.key !== null && !event.key.startsWith(definition.keyPrefix)) {
|
||||
return;
|
||||
}
|
||||
mapCaches.delete(definition);
|
||||
listener();
|
||||
};
|
||||
window.addEventListener("storage", handleStorageEvent);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", handleStorageEvent);
|
||||
};
|
||||
}
|
||||
|
||||
/** Moves `item` to the front of a most-recently-used list, deduplicating it and capping the list's length. */
|
||||
export function prependToRecentList<T>(
|
||||
items: readonly T[],
|
||||
item: T,
|
||||
maxLength: number,
|
||||
): T[] {
|
||||
return [item, ...items.filter((existing) => existing !== item)].slice(
|
||||
0,
|
||||
maxLength,
|
||||
);
|
||||
}
|
||||
|
||||
function codec<S extends z.ZodType>(schema: S, defaultValue: z.output<S>) {
|
||||
return {
|
||||
decode: (raw: string | null): z.output<S> => {
|
||||
if (raw === null) return defaultValue;
|
||||
const parsed = schema.safeParse(rawToJson(raw));
|
||||
return parsed.success ? parsed.data : defaultValue;
|
||||
},
|
||||
encode: (value: z.output<S>) => JSON.stringify(value),
|
||||
};
|
||||
}
|
||||
|
||||
function rawToJson(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
// legacy values were stored as plain strings
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function storageOf(kind: StorageKind): Storage {
|
||||
return kind === "local" ? window.localStorage : window.sessionStorage;
|
||||
}
|
||||
|
||||
function readRaw(kind: StorageKind, key: string): string | null {
|
||||
try {
|
||||
return storageOf(kind).getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMapFromStorage<T>(
|
||||
definition: PersistedMapDefinition<T>,
|
||||
): Record<string, T> {
|
||||
const result: Record<string, T> = {};
|
||||
try {
|
||||
const storage = storageOf(definition.storage);
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (!key?.startsWith(definition.keyPrefix)) continue;
|
||||
|
||||
result[key.slice(definition.keyPrefix.length)] = definition.decode(
|
||||
storage.getItem(key),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// web storage may be unavailable
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function listenersOf(definition: object): Set<() => void> {
|
||||
let listeners = listenersByDefinition.get(definition);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
listenersByDefinition.set(definition, listeners);
|
||||
}
|
||||
return listeners;
|
||||
}
|
||||
|
||||
function notify(definition: object) {
|
||||
for (const listener of listenersOf(definition)) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user