From 1f4360ae81351e78be13fb07327a62bfd1473a06 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 7 Aug 2026 18:27:47 -0700 Subject: [PATCH 1/6] 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. --- .../studio-server/src/routes/files.test.ts | 32 +++++++ packages/studio-server/src/routes/files.ts | 84 ++++++++++++++----- .../src/hooks/gsapScriptCommitHelpers.ts | 3 +- .../studio/src/hooks/useDomEditCommits.ts | 3 +- .../src/hooks/useDomEditCommitsHelpers.ts | 3 +- .../src/hooks/useElementLifecycleOps.ts | 3 +- .../hooks/useExternalFileChangeCoordinator.ts | 19 ++++- packages/studio/src/hooks/useGroupCommits.ts | 3 +- .../studio/src/hooks/useTimelineEditing.ts | 3 +- .../src/player/hooks/useTimelinePlayer.ts | 2 + packages/studio/src/utils/reloadDebug.ts | 26 ++++++ .../studio/src/utils/studioFileVersion.ts | 15 ++++ .../src/utils/timelineCompositionInsert.ts | 3 +- 13 files changed, 168 insertions(+), 31 deletions(-) create mode 100644 packages/studio/src/utils/reloadDebug.ts diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index ffd9f61a8c..65ee973f0a 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -425,6 +425,38 @@ 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); + expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({ + path: "index.html", + version: fileContentVersion(readFileSync(join(projectDir, "index.html"), "utf-8")), + writeToken: "studio-patch-1", + }); + }); + it("applies an ordered element patch batch with one file write", async () => { const projectDir = createProjectDir(); const original = diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 3ec64bbff1..63b71a4904 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,35 @@ export function commitElementPatchBatches( return { durable: true, files }; } +/** + * Write a mutation result, and leave behind the receipt that claims it. + * + * 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 writes through here so no route can forget. + */ +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}`); + writeFileSync(absPath, html, "utf-8"); + const version = fileContentVersion(html); + recordFileWriteReceipt(absPath, { + path: filePath, + version, + writeToken: createWriteToken(c.req.header("X-Hyperframes-Write-Token")), + }); + 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 +441,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 +1266,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 = { @@ -2623,10 +2654,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 +2669,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 +2710,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, }); }); @@ -2807,16 +2845,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/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/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/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/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..11fc23824f 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/reloadDebug.ts b/packages/studio/src/utils/reloadDebug.ts new file mode 100644 index 0000000000..40d03c068d --- /dev/null +++ b/packages/studio/src/utils/reloadDebug.ts @@ -0,0 +1,26 @@ +// 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 = {}): void { + if (!isEnabled()) return; + console.log( + `[hf-reload] ${JSON.stringify({ stage, t: Math.round(performance.now()), ...data })}`, + ); +} diff --git a/packages/studio/src/utils/studioFileVersion.ts b/packages/studio/src/utils/studioFileVersion.ts index 2737c069b0..db89a42166 100644 --- a/packages/studio/src/utils/studioFileVersion.ts +++ b/packages/studio/src/utils/studioFileVersion.ts @@ -51,3 +51,18 @@ export async function studioExpectedFileVersion( export 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, From 9597d106da727fb7bbfa1be9cd2132645be2f49e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 7 Aug 2026 18:55:37 -0700 Subject: [PATCH 2/6] 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. --- packages/studio/src/captions/hooks/useCaptionSync.ts | 3 ++- packages/studio/src/hooks/timelineTimingSync.ts | 6 +++++- packages/studio/src/hooks/useGsapScriptCommits.ts | 5 +++-- 3 files changed, 10 insertions(+), 4 deletions(-) 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/timelineTimingSync.ts b/packages/studio/src/hooks/timelineTimingSync.ts index 919d259a90..5e8cd5997f 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,9 @@ 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 the preview is on bytes nobody can vouch for; let the + // restored file reload it rather than suppressing that as our own write. headers: { "Content-Type": "application/json" }, body: JSON.stringify({ expected, restore }), }, @@ -156,7 +160,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/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 }), }, ); From 6060bf1a72d0fdd64c3855b13195407d8ed82f08 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 9 Aug 2026 03:09:04 -0400 Subject: [PATCH 3/6] 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. --- packages/studio/src/hooks/timelineTimingSync.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/hooks/timelineTimingSync.ts b/packages/studio/src/hooks/timelineTimingSync.ts index 5e8cd5997f..aac58e926f 100644 --- a/packages/studio/src/hooks/timelineTimingSync.ts +++ b/packages/studio/src/hooks/timelineTimingSync.ts @@ -59,9 +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 the preview is on bytes nobody can vouch for; let the - // restored file reload it rather than suppressing that as our own write. + // 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 }), }, From 0ef44138f1819a182be91b9eb6de09951942fbfc Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 9 Aug 2026 12:20:22 -0400 Subject: [PATCH 4/6] fix(studio): cover remaining write receipt paths --- .../studio-server/src/routes/files.test.ts | 10 +++++- packages/studio-server/src/routes/files.ts | 32 ++++++++++++------- packages/studio/src/hooks/useFileManager.ts | 10 ++---- .../src/player/hooks/useTimelinePlayer.ts | 2 +- .../src/utils/razorSplitTransaction.test.ts | 18 +++++++++-- .../studio/src/utils/razorSplitTransaction.ts | 2 ++ packages/studio/src/utils/reloadDebug.ts | 8 +++-- .../studio/src/utils/studioFileVersion.ts | 2 +- 8 files changed, 57 insertions(+), 27 deletions(-) diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 65ee973f0a..65c5a5828e 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -119,7 +119,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 +134,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"))).toEqual({ + path: "index.html", + version: result.version, + writeToken: "studio-insert-1", + }); const committed = result.after; const stale = await insert(fileContentVersion(before)); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 63b71a4904..ca0026c275 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -410,6 +410,20 @@ export function commitElementPatchBatches( * the stage for a few hundred milliseconds right after the user typed. Every * mutation route writes through here so no route can forget. */ +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. + const version = fileContentVersion(html); + const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token")); + recordFileWriteReceipt(absPath, { path: filePath, version, writeToken }); + return { version, writeToken }; +} + function writeMutationResult( c: RouteContext, projectDir: string, @@ -419,13 +433,7 @@ function writeMutationResult( ): { backupPath: string | null; version: string } { const backup = snapshotBeforeWrite(projectDir, absPath); if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`); - writeFileSync(absPath, html, "utf-8"); - const version = fileContentVersion(html); - recordFileWriteReceipt(absPath, { - path: filePath, - version, - writeToken: createWriteToken(c.req.header("X-Hyperframes-Write-Token")), - }); + const { version } = writeFileWithReceipt(c, filePath, absPath, html); return { backupPath: backupPathForResponse(projectDir, backup.backupPath), version }; } @@ -2419,10 +2427,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, 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/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 11fc23824f..b53efd3f36 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -446,7 +446,7 @@ export function useTimelinePlayer() { const refreshPlayer = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; - logReload("refreshPlayer", { stack: new Error("refreshPlayer").stack }); + 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 index 40d03c068d..b884aa9f35 100644 --- a/packages/studio/src/utils/reloadDebug.ts +++ b/packages/studio/src/utils/reloadDebug.ts @@ -18,9 +18,13 @@ function isEnabled(): boolean { return enabled; } -export function logReload(stage: string, data: Record = {}): void { +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()), ...data })}`, + `[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 db89a42166..685cb95924 100644 --- a/packages/studio/src/utils/studioFileVersion.ts +++ b/packages/studio/src/utils/studioFileVersion.ts @@ -48,7 +48,7 @@ export async function studioExpectedFileVersion( return versions.get(path); } -export function createStudioWriteToken(): string { +function createStudioWriteToken(): string { return globalThis.crypto.randomUUID(); } From 4d2a48ae64be3c25a9eaf8dd27221eb30c30ca48 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 9 Aug 2026 13:55:42 -0400 Subject: [PATCH 5/6] fix(studio): preserve batch write receipts --- packages/cli/src/server/studioServer.ts | 10 +- .../src/helpers/fileVersion.test.ts | 22 +++- .../studio-server/src/helpers/fileVersion.ts | 10 +- .../studio-server/src/routes/files.test.ts | 108 +++++++++++++++--- packages/studio-server/src/routes/files.ts | 40 +++++-- 5 files changed, 159 insertions(+), 31 deletions(-) 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 65c5a5828e..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 }), }); } @@ -134,7 +146,7 @@ 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"))).toEqual({ + expect(consumeFileWriteReceipt(join(projectDir, "index.html"), result.version)).toEqual({ path: "index.html", version: result.version, writeToken: "studio-insert-1", @@ -374,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", @@ -458,9 +470,10 @@ describe("registerFileRoutes", () => { ); expect(response.status).toBe(200); - expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({ + const version = fileContentVersion(readFileSync(join(projectDir, "index.html"), "utf-8")); + expect(consumeFileWriteReceipt(join(projectDir, "index.html"), version)).toEqual({ path: "index.html", - version: fileContentVersion(readFileSync(join(projectDir, "index.html"), "utf-8")), + version, writeToken: "studio-patch-1", }); }); @@ -473,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; @@ -498,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); }); @@ -559,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
'; @@ -772,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 ca0026c275..dbe2f9b916 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -400,30 +400,56 @@ 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; +} + /** - * Write a mutation result, and leave behind the receipt that claims it. + * 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 writes through here so no route can forget. + * mutation route records through here so no route can forget. */ -function writeFileWithReceipt( +function recordMutationReceipt( 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. 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, @@ -2755,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); } @@ -2783,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); } From f1de808e7c518d8828488da781291fb78638d21e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 9 Aug 2026 14:32:25 -0400 Subject: [PATCH 6/6] fix(cli): emit every file in a watcher burst --- packages/cli/src/server/fileWatcher.test.ts | 38 ++++++++++++++++++--- packages/cli/src/server/fileWatcher.ts | 12 +++++-- 2 files changed, 44 insertions(+), 6 deletions(-) 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(); },