From 6380bd31cd48203006dbb6f4ea2f9256d257d536 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 11 Sep 2026 21:18:11 +0000 Subject: [PATCH 1/6] Let FileSystem switch to a different storage Groundwork for a project library: opening a project will swap the FileSystem's backing storage rather than replace files one by one. switchStorage waits for any in-flight initialisation, makes the new storage the record, refills the hex file system from it and bumps the version of every file it holds so an editor showing a same-named file reloads it. Versions increment rather than reset because the editor keys on name plus version; a reset to 1 could collide with the old project's version and leave stale content on screen. --- src/fs/fs.test.ts | 90 +++++++++++++++++++++++++++++++++++++++++++++++ src/fs/fs.ts | 23 ++++++++++++ 2 files changed, 113 insertions(+) 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..b76da77c5 100644 --- a/src/fs/fs.ts +++ b/src/fs/fs.ts @@ -253,6 +253,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. * From ed1a36685918b06af1ecfc50f9516b254a2ad13b Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 11 Sep 2026 21:26:58 +0000 Subject: [PATCH 2/6] Persist the current project in an IndexedDB project library The editor's file system is now mirrored to IndexedDB rather than session storage, as the first step towards a library of projects. Behaviour is unchanged for the user: one project per tab, and a new tab gets a new project. - projects-db.ts: the library. Two stores, project metadata and files keyed by [projectId, name], so listing is cheap and a change writes only the files it touched. The database name includes the base path because production, beta and review builds share an origin. Opening asserts the stores exist so an old build fails clearly against a database a newer one created. - indexeddb-storage.ts: an FSStorage for one project, used as the SplitStrategyStorage secondary. Keystroke writes are coalesced per file and flushed in one transaction after a short delay and when the page is hidden. A failed flush is reported, not thrown, and its changes dropped: the in-memory primary still has them and retrying forever against a full quota helps nobody. - current-project.ts: which project a tab opens. The tab's current project id lives in session storage. A session-storage file system from before this change is migrated into the library on first load, so a reload after deploy lands in the user's work; only the file system keys are removed, since session settings live there too. Without IndexedDB, or with an incompatible database, the editor falls back to session storage as before. - SplitStrategyStorage accepts a promise of its secondary, since opening IndexedDB is asynchronous and the Host API is not. The shared FSStorage tests move to storage-tests.ts so the new storage runs them too, against fake-indexeddb. --- package-lock.json | 33 +++++- package.json | 2 + src/fs/current-project.test.ts | 113 +++++++++++++++++++ src/fs/current-project.ts | 116 ++++++++++++++++++++ src/fs/fs.ts | 5 +- src/fs/host.ts | 10 +- src/fs/indexeddb-storage.test.ts | 104 ++++++++++++++++++ src/fs/indexeddb-storage.ts | 163 ++++++++++++++++++++++++++++ src/fs/projects-db.test.ts | 123 +++++++++++++++++++++ src/fs/projects-db.ts | 181 +++++++++++++++++++++++++++++++ src/fs/storage-tests.ts | 66 +++++++++++ src/fs/storage.test.ts | 65 +---------- src/fs/storage.ts | 32 +++++- 13 files changed, 933 insertions(+), 80 deletions(-) create mode 100644 src/fs/current-project.test.ts create mode 100644 src/fs/current-project.ts create mode 100644 src/fs/indexeddb-storage.test.ts create mode 100644 src/fs/indexeddb-storage.ts create mode 100644 src/fs/projects-db.test.ts create mode 100644 src/fs/projects-db.ts create mode 100644 src/fs/storage-tests.ts 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/fs/current-project.test.ts b/src/fs/current-project.test.ts new file mode 100644 index 000000000..0f0fef731 --- /dev/null +++ b/src/fs/current-project.test.ts @@ -0,0 +1,113 @@ +/** + * (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"; + +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(); + }); + + it("creates a new project and makes it current when there is nothing", async () => { + const storage = await openCurrentProjectStorage(logging); + expect(storage).toBeInstanceOf(IndexedDBFSStorage); + 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, dirty: false }, {}); + await db.create({ id: "cur", name: "Cur", timestamp: 2, dirty: false }, {}); + 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"); + await legacy.markDirty(); + sessionStorage.setItem("unrelated", "kept"); + + const storage = await openCurrentProjectStorage(logging); + + expect(await storage!.projectName()).toEqual("My project"); + expect(await storage!.isDirty()).toEqual(true); + 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 library cannot be opened", async () => { + vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( + new DOMException("nope", "VersionError") + ); + const storage = await openCurrentProjectStorage(logging); + expect(storage).toBeInstanceOf(SessionStorageFSStorage); + expect(logging.errors[0].message).toMatch(/using session storage/); + }); + + 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..7d8e39526 --- /dev/null +++ b/src/fs/current-project.ts @@ -0,0 +1,116 @@ +/** + * Which project the editor opens, and its storage. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +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"; + +/** + * 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 library, 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. + */ +export const openCurrentProjectStorage = async ( + logging: Logging +): Promise => { + if (typeof indexedDB === "undefined") { + return SessionStorageFSStorage.create(); + } + let db: ProjectsDatabase; + try { + db = await ProjectsDatabase.open(); + } catch (e) { + logging.error("Project library unavailable, using session storage", e); + return SessionStorageFSStorage.create(); + } + const session = sessionStorageIfPossible(); + const id = await chooseProject(db, session); + 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(), dirty: false }, + {} + ); + setCurrentProjectId(session, id); + return id; +}; + +/** + * Moves the single session-storage project into the library. 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(), + dirty: await legacy.isDirty(), + }, + files + ); + await legacy.removeAll(); + return id; +}; diff --git a/src/fs/fs.ts b/src/fs/fs.ts index b76da77c5..2898694dc 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 library, 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. 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..ca6232931 --- /dev/null +++ b/src/fs/indexeddb-storage.test.ts @@ -0,0 +1,104 @@ +/** + * (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, dirty: false }, + {} + ); + 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.markDirty(); + 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))?.dirty).toEqual(true); + }); + + 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..caa53fa0e --- /dev/null +++ b/src/fs/indexeddb-storage.ts @@ -0,0 +1,163 @@ +/** + * (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 library. + * + * 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. + */ +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, dirty: false }; + 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 { + this.pendingMeta.dirty = true; + this.schedule(); + } + + async clearDirty(): Promise { + this.pendingMeta.dirty = false; + this.schedule(); + } + + async isDirty(): Promise { + await this.flush(); + return (await this.db.get(this.projectId))?.dirty ?? 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..0b332162e --- /dev/null +++ b/src/fs/projects-db.test.ts @@ -0,0 +1,123 @@ +/** + * (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, + dirty: false, +}); +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 deployments on one origin stay apart", () => { + expect(databaseName("/")).toEqual("python-editor"); + expect(databaseName("/v/3/")).toEqual("python-editor/v/3"); + expect(databaseName("/v/beta/")).toEqual("python-editor/v/beta"); + }); +}); + +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", dirty: true, timestamp: 5 }, + writes: { "main.py": bytes(2), "new.py": bytes(3) }, + deletes: ["old.py"], + }); + expect(await db.get("a")).toEqual({ + id: "a", + name: "renamed", + dirty: true, + 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..0f9f99968 --- /dev/null +++ b/src/fs/projects-db.ts @@ -0,0 +1,181 @@ +/** + * The project library in IndexedDB. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { DBSchema, IDBPDatabase, openDB } from "idb"; +import { baseUrl } from "../base"; + +/** + * 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; + /** Changed since the last hex save. Persisted so a reload keeps it. */ + dirty: boolean; +} + +/** + * 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 (production, beta and every review build), so + * the database name includes the base path to keep their libraries apart. + */ +export const databaseName = (base: string = baseUrl): string => + 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-tests.ts b/src/fs/storage-tests.ts new file mode 100644 index 000000000..dfb07dfec --- /dev/null +++ b/src/fs/storage-tests.ts @@ -0,0 +1,66 @@ +/** + * 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 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(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([]); + }); +}; diff --git a/src/fs/storage.test.ts b/src/fs/storage.test.ts index afbb52d1c..269e1f5f0 100644 --- a/src/fs/storage.test.ts +++ b/src/fs/storage.test.ts @@ -6,77 +6,20 @@ import { ConsoleLogging } from "../deployment/default/logging"; import { MockLogging } from "../logging/mock"; import { - FSStorage, InMemoryFSStorage, SessionStorageFSStorage, SplitStrategyStorage, } from "./storage"; +import { commonStorageTests } 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); }); describe("InMemoryFSStorage", () => { @@ -84,7 +27,7 @@ describe("InMemoryFSStorage", () => { beforeEach(() => { storage.clear(); }); - commonStorageTests(storage); + commonStorageTests(() => storage); }); describe("SplitStrategyStorage", () => { @@ -98,7 +41,7 @@ describe("SplitStrategyStorage", () => { storage.clear(); sessionStorage.clear(); }); - commonStorageTests(storage); + commonStorageTests(() => 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..d93c531a3 100644 --- a/src/fs/storage.ts +++ b/src/fs/storage.ts @@ -169,6 +169,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 +201,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() { From ddfd70035d1559a999ba7f2070761e93c01478b5 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 11 Sep 2026 21:32:17 +0000 Subject: [PATCH 3/6] Share one project library across review builds Review builds are internal, and a project made on one branch is useful on the next, so they share a database rather than each having their own by base path. Production and beta keep separate libraries. The cost is that a schema change can leave the shared library incompatible with an older build. On non-public stages that now shows a "Breaking change to stored data" page offering to clear the library and reload, as ml-trainer does, instead of quietly falling back to session storage. Public stages keep the quiet fallback. The outcome of opening the library is held in a small external store, since storage opens at module load before React mounts. --- src/RootLayout.tsx | 5 ++- src/environment.ts | 7 ++++ src/fs/StorageVersionErrorPage.tsx | 56 ++++++++++++++++++++++++++++++ src/fs/current-project.test.ts | 25 ++++++++++++- src/fs/current-project.ts | 16 +++++++-- src/fs/projects-db.test.ts | 15 +++++--- src/fs/projects-db.ts | 21 ++++++++--- src/fs/storage-status.ts | 33 ++++++++++++++++++ 8 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 src/fs/StorageVersionErrorPage.tsx create mode 100644 src/fs/storage-status.ts 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/environment.ts b/src/environment.ts index 92ad85ded..d050a3844 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 project library) 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..94917f77e --- /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 project library was created by an + * incompatible build. Review builds share one library, 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 index 0f0fef731..c38748964 100644 --- a/src/fs/current-project.test.ts +++ b/src/fs/current-project.test.ts @@ -14,6 +14,7 @@ import { import { IndexedDBFSStorage } from "./indexeddb-storage"; import { databaseName, ProjectsDatabase } from "./projects-db"; import { SessionStorageFSStorage } from "./storage"; +import { resetStorageStatus } from "./storage-status"; const deleteDatabase = () => new Promise((resolve, reject) => { @@ -31,6 +32,7 @@ describe("openCurrentProjectStorage", () => { sessionStorage.clear(); await deleteDatabase(); logging = new MockLogging(); + resetStorageStatus(); }); it("creates a new project and makes it current when there is nothing", async () => { @@ -87,13 +89,34 @@ describe("openCurrentProjectStorage", () => { it("falls back to session storage when the library cannot be opened", async () => { vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( - new DOMException("nope", "VersionError") + new Error("blocked") ); const storage = await openCurrentProjectStorage(logging); expect(storage).toBeInstanceOf(SessionStorageFSStorage); expect(logging.errors[0].message).toMatch(/using session storage/); }); + it("falls back to session storage for an incompatible library 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 library for clearing on non-public stages", async () => { + vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( + new DOMException("nope", "VersionError") + ); + const { renderHook } = await import("@testing-library/react"); + const { useStorageVersionError } = await import("./storage-status"); + 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", { diff --git a/src/fs/current-project.ts b/src/fs/current-project.ts index 7d8e39526..9ee56ac07 100644 --- a/src/fs/current-project.ts +++ b/src/fs/current-project.ts @@ -5,11 +5,13 @@ * * 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 { reportStorageVersionError } from "./storage-status"; /** * The open project is per tab, as the whole project used to be. @@ -43,10 +45,13 @@ export const sessionStorageIfPossible = (): Storage | undefined => { * 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. + * 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 library. */ export const openCurrentProjectStorage = async ( - logging: Logging + logging: Logging, + publicFacing: boolean = isPublicFacingStage() ): Promise => { if (typeof indexedDB === "undefined") { return SessionStorageFSStorage.create(); @@ -55,6 +60,10 @@ export const openCurrentProjectStorage = async ( try { db = await ProjectsDatabase.open(); } catch (e) { + if (isVersionError(e) && !publicFacing) { + reportStorageVersionError(e); + return undefined; + } logging.error("Project library unavailable, using session storage", e); return SessionStorageFSStorage.create(); } @@ -114,3 +123,6 @@ const migrateLegacyProject = async ( await legacy.removeAll(); return id; }; + +const isVersionError = (e: unknown): boolean => + e instanceof DOMException && e.name === "VersionError"; diff --git a/src/fs/projects-db.test.ts b/src/fs/projects-db.test.ts index 0b332162e..49f022c9e 100644 --- a/src/fs/projects-db.test.ts +++ b/src/fs/projects-db.test.ts @@ -25,10 +25,17 @@ const contents = (files: Record) => ); describe("databaseName", () => { - it("is namespaced by the base path so deployments on one origin stay apart", () => { - expect(databaseName("/")).toEqual("python-editor"); - expect(databaseName("/v/3/")).toEqual("python-editor/v/3"); - expect(databaseName("/v/beta/")).toEqual("python-editor/v/beta"); + 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"); }); }); diff --git a/src/fs/projects-db.ts b/src/fs/projects-db.ts index 0f9f99968..fd5c748ef 100644 --- a/src/fs/projects-db.ts +++ b/src/fs/projects-db.ts @@ -7,6 +7,7 @@ */ 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. @@ -58,11 +59,23 @@ interface Schema extends DBSchema { const DB_VERSION = 1; /** - * Deployments share an origin (production, beta and every review build), so - * the database name includes the base path to keep their libraries apart. + * 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 = (base: string = baseUrl): string => - base === "/" ? "python-editor" : `python-editor${base.replace(/\/$/, "")}`; +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) {} diff --git a/src/fs/storage-status.ts b/src/fs/storage-status.ts new file mode 100644 index 000000000..9baca7d92 --- /dev/null +++ b/src/fs/storage-status.ts @@ -0,0 +1,33 @@ +/** + * (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; +const listeners = new Set<() => void>(); + +/** + * Records that the project library 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; + listeners.forEach((listener) => listener()); +}; + +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +export const useStorageVersionError = (): unknown => + useSyncExternalStore(subscribe, () => versionError); + +/** For tests. */ +export const resetStorageStatus = (): void => { + versionError = undefined; +}; From f946badc31e6a4f3504bb9c1fde27eeacd8dbac1 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 11 Sep 2026 21:43:52 +0000 Subject: [PATCH 4/6] Terminology tweak: Say projects database, not library --- src/environment.ts | 2 +- src/fs/StorageVersionErrorPage.tsx | 4 ++-- src/fs/current-project.test.ts | 6 +++--- src/fs/current-project.ts | 8 ++++---- src/fs/fs.ts | 2 +- src/fs/indexeddb-storage.ts | 2 +- src/fs/projects-db.ts | 2 +- src/fs/storage-status.ts | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/environment.ts b/src/environment.ts index d050a3844..48c3efe6a 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -11,7 +11,7 @@ export const stage = (import.meta.env.VITE_STAGE || "local") as Stage; /** * Stages real users reach. Development affordances (like offering to clear - * an incompatible project library) are for the others. + * 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 index 94917f77e..a63750f3c 100644 --- a/src/fs/StorageVersionErrorPage.tsx +++ b/src/fs/StorageVersionErrorPage.tsx @@ -20,8 +20,8 @@ const deleteDatabase = (name: string) => }); /** - * Shown on non-public stages when the project library was created by an - * incompatible build. Review builds share one library, so this is expected + * 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. diff --git a/src/fs/current-project.test.ts b/src/fs/current-project.test.ts index c38748964..56499cfa7 100644 --- a/src/fs/current-project.test.ts +++ b/src/fs/current-project.test.ts @@ -87,7 +87,7 @@ describe("openCurrentProjectStorage", () => { await (storage as IndexedDBFSStorage).dispose(); }); - it("falls back to session storage when the library cannot be opened", async () => { + it("falls back to session storage when the database cannot be opened", async () => { vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( new Error("blocked") ); @@ -96,7 +96,7 @@ describe("openCurrentProjectStorage", () => { expect(logging.errors[0].message).toMatch(/using session storage/); }); - it("falls back to session storage for an incompatible library on public stages", async () => { + it("falls back to session storage for an incompatible database on public stages", async () => { vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( new DOMException("nope", "VersionError") ); @@ -104,7 +104,7 @@ describe("openCurrentProjectStorage", () => { expect(storage).toBeInstanceOf(SessionStorageFSStorage); }); - it("reports an incompatible library for clearing on non-public stages", async () => { + it("reports an incompatible database for clearing on non-public stages", async () => { vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( new DOMException("nope", "VersionError") ); diff --git a/src/fs/current-project.ts b/src/fs/current-project.ts index 9ee56ac07..d33d81249 100644 --- a/src/fs/current-project.ts +++ b/src/fs/current-project.ts @@ -41,13 +41,13 @@ export const sessionStorageIfPossible = (): Storage | 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 library, so a reload + * 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 library. + * clearing it instead, since review builds share one database. */ export const openCurrentProjectStorage = async ( logging: Logging, @@ -64,7 +64,7 @@ export const openCurrentProjectStorage = async ( reportStorageVersionError(e); return undefined; } - logging.error("Project library unavailable, using session storage", e); + logging.error("Projects database unavailable, using session storage", e); return SessionStorageFSStorage.create(); } const session = sessionStorageIfPossible(); @@ -99,7 +99,7 @@ const chooseProject = async ( }; /** - * Moves the single session-storage project into the library. The session + * 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 ( diff --git a/src/fs/fs.ts b/src/fs/fs.ts index 2898694dc..bd4bdeb61 100644 --- a/src/fs/fs.ts +++ b/src/fs/fs.ts @@ -161,7 +161,7 @@ export const isNameLengthValid = (filename: string): boolean => * The MicroPython file system adapted for convienient use from the UI. * * Contents are held in memory and mirrored to the host's persistent storage: - * the current project in the IndexedDB library, or session storage where + * 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. diff --git a/src/fs/indexeddb-storage.ts b/src/fs/indexeddb-storage.ts index caa53fa0e..0833504a7 100644 --- a/src/fs/indexeddb-storage.ts +++ b/src/fs/indexeddb-storage.ts @@ -9,7 +9,7 @@ import { FSStorage } from "./storage"; const defaultFlushDelayMs = 300; /** - * File system storage for one project in the IndexedDB library. + * 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 diff --git a/src/fs/projects-db.ts b/src/fs/projects-db.ts index fd5c748ef..a3d8f3f45 100644 --- a/src/fs/projects-db.ts +++ b/src/fs/projects-db.ts @@ -1,5 +1,5 @@ /** - * The project library in IndexedDB. + * The projects database in IndexedDB. * * (c) 2026, Micro:bit Educational Foundation and contributors * diff --git a/src/fs/storage-status.ts b/src/fs/storage-status.ts index 9baca7d92..54bfd102a 100644 --- a/src/fs/storage-status.ts +++ b/src/fs/storage-status.ts @@ -11,7 +11,7 @@ let versionError: unknown; const listeners = new Set<() => void>(); /** - * Records that the project library was created by an incompatible version + * 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 => { From d2035281c8a38d81624c1e2f665a3c67f45b299b Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 09:23:44 +0000 Subject: [PATCH 5/6] Drop the dirty flag from the projects database and its before-unload prompt The dirty flag means "changed since the last hex save" and exists only to warn before work is lost: the before-unload prompt and the replace-project confirmation are its only readers. Neither applies to a project in the database, which outlives the tab, so the flag is no longer stored there and IndexedDBFSStorage always reports not dirty. Session storage keeps it and FileSystem still tracks it for the tab, so the replace confirmation works for edits made in this tab until the replace flows go. openCurrentProjectStorage now reports through storage-status when the projects database is active, and BeforeUnloadDirtyCheck registers nothing in that case. The session-storage fallback and iframe mode keep the prompt. The edits e2e spec now asserts the prompt is absent and that text typed just before a reload survives via the pagehide flush. The old helper proved nothing: Playwright accepts an unlistened beforeunload dialog itself, so the new one listens for it. --- src/e2e/app.ts | 23 ++++++++++---------- src/e2e/edits.test.ts | 27 ++++++++++++++++++------ src/fs/current-project.test.ts | 21 ++++++++++++------ src/fs/current-project.ts | 18 ++++++---------- src/fs/indexeddb-storage.test.ts | 17 +++++++++------ src/fs/indexeddb-storage.ts | 18 ++++++---------- src/fs/projects-db.test.ts | 4 +--- src/fs/projects-db.ts | 2 -- src/fs/storage-status.ts | 19 ++++++++++++++++- src/fs/storage-tests.ts | 21 +++++++++++------- src/fs/storage.test.ts | 5 ++++- src/fs/storage.ts | 6 ++++-- src/workbench/BeforeUnloadDirtyCheck.tsx | 11 ++++++++-- 13 files changed, 120 insertions(+), 72 deletions(-) diff --git a/src/e2e/app.ts b/src/e2e/app.ts index 363bd056b..1e36b00c8 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -495,19 +495,18 @@ 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 { + // Playwright accepts a beforeunload dialog itself if nobody listens, so + // listen to see it. Any dialog is handled before the page can close. + 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; + expect(dialogs).not.toContain("beforeunload"); } async expectDocumentationTopLevelHeading( 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/fs/current-project.test.ts b/src/fs/current-project.test.ts index 56499cfa7..48619ae2b 100644 --- a/src/fs/current-project.test.ts +++ b/src/fs/current-project.test.ts @@ -14,7 +14,12 @@ import { import { IndexedDBFSStorage } from "./indexeddb-storage"; import { databaseName, ProjectsDatabase } from "./projects-db"; import { SessionStorageFSStorage } from "./storage"; -import { resetStorageStatus } from "./storage-status"; +import { renderHook } from "@testing-library/react"; +import { + resetStorageStatus, + useProjectsDatabaseActive, + useStorageVersionError, +} from "./storage-status"; const deleteDatabase = () => new Promise((resolve, reject) => { @@ -38,6 +43,9 @@ describe("openCurrentProjectStorage", () => { 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(); @@ -50,8 +58,8 @@ describe("openCurrentProjectStorage", () => { 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, dirty: false }, {}); - await db.create({ id: "cur", name: "Cur", timestamp: 2, dirty: false }, {}); + 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"); @@ -69,13 +77,11 @@ describe("openCurrentProjectStorage", () => { await legacy.write("main.py", encode("# mine")); await legacy.write("helper.py", encode("# helper")); await legacy.setProjectName("My project"); - await legacy.markDirty(); sessionStorage.setItem("unrelated", "kept"); const storage = await openCurrentProjectStorage(logging); expect(await storage!.projectName()).toEqual("My project"); - expect(await storage!.isDirty()).toEqual(true); expect(await storage!.ls()).toEqual(["helper.py", "main.py"]); expect(Array.from(await storage!.read("main.py"))).toEqual( Array.from(encode("# mine")) @@ -94,6 +100,9 @@ describe("openCurrentProjectStorage", () => { 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 () => { @@ -108,8 +117,6 @@ describe("openCurrentProjectStorage", () => { vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( new DOMException("nope", "VersionError") ); - const { renderHook } = await import("@testing-library/react"); - const { useStorageVersionError } = await import("./storage-status"); const storage = await openCurrentProjectStorage(logging, false); expect(storage).toBeUndefined(); expect(logging.errors).toEqual([]); diff --git a/src/fs/current-project.ts b/src/fs/current-project.ts index d33d81249..df9c620e1 100644 --- a/src/fs/current-project.ts +++ b/src/fs/current-project.ts @@ -11,7 +11,10 @@ import { generateId } from "./fs-util"; import { IndexedDBFSStorage } from "./indexeddb-storage"; import { ProjectsDatabase } from "./projects-db"; import { FSStorage, SessionStorageFSStorage } from "./storage"; -import { reportStorageVersionError } from "./storage-status"; +import { + reportProjectsDatabaseActive, + reportStorageVersionError, +} from "./storage-status"; /** * The open project is per tab, as the whole project used to be. @@ -69,6 +72,7 @@ export const openCurrentProjectStorage = async ( } const session = sessionStorageIfPossible(); const id = await chooseProject(db, session); + reportProjectsDatabaseActive(); return new IndexedDBFSStorage(db, id, (e) => logging.error("Failed to save project", e) ); @@ -90,10 +94,7 @@ const chooseProject = async ( return id; } const id = generateId(); - await db.create( - { id, name: undefined, timestamp: Date.now(), dirty: false }, - {} - ); + await db.create({ id, name: undefined, timestamp: Date.now() }, {}); setCurrentProjectId(session, id); return id; }; @@ -112,12 +113,7 @@ const migrateLegacyProject = async ( } const id = generateId(); await db.create( - { - id, - name: await legacy.projectName(), - timestamp: Date.now(), - dirty: await legacy.isDirty(), - }, + { id, name: await legacy.projectName(), timestamp: Date.now() }, files ); await legacy.removeAll(); diff --git a/src/fs/indexeddb-storage.test.ts b/src/fs/indexeddb-storage.test.ts index ca6232931..f9e21388c 100644 --- a/src/fs/indexeddb-storage.test.ts +++ b/src/fs/indexeddb-storage.test.ts @@ -19,10 +19,7 @@ const contents = (files: Record) => const openWithProject = async () => { const db = await ProjectsDatabase.open(uniqueName()); - await db.create( - { id: projectId, name: undefined, timestamp: 1, dirty: false }, - {} - ); + await db.create({ id: projectId, name: undefined, timestamp: 1 }, {}); return db; }; @@ -46,7 +43,7 @@ describe("IndexedDBFSStorage", () => { 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.markDirty(); + await storage.setProjectName("Renamed"); expect(apply).not.toHaveBeenCalled(); await new Promise((resolve) => setTimeout(resolve, 40)); @@ -56,7 +53,15 @@ describe("IndexedDBFSStorage", () => { "main.py": [2], "other.py": [3], }); - expect((await db.get(projectId))?.dirty).toEqual(true); + 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 () => { diff --git a/src/fs/indexeddb-storage.ts b/src/fs/indexeddb-storage.ts index 0833504a7..47d6b09a8 100644 --- a/src/fs/indexeddb-storage.ts +++ b/src/fs/indexeddb-storage.ts @@ -19,6 +19,9 @@ const defaultFlushDelayMs = 300; * 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(); @@ -86,7 +89,7 @@ export class IndexedDBFSStorage implements FSStorage { for (const name of await this.ls()) { this.pendingWrites.set(name, null); } - this.pendingMeta = { name: undefined, dirty: false }; + this.pendingMeta = { name: undefined }; await this.flush(); } @@ -100,19 +103,12 @@ export class IndexedDBFSStorage implements FSStorage { return (await this.db.get(this.projectId))?.name; } - async markDirty(): Promise { - this.pendingMeta.dirty = true; - this.schedule(); - } + async markDirty(): Promise {} - async clearDirty(): Promise { - this.pendingMeta.dirty = false; - this.schedule(); - } + async clearDirty(): Promise {} async isDirty(): Promise { - await this.flush(); - return (await this.db.get(this.projectId))?.dirty ?? false; + return false; } private schedule(): void { diff --git a/src/fs/projects-db.test.ts b/src/fs/projects-db.test.ts index 49f022c9e..8ee5d290d 100644 --- a/src/fs/projects-db.test.ts +++ b/src/fs/projects-db.test.ts @@ -14,7 +14,6 @@ const meta = (id: string, timestamp: number, name = id): ProjectMeta => ({ id, name, timestamp, - dirty: false, }); const bytes = (...values: number[]) => new Uint8Array(values); // Typed arrays read back through fake-indexeddb under jsdom belong to another @@ -68,14 +67,13 @@ describe("ProjectsDatabase", () => { 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", dirty: true, timestamp: 5 }, + 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", - dirty: true, timestamp: 5, }); expect(contents(await db.files("a"))).toEqual({ diff --git a/src/fs/projects-db.ts b/src/fs/projects-db.ts index a3d8f3f45..9a0f033e1 100644 --- a/src/fs/projects-db.ts +++ b/src/fs/projects-db.ts @@ -17,8 +17,6 @@ export interface ProjectMeta { name: string | undefined; /** Last modified or opened, for ordering by recency. */ timestamp: number; - /** Changed since the last hex save. Persisted so a reload keeps it. */ - dirty: boolean; } /** diff --git a/src/fs/storage-status.ts b/src/fs/storage-status.ts index 54bfd102a..804a5bbf9 100644 --- a/src/fs/storage-status.ts +++ b/src/fs/storage-status.ts @@ -8,15 +8,28 @@ 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; - listeners.forEach((listener) => listener()); + 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) => { @@ -27,7 +40,11 @@ const subscribe = (listener: () => void) => { 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 index dfb07dfec..99a8f3c98 100644 --- a/src/fs/storage-tests.ts +++ b/src/fs/storage-tests.ts @@ -18,14 +18,6 @@ export const commonStorageTests = (storage: () => FSStorage) => { 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])); @@ -64,3 +56,16 @@ export const commonStorageTests = (storage: () => FSStorage) => { 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 269e1f5f0..1d4c990cf 100644 --- a/src/fs/storage.test.ts +++ b/src/fs/storage.test.ts @@ -10,7 +10,7 @@ import { SessionStorageFSStorage, SplitStrategyStorage, } from "./storage"; -import { commonStorageTests } from "./storage-tests"; +import { commonStorageTests, dirtyFlagTests } from "./storage-tests"; const projectName = "projectName"; @@ -20,6 +20,7 @@ describe("SessionStorageFSStorage", () => { sessionStorage.clear(); }); commonStorageTests(() => storage); + dirtyFlagTests(() => storage); }); describe("InMemoryFSStorage", () => { @@ -28,6 +29,7 @@ describe("InMemoryFSStorage", () => { storage.clear(); }); commonStorageTests(() => storage); + dirtyFlagTests(() => storage); }); describe("SplitStrategyStorage", () => { @@ -42,6 +44,7 @@ describe("SplitStrategyStorage", () => { sessionStorage.clear(); }); 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 d93c531a3..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; 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; }; From f002fc3f4e3b6d4ae29a541fe66e91b2ba6156a8 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 09:23:44 +0000 Subject: [PATCH 6/6] Add e2e coverage for the session-storage fallback and iframe controller mode The three storage modes now behave differently and only the projects database path was exercised in the browser. storage-errors.test.ts uses a new noIndexedDB fixture option, an init script that hides indexedDB before the app loads, and checks the fallback keeps the before-unload prompt and survives a reload. iframe.test.ts embeds the editor with controller=1 and checks the workspacesync, workspaceloaded, workspacesave and importproject messages and the prompt. The host page is an HTML string in the spec, served by intercepting a request for it, so it is never part of the build. --- src/e2e/app-test-fixtures.ts | 26 ++++++- src/e2e/app.ts | 27 +++++-- src/e2e/iframe.test.ts | 128 +++++++++++++++++++++++++++++++++ src/e2e/storage-errors.test.ts | 43 +++++++++++ 4 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 src/e2e/iframe.test.ts create mode 100644 src/e2e/storage-errors.test.ts 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 1e36b00c8..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(); } @@ -496,8 +498,18 @@ export class App { } async closeWithoutBeforeUnloadPrompt(): Promise { - // Playwright accepts a beforeunload dialog itself if nobody listens, so - // listen to see it. Any dialog is handled before the page can close. + 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()); @@ -506,7 +518,7 @@ export class App { const closed = this.page.waitForEvent("close"); await this.page.close({ runBeforeUnload: true }); await closed; - expect(dialogs).not.toContain("beforeunload"); + return dialogs; } async expectDocumentationTopLevelHeading( @@ -822,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", @@ -835,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/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/); + }); +});