diff --git a/package-lock.json b/package-lock.json
index 1c4940098..195ff89cd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -34,6 +34,7 @@
"crelt": "^1.0.5",
"dompurify": "^3.2.5",
"file-saver": "^2.0.5",
+ "idb": "^8.0.3",
"lunr": "^2.3.9",
"lunr-languages": "^1.14.0",
"lzma": "^2.3.2",
@@ -68,6 +69,7 @@
"cross-env": "^7.0.3",
"ejs": "^3.1.9",
"eslint": "^10.9.1",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^28.1.0",
"playwright": "^1.58.2",
"prettier": "2.3.2",
@@ -7545,6 +7547,16 @@
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
"license": "MIT"
},
+ "node_modules/fake-indexeddb": {
+ "version": "6.2.5",
+ "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
+ "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -8278,10 +8290,9 @@
}
},
"node_modules/idb": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
- "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
- "dev": true,
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
+ "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/ignore": {
@@ -13132,6 +13143,13 @@
"workbox-core": "7.4.1"
}
},
+ "node_modules/workbox-background-sync/node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/workbox-broadcast-update": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz",
@@ -13289,6 +13307,13 @@
"workbox-core": "7.4.1"
}
},
+ "node_modules/workbox-expiration/node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/workbox-google-analytics": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz",
diff --git a/package.json b/package.json
index f89d5f57c..85006632c 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"crelt": "^1.0.5",
"dompurify": "^3.2.5",
"file-saver": "^2.0.5",
+ "idb": "^8.0.3",
"lunr": "^2.3.9",
"lunr-languages": "^1.14.0",
"lzma": "^2.3.2",
@@ -72,6 +73,7 @@
"cross-env": "^7.0.3",
"ejs": "^3.1.9",
"eslint": "^10.9.1",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^28.1.0",
"playwright": "^1.58.2",
"prettier": "2.3.2",
diff --git a/src/RootLayout.tsx b/src/RootLayout.tsx
index 918edb8d8..168e47aeb 100644
--- a/src/RootLayout.tsx
+++ b/src/RootLayout.tsx
@@ -6,6 +6,8 @@
import { ErrorBoundary, UnexpectedErrorPage } from "@microbit/ui-patterns";
import { useCallback } from "react";
import { Outlet } from "react-router";
+import StorageVersionErrorPage from "./fs/StorageVersionErrorPage";
+import { useStorageVersionError } from "./fs/storage-status";
import { useDeployment } from "./deployment";
import { useLogging } from "./logging/logging-hooks";
@@ -20,6 +22,7 @@ const RootLayout = () => {
(error: unknown) => logging.error("Uncaught render error", error),
[logging]
);
+ const storageVersionError = useStorageVersionError();
return (
{
)}
>
-
+ {storageVersionError ? : }
);
};
diff --git a/src/e2e/app-test-fixtures.ts b/src/e2e/app-test-fixtures.ts
index 2d84dda53..ddf9c22d5 100644
--- a/src/e2e/app-test-fixtures.ts
+++ b/src/e2e/app-test-fixtures.ts
@@ -5,8 +5,17 @@ type MyFixtures = {
app: App;
};
-export const test = base.extend({
- app: async ({ page, context }, use) => {
+type Options = {
+ /** Hide IndexedDB so the editor falls back to session storage. */
+ noIndexedDB: boolean;
+ /** Open the editor before the test. Off for tests that embed it. */
+ autoGoto: boolean;
+};
+
+export const test = base.extend({
+ noIndexedDB: [false, { option: true }],
+ autoGoto: [true, { option: true }],
+ app: async ({ page, context, noIndexedDB, autoGoto }, use) => {
const app = new App(page, context);
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await context.addCookies([
@@ -29,7 +38,18 @@ export const test = base.extend({
url: app.baseUrl,
},
]);
- await app.goto();
+ if (noIndexedDB) {
+ await context.addInitScript(() => {
+ // The getter lives on the prototype so an own property is needed.
+ Object.defineProperty(window, "indexedDB", {
+ value: undefined,
+ configurable: true,
+ });
+ });
+ }
+ if (autoGoto) {
+ await app.goto();
+ }
await use(app);
},
});
diff --git a/src/e2e/app.ts b/src/e2e/app.ts
index 363bd056b..acf2d5718 100644
--- a/src/e2e/app.ts
+++ b/src/e2e/app.ts
@@ -32,10 +32,12 @@ export interface BrowserDownload {
// E2E_PORT points the suite at a server on another port.
const baseUrl = `http://localhost:${process.env.E2E_PORT ?? "3000"}`;
-interface UrlOptions {
+export interface UrlOptions {
flags?: Flag[];
fragment?: string;
language?: string;
+ /** Iframe controller mode, as embedded by classroom. */
+ controller?: boolean;
}
interface SaveOptions {
@@ -280,7 +282,7 @@ export class App {
}
async goto(options: UrlOptions = {}) {
- await this.page.goto(optionsToURL(options));
+ await this.page.goto(editorUrl(options));
// Wait for the page to be loaded
await this.editor.waitFor();
}
@@ -495,19 +497,28 @@ export class App {
await this.page.getByRole("button", { name: "Close" }).click();
}
- async closeAndExpectBeforeUnloadDialogVisible(
- visible: boolean
- ): Promise {
- if (visible) {
- this.page.on("dialog", async (dialog) => {
- expect(dialog.type() === "beforeunload").toEqual(visible);
-
- // Though https://playwright.dev/docs/api/class-page#page-event-dialog
- // says that dialog.dismiss() is needed otherwise the page will freeze,
- // in practice, it appears that the dialog is dismissed automatically.
- });
- }
+ async closeWithoutBeforeUnloadPrompt(): Promise {
+ expect(await this.closeAndCollectDialogs()).not.toContain("beforeunload");
+ }
+
+ async closeAndExpectBeforeUnloadPrompt(): Promise {
+ expect(await this.closeAndCollectDialogs()).toContain("beforeunload");
+ }
+
+ /**
+ * Playwright accepts a beforeunload dialog itself if nobody listens, so
+ * listen to see it. Any dialog is handled before the page can close.
+ */
+ private async closeAndCollectDialogs(): Promise {
+ const dialogs: string[] = [];
+ this.page.on("dialog", async (dialog) => {
+ dialogs.push(dialog.type());
+ await dialog.accept();
+ });
+ const closed = this.page.waitForEvent("close");
await this.page.close({ runBeforeUnload: true });
+ await closed;
+ return dialogs;
}
async expectDocumentationTopLevelHeading(
@@ -823,7 +834,7 @@ const getAbsoluteFilePath = (filePathFromProjectRoot: string) => {
return path.join(dir.replace("src/e2e", ""), filePathFromProjectRoot);
};
-const optionsToURL = (options: UrlOptions): string => {
+export const editorUrl = (options: UrlOptions = {}): string => {
const flags = new Set([
"none",
"noWelcome",
@@ -836,6 +847,9 @@ const optionsToURL = (options: UrlOptions): string => {
if (options.language) {
params.push(["l", options.language]);
}
+ if (options.controller) {
+ params.push(["controller", "1"]);
+ }
return (
baseUrl +
// We didn't use BASE_URL here as CRA seems to set it to "" before running jest.
diff --git a/src/e2e/edits.test.ts b/src/e2e/edits.test.ts
index 356dcf528..e73aea805 100644
--- a/src/e2e/edits.test.ts
+++ b/src/e2e/edits.test.ts
@@ -7,25 +7,29 @@ import { test } from "./app-test-fixtures.js";
test.describe("edits", () => {
test("doesn't prompt on close if no edits made", async ({ app }) => {
- await app.closeAndExpectBeforeUnloadDialogVisible(false);
+ await app.closeWithoutBeforeUnloadPrompt();
});
- test("prompts on close if file edited", async ({ app }) => {
+ test("doesn't prompt on close if file edited, as the project is saved", async ({
+ app,
+ }) => {
await app.typeInEditor("A change!");
await app.expectEditorContainText(/A change/);
- await app.closeAndExpectBeforeUnloadDialogVisible(true);
+ await app.closeWithoutBeforeUnloadPrompt();
});
- test("prompts on close if project name edited", async ({ app }) => {
+ test("doesn't prompt on close if project name edited, as the project is saved", async ({
+ app,
+ }) => {
const name = "idiosyncratic ruminant";
await app.setProjectName(name);
await app.expectProjectName(name);
- await app.closeAndExpectBeforeUnloadDialogVisible(true);
+ await app.closeWithoutBeforeUnloadPrompt();
});
- test("retains text across a reload via session storage", async ({ app }) => {
+ test("retains text across a reload", async ({ app }) => {
await app.typeInEditor("A change!");
await app.expectEditorContainText(/A change/);
@@ -33,4 +37,15 @@ test.describe("edits", () => {
await app.expectEditorContainText(/A change/);
});
+
+ test("retains text across a reload straight after typing", async ({
+ app,
+ }) => {
+ // Writes are coalesced for a few hundred milliseconds, so this relies on
+ // the pending ones being flushed as the page unloads.
+ await app.typeInEditor("A change!");
+ await app.page.reload();
+
+ await app.expectEditorContainText(/A change/);
+ });
});
diff --git a/src/e2e/iframe.test.ts b/src/e2e/iframe.test.ts
new file mode 100644
index 000000000..137aad086
--- /dev/null
+++ b/src/e2e/iframe.test.ts
@@ -0,0 +1,128 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { expect, FrameLocator, Page } from "@playwright/test";
+import { editorUrl } from "./app.js";
+import { test } from "./app-test-fixtures.js";
+
+interface EditorMessage {
+ type: "pyeditor";
+ action: string;
+ project?: { files: Record };
+}
+
+const hostCode = "display.scroll('from the host')";
+
+/**
+ * The host page is served by intercepting a request for it and is defined
+ * here, in the tests, so it is never part of the app.
+ */
+const hostPage = (editorSrc: string) => `
+Iframe host harness
+
+
+`;
+
+const hostUrl = new URL("e2e-iframe-host.html", editorUrl()).href;
+
+const openEmbeddedEditor = async (page: Page): Promise => {
+ await page.route(hostUrl, (route) =>
+ route.fulfill({
+ contentType: "text/html",
+ body: hostPage(editorUrl({ controller: true })),
+ })
+ );
+ await page.goto(hostUrl);
+ const frame = page.frameLocator("iframe[name='editor']");
+ await frame.getByTestId("editor").waitFor();
+ return frame;
+};
+
+const editorText = (frame: FrameLocator) =>
+ frame.getByTestId("editor").getByRole("textbox");
+
+// Set by the host harness page. Evaluate callbacks run in the page, so the
+// cast has to be repeated inside each rather than shared.
+const receivedActions = (page: Page) =>
+ page.evaluate(() =>
+ (window as unknown as { messages: EditorMessage[] }).messages.map(
+ (m) => m.action
+ )
+ );
+
+const lastSavedMain = (page: Page) =>
+ page.evaluate(() => {
+ const { messages } = window as unknown as { messages: EditorMessage[] };
+ const saves = messages.filter((m) => m.action === "workspacesave");
+ const encoded = saves[saves.length - 1]?.project?.files["main.py"];
+ return encoded === undefined ? undefined : atob(encoded);
+ });
+
+/**
+ * Controller mode, as embedded by classroom: the host owns the project and
+ * there is no storage or project management in the editor.
+ */
+test.describe("iframe controller mode", () => {
+ test.use({ autoGoto: false });
+
+ test("loads the host's project and reports back edits", async ({ app }) => {
+ const frame = await openEmbeddedEditor(app.page);
+
+ await expect(editorText(frame)).toContainText("from the host");
+ expect(await receivedActions(app.page)).toEqual([
+ "workspacesync",
+ "workspaceloaded",
+ ]);
+
+ await editorText(frame).fill("display.scroll('edited')");
+ await expect
+ .poll(() => lastSavedMain(app.page))
+ .toEqual("display.scroll('edited')");
+ });
+
+ test("replaces the project when the host imports one", async ({ app }) => {
+ const frame = await openEmbeddedEditor(app.page);
+ await expect(editorText(frame)).toContainText("from the host");
+
+ await app.page.evaluate(() => {
+ const editor = document.querySelector("iframe")!.contentWindow!;
+ editor.postMessage(
+ {
+ type: "pyeditor",
+ action: "importproject",
+ project: "display.scroll('imported')",
+ },
+ "*"
+ );
+ });
+
+ await expect(editorText(frame)).toContainText("imported");
+ });
+
+ test("prompts on close if file edited, as the host may not have saved", async ({
+ app,
+ }) => {
+ const frame = await openEmbeddedEditor(app.page);
+ await expect(editorText(frame)).toContainText("from the host");
+
+ await editorText(frame).fill("display.scroll('edited')");
+ await expect(editorText(frame)).toContainText("edited");
+
+ await app.closeAndExpectBeforeUnloadPrompt();
+ });
+});
diff --git a/src/e2e/storage-errors.test.ts b/src/e2e/storage-errors.test.ts
new file mode 100644
index 000000000..148d2de2f
--- /dev/null
+++ b/src/e2e/storage-errors.test.ts
@@ -0,0 +1,43 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { test } from "./app-test-fixtures.js";
+
+/**
+ * Without IndexedDB the editor keeps the project in session storage, as it
+ * did before the projects database, so closing the tab loses it and the
+ * before-unload prompt is back.
+ */
+test.describe("storage fallback", () => {
+ test.use({ noIndexedDB: true });
+
+ test("doesn't prompt on close if no edits made", async ({ app }) => {
+ await app.closeWithoutBeforeUnloadPrompt();
+ });
+
+ test("prompts on close if file edited", async ({ app }) => {
+ await app.typeInEditor("A change!");
+ await app.expectEditorContainText(/A change/);
+
+ await app.closeAndExpectBeforeUnloadPrompt();
+ });
+
+ test("prompts on close if project name edited", async ({ app }) => {
+ const name = "idiosyncratic ruminant";
+ await app.setProjectName(name);
+ await app.expectProjectName(name);
+
+ await app.closeAndExpectBeforeUnloadPrompt();
+ });
+
+ test("retains text across a reload via session storage", async ({ app }) => {
+ await app.typeInEditor("A change!");
+ await app.expectEditorContainText(/A change/);
+
+ await app.page.reload();
+
+ await app.expectEditorContainText(/A change/);
+ });
+});
diff --git a/src/environment.ts b/src/environment.ts
index 92ad85ded..48c3efe6a 100644
--- a/src/environment.ts
+++ b/src/environment.ts
@@ -8,3 +8,10 @@ export const version = import.meta.env.VITE_VERSION || "local";
export type Stage = "local" | "REVIEW" | "STAGING" | "PRODUCTION";
export const stage = (import.meta.env.VITE_STAGE || "local") as Stage;
+
+/**
+ * Stages real users reach. Development affordances (like offering to clear
+ * an incompatible projects database) are for the others.
+ */
+export const isPublicFacingStage = (s: Stage = stage): boolean =>
+ s === "STAGING" || s === "PRODUCTION";
diff --git a/src/fs/StorageVersionErrorPage.tsx b/src/fs/StorageVersionErrorPage.tsx
new file mode 100644
index 000000000..a63750f3c
--- /dev/null
+++ b/src/fs/StorageVersionErrorPage.tsx
@@ -0,0 +1,56 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Button, Text } from "@microbit/ui";
+import { ErrorPage } from "@microbit/ui-patterns";
+import {
+ getCurrentProjectId,
+ sessionStorageIfPossible,
+} from "./current-project";
+import { databaseName } from "./projects-db";
+
+const deleteDatabase = (name: string) =>
+ new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(name);
+ request.onsuccess = () => resolve();
+ request.onerror = () =>
+ reject(request.error ?? new Error("deleteDatabase failed"));
+ });
+
+/**
+ * Shown on non-public stages when the projects database was created by an
+ * incompatible build. Review builds share one database, so this is expected
+ * to happen there now and then; the fix is to start again.
+ *
+ * Deliberately untranslated: it never appears on a public deployment.
+ */
+const StorageVersionErrorPage = () => {
+ const handleClearAndReload = async () => {
+ try {
+ await deleteDatabase(databaseName());
+ } catch {
+ // Best effort; the reload will show this page again if it failed.
+ }
+ const session = sessionStorageIfPossible();
+ if (session && getCurrentProjectId(session)) {
+ session.removeItem("currentProjectId");
+ }
+ window.location.reload();
+ };
+ return (
+
+
+ The project storage format has changed in this pre-release version and
+ the old data is not supported. Clearing removes every project stored by
+ review builds in this browser.
+
+
+
+ );
+};
+
+export default StorageVersionErrorPage;
diff --git a/src/fs/current-project.test.ts b/src/fs/current-project.test.ts
new file mode 100644
index 000000000..48619ae2b
--- /dev/null
+++ b/src/fs/current-project.test.ts
@@ -0,0 +1,143 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import "fake-indexeddb/auto";
+import { vi } from "vitest";
+import { MockLogging } from "../logging/mock";
+import {
+ getCurrentProjectId,
+ openCurrentProjectStorage,
+ setCurrentProjectId,
+} from "./current-project";
+import { IndexedDBFSStorage } from "./indexeddb-storage";
+import { databaseName, ProjectsDatabase } from "./projects-db";
+import { SessionStorageFSStorage } from "./storage";
+import { renderHook } from "@testing-library/react";
+import {
+ resetStorageStatus,
+ useProjectsDatabaseActive,
+ useStorageVersionError,
+} from "./storage-status";
+
+const deleteDatabase = () =>
+ new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(databaseName());
+ request.onsuccess = () => resolve();
+ request.onerror = () =>
+ reject(request.error ?? new Error("deleteDatabase failed"));
+ });
+
+const encode = (text: string) => new TextEncoder().encode(text);
+
+describe("openCurrentProjectStorage", () => {
+ let logging: MockLogging;
+ beforeEach(async () => {
+ sessionStorage.clear();
+ await deleteDatabase();
+ logging = new MockLogging();
+ resetStorageStatus();
+ });
+
+ it("creates a new project and makes it current when there is nothing", async () => {
+ const storage = await openCurrentProjectStorage(logging);
+ expect(storage).toBeInstanceOf(IndexedDBFSStorage);
+ expect(
+ renderHook(() => useProjectsDatabaseActive()).result.current
+ ).toEqual(true);
+ const id = getCurrentProjectId(sessionStorage);
+ expect(id).toBeDefined();
+
+ const db = await ProjectsDatabase.open();
+ expect((await db.list()).map((p) => p.id)).toEqual([id]);
+ expect(await db.files(id!)).toEqual({});
+ db.close();
+ await (storage as IndexedDBFSStorage).dispose();
+ });
+
+ it("reopens the tab's current project and marks it most recent", async () => {
+ const db = await ProjectsDatabase.open();
+ await db.create({ id: "old", name: "Old", timestamp: 1 }, {});
+ await db.create({ id: "cur", name: "Cur", timestamp: 2 }, {});
+ await db.touch("old", 3);
+ db.close();
+ setCurrentProjectId(sessionStorage, "cur");
+
+ const storage = await openCurrentProjectStorage(logging);
+ expect(await storage!.projectName()).toEqual("Cur");
+ const reopened = await ProjectsDatabase.open();
+ expect((await reopened.mostRecent())?.id).toEqual("cur");
+ reopened.close();
+ await (storage as IndexedDBFSStorage).dispose();
+ });
+
+ it("migrates a project from session storage on first load", async () => {
+ const legacy = new SessionStorageFSStorage(sessionStorage);
+ await legacy.write("main.py", encode("# mine"));
+ await legacy.write("helper.py", encode("# helper"));
+ await legacy.setProjectName("My project");
+ sessionStorage.setItem("unrelated", "kept");
+
+ const storage = await openCurrentProjectStorage(logging);
+
+ expect(await storage!.projectName()).toEqual("My project");
+ expect(await storage!.ls()).toEqual(["helper.py", "main.py"]);
+ expect(Array.from(await storage!.read("main.py"))).toEqual(
+ Array.from(encode("# mine"))
+ );
+ expect(await legacy.ls()).toEqual([]);
+ expect(await legacy.projectName()).toBeUndefined();
+ expect(sessionStorage.getItem("unrelated")).toEqual("kept");
+ expect(getCurrentProjectId(sessionStorage)).toBeDefined();
+ await (storage as IndexedDBFSStorage).dispose();
+ });
+
+ it("falls back to session storage when the database cannot be opened", async () => {
+ vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce(
+ new Error("blocked")
+ );
+ const storage = await openCurrentProjectStorage(logging);
+ expect(storage).toBeInstanceOf(SessionStorageFSStorage);
+ expect(logging.errors[0].message).toMatch(/using session storage/);
+ expect(
+ renderHook(() => useProjectsDatabaseActive()).result.current
+ ).toEqual(false);
+ });
+
+ it("falls back to session storage for an incompatible database on public stages", async () => {
+ vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce(
+ new DOMException("nope", "VersionError")
+ );
+ const storage = await openCurrentProjectStorage(logging, true);
+ expect(storage).toBeInstanceOf(SessionStorageFSStorage);
+ });
+
+ it("reports an incompatible database for clearing on non-public stages", async () => {
+ vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce(
+ new DOMException("nope", "VersionError")
+ );
+ const storage = await openCurrentProjectStorage(logging, false);
+ expect(storage).toBeUndefined();
+ expect(logging.errors).toEqual([]);
+ const { result } = renderHook(() => useStorageVersionError());
+ expect((result.current as DOMException).name).toEqual("VersionError");
+ });
+
+ it("falls back to session storage without IndexedDB", async () => {
+ const original = globalThis.indexedDB;
+ Object.defineProperty(globalThis, "indexedDB", {
+ value: undefined,
+ configurable: true,
+ });
+ try {
+ const storage = await openCurrentProjectStorage(logging);
+ expect(storage).toBeInstanceOf(SessionStorageFSStorage);
+ } finally {
+ Object.defineProperty(globalThis, "indexedDB", {
+ value: original,
+ configurable: true,
+ });
+ }
+ });
+});
diff --git a/src/fs/current-project.ts b/src/fs/current-project.ts
new file mode 100644
index 000000000..df9c620e1
--- /dev/null
+++ b/src/fs/current-project.ts
@@ -0,0 +1,124 @@
+/**
+ * Which project the editor opens, and its storage.
+ *
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { isPublicFacingStage } from "../environment";
+import { Logging } from "../logging/logging";
+import { generateId } from "./fs-util";
+import { IndexedDBFSStorage } from "./indexeddb-storage";
+import { ProjectsDatabase } from "./projects-db";
+import { FSStorage, SessionStorageFSStorage } from "./storage";
+import {
+ reportProjectsDatabaseActive,
+ reportStorageVersionError,
+} from "./storage-status";
+
+/**
+ * The open project is per tab, as the whole project used to be.
+ */
+const currentProjectKey = "currentProjectId";
+
+export const getCurrentProjectId = (
+ session: Storage | undefined
+): string | undefined => session?.getItem(currentProjectKey) ?? undefined;
+
+export const setCurrentProjectId = (
+ session: Storage | undefined,
+ id: string
+): void => session?.setItem(currentProjectKey, id);
+
+export const sessionStorageIfPossible = (): Storage | undefined => {
+ try {
+ return window.sessionStorage;
+ } catch {
+ // SecurityError in some embedding scenarios (issue 736) and no window in
+ // tests; either way there is nothing to read.
+ return undefined;
+ }
+};
+
+/**
+ * Opens the storage for the tab's current project.
+ *
+ * In order: the project the tab already has open; a project migrated from
+ * the session-storage file system that predates the projects database, so a reload
+ * after deploying this lands in the user's work; otherwise a new project.
+ *
+ * Without IndexedDB (unavailable, blocked, or an incompatible database) this
+ * falls back to session storage, which is what the editor used before. On
+ * non-public stages an incompatible database is reported for the UI to offer
+ * clearing it instead, since review builds share one database.
+ */
+export const openCurrentProjectStorage = async (
+ logging: Logging,
+ publicFacing: boolean = isPublicFacingStage()
+): Promise => {
+ if (typeof indexedDB === "undefined") {
+ return SessionStorageFSStorage.create();
+ }
+ let db: ProjectsDatabase;
+ try {
+ db = await ProjectsDatabase.open();
+ } catch (e) {
+ if (isVersionError(e) && !publicFacing) {
+ reportStorageVersionError(e);
+ return undefined;
+ }
+ logging.error("Projects database unavailable, using session storage", e);
+ return SessionStorageFSStorage.create();
+ }
+ const session = sessionStorageIfPossible();
+ const id = await chooseProject(db, session);
+ reportProjectsDatabaseActive();
+ return new IndexedDBFSStorage(db, id, (e) =>
+ logging.error("Failed to save project", e)
+ );
+};
+
+const chooseProject = async (
+ db: ProjectsDatabase,
+ session: Storage | undefined
+): Promise => {
+ const current = getCurrentProjectId(session);
+ if (current && (await db.get(current))) {
+ await db.touch(current);
+ return current;
+ }
+ const legacy = session && new SessionStorageFSStorage(session);
+ if (legacy && (await legacy.ls()).length > 0) {
+ const id = await migrateLegacyProject(db, legacy);
+ setCurrentProjectId(session, id);
+ return id;
+ }
+ const id = generateId();
+ await db.create({ id, name: undefined, timestamp: Date.now() }, {});
+ setCurrentProjectId(session, id);
+ return id;
+};
+
+/**
+ * Moves the single session-storage project into the database. The session
+ * storage copy is removed so this happens once; the id then stands in for it.
+ */
+const migrateLegacyProject = async (
+ db: ProjectsDatabase,
+ legacy: SessionStorageFSStorage
+): Promise => {
+ const files: Record = {};
+ for (const name of await legacy.ls()) {
+ files[name] = await legacy.read(name);
+ }
+ const id = generateId();
+ await db.create(
+ { id, name: await legacy.projectName(), timestamp: Date.now() },
+ files
+ );
+ await legacy.removeAll();
+ return id;
+};
+
+const isVersionError = (e: unknown): boolean =>
+ e instanceof DOMException && e.name === "VersionError";
diff --git a/src/fs/fs.test.ts b/src/fs/fs.test.ts
index baa8cc299..ccaf45e6a 100644
--- a/src/fs/fs.test.ts
+++ b/src/fs/fs.test.ts
@@ -20,6 +20,7 @@ import {
} from "./fs";
import { DefaultHost } from "./host";
import { defaultInitialProject } from "./initial-project";
+import { InMemoryFSStorage } from "./storage";
const hexes = [
fs.readFileSync("src/micropython/microbit-micropython-v1.hex", {
@@ -295,5 +296,94 @@ describe("fs - diff", () => {
});
});
+describe("Filesystem switchStorage", () => {
+ const logging = new ConsoleLogging();
+ const host = new DefaultHost();
+ const encode = (text: string) => new TextEncoder().encode(text);
+
+ const otherProject = async () => {
+ const storage = new InMemoryFSStorage("Other project");
+ await storage.write(MAIN_FILE, encode("# other main"));
+ await storage.write("helper.py", encode("# helper"));
+ await storage.markDirty();
+ return storage;
+ };
+
+ it("presents the new storage's files, name and dirty flag", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ await ufs.initialize();
+ const events: Project[] = [];
+ ufs.addEventListener("project_updated", (e) => {
+ events.push(e.project);
+ });
+
+ const storage = await otherProject();
+ await ufs.switchStorage(storage);
+
+ expect(ufs.project.name).toEqual("Other project");
+ expect(ufs.project.files.map((f) => f.name)).toEqual([
+ MAIN_FILE,
+ "helper.py",
+ ]);
+ expect(await asString(ufs.read(MAIN_FILE))).toEqual("# other main");
+ expect(ufs.dirty).toEqual(true);
+ expect(events).toHaveLength(1);
+ });
+
+ it("bumps versions so editors reload the same-named file", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ await ufs.initialize();
+ const before = ufs.project.files.find((f) => f.name === MAIN_FILE)!;
+
+ await ufs.switchStorage(await otherProject());
+
+ const after = ufs.project.files.find((f) => f.name === MAIN_FILE)!;
+ expect(after.version).toBeGreaterThan(before.version);
+ });
+
+ it("gives the project a new id", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ await ufs.initialize();
+ const before = ufs.project.id;
+ await ufs.switchStorage(await otherProject());
+ expect(ufs.project.id).not.toEqual(before);
+ });
+
+ it("refills the hex file system from the new storage", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ await ufs.initialize();
+ await ufs.switchStorage(await otherProject());
+
+ const stats = await ufs.statistics();
+ expect(stats.files).toEqual(2);
+ expect(await ufs.toHexForSave()).toContain(":");
+ });
+
+ it("directs later writes and removes to the new storage only", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ await ufs.initialize();
+ const storage = await otherProject();
+ await ufs.switchStorage(storage);
+
+ await ufs.write("new.py", "# new", VersionAction.INCREMENT);
+ await ufs.remove("helper.py");
+
+ expect(await storage.ls()).toEqual([MAIN_FILE, "new.py"]);
+ const original = await new DefaultHost().createStorage(logging).ls();
+ expect(original).not.toContain("new.py");
+ });
+
+ it("waits for an in-flight initialisation", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ const initializing = ufs.initialize();
+ await ufs.switchStorage(await otherProject());
+ await initializing;
+
+ expect(ufs.project.name).toEqual("Other project");
+ expect(await asString(ufs.read(MAIN_FILE))).toEqual("# other main");
+ expect((await ufs.statistics()).files).toEqual(2);
+ });
+});
+
const asString = async (f: Promise) =>
new TextDecoder().decode((await f).data);
diff --git a/src/fs/fs.ts b/src/fs/fs.ts
index c032246fb..bd4bdeb61 100644
--- a/src/fs/fs.ts
+++ b/src/fs/fs.ts
@@ -160,8 +160,9 @@ export const isNameLengthValid = (filename: string): boolean =>
/**
* The MicroPython file system adapted for convienient use from the UI.
*
- * For now we store contents backed by session storage so they're only
- * persistent over a browser refresh or Chrome tab restore.
+ * Contents are held in memory and mirrored to the host's persistent storage:
+ * the current project in the IndexedDB projects database, or session storage where
+ * that is unavailable.
*
* We version files in a way that's designed to make UI updates simple.
* If a UI action updates a file (e.g. load from disk) then we bump its version.
@@ -253,6 +254,29 @@ export class FileSystem extends TypedEventTarget {
return this.fs!;
}
+ /**
+ * Switch to a different backing storage, typically another project.
+ *
+ * The new storage is the record from here on: reads, writes and the hex
+ * file system all reflect it. Versions of files present in the new storage
+ * are bumped so editors showing a same-named file reload it.
+ */
+ async switchStorage(storage: FSStorage): Promise {
+ if (this.initializing) {
+ await this.initializing;
+ }
+ this.storage = storage;
+ this._dirty = await storage.isDirty();
+ this.project = { ...this.project, id: generateId() };
+ if (this.fs) {
+ await this.initializeFsFromStorage(this.fs);
+ }
+ for (const name of await storage.ls()) {
+ this.incrementFileVersion(name);
+ }
+ return this.notify();
+ }
+
/**
* Update the project name.
*
diff --git a/src/fs/host.ts b/src/fs/host.ts
index 37480617f..7ccdecfd9 100644
--- a/src/fs/host.ts
+++ b/src/fs/host.ts
@@ -12,12 +12,8 @@ import {
projectFilesToBase64,
} from "./initial-project";
import { parseMigrationFromUrl } from "./migration";
-import {
- FSStorage,
- InMemoryFSStorage,
- SessionStorageFSStorage,
- SplitStrategyStorage,
-} from "./storage";
+import { openCurrentProjectStorage } from "./current-project";
+import { FSStorage, InMemoryFSStorage, SplitStrategyStorage } from "./storage";
const messages = {
type: "pyeditor",
@@ -42,7 +38,7 @@ export class DefaultHost implements Host {
createStorage(logging: Logging): FSStorage {
return new SplitStrategyStorage(
new InMemoryFSStorage(undefined),
- SessionStorageFSStorage.create(),
+ openCurrentProjectStorage(logging),
logging
);
}
diff --git a/src/fs/indexeddb-storage.test.ts b/src/fs/indexeddb-storage.test.ts
new file mode 100644
index 000000000..f9e21388c
--- /dev/null
+++ b/src/fs/indexeddb-storage.test.ts
@@ -0,0 +1,109 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import "fake-indexeddb/auto";
+import { vi } from "vitest";
+import { IndexedDBFSStorage } from "./indexeddb-storage";
+import { ProjectsDatabase } from "./projects-db";
+import { commonStorageTests } from "./storage-tests";
+
+let counter = 0;
+const uniqueName = () => `test-${Date.now()}-${counter++}`;
+const projectId = "p1";
+const contents = (files: Record) =>
+ Object.fromEntries(
+ Object.entries(files).map(([name, data]) => [name, Array.from(data)])
+ );
+
+const openWithProject = async () => {
+ const db = await ProjectsDatabase.open(uniqueName());
+ await db.create({ id: projectId, name: undefined, timestamp: 1 }, {});
+ return db;
+};
+
+describe("IndexedDBFSStorage", () => {
+ let db: ProjectsDatabase;
+ let storage: IndexedDBFSStorage;
+ let errors: unknown[];
+ beforeEach(async () => {
+ db = await openWithProject();
+ errors = [];
+ storage = new IndexedDBFSStorage(db, projectId, (e) => errors.push(e), 20);
+ });
+ afterEach(async () => {
+ await storage.dispose();
+ });
+
+ commonStorageTests(() => storage);
+
+ it("coalesces writes into one transaction after the delay", async () => {
+ const apply = vi.spyOn(db, "apply");
+ await storage.write("main.py", new Uint8Array([1]));
+ await storage.write("main.py", new Uint8Array([2]));
+ await storage.write("other.py", new Uint8Array([3]));
+ await storage.setProjectName("Renamed");
+ expect(apply).not.toHaveBeenCalled();
+
+ await new Promise((resolve) => setTimeout(resolve, 40));
+
+ expect(apply).toHaveBeenCalledTimes(1);
+ expect(contents(await db.files(projectId))).toEqual({
+ "main.py": [2],
+ "other.py": [3],
+ });
+ expect((await db.get(projectId))?.name).toEqual("Renamed");
+ });
+
+ it("does not track the dirty flag", async () => {
+ const apply = vi.spyOn(db, "apply");
+ await storage.markDirty();
+ await storage.flush();
+ expect(await storage.isDirty()).toEqual(false);
+ expect(apply).not.toHaveBeenCalled();
+ });
+
+ it("a remove after a write in the same batch wins", async () => {
+ await storage.write("main.py", new Uint8Array([1]));
+ await storage.remove("main.py");
+ await storage.flush();
+ expect(contents(await db.files(projectId))).toEqual({});
+ });
+
+ it("bumps the project timestamp when it flushes changes", async () => {
+ await storage.write("main.py", new Uint8Array([1]));
+ await storage.flush();
+ expect((await db.get(projectId))?.timestamp).toBeGreaterThan(1);
+ });
+
+ it("flushes when the page is hidden", async () => {
+ await storage.write("main.py", new Uint8Array([1]));
+ Object.defineProperty(document, "visibilityState", {
+ value: "hidden",
+ configurable: true,
+ });
+ document.dispatchEvent(new Event("visibilitychange"));
+ // The flush is asynchronous; wait less than the scheduled delay.
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ expect(contents(await db.files(projectId))).toEqual({ "main.py": [1] });
+ Object.defineProperty(document, "visibilityState", {
+ value: "visible",
+ configurable: true,
+ });
+ });
+
+ it("reports a failed flush and drops those changes rather than throwing", async () => {
+ vi.spyOn(db, "apply").mockRejectedValueOnce(
+ new DOMException("full", "QuotaExceededError")
+ );
+ await storage.write("main.py", new Uint8Array([1]));
+ await storage.flush();
+ expect(errors).toHaveLength(1);
+ expect((errors[0] as DOMException).name).toEqual("QuotaExceededError");
+
+ await storage.write("other.py", new Uint8Array([2]));
+ await storage.flush();
+ expect(contents(await db.files(projectId))).toEqual({ "other.py": [2] });
+ });
+});
diff --git a/src/fs/indexeddb-storage.ts b/src/fs/indexeddb-storage.ts
new file mode 100644
index 000000000..47d6b09a8
--- /dev/null
+++ b/src/fs/indexeddb-storage.ts
@@ -0,0 +1,159 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { ProjectChanges, ProjectsDatabase } from "./projects-db";
+import { FSStorage } from "./storage";
+
+const defaultFlushDelayMs = 300;
+
+/**
+ * File system storage for one project in the IndexedDB projects database.
+ *
+ * Intended as the secondary of a SplitStrategyStorage, so reads are rare and
+ * writes arrive on every keystroke. Writes are coalesced per file and flushed
+ * as one transaction after a short delay, and when the page is hidden or
+ * unloading. Reads flush first so they always see the latest write.
+ *
+ * Failures never propagate: the in-memory primary still holds the content,
+ * so a failed flush is reported through onError and the changes are dropped
+ * rather than retried forever against, say, a full quota.
+ *
+ * The dirty flag is not stored: it exists to warn before work is lost, and
+ * a project in the database outlives the tab.
+ */
+export class IndexedDBFSStorage implements FSStorage {
+ private pendingWrites = new Map();
+ private pendingMeta: NonNullable = {};
+ private timer: ReturnType | undefined;
+ private flushing: Promise = Promise.resolve();
+ private readonly handleHidden = () => {
+ if (document.visibilityState === "hidden") {
+ void this.flush();
+ }
+ };
+
+ constructor(
+ private db: ProjectsDatabase,
+ private projectId: string,
+ private onError: (e: unknown) => void,
+ private flushDelayMs: number = defaultFlushDelayMs
+ ) {
+ if (typeof document !== "undefined") {
+ document.addEventListener("visibilitychange", this.handleHidden);
+ window.addEventListener("pagehide", this.handleHidden);
+ }
+ }
+
+ /** Flushes, stops listening and closes the database. */
+ async dispose(): Promise {
+ if (typeof document !== "undefined") {
+ document.removeEventListener("visibilitychange", this.handleHidden);
+ window.removeEventListener("pagehide", this.handleHidden);
+ }
+ await this.flush();
+ this.db.close();
+ }
+
+ async ls(): Promise {
+ await this.flush();
+ return this.db.fileNames(this.projectId);
+ }
+
+ async exists(filename: string): Promise {
+ await this.flush();
+ return (await this.db.file(this.projectId, filename)) !== undefined;
+ }
+
+ async read(filename: string): Promise {
+ await this.flush();
+ const data = await this.db.file(this.projectId, filename);
+ if (data === undefined) {
+ throw new Error(`No such file ${filename}`);
+ }
+ return data;
+ }
+
+ async write(name: string, content: Uint8Array): Promise {
+ this.pendingWrites.set(name, content);
+ this.schedule();
+ }
+
+ async remove(name: string): Promise {
+ this.pendingWrites.set(name, null);
+ this.schedule();
+ }
+
+ async clear(): Promise {
+ for (const name of await this.ls()) {
+ this.pendingWrites.set(name, null);
+ }
+ this.pendingMeta = { name: undefined };
+ await this.flush();
+ }
+
+ async setProjectName(projectName: string | undefined): Promise {
+ this.pendingMeta.name = projectName;
+ this.schedule();
+ }
+
+ async projectName(): Promise {
+ await this.flush();
+ return (await this.db.get(this.projectId))?.name;
+ }
+
+ async markDirty(): Promise {}
+
+ async clearDirty(): Promise {}
+
+ async isDirty(): Promise {
+ return false;
+ }
+
+ private schedule(): void {
+ if (this.timer === undefined) {
+ this.timer = setTimeout(() => void this.flush(), this.flushDelayMs);
+ }
+ }
+
+ /**
+ * Writes everything pending in one transaction. Safe to call at any time;
+ * concurrent calls queue behind each other.
+ */
+ flush(): Promise {
+ clearTimeout(this.timer);
+ this.timer = undefined;
+ this.flushing = this.flushing.then(() => this.flushPending());
+ return this.flushing;
+ }
+
+ private async flushPending(): Promise {
+ if (this.pendingWrites.size === 0 && isEmpty(this.pendingMeta)) {
+ return;
+ }
+ const writes: Record = {};
+ const deletes: string[] = [];
+ for (const [name, data] of this.pendingWrites) {
+ if (data === null) {
+ deletes.push(name);
+ } else {
+ writes[name] = data;
+ }
+ }
+ const changes: ProjectChanges = {
+ meta: { ...this.pendingMeta, timestamp: Date.now() },
+ writes,
+ deletes,
+ };
+ this.pendingWrites = new Map();
+ this.pendingMeta = {};
+ try {
+ await this.db.apply(this.projectId, changes);
+ } catch (e) {
+ this.onError(e);
+ }
+ }
+}
+
+const isEmpty = (o: object) => Object.keys(o).length === 0;
diff --git a/src/fs/projects-db.test.ts b/src/fs/projects-db.test.ts
new file mode 100644
index 000000000..8ee5d290d
--- /dev/null
+++ b/src/fs/projects-db.test.ts
@@ -0,0 +1,128 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import "fake-indexeddb/auto";
+import { openDB } from "idb";
+import { databaseName, ProjectMeta, ProjectsDatabase } from "./projects-db";
+
+let counter = 0;
+const uniqueName = () => `test-${Date.now()}-${counter++}`;
+
+const meta = (id: string, timestamp: number, name = id): ProjectMeta => ({
+ id,
+ name,
+ timestamp,
+});
+const bytes = (...values: number[]) => new Uint8Array(values);
+// Typed arrays read back through fake-indexeddb under jsdom belong to another
+// realm, so compare contents rather than objects.
+const contents = (files: Record) =>
+ Object.fromEntries(
+ Object.entries(files).map(([name, data]) => [name, Array.from(data)])
+ );
+
+describe("databaseName", () => {
+ it("is namespaced by the base path so production and beta stay apart", () => {
+ expect(databaseName("local", "/")).toEqual("python-editor");
+ expect(databaseName("PRODUCTION", "/v/3/")).toEqual("python-editor/v/3");
+ expect(databaseName("STAGING", "/v/beta/")).toEqual("python-editor/v/beta");
+ });
+
+ it("is shared by every review build", () => {
+ expect(databaseName("REVIEW", "/some-branch/")).toEqual(
+ "python-editor-review"
+ );
+ expect(databaseName("REVIEW", "/another/")).toEqual("python-editor-review");
+ });
+});
+
+describe("ProjectsDatabase", () => {
+ let db: ProjectsDatabase;
+ beforeEach(async () => {
+ db = await ProjectsDatabase.open(uniqueName());
+ });
+ afterEach(() => db.close());
+
+ it("starts empty", async () => {
+ expect(await db.list()).toEqual([]);
+ expect(await db.mostRecent()).toBeUndefined();
+ });
+
+ it("creates projects with files and lists them most recent first", async () => {
+ await db.create(meta("a", 1), { "main.py": bytes(1) });
+ await db.create(meta("b", 2), { "main.py": bytes(2), "x.py": bytes(3) });
+ expect((await db.list()).map((p) => p.id)).toEqual(["b", "a"]);
+ expect(await db.mostRecent()).toEqual(meta("b", 2));
+ expect(contents(await db.files("b"))).toEqual({
+ "main.py": [2],
+ "x.py": [3],
+ });
+ expect(await db.fileNames("a")).toEqual(["main.py"]);
+ expect(Array.from((await db.file("a", "main.py"))!)).toEqual([1]);
+ expect(await db.file("a", "nope.py")).toBeUndefined();
+ });
+
+ it("applies metadata, writes and deletes together", async () => {
+ await db.create(meta("a", 1), { "main.py": bytes(1), "old.py": bytes(9) });
+ await db.apply("a", {
+ meta: { name: "renamed", timestamp: 5 },
+ writes: { "main.py": bytes(2), "new.py": bytes(3) },
+ deletes: ["old.py"],
+ });
+ expect(await db.get("a")).toEqual({
+ id: "a",
+ name: "renamed",
+ timestamp: 5,
+ });
+ expect(contents(await db.files("a"))).toEqual({
+ "main.py": [2],
+ "new.py": [3],
+ });
+ });
+
+ it("refuses changes to a project that does not exist", async () => {
+ await expect(db.apply("missing", { meta: { name: "x" } })).rejects.toThrow(
+ /No such project missing/
+ );
+ });
+
+ it("touch makes a project the most recent", async () => {
+ await db.create(meta("a", 1), {});
+ await db.create(meta("b", 2), {});
+ await db.touch("a", 3);
+ expect((await db.mostRecent())?.id).toEqual("a");
+ });
+
+ it("duplicates a project's files under new metadata", async () => {
+ await db.create(meta("a", 1), { "main.py": bytes(1) });
+ await db.duplicate("a", meta("b", 2, "copy"));
+ expect(await db.get("b")).toEqual(meta("b", 2, "copy"));
+ expect(contents(await db.files("b"))).toEqual({ "main.py": [1] });
+ });
+
+ it("deletes a project and its files", async () => {
+ await db.create(meta("a", 1), { "main.py": bytes(1), "x.py": bytes(2) });
+ await db.create(meta("b", 2), { "main.py": bytes(3) });
+ await db.delete("a");
+ expect((await db.list()).map((p) => p.id)).toEqual(["b"]);
+ expect(contents(await db.files("a"))).toEqual({});
+ expect(contents(await db.files("b"))).toEqual({ "main.py": [3] });
+ });
+
+ it("rejects a database created by an incompatible version", async () => {
+ const name = uniqueName();
+ // Same version number, different stores: what a future schema change
+ // that forgot to bump the version would look like from an old build.
+ const other = await openDB(name, 1, {
+ upgrade(db) {
+ db.createObjectStore("something-else");
+ },
+ });
+ other.close();
+ await expect(ProjectsDatabase.open(name)).rejects.toMatchObject({
+ name: "VersionError",
+ });
+ });
+});
diff --git a/src/fs/projects-db.ts b/src/fs/projects-db.ts
new file mode 100644
index 000000000..9a0f033e1
--- /dev/null
+++ b/src/fs/projects-db.ts
@@ -0,0 +1,192 @@
+/**
+ * The projects database in IndexedDB.
+ *
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { DBSchema, IDBPDatabase, openDB } from "idb";
+import { baseUrl } from "../base";
+import { Stage, stage as currentStage } from "../environment";
+
+/**
+ * Project metadata. Deliberately holds no file content so listing is cheap.
+ */
+export interface ProjectMeta {
+ id: string;
+ name: string | undefined;
+ /** Last modified or opened, for ordering by recency. */
+ timestamp: number;
+}
+
+/**
+ * A file belonging to a project, keyed by [projectId, name]: a file's name
+ * is its identity within a project.
+ */
+export interface FileRecord {
+ projectId: string;
+ name: string;
+ data: Uint8Array;
+}
+
+/**
+ * A set of changes to one project, applied in a single transaction.
+ */
+export interface ProjectChanges {
+ meta?: Partial>;
+ writes?: Record;
+ deletes?: string[];
+}
+
+const PROJECTS = "projects";
+const FILES = "files";
+const stores = [PROJECTS, FILES] as const;
+
+interface Schema extends DBSchema {
+ [PROJECTS]: {
+ key: string;
+ value: ProjectMeta;
+ };
+ [FILES]: {
+ key: [string, string];
+ value: FileRecord;
+ indexes: { projectId: string };
+ };
+}
+
+const DB_VERSION = 1;
+
+/**
+ * Deployments share an origin, so the database name includes the base path
+ * to keep production's and beta's libraries apart. Review builds all share
+ * one: they are internal, and a project made on one branch is useful on the
+ * next. When a schema change breaks it, StorageVersionErrorPage offers to
+ * clear it.
+ */
+export const databaseName = (
+ stage: Stage = currentStage,
+ base: string = baseUrl
+): string => {
+ if (stage === "REVIEW") {
+ return "python-editor-review";
+ }
+ return base === "/"
+ ? "python-editor"
+ : `python-editor${base.replace(/\/$/, "")}`;
+};
+
+export class ProjectsDatabase {
+ private constructor(private db: IDBPDatabase) {}
+
+ /**
+ * Opens the database, creating it if needed.
+ *
+ * @throws a VersionError DOMException if the database exists but lacks the
+ * expected stores, which means a newer version of the app created it.
+ */
+ static async open(name: string = databaseName()): Promise {
+ const db = await openDB(name, DB_VERSION, {
+ upgrade(db) {
+ db.createObjectStore(PROJECTS, { keyPath: "id" });
+ const files = db.createObjectStore(FILES, {
+ keyPath: ["projectId", "name"],
+ });
+ files.createIndex("projectId", "projectId");
+ },
+ });
+ for (const store of stores) {
+ if (!db.objectStoreNames.contains(store)) {
+ db.close();
+ throw new DOMException(
+ `Database ${name} has no ${store} store; it was created by an incompatible version of the app`,
+ "VersionError"
+ );
+ }
+ }
+ return new ProjectsDatabase(db);
+ }
+
+ close(): void {
+ this.db.close();
+ }
+
+ /** All projects, most recent first. */
+ async list(): Promise {
+ const all = await this.db.getAll(PROJECTS);
+ return all.sort((a, b) => b.timestamp - a.timestamp);
+ }
+
+ async get(id: string): Promise {
+ return this.db.get(PROJECTS, id);
+ }
+
+ async mostRecent(): Promise {
+ return (await this.list())[0];
+ }
+
+ async files(id: string): Promise> {
+ const records = await this.db.getAllFromIndex(FILES, "projectId", id);
+ return Object.fromEntries(records.map((r) => [r.name, r.data]));
+ }
+
+ async fileNames(id: string): Promise {
+ const keys = await this.db.getAllKeysFromIndex(FILES, "projectId", id);
+ return keys.map(([, name]) => name);
+ }
+
+ async file(id: string, name: string): Promise {
+ return (await this.db.get(FILES, [id, name]))?.data;
+ }
+
+ /** Creates a project and its files atomically. */
+ async create(
+ meta: ProjectMeta,
+ files: Record
+ ): Promise {
+ const tx = this.db.transaction(stores, "readwrite");
+ await tx.objectStore(PROJECTS).put(meta);
+ const fileStore = tx.objectStore(FILES);
+ for (const [name, data] of Object.entries(files)) {
+ await fileStore.put({ projectId: meta.id, name, data });
+ }
+ await tx.done;
+ }
+
+ /** Applies a set of changes to a project in one transaction. */
+ async apply(id: string, changes: ProjectChanges): Promise {
+ const tx = this.db.transaction(stores, "readwrite");
+ const projects = tx.objectStore(PROJECTS);
+ const existing = await projects.get(id);
+ if (!existing) {
+ throw new Error(`No such project ${id}`);
+ }
+ await projects.put({ ...existing, ...changes.meta, id });
+ const fileStore = tx.objectStore(FILES);
+ for (const [name, data] of Object.entries(changes.writes ?? {})) {
+ await fileStore.put({ projectId: id, name, data });
+ }
+ for (const name of changes.deletes ?? []) {
+ await fileStore.delete([id, name]);
+ }
+ await tx.done;
+ }
+
+ /** Marks a project as the most recent. */
+ async touch(id: string, timestamp: number = Date.now()): Promise {
+ await this.apply(id, { meta: { timestamp } });
+ }
+
+ async duplicate(sourceId: string, meta: ProjectMeta): Promise {
+ await this.create(meta, await this.files(sourceId));
+ }
+
+ async delete(id: string): Promise {
+ const tx = this.db.transaction(stores, "readwrite");
+ await tx.objectStore(PROJECTS).delete(id);
+ const fileStore = tx.objectStore(FILES);
+ for (const key of await fileStore.index("projectId").getAllKeys(id)) {
+ await fileStore.delete(key);
+ }
+ await tx.done;
+ }
+}
diff --git a/src/fs/storage-status.ts b/src/fs/storage-status.ts
new file mode 100644
index 000000000..804a5bbf9
--- /dev/null
+++ b/src/fs/storage-status.ts
@@ -0,0 +1,50 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { useSyncExternalStore } from "react";
+
+// The storage opens at module load, before React mounts, so the outcome is
+// held here for the UI to read rather than passed through props.
+let versionError: unknown;
+let projectsDatabaseActive = false;
+const listeners = new Set<() => void>();
+
+const notify = () => listeners.forEach((listener) => listener());
+
+/**
+ * Records that the projects database was created by an incompatible version
+ * of the app. Only used on non-public stages, where the fix is to clear it.
+ */
+export const reportStorageVersionError = (error: unknown): void => {
+ versionError = error;
+ notify();
+};
+
+/**
+ * Records that the open project is in the projects database, which outlives
+ * the tab. Not reported for the session-storage fallback or in iframe mode,
+ * where closing the tab still loses the work.
+ */
+export const reportProjectsDatabaseActive = (): void => {
+ projectsDatabaseActive = true;
+ notify();
+};
+
+const subscribe = (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+};
+
+export const useStorageVersionError = (): unknown =>
+ useSyncExternalStore(subscribe, () => versionError);
+
+export const useProjectsDatabaseActive = (): boolean =>
+ useSyncExternalStore(subscribe, () => projectsDatabaseActive);
+
+/** For tests. */
+export const resetStorageStatus = (): void => {
+ versionError = undefined;
+ projectsDatabaseActive = false;
+};
diff --git a/src/fs/storage-tests.ts b/src/fs/storage-tests.ts
new file mode 100644
index 000000000..99a8f3c98
--- /dev/null
+++ b/src/fs/storage-tests.ts
@@ -0,0 +1,71 @@
+/**
+ * Behaviour every FSStorage implementation must have. Not a test file itself;
+ * each implementation's test calls it.
+ *
+ * (c) 2021, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { FSStorage } from "./storage";
+
+export const commonStorageTests = (storage: () => FSStorage) => {
+ it("is empty", async () => {
+ expect(await storage().ls()).toEqual([]);
+ });
+
+ it("stores project name", async () => {
+ await storage().setProjectName("foo");
+ expect(await storage().projectName()).toEqual("foo");
+ });
+
+ it("stores files", async () => {
+ await storage().write("test1.py", new Uint8Array([1]));
+ await storage().write("test2.py", new Uint8Array([2]));
+
+ expect(await storage().ls()).toEqual(["test1.py", "test2.py"]);
+ expect(await storage().exists("test1.py")).toEqual(true);
+ expect(await storage().exists("testX.py")).toEqual(false);
+ expect(Array.from(await storage().read("test1.py"))).toEqual([1]);
+ expect(Array.from(await storage().read("test2.py"))).toEqual([2]);
+ });
+
+ it("throws trying to read a non-existent file", async () => {
+ await expect(() => storage().read("test1.py")).rejects.toThrow(
+ /No such file test1.py/
+ );
+ });
+
+ it("removes files", async () => {
+ await storage().write("test1.py", new Uint8Array([1]));
+ await storage().write("test2.py", new Uint8Array([2]));
+
+ await storage().remove("test1.py");
+
+ expect(await storage().exists("test1.py")).toEqual(false);
+ expect(await storage().ls()).toEqual(["test2.py"]);
+ });
+
+ it("clears", async () => {
+ await storage().write("test1.py", new Uint8Array([1]));
+ await storage().write("test2.py", new Uint8Array([2]));
+
+ await storage().clear();
+
+ expect(await storage().exists("test1.py")).toEqual(false);
+ expect(await storage().exists("test2.py")).toEqual(false);
+ expect(await storage().ls()).toEqual([]);
+ });
+};
+
+/**
+ * For storage that lives no longer than the tab and so tracks the dirty flag.
+ */
+export const dirtyFlagTests = (storage: () => FSStorage) => {
+ it("stores dirty flag", async () => {
+ expect(await storage().isDirty()).toEqual(false);
+ await storage().markDirty();
+ expect(await storage().isDirty()).toEqual(true);
+ await storage().clearDirty();
+ expect(await storage().isDirty()).toEqual(false);
+ });
+};
diff --git a/src/fs/storage.test.ts b/src/fs/storage.test.ts
index afbb52d1c..1d4c990cf 100644
--- a/src/fs/storage.test.ts
+++ b/src/fs/storage.test.ts
@@ -6,77 +6,21 @@
import { ConsoleLogging } from "../deployment/default/logging";
import { MockLogging } from "../logging/mock";
import {
- FSStorage,
InMemoryFSStorage,
SessionStorageFSStorage,
SplitStrategyStorage,
} from "./storage";
+import { commonStorageTests, dirtyFlagTests } from "./storage-tests";
const projectName = "projectName";
-const commonStorageTests = (storage: FSStorage) => {
- it("is empty", async () => {
- expect(await storage.ls()).toEqual([]);
- });
-
- it("stores project name", async () => {
- await storage.setProjectName("foo");
- expect(await storage.projectName()).toEqual("foo");
- });
-
- it("stores dirty flag", async () => {
- expect(await storage.isDirty()).toEqual(false);
- await storage.markDirty();
- expect(await storage.isDirty()).toEqual(true);
- await storage.clearDirty();
- expect(await storage.isDirty()).toEqual(false);
- });
-
- it("stores files", async () => {
- await storage.write("test1.py", new Uint8Array([1]));
- await storage.write("test2.py", new Uint8Array([2]));
-
- expect(await storage.ls()).toEqual(["test1.py", "test2.py"]);
- expect(await storage.exists("test1.py")).toEqual(true);
- expect(await storage.exists("testX.py")).toEqual(false);
- expect(await storage.read("test1.py")).toEqual(new Uint8Array([1]));
- expect(await storage.read("test2.py")).toEqual(new Uint8Array([2]));
- });
-
- it("throws trying to read a non-existent file", async () => {
- await expect(() => storage.read("test1.py")).rejects.toThrow(
- /No such file test1.py/
- );
- });
-
- it("removes files", async () => {
- await storage.write("test1.py", new Uint8Array([1]));
- await storage.write("test2.py", new Uint8Array([2]));
-
- await storage.remove("test1.py");
-
- expect(await storage.exists("test1.py")).toEqual(false);
- expect(await storage.ls()).toEqual(["test2.py"]);
- });
-
- it("clears", async () => {
- await storage.write("test1.py", new Uint8Array([1]));
- await storage.write("test2.py", new Uint8Array([2]));
-
- await storage.clear();
-
- expect(await storage.exists("test1.py")).toEqual(false);
- expect(await storage.exists("test2.py")).toEqual(false);
- expect(await storage.ls()).toEqual([]);
- });
-};
-
describe("SessionStorageFSStorage", () => {
const storage = new SessionStorageFSStorage(sessionStorage);
beforeEach(() => {
sessionStorage.clear();
});
- commonStorageTests(storage);
+ commonStorageTests(() => storage);
+ dirtyFlagTests(() => storage);
});
describe("InMemoryFSStorage", () => {
@@ -84,7 +28,8 @@ describe("InMemoryFSStorage", () => {
beforeEach(() => {
storage.clear();
});
- commonStorageTests(storage);
+ commonStorageTests(() => storage);
+ dirtyFlagTests(() => storage);
});
describe("SplitStrategyStorage", () => {
@@ -98,7 +43,8 @@ describe("SplitStrategyStorage", () => {
storage.clear();
sessionStorage.clear();
});
- commonStorageTests(storage);
+ commonStorageTests(() => storage);
+ dirtyFlagTests(() => storage);
it("initializes from session storage", async () => {
const memory = new InMemoryFSStorage(projectName);
diff --git a/src/fs/storage.ts b/src/fs/storage.ts
index 90ed81333..6ebc4a28c 100644
--- a/src/fs/storage.ts
+++ b/src/fs/storage.ts
@@ -23,8 +23,10 @@ export interface FSStorage {
projectName(): Promise;
clear(): Promise;
/**
- * We persist the dirty flag so that we know whether the user
- * had previously made changes after a restore from storage.
+ * Whether the user has changed the project since the last hex save, used
+ * to warn before their work is lost. Storage that lives no longer than the
+ * tab persists it so the warning survives a reload; storage that outlives
+ * the tab has nothing to warn about and always reports false.
*/
markDirty(): Promise;
clearDirty(): Promise;
@@ -169,6 +171,18 @@ export class SessionStorageFSStorage implements FSStorage {
this.storage.clear();
}
+ /**
+ * Removes the file system's keys and nothing else: session storage also
+ * holds session settings.
+ */
+ async removeAll(): Promise {
+ for (const key of Object.keys(this.storage)) {
+ if (key.startsWith(fsFilesPrefix) || key.startsWith(fsMetadataPrefix)) {
+ this.storage.removeItem(key);
+ }
+ }
+ }
+
async markDirty(): Promise {
this.storage.setItem(dirtyKey, "true");
}
@@ -189,17 +203,25 @@ export class SessionStorageFSStorage implements FSStorage {
*/
export class SplitStrategyStorage implements FSStorage {
private initialized: Promise;
+ private secondary: FSStorage | undefined;
+ /**
+ * @param secondary The persistent copy, or a promise of one for storage
+ * that takes time to open. Every operation waits for it.
+ */
constructor(
private primary: FSStorage,
- private secondary: FSStorage | undefined,
+ secondary: FSStorage | undefined | Promise,
private log: Logging
) {
- this.initialized = secondary
- ? this.secondaryErrorHandle(async () => {
- await initializeFromStorage(secondary, primary);
- })
- : Promise.resolve();
+ this.initialized = Promise.resolve(secondary).then((resolved) => {
+ this.secondary = resolved;
+ return resolved
+ ? this.secondaryErrorHandle(async () => {
+ await initializeFromStorage(resolved, primary);
+ })
+ : undefined;
+ });
}
async ls() {
diff --git a/src/workbench/BeforeUnloadDirtyCheck.tsx b/src/workbench/BeforeUnloadDirtyCheck.tsx
index 9901d82c6..f96e94a48 100644
--- a/src/workbench/BeforeUnloadDirtyCheck.tsx
+++ b/src/workbench/BeforeUnloadDirtyCheck.tsx
@@ -5,13 +5,20 @@
*/
import { useEffect } from "react";
import { useFileSystem } from "../fs/fs-hooks";
+import { useProjectsDatabaseActive } from "../fs/storage-status";
/**
- * Warns the user before closing a tab if they've made changes.
+ * Warns the user before closing a tab if they've made changes that would be
+ * lost. Nothing is lost when the project is in the projects database, so the
+ * warning only applies to the session-storage fallback and iframe mode.
*/
const BeforeUnloadDirtyCheck = () => {
const fs = useFileSystem();
+ const persisted = useProjectsDatabaseActive();
useEffect(() => {
+ if (persisted) {
+ return;
+ }
const listener = (e: BeforeUnloadEvent) => {
if (fs.dirty) {
e.preventDefault();
@@ -24,7 +31,7 @@ const BeforeUnloadDirtyCheck = () => {
return () => {
window.removeEventListener("beforeunload", listener);
};
- }, [fs]);
+ }, [fs, persisted]);
return null;
};