Skip to content

Commit 9360a9f

Browse files
committed
fix(studio): server-side DOM patching, render CSS scoping, and resilience
Root-cause fix for edits being wiped after refresh: the studio's inspector edits were patched client-side via regex matching in sourcePatcher.ts, which silently failed for many compositions ("Unable to patch" toast). Replaced with a server-side patch-element API endpoint using linkedom for proper DOM parsing via querySelector. Also fixes the WYSIWYG render bug where sub-composition CSS was not applied. The CSS scoping generated descendant selectors when both attributes coexist on the same host element. Fixed to use compound selectors for the authored root. Edit persistence: - New POST /file-mutations/patch-element endpoint using linkedom - persistDomEditOperations calls server instead of client regex - 15 tests covering all patch operation types Render CSS scoping: - Compound selector for authored root on host element - Regression test: wysiwyg-subcomp-css (baseline pending Docker) - 3 unit tests + 1 integration test GSAP CDN fallback: - Preview: error-handler catches gsap 404 and loads from CDN - Producer: rewrites missing local gsap paths to CDN before compile Studio resilience: - Error boundary with recoverable UI - Lazy mediabunny import prevents crash cascade - Hash routing listens for hashchange events - Sub-composition duration reads data-hf-authored-duration fallback - Save debounce 600ms to requestAnimationFrame Observability: - PostHog telemetry for crashes, save failures, tab switches, playback, toolbar actions, navigation, and render starts
1 parent ce95c9a commit 9360a9f

28 files changed

Lines changed: 816 additions & 62 deletions

.oxlintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
"correctness": "error"
55
},
66
"plugins": ["react", "typescript"],
7-
"ignorePatterns": ["dist/", "coverage/", "node_modules/"]
7+
"ignorePatterns": ["dist/", "coverage/", "node_modules/", "playground/"]
88
}

packages/core/src/compiler/compositionScoping.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,53 @@ window.__afterTimeline = window.__timelines.scene;
497497
expect(errorSpy).not.toHaveBeenCalled();
498498
});
499499

500+
it("uses compound selector when authored root is the scoped element itself", () => {
501+
const scoped = scopeCssToComposition(
502+
"#chrome-overlay-root { --primary: #FFDC8B; }",
503+
"chrome-overlay",
504+
undefined,
505+
"chrome-overlay-root",
506+
);
507+
508+
// Both attributes are on the same element after inlining, so the selector
509+
// must be compound (no space) to match.
510+
expect(scoped).toContain(
511+
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"]',
512+
);
513+
expect(scoped).not.toContain(
514+
'[data-composition-id="chrome-overlay"] [data-hf-authored-id="chrome-overlay-root"]',
515+
);
516+
});
517+
518+
it("uses compound selector for authored root with descendant combinators", () => {
519+
const scoped = scopeCssToComposition(
520+
"#chrome-overlay-root .chrome { display: flex; }",
521+
"chrome-overlay",
522+
undefined,
523+
"chrome-overlay-root",
524+
);
525+
526+
// The authored root part is compound with scope, .chrome is a descendant
527+
expect(scoped).toContain(
528+
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"] .chrome',
529+
);
530+
expect(scoped).not.toMatch(
531+
/\[data-composition-id="chrome-overlay"\]\s+\[data-hf-authored-id="chrome-overlay-root"\]\s+\.chrome/,
532+
);
533+
});
534+
535+
it("still uses descendant selector for non-root selectors with authoredRootId", () => {
536+
const scoped = scopeCssToComposition(
537+
".child-element { color: red; }",
538+
"chrome-overlay",
539+
undefined,
540+
"chrome-overlay-root",
541+
);
542+
543+
// Regular child selectors still get a descendant combinator (space)
544+
expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element');
545+
});
546+
500547
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
501548
const scoped = scopeCssToComposition(
502549
`#intro { background: #111; }

packages/core/src/compiler/compositionScoping.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ function scopeSelector(
120120
}
121121
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
122122
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
123+
const authoredRootAttr = authoredRootId
124+
? `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(authoredRootId)}"]`
125+
: null;
126+
if (authoredRootAttr && trimmed.startsWith(authoredRootAttr)) {
127+
const rest = trimmed.slice(authoredRootAttr.length);
128+
return `${leading}${scope}${authoredRootAttr}${rest}${trailing}`;
129+
}
123130
return `${leading}${scope} ${trimmed}${trailing}`;
124131
}
125132

