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/3] 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/3] 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/3] 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 }), },