Protect toolbox data after IndexedDB startup failures
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-12 23:05:16 +02:00
parent b53c7c17c8
commit 09a2d4b71a
13 changed files with 242 additions and 5 deletions

View file

@ -39,3 +39,4 @@
{"id":"int-e79db6c1131fb3c5c57dde09917ef735","kind":"field_change","created_at":"2026-09-12T10:50:44.387707762Z","actor":"Shinuwa","issue_id":"sokko-g-8po","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Enabled app-wide StrictMode, restored recommended react-hooks/set-state-in-effect rule, documented development checks and added an effective ESLint configuration regression test. npm run check:full passed: lint, 101 Node tests, build and 36 E2E. All C10 subsets complete."}}
{"id":"int-4d9a5305507377a0f0d42d8c0e073c0e","kind":"field_change","created_at":"2026-09-12T11:13:23.757763936Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
{"id":"int-e86990d8d18afb8dec65036e0ab7712f","kind":"field_change","created_at":"2026-09-12T11:14:17.70653268Z","actor":"Shinuwa","issue_id":"sokko-g-usb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproducible production profiling completed and documented: npm run profile:toolboxes, four disk-backed scenarios and five genuine reloads each, 117.9 MiB identical media dataset. Raw and analysis reports in ignored .local/performance. Closed media retains about118MiB extra JS heap. check:full passes lint,101 Node tests,build,40 E2E. Initial read failure handling tracked separately as P1 sokko-g-d5u; no storage/schema changes."}}
{"id":"int-5a1d6dccbbcebf69807b6134785175ef","kind":"field_change","created_at":"2026-09-12T18:30:49.351851361Z","actor":"Shinuwa","issue_id":"sokko-g-d5u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Protected IndexedDB bootstrap: readiness granted only after complete successful reads; all store writes rejected before initialization; create/import/export guards; startup error/loading gate with non-destructive retry; rejected connection promise evicted for reopening; generic accurate write-error title. Editorial strings validated and storage/workflow docs updated. Four new E2E cover repeated open/read failures, preserved data and exports, creation after retry, direct command guards and later write errors. Desktop/mobile screenshots reviewed. npm run check:full passed lint,101 Node tests,build,44 E2E. No schema or format changes."}}

View file

@ -14,6 +14,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Mettre à jour `docs/STORAGE_SCHEMA.md`.
- Vérifier l'import et l'export si l'outil stocke des données.
- Pour une modification de persistance, vérifier les écritures successives en succès/échec, les suppressions, ainsi qu'un import concurrent avec une édition ; un échec ne doit pas restaurer une valeur jamais confirmée.
- Vérifier qu'une erreur d'ouverture ou de lecture au démarrage bloque les écritures, créations, imports et exports sans afficher une fausse liste vide ; plusieurs tentatives puis une reprise réussie doivent retrouver les données initiales sans recharger la page ni les effacer.
- Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip depuis le toggle import/export du header.
- Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu.
- Vérifier l'affichage dans la page toolbox complète.

View file

@ -37,6 +37,15 @@ Entrée type dans `modules` :
### Ordre et confirmation des écritures
Le démarrage attend la lecture complète du registre, des liens, des modules et
de la position des notifications avant d'ouvrir l'interface. Un échec laisse
le stockage non prêt : les méthodes d'écriture refusent les opérations, et la
création, l'import et l'export ne peuvent pas utiliser un état vide incomplet.
Le bouton de reprise relance uniquement les lectures, sans supprimer de données.
Une ouverture IndexedDB échouée n'est pas conservée dans le cache de connexion.
Les erreurs de lecture sont distinctes des erreurs d'écriture et ne sont pas
présentées systématiquement comme un dépassement de quota.
Dans une instance de l'application, le registre, les liens, les modules et la
position des notifications partagent une file d'écritures. L'interface présente
les modifications en attente ; en cas d'échec, elle est reconstruite depuis le

View file