packages/core/src/compiler/inlineSubCompositions.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,34 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => {
151151

152152
expect(host.getAttribute("data-composition-id")).toBe("intro");
153153
});
154+
155+
it("producer path: scoped CSS matches host element when both attributes coexist", () => {
156+
const document = makeHostDocument("intro");
157+
const host = document.querySelector('[data-composition-src="intro.html"]')!;
158+
159+
const result = inlineSubCompositions(document, [host], {
160+
resolveHtml: () => SUB_COMP_HTML,
161+
parseHtml: (html) => parseHTML(html).document,
162+
});
163+
164+
// After inlining, the host has both data-composition-id and data-hf-authored-id.
165+
// CSS selectors targeting the root must be compound (no space) so they match
166+
// when both attributes are on the same element.
167+
expect(host.getAttribute("data-composition-id")).toBe("intro");
168+
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
169+
170+
const scopedCss = result.styles.join("\n");
171+
172+
// Root-only selector: must be compound
173+
expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/);
174+
// Must NOT have a descendant combinator between the two attribute selectors
175+
expect(scopedCss).not.toMatch(
176+
/\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/,
177+
);
178+
179+
// Descendant selector: compound root + space + child
180+
expect(scopedCss).toMatch(
181+
/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/,
182+
);
183+
});
154184
});

packages/core/src/studio-api/helpers/sourceMutation.test.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { removeElementFromHtml } from "./sourceMutation.js";
2+
import { removeElementFromHtml, patchElementInHtml } from "./sourceMutation.js";
33

