diff --git a/AGENTS.md b/AGENTS.md index 6bfb6c674..fd6d1088f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ When documenting properties/fields use the following style of comment: To add or amend UI strings: 1. Edit `lang/ui.en.json` -2. Run `find lang -type f -not -name ui.en.json | while read n; do git checkout main -- $n; done && npm run i18n:compile` to update `src/messages/` via formatjs and ensure that outdated text of new message is not preserved in translation bundles as we iterate on the text. +2. Run `find lang -type f -not -name ui.en.json | while read n; do git checkout beta -- $n; done && npm run i18n:compile` to update `src/messages/` via formatjs and ensure that outdated text of new message is not preserved in translation bundles as we iterate on the text. ## Vitest diff --git a/lang/ui.ca.json b/lang/ui.ca.json index 86978de42..ad978bd87 100644 --- a/lang/ui.ca.json +++ b/lang/ui.ca.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "Connecta una micro:bit de recollida de dades o importa mostres de dades", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "Copia el patró", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "Atura l'enregistrament", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when a storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "Si us plau, considerafer una sol·licitud d'assistència.", "description": "Support request link text" diff --git a/lang/ui.en.json b/lang/ui.en.json index 1e89044bf..c832a7e5c 100644 --- a/lang/ui.en.json +++ b/lang/ui.en.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "Connect a data collection micro:bit or import data samples", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "Copy pattern", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "Stop recording", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when an IndexedDB storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "Please consider raising a support request.", "description": "Support request link text" diff --git a/lang/ui.es-es.json b/lang/ui.es-es.json index 9a53e6a2c..837579b9f 100644 --- a/lang/ui.es-es.json +++ b/lang/ui.es-es.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "Conecta una colección de datos de micro:bit o importa muestras de datos", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "Copiar patrón", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "Detener la grabación", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when a storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "Por favor considere una solicitud de soporte.", "description": "Support request link text" diff --git a/lang/ui.fr.json b/lang/ui.fr.json index 3c49e5f83..1071e0bc7 100644 --- a/lang/ui.fr.json +++ b/lang/ui.fr.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "Connecter un micro:bit de collecte de données ou importer des échantillons de données", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "Copier le motif", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "Arrêter l'enregistrement", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when a storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "Veuillez formuler une demande de support.", "description": "Support request link text" diff --git a/lang/ui.ja.json b/lang/ui.ja.json index 08e2dbc89..2a79e8d61 100644 --- a/lang/ui.ja.json +++ b/lang/ui.ja.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "データ収集用micro:bitを接続 または データサンプルのインポート", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "パターンをコピー", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "記録の停止", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when a storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "サポートリクエストの提出 を検討してください。", "description": "Support request link text" diff --git a/lang/ui.ko.json b/lang/ui.ko.json index dbfb4ff37..b097df5a7 100644 --- a/lang/ui.ko.json +++ b/lang/ui.ko.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "데이터 수집 micro:bit에 연결하기 또는 데이터 샘플 가져오기를 하세요.", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "패턴 복사", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "기록 중단", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when a storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "지원 요청을 생각해 보세요.", "description": "Support request link text" diff --git a/lang/ui.lol.json b/lang/ui.lol.json index c7cec7fcd..a6e216292 100644 --- a/lang/ui.lol.json +++ b/lang/ui.lol.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "crwdns362772:0crwdne362772:0", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "crwdns362774:0crwdne362774:0", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "crwdns363360:0crwdne363360:0", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when a storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "crwdns363362:0crwdne363362:0", "description": "Support request link text" diff --git a/lang/ui.nl.json b/lang/ui.nl.json index 76a60c68d..01a861560 100644 --- a/lang/ui.nl.json +++ b/lang/ui.nl.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "Verbind een micro:bit die gegevens verzamelt of importeer data samples", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "Kopieer patroon", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "Stop met opnemen", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when a storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "Overweeg een ondersteuningsverzoek in te dienen.", "description": "Support request link text" diff --git a/lang/ui.pl.json b/lang/ui.pl.json index c6e2a71fe..d9f27b16e 100644 --- a/lang/ui.pl.json +++ b/lang/ui.pl.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "Podłącz micro:bit zbierający dane lub importuj próbki danych", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "Kopiuj wzór", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "Zatrzymaj nagrywanie", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when a storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "Rozważ złożenie prośby o pomoc.", "description": "Support request link text" diff --git a/lang/ui.pt-br.json b/lang/ui.pt-br.json index 9ff63c259..a2027f587 100644 --- a/lang/ui.pt-br.json +++ b/lang/ui.pt-br.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "Conecte um micro:bit de coleta de dados ou importe as amostras de dados", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "Copiar padrão", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "Parar a gravação", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when a storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "Por favor, considere abrir uma solicitação de suporte", "description": "Support request link text" diff --git a/lang/ui.zh-tw.json b/lang/ui.zh-tw.json index 1002a49b1..d94a3c94e 100644 --- a/lang/ui.zh-tw.json +++ b/lang/ui.zh-tw.json @@ -379,10 +379,6 @@ "defaultMessage": "Connect micro:bit battery pack", "description": "Connection dialog heading" }, - "connect-or-import": { - "defaultMessage": "連線數據收集用的 micro:bit匯入數據樣本", - "description": "Empty data samples page text" - }, "connect-pattern-heading": { "defaultMessage": "複製圖案", "description": "Heading for Bluetooth pattern connection dialog" @@ -1855,6 +1851,22 @@ "defaultMessage": "停止錄製", "description": "Button label to stop recording movement data while recording multiple samples" }, + "storage-error-device-other": { + "defaultMessage": "Failed to save your project to device storage", + "description": "Toast shown when a SQLite storage write fails on a native device" + }, + "storage-error-other": { + "defaultMessage": "Failed to save your project to browser storage", + "description": "Toast shown when a storage write fails for an unknown reason" + }, + "storage-error-quota-description": { + "defaultMessage": "Your project edit may not be saved.", + "description": "Toast description when browser storage quota is exceeded" + }, + "storage-error-quota-title": { + "defaultMessage": "Browser storage full", + "description": "Toast title when browser storage quota is exceeded" + }, "support-request": { "defaultMessage": "請考慮提出支援請求。", "description": "Support request link text" diff --git a/src/App.tsx b/src/App.tsx index ee2a5b05e..67504191f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,12 +23,12 @@ import { useRouteError, } from "react-router-dom"; import "theme-package/fonts/fonts.css"; +import { useNativeBackButton } from "./back-button"; import { broadcastChannel, BroadcastChannelData, BroadcastChannelMessageType, } from "./broadcast-channel"; -import { useNativeBackButton } from "./back-button"; import { BufferedDataProvider } from "./buffered-data-hooks"; import EditCodeDialog from "./components/EditCodeDialog"; import ErrorBoundary from "./components/ErrorBoundary"; @@ -42,22 +42,25 @@ import { MockRadioBridgeConnection } from "./device/mockRadioBridge"; import { MockUSBConnection } from "./device/mockUsb"; import { flags } from "./flags"; import { ProjectProvider } from "./hooks/project-hooks"; +import { useSafeAreaInsets } from "./hooks/use-safe-area-insets"; import { LoggingProvider } from "./logging/logging-hooks"; import { hasMakeCodeMlExtension } from "./makecode/utils"; import TranslationProvider from "./messages/TranslationProvider"; import { PostImportDialogState } from "./model"; import CodePage from "./pages/CodePage"; import DataSamplesPage from "./pages/DataSamplesPage"; +import HomePage from "./pages/HomePage"; import ImportPage from "./pages/ImportPage"; import OpenSharedProjectPage from "./pages/OpenSharedProjectPage"; +import ProjectsPage from "./pages/ProjectsPage"; import TestingModelPage from "./pages/TestingModelPage"; -import { projectSessionStorage } from "./session-storage"; -import { useSafeAreaInsets } from "./hooks/use-safe-area-insets"; import { isNativePlatform } from "./platform"; +import { projectSessionStorage } from "./session-storage"; import { getAllProjectsFromStorage, loadProjectAndModelFromStorage, loadSettingsFromStorage, + StorageErrorEvent, useStore, } from "./store"; import { @@ -70,8 +73,6 @@ import { createProjectsPageUrl, createTestingModelPageUrl, } from "./urls"; -import ProjectsPage from "./pages/ProjectsPage"; -import HomePage from "./pages/HomePage"; export interface ProviderLayoutProps { children: ReactNode; @@ -163,6 +164,49 @@ const Layout = () => { ); }, [intl, navigate, setPostImportDialogState, toast]); + const storageError: StorageErrorEvent | undefined = useStore( + (s) => s.storageError + ); + useEffect(() => { + if (!storageError) { + return; + } + const messages = (() => { + if (storageError.type === "quota") { + return { + title: intl.formatMessage({ id: "storage-error-quota-title" }), + description: intl.formatMessage({ + id: "storage-error-quota-description", + }), + }; + } + if (storageError.kind === "device") { + return { + title: intl.formatMessage({ + id: "storage-error-device-other", + }), + }; + } + return { + title: intl.formatMessage({ id: "storage-error-other" }), + }; + })(); + const toastOptions = { + id: "storage-error", + position: "top" as const, + duration: null, + isClosable: true, + variant: "toast", + status: "error" as const, + ...messages, + }; + if (toast.isActive("storage-error")) { + toast.update("storage-error", toastOptions); + } else { + toast(toastOptions); + } + }, [intl, storageError, toast]); + useEffect(() => { if (updateProjectTimestampUrls.includes(location.pathname) && id) { void updateProjectTimestamp(); diff --git a/src/e2e/storage-errors.spec.ts b/src/e2e/storage-errors.spec.ts new file mode 100644 index 000000000..6a49ec1c9 --- /dev/null +++ b/src/e2e/storage-errors.spec.ts @@ -0,0 +1,121 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +/** + * Monkey-patches IDBObjectStore.prototype.put so that the next call + * throws, then restores the original method. + */ +const injectWriteError = async ( + page: import("@playwright/test").Page, + errorName: string = "QuotaExceededError" +) => { + await page.evaluate((errorName) => { + const original = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function () { + IDBObjectStore.prototype.put = original; + throw new DOMException(`Simulated ${errorName}`, errorName); + }; + }, errorName); +}; + +test.describe("storage write errors", () => { + test("shows toast on QuotaExceededError and app remains usable", async ({ + homePage, + dataSamplesPage, + }) => { + // Create a project so we have something to interact with. + await homePage.goto(); + await homePage.newProject(); + await dataSamplesPage.expectOnPage(); + + // Inject a one-shot write failure for the next put. + await injectWriteError(dataSamplesPage.page, "QuotaExceededError"); + + // Rename an action to trigger a storage write. + const actionInput = dataSamplesPage.page.getByRole("textbox", { + name: "Name of action", + }); + await actionInput.first().fill("Wave"); + // Blur to commit the rename which triggers the storage write. + await actionInput.first().blur(); + + // Toast should appear with the quota title and description. + await expect( + dataSamplesPage.page.getByText("Browser storage full") + ).toBeVisible(); + await expect( + dataSamplesPage.page.getByText("Your project edit may not be saved.") + ).toBeVisible(); + + // App should still be usable — no error boundary. + await expect( + dataSamplesPage.page.getByText("An unexpected error occurred") + ).toBeHidden(); + // Verify we can still interact with the page. + await expect(actionInput.first()).toBeVisible(); + }); + + test("shows toast on generic write error", async ({ + homePage, + dataSamplesPage, + }) => { + await homePage.goto(); + await homePage.newProject(); + await dataSamplesPage.expectOnPage(); + + await injectWriteError(dataSamplesPage.page, "UnknownError"); + + const actionInput = dataSamplesPage.page.getByRole("textbox", { + name: "Name of action", + }); + await actionInput.first().fill("Wave"); + await actionInput.first().blur(); + + await expect( + dataSamplesPage.page.getByText( + "Failed to save your project to browser storage" + ) + ).toBeVisible(); + + await expect( + dataSamplesPage.page.getByText("An unexpected error occurred") + ).toBeHidden(); + }); +}); + +test.describe("storage read errors", () => { + test("shows error boundary on read failure", async ({ + homePage, + dataSamplesPage, + }) => { + // Create a project first so the home page has data to load. + await homePage.goto(); + await homePage.newProject(); + await dataSamplesPage.welcomeDialog.close(); + await dataSamplesPage.navbar.home(); + await homePage.expectOnHomePage(); + + // Register an init script that monkey-patches getAll to fail once. + // addInitScript runs before any page script on every navigation/reload, + // so the patch is in place when the route loader fires. + await homePage.page.addInitScript(() => { + const original = IDBObjectStore.prototype.getAll; + IDBObjectStore.prototype.getAll = function () { + IDBObjectStore.prototype.getAll = original; + throw new DOMException("Simulated read error", "UnknownError"); + }; + }); + + await homePage.page.reload(); + + // Error boundary should render. + await expect( + homePage.page.getByText("An unexpected error occurred") + ).toBeVisible(); + }); +}); diff --git a/src/messages/ui.ca.json b/src/messages/ui.ca.json index 6125b2b4a..ad440989e 100644 --- a/src/messages/ui.ca.json +++ b/src/messages/ui.ca.json @@ -693,32 +693,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "children": [ - { - "type": 0, - "value": "Connecta una micro:bit de recollida de dades" - } - ], - "type": 8, - "value": "link1" - }, - { - "type": 0, - "value": " o " - }, - { - "children": [ - { - "type": 0, - "value": "importa mostres de dades" - } - ], - "type": 8, - "value": "link2" - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3379,6 +3353,30 @@ "value": "Atura l'enregistrament" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "type": 0, diff --git a/src/messages/ui.en.json b/src/messages/ui.en.json index a6ff464f7..f0a2ed265 100644 --- a/src/messages/ui.en.json +++ b/src/messages/ui.en.json @@ -697,32 +697,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "children": [ - { - "type": 0, - "value": "Connect a data collection micro:bit" - } - ], - "type": 8, - "value": "link1" - }, - { - "type": 0, - "value": " or " - }, - { - "children": [ - { - "type": 0, - "value": "import data samples" - } - ], - "type": 8, - "value": "link2" - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3397,6 +3371,30 @@ "value": "Stop recording" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "type": 0, diff --git a/src/messages/ui.es-es.json b/src/messages/ui.es-es.json index 77f60e0d8..23923c7aa 100644 --- a/src/messages/ui.es-es.json +++ b/src/messages/ui.es-es.json @@ -697,32 +697,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "children": [ - { - "type": 0, - "value": "Conecta una colección de datos de micro:bit" - } - ], - "type": 8, - "value": "link1" - }, - { - "type": 0, - "value": " o " - }, - { - "children": [ - { - "type": 0, - "value": "importa muestras de datos" - } - ], - "type": 8, - "value": "link2" - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3409,6 +3383,30 @@ "value": "Detener la grabación" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "type": 0, diff --git a/src/messages/ui.fr.json b/src/messages/ui.fr.json index 8fd64356c..170686e4d 100644 --- a/src/messages/ui.fr.json +++ b/src/messages/ui.fr.json @@ -697,32 +697,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "children": [ - { - "type": 0, - "value": "Connecter un micro:bit de collecte de données" - } - ], - "type": 8, - "value": "link1" - }, - { - "type": 0, - "value": " ou " - }, - { - "children": [ - { - "type": 0, - "value": "importer des échantillons de données" - } - ], - "type": 8, - "value": "link2" - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3401,6 +3375,30 @@ "value": "Arrêter l'enregistrement" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "type": 0, diff --git a/src/messages/ui.ja.json b/src/messages/ui.ja.json index 0f05eccb5..43d135de1 100644 --- a/src/messages/ui.ja.json +++ b/src/messages/ui.ja.json @@ -685,32 +685,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "children": [ - { - "type": 0, - "value": "データ収集用micro:bitを接続" - } - ], - "type": 8, - "value": "link1" - }, - { - "type": 0, - "value": " または " - }, - { - "children": [ - { - "type": 0, - "value": "データサンプルのインポート" - } - ], - "type": 8, - "value": "link2" - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3333,6 +3307,30 @@ "value": "記録の停止" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "children": [ diff --git a/src/messages/ui.ko.json b/src/messages/ui.ko.json index de669174b..073bfbc90 100644 --- a/src/messages/ui.ko.json +++ b/src/messages/ui.ko.json @@ -681,36 +681,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "children": [ - { - "type": 0, - "value": "데이터 수집 micro:bit에 연결하기" - } - ], - "type": 8, - "value": "link1" - }, - { - "type": 0, - "value": " 또는 " - }, - { - "children": [ - { - "type": 0, - "value": "데이터 샘플 가져오기" - } - ], - "type": 8, - "value": "link2" - }, - { - "type": 0, - "value": "를 하세요." - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3333,6 +3303,30 @@ "value": "기록 중단" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "children": [ diff --git a/src/messages/ui.lol.json b/src/messages/ui.lol.json index ecbde1a2a..789cf3541 100644 --- a/src/messages/ui.lol.json +++ b/src/messages/ui.lol.json @@ -685,12 +685,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "type": 0, - "value": "crwdns362772:0crwdne362772:0" - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3291,6 +3285,30 @@ "value": "crwdns363360:0crwdne363360:0" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "type": 0, diff --git a/src/messages/ui.nl.json b/src/messages/ui.nl.json index 189d5767a..8e1c3464a 100644 --- a/src/messages/ui.nl.json +++ b/src/messages/ui.nl.json @@ -697,32 +697,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "children": [ - { - "type": 0, - "value": "Verbind een micro:bit die gegevens verzamelt" - } - ], - "type": 8, - "value": "link1" - }, - { - "type": 0, - "value": " of " - }, - { - "children": [ - { - "type": 0, - "value": "importeer data samples" - } - ], - "type": 8, - "value": "link2" - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3405,6 +3379,30 @@ "value": "Stop met opnemen" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "type": 0, diff --git a/src/messages/ui.pl.json b/src/messages/ui.pl.json index 4205f560d..ef1bc59b5 100644 --- a/src/messages/ui.pl.json +++ b/src/messages/ui.pl.json @@ -697,32 +697,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "children": [ - { - "type": 0, - "value": "Podłącz micro:bit zbierający dane" - } - ], - "type": 8, - "value": "link1" - }, - { - "type": 0, - "value": " lub " - }, - { - "children": [ - { - "type": 0, - "value": "importuj próbki danych" - } - ], - "type": 8, - "value": "link2" - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3405,6 +3379,30 @@ "value": "Zatrzymaj nagrywanie" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "type": 0, diff --git a/src/messages/ui.pt-br.json b/src/messages/ui.pt-br.json index af655a5e6..93320cabc 100644 --- a/src/messages/ui.pt-br.json +++ b/src/messages/ui.pt-br.json @@ -697,32 +697,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "children": [ - { - "type": 0, - "value": "Conecte um micro:bit de coleta de dados" - } - ], - "type": 8, - "value": "link1" - }, - { - "type": 0, - "value": " ou " - }, - { - "children": [ - { - "type": 0, - "value": "importe as amostras de dados" - } - ], - "type": 8, - "value": "link2" - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3401,6 +3375,30 @@ "value": "Parar a gravação" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "type": 0, diff --git a/src/messages/ui.zh-tw.json b/src/messages/ui.zh-tw.json index 47aad60d3..e41c2ff5b 100644 --- a/src/messages/ui.zh-tw.json +++ b/src/messages/ui.zh-tw.json @@ -705,32 +705,6 @@ "value": "Connect micro:bit battery pack" } ], - "connect-or-import": [ - { - "children": [ - { - "type": 0, - "value": "連線數據收集用的 micro:bit" - } - ], - "type": 8, - "value": "link1" - }, - { - "type": 0, - "value": " 或" - }, - { - "children": [ - { - "type": 0, - "value": "匯入數據樣本" - } - ], - "type": 8, - "value": "link2" - } - ], "connect-pattern-heading": [ { "type": 0, @@ -3401,6 +3375,30 @@ "value": "停止錄製" } ], + "storage-error-device-other": [ + { + "type": 0, + "value": "Failed to save your project to device storage" + } + ], + "storage-error-other": [ + { + "type": 0, + "value": "Failed to save your project to browser storage" + } + ], + "storage-error-quota-description": [ + { + "type": 0, + "value": "Your project edit may not be saved." + } + ], + "storage-error-quota-title": [ + { + "type": 0, + "value": "Browser storage full" + } + ], "support-request": [ { "type": 0, diff --git a/src/store.ts b/src/store.ts index 8d42e2c08..3f876076d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -59,6 +59,7 @@ import { TourTriggerName, TrainModelDialogStage, } from "./model"; +import { isNativePlatform } from "./platform"; import { createUntitledProject, currentDataWindow, @@ -71,12 +72,7 @@ import { import { projectSessionStorage } from "./session-storage"; import { defaultSettings, Settings } from "./settings"; import { SqliteDatabase } from "./sqlite-storage"; -import { - Database, - IdbDatabase, - ProjectDataWithActions, - StorageError, -} from "./storage"; +import { Database, IdbDatabase, ProjectDataWithActions } from "./storage"; import { getTour as getTourSpec } from "./tours"; import { getTotalNumSamples } from "./utils/actions"; import { downloadDataString } from "./utils/fs-util"; @@ -139,7 +135,13 @@ const updateProject = ( }; }; +export interface StorageErrorEvent { + type: "quota" | "other"; + kind: "browser" | "device"; +} + export interface State { + storageError: StorageErrorEvent | undefined; id: string | undefined; actions: ActionData[]; dataWindow: DataWindow; @@ -379,6 +381,7 @@ const createMlStore = (logging: Logging) => { return create()( devtools( subscribeWithSelector((set, get) => ({ + storageError: undefined, id: undefined, timestamp: 0, actions: [createFirstAction()], @@ -474,7 +477,7 @@ const createMlStore = (logging: Logging) => { }; const timestamp = Date.now(); set({ settings: updatedSettings, timestamp }, false, "setSettings"); - await storageWithErrHandling( + await storageWriteWithErrHandling( () => storage.updateSettings(updatedSettings), "settings" ); @@ -508,7 +511,7 @@ const createMlStore = (logging: Logging) => { false, "setLanguage" ); - await storageWithErrHandling( + await storageWriteWithErrHandling( () => storage.updateSettings(updatedSettings), "settings" ); @@ -539,7 +542,7 @@ const createMlStore = (logging: Logging) => { "newSession" ); projectSessionStorage.setProjectId(id); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.newSession( actions, { @@ -614,7 +617,7 @@ const createMlStore = (logging: Logging) => { async updateProjectUpdatedAt() { const { id } = get(); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateProjectTimestamp(id, Date.now()) ); }, @@ -631,7 +634,7 @@ const createMlStore = (logging: Logging) => { { ...duplicatedProject, name, id: newProjectId, timestamp }, ], }); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.duplicateProject(id, { id: newProjectId, name, @@ -665,7 +668,10 @@ const createMlStore = (logging: Logging) => { if (id === currentProjectId) { projectSessionStorage.clearProjectId(); } - await storageWithErrHandling(() => storage.deleteProject(id), false); + await storageWriteWithErrHandling( + () => storage.deleteProject(id), + false + ); const message: BroadcastChannelData = { messageType: BroadcastChannelMessageType.DELETE_PROJECT, projectIds: [id], @@ -697,7 +703,7 @@ const createMlStore = (logging: Logging) => { if (currentProjectId && ids.includes(currentProjectId)) { projectSessionStorage.clearProjectId(); } - await storageWithErrHandling( + await storageWriteWithErrHandling( () => storage.deleteProjects(ids), false ); @@ -751,7 +757,7 @@ const createMlStore = (logging: Logging) => { timestamp, ...updatedProject, }); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.addAction( id, newAction, @@ -793,7 +799,7 @@ const createMlStore = (logging: Logging) => { ...updatedProject, }); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.addRecording( id, recording, @@ -832,7 +838,7 @@ const createMlStore = (logging: Logging) => { timestamp, ...updatedProject, }); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.deleteAction( id, action, @@ -882,7 +888,7 @@ const createMlStore = (logging: Logging) => { timestamp, ...updatedProject, }); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateAction( id, updatedAction, @@ -931,7 +937,7 @@ const createMlStore = (logging: Logging) => { timestamp, ...updatedProject, }); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateActions( id, updatedActions, @@ -968,7 +974,7 @@ const createMlStore = (logging: Logging) => { timestamp, ...updatedProject, }); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateAction( id, updatedAction, @@ -1018,7 +1024,7 @@ const createMlStore = (logging: Logging) => { timestamp, ...updatedProject, }); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.deleteRecording( id, recordingId.toString(), @@ -1050,7 +1056,7 @@ const createMlStore = (logging: Logging) => { model: undefined, ...updatedProject, }); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.deleteAllActions( id, actions, @@ -1117,7 +1123,7 @@ const createMlStore = (logging: Logging) => { hint: getHint(newActionsWithIcons, true), ...updatedProject, }); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.replaceActions( newActionsWithIcons, { @@ -1130,7 +1136,7 @@ const createMlStore = (logging: Logging) => { } ) ); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateSettings(updatedSettings) ); } else if (loadAction === "replaceProject") { @@ -1146,7 +1152,7 @@ const createMlStore = (logging: Logging) => { ...updatedProject, }); projectSessionStorage.setProjectId(newId); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.importProject( newActionsWithIcons, { @@ -1159,7 +1165,7 @@ const createMlStore = (logging: Logging) => { } ) ); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateSettings(updatedSettings) ); } @@ -1208,14 +1214,14 @@ const createMlStore = (logging: Logging) => { }; }); projectSessionStorage.setProjectId(id); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.importProject( newActions, { project, projectEdited }, { timestamp, id } ) ); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateSettings(updatedSettings) ); }, @@ -1297,7 +1303,7 @@ const createMlStore = (logging: Logging) => { false, actionName ); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateMakeCodeProject( id, { @@ -1348,7 +1354,7 @@ const createMlStore = (logging: Logging) => { false, "resetProject" ); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateMakeCodeProject( id, { @@ -1388,7 +1394,7 @@ const createMlStore = (logging: Logging) => { false, "setProjectName" ); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.renameProject(id, name, timestamp) ); }, @@ -1574,7 +1580,7 @@ const createMlStore = (logging: Logging) => { ); if (importProject) { projectSessionStorage.setProjectId(newProjectId); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.importProject( newActions as ActionData[], { @@ -1584,11 +1590,11 @@ const createMlStore = (logging: Logging) => { { timestamp: updatedTimestamp, id: newProjectId } ) ); - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateSettings(updatedSettings) ); } else if (updateMakeCodeProject) { - await storageWithErrHandling(() => + await storageWriteWithErrHandling(() => storage.updateMakeCodeProject( id, { @@ -1720,7 +1726,7 @@ const createMlStore = (logging: Logging) => { timestamp, }; set(updatedState); - await storageWithErrHandling( + await storageWriteWithErrHandling( () => storage.updateSettings(updatedSettings), "settings" ); @@ -1760,7 +1766,7 @@ const createMlStore = (logging: Logging) => { settings: updatedSettings, timestamp, }); - await storageWithErrHandling( + await storageWriteWithErrHandling( () => storage.updateSettings(updatedSettings), "settings" ); @@ -1777,7 +1783,7 @@ const createMlStore = (logging: Logging) => { settings: updatedSettings, timestamp, }); - await storageWithErrHandling( + await storageWriteWithErrHandling( () => storage.updateSettings(updatedSettings), "settings" ); @@ -1791,7 +1797,7 @@ const createMlStore = (logging: Logging) => { set({ settings: updatedSettings, }); - await storageWithErrHandling( + await storageWriteWithErrHandling( () => storage.updateSettings(updatedSettings), "settings" ); @@ -1988,14 +1994,17 @@ useStore.subscribe(async (state, prevState) => { const { model: previousModel, id: prevId } = prevState; if (newModel !== previousModel) { if (!newModel && newId && newId === prevId) { - await storageWithErrHandling(() => storage.removeModel(newId), false); + await storageWriteWithErrHandling( + () => storage.removeModel(newId), + false + ); const message: BroadcastChannelData = { messageType: BroadcastChannelMessageType.REMOVE_MODEL, projectIds: [newId], }; broadcastChannel.postMessage(message); } else if (newModel) { - await storageWithErrHandling( + await storageWriteWithErrHandling( () => storage.saveModel(newId, newModel), false ); @@ -2138,36 +2147,63 @@ const projectInHexIsEdited = (project: MakeCodeProject): boolean => { return projectEdited; }; +let storageErrorReported = false; +const setStorageError = (err: unknown) => { + if (!storageErrorReported) { + storageErrorReported = true; + deployment.logging.error("Storage error", err); + } + useStore.setState({ + storageError: { + type: + err instanceof DOMException && err.name === "QuotaExceededError" + ? "quota" + : "other", + kind: isNativePlatform() ? "device" : "browser", + }, + }); +}; + +/** + * Wraps a storage operation with error handling. + * + * Write operations show a toast and swallow the error so that + * optimistic UI updates are not disrupted by an error boundary. + * Read operations re-throw so that React Router loaders / error + * boundaries can handle them. + */ const storageWithErrHandling = async ( callback: () => Promise, broadcastEvent: boolean | "settings" = true +) => { + const value = await callback(); + const projectId = useStore.getState().id; + const settings = broadcastEvent === "settings"; + if (broadcastEvent && (projectId || settings)) { + const message: BroadcastChannelData = { + messageType: BroadcastChannelMessageType.RELOAD_PROJECT, + projectIds: projectId ? [projectId] : [], + ...(settings && { settings: true }), + }; + broadcastChannel.postMessage(message); + } + return value; +}; + +/** + * Like {@link storageWithErrHandling} but sets a storage error in the + * store (triggering a toast) and swallows the error. Use for + * fire-and-forget writes where the in-memory state has already been + * optimistically updated. + */ +const storageWriteWithErrHandling = async ( + callback: () => Promise, + broadcastEvent: boolean | "settings" = true ) => { try { - const value = await callback(); - const projectId = useStore.getState().id; - const settings = broadcastEvent === "settings"; - if (broadcastEvent && (projectId || settings)) { - const message: BroadcastChannelData = { - messageType: BroadcastChannelMessageType.RELOAD_PROJECT, - projectIds: projectId ? [projectId] : [], - ...(settings && { settings: true }), - }; - broadcastChannel.postMessage(message); - } - return value; + await storageWithErrHandling(callback, broadcastEvent); } catch (err) { - // TODO: Add sensible error handling. - if (err instanceof DOMException && err.name === "QuotaExceededError") { - console.error("Storage quota exceeded!", err); - } else if (err instanceof StorageError) { - // We have failed to load an expected value from storage. - console.error(err); - } else { - console.error(err); - } - // Throw for now to improve typing. - throw err; - // We can in theory set error state here with useStore.setState. + setStorageError(err); } };