fix(studio): gracefully handle visual edits on runtime-generated elements#1150
Conversation
…ents When the DOM patcher can't find an element in source HTML (e.g. elements created by JavaScript at runtime like #arrows-svg, .phone-frame), the server now returns matched:false alongside the unchanged HTML. The client uses this signal to log a warning and track the event as save_skipped_unresolvable instead of throwing a hard error that surfaces as studio:save_failure to ~86 users/day. Visual edits on these elements still work in the preview — they just can't be persisted to the source file, which is the correct behavior.
jrusso1020
left a comment
There was a problem hiding this comment.
Reviewed at a1163263. Materially LGTM — the tagged-union return shape is the right fix and the no-throw user path is the user-visible win. A couple of small observations; no blockers.
Fix shape is right
The "matched" signal makes the patch helper honest about what it observed:
- Old:
stringreturn, indistinguishable between "target not found" (genuine miss) and "target found, ops were no-ops" (e.g. setting a style to its current value). Caller had to comparebefore === afterand could only infer "something didn't change." - New:
{ html, matched }tagged return.matched: falseis the genuine miss; the route'schanged: boolean(before !== after) is the no-op signal. Caller can distinguish.
That distinction is what unblocks the route + hook fix. Before, the hook saw changed: false for both "patched element doesn't exist in source (runtime-generated, JS-cloned overlay, etc.)" and "patched element is in source but nothing changed (rare; usually means upstream computed the same value as current)." Now those are separable; only the runtime-generated case triggers save_skipped_unresolvable + the warn.
Verified end-to-end:
sourceMutation.ts:160-204— return type changed, every return site returns{ html, matched }. ✓studio-api/routes/files.ts:335-340— destructuresmatched, includes it in BOTH branches of the JSON response (changed=true and changed=false). ✓studio/src/hooks/useDomEditCommits.ts:173-191— branches onpatchData.matched === false, tracks + warns, returns gracefully. ✓- 37 test callsites updated for the new return shape, plus a new assertion
expect(matched).toBe(true)on the happy path andexpect(matched).toBe(false)on the not-found path. ✓
Severity walk — user-visible improvement is real
Walking the next user action from the pre-fix state:
- User opens a composition with a GSAP-animated clone (e.g., copied an element 4× at runtime to make a particle field)
- Visual-edits one of the clones via Studio's editor (drag-resize, color change)
- Save fires → patch route can't find the cloned element in source HTML (it only exists in the runtime DOM)
- Old behavior: route returns
{ ok: true, changed: false }. Hook throwsUnable to patch {selector}. Editor surfaces a save-failure toast. User thinks Studio is broken. - User tries again. Same result. Files a bug.
So this isn't "non-destructive but annoying" — repeated false-failure toasts make users distrust the editor as a whole. Real UX bug; the diagnostic event the new code adds (save_skipped_unresolvable) is exactly what's needed for the team to find these compositions and decide whether to extend the persistence path or hide the edit affordance for runtime-only elements.
Small observations
- Hook test coverage missing for the new branch.
sourceMutation.test.tscovers thematched: falsereturn from the helper, butuseDomEditCommits.ts's new "warn + track + return" branch has no test. A hook unit test that supplies{ changed: false, matched: false }and assertsconsole.warnwas called +trackStudioEventwas called withsave_skipped_unresolvablewould pin the contract. Non-blocking — the logic is simple — but the dashboard correlation depends on this event being emitted correctly. - Telemetry payload — consider adding
composition_idorcomposition_urltosave_skipped_unresolvable. Right now you gettarget_id,target_selector,target_source_file. If the dashboard wants to see "which compositions trigger this most often" (to prioritize either fixing the persistence path or hiding the affordance), the composition would be the natural grouping. Source file is close but not always equivalent — e.g. sharedindex.htmlacross many compositions in a multi-comp project. Optional. - Idempotency of the new event. A user drag-resizing a runtime-generated element will fire
save_skipped_unresolvableon every commit cycle (every keyframe of the drag), potentially hundreds per session. That's a lot of identical events. Worth checking if your dashboard de-dupes by(session, target_id), or if Studio's commit-debounce already throttles this naturally. Napkin: 60 drag ticks/sec × 5 sec drag × 1 commit/tick = 300 events per drag. Not catastrophic but worth knowing the rate.
Verdict
LGTM. Fix shape is exactly right (tagged union), user-path improvement is meaningful, tests cover the helper change comprehensively. Hook-side test would pin the new branch nicely; telemetry payload could grow composition_id for dashboard pivoting; throttling worth a quick check. None are blockers. Stamp held — James gates.
— Rames Jusso (hyperframes)
vanceingalls
left a comment
There was a problem hiding this comment.
Review: fix(studio): gracefully handle visual edits on runtime-generated elements
CI: baseRefName is main — all required checks green. ✓
The design is sound. Runtime-generated elements were never persistable, so treating a failed lookup as a hard error was wrong from the start. The { html, matched } return shape is a clean, minimal API extension, the route threads the signal correctly, and the client branches clearly on matched === false vs. genuine no-op. All 37 sourceMutation unit tests updated; Typecheck green confirms no unhandled call sites of the old string return type.
Important — silent skip vs. user feedback on runtime elements
The old "Unable to patch" toast was misleading, agreed. But the new behavior goes the other direction: the user drags a runtime-generated element, sees the change apply optimistically in-preview (the return skips reloadPreview(), so the preview state sticks), saves — and the edit silently vanishes on next load or render. The console.warn is invisible to users and save_skipped_unresolvable telemetry helps you but not them.
For a fix motivated by ~86 errors/day, worth deciding whether a non-alarming info toast ("This element is generated at runtime and can't be saved") is better than pure silence — or at minimum, whether the optimistic preview should be reverted.
Important — no integration coverage on the changed behavior
All 37 tests exercise patchElementInHtml directly. Nothing covers the route correctly threading matched in the response body, or the client correctly branching on matched === false. A route-level integration test or msw-mocked unit test for persistDomEditOperations would give confidence the wire protocol matches. Acceptable to defer, but worth a follow-up ticket.
Nit — throttling under rapid drag
persistDomEditOperations is called on every RAF tick during a drag. For a runtime-generated element, that's potentially hundreds of save_skipped_unresolvable events per session. Consider debouncing or deduping the telemetry call (e.g. only log once per element ID per session).
Nit — inconsistent write-if-changed pattern in the route
The remove-element route uses the writeIfChanged() helper; the patch-element route now inlines equivalent logic (because writeIfChanged doesn't thread matched). Two patterns 30 lines apart — a comment or a writeIfChangedWithMatched() helper would prevent confusion.
LGTM. Tight scope, correct behavior, clean API extension. The UX gap (optimistic preview + silent discard) and throttling are worth addressing in a follow-up.
— Vai
…ontext Deduplicate telemetry — fire once per selector per session instead of on every RAF tick during drag. Add composition path to the event payload for dashboard pivoting.
Summary
patchElementInHtml()now returns{ html, matched }so callers can distinguish "element not found in source" from "element found but patch was a no-op"matchedflag in its JSON responsesave_skipped_unresolvablefor diagnostics without alarming userssourceMutationtests updated for the new return shapeTest plan
save_skipped_unresolvableevents appear in telemetry for runtime elementsbun run testinpackages/core— all sourceMutation tests pass