From 888756265e2323fc4038770bfad9100619b47379 Mon Sep 17 00:00:00 2001 From: Loren Segal Date: Sat, 3 Oct 2026 14:43:13 -0700 Subject: [PATCH 1/2] Start work on issue #8 [skip ci] From b93124ba41d50cbd0cfe598a94ee7c4470dfb4b3 Mon Sep 17 00:00:00 2001 From: Loren Segal Date: Sat, 3 Oct 2026 14:44:45 -0700 Subject: [PATCH 2/2] Fix duplicate blank document when an empty project opens twice Closes #8 --- app/src/lib/local_project.test.ts | 24 ++++++++++++++++++++++++ app/src/lib/project.ts | 25 +++++++++++++++++++------ 2 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 app/src/lib/local_project.test.ts diff --git a/app/src/lib/local_project.test.ts b/app/src/lib/local_project.test.ts new file mode 100644 index 0000000..bacc51d --- /dev/null +++ b/app/src/lib/local_project.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { defaultConfig } from "./config"; +import { loadIdentity } from "./identity"; +import { LocalProject } from "./project"; + +describe("LocalProject.open", () => { + it("creates a single blank document when an empty project is opened twice concurrently", async () => { + const identity = await loadIdentity(defaultConfig); + const projectId = `concurrent-open-${crypto.randomUUID()}`; + + const opened = await Promise.all([ + LocalProject.open(identity, defaultConfig, projectId), + LocalProject.open(identity, defaultConfig, projectId), + ]); + for (const project of opened) project.destroy(); + + const reopened = await LocalProject.open(identity, defaultConfig, projectId); + try { + expect(reopened.snapshot().paragraphs).toHaveLength(1); + } finally { + reopened.destroy(); + } + }); +}); diff --git a/app/src/lib/project.ts b/app/src/lib/project.ts index 6700202..a46ba0a 100644 --- a/app/src/lib/project.ts +++ b/app/src/lib/project.ts @@ -223,6 +223,8 @@ const mapParagraph = (paragraph: ScreenplayParagraph) => { return map; }; +const openingProjects = new Map>(); + export class LocalProject { readonly doc = new Y.Doc(); readonly persistence: IndexeddbPersistence; @@ -267,13 +269,24 @@ export class LocalProject { projectId = "local-draft", initialTitle = "Untitled screenplay", ) { - const project = new LocalProject(identity, projectId, config); - await project.persistence.whenSynced; - project.migrateParagraphText(); - if (project.paragraphStore.length === 0) { - await project.createDocument(initialTitle); + // Opening the same empty project twice (e.g. a StrictMode double effect) would create two + // blank documents that merge in IndexedDB, so each open waits for the previous one to finish. + const previous = openingProjects.get(projectId); + const opening = (previous?.catch(() => undefined) ?? Promise.resolve()).then(async () => { + const project = new LocalProject(identity, projectId, config); + await project.persistence.whenSynced; + project.migrateParagraphText(); + if (project.paragraphStore.length === 0) { + project.createDocument(initialTitle); + } + return project; + }); + openingProjects.set(projectId, opening); + try { + return await opening; + } finally { + if (openingProjects.get(projectId) === opening) openingProjects.delete(projectId); } - return project; } subscribe = (listener: () => void) => {