diff --git a/packages/cli/src/server/fileWatcher.test.ts b/packages/cli/src/server/fileWatcher.test.ts index 686e14ed59..57b0e8a65f 100644 --- a/packages/cli/src/server/fileWatcher.test.ts +++ b/packages/cli/src/server/fileWatcher.test.ts @@ -1,12 +1,21 @@ import { EventEmitter } from "node:events"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type WatchCallback = (eventType: string, filename: string | Buffer | null) => void; const mockWatcher = new EventEmitter() as EventEmitter & { close: () => void }; mockWatcher.close = vi.fn(); -vi.mock("node:fs", () => ({ - watch: vi.fn(() => mockWatcher), -})); +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + watch: vi.fn((_path: string, _options: unknown, onChange: WatchCallback) => { + mockWatcher.on("change", onChange); + return mockWatcher; + }), + }; +}); const { shouldWatchProjectFile, createProjectWatcher } = await import("./fileWatcher.js"); @@ -30,6 +39,27 @@ describe("shouldWatchProjectFile", () => { }); describe("createProjectWatcher", () => { + beforeEach(() => { + mockWatcher.removeAllListeners(); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("notifies once for every file changed in one debounce burst", () => { + vi.useFakeTimers(); + const projectWatcher = createProjectWatcher("/fake/project/dir"); + const listener = vi.fn(); + projectWatcher.addListener(listener); + + mockWatcher.emit("change", "change", "scene-a.html"); + mockWatcher.emit("change", "change", "scene-b.html"); + mockWatcher.emit("change", "change", "scene-a.html"); + vi.advanceTimersByTime(300); + + expect(listener.mock.calls).toEqual([["scene-a.html"], ["scene-b.html"]]); + projectWatcher.close(); + }); + // Regression: fs.watch can fail asynchronously (e.g. EMFILE from exhausted // OS watch handles) via an 'error' event, not a thrown exception. An // EventEmitter 'error' with no listener crashes the whole process — this diff --git a/packages/cli/src/server/fileWatcher.ts b/packages/cli/src/server/fileWatcher.ts index 3f7dec580a..d5e2f67e53 100644 --- a/packages/cli/src/server/fileWatcher.ts +++ b/packages/cli/src/server/fileWatcher.ts @@ -34,6 +34,7 @@ export function shouldWatchProjectFile(filename: string): boolean { export function createProjectWatcher(projectDir: string): ProjectWatcher { const listeners = new Set(); + const pendingPaths = new Set(); let debounceTimer: ReturnType | null = null; let watcher: FSWatcher | null = null; @@ -43,10 +44,16 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher { const relativePath = filename.toString(); if (!shouldWatchProjectFile(relativePath)) return; + pendingPaths.add(relativePath); if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { - for (const fn of listeners) { - fn(relativePath); + const changedPaths = [...pendingPaths]; + pendingPaths.clear(); + debounceTimer = null; + for (const changedPath of changedPaths) { + for (const fn of listeners) { + fn(changedPath); + } } }, DEBOUNCE_MS); }); @@ -72,6 +79,7 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher { }, close() { if (debounceTimer) clearTimeout(debounceTimer); + pendingPaths.clear(); watcher?.close(); listeners.clear(); }, diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 613a3ddb72..9d9e83e054 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -30,6 +30,7 @@ import { createProjectSignature, createBackgroundRemovalJob, consumeFileWriteReceipt, + fileContentVersion, getMimeType, type PreviewApiAdapter, thumbnailDeviceScaleFactor, @@ -752,7 +753,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { app.get("/api/events", (c) => { return streamSSE(c, async (stream) => { const listener = (path: string) => { - const receipt = consumeFileWriteReceipt(resolve(projectDir, path)); + const absPath = resolve(projectDir, path); + let version: string | null = null; + try { + version = fileContentVersion(readFileSync(absPath, "utf-8")); + } catch { + // A deletion has no current bytes to match against an API write receipt. + } + const receipt = version ? consumeFileWriteReceipt(absPath, version) : null; stream .writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) }) .catch(() => {}); diff --git a/packages/studio-server/src/helpers/fileVersion.test.ts b/packages/studio-server/src/helpers/fileVersion.test.ts index 3ecd0fbdb0..2ed4511476 100644 --- a/packages/studio-server/src/helpers/fileVersion.test.ts +++ b/packages/studio-server/src/helpers/fileVersion.test.ts @@ -23,7 +23,25 @@ describe("file versions and write receipts", () => { }; recordFileWriteReceipt("/project/index.html", receipt); - expect(consumeFileWriteReceipt("/project/index.html")).toEqual(receipt); - expect(consumeFileWriteReceipt("/project/index.html")).toBeNull(); + expect(consumeFileWriteReceipt("/project/index.html", receipt.version)).toEqual(receipt); + expect(consumeFileWriteReceipt("/project/index.html", receipt.version)).toBeNull(); + }); + + it("matches the final debounced watcher version instead of receipt insertion order", () => { + const first = { + path: "index.html", + version: fileContentVersion("first"), + writeToken: "write-1", + }; + const last = { + path: "index.html", + version: fileContentVersion("last"), + writeToken: "write-2", + }; + recordFileWriteReceipt("/project/index.html", first); + recordFileWriteReceipt("/project/index.html", last); + + expect(consumeFileWriteReceipt("/project/index.html", last.version)).toEqual(last); + expect(consumeFileWriteReceipt("/project/index.html", first.version)).toEqual(first); }); }); diff --git a/packages/studio-server/src/helpers/fileVersion.ts b/packages/studio-server/src/helpers/fileVersion.ts index ab95e866b5..dd2345abc4 100644 --- a/packages/studio-server/src/helpers/fileVersion.ts +++ b/packages/studio-server/src/helpers/fileVersion.ts @@ -32,13 +32,17 @@ export function recordFileWriteReceipt(absPath: string, receipt: FileWriteReceip receipts.set(absPath, current); } -/** Attach one API write's identity to the corresponding filesystem-watch echo. */ -export function consumeFileWriteReceipt(absPath: string): FileWriteReceipt | null { +/** Attach one API write's identity to the watcher echo for its exact bytes. */ +export function consumeFileWriteReceipt( + absPath: string, + expectedVersion: string, +): FileWriteReceipt | null { const now = Date.now(); const current = (receipts.get(absPath) ?? []).filter( (entry) => now - entry.recordedAt < RECEIPT_TTL_MS, ); - const receipt = current.shift() ?? null; + const receiptIndex = current.findIndex((entry) => entry.version === expectedVersion); + const receipt = receiptIndex === -1 ? null : (current.splice(receiptIndex, 1)[0] ?? null); if (current.length > 0) receipts.set(absPath, current); else receipts.delete(absPath); if (!receipt) return null; diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index ffd9f61a8c..e6413b4ada 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -65,10 +65,18 @@ function createAdapter(projectDir: string): StudioApiAdapter { }; } -function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Promise { +function postElementPatchBatch( + app: Hono, + file: string, + patches: unknown[], + writeToken?: string, +): Promise { return app.request(`http://localhost/projects/demo/file-mutations/patch-elements-batch/${file}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(writeToken ? { "X-Hyperframes-Write-Token": writeToken } : {}), + }, body: JSON.stringify({ patches }), }); } @@ -76,10 +84,14 @@ function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Pro function postElementPatchBatches( app: Hono, batches: Array<{ sourceFile: string; patches: unknown[] }>, + writeToken?: string, ): Promise { return app.request("http://localhost/projects/demo/file-mutations/patch-element-batches", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(writeToken ? { "X-Hyperframes-Write-Token": writeToken } : {}), + }, body: JSON.stringify({ batches }), }); } @@ -119,7 +131,10 @@ describe("registerFileRoutes", () => { const insert = (expectedVersion: string) => app.request("http://localhost/projects/demo/file-mutations/insert-composition/index.html", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-Hyperframes-Write-Token": "studio-insert-1", + }, body: JSON.stringify({ sourcePath: "child.html", start: 4, track: 0, expectedVersion }), }); @@ -131,6 +146,11 @@ describe("registerFileRoutes", () => { expect(result.after).toContain('data-duration="7"'); expect(result.after).toContain(`id="${result.hostId}"`); expect(result.version).toBe(fileContentVersion(result.after)); + expect(consumeFileWriteReceipt(join(projectDir, "index.html"), result.version)).toEqual({ + path: "index.html", + version: result.version, + writeToken: "studio-insert-1", + }); const committed = result.after; const stale = await insert(fileContentVersion(before)); @@ -366,7 +386,7 @@ describe("registerFileRoutes", () => { expect(payload.version).toBe(fileContentVersion("after")); expect(payload.writeToken).toBe("studio-write-1"); expect(response.headers.get("etag")).toBe(payload.version); - expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({ + expect(consumeFileWriteReceipt(join(projectDir, "index.html"), payload.version!)).toEqual({ path: "index.html", version: payload.version, writeToken: "studio-write-1", @@ -425,6 +445,39 @@ describe("registerFileRoutes", () => { expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After"); }); + // Without the receipt the client cannot recognise its own edit in the watcher + // broadcast, so it treats it as someone else's write and does a full preview + // reload — a visible blank on the stage right after the user typed. + it("leaves a write receipt so the patch's own file-change echo is identifiable", async () => { + const projectDir = createProjectDir(); + writeFileSync(projectDir + "/index.html", '
Before
'); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/patch-element/index.html", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Hyperframes-Write-Token": "studio-patch-1", + }, + body: JSON.stringify({ + target: { id: "title" }, + operations: [{ type: "text-content", property: "textContent", value: "After" }], + }), + }, + ); + + expect(response.status).toBe(200); + const version = fileContentVersion(readFileSync(join(projectDir, "index.html"), "utf-8")); + expect(consumeFileWriteReceipt(join(projectDir, "index.html"), version)).toEqual({ + path: "index.html", + version, + writeToken: "studio-patch-1", + }); + }); + it("applies an ordered element patch batch with one file write", async () => { const projectDir = createProjectDir(); const original = @@ -433,16 +486,21 @@ describe("registerFileRoutes", () => { const app = new Hono(); registerFileRoutes(app, createAdapter(projectDir)); - const response = await postElementPatchBatch(app, "index.html", [ - { - target: { id: "back" }, - operations: [{ type: "inline-style", property: "z-index", value: "2" }], - }, - { - target: { id: "front" }, - operations: [{ type: "inline-style", property: "z-index", value: "1" }], - }, - ]); + const response = await postElementPatchBatch( + app, + "index.html", + [ + { + target: { id: "back" }, + operations: [{ type: "inline-style", property: "z-index", value: "2" }], + }, + { + target: { id: "front" }, + operations: [{ type: "inline-style", property: "z-index", value: "1" }], + }, + ], + "studio-layer-order-1", + ); expect(response.status).toBe(200); const payload = (await response.json()) as { changed?: boolean; @@ -458,6 +516,12 @@ describe("registerFileRoutes", () => { expect(payload.content).toContain('id="back" style="z-index: 2"'); expect(payload.content).toContain('id="front" style="z-index: 1"'); expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(original); + const version = fileContentVersion(payload.content!); + expect(consumeFileWriteReceipt(join(projectDir, "index.html"), version)).toEqual({ + path: "index.html", + version, + writeToken: "studio-layer-order-1", + }); expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(1); }); @@ -519,6 +583,52 @@ describe("registerFileRoutes", () => { expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false); }); + it("leaves one exact write receipt for every file in a durable element patch batch", async () => { + const projectDir = createProjectDir(); + writeFileSync(join(projectDir, "index.html"), '
Before
'); + writeFileSync(join(projectDir, "scene.html"), '
Before
'); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postElementPatchBatches( + app, + [ + { + sourceFile: "index.html", + patches: [ + { + target: { id: "index" }, + operations: [{ type: "text-content", property: "textContent", value: "After" }], + }, + ], + }, + { + sourceFile: "scene.html", + patches: [ + { + target: { id: "scene" }, + operations: [{ type: "text-content", property: "textContent", value: "After" }], + }, + ], + }, + ], + "studio-group-drag-1", + ); + const payload = (await response.json()) as { + files: Array<{ sourceFile: string; after: string }>; + }; + + expect(response.status).toBe(200); + for (const file of payload.files) { + const version = fileContentVersion(file.after); + expect(consumeFileWriteReceipt(join(projectDir, file.sourceFile), version)).toEqual({ + path: file.sourceFile, + version, + writeToken: "studio-group-drag-1", + }); + } + }); + it("refuses every file when one batch contains an unmatched target", async () => { const projectDir = createProjectDir(); const indexOriginal = '
Present
'; @@ -732,7 +842,9 @@ describe("registerFileRoutes", () => { expect(payload.files[0].after).toContain('id="a-split"'); expect(payload.files[0].after).toContain('id="b-split"'); expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(payload.files[0].after); - expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({ + expect( + consumeFileWriteReceipt(join(projectDir, "index.html"), payload.files[0].version), + ).toEqual({ path: "index.html", version: payload.files[0].version, writeToken: "cut-test", diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 3ec64bbff1..dbe2f9b916 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -112,6 +112,7 @@ interface RouteContext { param: (name: string) => string; path: string; query: (name: string) => string | undefined; + header: (name: string) => string | undefined; }; header: (name: string, value: string) => void; json: (data: unknown, status?: number) => Response; @@ -399,6 +400,69 @@ export function commitElementPatchBatches( return { durable: true, files }; } +function commitElementPatchBatchesWithReceipts( + c: RouteContext, + projectDir: string, + batches: ElementPatchBatchRequest[], +): ReturnType { + const result = commitElementPatchBatches(projectDir, batches); + if ("error" in result || !result.durable) return result; + + for (const file of result.files) { + if (!file.changed) continue; + const absPath = resolveWithinProject(projectDir, file.sourceFile); + if (!absPath) throw new Error(`Committed element patch escaped project: ${file.sourceFile}`); + recordMutationReceipt(c, file.sourceFile, absPath, file.after); + } + return result; +} + +/** + * Record the receipt that claims a mutation result. + * + * The file watcher broadcasts every write, including the ones Studio itself just + * asked for. The receipt is what lets the client tell its own echo from an agent + * or an editor writing the file behind its back: without one, the client treats + * its own edit as an external change and does a full preview reload, which blanks + * the stage for a few hundred milliseconds right after the user typed. Every + * mutation route records through here so no route can forget. + */ +function recordMutationReceipt( + c: RouteContext, + filePath: string, + absPath: string, + html: string, +): { version: string; writeToken: string } { + const version = fileContentVersion(html); + const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token")); + recordFileWriteReceipt(absPath, { path: filePath, version, writeToken }); + return { version, writeToken }; +} + +function writeFileWithReceipt( + c: RouteContext, + filePath: string, + absPath: string, + html: string, +): { version: string; writeToken: string } { + writeFileSync(absPath, html, "utf-8"); + // The synchronous write cannot yield before its receipt is recorded; keep this block await-free. + return recordMutationReceipt(c, filePath, absPath, html); +} + +function writeMutationResult( + c: RouteContext, + projectDir: string, + filePath: string, + absPath: string, + html: string, +): { backupPath: string | null; version: string } { + const backup = snapshotBeforeWrite(projectDir, absPath); + if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`); + const { version } = writeFileWithReceipt(c, filePath, absPath, html); + return { backupPath: backupPathForResponse(projectDir, backup.backupPath), version }; +} + /** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */ function writeIfChanged( c: RouteContext, @@ -411,15 +475,13 @@ function writeIfChanged( if (next === original) { return c.json({ ok: true, changed: false, content: original, path: filePath }); } - const backup = snapshotBeforeWrite(projectDir, absPath); - if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`); - writeFileSync(absPath, next, "utf-8"); + const { backupPath } = writeMutationResult(c, projectDir, filePath, absPath, next); return c.json({ ok: true, changed: true, content: next, path: filePath, - backupPath: backupPathForResponse(projectDir, backup.backupPath), + backupPath, }); } @@ -1238,10 +1300,13 @@ async function applyGsapMutations( return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409); } if (changed) { - const backup = snapshotBeforeWrite(res.project.dir, res.absPath); - if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`); - backupPath = backupPathForResponse(res.project.dir, backup.backupPath); - writeFileSync(res.absPath, newHtml, "utf-8"); + backupPath = writeMutationResult( + c, + res.project.dir, + res.filePath, + res.absPath, + newHtml, + ).backupPath; } const responsePayload: Record = { @@ -2388,10 +2453,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500); - writeFileSync(ctx.absPath, insertion.html, "utf-8"); - const version = fileContentVersion(insertion.html); - const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token")); - recordFileWriteReceipt(ctx.absPath, { path: ctx.filePath, version, writeToken }); + const { version, writeToken } = writeFileWithReceipt( + c, + ctx.filePath, + ctx.absPath, + insertion.html, + ); c.header("ETag", version); return c.json({ ok: true, @@ -2623,10 +2690,13 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { version, }); } - const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); - if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); - writeFileSync(ctx.absPath, result.html, "utf-8"); - const version = fileContentVersion(result.html); + const { version, backupPath } = writeMutationResult( + c, + ctx.project.dir, + ctx.filePath, + ctx.absPath, + result.html, + ); c.header("ETag", version); return c.json({ ok: true, @@ -2635,7 +2705,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { newId: result.newId, path: ctx.filePath, version, - backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), + backupPath, }); }); @@ -2676,16 +2746,20 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { path: ctx.filePath, }); } - const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); - if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); - writeFileSync(ctx.absPath, patched, "utf-8"); + const { backupPath } = writeMutationResult( + c, + ctx.project.dir, + ctx.filePath, + ctx.absPath, + patched, + ); return c.json({ ok: true, changed: true, matched, content: patched, path: ctx.filePath, - backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), + backupPath, }); }); @@ -2707,7 +2781,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const unsafeFields = findUnsafeElementPatchBatchValues(body.batches); if (unsafeFields.length > 0) return rejectUnsafeMutationValues(c, unsafeFields); - const result = commitElementPatchBatches(project.dir, body.batches); + const result = commitElementPatchBatchesWithReceipts(c, project.dir, body.batches); if ("error" in result) { return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile); } @@ -2735,7 +2809,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { return rejectUnsafeMutationValues(c, unsafeFields); } - const result = commitElementPatchBatches(ctx.project.dir, [batch]); + const result = commitElementPatchBatchesWithReceipts(c, ctx.project.dir, [batch]); if ("error" in result) { return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile); } @@ -2807,16 +2881,20 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { result.error === "grouped elements must share a single parent" ? 422 : 400, ); } - const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); - if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); - writeFileSync(ctx.absPath, result.html, "utf-8"); + const { backupPath } = writeMutationResult( + c, + ctx.project.dir, + ctx.filePath, + ctx.absPath, + result.html, + ); return c.json({ ok: true, changed: true, groupId: result.groupId, content: result.html, path: ctx.filePath, - backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), + backupPath, }); }); diff --git a/packages/studio/src/captions/hooks/useCaptionSync.ts b/packages/studio/src/captions/hooks/useCaptionSync.ts index 5fdbf80138..1b2037a0f4 100644 --- a/packages/studio/src/captions/hooks/useCaptionSync.ts +++ b/packages/studio/src/captions/hooks/useCaptionSync.ts @@ -3,6 +3,7 @@ import { useCaptionStore } from "../store"; import { useMountEffect } from "../../hooks/useMountEffect"; import { trackEvent } from "../../telemetry/client"; import type { CaptionStyle } from "../types"; +import { studioWriteHeaders } from "../../utils/studioFileVersion"; interface CaptionOverrideEntry { wordId?: string; @@ -77,7 +78,7 @@ export function useCaptionSync(projectId: string | null) { fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, { method: "PUT", - headers: { "Content-Type": "text/plain" }, + headers: { "Content-Type": "text/plain", ...studioWriteHeaders() }, body: JSON.stringify(overrides, null, 2), }).catch((error: unknown) => { // Caption auto-save is a data-loss path; surface failures via telemetry diff --git a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts index 8c6e45a020..4bca7d0e2c 100644 --- a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts +++ b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts @@ -3,6 +3,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes"; export { PROPERTY_DEFAULTS } from "./gsapShared"; import { idSelector, matchesExactlyOne } from "./gsapShared"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; /** * The selector to author a NEW tween against, minting an id on the element when @@ -119,7 +120,7 @@ export async function assignGsapTargetAutoIdIfNeeded({ `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(patchBody), }, ); diff --git a/packages/studio/src/hooks/timelineTimingSync.ts b/packages/studio/src/hooks/timelineTimingSync.ts index 919d259a90..aac58e926f 100644 --- a/packages/studio/src/hooks/timelineTimingSync.ts +++ b/packages/studio/src/hooks/timelineTimingSync.ts @@ -7,6 +7,7 @@ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftR import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers"; import type { RecordEditInput } from "../utils/studioFileHistory"; import { patchDocumentRootDuration } from "./timelineEditingGsap"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; class GsapPreviewConvergenceError extends Error {} class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {} @@ -58,6 +59,8 @@ async function rollbackOwnedMutation( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`, { method: "POST", + // Deliberately unclaimed: a rollback runs because a mutation did not + // converge, so let the restored file reload the preview. headers: { "Content-Type": "application/json" }, body: JSON.stringify({ expected, restore }), }, @@ -156,7 +159,7 @@ async function postGsapMutation( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(mutation), }, ); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 7a02be735d..deb5ae9e3c 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -33,6 +33,7 @@ import { readErrorResponseBody, } from "./useDomEditCommitsHelpers"; import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; interface RecordEditInput { label: string; kind: EditHistoryKind; @@ -201,7 +202,7 @@ export function useDomEditCommits({ `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(patchBody), }, ); diff --git a/packages/studio/src/hooks/useDomEditCommitsHelpers.ts b/packages/studio/src/hooks/useDomEditCommitsHelpers.ts index dd82aff581..a440c6e4ef 100644 --- a/packages/studio/src/hooks/useDomEditCommitsHelpers.ts +++ b/packages/studio/src/hooks/useDomEditCommitsHelpers.ts @@ -1,6 +1,7 @@ import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics"; import type { DomEditPatchBatch } from "./domEditCommitTypes"; import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; export function formatUnsafeFieldList(fields: Array<{ path: string }>): string { return fields.map((field) => field.path).join(", "); @@ -99,7 +100,7 @@ export async function patchElementBatches(projectId: string, batches: DomEditPat `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body, }, ); diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index 4e17c5af36..3df6bf402e 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -21,6 +21,7 @@ import { } from "../components/editor/useLayerRevealOverride"; import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes"; import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams { /** Route delete through SDK when session resolves the hf-id. */ @@ -115,7 +116,7 @@ export function useElementLifecycleOps({ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ target: patchTarget }), }, ); diff --git a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts index 4d16842859..b1de697b09 100644 --- a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts +++ b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts @@ -4,6 +4,7 @@ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics"; import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage"; import { isSelfWriteEcho } from "./sdkSelfWriteRegistry"; import { consumeStudioWriteToken } from "../utils/studioFileVersion"; +import { logReload } from "../utils/reloadDebug"; type ExternalChangeDrainResult = | { status: "clean" } @@ -194,6 +195,7 @@ export function useExternalFileChangeCoordinator({ const reloadAcceptedGeneration = useCallback( (path: string) => { + logReload("reload", { path, by: "external-change coordinator" }); reloadPreview(); reloadSdkSession(path); }, @@ -221,11 +223,22 @@ export function useExternalFileChangeCoordinator({ pendingTimelinePaths.delete(path); const content = readFileChangeContent(payload); - if (consumeStudioWriteToken(readFileChangeWriteToken(payload))) return; - if (content != null && isSelfWriteEcho(path, content)) return; + const token = readFileChangeWriteToken(payload); + logReload("file-change", { path, token: token ?? null, hasContent: content != null }); + if (consumeStudioWriteToken(token)) { + logReload("suppressed", { path, why: "own write token" }); + return; + } + if (content != null && isSelfWriteEcho(path, content)) { + logReload("suppressed", { path, why: "own content echo" }); + return; + } const identity = eventIdentity(path, payload); - if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) return; + if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) { + logReload("suppressed", { path, why: "duplicate event" }); + return; + } lastEventIdentityRef.current = identity; const generation = ++generationRef.current; const result = await drainPendingChanges(); diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index 28eaeb88dc..f2eb0201d5 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -10,11 +10,7 @@ import { StudioFileConflictError, StudioSaveNetworkError, } from "../utils/studioSaveDiagnostics"; -import { - createStudioWriteToken, - markStudioWriteToken, - studioExpectedFileVersion, -} from "../utils/studioFileVersion"; +import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion"; import { useFileTree } from "./useFileTree"; import { useEditorSave } from "./useEditorSave"; @@ -124,8 +120,6 @@ export function useFileManager({ await retryStudioSave(async () => { // Each request gets its own receipt identity. If a committed request loses its response, // the retry can produce a second filesystem receipt that must be suppressed independently. - const writeToken = createStudioWriteToken(); - markStudioWriteToken(writeToken); let response: Response; try { response = await fetch( @@ -134,7 +128,7 @@ export function useFileManager({ method: "PUT", headers: { "Content-Type": "text/plain", - "X-Hyperframes-Write-Token": writeToken, + ...studioWriteHeaders(), ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }), }, body: content, diff --git a/packages/studio/src/hooks/useGroupCommits.ts b/packages/studio/src/hooks/useGroupCommits.ts index 6e838f1dbe..1ac24bd14e 100644 --- a/packages/studio/src/hooks/useGroupCommits.ts +++ b/packages/studio/src/hooks/useGroupCommits.ts @@ -5,6 +5,7 @@ import { type DomEditCommitBaseParams, } from "../utils/studioFileHistory"; import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; interface UseGroupCommitsParams extends DomEditCommitBaseParams { /** Resync the SDK session after a server-side write (the wrapper/unwrap changes @@ -75,7 +76,7 @@ async function commitStructuralMutation( `/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(body), }, ); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index b4b7d6f636..f1a21a3afe 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -36,6 +36,7 @@ import { useGsapSaveFailureTelemetry, useSafeGsapCommitMutation, } from "./useSafeGsapCommitMutation"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; async function mutateGsapScript( projectId: string, @@ -46,7 +47,7 @@ async function mutateGsapScript( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(mutation), }, ); @@ -65,7 +66,7 @@ async function mutateGsapScriptBatch( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ mutations }), }, ); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index bf846f06fe..10f0da7dee 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -36,6 +36,7 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture"; import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover"; import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes"; import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; type TimelineMoveUpdates = Pick & { stackingReorder?: TimelineStackingReorderIntent | null; @@ -412,7 +413,7 @@ export function useTimelineEditing({ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ target: patchTarget }), }, ); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 4ac8e6be0e..b53efd3f36 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -4,6 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect"; import { usePlaybackKeyboard } from "./usePlaybackKeyboard"; import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks"; import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop"; +import { logReload } from "../../utils/reloadDebug"; export type { ClipManifestClip } from "../lib/playbackTypes"; export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter"; @@ -445,6 +446,7 @@ export function useTimelinePlayer() { const refreshPlayer = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; + logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack })); saveSeekPosition(); // Hide the iframe across the full reload so the user never sees the reloading // document's RAW DOM (every clip stacked and visible) in the window between the diff --git a/packages/studio/src/utils/razorSplitTransaction.test.ts b/packages/studio/src/utils/razorSplitTransaction.test.ts index e942dc188e..567bbffe39 100644 --- a/packages/studio/src/utils/razorSplitTransaction.test.ts +++ b/packages/studio/src/utils/razorSplitTransaction.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { TimelineElement } from "../player"; import { buildAtomicCutIntents, runAtomicCutTransaction } from "./razorSplitTransaction"; +import { consumeStudioWriteToken, resetStudioWriteTokens } from "./studioFileVersion"; const element = (over: Partial = {}): TimelineElement => ({ id: "clip", @@ -14,7 +15,10 @@ const element = (over: Partial = {}): TimelineElement => ({ ...over, }); -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + resetStudioWriteTokens(); + vi.unstubAllGlobals(); +}); describe("buildAtomicCutIntents", () => { it("deduplicates runtime aliases but keeps repeated authored hosts distinct", () => { @@ -45,12 +49,16 @@ describe("buildAtomicCutIntents", () => { }); function installCutServer(options: { status?: number } = {}) { - const requests: Array<{ url: string; body?: unknown }> = []; + const requests: Array<{ url: string; body?: unknown; headers?: HeadersInit }> = []; vi.stubGlobal( "fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); - requests.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + requests.push({ + url, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + headers: init?.headers, + }); if (url.includes("/files/")) { return new Response(JSON.stringify({ content: "before", version: '"v0"' }), { status: 200, @@ -109,6 +117,10 @@ describe("runAtomicCutTransaction", () => { "/api/projects/launch%2Fdemo/files/index.html", "/api/projects/launch%2Fdemo/file-mutations/split-batch", ]); + const splitRequest = requests.find((request) => request.url.includes("split-batch")); + const writeToken = new Headers(splitRequest?.headers).get("X-Hyperframes-Write-Token"); + expect(writeToken).toMatch(/^cut:/); + expect(consumeStudioWriteToken(writeToken)).toBe(true); expect(writeProjectFile).not.toHaveBeenCalled(); expect(recordEdit).toHaveBeenCalledWith({ label: "Split timeline clip", diff --git a/packages/studio/src/utils/razorSplitTransaction.ts b/packages/studio/src/utils/razorSplitTransaction.ts index b11e4e406c..19ab58abc8 100644 --- a/packages/studio/src/utils/razorSplitTransaction.ts +++ b/packages/studio/src/utils/razorSplitTransaction.ts @@ -3,6 +3,7 @@ import type { RecordEditInput } from "../hooks/timelineEditingHelpers"; import { buildPatchTarget } from "./timelineElementSplit"; import { serializeStudioFileMutations } from "./studioFileMutationCoordinator"; import { buildProjectApiPath } from "./projectRouting"; +import { markStudioWriteToken } from "./studioFileVersion"; type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise; @@ -117,6 +118,7 @@ async function requestAtomicCut( }); } const transactionToken = `cut:${crypto.randomUUID()}`; + markStudioWriteToken(transactionToken); const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), { method: "POST", headers: { diff --git a/packages/studio/src/utils/reloadDebug.ts b/packages/studio/src/utils/reloadDebug.ts new file mode 100644 index 0000000000..b884aa9f35 --- /dev/null +++ b/packages/studio/src/utils/reloadDebug.ts @@ -0,0 +1,30 @@ +// Preview full-reload diagnostics — grep [hf-reload]. Off by default; opt in per +// session with `localStorage.setItem("hf-reload-debug", "1")` (then reload). +// +// A full reload blanks the stage for ~100-300ms, so any reload the user did not +// ask for reads as a flash. These lines answer the only question that matters +// when one appears: who asked for it, and why the write that triggered it was +// not recognised as Studio's own. +let enabled: boolean | null = null; + +function isEnabled(): boolean { + if (enabled === null) { + try { + enabled = localStorage.getItem("hf-reload-debug") === "1"; + } catch { + enabled = false; + } + } + return enabled; +} + +export function logReload( + stage: string, + data: Record | (() => Record) = {}, +): void { + if (!isEnabled()) return; + const details = typeof data === "function" ? data() : data; + console.log( + `[hf-reload] ${JSON.stringify({ stage, t: Math.round(performance.now()), ...details })}`, + ); +} diff --git a/packages/studio/src/utils/studioFileVersion.ts b/packages/studio/src/utils/studioFileVersion.ts index 2737c069b0..685cb95924 100644 --- a/packages/studio/src/utils/studioFileVersion.ts +++ b/packages/studio/src/utils/studioFileVersion.ts @@ -48,6 +48,21 @@ export async function studioExpectedFileVersion( return versions.get(path); } -export function createStudioWriteToken(): string { +function createStudioWriteToken(): string { return globalThis.crypto.randomUUID(); } + +/** + * Headers that claim the write a mutation request is about to make as our own. + * + * The token is marked BEFORE the request goes out on purpose: the server writes + * the file and the watcher broadcasts it while the request is still in flight, so + * a token marked from the response can arrive after the echo it was meant to + * match. An unmatched echo reads as an external change and costs a full preview + * reload, which the user sees as a flash right after their own edit. + */ +export function studioWriteHeaders(): Record { + const token = createStudioWriteToken(); + markStudioWriteToken(token); + return { "X-Hyperframes-Write-Token": token }; +} diff --git a/packages/studio/src/utils/timelineCompositionInsert.ts b/packages/studio/src/utils/timelineCompositionInsert.ts index 144b6c4efb..275905a6d9 100644 --- a/packages/studio/src/utils/timelineCompositionInsert.ts +++ b/packages/studio/src/utils/timelineCompositionInsert.ts @@ -2,6 +2,7 @@ import { createStudioSaveHttpError } from "./studioSaveDiagnostics"; import { serializeStudioFileMutation } from "./studioFileMutationCoordinator"; import type { RecordEditInput } from "./studioFileHistory"; import { buildProjectApiPath } from "./projectRouting"; +import { studioWriteHeaders } from "./studioFileVersion"; interface TimelineCompositionInsertionResult { path: string; @@ -34,7 +35,7 @@ async function insertTimelineComposition(input: { ), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ sourcePath: input.sourcePath, start: input.start,