From 7e5417b9b897ce3181b5fa66b8076cf41c5c1a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 22:38:04 -0400 Subject: [PATCH 1/9] fix(studio): undo takes back the first edit of a film whose media is still being copied --- packages/studio-server/src/createStudioApi.ts | 9 ++ .../src/history/projectHistory.ts | 69 +++++++++- .../studio-server/src/routes/history.test.ts | 125 ++++++++++++++++++ .../studio/tests/e2e/undo-socket-pool.mjs | 3 +- 4 files changed, 200 insertions(+), 6 deletions(-) diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index 3c1e3cde3e7..3ca2db4d9ab 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -53,6 +53,15 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono { .catch(() => null); if (project) adapter.invalidateProjectSignature?.(project.dir); }); + api.use("/projects/:id/*", async function openHistorySoNoWriteBecomesItsBaseline(c, next) { + if (c.req.method !== "GET" && c.req.method !== "HEAD" && adapter.history) { + await Promise.resolve() + .then(() => adapter.resolveProject(c.req.param("id"))) + .then((project) => project && adapter.history?.(project)) + .catch(() => null); + } + await next(); + }); registerProjectRoutes(api, adapter); registerFileRoutes(api, adapter); diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index 787ba3ba206..db4e761bf09 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -221,6 +221,8 @@ function withoutHiddenPaths(log: HistoryLog): HistoryLog { const historyFiles = (dir: string) => listProjectFiles(dir).filter((file) => isHistoryPath(file.path)); +type ListedFile = ReturnType[number]; +const COPY_LATER_ABOVE_BYTES = 1024 ** 2; const sameWho = (a: HistoryWho, b: HistoryWho) => a.kind === b.kind && a.name === b.name; @@ -319,6 +321,7 @@ class Engine { outside: Group | null = null; /** A coalescing claim, open until another key, its idle timer, an operation, a window, or another write. */ claimed: { group: Group; key: string; timer?: NodeJS.Timeout } | null = null; + adopting = new Map>(); /** Per path and hash an API write left, the hash of the bytes it replaced, until walked or written past. */ overwritten = new Map>(); stopHearing: (() => void) | undefined; @@ -413,6 +416,10 @@ class Engine { const cached = cache.get(path); this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" }); } + const left = this.readAdopting(); + const unadopted = (file: { path: string }) => + left.has(file.path) && !this.tracked.has(file.path); + this.adoptMediaInBackground(historyFiles(this.dir).filter(unadopted), this.now()); // What changed while the project was closed is one outside entry, or the closed window's. this.reopenClosedWindow(); await this.settleAll(); @@ -481,7 +488,8 @@ class Engine { async firstOpen(): Promise { const sweptAt = this.now(); - for (const file of historyFiles(this.dir)) { + const files = historyFiles(this.dir); + for (const file of files.filter((file) => file.size <= COPY_LATER_ABOVE_BYTES)) { const hash = await this.storeIfPresent(file.path); if (this.whereFolder() !== "here") throw this.replaced(); if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) }); @@ -490,6 +498,47 @@ class Engine { this.log.keepsLedger = true; this.persistLog(); this.saveStatCache(); + this.adoptMediaInBackground( + files.filter((file) => file.size > COPY_LATER_ABOVE_BYTES), + sweptAt, + ); + } + + adoptMediaInBackground(files: ListedFile[], sweptAt: number): void { + let copied = Promise.resolve(); + for (const file of files) { + copied = copied.then(() => this.adopt(file, sweptAt)); + this.adopting.set(file.path, copied); + } + this.saveAdopting(); + } + + saveAdopting(): void { + const file = join(this.home, "adopting.json"); + if (!this.adopting.size) return rmSync(file, { force: true }); + mkdirSync(this.home, { recursive: true }); + replaceFileAtomically(file, JSON.stringify([...this.adopting.keys()]), 0o644); + } + + async adopt(file: ListedFile, sweptAt: number) { + try { + const hash = await this.storeIfPresent(file.path); + await this.queue(async () => { + this.adopting.delete(file.path); + this.saveAdopting(); + if (hash === null || this.whereFolder() !== "here") return; + const change = { path: file.path, before: null, after: hash }; + const found = this.overwrittenBy(change, undefined, new Set()); + const start = found && this.blobs.has(found) ? found : hash; + this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) }); + this.log.baseline.set(file.path, start); + if (start !== hash) addChange(this.outsideGroup(), file.path, start, hash); + this.persistLog({ type: "baseline", files: Object.fromEntries(this.log.baseline) }); + this.saveStatCache(); + }); + } catch (error) { + if (!(error instanceof HistoryClosedError)) this.options.onError?.(error); + } } manifest(): Manifest { @@ -505,6 +554,16 @@ class Engine { } } + readAdopting(): Set { + try { + return new Set( + JSON.parse(readFileSync(join(this.home, "adopting.json"), "utf-8")) as string[], + ); + } catch { + return new Set(); + } + } + saveStatCache(): void { const files = JSON.stringify(Object.fromEntries(this.tracked)); mkdirSync(this.home, { recursive: true }); @@ -521,7 +580,7 @@ class Engine { const sweptAt = this.now(); const changedAt = (file: { mtimeMs: number; ctimeMs: number }) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs)); - const seen = historyFiles(this.dir); + const seen = historyFiles(this.dir).filter((file) => !this.adopting.has(file.path)); const heard = new Map(this.overwritten); const present = new Set(seen.map((file) => file.path)); const removed = [...this.tracked.keys()] @@ -1091,8 +1150,10 @@ class Engine { projectId: this.projectId, beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 30_000), - claim: (who, label, paths, options = {}) => - this.queue(() => this.claimNow(who, label, paths, options)), + claim: async (who, label, paths, options = {}) => { + await Promise.all(paths.map((path) => this.adopting.get(this.logPath(path)))); + return this.queue(() => this.claimNow(who, label, paths, options)); + }, noteChange: (path) => { if (!this.closed) this.noteChange(path); }, diff --git a/packages/studio-server/src/routes/history.test.ts b/packages/studio-server/src/routes/history.test.ts index ba55a1cafbe..ca8a2928004 100644 --- a/packages/studio-server/src/routes/history.test.ts +++ b/packages/studio-server/src/routes/history.test.ts @@ -7,12 +7,34 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createStudioApi } from "../createStudioApi"; import { DELETED_VERSION, fileContentVersion, identifyFileWrite } from "../helpers/fileVersion"; import { openProjectHistory, type ProjectHistory } from "../history/projectHistory"; +import { START } from "../history/historyLog"; +import { historyCache } from "../history/historyCache"; import { createProjectSignature, resolveProjectSignature } from "../helpers/projectSignature"; import type { StudioApiAdapter } from "../types"; +// Media copies wait on `held`, so a test can keep the first open's media copy running. +const mediaCopy = vi.hoisted(() => ({ held: null as Promise | null })); +vi.mock("../history/blobStore", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + openBlobStore: async (dir: string) => { + const store = await real.openBlobStore(dir); + return { + ...store, + put: async (path: string) => { + if (path.endsWith(".mp4")) await mediaCopy.held; + return store.put(path); + }, + }; + }, + }; +}); + const cleanup: Array<() => unknown> = []; afterEach(async () => { + mediaCopy.held = null; for (const step of cleanup.splice(0).reverse()) await step(); }); @@ -36,6 +58,39 @@ function apiFor(projectDir: string, history?: ProjectHistory) { ); } +/** A project whose index.html reads "A", its history opened on first use as both hosts do. */ +function lazyHistoryProject() { + const projectDir = tempDir("hf-history-first-write-"); + const file = join(projectDir, "index.html"); + writeFileSync(file, "A"); + const historyRoot = tempDir("hf-history-first-write-root-"); + const histories = historyCache((dir) => openProjectHistory({ projectDir: dir, historyRoot })); + cleanup.push(() => histories.closeAll()); + const api = createStudioApi({ + listProjects: () => [], + resolveProject: (id: string) => (id === "demo" ? { id, dir: projectDir } : null), + history: (project: { dir: string }) => histories.get(project.dir), + } as unknown as StudioApiAdapter); + const post = (path: string, body: object) => + api.request(`/projects/demo/history${path}`, { method: "POST", body: JSON.stringify(body) }); + const put = (content: string) => + api.request("/projects/demo/files/index.html", { + method: "PUT", + body: content, + headers: { "If-Match": fileContentVersion("A") }, + }); + return { projectDir, file, histories, api, post, put }; +} + +async function claimFirstWrite(post: ReturnType["post"]) { + const reply = await post("/claim", { + label: "Color", + paths: ["index.html"], + overwrote: { "index.html": fileContentVersion("A") }, + }); + return ((await reply.json()) as { claimed: unknown }).claimed; +} + /** A project whose index.html reads "A", with its history and the routes over it. */ async function demoProject({ quietMs }: { quietMs?: number } = {}) { const projectDir = tempDir("hf-history-routes-"); @@ -340,6 +395,76 @@ describe("history routes", () => { expect(back).toMatchObject({ id: claimed.id, label: "Moved Title" }); }); + it("undo Studio's first write when that write is what opens the project's history", async () => { + const { file, put, post } = lazyHistoryProject(); + + expect((await put("B")).status).toBe(200); + expect(await claimFirstWrite(post)).not.toBeNull(); + expect(await (await post("/step", { direction: "back" })).json()).toMatchObject({ ok: true }); + expect(readFileSync(file, "utf-8")).toBe("A"); + }); + + it("save and undo the first edit while the project's media is still being copied in", async () => { + let finishCopy = () => {}; + mediaCopy.held = new Promise((resolve) => (finishCopy = resolve)); + const { projectDir, file, put, post, histories } = lazyHistoryProject(); + writeFileSync(join(projectDir, "clip.mp4"), Buffer.alloc(4 * 1024 ** 2, 7)); + + // The copy stays held until finishCopy, so a save or claim that waited on it never returns. + expect((await put("B")).status).toBe(200); + expect(await claimFirstWrite(post)).not.toBeNull(); + await post("/step", { direction: "back" }); + expect(readFileSync(file, "utf-8")).toBe("A"); + + finishCopy(); + const history = (await histories.get(projectDir))!; + await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"])); + await history.flush(); + expect(history.list().map((entry) => entry.label)).toEqual(["Color", "Undid: Color"]); + }); + + it("undo Studio's overwrite of media whose first copy was still running", async () => { + let finishCopy = () => {}; + mediaCopy.held = new Promise((resolve) => (finishCopy = resolve)); + const { projectDir, api, post } = lazyHistoryProject(); + const clip = join(projectDir, "clip.mp4"); + const original = Buffer.alloc(4 * 1024 ** 2, 7); + writeFileSync(clip, original); + await post("/claim", { label: "Nothing", paths: [] }); + + const written = await api.request("/projects/demo/files/clip.mp4", { + method: "PUT", + body: Buffer.alloc(4 * 1024 ** 2, 9), + headers: { "If-Match": fileContentVersion(original) }, + }); + expect(written.status).toBe(200); + finishCopy(); + const claimed = await post("/claim", { + label: "Replace clip", + paths: ["clip.mp4"], + overwrote: { "clip.mp4": fileContentVersion(original) }, + }); + expect(((await claimed.json()) as { claimed: unknown }).claimed).not.toBeNull(); + + await post("/step", { direction: "back" }); + expect(readFileSync(clip).equals(original)).toBe(true); + }); + + it("adopt media whose copy a restart cut short, rather than log it as added", async () => { + mediaCopy.held = new Promise(() => {}); + const { projectDir, post, histories } = lazyHistoryProject(); + writeFileSync(join(projectDir, "clip.mp4"), Buffer.alloc(4 * 1024 ** 2, 7)); + await post("/claim", { label: "Nothing", paths: [] }); + await histories.closeAll(); + histories.forget(projectDir); + + mediaCopy.held = null; + const history = (await histories.get(projectDir))!; + await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"])); + await history.flush(); + expect(history.list()).toEqual([]); + }); + it("end a window given idleMs by itself once its writes stop", async () => { const { projectDir, call } = await demoProject(); diff --git a/packages/studio/tests/e2e/undo-socket-pool.mjs b/packages/studio/tests/e2e/undo-socket-pool.mjs index fda8c841a70..eb37a57cfff 100644 --- a/packages/studio/tests/e2e/undo-socket-pool.mjs +++ b/packages/studio/tests/e2e/undo-socket-pool.mjs @@ -154,8 +154,7 @@ try { const reverted = await until(() => readIndex() === before, 5_000); const stallMs = undo?.timing ? Math.round(undo.timing.sendStart) : null; rounds.push({ round, pinned, stallMs, reverted }); - // Round 0 is exempt until the fix for the first undo of a GSAP-free film, which takes back only part, lands. - if (!reverted && round > 0) failures.push(`round ${round}: the undo did not restore the file`); + if (!reverted) failures.push(`round ${round}: the undo did not restore the file`); if (stallMs == null) failures.push(`round ${round}: the undo's POST history/step never answered`); else if (stallMs > STALL_LIMIT_MS) From ed1ae5d52053e60019052f8e0f1818ac1e3056a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 23:14:30 -0400 Subject: [PATCH 2/9] fix(studio): the first undo of a film repaints the preview in place --- .../studio/src/utils/gsapUndoRestore.test.ts | 18 ++++++++++++++++++ packages/studio/src/utils/gsapUndoRestore.ts | 8 +++++--- packages/studio/tests/e2e/undo-socket-pool.mjs | 16 +++++++++++++++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/utils/gsapUndoRestore.test.ts b/packages/studio/src/utils/gsapUndoRestore.test.ts index cd1003878ee..e4df93243b8 100644 --- a/packages/studio/src/utils/gsapUndoRestore.test.ts +++ b/packages/studio/src/utils/gsapUndoRestore.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { describe, it, expect, vi } from "vitest"; +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { applyUndoRestoreToPreview, diffSoftReloadableRestore, @@ -100,6 +101,23 @@ function buildLiveIframe(bodyHtml: string) { describe("applyUndoRestoreToPreview", () => { const ROOT = "index.html"; + it("repaints in place an undo back to the film before Studio first stamped it", () => { + const film = (color: string) => + `
T

caption

`; + // The preview stamped the film as it loaded; the first edit wrote those stamps with its red. + const edited = ensureHfIds(film("blue")).replace( + "background-color: blue", + "background-color: red", + ); + const { iframe, doc } = buildLiveIframe(edited); + const reloadPreview = vi.fn(); + const files = { [ROOT]: { previous: wrap(edited), restored: wrap(film("blue")) } }; + + expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft"); + expect(reloadPreview).not.toHaveBeenCalled(); + expect((doc.getElementById("target") as HTMLElement).style.backgroundColor).toBe("blue"); + }); + it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => { const { iframe, contentWindow, doc } = buildLiveIframe( `
t
`, diff --git a/packages/studio/src/utils/gsapUndoRestore.ts b/packages/studio/src/utils/gsapUndoRestore.ts index 5814a59afc5..3373f5ed4d5 100644 --- a/packages/studio/src/utils/gsapUndoRestore.ts +++ b/packages/studio/src/utils/gsapUndoRestore.ts @@ -9,7 +9,7 @@ import { findGsapScriptElements, readNestedFiles, } from "./gsapSoftReload"; -import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids"; +import { ensureHfIds, isCompositionTemplate } from "@hyperframes/parsers/hf-ids"; import { findAuthoredElement, parseSavedSource } from "./authoredSource"; import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply"; import { markScenesStale } from "../player/sceneSwap"; @@ -252,8 +252,10 @@ function fileTargets( isActive: boolean, file: UndoRestoreFile, ): RestorePlan | null { - const prevDoc = parseRestoreSource(file.previous); - const nextDoc = parseRestoreSource(file.restored); + // The preview stamps the film it shows, so a source from before Studio first stamped it is stamped to match. + const asShown = doc.querySelector("[data-hf-id]") ? ensureHfIds : (html: string) => html; + const prevDoc = parseRestoreSource(asShown(file.previous)); + const nextDoc = parseRestoreSource(asShown(file.restored)); const scripted = restoreScripted(prevDoc, nextDoc, isActive); if (scripted === null) return null; const keys = diffRestoreDocs(prevDoc, nextDoc); diff --git a/packages/studio/tests/e2e/undo-socket-pool.mjs b/packages/studio/tests/e2e/undo-socket-pool.mjs index eb37a57cfff..5566c080366 100644 --- a/packages/studio/tests/e2e/undo-socket-pool.mjs +++ b/packages/studio/tests/e2e/undo-socket-pool.mjs @@ -122,8 +122,19 @@ try { throw new Error(`studio_look found no #target: ${JSON.stringify(look).slice(0, 400)}`); const colors = ["#ff0000", "#00ff00", "#0000ff", "#ff00ff", "#00ffff"]; + // What the preview paints for #target: an undo must show the edit taken back, not only write the file. + const previewColor = async () => { + for (const frame of page.frames()) { + const color = await frame + .$eval("#target", (el) => getComputedStyle(el).backgroundColor) + .catch(() => null); + if (color) return color; + } + return null; + }; for (let round = 0; round < ROUNDS; round++) { const before = readIndex(); + const shownBefore = await previewColor(); await call("studio_set_style", { handle: target.handle, styles: { backgroundColor: colors[round % colors.length] }, @@ -152,9 +163,12 @@ try { await until(() => step()?.done, 15_000); const undo = step(); const reverted = await until(() => readIndex() === before, 5_000); + const repainted = await until(async () => (await previewColor()) === shownBefore, 3_000); const stallMs = undo?.timing ? Math.round(undo.timing.sendStart) : null; - rounds.push({ round, pinned, stallMs, reverted }); + rounds.push({ round, pinned, stallMs, reverted, repainted }); if (!reverted) failures.push(`round ${round}: the undo did not restore the file`); + if (!repainted) + failures.push(`round ${round}: the preview still showed the edit 3 s after the undo`); if (stallMs == null) failures.push(`round ${round}: the undo's POST history/step never answered`); else if (stallMs > STALL_LIMIT_MS) From 09b81aebcf5edb6eb44fbd18b47a94be2ad82f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 23:37:56 -0400 Subject: [PATCH 3/9] fix(studio): background media copies keep the media ledger and survive a history prune --- .../studio-server/src/history/historyLog.ts | 14 ++-- .../src/history/projectHistory.test.ts | 68 +++++++++++++++++++ .../src/history/projectHistory.ts | 10 ++- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/studio-server/src/history/historyLog.ts b/packages/studio-server/src/history/historyLog.ts index ff78e737c05..ee5c3bf996c 100644 --- a/packages/studio-server/src/history/historyLog.ts +++ b/packages/studio-server/src/history/historyLog.ts @@ -100,13 +100,17 @@ export function saveRecord(file: string, log: HistoryLog, record: LogRecord): vo } } +export function baselineRecord(log: HistoryLog): LogRecord { + return { + type: "baseline", + files: Object.fromEntries(log.baseline), + ...(log.keepsLedger && { keepsLedger: true }), + }; +} + export function writeLog(file: string, log: HistoryLog): void { const records: LogRecord[] = [ - { - type: "baseline", - files: Object.fromEntries(log.baseline), - ...(log.keepsLedger && { keepsLedger: true }), - }, + baselineRecord(log), ...log.entries.map((entry) => ({ type: "entry" as const, entry })), ...[...log.pins].map((id) => ({ type: "pin" as const, id, pinned: true })), ]; diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index fa846ec88aa..bd4be8fb13c 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -24,12 +24,37 @@ import { HistoryIdError } from "./historyId"; import { HistoryClosedError, openProjectHistory, type ProjectHistory } from "./projectHistory"; import { START, type HistoryWho } from "./historyLog"; +// A test can hold a media copy, and run work after its blob is stored but before history records it. +const mediaCopy = vi.hoisted(() => ({ + held: null as Promise | null, + stored: null as (() => Promise) | null, +})); +vi.mock("./blobStore", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + openBlobStore: async (dir: string) => { + const store = await real.openBlobStore(dir); + return { + ...store, + put: async (path: string) => { + if (path.endsWith(".mp4")) await mediaCopy.held; + const hash = await store.put(path); + if (path.endsWith(".mp4")) await mediaCopy.stored?.(); + return hash; + }, + }; + }, + }; +}); + const you: HistoryWho = { kind: "person", name: "You" }; const pause = (ms: number) => new Promise((settle) => setTimeout(settle, ms)); const agent: HistoryWho = { kind: "agent", name: "Agent" }; const cleanup: Array<() => unknown> = []; afterEach(async () => { + Object.assign(mediaCopy, { held: null, stored: null }); for (const step of cleanup.splice(0).reverse()) await step(); }); @@ -265,6 +290,49 @@ describe("openProjectHistory", () => { expect([read("index.html"), has(".media/manifest.jsonl")]).toEqual(["A", false]); }); + it("a ledger made while closed is still a change after the project's media was copied in the background", async () => { + const { history, write, has, projectDir, historyRoot } = await project( + { "index.html": "A", "clip.mp4": Buffer.alloc(2 * 1024 ** 2, 7) }, + { quietMs: 30 }, + ); + await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]), { + timeout: 10_000, + }); + await history.close(); + write("b.png", "png"); + write(".media/manifest.jsonl", '{"path":"b.png"}\n'); + const reopened = await open(projectDir, historyRoot, { quietMs: 30 }); + await reopened.flush(); + const [outside] = reopened.list(); + expect(outside!.files.map((file) => file.path).sort()).toEqual([ + ".media/manifest.jsonl", + "b.png", + ]); + expect((await reopened.undo(outside!.id, { who: you })).ok).toBe(true); + expect([has("b.png"), has(".media/manifest.jsonl")]).toEqual([false, false]); + }); + + it("keeps a media copy that a budget prune ran into before history recorded it", async () => { + const clip = Buffer.alloc(2 * 1024 ** 2, 7); + let release = () => {}; + mediaCopy.held = new Promise((resolve) => (release = resolve)); + const { history, write } = await project( + { "index.html": "A", "clip.mp4": clip }, + { budgetBytes: 1, quietMs: 30 }, + ); + // The copy is stored; an edit commits and prunes before history records the copy. + mediaCopy.stored = () => { + mediaCopy.stored = null; + return change(history, you, "Color", () => write("index.html", "B")); + }; + release(); + await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]), { + timeout: 10_000, + }); + const start = history.peek(START)["clip.mp4"]!; + expect((await history.readBlob(start)).equals(clip)).toBe(true); + }); + it("a log 0.8.123 wrote without a ledger takes none in, and one made later while closed is a change", async () => { const { history, write, projectDir, historyRoot } = await project( { "index.html": "A" }, diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index db4e761bf09..f61c0135d47 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -43,6 +43,7 @@ import { import { takeHistoryOwnership } from "./ownerLock.js"; import { START, + baselineRecord, foldOldest, manifestAround, manifestAt, @@ -522,18 +523,21 @@ class Engine { async adopt(file: ListedFile, sweptAt: number) { try { - const hash = await this.storeIfPresent(file.path); + const stored = await this.storeIfPresent(file.path); await this.queue(async () => { this.adopting.delete(file.path); this.saveAdopting(); - if (hash === null || this.whereFolder() !== "here") return; + if (stored === null || this.whereFolder() !== "here") return; + // A prune that ran while this copy waited for the queue may have taken its bytes. + const hash = this.blobs.has(stored) ? stored : await this.storeIfPresent(file.path); + if (hash === null) return; const change = { path: file.path, before: null, after: hash }; const found = this.overwrittenBy(change, undefined, new Set()); const start = found && this.blobs.has(found) ? found : hash; this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) }); this.log.baseline.set(file.path, start); if (start !== hash) addChange(this.outsideGroup(), file.path, start, hash); - this.persistLog({ type: "baseline", files: Object.fromEntries(this.log.baseline) }); + this.persistLog(baselineRecord(this.log)); this.saveStatCache(); }); } catch (error) { From 1f82269e79110b7a293204770f076ab31fea6198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 23:47:25 -0400 Subject: [PATCH 4/9] fix(studio): a busy project history never makes writes wait for its lock again --- packages/cli/src/server/studioServer.test.ts | 16 ++++++++++++++++ packages/cli/src/server/studioServer.ts | 6 +++++- .../studio-server/src/routes/history.test.ts | 10 +++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index 3af11315fee..e97a08a5bcd 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -185,6 +185,22 @@ describe("createStudioServer project history (D-491)", () => { }, ); + it("never makes a request wait on another process's history lock again once it was busy", async () => { + const waits: unknown[] = []; + historyState.open = async (options) => { + waits.push((options as { ownerWaitMs?: number }).ownerWaitMs); + throw new HistoryBusyError(1); + }; + const projectDir = tmpProject(); + server = createStudioServer({ projectDir, historyRoot: tmpProject() }); + const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`; + await server.app.request(historyUrl); + await server.app.request(historyUrl); + historyState.open = null; + expect(waits).toEqual([undefined, 0]); + await server.shutdown(); + }); + it("opens a new project's own history once it takes the folder's path", async () => { const projectDir = tmpProject(); writeFileSync(join(projectDir, "index.html"), "before"); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index e1acf8f2ecb..60cb50a66db 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -436,13 +436,17 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }; // Opened on first use, so a server that never serves Studio's history never writes one. A failed open stays off - // for this run; one another process was holding is tried again on the next request. + // for this run; one another process was holding is tried again on the next request, without waiting for it, + // since every write to the project waits on that open. + let ownerWaitMs: number | undefined; const histories = historyCache(() => openProjectHistory({ projectDir, historyRoot: options.historyRoot ?? DEFAULT_HISTORY_ROOT, + ownerWaitMs, }).catch((error: unknown) => { console.warn(`[studio] Project history is off: ${String(error)}`); + if (error instanceof HistoryBusyError) ownerWaitMs = 0; if (error instanceof HistoryBusyError || error instanceof HistoryClosedError) histories.forget(projectDir); return null; diff --git a/packages/studio-server/src/routes/history.test.ts b/packages/studio-server/src/routes/history.test.ts index ca8a2928004..18c7394822c 100644 --- a/packages/studio-server/src/routes/history.test.ts +++ b/packages/studio-server/src/routes/history.test.ts @@ -418,7 +418,9 @@ describe("history routes", () => { finishCopy(); const history = (await histories.get(projectDir))!; - await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"])); + await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]), { + timeout: 10_000, + }); await history.flush(); expect(history.list().map((entry) => entry.label)).toEqual(["Color", "Undid: Color"]); }); @@ -460,7 +462,9 @@ describe("history routes", () => { mediaCopy.held = null; const history = (await histories.get(projectDir))!; - await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"])); + await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]), { + timeout: 10_000, + }); await history.flush(); expect(history.list()).toEqual([]); }); @@ -475,7 +479,7 @@ describe("history routes", () => { await vi.waitFor( async () => expect((await (await call("")).json()).entries).toMatchObject([{ label: "Dragged Title" }]), - { timeout: 2_000, interval: 50 }, + { timeout: 10_000, interval: 50 }, ); const { entry } = await (await call(`/window/${windowId}/close`, {})).json(); expect(entry).toMatchObject({ id: windowId, label: "Dragged Title" }); From ecd8adcaf26e01f60227d0b7da19cbcff9397898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 00:14:53 -0400 Subject: [PATCH 5/9] refactor(studio): name the restore stamping instead of commenting it --- packages/cli/src/server/studioServer.ts | 3 +-- packages/studio/src/utils/gsapUndoRestore.ts | 9 +++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 60cb50a66db..1c4c4ef5e95 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -436,8 +436,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }; // Opened on first use, so a server that never serves Studio's history never writes one. A failed open stays off - // for this run; one another process was holding is tried again on the next request, without waiting for it, - // since every write to the project waits on that open. + // for this run; one another process was holding is tried again on the next request, which no write waits for. let ownerWaitMs: number | undefined; const histories = historyCache(() => openProjectHistory({ diff --git a/packages/studio/src/utils/gsapUndoRestore.ts b/packages/studio/src/utils/gsapUndoRestore.ts index 3373f5ed4d5..e76a79485a8 100644 --- a/packages/studio/src/utils/gsapUndoRestore.ts +++ b/packages/studio/src/utils/gsapUndoRestore.ts @@ -252,10 +252,11 @@ function fileTargets( isActive: boolean, file: UndoRestoreFile, ): RestorePlan | null { - // The preview stamps the film it shows, so a source from before Studio first stamped it is stamped to match. - const asShown = doc.querySelector("[data-hf-id]") ? ensureHfIds : (html: string) => html; - const prevDoc = parseRestoreSource(asShown(file.previous)); - const nextDoc = parseRestoreSource(asShown(file.restored)); + const stampedLikeThePreview = doc.querySelector("[data-hf-id]") + ? ensureHfIds + : (html: string) => html; + const prevDoc = parseRestoreSource(stampedLikeThePreview(file.previous)); + const nextDoc = parseRestoreSource(stampedLikeThePreview(file.restored)); const scripted = restoreScripted(prevDoc, nextDoc, isActive); if (scripted === null) return null; const keys = diffRestoreDocs(prevDoc, nextDoc); From be63510127426993072a41498351d9a6441d8fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 00:22:50 -0400 Subject: [PATCH 6/9] perf(studio): undo stamps only a restore side that has no stamps --- packages/studio/src/utils/gsapUndoRestore.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/utils/gsapUndoRestore.ts b/packages/studio/src/utils/gsapUndoRestore.ts index e76a79485a8..186fde36624 100644 --- a/packages/studio/src/utils/gsapUndoRestore.ts +++ b/packages/studio/src/utils/gsapUndoRestore.ts @@ -252,9 +252,9 @@ function fileTargets( isActive: boolean, file: UndoRestoreFile, ): RestorePlan | null { - const stampedLikeThePreview = doc.querySelector("[data-hf-id]") - ? ensureHfIds - : (html: string) => html; + const previewStamped = doc.querySelector("[data-hf-id]") !== null; + const stampedLikeThePreview = (html: string) => + previewStamped && !html.includes("data-hf-id") ? ensureHfIds(html) : html; const prevDoc = parseRestoreSource(stampedLikeThePreview(file.previous)); const nextDoc = parseRestoreSource(stampedLikeThePreview(file.restored)); const scripted = restoreScripted(prevDoc, nextDoc, isActive); From f2c14e8210ef269b247415205bfde95e6d0cf8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 02:09:06 -0400 Subject: [PATCH 7/9] fix(studio): closing a project history finishes its media copy first --- .../src/history/projectHistory.test.ts | 35 +++++++++++++++++ .../src/history/projectHistory.ts | 38 ++++++++++++------- .../studio-server/src/routes/history.test.ts | 17 --------- 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index bd4be8fb13c..e9beb55e1e5 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -312,6 +312,41 @@ describe("openProjectHistory", () => { expect([has("b.png"), has(".media/manifest.jsonl")]).toEqual([false, false]); }); + it("undoes a clip replaced while closed, though the history closed before its copy finished", async () => { + const clip = Buffer.alloc(2 * 1024 ** 2, 7); + let release = () => {}; + mediaCopy.held = new Promise((resolve) => (release = resolve)); + const { history, write, projectDir, historyRoot } = await project( + { "index.html": "A", "clip.mp4": clip }, + { quietMs: 30 }, + ); + const closed = history.close(); + release(); + await closed; + write("clip.mp4", Buffer.alloc(2 * 1024 ** 2, 9)); + const reopened = await open(projectDir, historyRoot, { quietMs: 30 }); + await reopened.flush(); + expect(reopened.list().map((entry) => entry.files.map((file) => file.path))).toEqual([ + ["clip.mp4"], + ]); + expect(await reopened.step("back", you)).toMatchObject({ ok: true }); + expect(readFileSync(join(projectDir, "clip.mp4")).equals(clip)).toBe(true); + }); + + it("adopts media whose copy a process exit cut short, rather than log it as added", async () => { + const { history, write, projectDir, historyRoot } = await project({ "index.html": "A" }); + await history.close(); + // What an exit mid-copy leaves: the clip on disk, named as still being copied, not in the baseline. + write("clip.mp4", Buffer.alloc(2 * 1024 ** 2, 7)); + writeFileSync(join(historyRoot, history.projectId, "adopting.json"), '["clip.mp4"]'); + const reopened = await open(projectDir, historyRoot, { quietMs: 30 }); + await vi.waitFor(() => expect(reopened.peek(START)).toHaveProperty(["clip.mp4"]), { + timeout: 10_000, + }); + await reopened.flush(); + expect(reopened.list()).toEqual([]); + }); + it("keeps a media copy that a budget prune ran into before history recorded it", async () => { const clip = Buffer.alloc(2 * 1024 ** 2, 7); let release = () => {}; diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index f61c0135d47..69e9a5dd27a 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -523,28 +523,36 @@ class Engine { async adopt(file: ListedFile, sweptAt: number) { try { + if (this.closed) return; const stored = await this.storeIfPresent(file.path); await this.queue(async () => { - this.adopting.delete(file.path); - this.saveAdopting(); - if (stored === null || this.whereFolder() !== "here") return; - // A prune that ran while this copy waited for the queue may have taken its bytes. - const hash = this.blobs.has(stored) ? stored : await this.storeIfPresent(file.path); - if (hash === null) return; - const change = { path: file.path, before: null, after: hash }; - const found = this.overwrittenBy(change, undefined, new Set()); - const start = found && this.blobs.has(found) ? found : hash; - this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) }); - this.log.baseline.set(file.path, start); - if (start !== hash) addChange(this.outsideGroup(), file.path, start, hash); - this.persistLog(baselineRecord(this.log)); - this.saveStatCache(); + try { + await this.recordAdopted(file, sweptAt, stored); + } finally { + this.adopting.delete(file.path); + this.saveAdopting(); + } }); } catch (error) { if (!(error instanceof HistoryClosedError)) this.options.onError?.(error); } } + async recordAdopted(file: ListedFile, sweptAt: number, stored: string | null) { + if (stored === null || this.whereFolder() !== "here") return; + // A prune that ran while this copy waited for the queue may have taken its bytes. + const hash = this.blobs.has(stored) ? stored : await this.storeIfPresent(file.path); + if (hash === null) return; + const change = { path: file.path, before: null, after: hash }; + const found = this.overwrittenBy(change, undefined, new Set()); + const start = found && this.blobs.has(found) ? found : hash; + this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) }); + this.log.baseline.set(file.path, start); + if (start !== hash) addChange(this.outsideGroup(), file.path, start, hash); + this.persistLog(baselineRecord(this.log)); + this.saveStatCache(); + } + manifest(): Manifest { return new Map([...this.tracked].map(([path, file]) => [path, file.hash])); } @@ -1227,6 +1235,8 @@ class Engine { replacedAtPath: () => this.whereFolder() === "replaced", close: () => (this.closing ??= (async () => { + // A copy cut short would leave its media out of the baseline, so a change made while closed is lost. + await Promise.all(this.adopting.values()); if (this.notedTimer) clearTimeout(this.notedTimer); this.stopHearing?.(); const settled = this.queue(() => this.settleAll()); diff --git a/packages/studio-server/src/routes/history.test.ts b/packages/studio-server/src/routes/history.test.ts index 18c7394822c..44f2390086b 100644 --- a/packages/studio-server/src/routes/history.test.ts +++ b/packages/studio-server/src/routes/history.test.ts @@ -452,23 +452,6 @@ describe("history routes", () => { expect(readFileSync(clip).equals(original)).toBe(true); }); - it("adopt media whose copy a restart cut short, rather than log it as added", async () => { - mediaCopy.held = new Promise(() => {}); - const { projectDir, post, histories } = lazyHistoryProject(); - writeFileSync(join(projectDir, "clip.mp4"), Buffer.alloc(4 * 1024 ** 2, 7)); - await post("/claim", { label: "Nothing", paths: [] }); - await histories.closeAll(); - histories.forget(projectDir); - - mediaCopy.held = null; - const history = (await histories.get(projectDir))!; - await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]), { - timeout: 10_000, - }); - await history.flush(); - expect(history.list()).toEqual([]); - }); - it("end a window given idleMs by itself once its writes stop", async () => { const { projectDir, call } = await demoProject(); From fa72aea34059ee589f88cbcdb64701fd32cdef87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 02:30:32 -0400 Subject: [PATCH 8/9] fix(studio): media copies stay resumable until recorded and skip a replaced folder --- .../src/history/projectHistory.test.ts | 64 ++++++++++++++++++- .../src/history/projectHistory.ts | 12 ++-- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index e9beb55e1e5..7df644e3717 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -28,6 +28,8 @@ import { START, type HistoryWho } from "./historyLog"; const mediaCopy = vi.hoisted(() => ({ held: null as Promise | null, stored: null as (() => Promise) | null, + copies: [] as string[], + fails: null as ((copy: number) => boolean) | null, })); vi.mock("./blobStore", async (importOriginal) => { const real = await importOriginal(); @@ -38,7 +40,11 @@ vi.mock("./blobStore", async (importOriginal) => { return { ...store, put: async (path: string) => { - if (path.endsWith(".mp4")) await mediaCopy.held; + if (path.endsWith(".mp4")) { + await mediaCopy.held; + mediaCopy.copies.push(basename(path)); + if (mediaCopy.fails?.(mediaCopy.copies.length)) throw new Error("The disk is full."); + } const hash = await store.put(path); if (path.endsWith(".mp4")) await mediaCopy.stored?.(); return hash; @@ -54,8 +60,8 @@ const agent: HistoryWho = { kind: "agent", name: "Agent" }; const cleanup: Array<() => unknown> = []; afterEach(async () => { - Object.assign(mediaCopy, { held: null, stored: null }); for (const step of cleanup.splice(0).reverse()) await step(); + Object.assign(mediaCopy, { held: null, stored: null, copies: [], fails: null }); }); const inside = (dir: string, path: string) => readFileSync(join(dir, path), "utf-8"); @@ -312,6 +318,60 @@ describe("openProjectHistory", () => { expect([has("b.png"), has(".media/manifest.jsonl")]).toEqual([false, false]); }); + it("names media in adopting.json until its copy is recorded, so an exit mid-copy resumes it", async () => { + let release = () => {}; + mediaCopy.held = new Promise((resolve) => (release = resolve)); + const { history, historyRoot } = await project({ + "index.html": "A", + "clip.mp4": Buffer.alloc(2 * 1024 ** 2, 7), + }); + const adopting = join(historyRoot, history.projectId, "adopting.json"); + expect(JSON.parse(readFileSync(adopting, "utf-8"))).toEqual(["clip.mp4"]); + release(); + await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]), { + timeout: 10_000, + }); + expect(existsSync(adopting)).toBe(false); + }); + + it("keeps media whose recording failed for the next open, rather than log it as added", async () => { + const { history, write, historyRoot } = await project( + { "index.html": "A", "clip.mp4": Buffer.alloc(2 * 1024 ** 2, 7) }, + { budgetBytes: 1, quietMs: 30 }, + ); + // The edit's prune takes the first copy; storing it again then fails. + mediaCopy.fails = (copy) => copy === 2; + mediaCopy.stored = () => { + mediaCopy.stored = null; + return change(history, you, "Color", () => write("index.html", "B")); + }; + await vi.waitFor(() => expect(mediaCopy.copies).toHaveLength(2), { timeout: 10_000 }); + await history.flush(); + expect(history.list().flatMap((entry) => entry.files.map((file) => file.path))).not.toContain( + "clip.mp4", + ); + const adopting = join(historyRoot, history.projectId, "adopting.json"); + expect(JSON.parse(readFileSync(adopting, "utf-8"))).toEqual(["clip.mp4"]); + }); + + it("never copies the media of a folder that replaced the project", async () => { + let release = () => {}; + mediaCopy.held = new Promise((resolve) => (release = resolve)); + const media = { + "a.mp4": Buffer.alloc(2 * 1024 ** 2, 1), + "b.mp4": Buffer.alloc(2 * 1024 ** 2, 2), + }; + const { history, write, projectDir } = await project({ "index.html": "A", ...media }); + renameSync(projectDir, `${projectDir}-moved`); + cleanup.push(() => rmSync(`${projectDir}-moved`, { recursive: true, force: true })); + mkdirSync(projectDir); + for (const [path, bytes] of Object.entries(media)) write(path, bytes); + release(); + await history.close(); + // Only the copy already running when the folder changed may finish. + expect(mediaCopy.copies).toEqual(["a.mp4"]); + }); + it("undoes a clip replaced while closed, though the history closed before its copy finished", async () => { const clip = Buffer.alloc(2 * 1024 ** 2, 7); let release = () => {}; diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index 69e9a5dd27a..f4ae211d888 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -523,15 +523,13 @@ class Engine { async adopt(file: ListedFile, sweptAt: number) { try { - if (this.closed) return; + if (this.whereFolder() !== "here") return; const stored = await this.storeIfPresent(file.path); await this.queue(async () => { - try { - await this.recordAdopted(file, sweptAt, stored); - } finally { - this.adopting.delete(file.path); - this.saveAdopting(); - } + // Still named for the next open until recorded, so a failed record is never logged as added. + await this.recordAdopted(file, sweptAt, stored); + this.adopting.delete(file.path); + this.saveAdopting(); }); } catch (error) { if (!(error instanceof HistoryClosedError)) this.options.onError?.(error); From 76f4198d2d9acfa7f7f4f053d19a3ae474e8bdbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 03:30:48 -0400 Subject: [PATCH 9/9] fix(studio): the history lock wait comes back after a successful open --- packages/cli/src/server/studioServer.test.ts | 22 ++++++++++++++++ packages/cli/src/server/studioServer.ts | 19 ++++++++------ .../studio/src/utils/gsapUndoRestore.test.ts | 25 +++++++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index e97a08a5bcd..538b3203f4e 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -201,6 +201,28 @@ describe("createStudioServer project history (D-491)", () => { await server.shutdown(); }); + it("waits for the history lock again once a history opened after a busy one", async () => { + const waits: unknown[] = []; + const reopenable = { replacedAtPath: () => true, close: async () => {} }; + let opens = 0; + historyState.open = async (options) => { + waits.push((options as { ownerWaitMs?: number }).ownerWaitMs); + opens += 1; + if (opens === 1) throw new HistoryBusyError(1); + if (opens === 2) return reopenable; + historyState.open = null; + throw new HistoryBusyError(1); + }; + const projectDir = tmpProject(); + server = createStudioServer({ projectDir, historyRoot: tmpProject() }); + const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`; + await server.app.request(historyUrl); + await server.app.request(historyUrl); + historyState.open = null; + expect(waits).toEqual([undefined, 0, undefined]); + await server.shutdown(); + }); + it("opens a new project's own history once it takes the folder's path", async () => { const projectDir = tmpProject(); writeFileSync(join(projectDir, "index.html"), "before"); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 1c4c4ef5e95..148c3b8531f 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -443,13 +443,18 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { projectDir, historyRoot: options.historyRoot ?? DEFAULT_HISTORY_ROOT, ownerWaitMs, - }).catch((error: unknown) => { - console.warn(`[studio] Project history is off: ${String(error)}`); - if (error instanceof HistoryBusyError) ownerWaitMs = 0; - if (error instanceof HistoryBusyError || error instanceof HistoryClosedError) - histories.forget(projectDir); - return null; - }), + }) + .then((history) => { + ownerWaitMs = undefined; + return history; + }) + .catch((error: unknown) => { + console.warn(`[studio] Project history is off: ${String(error)}`); + if (error instanceof HistoryBusyError) ownerWaitMs = 0; + if (error instanceof HistoryBusyError || error instanceof HistoryClosedError) + histories.forget(projectDir); + return null; + }), ); const projectHistory = () => histories.get(projectDir); watcher.addListener((changedPath) => { diff --git a/packages/studio/src/utils/gsapUndoRestore.test.ts b/packages/studio/src/utils/gsapUndoRestore.test.ts index e4df93243b8..34f0599a180 100644 --- a/packages/studio/src/utils/gsapUndoRestore.test.ts +++ b/packages/studio/src/utils/gsapUndoRestore.test.ts @@ -2,6 +2,18 @@ import { describe, it, expect, vi } from "vitest"; import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; + +const stamping = vi.hoisted(() => ({ calls: 0 })); +vi.mock("@hyperframes/parsers/hf-ids", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + ensureHfIds: (html: string) => { + stamping.calls += 1; + return real.ensureHfIds(html); + }, + }; +}); import { applyUndoRestoreToPreview, diffSoftReloadableRestore, @@ -101,6 +113,19 @@ function buildLiveIframe(bodyHtml: string) { describe("applyUndoRestoreToPreview", () => { const ROOT = "index.html"; + it("never stamps an undo whose sides are both already stamped", () => { + const film = (color: string) => + `
T

caption

`; + const blue = ensureHfIds(film("blue")); + const red = blue.replace("background-color: blue", "background-color: red"); + const { iframe } = buildLiveIframe(red); + stamping.calls = 0; + + const files = { [ROOT]: { previous: wrap(red), restored: wrap(blue) } }; + expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft"); + expect(stamping.calls).toBe(0); + }); + it("repaints in place an undo back to the film before Studio first stamped it", () => { const film = (color: string) => `
T

caption

`;