From 45999226a3a3f3ed2747bfa5c5e86ac2b95052cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 20 May 2026 16:55:52 -0400 Subject: [PATCH 1/9] 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 --- .oxlintrc.json | 2 +- .../src/compiler/compositionScoping.test.ts | 49 +++++++ .../core/src/compiler/compositionScoping.ts | 19 ++- .../compiler/inlineSubCompositions.test.ts | 31 +++++ .../src/compiler/inlineSubCompositions.ts | 18 ++- .../studio-api/helpers/sourceMutation.test.ts | 121 ++++++++++++++++- .../src/studio-api/helpers/sourceMutation.ts | 48 +++++++ packages/core/src/studio-api/routes/files.ts | 45 ++++++- .../core/src/studio-api/routes/preview.ts | 31 ++++- .../producer/src/services/htmlCompiler.ts | 20 ++- .../tests/wysiwyg-subcomp-css/meta.json | 12 ++ .../src/compositions/overlay.html | 47 +++++++ .../tests/wysiwyg-subcomp-css/src/index.html | 35 +++++ .../src/components/StudioErrorBoundary.tsx | 68 ++++++++++ .../studio/src/components/StudioHeader.tsx | 18 ++- .../src/components/editor/PropertyPanel.tsx | 5 +- .../components/nle/CompositionBreadcrumb.tsx | 14 +- .../src/components/renders/RenderQueue.tsx | 2 + .../src/components/sidebar/LeftSidebar.tsx | 2 + .../src/contexts/FileManagerContext.tsx | 6 +- .../studio/src/hooks/useDomEditCommits.ts | 76 +++++++---- packages/studio/src/hooks/useFileManager.ts | 28 ++-- packages/studio/src/hooks/usePanelLayout.ts | 12 +- .../studio/src/hooks/useServerConnection.ts | 12 +- packages/studio/src/main.tsx | 23 +++- .../src/player/components/PlayerControls.tsx | 17 ++- packages/studio/src/player/lib/mediaProbe.ts | 25 +++- packages/studio/src/utils/studioTelemetry.ts | 124 ++++++++++++++++++ packages/studio/vite.config.ts | 3 + 29 files changed, 848 insertions(+), 65 deletions(-) create mode 100644 packages/producer/tests/wysiwyg-subcomp-css/meta.json create mode 100644 packages/producer/tests/wysiwyg-subcomp-css/src/compositions/overlay.html create mode 100644 packages/producer/tests/wysiwyg-subcomp-css/src/index.html create mode 100644 packages/studio/src/components/StudioErrorBoundary.tsx create mode 100644 packages/studio/src/utils/studioTelemetry.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 0fc2d5f8c..3c14d747d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -4,5 +4,5 @@ "correctness": "error" }, "plugins": ["react", "typescript"], - "ignorePatterns": ["dist/", "coverage/", "node_modules/"] + "ignorePatterns": ["dist/", "coverage/", "node_modules/", "playground/"] } diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index 4362bb956..ddd9a1ab6 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -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; } diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index f6fb503d9..876ab59bc 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -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, + ), ); }); diff --git a/packages/core/src/compiler/inlineSubCompositions.test.ts b/packages/core/src/compiler/inlineSubCompositions.test.ts index 7e91f165e..dd3693a3e 100644 --- a/packages/core/src/compiler/inlineSubCompositions.test.ts +++ b/packages/core/src/compiler/inlineSubCompositions.test.ts @@ -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/, + ); + }); }); diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 3ff1a437a..6d2137564 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -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 `` 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(); diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 407d948a5..2b505d03e 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { removeElementFromHtml } from "./sourceMutation.js"; +import { removeElementFromHtml, patchElementInHtml } from "./sourceMutation.js"; describe("removeElementFromHtml", () => { it("removes a self-closing element by id", () => { @@ -28,3 +28,122 @@ describe("removeElementFromHtml", () => { expect(removeElementFromHtml(html, { id: "photo" })).toBe(`
`); }); }); + +describe("patchElementInHtml", () => { + const FIXTURE = ` +
+
+
+ HyperFrames +
+
+
Hello World
+
+`; + + it("patches inline style by id", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "inline-style", property: "color", value: "red" }, + ]); + + 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" }, [ + { type: "inline-style", property: "font-size", value: "72px" }, + ]); + + expect(result).toMatch(/font-size:\s*72px/); + }); + + it("patches data attribute", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "attribute", property: "hf-studio-path-offset", value: "true" }, + ]); + + expect(result).toContain('data-hf-studio-path-offset="true"'); + }); + + it("patches html attribute", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "title", value: "greeting" }, + ]); + + expect(result).toContain('title="greeting"'); + }); + + it("patches text content", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "text-content", property: "", value: "New Title" }, + ]); + + expect(result).toContain("New Title"); + expect(result).not.toContain("Hello World"); + }); + + it("applies multiple operations in one call", () => { + const 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" }, + ]); + + expect(result).toMatch(/color:\s*blue/); + expect(result).toMatch(/font-size:\s*96px/); + expect(result).toContain('data-hf-studio-path-offset="true"'); + }); + + it("finds element by composition-id selector", () => { + const 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 }, [ + { type: "text-content", property: "", value: "Changed" }, + ]); + + expect(result).toContain("A"); + expect(result).toContain("Changed"); + expect(result).not.toContain(">B<"); + }); + + it("returns unchanged html when target not found", () => { + const result = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [ + { type: "inline-style", property: "color", value: "red" }, + ]); + + expect(result).toBe(FIXTURE); + }); + + it("removes inline style when value is null", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "inline-style", property: "font-size", value: null }, + ]); + + expect(result).not.toContain("font-size"); + }); + + 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 }, + ]); + + expect(result).not.toContain("data-composition-src"); + }); + + it("patches fragment html without doctype", () => { + const fragment = `
Title
`; + const result = patchElementInHtml(fragment, { id: "card" }, [ + { type: "inline-style", property: "padding", value: "16px" }, + ]); + + expect(result).toMatch(/padding:\s*16px/); + }); +}); diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 2be500ebd..da41461f5 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -54,3 +54,51 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg element.remove(); return wrappedFragment ? document.body.innerHTML || "" : document.toString(); } + +export interface PatchOperation { + type: "inline-style" | "attribute" | "html-attribute" | "text-content"; + property: string; + value: string | null; +} + +export function patchElementInHtml( + source: string, + target: SourceMutationTarget, + operations: PatchOperation[], +): string { + const { document, wrappedFragment } = parseSourceDocument(source); + const el = findTargetElement(document, target); + if (!el || !(el instanceof (el.ownerDocument.defaultView?.HTMLElement ?? Element))) return source; + const htmlEl = el as unknown as HTMLElement; + + for (const op of operations) { + switch (op.type) { + case "inline-style": + if (op.value != null) { + htmlEl.style.setProperty(op.property, op.value); + } else { + htmlEl.style.removeProperty(op.property); + } + break; + case "attribute": + if (op.value != null) { + htmlEl.setAttribute(`data-${op.property}`, op.value); + } else { + htmlEl.removeAttribute(`data-${op.property}`); + } + break; + case "html-attribute": + if (op.value != null) { + htmlEl.setAttribute(op.property, op.value); + } else { + htmlEl.removeAttribute(op.property); + } + break; + case "text-content": + if (op.value != null) htmlEl.textContent = op.value; + break; + } + } + + return wrappedFragment ? document.body.innerHTML || "" : document.toString(); +} diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 732f2fd3e..aa870cf5d 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -17,7 +17,11 @@ import { isAudioFile } from "../helpers/mime.js"; import { generateWaveformCache } from "../helpers/waveform.js"; import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js"; import { isSafePath } from "../helpers/safePath.js"; -import { removeElementFromHtml } from "../helpers/sourceMutation.js"; +import { + removeElementFromHtml, + patchElementInHtml, + type PatchOperation, +} from "../helpers/sourceMutation.js"; // ── Shared helpers ────────────────────────────────────────────────────────── @@ -236,6 +240,45 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { return c.json({ ok: true, changed: true, content: patchedContent }); }); + api.post("/projects/:id/file-mutations/patch-element/*", async (c) => { + const id = c.req.param("id"); + const project = await adapter.resolveProject(id); + if (!project) return c.json({ error: "not found" }, 404); + + const filePath = decodeURIComponent( + c.req.path.replace(`/projects/${project.id}/file-mutations/patch-element/`, ""), + ); + if (filePath.includes("\0")) { + return c.json({ error: "forbidden" }, 403); + } + + const absPath = resolve(project.dir, filePath); + if (!isSafePath(project.dir, absPath)) { + return c.json({ error: "forbidden" }, 403); + } + const body = (await c.req.json().catch(() => null)) as { + target?: { id?: string | null; selector?: string; selectorIndex?: number }; + operations?: PatchOperation[]; + } | null; + if (!body?.target || !Array.isArray(body.operations) || body.operations.length === 0) { + return c.json({ error: "target and operations required" }, 400); + } + + let originalContent: string; + try { + originalContent = readFileSync(absPath, "utf-8"); + } catch { + return c.json({ error: "not found" }, 404); + } + const patchedContent = patchElementInHtml(originalContent, body.target, body.operations); + if (patchedContent === originalContent) { + return c.json({ ok: true, changed: false, content: originalContent }); + } + + writeFileSync(absPath, patchedContent, "utf-8"); + return c.json({ ok: true, changed: true, content: patchedContent }); + }); + // ── Rename / Move ── api.patch("/projects/:id/files/*", async (c) => { diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index cd2ba50e3..30da87ba6 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -111,6 +111,33 @@ function injectStudioMotionScript( ); } +const GSAP_CDN_FALLBACK_SCRIPT = ``; + +function injectGsapCdnFallback(html: string): string { + if (html.includes("data-hf-gsap-fallback")) return html; + if (html.includes("")) return html.replace("", "" + GSAP_CDN_FALLBACK_SCRIPT); + return GSAP_CDN_FALLBACK_SCRIPT + html; +} + function injectStudioPreviewAugmentations( html: string, adapter: StudioApiAdapter, @@ -118,7 +145,9 @@ function injectStudioPreviewAugmentations( activeCompositionPath: string, ): string { return injectStudioMotionScript( - injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)), + injectGsapCdnFallback( + injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)), + ), projectDir, activeCompositionPath, ); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 5894a3e58..0e91f5322 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -575,6 +575,7 @@ function inlineSubCompositions( }, parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document, scriptErrorLabel: "[Compiler] Composition script failed", + compoundAuthoredRoot: true, }, ); @@ -834,6 +835,23 @@ export interface CompileForRenderOptions { failClosedFontFetch?: boolean; } +const GSAP_CDN_BASE = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/"; + +function rewriteUnresolvableGsapToCdn(html: string, projectDir: string): string { + return html.replace( + /(]*\bsrc=["'])([^"']*gsap[^"']*\/dist\/([^"']+))(["'][^>]*>)/gi, + (full, prefix, src, file, suffix) => { + if (/^https?:\/\//i.test(src)) return full; + const absPath = resolve(projectDir, src); + if (existsSync(absPath)) return full; + console.log( + `[Compiler] Rewriting missing gsap script to CDN: ${src} → ${GSAP_CDN_BASE}${file}`, + ); + return `${prefix}${GSAP_CDN_BASE}${file}${suffix}`; + }, + ); +} + /** * Compile an HTML composition project into a single self-contained HTML string * with all media metadata resolved. @@ -844,7 +862,7 @@ export async function compileForRender( downloadDir: string, options: CompileForRenderOptions = {}, ): Promise { - const rawHtml = readFileSync(htmlPath, "utf-8"); + const rawHtml = rewriteUnresolvableGsapToCdn(readFileSync(htmlPath, "utf-8"), projectDir); const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile( rawHtml, projectDir, diff --git a/packages/producer/tests/wysiwyg-subcomp-css/meta.json b/packages/producer/tests/wysiwyg-subcomp-css/meta.json new file mode 100644 index 000000000..796217263 --- /dev/null +++ b/packages/producer/tests/wysiwyg-subcomp-css/meta.json @@ -0,0 +1,12 @@ +{ + "name": "wysiwyg-subcomp-css", + "description": "Verifies sub-composition CSS is correctly scoped and applied in rendered output. Regression test for compound-selector bug where [data-composition-id] [data-hf-authored-id] used a descendant combinator (space) instead of compound (no space) when both attributes coexist on the same host element after inlining.", + "tags": ["regression", "css-scoping", "sub-compositions", "wysiwyg"], + "minPsnr": 25, + "maxFrameFailures": 2, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 0, + "renderConfig": { + "fps": 30 + } +} diff --git a/packages/producer/tests/wysiwyg-subcomp-css/src/compositions/overlay.html b/packages/producer/tests/wysiwyg-subcomp-css/src/compositions/overlay.html new file mode 100644 index 000000000..eb18811bf --- /dev/null +++ b/packages/producer/tests/wysiwyg-subcomp-css/src/compositions/overlay.html @@ -0,0 +1,47 @@ + diff --git a/packages/producer/tests/wysiwyg-subcomp-css/src/index.html b/packages/producer/tests/wysiwyg-subcomp-css/src/index.html new file mode 100644 index 000000000..8410f4bab --- /dev/null +++ b/packages/producer/tests/wysiwyg-subcomp-css/src/index.html @@ -0,0 +1,35 @@ + + + + + + + +
+
+
+
+ + diff --git a/packages/studio/src/components/StudioErrorBoundary.tsx b/packages/studio/src/components/StudioErrorBoundary.tsx new file mode 100644 index 000000000..4a1deb6de --- /dev/null +++ b/packages/studio/src/components/StudioErrorBoundary.tsx @@ -0,0 +1,68 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { trackStudioEvent } from "../utils/studioTelemetry"; + +interface Props { + children: ReactNode; +} + +interface State { + error: Error | null; +} + +export class StudioErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[Studio] Uncaught error:", error, info.componentStack); + trackStudioEvent("crash", { + error_message: error.message, + error_name: error.name, + component_stack: info.componentStack?.slice(0, 500) ?? null, + }); + } + + render() { + if (!this.state.error) return this.props.children; + + return ( +
+
Something went wrong
+
+ {this.state.error.message} +
+ +
+ ); + } +} diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index ee4c5ea66..1a7cf8c28 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -8,6 +8,7 @@ import { getHistoryShortcutLabel } from "../utils/studioHelpers"; import { useStudioContext } from "../contexts/StudioContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { useDomEditContext } from "../contexts/DomEditContext"; +import { trackStudioEvent } from "../utils/studioTelemetry"; export interface StudioHeaderProps { captureFrameHref: string; @@ -165,7 +166,10 @@ export function StudioHeader({