src/lib/device-memory.ts
Rama main · réplica de lectura del repositorio original.
// "Don't remember display preferences" - one switch, and the
// whole of its logic.
//
// UK PECR Sch. A1 ¶6(1)(d) conditions the appearance/functionality exception on
// the service giving "a simple means of objecting, free of charge, to the
// storage or access". The right is to object to *the storage*, not to the value,
// so a preference you can rewrite but never un-store does not satisfy it. This
// module is that means: it removes what is stored and stops the future writes.
//
// **Suppression is an interposition on `setItem`, not a guard each writer
// consults.** Two things force that. `theme` belongs to next-themes and has no
// write site in this tree at all, so there is nothing to add a guard to; and
// wrapping the one `setTheme` call site is defeated by next-themes' own
// `storage` listener, which reacts to another tab removing the key by calling
// `setTheme(defaultTheme)` and writing it straight back - a remove/rewrite
// livelock with a second tab open. An interposition catches that rewrite too,
// because it catches every write regardless of who started it. The cost is that
// it is invisible magic a future reader has to discover, which is what this
// comment, the DECISIONS entry and the classification check in
// `storage-keys.test.ts` are the fence against.
//
// **Clearing goes by prefix; suppression goes by list.** Orphaned keys sit in
// browsers that ran an older build and have no literal left in this tree: the
// four superseded dive-profile series keys from the `-v2`, `-v3`, `-v4` and
// `-v5` bumps, and the last-auth-method key from the change that deleted the sign-in
// hint. A census cannot see any of them, and naming one here as a literal would oblige
// §10 of the privacy page to give a row to a key this app no longer writes, so
// `setOptOut(true)` walks live storage instead and removes everything under the
// prefix bar the named exclusions. Suppression cannot work that way - a key has
// to be classified before it can be dropped - so the covered list is a list,
// and check (4) of `storage-keys.test.ts` is what stops a new key sitting in
// neither.
//
// `access_token` is deliberately out of reach of the prefix rule, and that is
// recorded rather than left as a gap: `lib/api/client.ts` wrote it to
// `localStorage` until the change recorded under "Access token lives in memory
// only, never in `localStorage`", and it carries no prefix. The ground for
// leaving it is population, not harm - that change predates every release and
// the project's own hosted instance, so the only browser that ever held one is
// the maintainer's own.
//
// React-free on purpose: the read/subscribe pair below is in the
// `useSyncExternalStore` shape the other storage modules use, and the one
// component that consumes it is what renders under the theme provider.
/**
* The switch's own entry. **Presence means opted out** - the value is
* incidental, and nothing reads it.
*/
export const DEVICE_MEMORY_OPT_OUT_KEY = "opendiving:device-memory-opt-out";
/**
* The keys the switch removes and then refuses to store.
*
* `theme` is here without an `opendiving:` prefix because it is next-themes'
* own default key, configured in `app/layout.tsx` without a `storageKey`
* override - there is no literal for `storage-keys.test.ts` to sweep, which is
* why check (4) asserts this one by hand.
*
* `opendiving:entry-units` is covered even though it already passes the
* objection test on its own (emptying the overrides deletes the key): a switch
* that says "don't remember display preferences" and silently skipped the unit
* overrides would not do what it says.
*/
export const COVERED_KEYS = [
"theme",
"opendiving:passkey-nudge-dismissed",
"opendiving:dive-profile-series-v5",
"opendiving:gas-use-series",
"opendiving:dive-activity-view",
"opendiving:gas-use-view",
"opendiving:entry-units",
] as const;
/**
* The `opendiving:` keys the switch leaves alone, each for a stated reason.
*
* `post-auth-redirect` and `google-sign-in-attempts` are functional storage -
* they carry a sign-in across the mail-client or Google hop - and both remove
* themselves the moment they are read. The flag itself is the
* consent-mechanism-storage case: an objection this browser cannot remember is
* not an objection, and Sch. A1 ¶6(2) means the means need only be offered "in
* respect of the initial use" rather than re-offered per key or per visit.
*/
export const EXCLUDED_KEYS = [
"opendiving:post-auth-redirect",
"opendiving:google-sign-in-attempts",
DEVICE_MEMORY_OPT_OUT_KEY,
] as const;
const KEY_PREFIX = "opendiving:";
/**
* How the switch treats a key, or `null` for one it has never heard of.
*
* The `null` answer is the whole point: check (4) of `storage-keys.test.ts`
* fails on it, so a new browser-storage key cannot ship without somebody
* deciding whether the objection switch covers it.
*/
export function deviceMemoryClass(key: string): "covered" | "excluded" | null {
if ((COVERED_KEYS as readonly string[]).includes(key)) return "covered";
if ((EXCLUDED_KEYS as readonly string[]).includes(key)) return "excluded";
return null;
}
// Storage can be unavailable (Safari private mode, storage disabled), missing
// entirely under the test runner, or - on the SSR pass, since the root layout
// is a Server Component rendering client modules - reached with no `window` at
// all. The last is a `ReferenceError` rather than a storage error, which is why
// this never reads `window.localStorage` bare.
function storage(): Storage | undefined {
if (typeof window === "undefined") return undefined;
try {
return window.localStorage;
} catch {
return undefined;
}
}
/**
* Whether this browser has objected, read live from storage.
*
* The snapshot half of `useSyncExternalStore`, and also what the interposition
* consults on every write - deliberately not a boolean cached at install time,
* because the tab that has to honour the objection is often not the tab that
* made it.
*/
export function isOptedOut(): boolean {
try {
return storage()?.getItem(DEVICE_MEMORY_OPT_OUT_KEY) != null;
} catch {
return false;
}
}
/** The server (and pre-hydration) answer: nothing is stored, so nothing is opted out. */
export function isOptedOutOnServer(): boolean {
return false;
}
const listeners = new Set<() => void>();
// A `storage` event fires only in *other* documents, so this covers the second
// tab and the same-document notify below covers this one. Filtered on the key
// because the event fires for every write in every other tab, and `null` is the
// whole-store clear.
function onStorageEvent(event: StorageEvent): void {
if (event.key === null || event.key === DEVICE_MEMORY_OPT_OUT_KEY) {
for (const listener of listeners) listener();
}
}
/**
* The subscribe half of `useSyncExternalStore`.
*
* Genuinely subscribed, unlike `subscribeToNothing` in `chart-series-view.ts`:
* two surfaces render this same state, so a change made on one is what the
* other has to read.
*/
export function subscribeToOptOut(listener: () => void): () => void {
if (listeners.size === 0) {
window.addEventListener("storage", onStorageEvent);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
window.removeEventListener("storage", onStorageEvent);
}
};
}
// Every `opendiving:` key present bar the exclusions, plus the covered keys
// that carry no prefix. Collected before anything is removed, because removing
// while walking `key(i)` shifts every later index.
function keysToClear(current: Storage): string[] {
const found = new Set<string>();
for (let index = 0; index < current.length; index += 1) {
const key = current.key(index);
if (key === null) continue;
if (!key.startsWith(KEY_PREFIX)) continue;
if (deviceMemoryClass(key) === "excluded") continue;
found.add(key);
}
// `theme` has no prefix, so the walk above cannot find it. Any future
// literal-less covered key joins it here for free.
for (const key of COVERED_KEYS) {
if (!key.startsWith(KEY_PREFIX)) found.add(key);
}
return [...found];
}
/**
* Turns the objection on or off.
*
* **On** writes the flag and then clears: `theme` plus every non-excluded
* `opendiving:` key present. The flag goes first so that a second tab reacting
* to the removals already sees the objection and has its rewrite dropped.
*
* **Off** removes the flag and restores nothing. The stored values were the
* thing objected to, and ¶6(1)(d) is about the storage rather than the value -
* so "off" simply lets future interactions store again.
*/
export function setOptOut(optedOut: boolean): void {
const current = storage();
if (current) {
try {
if (optedOut) {
current.setItem(DEVICE_MEMORY_OPT_OUT_KEY, "1");
for (const key of keysToClear(current)) current.removeItem(key);
} else {
current.removeItem(DEVICE_MEMORY_OPT_OUT_KEY);
}
} catch {
// A browser that refuses storage refuses to remember the objection too,
// and has nothing stored to object to. Nothing to report, and the
// control still reflects what storage says on the next read.
}
}
for (const listener of listeners) listener();
}
// Set on the replacement so a second install is a no-op while a *fresh*
// `Storage` still gets wrapped - which is not the same question, and a
// module-level "installed" boolean would answer only the first. Under the test
// runner `test/memory-storage.ts` swaps in a new stub after every test.
const INTERPOSED = Symbol.for("opendiving.device-memory.interposed");
type Interposed = Storage["setItem"] & { [INTERPOSED]?: true };
// The object along the prototype chain that actually defines `setItem`:
// `Storage.prototype` in a browser, the stub object itself under the test
// runner, whose methods are own properties. Hard-coding `Storage.prototype`
// would make the interposition invisible to every test in this repo, and the
// suppression suite would pass against an implementation that does nothing.
function definitionSiteOf(target: object, name: string): object | undefined {
let current: object | null = target;
while (current !== null) {
if (Object.prototype.hasOwnProperty.call(current, name)) return current;
current = Object.getPrototypeOf(current) as object | null;
}
return undefined;
}
/**
* Installs the write suppression. Idempotent, and a no-op wherever there is no
* storage to interpose on.
*
* Called from the module scope of `components/device-memory-installer.tsx`, so
* it runs when the route's client bundle loads rather than when anything
* renders - which is what puts it ahead of the Home page cards that write their
* view keys from a mount effect with no interaction at all.
*/
export function installDeviceMemorySuppression(): void {
const current = storage();
if (!current) return;
const owner = definitionSiteOf(current, "setItem") as
(Storage & Record<string, unknown>) | undefined;
if (!owner) return;
const original = owner.setItem as Interposed;
if (original[INTERPOSED]) return;
const interposed: Interposed = function (
this: Storage,
key: string,
value: string,
): void {
// In a browser `sessionStorage` shares this prototype, so the receiver is
// what keeps the suppression to local storage. Anything else - including a
// detached call with no receiver at all - reaches the original untouched,
// which is what "everything else passes through byte-identical" means.
if (this === storage() && deviceMemoryClass(key) === "covered") {
if (isOptedOut()) return;
}
original.call(this, key, value);
};
interposed[INTERPOSED] = true;
owner.setItem = interposed;
}