Skip to content

Commit c5c38f6

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 c5c38f6

29 files changed

Lines changed: 847 additions & 65 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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,55 @@ 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+
{ compoundAuthoredRoot: true },
507+
);
508+
509+
// Both attributes are on the same element after inlining, so the selector
510+
// must be compound (no space) to match.
511+
expect(scoped).toContain(
512+
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"]',
513+
);
514+
expect(scoped).not.toContain(
515+
'[data-composition-id="chrome-overlay"] [data-hf-authored-id="chrome-overlay-root"]',
516+
);
517+
});
518+
519+
it("uses compound selector for authored root with descendant combinators", () => {
520+
const scoped = scopeCssToComposition(
521+
"#chrome-overlay-root .chrome { display: flex; }",
522+
"chrome-overlay",
523+
undefined,
524+
"chrome-overlay-root",
525+
{ compoundAuthoredRoot: true },
526+
);
527+
528+
// The authored root part is compound with scope, .chrome is a descendant
529+
expect(scoped).toContain(
530+
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"] .chrome',
531+
);
532+
expect(scoped).not.toMatch(
533+
/\[data-composition-id="chrome-overlay"\]\s+\[data-hf-authored-id="chrome-overlay-root"\]\s+\.chrome/,
534+
);
535+
});
536+
537+
it("still uses descendant selector for non-root selectors with authoredRootId", () => {
538+
const scoped = scopeCssToComposition(
539+
".child-element { color: red; }",
540+
"chrome-overlay",
541+
undefined,
542+
"chrome-overlay-root",
543+
);
544+
545+
// Regular child selectors still get a descendant combinator (space)
546+
expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element');
547+
});
548+
500549
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
501550
const scoped = scopeCssToComposition(
502551
`#intro { background: #111; }

packages/core/src/compiler/compositionScoping.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ function scopeSelector(
101101
scope: string,
102102
compositionId: string,
103103
authoredRootId?: string | null,
104+
compoundAuthoredRoot?: boolean,
104105
): string {
105106
const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId);
106107
const selectorWithoutRootTiming = normalizeCompositionRootSelector(
@@ -120,6 +121,15 @@ function scopeSelector(
120121
}
121122
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
122123
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
124+
if (compoundAuthoredRoot) {
125+
const authoredRootAttr = authoredRootId
126+
? `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(authoredRootId)}"]`
127+
: null;
128+
if (authoredRootAttr && trimmed.startsWith(authoredRootAttr)) {
129+
const rest = trimmed.slice(authoredRootAttr.length);
130+
return `${leading}${scope}${authoredRootAttr}${rest}${trailing}`;
131+
}
132+
}
123133
return `${leading}${scope} ${trimmed}${trailing}`;
124134
}
125135

@@ -158,6 +168,7 @@ export function scopeCssToComposition(
158168
compositionId: string,
159169
scopeSelectorOverride?: string,
160170
authoredRootId?: string | null,
171+
options?: { compoundAuthoredRoot?: boolean },
161172
): string {
162173
const trimmedCompositionId = compositionId.trim();
163174
if (!css || !trimmedCompositionId) return css;
@@ -169,7 +180,13 @@ export function scopeCssToComposition(
169180
root.walkRules((rule) => {
170181
if (isInsideGlobalAtRule(rule)) return;
171182
rule.selectors = rule.selectors.map((selector) =>
172-
scopeSelector(selector, scope, trimmedCompositionId, authoredRootId),
183+
scopeSelector(
184+
selector,
185+
scope,
186+
trimmedCompositionId,
187+
authoredRootId,
188+
options?.compoundAuthoredRoot,
189+
),
173190
);
174191
});
175192

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,35 @@ 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+
compoundAuthoredRoot: true,
163+
});
164+
165+
// After inlining, the host has both data-composition-id and data-hf-authored-id.
166+
// CSS selectors targeting the root must be compound (no space) so they match
167+
// when both attributes are on the same element.
168+
expect(host.getAttribute("data-composition-id")).toBe("intro");
169+
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
170+
171+
const scopedCss = result.styles.join("\n");
172+
173+
// Root-only selector: must be compound
174+
expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/);
175+
// Must NOT have a descendant combinator between the two attribute selectors
176+
expect(scopedCss).not.toMatch(
177+
/\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/,
178+
);
179+
180+
// Descendant selector: compound root + space + child
181+
expect(scopedCss).toMatch(
182+
/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/,
183+
);
184+
});
154185
});

packages/core/src/compiler/inlineSubCompositions.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ export interface InlineSubCompositionsOptions {
6060
*/
6161
flattenInnerRoot?: (innerRoot: Element) => Element;
6262

63+
/**
64+
* When true, CSS selectors targeting the authored root use a compound
65+
* selector (`[scope][root]`) instead of a descendant (`[scope] [root]`).
66+
* Enable this in the producer path where the inner root merges onto
67+
* the host element via innerHTML — both attributes end up on the same
68+
* element and a descendant selector won't match.
69+
*/
70+
compoundAuthoredRoot?: boolean;
71+
6372
/**
6473
* Read declared variable defaults from a sub-composition's `<html>` element.
6574
* The bundler passes `readDeclaredDefaults`; the producer can omit this.
@@ -139,6 +148,7 @@ export function inlineSubCompositions(
139148
hostIdentityMap,
140149
rewriteInlineStyles = false,
141150
flattenInnerRoot,
151+
compoundAuthoredRoot,
142152
readVariableDefaults,
143153
parseHostVariables,
144154
buildScopeSelector = defaultBuildScopeSelector,
@@ -211,7 +221,9 @@ export function inlineSubCompositions(
211221
const css = rewriteCssAssetUrls(s.textContent || "", src);
212222
styles.push(
213223
scopeCompId
214-
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
224+
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
225+
compoundAuthoredRoot: compoundAuthoredRoot === true,
226+
})
215227
: css,
216228
);
217229
}
@@ -228,7 +240,9 @@ export function inlineSubCompositions(
228240
const css = rewriteCssAssetUrls(s.textContent || "", src);
229241
styles.push(
230242
scopeCompId
231-
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
243+
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
244+
compoundAuthoredRoot: compoundAuthoredRoot === true,
245+
})
232246
: css,
233247
);
234248
s.remove();

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+
}

0 commit comments

Comments
 (0)