Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/tools/safeLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
};
13 changes: 7 additions & 6 deletions src/useIsDark/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -90,7 +91,7 @@ export const useIsDarkClientSide: UseIsDark = () => {
data_fr_theme,
newColorScheme === "system" ? getSystemColorScheme() : newColorScheme
);
localStorage.setItem("scheme", newColorScheme);
safeLocalStorage.setItem("scheme", newColorScheme);
}
}
);
Expand Down Expand Up @@ -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_ } = ((): {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 28 additions & 5 deletions src/useIsDark/scriptToRunAsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,45 @@ 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,
};

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 = (() => {
Expand All @@ -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;
Expand Down Expand Up @@ -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}";
Expand Down
74 changes: 74 additions & 0 deletions test/runtime/lib/safeLocalStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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);
});
});
108 changes: 108 additions & 0 deletions test/runtime/lib/scriptToRunAsap.test.ts
Original file line number Diff line number Diff line change
@@ -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 <html>.
*/
function runScript(params: {
localStorage: unknown;
defaultColorScheme: "light" | "dark" | "system";
}) {
const htmlAttributes = new Map<string, string>();

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<string, string>([
["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");
});
});