From 09a2d4b71ab2705e85620cfabfe8f1514dd0f9fd Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Sat, 12 Sep 2026 23:05:16 +0200 Subject: [PATCH] Protect toolbox data after IndexedDB startup failures --- .beads/interactions.jsonl | 1 + docs/FEATURE_CHECKLIST.md | 1 + docs/STORAGE_SCHEMA.md | 9 ++ e2e/fixtures/storage-startup-probe.js | 20 +++ e2e/storage-startup.spec.js | 143 ++++++++++++++++++ tests/helpers/data-validation.mjs | 6 + tests/persistence-queue.test.mjs | 3 + website/public/data/site.json | 8 + website/src/components/AppOverlays.jsx | 3 +- .../toolboxes/storage/useIndexedToolboxes.js | 25 ++- .../features/toolboxes/useToolboxActions.js | 5 + website/src/main.jsx | 20 +++ website/src/utils/indexedDbStorage.js | 3 + 13 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 e2e/fixtures/storage-startup-probe.js create mode 100644 e2e/storage-startup.spec.js diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 4841ea8..1b2ad9a 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -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."}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 0c4f338..40f9aeb 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -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. diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 86524e9..1d4ff9e 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -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 diff --git a/e2e/fixtures/storage-startup-probe.js b/e2e/fixtures/storage-startup-probe.js new file mode 100644 index 0000000..27931c0 --- /dev/null +++ b/e2e/fixtures/storage-startup-probe.js @@ -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() }; +} diff --git a/e2e/storage-startup.spec.js b/e2e/storage-startup.spec.js new file mode 100644 index 0000000..5e1a761 --- /dev/null +++ b/e2e/storage-startup.spec.js @@ -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); +}); diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index e9e4cda..16d8016 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -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", diff --git a/tests/persistence-queue.test.mjs b/tests/persistence-queue.test.mjs index a5b827b..e4c867d 100644 --- a/tests/persistence-queue.test.mjs +++ b/tests/persistence-queue.test.mjs @@ -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"); }) } diff --git a/website/public/data/site.json b/website/public/data/site.json index 3dbe46e..bf34c0c 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -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", diff --git a/website/src/components/AppOverlays.jsx b/website/src/components/AppOverlays.jsx index 280b406..963277c 100644 --- a/website/src/components/AppOverlays.jsx +++ b/website/src/components/AppOverlays.jsx @@ -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 && setStorageError("")} />} + {storageError && setStorageError("")} />} {image && setImage(null)} createMarkerId={() => uid("marker")} />} {notifications.length > 0 && (
diff --git a/website/src/features/toolboxes/storage/useIndexedToolboxes.js b/website/src/features/toolboxes/storage/useIndexedToolboxes.js index f723188..285c10b 100644 --- a/website/src/features/toolboxes/storage/useIndexedToolboxes.js +++ b/website/src/features/toolboxes/storage/useIndexedToolboxes.js @@ -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, diff --git a/website/src/features/toolboxes/useToolboxActions.js b/website/src/features/toolboxes/useToolboxActions.js index 9bbd75b..c2902f4 100644 --- a/website/src/features/toolboxes/useToolboxActions.js +++ b/website/src/features/toolboxes/useToolboxActions.js @@ -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é."); } diff --git a/website/src/main.jsx b/website/src/main.jsx index 7637db6..efa33eb 100644 --- a/website/src/main.jsx +++ b/website/src/main.jsx @@ -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 ( +
+
+

{store.loadError ? content.errorTitle : content.loadingTitle}

+

{store.loadError ? content.errorMessage : content.loadingMessage}

+ {store.loadError && <> +

{store.loadError}

+ + } +
+
+ ); + } + return ( @@ -151,6 +170,7 @@ function App() { )} resolve(request.result); request.onerror = () => reject(request.error); + }).catch((error) => { + dbPromise = undefined; + throw error; }); return dbPromise; }