44
describe("removeElementFromHtml", () => {
55
it("removes a self-closing element by id", () => {
@@ -28,3 +28,122 @@ describe("removeElementFromHtml", () => {
2828
expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`);
2929
});
3030
});
31+
32+
describe("patchElementInHtml", () => {
33+
const FIXTURE = `<!doctype html><html><head></head><body>
34+
<div id="root" data-composition-id="main">
35+
<div class="layer" data-composition-id="overlay" data-composition-src="compositions/overlay.html">
36+
<div class="chrome">
37+
<span class="brand">HyperFrames</span>
38+
</div>
39+
</div>
40+
<div id="hero" class="hero-heading" style="font-size: 48px">Hello World</div>
41+
</div>
42+
</body></html>`;
43+
44+
it("patches inline style by id", () => {
45+
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
46+
{ type: "inline-style", property: "color", value: "red" },
47+
]);
48+
49+
expect(result).toMatch(/color:\s*red/);
50+
expect(result).toContain('id="hero"');
51+
});
52+
53+
it("patches inline style by class selector", () => {
54+
const result = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [
55+
{ type: "inline-style", property: "font-size", value: "72px" },
56+
]);
57+
58+
expect(result).toMatch(/font-size:\s*72px/);
59+
});
60+
61+
it("patches data attribute", () => {
62+
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
63+
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
64+
]);
65+
66+
expect(result).toContain('data-hf-studio-path-offset="true"');
67+
});
68+
69+
it("patches html attribute", () => {
70+
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
71+
{ type: "html-attribute", property: "title", value: "greeting" },
72+
]);
73+
74+
expect(result).toContain('title="greeting"');
75+
});
76+
77+
it("patches text content", () => {
78+
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
79+
{ type: "text-content", property: "", value: "New Title" },
80+
]);
81+
82+
expect(result).toContain("New Title");
83+
expect(result).not.toContain("Hello World");
84+
});
85+
86+
it("applies multiple operations in one call", () => {
87+
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
88+
{ type: "inline-style", property: "color", value: "blue" },
89+
{ type: "inline-style", property: "font-size", value: "96px" },
90+
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
91+
]);
92+
93+
expect(result).toMatch(/color:\s*blue/);
94+
expect(result).toMatch(/font-size:\s*96px/);
95+
expect(result).toContain('data-hf-studio-path-offset="true"');
96+
});
97+
98+
it("finds element by composition-id selector", () => {
99+
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
100+
{ type: "inline-style", property: "opacity", value: "0.5" },
101+
]);
102+
103+
expect(result).toMatch(/opacity:\s*0\.5/);
104+
});
105+
106+
it("finds element by class with selectorIndex", () => {
107+
const html = `<div class="item">A</div><div class="item">B</div>`;
108+
const result = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [
109+
{ type: "text-content", property: "", value: "Changed" },
110+
]);
111+
112+
expect(result).toContain("A");
113+
expect(result).toContain("Changed");
114+
expect(result).not.toContain(">B<");
115+
});
116+
117+
it("returns unchanged html when target not found", () => {
118+
const result = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [
119+
{ type: "inline-style", property: "color", value: "red" },
120+
]);
121+
122+
expect(result).toBe(FIXTURE);
123+
});
124+
125+
it("removes inline style when value is null", () => {
126+
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
127+
{ type: "inline-style", property: "font-size", value: null },
128+
]);
129+
130+
expect(result).not.toContain("font-size");
131+
});
132+
133+
it("removes attribute when value is null", () => {
134+
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
135+
{ type: "html-attribute", property: "data-composition-src", value: null },
136+
]);
137+
138+
expect(result).not.toContain("data-composition-src");
139+
});
140+
141+
it("patches fragment html without doctype", () => {
142+
const fragment = `<div id="card" style="padding: 8px"><span>Title</span></div>`;
143+
const result = patchElementInHtml(fragment, { id: "card" }, [
144+
{ type: "inline-style", property: "padding", value: "16px" },
145+
]);
146+
147+
expect(result).toMatch(/padding:\s*16px/);
148+
});
149+
});

packages/core/src/studio-api/helpers/sourceMutation.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,51 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg
5454
element.remove();
5555
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
5656
}
57+
58+
export interface PatchOperation {
59+
type: "inline-style" | "attribute" | "html-attribute" | "text-content";
60+
property: string;
61+
value: string | null;
62+
}
63+
64+
export function patchElementInHtml(
65+
source: string,
66+
target: SourceMutationTarget,
67+
operations: PatchOperation[],
68+
): string {
69+
const { document, wrappedFragment } = parseSourceDocument(source);
70+
const el = findTargetElement(document, target);
71+
if (!el || !(el instanceof (el.ownerDocument.defaultView?.HTMLElement ?? Element))) return source;
72+
const htmlEl = el as unknown as HTMLElement;
73+
74+
for (const op of operations) {
75+
switch (op.type) {
76+
case "inline-style":
77+
if (op.value != null) {
78+
htmlEl.style.setProperty(op.property, op.value);
79+
} else {
80+
htmlEl.style.removeProperty(op.property);
81+
}
82+
break;
83+
case "attribute":
84+
if (op.value != null) {
85+
htmlEl.setAttribute(`data-${op.property}`, op.value);
86+
} else {
87+
htmlEl.removeAttribute(`data-${op.property}`);
88+
}
89+
break;
90+
case "html-attribute":
91+
if (op.value != null) {
92+
htmlEl.setAttribute(op.property, op.value);
93+
} else {
94+
htmlEl.removeAttribute(op.property);
95+
}
96+
break;
97+
case "text-content":
98+
if (op.value != null) htmlEl.textContent = op.value;
99+
break;
100+
}
101+
}
102+
103+
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
104+
}

packages/core/src/studio-api/routes/files.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import { isAudioFile } from "../helpers/mime.js";
1717
import { generateWaveformCache } from "../helpers/waveform.js";
1818
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
1919
import { isSafePath } from "../helpers/safePath.js";
20-
import { removeElementFromHtml } from "../helpers/sourceMutation.js";
20+
import {
21+
removeElementFromHtml,
22+
patchElementInHtml,
23+
type PatchOperation,
24+
} from "../helpers/sourceMutation.js";
2125

2226
// ── Shared helpers ──────────────────────────────────────────────────────────
2327

@@ -236,6 +240,44 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
236240
return c.json({ ok: true, changed: true, content: patchedContent });
237241
});
238242

243+
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
244+
const id = c.req.param("id");
245+
const project = await adapter.resolveProject(id);
246+
if (!project) return c.json({ error: "not found" }, 404);
247+
248+
const filePath = decodeURIComponent(
249+
c.req.path.replace(`/projects/${project.id}/file-mutations/patch-element/`, ""),
250+
);
251+
if (filePath.includes("\0")) {
252+
return c.json({ error: "forbidden" }, 403);
253+
}
254+
255+
const absPath = resolve(project.dir, filePath);
256+
if (!isSafePath(project.dir, absPath)) {
257+
return c.json({ error: "forbidden" }, 403);
258+
}
259+
if (!existsSync(absPath)) {
260+
return c.json({ error: "not found" }, 404);
261+
}
262+
263+
const body = (await c.req.json().catch(() => null)) as {
264+
target?: { id?: string | null; selector?: string; selectorIndex?: number };
265+
operations?: PatchOperation[];
266+
} | null;
267+
if (!body?.target || !Array.isArray(body.operations) || body.operations.length === 0) {
268+
return c.json({ error: "target and operations required" }, 400);
269+
}
270+
271+
const originalContent = readFileSync(absPath, "utf-8");
272+
const patchedContent = patchElementInHtml(originalContent, body.target, body.operations);
273+
if (patchedContent === originalContent) {
274+
return c.json({ ok: true, changed: false, content: originalContent });
275+
}
276+
277+
writeFileSync(absPath, patchedContent, "utf-8");
278+
return c.json({ ok: true, changed: true, content: patchedContent });
279+
});
280+
239281
// ── Rename / Move ──
240282

241283
api.patch("/projects/:id/files/*", async (c) => {

packages/core/src/studio-api/routes/preview.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,43 @@ function injectStudioMotionScript(
111111
);
112112
}
113113

114+
const GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
115+
(function(){
116+
var cdnBase="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/";
117+
var loaded={};
118+
function loadFallback(file){
119+
if(loaded[file])return loaded[file];
120+
return loaded[file]=new Promise(function(ok,fail){
121+
var s=document.createElement("script");
122+
s.src=cdnBase+file;s.onload=ok;s.onerror=fail;
123+
document.head.appendChild(s);
124+
});
125+
}
126+
document.addEventListener("error",function(e){
127+
var t=e.target;
128+
if(!t||t.tagName!=="SCRIPT"||!t.src)return;
129+
var m=t.src.match(/gsap[^/]*\\/dist\\/(.+\\.js)/);
130+
if(m)loadFallback(m[1]);
131+
},true);
132+
})();
133+
</script>`;
134+
135+
function injectGsapCdnFallback(html: string): string {
136+
if (html.includes("data-hf-gsap-fallback")) return html;
137+
if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
138+
return GSAP_CDN_FALLBACK_SCRIPT + html;
139+
}
140+
114141
function injectStudioPreviewAugmentations(
115142
html: string,
116143
adapter: StudioApiAdapter,
117144
projectDir: string,
118145
activeCompositionPath: string,
119146
): string {
120147
return injectStudioMotionScript(
121-
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
148+
injectGsapCdnFallback(
149+
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
150+
),
122151
projectDir,
123152
activeCompositionPath,
124153
);

0 commit comments

Comments
 (0)