fix(studio): stop a Studio edit from reloading the preview (#3137)

* fix(studio): stop a Studio edit from reloading the preview as if it were external

Every mutation route wrote the file without leaving a write receipt, so the
watcher's broadcast of Studio's own edit arrived with no identity on it. The
external-change coordinator could not tell that echo from an agent or an editor
writing the file behind Studio's back, so it took the safe branch and did a full
iframe reload. That reload hides the stage for the length of the reload, which is
what the flash after a text edit was.

Every mutation write now goes through one helper that records the receipt, and
the client claims the write before the request goes out rather than after it: the
server writes and the watcher fires while the request is still in flight, so a
token marked from the response can arrive after the echo it was meant to match.

Reproduced in the browser before and after, with the reload path traced end to
end. Before, a patch-element write logged `token: null` then a reload from the
coordinator; after, the same write logs the token and `suppressed: own write
token`, with no reload.

Adds `hf-reload-debug` (localStorage, off by default) alongside the existing
`hf-resize-debug`: it records each file-change decision and its reason, plus the
stack of whoever asked for a full reload.

* fix(studio): claim the timeline and caption writes too, not just the DOM ones

The receipt only helps when the client marked the token it sent, and the GSAP
mutation writers never sent one. A drag commits through gsap-mutations, so the
server minted a token the client had never seen, the change came back looking
like someone else's, and the preview did the full reload the receipt was meant
to prevent.

Same one-line claim on both GSAP mutation writers, the timing sync's mutation
call, and the caption auto-save PUT.

The rollback call stays deliberately unclaimed and says why: it runs because a
mutation did not converge, so the preview is on bytes nobody can vouch for and
the reload is the point.

Verified live: a drag-shaped update-properties on the timeline now logs
`suppressed: own write token` with no reload, where it logged a coordinator
reload before.

* refactor(studio): keep timelineTimingSync under the size cap

Claiming the timeline writes pushed this file one line past the 600-line
gate. Same change as the branch made later, landed with the commit that
caused it.

* fix(studio): cover remaining write receipt paths

* fix(studio): preserve batch write receipts

* fix(cli): emit every file in a watcher burst
This commit is contained in:
Miguel Ángel
2026-08-09 19:10:02 -04:00
committed by GitHub
parent adb13ce125
commit bea32b8aae
24 changed files with 419 additions and 81 deletions
+34 -4
View File
@@ -1,12 +1,21 @@
import { EventEmitter } from "node:events"; 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 }; const mockWatcher = new EventEmitter() as EventEmitter & { close: () => void };
mockWatcher.close = vi.fn(); mockWatcher.close = vi.fn();
vi.mock("node:fs", () => ({ vi.mock("node:fs", async (importOriginal) => {
watch: vi.fn(() => mockWatcher), const original = await importOriginal<typeof import("node:fs")>();
})); return {
...original,
watch: vi.fn((_path: string, _options: unknown, onChange: WatchCallback) => {
mockWatcher.on("change", onChange);
return mockWatcher;
}),
};
});
const { shouldWatchProjectFile, createProjectWatcher } = await import("./fileWatcher.js"); const { shouldWatchProjectFile, createProjectWatcher } = await import("./fileWatcher.js");
@@ -30,6 +39,27 @@ describe("shouldWatchProjectFile", () => {
}); });
describe("createProjectWatcher", () => { 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 // Regression: fs.watch can fail asynchronously (e.g. EMFILE from exhausted
// OS watch handles) via an 'error' event, not a thrown exception. An // OS watch handles) via an 'error' event, not a thrown exception. An
// EventEmitter 'error' with no listener crashes the whole process — this // EventEmitter 'error' with no listener crashes the whole process — this
+10 -2
View File
@@ -34,6 +34,7 @@ export function shouldWatchProjectFile(filename: string): boolean {
export function createProjectWatcher(projectDir: string): ProjectWatcher { export function createProjectWatcher(projectDir: string): ProjectWatcher {
const listeners = new Set<FileChangeListener>(); const listeners = new Set<FileChangeListener>();
const pendingPaths = new Set<string>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let watcher: FSWatcher | null = null; let watcher: FSWatcher | null = null;
@@ -43,10 +44,16 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
const relativePath = filename.toString(); const relativePath = filename.toString();
if (!shouldWatchProjectFile(relativePath)) return; if (!shouldWatchProjectFile(relativePath)) return;
pendingPaths.add(relativePath);
if (debounceTimer) clearTimeout(debounceTimer); if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
for (const fn of listeners) { const changedPaths = [...pendingPaths];
fn(relativePath); pendingPaths.clear();
debounceTimer = null;
for (const changedPath of changedPaths) {
for (const fn of listeners) {
fn(changedPath);
}
} }
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
}); });
@@ -72,6 +79,7 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
}, },
close() { close() {
if (debounceTimer) clearTimeout(debounceTimer); if (debounceTimer) clearTimeout(debounceTimer);
pendingPaths.clear();
watcher?.close(); watcher?.close();
listeners.clear(); listeners.clear();
}, },
+9 -1
View File
@@ -30,6 +30,7 @@ import {
createProjectSignature, createProjectSignature,
createBackgroundRemovalJob, createBackgroundRemovalJob,
consumeFileWriteReceipt, consumeFileWriteReceipt,
fileContentVersion,
getMimeType, getMimeType,
type PreviewApiAdapter, type PreviewApiAdapter,
thumbnailDeviceScaleFactor, thumbnailDeviceScaleFactor,
@@ -752,7 +753,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
app.get("/api/events", (c) => { app.get("/api/events", (c) => {
return streamSSE(c, async (stream) => { return streamSSE(c, async (stream) => {
const listener = (path: string) => { 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 stream
.writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) }) .writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) })
.catch(() => {}); .catch(() => {});
@@ -23,7 +23,25 @@ describe("file versions and write receipts", () => {
}; };
recordFileWriteReceipt("/project/index.html", receipt); recordFileWriteReceipt("/project/index.html", receipt);
expect(consumeFileWriteReceipt("/project/index.html")).toEqual(receipt); expect(consumeFileWriteReceipt("/project/index.html", receipt.version)).toEqual(receipt);
expect(consumeFileWriteReceipt("/project/index.html")).toBeNull(); 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);
}); });
}); });
@@ -32,13 +32,17 @@ export function recordFileWriteReceipt(absPath: string, receipt: FileWriteReceip
receipts.set(absPath, current); receipts.set(absPath, current);
} }
/** Attach one API write's identity to the corresponding filesystem-watch echo. */ /** Attach one API write's identity to the watcher echo for its exact bytes. */
export function consumeFileWriteReceipt(absPath: string): FileWriteReceipt | null { export function consumeFileWriteReceipt(
absPath: string,
expectedVersion: string,
): FileWriteReceipt | null {
const now = Date.now(); const now = Date.now();
const current = (receipts.get(absPath) ?? []).filter( const current = (receipts.get(absPath) ?? []).filter(
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS, (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); if (current.length > 0) receipts.set(absPath, current);
else receipts.delete(absPath); else receipts.delete(absPath);
if (!receipt) return null; if (!receipt) return null;
+128 -16
View File
@@ -65,10 +65,18 @@ function createAdapter(projectDir: string): StudioApiAdapter {
}; };
} }
function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Promise<Response> { function postElementPatchBatch(
app: Hono,
file: string,
patches: unknown[],
writeToken?: string,
): Promise<Response> {
return app.request(`http://localhost/projects/demo/file-mutations/patch-elements-batch/${file}`, { return app.request(`http://localhost/projects/demo/file-mutations/patch-elements-batch/${file}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...(writeToken ? { "X-Hyperframes-Write-Token": writeToken } : {}),
},
body: JSON.stringify({ patches }), body: JSON.stringify({ patches }),
}); });
} }
@@ -76,10 +84,14 @@ function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Pro
function postElementPatchBatches( function postElementPatchBatches(
app: Hono, app: Hono,
batches: Array<{ sourceFile: string; patches: unknown[] }>, batches: Array<{ sourceFile: string; patches: unknown[] }>,
writeToken?: string,
): Promise<Response> { ): Promise<Response> {
return app.request("http://localhost/projects/demo/file-mutations/patch-element-batches", { return app.request("http://localhost/projects/demo/file-mutations/patch-element-batches", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...(writeToken ? { "X-Hyperframes-Write-Token": writeToken } : {}),
},
body: JSON.stringify({ batches }), body: JSON.stringify({ batches }),
}); });
} }
@@ -119,7 +131,10 @@ describe("registerFileRoutes", () => {
const insert = (expectedVersion: string) => const insert = (expectedVersion: string) =>
app.request("http://localhost/projects/demo/file-mutations/insert-composition/index.html", { app.request("http://localhost/projects/demo/file-mutations/insert-composition/index.html", {
method: "POST", 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 }), 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('data-duration="7"');
expect(result.after).toContain(`id="${result.hostId}"`); expect(result.after).toContain(`id="${result.hostId}"`);
expect(result.version).toBe(fileContentVersion(result.after)); 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 committed = result.after;
const stale = await insert(fileContentVersion(before)); const stale = await insert(fileContentVersion(before));
@@ -366,7 +386,7 @@ describe("registerFileRoutes", () => {
expect(payload.version).toBe(fileContentVersion("after")); expect(payload.version).toBe(fileContentVersion("after"));
expect(payload.writeToken).toBe("studio-write-1"); expect(payload.writeToken).toBe("studio-write-1");
expect(response.headers.get("etag")).toBe(payload.version); 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", path: "index.html",
version: payload.version, version: payload.version,
writeToken: "studio-write-1", writeToken: "studio-write-1",
@@ -425,6 +445,39 @@ describe("registerFileRoutes", () => {
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After"); 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", '<div id="title">Before</div>');
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 () => { it("applies an ordered element patch batch with one file write", async () => {
const projectDir = createProjectDir(); const projectDir = createProjectDir();
const original = const original =
@@ -433,16 +486,21 @@ describe("registerFileRoutes", () => {
const app = new Hono(); const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir)); registerFileRoutes(app, createAdapter(projectDir));
const response = await postElementPatchBatch(app, "index.html", [ const response = await postElementPatchBatch(
{ app,
target: { id: "back" }, "index.html",
operations: [{ type: "inline-style", property: "z-index", value: "2" }], [
}, {
{ target: { id: "back" },
target: { id: "front" }, operations: [{ type: "inline-style", property: "z-index", value: "2" }],
operations: [{ type: "inline-style", property: "z-index", value: "1" }], },
}, {
]); target: { id: "front" },
operations: [{ type: "inline-style", property: "z-index", value: "1" }],
},
],
"studio-layer-order-1",
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const payload = (await response.json()) as { const payload = (await response.json()) as {
changed?: boolean; changed?: boolean;
@@ -458,6 +516,12 @@ describe("registerFileRoutes", () => {
expect(payload.content).toContain('id="back" style="z-index: 2"'); expect(payload.content).toContain('id="back" style="z-index: 2"');
expect(payload.content).toContain('id="front" style="z-index: 1"'); expect(payload.content).toContain('id="front" style="z-index: 1"');
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(original); 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); expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(1);
}); });
@@ -519,6 +583,52 @@ describe("registerFileRoutes", () => {
expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false); 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"), '<div id="index">Before</div>');
writeFileSync(join(projectDir, "scene.html"), '<div id="scene">Before</div>');
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 () => { it("refuses every file when one batch contains an unmatched target", async () => {
const projectDir = createProjectDir(); const projectDir = createProjectDir();
const indexOriginal = '<div id="present" style="z-index: 1">Present</div>'; const indexOriginal = '<div id="present" style="z-index: 1">Present</div>';
@@ -732,7 +842,9 @@ describe("registerFileRoutes", () => {
expect(payload.files[0].after).toContain('id="a-split"'); expect(payload.files[0].after).toContain('id="a-split"');
expect(payload.files[0].after).toContain('id="b-split"'); expect(payload.files[0].after).toContain('id="b-split"');
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(payload.files[0].after); 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", path: "index.html",
version: payload.files[0].version, version: payload.files[0].version,
writeToken: "cut-test", writeToken: "cut-test",
+105 -27
View File
@@ -112,6 +112,7 @@ interface RouteContext {
param: (name: string) => string; param: (name: string) => string;
path: string; path: string;
query: (name: string) => string | undefined; query: (name: string) => string | undefined;
header: (name: string) => string | undefined;
}; };
header: (name: string, value: string) => void; header: (name: string, value: string) => void;
json: (data: unknown, status?: number) => Response; json: (data: unknown, status?: number) => Response;
@@ -399,6 +400,69 @@ export function commitElementPatchBatches(
return { durable: true, files }; return { durable: true, files };
} }
function commitElementPatchBatchesWithReceipts(
c: RouteContext,
projectDir: string,
batches: ElementPatchBatchRequest[],
): ReturnType<typeof commitElementPatchBatches> {
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. */ /** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */
function writeIfChanged( function writeIfChanged(
c: RouteContext, c: RouteContext,
@@ -411,15 +475,13 @@ function writeIfChanged(
if (next === original) { if (next === original) {
return c.json({ ok: true, changed: false, content: original, path: filePath }); return c.json({ ok: true, changed: false, content: original, path: filePath });
} }
const backup = snapshotBeforeWrite(projectDir, absPath); const { backupPath } = writeMutationResult(c, projectDir, filePath, absPath, next);
if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`);
writeFileSync(absPath, next, "utf-8");
return c.json({ return c.json({
ok: true, ok: true,
changed: true, changed: true,
content: next, content: next,
path: filePath, 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); return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409);
} }
if (changed) { if (changed) {
const backup = snapshotBeforeWrite(res.project.dir, res.absPath); backupPath = writeMutationResult(
if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`); c,
backupPath = backupPathForResponse(res.project.dir, backup.backupPath); res.project.dir,
writeFileSync(res.absPath, newHtml, "utf-8"); res.filePath,
res.absPath,
newHtml,
).backupPath;
} }
const responsePayload: Record<string, unknown> = { const responsePayload: Record<string, unknown> = {
@@ -2388,10 +2453,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500); if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
writeFileSync(ctx.absPath, insertion.html, "utf-8"); const { version, writeToken } = writeFileWithReceipt(
const version = fileContentVersion(insertion.html); c,
const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token")); ctx.filePath,
recordFileWriteReceipt(ctx.absPath, { path: ctx.filePath, version, writeToken }); ctx.absPath,
insertion.html,
);
c.header("ETag", version); c.header("ETag", version);
return c.json({ return c.json({
ok: true, ok: true,
@@ -2623,10 +2690,13 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
version, version,
}); });
} }
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); const { version, backupPath } = writeMutationResult(
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); c,
writeFileSync(ctx.absPath, result.html, "utf-8"); ctx.project.dir,
const version = fileContentVersion(result.html); ctx.filePath,
ctx.absPath,
result.html,
);
c.header("ETag", version); c.header("ETag", version);
return c.json({ return c.json({
ok: true, ok: true,
@@ -2635,7 +2705,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
newId: result.newId, newId: result.newId,
path: ctx.filePath, path: ctx.filePath,
version, version,
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), backupPath,
}); });
}); });
@@ -2676,16 +2746,20 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
path: ctx.filePath, path: ctx.filePath,
}); });
} }
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); const { backupPath } = writeMutationResult(
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); c,
writeFileSync(ctx.absPath, patched, "utf-8"); ctx.project.dir,
ctx.filePath,
ctx.absPath,
patched,
);
return c.json({ return c.json({
ok: true, ok: true,
changed: true, changed: true,
matched, matched,
content: patched, content: patched,
path: ctx.filePath, 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); const unsafeFields = findUnsafeElementPatchBatchValues(body.batches);
if (unsafeFields.length > 0) return rejectUnsafeMutationValues(c, unsafeFields); 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) { if ("error" in result) {
return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile); return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile);
} }
@@ -2735,7 +2809,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
return rejectUnsafeMutationValues(c, unsafeFields); return rejectUnsafeMutationValues(c, unsafeFields);
} }
const result = commitElementPatchBatches(ctx.project.dir, [batch]); const result = commitElementPatchBatchesWithReceipts(c, ctx.project.dir, [batch]);
if ("error" in result) { if ("error" in result) {
return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile); 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, result.error === "grouped elements must share a single parent" ? 422 : 400,
); );
} }
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); const { backupPath } = writeMutationResult(
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); c,
writeFileSync(ctx.absPath, result.html, "utf-8"); ctx.project.dir,
ctx.filePath,
ctx.absPath,
result.html,
);
return c.json({ return c.json({
ok: true, ok: true,
changed: true, changed: true,
groupId: result.groupId, groupId: result.groupId,
content: result.html, content: result.html,
path: ctx.filePath, path: ctx.filePath,
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), backupPath,
}); });
}); });
@@ -3,6 +3,7 @@ import { useCaptionStore } from "../store";
import { useMountEffect } from "../../hooks/useMountEffect"; import { useMountEffect } from "../../hooks/useMountEffect";
import { trackEvent } from "../../telemetry/client"; import { trackEvent } from "../../telemetry/client";
import type { CaptionStyle } from "../types"; import type { CaptionStyle } from "../types";
import { studioWriteHeaders } from "../../utils/studioFileVersion";
interface CaptionOverrideEntry { interface CaptionOverrideEntry {
wordId?: string; wordId?: string;
@@ -77,7 +78,7 @@ export function useCaptionSync(projectId: string | null) {
fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, { fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "text/plain" }, headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
body: JSON.stringify(overrides, null, 2), body: JSON.stringify(overrides, null, 2),
}).catch((error: unknown) => { }).catch((error: unknown) => {
// Caption auto-save is a data-loss path; surface failures via telemetry // Caption auto-save is a data-loss path; surface failures via telemetry
@@ -3,6 +3,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
export { PROPERTY_DEFAULTS } from "./gsapShared"; export { PROPERTY_DEFAULTS } from "./gsapShared";
import { idSelector, matchesExactlyOne } 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 * 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)}`, `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify(patchBody), body: JSON.stringify(patchBody),
}, },
); );
@@ -7,6 +7,7 @@ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftR
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers"; import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
import type { RecordEditInput } from "../utils/studioFileHistory"; import type { RecordEditInput } from "../utils/studioFileHistory";
import { patchDocumentRootDuration } from "./timelineEditingGsap"; import { patchDocumentRootDuration } from "./timelineEditingGsap";
import { studioWriteHeaders } from "../utils/studioFileVersion";
class GsapPreviewConvergenceError extends Error {} class GsapPreviewConvergenceError extends Error {}
class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {} class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
@@ -58,6 +59,8 @@ async function rollbackOwnedMutation(
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`, `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
{ {
method: "POST", 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" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expected, restore }), body: JSON.stringify({ expected, restore }),
}, },
@@ -156,7 +159,7 @@ async function postGsapMutation(
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`, `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify(mutation), body: JSON.stringify(mutation),
}, },
); );
@@ -33,6 +33,7 @@ import {
readErrorResponseBody, readErrorResponseBody,
} from "./useDomEditCommitsHelpers"; } from "./useDomEditCommitsHelpers";
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover"; import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
import { studioWriteHeaders } from "../utils/studioFileVersion";
interface RecordEditInput { interface RecordEditInput {
label: string; label: string;
kind: EditHistoryKind; kind: EditHistoryKind;
@@ -201,7 +202,7 @@ export function useDomEditCommits({
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify(patchBody), body: JSON.stringify(patchBody),
}, },
); );
@@ -1,6 +1,7 @@
import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics"; import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
import type { DomEditPatchBatch } from "./domEditCommitTypes"; import type { DomEditPatchBatch } from "./domEditCommitTypes";
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
import { studioWriteHeaders } from "../utils/studioFileVersion";
export function formatUnsafeFieldList(fields: Array<{ path: string }>): string { export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
return fields.map((field) => field.path).join(", "); 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`, `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body, body,
}, },
); );
@@ -21,6 +21,7 @@ import {
} from "../components/editor/useLayerRevealOverride"; } from "../components/editor/useLayerRevealOverride";
import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes"; import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover"; import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
import { studioWriteHeaders } from "../utils/studioFileVersion";
interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams { interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
/** Route delete through SDK when session resolves the hf-id. */ /** 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)}`, `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify({ target: patchTarget }), body: JSON.stringify({ target: patchTarget }),
}, },
); );
@@ -4,6 +4,7 @@ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage"; import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry"; import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
import { consumeStudioWriteToken } from "../utils/studioFileVersion"; import { consumeStudioWriteToken } from "../utils/studioFileVersion";
import { logReload } from "../utils/reloadDebug";
type ExternalChangeDrainResult = type ExternalChangeDrainResult =
| { status: "clean" } | { status: "clean" }
@@ -194,6 +195,7 @@ export function useExternalFileChangeCoordinator({
const reloadAcceptedGeneration = useCallback( const reloadAcceptedGeneration = useCallback(
(path: string) => { (path: string) => {
logReload("reload", { path, by: "external-change coordinator" });
reloadPreview(); reloadPreview();
reloadSdkSession(path); reloadSdkSession(path);
}, },
@@ -221,11 +223,22 @@ export function useExternalFileChangeCoordinator({
pendingTimelinePaths.delete(path); pendingTimelinePaths.delete(path);
const content = readFileChangeContent(payload); const content = readFileChangeContent(payload);
if (consumeStudioWriteToken(readFileChangeWriteToken(payload))) return; const token = readFileChangeWriteToken(payload);
if (content != null && isSelfWriteEcho(path, content)) return; 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); 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; lastEventIdentityRef.current = identity;
const generation = ++generationRef.current; const generation = ++generationRef.current;
const result = await drainPendingChanges(); const result = await drainPendingChanges();
+2 -8
View File
@@ -10,11 +10,7 @@ import {
StudioFileConflictError, StudioFileConflictError,
StudioSaveNetworkError, StudioSaveNetworkError,
} from "../utils/studioSaveDiagnostics"; } from "../utils/studioSaveDiagnostics";
import { import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
createStudioWriteToken,
markStudioWriteToken,
studioExpectedFileVersion,
} from "../utils/studioFileVersion";
import { useFileTree } from "./useFileTree"; import { useFileTree } from "./useFileTree";
import { useEditorSave } from "./useEditorSave"; import { useEditorSave } from "./useEditorSave";
@@ -124,8 +120,6 @@ export function useFileManager({
await retryStudioSave(async () => { await retryStudioSave(async () => {
// Each request gets its own receipt identity. If a committed request loses its response, // 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. // the retry can produce a second filesystem receipt that must be suppressed independently.
const writeToken = createStudioWriteToken();
markStudioWriteToken(writeToken);
let response: Response; let response: Response;
try { try {
response = await fetch( response = await fetch(
@@ -134,7 +128,7 @@ export function useFileManager({
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "text/plain", "Content-Type": "text/plain",
"X-Hyperframes-Write-Token": writeToken, ...studioWriteHeaders(),
...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }), ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
}, },
body: content, body: content,
+2 -1
View File
@@ -5,6 +5,7 @@ import {
type DomEditCommitBaseParams, type DomEditCommitBaseParams,
} from "../utils/studioFileHistory"; } from "../utils/studioFileHistory";
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing"; import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
import { studioWriteHeaders } from "../utils/studioFileVersion";
interface UseGroupCommitsParams extends DomEditCommitBaseParams { interface UseGroupCommitsParams extends DomEditCommitBaseParams {
/** Resync the SDK session after a server-side write (the wrapper/unwrap changes /** 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)}`, `/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify(body), body: JSON.stringify(body),
}, },
); );
@@ -36,6 +36,7 @@ import {
useGsapSaveFailureTelemetry, useGsapSaveFailureTelemetry,
useSafeGsapCommitMutation, useSafeGsapCommitMutation,
} from "./useSafeGsapCommitMutation"; } from "./useSafeGsapCommitMutation";
import { studioWriteHeaders } from "../utils/studioFileVersion";
async function mutateGsapScript( async function mutateGsapScript(
projectId: string, projectId: string,
@@ -46,7 +47,7 @@ async function mutateGsapScript(
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`, `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify(mutation), body: JSON.stringify(mutation),
}, },
); );
@@ -65,7 +66,7 @@ async function mutateGsapScriptBatch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`, `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify({ mutations }), body: JSON.stringify({ mutations }),
}, },
); );
@@ -36,6 +36,7 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover"; import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes"; import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics"; import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
import { studioWriteHeaders } from "../utils/studioFileVersion";
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & { type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null; stackingReorder?: TimelineStackingReorderIntent | null;
@@ -412,7 +413,7 @@ export function useTimelineEditing({
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify({ target: patchTarget }), body: JSON.stringify({ target: patchTarget }),
}, },
); );
@@ -4,6 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { usePlaybackKeyboard } from "./usePlaybackKeyboard"; import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks"; import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop"; import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
import { logReload } from "../../utils/reloadDebug";
export type { ClipManifestClip } from "../lib/playbackTypes"; export type { ClipManifestClip } from "../lib/playbackTypes";
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter"; export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
@@ -445,6 +446,7 @@ export function useTimelinePlayer() {
const refreshPlayer = useCallback(() => { const refreshPlayer = useCallback(() => {
const iframe = iframeRef.current; const iframe = iframeRef.current;
if (!iframe) return; if (!iframe) return;
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
saveSeekPosition(); saveSeekPosition();
// Hide the iframe across the full reload so the user never sees the reloading // 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 // document's RAW DOM (every clip stacked and visible) in the window between the
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../player"; import type { TimelineElement } from "../player";
import { buildAtomicCutIntents, runAtomicCutTransaction } from "./razorSplitTransaction"; import { buildAtomicCutIntents, runAtomicCutTransaction } from "./razorSplitTransaction";
import { consumeStudioWriteToken, resetStudioWriteTokens } from "./studioFileVersion";
const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({ const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({
id: "clip", id: "clip",
@@ -14,7 +15,10 @@ const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({
...over, ...over,
}); });
afterEach(() => vi.unstubAllGlobals()); afterEach(() => {
resetStudioWriteTokens();
vi.unstubAllGlobals();
});
describe("buildAtomicCutIntents", () => { describe("buildAtomicCutIntents", () => {
it("deduplicates runtime aliases but keeps repeated authored hosts distinct", () => { it("deduplicates runtime aliases but keeps repeated authored hosts distinct", () => {
@@ -45,12 +49,16 @@ describe("buildAtomicCutIntents", () => {
}); });
function installCutServer(options: { status?: number } = {}) { function installCutServer(options: { status?: number } = {}) {
const requests: Array<{ url: string; body?: unknown }> = []; const requests: Array<{ url: string; body?: unknown; headers?: HeadersInit }> = [];
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: string | URL | Request, init?: RequestInit) => { vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input); 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/")) { if (url.includes("/files/")) {
return new Response(JSON.stringify({ content: "before", version: '"v0"' }), { return new Response(JSON.stringify({ content: "before", version: '"v0"' }), {
status: 200, status: 200,
@@ -109,6 +117,10 @@ describe("runAtomicCutTransaction", () => {
"/api/projects/launch%2Fdemo/files/index.html", "/api/projects/launch%2Fdemo/files/index.html",
"/api/projects/launch%2Fdemo/file-mutations/split-batch", "/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(writeProjectFile).not.toHaveBeenCalled();
expect(recordEdit).toHaveBeenCalledWith({ expect(recordEdit).toHaveBeenCalledWith({
label: "Split timeline clip", label: "Split timeline clip",
@@ -3,6 +3,7 @@ import type { RecordEditInput } from "../hooks/timelineEditingHelpers";
import { buildPatchTarget } from "./timelineElementSplit"; import { buildPatchTarget } from "./timelineElementSplit";
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator"; import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
import { buildProjectApiPath } from "./projectRouting"; import { buildProjectApiPath } from "./projectRouting";
import { markStudioWriteToken } from "./studioFileVersion";
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>; type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
@@ -117,6 +118,7 @@ async function requestAtomicCut(
}); });
} }
const transactionToken = `cut:${crypto.randomUUID()}`; const transactionToken = `cut:${crypto.randomUUID()}`;
markStudioWriteToken(transactionToken);
const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), { const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), {
method: "POST", method: "POST",
headers: { headers: {
+30
View File
@@ -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<string, unknown> | (() => Record<string, unknown>) = {},
): void {
if (!isEnabled()) return;
const details = typeof data === "function" ? data() : data;
console.log(
`[hf-reload] ${JSON.stringify({ stage, t: Math.round(performance.now()), ...details })}`,
);
}
+16 -1
View File
@@ -48,6 +48,21 @@ export async function studioExpectedFileVersion(
return versions.get(path); return versions.get(path);
} }
export function createStudioWriteToken(): string { function createStudioWriteToken(): string {
return globalThis.crypto.randomUUID(); 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<string, string> {
const token = createStudioWriteToken();
markStudioWriteToken(token);
return { "X-Hyperframes-Write-Token": token };
}
@@ -2,6 +2,7 @@ import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
import { serializeStudioFileMutation } from "./studioFileMutationCoordinator"; import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
import type { RecordEditInput } from "./studioFileHistory"; import type { RecordEditInput } from "./studioFileHistory";
import { buildProjectApiPath } from "./projectRouting"; import { buildProjectApiPath } from "./projectRouting";
import { studioWriteHeaders } from "./studioFileVersion";
interface TimelineCompositionInsertionResult { interface TimelineCompositionInsertionResult {
path: string; path: string;
@@ -34,7 +35,7 @@ async function insertTimelineComposition(input: {
), ),
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify({ body: JSON.stringify({
sourcePath: input.sourcePath, sourcePath: input.sourcePath,
start: input.start, start: input.start,