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);
}
};