Skip to content

Studio caption saves always fail: caption-overrides.json is PUT without If-Match, server answers 428, no retry branch (toast also fires with no user edit) #3501

Description

@BenT034

Describe the bug

On-canvas caption editing cannot save. The Studio PUTs caption-overrides.json without a precondition header, and the files route requires one and answers 428. There is no retry-with-precondition branch anywhere in the caption path, so every save fails terminally and the red "Caption changes couldn't be saved" toast appears.

The toast also fires without the user editing anything — entering caption mode arms a save on its own — so in practice it shows up on more or less every session that opens a composition containing captions.

Root cause

1. The save omits If-Match. packages/studio/src/captions/hooks/useCaptionSync.ts (~:95):

fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, {
  method: "PUT",
  headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
  body: JSON.stringify(overrides, null, 2),
})
  .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); ... })
  .catch(... setSyncError("Caption changes couldn't be saved"))

studioWriteHeaders() (packages/studio/src/utils/studioFileVersion.ts) returns only X-Hyperframes-Write-Token — no If-Match, no If-None-Match.

2. The server mandates one. packages/studio-server/src/routes/files.ts, PUT /projects/:id/files/*:

const expectedVersion = c.req.header("If-Match")?.trim() ?? null;
const createOnly = c.req.header("If-None-Match")?.trim() === "*";
if (expectedVersion === null && !createOnly) {
  ... return c.json({ error: "precondition required", ... }, 428);
}

So the PUT is rejected 428 → !res.ok → toast. The ENOENT case takes the same branch, so deleting the file does not change anything.

Every other Studio writer does this correctly — packages/studio/src/hooks/useFileManager.ts preflights a GET for the version, sends If-Match / If-None-Match: *, handles 409 and wraps the whole thing in retryStudioSave. If-Match occurs exactly once in the entire packages/studio source: that one line. There is no fetch wrapper that could be injecting it elsewhere.

3. It fires with no user edit. packages/studio/src/captions/hooks/useCaptionDetection.ts activates caption mode purely on .caption-group existing in the live DOM, then:

store.setModel(model);        // isEditMode still false → the subscription early-returns
store.setSourceFilePath(srcPath);
store.setEditMode(true);      // subscription fires here
captionSync.loadOverrides();

The auto-save subscription in useCaptionSync.ts guards on !state.isEditMode || state.model === prevModel. On the setEditMode(true) transition both guards pass, because the earlier setModel returned before assigning prevModel. So it arms setTimeout(save, 800) on activation. 800 ms later: PUT → 428 → toast.

It then recurs: useCaptionDetection calls retrySave?.() on every composition switch (save() has no pending/edit-mode guard) and then reset(), which clears dismissed so the next composition re-activates and fires again.

There appear to be no tests for useCaptionSync in packages/studio, which would explain how this shipped.

Steps to reproduce

  1. Install a caption component that emits .caption-group (e.g. npx hyperframes add caption-weight-shift) and reference it from the composition
  2. npx hyperframes preview, open the Studio
  3. Wait ~1 s after the composition loads — the red toast appears with no interaction
  4. DevTools → Network: PUT /api/projects/<id>/files/caption-overrides.json428 precondition required
  5. Drag a caption word on canvas, reload — the edit is gone

Expected behavior

The caption save follows the same protocol as every other Studio write (preflight version → If-Match → handle 409 → retry), and does not fire at all when nothing has been edited.

Actual behavior

Every caption save returns 428 and is silently discarded; Retry calls the same broken save(). On-canvas caption overrides are effectively non-functional. Because the toast also appears unprompted, it reads as spurious noise — but it is telling the truth, which makes it worse: a user who dismisses it will lose real edits later.

Suggested fix

  1. useCaptionSync.ts — adopt the useFileManager.ts protocol: preflight GET for version, send If-Match (or If-None-Match: * when the file does not exist yet), handle 409, wrap in retryStudioSave.
  2. Do not arm a save on the bare setEditMode(true) transition — set prevModel in the early-return path, or gate the subscription on an actual mutation.
  3. useCaptionDetection.ts — only flush on composition switch when there is a pending change.

Possibly related, noticed while tracing

caption-overrides.json is a single project-root file keyed by a global word index, but the model is parsed from exactly one auto-picked sub-composition (useCaptionDetection.ts picks the first comp id/src containing "caption"), while packages/core/src/runtime/captionOverrides.ts applies indices across .caption-group > span in the whole document. In a project with more than one caption composition, overrides would be applied to the wrong words even once the save is fixed. Not verified end-to-end, since the save never succeeds.

Environment

Version          0.8.15 (latest)
Node.js          v22.23.2 (win32 x64)
OS               Windows 11 Home 10.0.26200

Verified against the bundle actually served by hyperframes preview on 0.8.15, not only against a source checkout: the served assets/index-*.js contains the same headers-less PUT, and dist/cli.js contains the same If-Match → 428 branch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions