diff --git a/src/tools/safeLocalStorage.ts b/src/tools/safeLocalStorage.ts new file mode 100644 index 000000000..b11e051e0 --- /dev/null +++ b/src/tools/safeLocalStorage.ts @@ -0,0 +1,33 @@ +/** + * `localStorage` wrapper that never throws. + * + * Chromium raises a `SecurityError` as soon as `window.localStorage` is *read* when storage is + * blocked for the document (third-party iframe with third-party cookies disabled, WebView with + * storage disabled, enterprise policy…). Safari in private mode used to throw a + * `QuotaExceededError` on `setItem`. In all those cases we behave as if the storage was empty: + * `getItem` returns `null` and writes are silently dropped, so the caller falls back to its + * defaults instead of crashing (https://github.com/codegouvfr/react-dsfr/issues/442). + */ +export const safeLocalStorage = { + "getItem": (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + "setItem": (key: string, value: string): void => { + try { + localStorage.setItem(key, value); + } catch { + // Storage unavailable: the preference simply won't be persisted. + } + }, + "removeItem": (key: string): void => { + try { + localStorage.removeItem(key); + } catch { + // Storage unavailable: nothing to remove. + } + } +}; diff --git a/src/useIsDark/client.ts b/src/useIsDark/client.ts index 8d7f42195..4eb9532ab 100644 --- a/src/useIsDark/client.ts +++ b/src/useIsDark/client.ts @@ -3,6 +3,7 @@ import { assert, type Equals } from "tsafe/assert"; import { isAmong } from "tsafe/isAmong"; import { createStatefulObservable, useRerenderOnChange } from "../tools/StatefulObservable"; import { useConstCallback } from "../tools/powerhooks/useConstCallback"; +import { safeLocalStorage } from "../tools/safeLocalStorage"; import { fr } from "../fr"; import { data_fr_scheme, data_fr_theme, rootColorSchemeStyleTagId } from "./constants"; @@ -90,7 +91,7 @@ export const useIsDarkClientSide: UseIsDark = () => { data_fr_theme, newColorScheme === "system" ? getSystemColorScheme() : newColorScheme ); - localStorage.setItem("scheme", newColorScheme); + safeLocalStorage.setItem("scheme", newColorScheme); } } ); @@ -141,15 +142,15 @@ export function startClientSideIsDarkLogic(params: { reset_persisted_value_if_website_config_changed: { const localStorageKey = "scheme-website-config-default"; - const localStorageValue = localStorage.getItem(localStorageKey); + const localStorageValue = safeLocalStorage.getItem(localStorageKey); if (localStorageValue === colorSchemeExplicitlyProvidedAsParameter) { break reset_persisted_value_if_website_config_changed; } - localStorage.removeItem("scheme"); + safeLocalStorage.removeItem("scheme"); - localStorage.setItem(localStorageKey, colorSchemeExplicitlyProvidedAsParameter); + safeLocalStorage.setItem(localStorageKey, colorSchemeExplicitlyProvidedAsParameter); } const { clientSideIsDark, ssrWasPerformedWithIsDark: ssrWasPerformedWithIsDark_ } = ((): { @@ -180,7 +181,7 @@ export function startClientSideIsDarkLogic(params: { })(); const isDarkFromLocalStorage = (() => { - const colorSchemeReadFromLocalStorage = localStorage.getItem("scheme"); + const colorSchemeReadFromLocalStorage = safeLocalStorage.getItem("scheme"); if (colorSchemeReadFromLocalStorage === null) { return undefined; @@ -233,7 +234,7 @@ export function startClientSideIsDarkLogic(params: { document.documentElement.setAttribute( data_fr_scheme, ((): ColorScheme | "system" => { - const colorSchemeReadFromLocalStorage = localStorage.getItem("scheme"); + const colorSchemeReadFromLocalStorage = safeLocalStorage.getItem("scheme"); if (colorSchemeReadFromLocalStorage === null) { return colorSchemeExplicitlyProvidedAsParameter; diff --git a/src/useIsDark/scriptToRunAsap.ts b/src/useIsDark/scriptToRunAsap.ts index c23a39e94..6fd6a81e5 100644 --- a/src/useIsDark/scriptToRunAsap.ts +++ b/src/useIsDark/scriptToRunAsap.ts @@ -24,6 +24,29 @@ export const getScriptToRunAsap: GetScriptToRunAsap = ({ { window.ssrWasPerformedWithIsDark = "${defaultColorScheme}" === "dark"; + + // Never throw when storage is blocked for the document (Chromium raises a SecurityError as + // soon as window.localStorage is read). Behave as if the storage was empty instead. + // See https://github.com/codegouvfr/react-dsfr/issues/442 + const safeLocalStorage = { + getItem: key => { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + setItem: (key, value) => { + try { + localStorage.setItem(key, value); + } catch {} + }, + removeItem: key => { + try { + localStorage.removeItem(key); + } catch {} + } + }; const sanitizer = typeof trustedTypes !== "undefined" ? trustedTypes.createPolicy("${trustedTypesPolicyName}-asap", { createHTML: s => s }) : { createHTML: s => s, }; @@ -31,15 +54,15 @@ export const getScriptToRunAsap: GetScriptToRunAsap = ({ reset_persisted_value_if_website_config_changed: { const localStorageKey = "scheme-website-config-default"; - const localStorageValue = localStorage.getItem(localStorageKey); + const localStorageValue = safeLocalStorage.getItem(localStorageKey); if (localStorageValue === "${defaultColorScheme}") { break reset_persisted_value_if_website_config_changed; } - localStorage.removeItem("scheme"); + safeLocalStorage.removeItem("scheme"); - localStorage.setItem(localStorageKey, "${defaultColorScheme}"); + safeLocalStorage.setItem(localStorageKey, "${defaultColorScheme}"); } const isDark = (() => { @@ -56,7 +79,7 @@ export const getScriptToRunAsap: GetScriptToRunAsap = ({ })(); const isDarkFromLocalStorage = (() => { - const colorSchemeReadFromLocalStorage = localStorage.getItem("scheme"); + const colorSchemeReadFromLocalStorage = safeLocalStorage.getItem("scheme"); if (colorSchemeReadFromLocalStorage === null) { return undefined; @@ -95,7 +118,7 @@ export const getScriptToRunAsap: GetScriptToRunAsap = ({ document.documentElement.setAttribute( "${data_fr_scheme}", (() => { - const colorSchemeReadFromLocalStorage = localStorage.getItem("scheme"); + const colorSchemeReadFromLocalStorage = safeLocalStorage.getItem("scheme"); if (colorSchemeReadFromLocalStorage === null) { return "${defaultColorScheme}"; diff --git a/test/runtime/lib/safeLocalStorage.test.ts b/test/runtime/lib/safeLocalStorage.test.ts new file mode 100644 index 000000000..0ceb2cbf6 --- /dev/null +++ b/test/runtime/lib/safeLocalStorage.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { safeLocalStorage } from "../../../src/tools/safeLocalStorage"; + +function installLocalStorage(storage: unknown) { + Object.defineProperty(globalThis, "localStorage", { + "configurable": true, + "get": () => storage + }); +} + +function installBlockedLocalStorage() { + // Chromium behavior when storage is blocked: the *getter* of window.localStorage throws. + Object.defineProperty(globalThis, "localStorage", { + "configurable": true, + "get": () => { + throw new DOMException( + "Failed to read the 'localStorage' property from 'Window': Access is denied for this document.", + "SecurityError" + ); + } + }); +} + +function createInMemoryStorage() { + const map = new Map(); + return { + "getItem": (key: string) => map.get(key) ?? null, + "setItem": (key: string, value: string) => { + map.set(key, value); + }, + "removeItem": (key: string) => { + map.delete(key); + } + }; +} + +describe("safeLocalStorage", () => { + afterEach(() => { + Reflect.deleteProperty(globalThis, "localStorage"); + }); + + it("delegates to localStorage when it is available", () => { + installLocalStorage(createInMemoryStorage()); + + expect(safeLocalStorage.getItem("scheme")).toBe(null); + safeLocalStorage.setItem("scheme", "dark"); + expect(safeLocalStorage.getItem("scheme")).toBe("dark"); + safeLocalStorage.removeItem("scheme"); + expect(safeLocalStorage.getItem("scheme")).toBe(null); + }); + + it("behaves as an empty storage when reading window.localStorage throws (storage blocked)", () => { + installBlockedLocalStorage(); + + expect(() => localStorage).toThrow("Access is denied"); + + expect(safeLocalStorage.getItem("scheme")).toBe(null); + expect(() => safeLocalStorage.setItem("scheme", "dark")).not.toThrow(); + expect(() => safeLocalStorage.removeItem("scheme")).not.toThrow(); + }); + + it("swallows write errors (e.g. QuotaExceededError) but keeps reads working", () => { + const storage = createInMemoryStorage(); + installLocalStorage({ + ...storage, + "setItem": () => { + throw new DOMException("The quota has been exceeded.", "QuotaExceededError"); + } + }); + + expect(() => safeLocalStorage.setItem("scheme", "dark")).not.toThrow(); + expect(safeLocalStorage.getItem("scheme")).toBe(null); + }); +}); diff --git a/test/runtime/lib/scriptToRunAsap.test.ts b/test/runtime/lib/scriptToRunAsap.test.ts new file mode 100644 index 000000000..fe1703b1e --- /dev/null +++ b/test/runtime/lib/scriptToRunAsap.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { getScriptToRunAsap } from "../../../src/useIsDark/scriptToRunAsap"; +import { data_fr_scheme, data_fr_theme } from "../../../src/useIsDark/constants"; + +const noop = () => undefined; + +/** + * Runs the generated inline script against a minimal fake DOM, with the given `localStorage` + * shadowing the global one, and returns the attributes set on . + */ +function runScript(params: { + localStorage: unknown; + defaultColorScheme: "light" | "dark" | "system"; +}) { + const htmlAttributes = new Map(); + + const documentElement = { + "setAttribute": (name: string, value: string) => { + htmlAttributes.set(name, value); + }, + "hasAttribute": (name: string) => htmlAttributes.has(name) + }; + + const document = { + documentElement, + "getElementById": () => null, + "querySelector": () => null, + "createElement": () => ({ "setAttribute": noop }), + "head": { "appendChild": noop } + }; + + const window = { "matchMedia": undefined }; + + class MutationObserver { + observe = noop; + disconnect = noop; + } + + const script = getScriptToRunAsap({ + "defaultColorScheme": params.defaultColorScheme, + "nonce": undefined, + "trustedTypesPolicyName": "react-dsfr" + }); + + new Function("window", "document", "localStorage", "MutationObserver", script)( + window, + document, + params.localStorage, + MutationObserver + ); + + return htmlAttributes; +} + +const blockedLocalStorage = new Proxy( + {}, + { + "get": () => { + throw new DOMException( + "Failed to read the 'localStorage' property from 'Window': Access is denied for this document.", + "SecurityError" + ); + } + } +); + +describe("getScriptToRunAsap", () => { + it("applies the persisted color scheme when localStorage works", () => { + const map = new Map([ + ["scheme", "dark"], + ["scheme-website-config-default", "light"] + ]); + const localStorage = { + "getItem": (key: string) => map.get(key) ?? null, + "setItem": (key: string, value: string) => { + map.set(key, value); + }, + "removeItem": (key: string) => { + map.delete(key); + } + }; + + const attributes = runScript({ localStorage, "defaultColorScheme": "light" }); + + expect(attributes.get(data_fr_theme)).toBe("dark"); + expect(attributes.get(data_fr_scheme)).toBe("dark"); + }); + + it("still applies the default color scheme when localStorage access throws (storage blocked)", () => { + const attributes = runScript({ + "localStorage": blockedLocalStorage, + "defaultColorScheme": "dark" + }); + + expect(attributes.get(data_fr_theme)).toBe("dark"); + expect(attributes.get(data_fr_scheme)).toBe("dark"); + }); + + it("falls back to light when nothing is persisted, storage is blocked and matchMedia is unavailable", () => { + const attributes = runScript({ + "localStorage": blockedLocalStorage, + "defaultColorScheme": "system" + }); + + expect(attributes.get(data_fr_theme)).toBe("light"); + expect(attributes.get(data_fr_scheme)).toBe("system"); + }); +});