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
- Install a caption component that emits
.caption-group (e.g. npx hyperframes add caption-weight-shift) and reference it from the composition
npx hyperframes preview, open the Studio
- Wait ~1 s after the composition loads — the red toast appears with no interaction
- DevTools → Network:
PUT /api/projects/<id>/files/caption-overrides.json → 428 precondition required
- 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
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.
- 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.
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.
Describe the bug
On-canvas caption editing cannot save. The Studio PUTs
caption-overrides.jsonwithout 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):studioWriteHeaders()(packages/studio/src/utils/studioFileVersion.ts) returns onlyX-Hyperframes-Write-Token— noIf-Match, noIf-None-Match.2. The server mandates one.
packages/studio-server/src/routes/files.ts,PUT /projects/:id/files/*: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.tspreflights a GET for the version, sendsIf-Match/If-None-Match: *, handles 409 and wraps the whole thing inretryStudioSave.If-Matchoccurs exactly once in the entirepackages/studiosource: that one line. There is nofetchwrapper that could be injecting it elsewhere.3. It fires with no user edit.
packages/studio/src/captions/hooks/useCaptionDetection.tsactivates caption mode purely on.caption-groupexisting in the live DOM, then:The auto-save subscription in
useCaptionSync.tsguards on!state.isEditMode || state.model === prevModel. On thesetEditMode(true)transition both guards pass, because the earliersetModelreturned before assigningprevModel. So it armssetTimeout(save, 800)on activation. 800 ms later: PUT → 428 → toast.It then recurs:
useCaptionDetectioncallsretrySave?.()on every composition switch (save()has no pending/edit-mode guard) and thenreset(), which clearsdismissedso the next composition re-activates and fires again.There appear to be no tests for
useCaptionSyncinpackages/studio, which would explain how this shipped.Steps to reproduce
.caption-group(e.g.npx hyperframes add caption-weight-shift) and reference it from the compositionnpx hyperframes preview, open the StudioPUT /api/projects/<id>/files/caption-overrides.json→ 428 precondition requiredExpected 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;
Retrycalls the same brokensave(). 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
useCaptionSync.ts— adopt theuseFileManager.tsprotocol: preflight GET forversion, sendIf-Match(orIf-None-Match: *when the file does not exist yet), handle 409, wrap inretryStudioSave.setEditMode(true)transition — setprevModelin the early-return path, or gate the subscription on an actual mutation.useCaptionDetection.ts— only flush on composition switch when there is a pending change.Possibly related, noticed while tracing
caption-overrides.jsonis a single project-root file keyed by a global word index, but the model is parsed from exactly one auto-picked sub-composition (useCaptionDetection.tspicks the first comp id/src containing"caption"), whilepackages/core/src/runtime/captionOverrides.tsapplies indices across.caption-group > spanin 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
Verified against the bundle actually served by
hyperframes previewon 0.8.15, not only against a source checkout: the servedassets/index-*.jscontains the same headers-less PUT, anddist/cli.jscontains the sameIf-Match→ 428 branch.