From a116326391b47f5637244ef54559e14d64713b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 16:13:19 -0400 Subject: [PATCH 1/2] fix(studio): gracefully handle visual edits on runtime-generated elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../studio-api/helpers/sourceMutation.test.ts | 68 ++++++++++--------- .../src/studio-api/helpers/sourceMutation.ts | 9 ++- packages/core/src/studio-api/routes/files.ts | 12 ++-- .../studio/src/hooks/useDomEditCommits.ts | 14 +++- 4 files changed, 64 insertions(+), 39 deletions(-) diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 5d1e1eb94..8774fffcd 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -46,16 +46,17 @@ describe("patchElementInHtml", () => { `; it("patches inline style by id", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "inline-style", property: "color", value: "red" }, ]); + expect(matched).toBe(true); expect(result).toMatch(/color:\s*red/); expect(result).toContain('id="hero"'); }); it("patches inline style by class selector", () => { - const result = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [ { type: "inline-style", property: "font-size", value: "72px" }, ]); @@ -63,7 +64,7 @@ describe("patchElementInHtml", () => { }); it("patches data attribute", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "attribute", property: "hf-studio-path-offset", value: "true" }, ]); @@ -71,7 +72,7 @@ describe("patchElementInHtml", () => { }); it("does not double data- prefix when property already has it", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "attribute", property: "data-hf-studio-path-offset", value: "true" }, ]); @@ -88,7 +89,7 @@ describe("patchElementInHtml", () => { "data-hf-studio-rotation", ]; for (const attr of attrs) { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "attribute", property: attr, value: "true" }, ]); expect(result).toContain(`${attr}="true"`); @@ -97,19 +98,19 @@ describe("patchElementInHtml", () => { }); it("removes attribute with data- prefix already present", () => { - const withAttr = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: withAttr } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "attribute", property: "data-hf-studio-path-offset", value: "true" }, ]); expect(withAttr).toContain('data-hf-studio-path-offset="true"'); - const removed = patchElementInHtml(withAttr, { id: "hero" }, [ + const { html: removed } = patchElementInHtml(withAttr, { id: "hero" }, [ { type: "attribute", property: "data-hf-studio-path-offset", value: null }, ]); expect(removed).not.toContain("hf-studio-path-offset"); }); it("patches html attribute", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "title", value: "greeting" }, ]); @@ -117,7 +118,7 @@ describe("patchElementInHtml", () => { }); it("patches text content", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "text-content", property: "", value: "New Title" }, ]); @@ -126,7 +127,7 @@ describe("patchElementInHtml", () => { }); it("applies multiple operations in one call", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "inline-style", property: "color", value: "blue" }, { type: "inline-style", property: "font-size", value: "96px" }, { type: "attribute", property: "hf-studio-path-offset", value: "true" }, @@ -138,16 +139,18 @@ describe("patchElementInHtml", () => { }); it("finds element by composition-id selector", () => { - const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [ - { type: "inline-style", property: "opacity", value: "0.5" }, - ]); + const { html: result } = patchElementInHtml( + FIXTURE, + { selector: '[data-composition-id="overlay"]' }, + [{ type: "inline-style", property: "opacity", value: "0.5" }], + ); expect(result).toMatch(/opacity:\s*0\.5/); }); it("finds element by class with selectorIndex", () => { const html = `
A
B
`; - const result = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [ + const { html: result } = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [ { type: "text-content", property: "", value: "Changed" }, ]); @@ -156,16 +159,17 @@ describe("patchElementInHtml", () => { expect(result).not.toContain(">B<"); }); - it("returns unchanged html when target not found", () => { - const result = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [ + it("returns unchanged html and matched:false when target not found", () => { + const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [ { type: "inline-style", property: "color", value: "red" }, ]); + expect(matched).toBe(false); expect(result).toBe(FIXTURE); }); it("removes inline style when value is null", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "inline-style", property: "font-size", value: null }, ]); @@ -173,16 +177,18 @@ describe("patchElementInHtml", () => { }); it("removes attribute when value is null", () => { - const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [ - { type: "html-attribute", property: "data-composition-src", value: null }, - ]); + const { html: result } = patchElementInHtml( + FIXTURE, + { selector: '[data-composition-id="overlay"]' }, + [{ type: "html-attribute", property: "data-composition-src", value: null }], + ); expect(result).not.toContain("data-composition-src"); }); it("patches fragment html without doctype", () => { const fragment = `
Title
`; - const result = patchElementInHtml(fragment, { id: "card" }, [ + const { html: result } = patchElementInHtml(fragment, { id: "card" }, [ { type: "inline-style", property: "padding", value: "16px" }, ]); @@ -190,7 +196,7 @@ describe("patchElementInHtml", () => { }); it("rejects event handler attributes", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "onload", value: "fetch('/evil')" }, ]); @@ -199,7 +205,7 @@ describe("patchElementInHtml", () => { }); it("rejects javascript: URLs in src", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "src", value: "javascript:alert(1)" }, ]); @@ -207,7 +213,7 @@ describe("patchElementInHtml", () => { }); it("allows aria-* and data-* attributes", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "aria-label", value: "greeting" }, { type: "html-attribute", property: "data-custom", value: "test" }, ]); @@ -217,7 +223,7 @@ describe("patchElementInHtml", () => { }); it("rejects srcdoc and formaction attributes", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "srcdoc", value: "" }, { type: "html-attribute", property: "formaction", value: "javascript:void(0)" }, ]); @@ -227,7 +233,7 @@ describe("patchElementInHtml", () => { }); it("rejects on* event handlers regardless of casing", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "onClick", value: "alert(1)" }, { type: "html-attribute", property: "ONERROR", value: "alert(2)" }, { type: "html-attribute", property: "onmouseover", value: "alert(3)" }, @@ -237,7 +243,7 @@ describe("patchElementInHtml", () => { }); it("rejects data:text/html URIs in src", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "src", @@ -249,7 +255,7 @@ describe("patchElementInHtml", () => { }); it("allows safe href values", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "href", value: "https://example.com" }, ]); @@ -257,7 +263,7 @@ describe("patchElementInHtml", () => { }); it("rejects javascript: in href", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "href", value: "javascript:alert(1)" }, ]); @@ -265,7 +271,7 @@ describe("patchElementInHtml", () => { }); it("allows legitimate form and media attributes", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "placeholder", value: "Enter text" }, { type: "html-attribute", property: "target", value: "_blank" }, { type: "html-attribute", property: "rel", value: "noopener" }, @@ -279,7 +285,7 @@ describe("patchElementInHtml", () => { }); it("rejects unknown/dangerous attributes", () => { - const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [ { type: "html-attribute", property: "xmlns", value: "http://evil.com" }, { type: "html-attribute", property: "background", value: "http://evil.com/bg.js" }, { type: "html-attribute", property: "dynsrc", value: "http://evil.com/vid.avi" }, diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 6c00041e7..6e934b2e5 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -160,10 +160,10 @@ export function patchElementInHtml( source: string, target: SourceMutationTarget, operations: PatchOperation[], -): string { +): { html: string; matched: boolean } { const { document, wrappedFragment } = parseSourceDocument(source); const el = findTargetElement(document, target); - if (!el || !isHTMLElement(el)) return source; + if (!el || !isHTMLElement(el)) return { html: source, matched: false }; const htmlEl = el as unknown as HTMLElement; for (const op of operations) { @@ -200,7 +200,10 @@ export function patchElementInHtml( } } - return wrappedFragment ? document.body.innerHTML || "" : document.toString(); + return { + html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), + matched: true, + }; } export function probeElementInSource(source: string, target: SourceMutationTarget): boolean { diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 5b0bdcc68..15bc71000 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -335,12 +335,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { } catch { return c.json({ error: "not found" }, 404); } - return writeIfChanged( - c, - ctx.absPath, + const { html: patched, matched } = patchElementInHtml( originalContent, - patchElementInHtml(originalContent, parsed.target, parsed.body.operations), + parsed.target, + parsed.body.operations, ); + if (patched === originalContent) { + return c.json({ ok: true, changed: false, matched, content: originalContent }); + } + writeFileSync(ctx.absPath, patched, "utf-8"); + return c.json({ ok: true, changed: true, matched, content: patched }); }); api.post("/projects/:id/file-mutations/probe-element/*", async (c) => { diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index d78e7b598..6e45642c2 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -173,11 +173,23 @@ export function useDomEditCommits({ const patchData = (await patchResponse.json()) as { ok?: boolean; changed?: boolean; + matched?: boolean; content?: string; }; if (!patchData.changed) { - throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`); + if (patchData.matched === false) { + trackStudioEvent("save_skipped_unresolvable", { + target_id: selection.id ?? undefined, + target_selector: selection.selector ?? undefined, + target_source_file: selection.sourceFile ?? undefined, + }); + console.warn( + `[studio] Element not found in source: ${selection.selector ?? selection.id ?? "selection"}. ` + + "This element may be generated at runtime and cannot be persisted.", + ); + } + return; } const patchedContent = From 649e3c800ddbfafe2516c6ef14b79c74f3ca83c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 16:41:34 -0400 Subject: [PATCH 2/2] fix(studio): throttle save_skipped_unresolvable and add composition context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../studio/src/hooks/useDomEditCommits.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 6e45642c2..e733a4cdd 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { usePlayerStore } from "../player"; import { FONT_EXT } from "../utils/mediaTypes"; import type { PatchOperation } from "../utils/sourcePatcher"; @@ -128,6 +128,8 @@ export function useDomEditCommits({ [fileTree, projectId, importedFontAssetsRef], ); + const reportedUnresolvableRef = useRef(new Set()); + // fallow-ignore-next-line complexity const persistDomEditOperations: PersistDomEditOperations = useCallback( async (selection, operations, options) => { @@ -179,15 +181,20 @@ export function useDomEditCommits({ if (!patchData.changed) { if (patchData.matched === false) { - trackStudioEvent("save_skipped_unresolvable", { - target_id: selection.id ?? undefined, - target_selector: selection.selector ?? undefined, - target_source_file: selection.sourceFile ?? undefined, - }); - console.warn( - `[studio] Element not found in source: ${selection.selector ?? selection.id ?? "selection"}. ` + - "This element may be generated at runtime and cannot be persisted.", - ); + const targetKey = selection.selector ?? selection.id ?? "selection"; + if (!reportedUnresolvableRef.current.has(targetKey)) { + reportedUnresolvableRef.current.add(targetKey); + trackStudioEvent("save_skipped_unresolvable", { + target_id: selection.id ?? undefined, + target_selector: selection.selector ?? undefined, + target_source_file: selection.sourceFile ?? undefined, + composition: activeCompPath ?? undefined, + }); + console.warn( + `[studio] Element not found in source: ${targetKey}. ` + + "This element may be generated at runtime and cannot be persisted.", + ); + } } return; }