@ -0,0 +1,20 @@
// Rôle : expose les commandes du vrai stockage et des actions sous StrictMode pour tester leur verrouillage initial.
import { createElement, StrictMode, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { useIndexedToolboxes } from "../../website/src/features/toolboxes/storage/useIndexedToolboxes.js";
import { useToolboxActions } from "../../website/src/features/toolboxes/useToolboxActions.js";
export function mountStorageStartupProbe(element) {
const root = createRoot(element);
const api = { store: null, actions: null, notices: [] };
const notify = (message) => api.notices.push(message);
function Probe() {
const store = useIndexedToolboxes(notify);
const actions = useToolboxActions({ store, notify, setConfirmModal: notify });
useLayoutEffect(() => { api.store = store; api.actions = actions; });
return null;
}
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe))));
return { ...api, get store() { return api.store; }, get actions() { return api.actions; }, unmount: () => root.unmount() };
}

143
e2e/storage-startup.spec.js Normal file
View file

@ -0,0 +1,143 @@
// Rôle : vérifie qu'une panne IndexedDB ne peut pas transformer les données existantes en un état vide modifiable.
/* global window, document, IDBFactory, IDBObjectStore, DOMException, Buffer */
import { test, expect } from "@playwright/test";
import { fileURLToPath, URL } from "node:url";
import { resetSokkoStorage } from "./helpers/storage.js";
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/storage-startup-probe.js", import.meta.url))}`;
const original = {
toolboxes: [{ id: "saved", name: "Conservée", updatedAt: "2026-09-12T12:00:00.000Z", modules: [{ id: "note", type: "memos", title: "Mémos" }] }],
links: { mhwilds: "saved" },
modules: [{ key: "saved:note", data: { items: [{ id: "memo", text: "Ne pas perdre" }] } }]
};
async function setup(page, fault) {
await resetSokkoStorage(page);
await page.evaluate(async (data) => {
const db = await import("/src/utils/indexedDbStorage.js");
await db.setToolboxSnapshot(data.toolboxes, data.links, Object.fromEntries(data.modules.map((entry) => [entry.key, entry.data])));
}, original);
await page.addInitScript((fault) => {
window.storageFault = fault;
const open = IDBFactory.prototype.open;
IDBFactory.prototype.open = function (...args) {
if (window.storageFault === "open") throw new DOMException("Ouverture indisponible", "UnknownError");
return open.apply(this, args);
};
const getAll = IDBObjectStore.prototype.getAll;
IDBObjectStore.prototype.getAll = function (...args) {
if (window.storageFault === "read" && this.name === "modules") throw new DOMException("Lecture indisponible", "UnknownError");
return getAll.apply(this, args);
};
const put = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function (...args) {
if (window.storageFault === "write") throw new DOMException("Écriture indisponible", "UnknownError");
return put.apply(this, args);
};
}, fault);
await page.goto("/#/toolboxes");
await page.reload();
}
async function snapshot(page) {
return page.evaluate(async () => {
const fault = window.storageFault;
window.storageFault = "";
try {
const db = await import("/src/utils/indexedDbStorage.js");
return { toolboxes: await db.getToolboxes(), links: await db.getLinks(), modules: await db.getAllModuleData() };
} finally {
window.storageFault = fault;
}
});
}
for (const fault of ["open", "read"]) {
test(`initial ${fault} failure blocks the interface and retries without changing persisted data`, async ({ page }, testInfo) => {
await page.setViewportSize(fault === "read" ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
await setup(page, fault);
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
await expect(page.getByRole("alert")).toHaveText(fault === "open" ? "Ouverture indisponible" : "Lecture indisponible");
await expect(page.getByRole("button", { name: /Nouvelle toolbox|Exporter tout|Importer/ })).toHaveCount(0);
await expect(page.getByText("Aucune toolbox", { exact: true })).toHaveCount(0);
await expect(page.getByText("Quota local atteint")).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath("storage-unavailable.png"), fullPage: true });
await page.getByRole("button", { name: "Réessayer" }).click();
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
await page.evaluate(() => { window.location.hash = "/toolbox/saved"; });
await expect(page.getByRole("heading", { name: "Toolbox introuvable" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
await page.evaluate(() => { window.storageFault = ""; });
await page.getByRole("button", { name: "Réessayer" }).click();
await expect(page.locator('[data-module-type="memos"]')).toBeVisible();
await expect(page.getByText("Ne pas perdre", { exact: true }).first()).toBeVisible();
expect(await snapshot(page)).toEqual(original);
await page.evaluate(() => { window.location.hash = "/toolboxes"; });
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Exporter tout", exact: true }).click();
const download = await downloadPromise;
const stream = await download.createReadStream();
const chunks = [];
for await (const chunk of stream) chunks.push(chunk);
const exported = JSON.parse(Buffer.concat(chunks).toString());
expect(exported.toolboxes[0].id).toBe("saved");
expect(exported.modules["saved:note"].items[0].text).toBe("Ne pas perdre");
expect(await snapshot(page)).toEqual(original);
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("Après reprise");
await page.getByRole("button", { name: "Créer", exact: true }).click();
await expect.poll(async () => (await snapshot(page)).toolboxes.length).toBe(2);
const saved = await snapshot(page);
expect(saved.toolboxes.find((toolbox) => toolbox.id === "saved").name).toBe("Conservée");
expect(saved.modules.find((entry) => entry.key === "saved:note")).toEqual(original.modules[0]);
expect(saved.links).toEqual(original.links);
});
}
test("commands reject writes, creation, imports and exports while the store is not initialized", async ({ page }) => {
await setup(page, "read");
await page.evaluate(async (url) => {
const { mountStorageStartupProbe } = await import(url);
const element = document.createElement("div");
document.body.append(element);
window.storageProbe = mountStorageStartupProbe(element);
}, probeUrl);
await expect.poll(() => page.evaluate(() => window.storageProbe.store.loadError)).toBe("Lecture indisponible");
const results = await page.evaluate(async () => {
const { store, actions } = window.storageProbe;
const forbidden = () => { throw new Error("Incomplete state must not be consumed"); };
return {
ready: store.ready,
writes: await Promise.all([
store.setToolboxes(forbidden), store.setLinks(forbidden), store.setToastPosition("left"),
store.updateModuleData("saved", "note", {}), store.removeModuleData("saved", "note"),
store.removeToolboxModuleData({ id: "saved", modules: [{ id: "note" }] }),
store.persistToolboxSnapshot(forbidden)
]),
created: actions.createToolbox("New"),
imported: await actions.importToolbox({ text: forbidden }),
importedAll: await actions.importAllToolboxes({ text: forbidden }),
exported: actions.exportToolbox("saved"), exportedAll: actions.exportAllToolboxes(),
notices: window.storageProbe.notices
};
});
expect(results).toEqual({ ready: false, writes: Array(7).fill(false), created: null, imported: null, importedAll: false, exported: false, exportedAll: false, notices: [] });
expect(await snapshot(page)).toEqual(original);
await page.evaluate(() => window.storageProbe.unmount());
});
test("a later write failure uses an accurate title and preserves the confirmed toolbox", async ({ page }) => {
await setup(page, "");
await expect(page.locator(".toolbox-card")).toHaveCount(1);
await page.evaluate(() => { window.storageFault = "write"; });
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("Not saved");
await page.getByRole("button", { name: "Créer", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Erreur de stockage local" })).toBeVisible();
await expect(page.getByText("Quota local atteint")).toHaveCount(0);
await page.getByRole("button", { name: "Compris", exact: true }).click();
expect(await snapshot(page)).toEqual(original);
});

View file

@ -116,6 +116,12 @@ export function validateSiteContent(site) {
"about.contribute.promptIntro",
"about.contribute.promptText",
"toolboxes.eyebrow",
"toolboxes.storageStatus.loadingTitle",
"toolboxes.storageStatus.loadingMessage",
"toolboxes.storageStatus.errorTitle",
"toolboxes.storageStatus.errorMessage",
"toolboxes.storageStatus.retryLabel",
"toolboxes.storageStatus.writeErrorTitle",
"toolboxes.title",
"toolboxes.newButton",
"toolboxes.libraryLink",

View file

@ -69,6 +69,7 @@ test("imports merge after file reading and preserve edits queued during the tran
const notices = [];
const actions = useToolboxActions({
store: {
ready: true,
...initial,
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => {
await transaction.promise;
@ -115,6 +116,7 @@ test("global imports started from the same render both survive", async () => {
const queue = createPersistenceQueue(initial, () => {});
const actions = useToolboxActions({
store: {
ready: true,
...initial,
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => { disk = next; })
},
@ -144,6 +146,7 @@ test("failed toolbox deletion preserves registry links and modules together", as
const queue = createPersistenceQueue(initial, () => {});
const actions = useToolboxActions({
store: {
ready: true,
...initial,
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async () => { throw new Error("abort"); })
}

View file

@ -1793,6 +1793,14 @@
]
},
"toolboxes": {
"storageStatus": {
"loadingTitle": "Chargement des données locales",
"loadingMessage": "Lecture des toolboxes en cours…",
"errorTitle": "Données locales indisponibles",
"errorMessage": "Le chargement a échoué. Les modifications et les exports sont bloqués pour protéger les données existantes.",
"retryLabel": "Réessayer",
"writeErrorTitle": "Erreur de stockage local"
},
"eyebrow": "Données locales",
"title": "Toolboxes",
"newButton": "Nouvelle toolbox",

View file

@ -32,6 +32,7 @@ export function AppOverlays({
setLinkModalGameId,
setDrawerGameId,
storageError,
storageErrorTitle,
setStorageError,
image,
setImage,
@ -69,7 +70,7 @@ export function AppOverlays({
actions.linkToolboxToGame(gameId, toolboxId);
setDrawerGameId(gameId);
}} />}
{storageError && <ConfirmModal title="Quota local atteint" message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
{storageError && <ConfirmModal title={storageErrorTitle} message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
{image && <ImageViewer image={image} onClose={() => setImage(null)} createMarkerId={() => uid("marker")} />}
{notifications.length > 0 && (
<div className={`notification-toast-stack nds-toast-stack is-${toastPosition === "left" ? "left" : "right"}`} data-position={toastPosition === "left" ? "left" : "right"} aria-live="polite" aria-relevant="additions">

View file

@ -30,6 +30,9 @@ function normalizeToolboxes(toolboxes) {
export function useIndexedToolboxes(onError) {
const [ready, setReady] = useState(false);
const [loadError, setLoadError] = useState("");
const [loadAttempt, setLoadAttempt] = useState(0);
const writableRef = useRef(false);
const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" });
const [queue] = useState(() => createPersistenceQueue(state, setState));
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
@ -71,26 +74,34 @@ export function useIndexedToolboxes(onError) {
moduleData: Object.fromEntries((modules || []).map((entry) => [entry.key, entry.data])),
toastPosition: toastPosition === "left" ? "left" : "right"
});
writableRef.current = true;
setReady(true);
refreshQuota();
} catch (error) {
if (!cancelled) {
setReady(true);
reportStorageError(error);
setLoadError(error?.message || "Erreur de stockage IndexedDB.");
}
}
}
loadStore();
return () => { cancelled = true; };
}, [queue, refreshQuota, reportStorageError]);
return () => { cancelled = true; writableRef.current = false; };
}, [queue, refreshQuota, loadAttempt]);
function retryLoad() {
if (ready || !loadError) return;
setLoadError("");
setLoadAttempt((attempt) => attempt + 1);
}
function enqueue(apply, persist) {
if (!writableRef.current) return Promise.resolve(false);
const result = queue.enqueue(apply, persist, reportStorageError);
void result.then((success) => { if (success) refreshQuota(); });
return result;
}
function persistToolboxes(value) {
if (!writableRef.current) return false;
const apply = typeof value === "function" ? value : () => value;
enqueue(
(current) => ({ ...current, toolboxes: normalizeToolboxes(apply(current.toolboxes)) }),
@ -100,6 +111,7 @@ export function useIndexedToolboxes(onError) {
}
function persistLinks(value) {
if (!writableRef.current) return false;
enqueue(
(current) => ({ ...current, links: typeof value === "function" ? value(current.links) : value }),
(next) => dbSetLinks(next.links)
@ -112,6 +124,7 @@ export function useIndexedToolboxes(onError) {
}
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
if (!writableRef.current) return false;
const compact = prepareModuleDataForStorage(queue.getCurrent().toolboxes, toolboxId, moduleId, value, moduleType);
const key = moduleStorageKey(toolboxId, moduleId);
const hasModule = (current) => current.toolboxes.some((toolbox) => toolbox.id === toolboxId && toolbox.modules.some((module) => module.id === moduleId));
@ -134,6 +147,7 @@ export function useIndexedToolboxes(onError) {
}
function removeToolboxModuleData(toolbox) {
if (!writableRef.current) return Promise.resolve(false);
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
if (!keys.length) return Promise.resolve(true);
return enqueue(
@ -150,6 +164,7 @@ export function useIndexedToolboxes(onError) {
}
function setToastPosition(position) {
if (!writableRef.current) return false;
const toastPosition = position === "left" ? "left" : "right";
enqueue(
(current) => ({ ...current, toastPosition }),
@ -160,6 +175,8 @@ export function useIndexedToolboxes(onError) {
return {
...state,
ready,
loadError,
retryLoad,
storageUsage,
setToolboxes: persistToolboxes,
setLinks: persistLinks,

View file

@ -27,6 +27,7 @@ function getLibraryUrl() {
export function useToolboxActions({ store, notify, creationTemplate, setConfirmModal, setCreateModal, setLinkModalGameId, setDrawerGameId, setImage }) {
function createToolbox(name, gameId = "", options = {}) {
if (!store.ready) return null;
const now = new Date().toISOString();
const memosModuleId = uid("mod");
const linksModuleId = uid("mod");
@ -241,6 +242,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
store.setToolboxes((toolboxes) => [...toolboxes].sort((a, b) => (order.get(a.id) ?? 9999) - (order.get(b.id) ?? 9999)));
},
importToolbox: async (file, gameId = "") => {
if (!store.ready) return null;
try {
const toolbox = await importToolboxPayload(file, gameId);
if (toolbox) notify("Toolbox importée.");
@ -257,6 +259,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
}
},
importAllToolboxes: async (file) => {
if (!store.ready) return false;
try {
await importAllToolboxesPayload(file);
notify("Import global terminé.");
@ -273,12 +276,14 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
}
},
exportToolbox: (id) => {
if (!store.ready) return false;
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id));
if (!toolbox) return;
downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
notify("Export de la toolbox lancé.");
},
exportAllToolboxes: () => {
if (!store.ready) return false;
downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`);
notify("Export global lancé.");
}

View file

@ -6,6 +6,7 @@ import "./styles/main.scss";
import { AppOverlays } from "./components/AppOverlays.jsx";
import { AppErrorBoundary } from "./components/AppErrorBoundary.jsx";
import { Shell } from "./components/Shell.jsx";
import { Icon } from "./components/Icon.jsx";
import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedToolboxes.js";
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
import { useAppData } from "./hooks/useAppData.js";
@ -102,6 +103,24 @@ function App() {
);
}
if (!store.ready) {
const content = siteContent.toolboxes.storageStatus;
return (
<main className="startup-error">
<section className="empty nds-empty-state" aria-busy={!store.loadError}>
<h1>{store.loadError ? content.errorTitle : content.loadingTitle}</h1>
<p>{store.loadError ? content.errorMessage : content.loadingMessage}</p>
{store.loadError && <>
<p role="alert">{store.loadError}</p>
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={store.retryLoad}>
<Icon name="refresh" />{content.retryLabel}
</button>
</>}
</section>
</main>
);
}
return (
<Shell route={route} content={siteContent} games={games} toolboxes={store.toolboxes} links={store.links} drawerGameId={drawerGameId} actions={actions}>
<AppErrorBoundary route={route}>
@ -151,6 +170,7 @@ function App() {
</Suspense>
)}
<AppOverlays
storageErrorTitle={siteContent.toolboxes.storageStatus.writeErrorTitle}
actions={actions}
confirmModal={confirmModal}
setConfirmModal={setConfirmModal}

View file

@ -19,6 +19,9 @@ function openDatabase() {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
}).catch((error) => {
dbPromise = undefined;
throw error;
});
return dbPromise;
}