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/captions/hooks/useCaptionSync.ts b/packages/studio/src/captions/hooks/useCaptionSync.ts index 5fdbf80138..1b2037a0f4 100644 --- a/packages/studio/src/captions/hooks/useCaptionSync.ts +++ b/packages/studio/src/captions/hooks/useCaptionSync.ts @@ -3,6 +3,7 @@ import { useCaptionStore } from "../store"; import { useMountEffect } from "../../hooks/useMountEffect"; import { trackEvent } from "../../telemetry/client"; import type { CaptionStyle } from "../types"; +import { studioWriteHeaders } from "../../utils/studioFileVersion"; interface CaptionOverrideEntry { wordId?: string; @@ -77,7 +78,7 @@ export function useCaptionSync(projectId: string | null) { fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, { method: "PUT", - headers: { "Content-Type": "text/plain" }, + headers: { "Content-Type": "text/plain", ...studioWriteHeaders() }, body: JSON.stringify(overrides, null, 2), }).catch((error: unknown) => { // Caption auto-save is a data-loss path; surface failures via telemetry diff --git a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts index 8c6e45a020..4bca7d0e2c 100644 --- a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts +++ b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts @@ -3,6 +3,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes"; export { PROPERTY_DEFAULTS } from "./gsapShared"; import { idSelector, matchesExactlyOne } from "./gsapShared"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; /** * The selector to author a NEW tween against, minting an id on the element when @@ -119,7 +120,7 @@ export async function assignGsapTargetAutoIdIfNeeded({ `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(patchBody), }, ); diff --git a/packages/studio/src/hooks/timelineTimingSync.ts b/packages/studio/src/hooks/timelineTimingSync.ts index 919d259a90..aac58e926f 100644 --- a/packages/studio/src/hooks/timelineTimingSync.ts +++ b/packages/studio/src/hooks/timelineTimingSync.ts @@ -7,6 +7,7 @@ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftR import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers"; import type { RecordEditInput } from "../utils/studioFileHistory"; import { patchDocumentRootDuration } from "./timelineEditingGsap"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; class GsapPreviewConvergenceError extends Error {} class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {} @@ -58,6 +59,8 @@ async function rollbackOwnedMutation( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`, { method: "POST", + // Deliberately unclaimed: a rollback runs because a mutation did not + // converge, so let the restored file reload the preview. headers: { "Content-Type": "application/json" }, body: JSON.stringify({ expected, restore }), }, @@ -156,7 +159,7 @@ async function postGsapMutation( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(mutation), }, ); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 7a02be735d..deb5ae9e3c 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -33,6 +33,7 @@ import { readErrorResponseBody, } from "./useDomEditCommitsHelpers"; import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; interface RecordEditInput { label: string; kind: EditHistoryKind; @@ -201,7 +202,7 @@ export function useDomEditCommits({ `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(patchBody), }, ); diff --git a/packages/studio/src/hooks/useDomEditCommitsHelpers.ts b/packages/studio/src/hooks/useDomEditCommitsHelpers.ts index dd82aff581..a440c6e4ef 100644 --- a/packages/studio/src/hooks/useDomEditCommitsHelpers.ts +++ b/packages/studio/src/hooks/useDomEditCommitsHelpers.ts @@ -1,6 +1,7 @@ import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics"; import type { DomEditPatchBatch } from "./domEditCommitTypes"; import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; export function formatUnsafeFieldList(fields: Array<{ path: string }>): string { return fields.map((field) => field.path).join(", "); @@ -99,7 +100,7 @@ export async function patchElementBatches(projectId: string, batches: DomEditPat `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body, }, ); diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index 4e17c5af36..3df6bf402e 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -21,6 +21,7 @@ import { } from "../components/editor/useLayerRevealOverride"; import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes"; import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams { /** Route delete through SDK when session resolves the hf-id. */ @@ -115,7 +116,7 @@ export function useElementLifecycleOps({ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ target: patchTarget }), }, ); diff --git a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts index 4d16842859..b1de697b09 100644 --- a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts +++ b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts @@ -4,6 +4,7 @@ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics"; import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage"; import { isSelfWriteEcho } from "./sdkSelfWriteRegistry"; import { consumeStudioWriteToken } from "../utils/studioFileVersion"; +import { logReload } from "../utils/reloadDebug"; type ExternalChangeDrainResult = | { status: "clean" } @@ -194,6 +195,7 @@ export function useExternalFileChangeCoordinator({ const reloadAcceptedGeneration = useCallback( (path: string) => { + logReload("reload", { path, by: "external-change coordinator" }); reloadPreview(); reloadSdkSession(path); }, @@ -221,11 +223,22 @@ export function useExternalFileChangeCoordinator({ pendingTimelinePaths.delete(path); const content = readFileChangeContent(payload); - if (consumeStudioWriteToken(readFileChangeWriteToken(payload))) return; - if (content != null && isSelfWriteEcho(path, content)) return; + const token = readFileChangeWriteToken(payload); + logReload("file-change", { path, token: token ?? null, hasContent: content != null }); + if (consumeStudioWriteToken(token)) { + logReload("suppressed", { path, why: "own write token" }); + return; + } + if (content != null && isSelfWriteEcho(path, content)) { + logReload("suppressed", { path, why: "own content echo" }); + return; + } const identity = eventIdentity(path, payload); - if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) return; + if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) { + logReload("suppressed", { path, why: "duplicate event" }); + return; + } lastEventIdentityRef.current = identity; const generation = ++generationRef.current; const result = await drainPendingChanges(); diff --git a/packages/studio/src/hooks/useGroupCommits.ts b/packages/studio/src/hooks/useGroupCommits.ts index 6e838f1dbe..1ac24bd14e 100644 --- a/packages/studio/src/hooks/useGroupCommits.ts +++ b/packages/studio/src/hooks/useGroupCommits.ts @@ -5,6 +5,7 @@ import { type DomEditCommitBaseParams, } from "../utils/studioFileHistory"; import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; interface UseGroupCommitsParams extends DomEditCommitBaseParams { /** Resync the SDK session after a server-side write (the wrapper/unwrap changes @@ -75,7 +76,7 @@ async function commitStructuralMutation( `/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(body), }, ); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index b4b7d6f636..f1a21a3afe 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -36,6 +36,7 @@ import { useGsapSaveFailureTelemetry, useSafeGsapCommitMutation, } from "./useSafeGsapCommitMutation"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; async function mutateGsapScript( projectId: string, @@ -46,7 +47,7 @@ async function mutateGsapScript( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(mutation), }, ); @@ -65,7 +66,7 @@ async function mutateGsapScriptBatch( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ mutations }), }, ); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index bf846f06fe..10f0da7dee 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -36,6 +36,7 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture"; import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover"; import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes"; import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; type TimelineMoveUpdates = Pick & { stackingReorder?: TimelineStackingReorderIntent | null; @@ -412,7 +413,7 @@ export function useTimelineEditing({ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ target: patchTarget }), }, ); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 4ac8e6be0e..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,