mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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
This commit is contained in:
@@ -497,6 +497,55 @@ window.__afterTimeline = window.__timelines.scene;
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses compound selector when authored root is the scoped element itself", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
"#chrome-overlay-root { --primary: #FFDC8B; }",
|
||||
"chrome-overlay",
|
||||
undefined,
|
||||
"chrome-overlay-root",
|
||||
{ compoundAuthoredRoot: true },
|
||||
);
|
||||
|
||||
// Both attributes are on the same element after inlining, so the selector
|
||||
// must be compound (no space) to match.
|
||||
expect(scoped).toContain(
|
||||
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"]',
|
||||
);
|
||||
expect(scoped).not.toContain(
|
||||
'[data-composition-id="chrome-overlay"] [data-hf-authored-id="chrome-overlay-root"]',
|
||||
);
|
||||
});
|
||||
|
||||
it("uses compound selector for authored root with descendant combinators", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
"#chrome-overlay-root .chrome { display: flex; }",
|
||||
"chrome-overlay",
|
||||
undefined,
|
||||
"chrome-overlay-root",
|
||||
{ compoundAuthoredRoot: true },
|
||||
);
|
||||
|
||||
// The authored root part is compound with scope, .chrome is a descendant
|
||||
expect(scoped).toContain(
|
||||
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"] .chrome',
|
||||
);
|
||||
expect(scoped).not.toMatch(
|
||||
/\[data-composition-id="chrome-overlay"\]\s+\[data-hf-authored-id="chrome-overlay-root"\]\s+\.chrome/,
|
||||
);
|
||||
});
|
||||
|
||||
it("still uses descendant selector for non-root selectors with authoredRootId", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
".child-element { color: red; }",
|
||||
"chrome-overlay",
|
||||
undefined,
|
||||
"chrome-overlay-root",
|
||||
);
|
||||
|
||||
// Regular child selectors still get a descendant combinator (space)
|
||||
expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element');
|
||||
});
|
||||
|
||||
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
`#intro { background: #111; }
|
||||
|
||||
@@ -101,6 +101,7 @@ function scopeSelector(
|
||||
scope: string,
|
||||
compositionId: string,
|
||||
authoredRootId?: string | null,
|
||||
compoundAuthoredRoot?: boolean,
|
||||
): string {
|
||||
const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId);
|
||||
const selectorWithoutRootTiming = normalizeCompositionRootSelector(
|
||||
@@ -120,6 +121,15 @@ function scopeSelector(
|
||||
}
|
||||
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
|
||||
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
|
||||
if (compoundAuthoredRoot) {
|
||||
const authoredRootAttr = authoredRootId
|
||||
? `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(authoredRootId)}"]`
|
||||
: null;
|
||||
if (authoredRootAttr && trimmed.startsWith(authoredRootAttr)) {
|
||||
const rest = trimmed.slice(authoredRootAttr.length);
|
||||
return `${leading}${scope}${authoredRootAttr}${rest}${trailing}`;
|
||||
}
|
||||
}
|
||||
return `${leading}${scope} ${trimmed}${trailing}`;
|
||||
}
|
||||
|
||||
@@ -158,6 +168,7 @@ export function scopeCssToComposition(
|
||||
compositionId: string,
|
||||
scopeSelectorOverride?: string,
|
||||
authoredRootId?: string | null,
|
||||
options?: { compoundAuthoredRoot?: boolean },
|
||||
): string {
|
||||
const trimmedCompositionId = compositionId.trim();
|
||||
if (!css || !trimmedCompositionId) return css;
|
||||
@@ -169,7 +180,13 @@ export function scopeCssToComposition(
|
||||
root.walkRules((rule) => {
|
||||
if (isInsideGlobalAtRule(rule)) return;
|
||||
rule.selectors = rule.selectors.map((selector) =>
|
||||
scopeSelector(selector, scope, trimmedCompositionId, authoredRootId),
|
||||
scopeSelector(
|
||||
selector,
|
||||
scope,
|
||||
trimmedCompositionId,
|
||||
authoredRootId,
|
||||
options?.compoundAuthoredRoot,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -151,4 +151,35 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => {
|
||||
|
||||
expect(host.getAttribute("data-composition-id")).toBe("intro");
|
||||
});
|
||||
|
||||
it("producer path: scoped CSS matches host element when both attributes coexist", () => {
|
||||
const document = makeHostDocument("intro");
|
||||
const host = document.querySelector('[data-composition-src="intro.html"]')!;
|
||||
|
||||
const result = inlineSubCompositions(document, [host], {
|
||||
resolveHtml: () => SUB_COMP_HTML,
|
||||
parseHtml: (html) => parseHTML(html).document,
|
||||
compoundAuthoredRoot: true,
|
||||
});
|
||||
|
||||
// After inlining, the host has both data-composition-id and data-hf-authored-id.
|
||||
// CSS selectors targeting the root must be compound (no space) so they match
|
||||
// when both attributes are on the same element.
|
||||
expect(host.getAttribute("data-composition-id")).toBe("intro");
|
||||
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
|
||||
|
||||
const scopedCss = result.styles.join("\n");
|
||||
|
||||
// Root-only selector: must be compound
|
||||
expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/);
|
||||
// Must NOT have a descendant combinator between the two attribute selectors
|
||||
expect(scopedCss).not.toMatch(
|
||||
/\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/,
|
||||
);
|
||||
|
||||
// Descendant selector: compound root + space + child
|
||||
expect(scopedCss).toMatch(
|
||||
/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,15 @@ export interface InlineSubCompositionsOptions {
|
||||
*/
|
||||
flattenInnerRoot?: (innerRoot: Element) => Element;
|
||||
|
||||
/**
|
||||
* When true, CSS selectors targeting the authored root use a compound
|
||||
* selector (`[scope][root]`) instead of a descendant (`[scope] [root]`).
|
||||
* Enable this in the producer path where the inner root merges onto
|
||||
* the host element via innerHTML — both attributes end up on the same
|
||||
* element and a descendant selector won't match.
|
||||
*/
|
||||
compoundAuthoredRoot?: boolean;
|
||||
|
||||
/**
|
||||
* Read declared variable defaults from a sub-composition's `<html>` element.
|
||||
* The bundler passes `readDeclaredDefaults`; the producer can omit this.
|
||||
@@ -139,6 +148,7 @@ export function inlineSubCompositions(
|
||||
hostIdentityMap,
|
||||
rewriteInlineStyles = false,
|
||||
flattenInnerRoot,
|
||||
compoundAuthoredRoot,
|
||||
readVariableDefaults,
|
||||
parseHostVariables,
|
||||
buildScopeSelector = defaultBuildScopeSelector,
|
||||
@@ -211,7 +221,9 @@ export function inlineSubCompositions(
|
||||
const css = rewriteCssAssetUrls(s.textContent || "", src);
|
||||
styles.push(
|
||||
scopeCompId
|
||||
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
|
||||
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
|
||||
compoundAuthoredRoot: compoundAuthoredRoot === true,
|
||||
})
|
||||
: css,
|
||||
);
|
||||
}
|
||||
@@ -228,7 +240,9 @@ export function inlineSubCompositions(
|
||||
const css = rewriteCssAssetUrls(s.textContent || "", src);
|
||||
styles.push(
|
||||
scopeCompId
|
||||
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
|
||||
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
|
||||
compoundAuthoredRoot: compoundAuthoredRoot === true,
|
||||
})
|
||||
: css,
|
||||
);
|
||||
s.remove();
|
||||
|
||||
Reference in New Issue
Block a user