Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -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");
});
});

View 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,
);
}

View File

@@ -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);
}
}

View File

@@ -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,
]);
});
});

View 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();
}
}