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