From 2cd47caa34f127bf4caffc83bc09c47da13825ce Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 15:33:20 -0800 Subject: [PATCH 01/11] categorize csp issues --- src/visual_editor/components/ErrorDisplay.tsx | 57 ++++++++++++++----- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/visual_editor/components/ErrorDisplay.tsx b/src/visual_editor/components/ErrorDisplay.tsx index 6a22255b..45a2afdf 100644 --- a/src/visual_editor/components/ErrorDisplay.tsx +++ b/src/visual_editor/components/ErrorDisplay.tsx @@ -2,19 +2,41 @@ import React, { useRef, FC, useEffect } from "react"; import { CSPError } from "../../../devtools"; const CSPErrorDisplay = ({ cspError }: { cspError: CSPError | null }) => ( -
- The {cspError ? `${cspError.violatedDirective} directive in the` : ""}{" "} - Content Security Policy on this page is too strict for the Visual Editor. - Refer to the Visual Editor documentation's{" "} - - 'Security Requirements' - {" "} - for details. +
+

+ {cspError?.isFatal ? "Critical CSP restriction" : "CSP warning"}:{" "} + {cspError + ? `${cspError.effectiveDirective || cspError.violatedDirective}` + : "Unknown directive"} + . +

+ {!cspError?.isFatal ? ( +

+ Some site scripts were blocked by CSP. Visual Editor should stay active. +

+ ) : ( +

+ Extension resources required by Visual Editor were blocked. +

+ )} +

+ Blocked URI: {cspError?.blockedURI || "unknown"} +

+

+ Source: {cspError?.sourceFile || "unknown"} +

+

+ Refer to{" "} + + Visual Editor Security Requirements + + . +

); @@ -23,6 +45,10 @@ interface ErrorDisplayProps { cspError: CSPError | null; } const ErrorDisplay: FC = ({ error, cspError }) => { + if (cspError && !cspError.isFatal && error !== "csp-error") { + return ; + } + switch (error) { case "no-api-host": case "no-api-key": @@ -52,15 +78,16 @@ const ErrorDisplay: FC = ({ error, cspError }) => { export default (props: ErrorDisplayProps) => { const { error, cspError } = props; const errorContainerRef = useRef(null); + const shouldAutoScroll = !!error || !!cspError?.isFatal; // scroll to error useEffect(() => { - if (!error && !cspError && !errorContainerRef.current) return; + if (!shouldAutoScroll) return; errorContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end", }); - }, [error, cspError, errorContainerRef.current]); + }, [shouldAutoScroll]); return (
From a55d78f57862d4c35bb9a9ff46fdb49e21c8b9e5 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 15:34:10 -0800 Subject: [PATCH 02/11] fix sendRespnse warn --- src/background/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/background/index.ts b/src/background/index.ts index 9a022c04..8202cf12 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -87,6 +87,7 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => { // not found, send empty message to signal unset }); } + sendResponse({ success: true }); } if (message.type === "setGlobalState") { await setState(message.property, message.value, message.persist); From 1fb2ad27f3053487886ee1361eba46496e3babb0 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 15:34:52 -0800 Subject: [PATCH 03/11] add csp classifier and metadata --- devtools.d.ts | 5 +++ src/visual_editor/lib/csp.ts | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/visual_editor/lib/csp.ts diff --git a/devtools.d.ts b/devtools.d.ts index fc596d1d..3b5e52a8 100644 --- a/devtools.d.ts +++ b/devtools.d.ts @@ -22,6 +22,11 @@ export type CopyMode = "energetic" | "concise" | "humorous"; export type CSPError = { violatedDirective: string; + effectiveDirective?: string; + blockedURI?: string; + sourceFile?: string; + isFatal?: boolean; + timestamp?: number; }; export interface VisualEditorVariation { diff --git a/src/visual_editor/lib/csp.ts b/src/visual_editor/lib/csp.ts new file mode 100644 index 00000000..3fde28b8 --- /dev/null +++ b/src/visual_editor/lib/csp.ts @@ -0,0 +1,60 @@ +import { CSPError } from "devtools"; + +const VE_RELEVANT_DIRECTIVES = new Set([ + "script-src", + "script-src-elem", + "style-src", + "style-src-elem", + "connect-src", +]); + +const EXTENSION_URI_PREFIXES = ["chrome-extension://", "moz-extension://"]; + +const isExtensionUri = (value?: string | null) => { + if (!value) return false; + return EXTENSION_URI_PREFIXES.some((prefix) => value.startsWith(prefix)); +}; + +const toSafeValue = (value?: string | null) => value || ""; + +export const classifyCSPViolation = ( + e: Pick< + SecurityPolicyViolationEvent, + "violatedDirective" | "effectiveDirective" | "blockedURI" | "sourceFile" + >, +): { + isRelevant: boolean; + isFatal: boolean; + key: string; + details: CSPError; +} => { + const effectiveDirective = toSafeValue(e.effectiveDirective); + const violatedDirective = toSafeValue(e.violatedDirective); + const blockedURI = toSafeValue(e.blockedURI); + const sourceFile = toSafeValue(e.sourceFile); + + const normalizedDirective = + effectiveDirective || violatedDirective || "unknown"; + const isRelevant = VE_RELEVANT_DIRECTIVES.has(normalizedDirective); + const blockedExtensionResource = + isExtensionUri(blockedURI) || isExtensionUri(sourceFile); + + // Fatal when extension-owned resources are explicitly blocked. + const isFatal = isRelevant && blockedExtensionResource; + const details: CSPError = { + violatedDirective, + effectiveDirective: normalizedDirective, + blockedURI, + sourceFile, + isFatal, + timestamp: Date.now(), + }; + + return { + isRelevant, + isFatal, + key: `${normalizedDirective}|${blockedURI}|${sourceFile}`, + details, + }; +}; + From 7d45f424930c68e511bab217fac34e04b6c26810 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 15:35:20 -0800 Subject: [PATCH 04/11] Added hydration safety gate and deferred mutation replay --- src/visual_editor/lib/hooks/useEditMode.ts | 26 +++++++- src/visual_editor/lib/hydration.ts | 71 ++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/visual_editor/lib/hydration.ts diff --git a/src/visual_editor/lib/hooks/useEditMode.ts b/src/visual_editor/lib/hooks/useEditMode.ts index a1893591..750e4d27 100644 --- a/src/visual_editor/lib/hooks/useEditMode.ts +++ b/src/visual_editor/lib/hooks/useEditMode.ts @@ -10,6 +10,7 @@ import { VisualEditorVariation } from "devtools"; import { Attribute } from "@/visual_editor/components/AttributeEdit"; import getSelector from "@/visual_editor/lib/getSelector"; import { CONTAINER_ID } from "@/visual_editor"; +import { waitForHydrationSafe } from "@/visual_editor/lib/hydration"; export const hoverAttributeName = "edit-mode-hover"; @@ -308,6 +309,18 @@ const useEditMode: UseEditModeHook = ({ // upon every DOM mutation, we revert all changes and replay them to ensure // that the DOM is in the correct state const mutateRevert = useRef<(() => void) | null>(null); + const [hydrationSafe, setHydrationSafe] = useState(false); + const pendingMutationsRef = useRef(); + + useEffect(() => { + let cancelled = false; + waitForHydrationSafe().then(() => { + if (!cancelled) setHydrationSafe(true); + }); + return () => { + cancelled = true; + }; + }, []); const runMutations = ( domMutations?: VisualEditorVariation["domMutations"], @@ -330,8 +343,19 @@ const useEditMode: UseEditModeHook = ({ }; useEffect(() => { + if (!hydrationSafe) { + pendingMutationsRef.current = variation?.domMutations; + return; + } runMutations(variation?.domMutations); - }, [variation]); + }, [variation, hydrationSafe]); + + useEffect(() => { + if (!hydrationSafe) return; + if (!pendingMutationsRef.current) return; + runMutations(pendingMutationsRef.current); + pendingMutationsRef.current = undefined; + }, [hydrationSafe]); const hasTextInChildren = (element: Element) => { for (let child of element.children) { diff --git a/src/visual_editor/lib/hydration.ts b/src/visual_editor/lib/hydration.ts new file mode 100644 index 00000000..e479f471 --- /dev/null +++ b/src/visual_editor/lib/hydration.ts @@ -0,0 +1,71 @@ +type HydrationWaitOptions = { + quietWindowMs?: number; + maxWaitMs?: number; +}; + +const DEFAULT_QUIET_MS = 300; +const DEFAULT_MAX_WAIT_MS = 5000; + +export const waitForHydrationSafe = ({ + quietWindowMs = DEFAULT_QUIET_MS, + maxWaitMs = DEFAULT_MAX_WAIT_MS, +}: HydrationWaitOptions = {}): Promise => { + return new Promise((resolve) => { + const finish = () => resolve(); + + const waitForLoad = (onLoaded: () => void) => { + if (document.readyState === "complete") { + onLoaded(); + return; + } + + const onLoad = () => { + window.removeEventListener("load", onLoad); + onLoaded(); + }; + window.addEventListener("load", onLoad, { once: true }); + }; + + waitForLoad(() => { + let quietTimer: number | null = null; + let maxTimer: number | null = null; + let observer: MutationObserver | null = null; + let done = false; + + const cleanup = () => { + if (done) return; + done = true; + if (quietTimer) window.clearTimeout(quietTimer); + if (maxTimer) window.clearTimeout(maxTimer); + observer?.disconnect(); + }; + + const resolveOnce = () => { + cleanup(); + finish(); + }; + + const scheduleQuiet = () => { + if (quietTimer) window.clearTimeout(quietTimer); + quietTimer = window.setTimeout(resolveOnce, quietWindowMs); + }; + + maxTimer = window.setTimeout(resolveOnce, maxWaitMs); + + try { + observer = new MutationObserver(() => scheduleQuiet()); + observer.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + } catch (e) { + resolveOnce(); + return; + } + + scheduleQuiet(); + }); + }); +}; + From 346f54120dc5f628582c4e15c4e6fddfa44d4fec Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 15:36:28 -0800 Subject: [PATCH 05/11] classify csp and contain runtime crash --- src/content_script/index.ts | 19 +++- src/visual_editor/index.tsx | 179 ++++++++++++++++++++++++++++++++---- 2 files changed, 177 insertions(+), 21 deletions(-) diff --git a/src/content_script/index.ts b/src/content_script/index.ts index b665c7cf..fe042033 100644 --- a/src/content_script/index.ts +++ b/src/content_script/index.ts @@ -124,6 +124,7 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => { // not found, send empty message to signal unset }); } + sendResponse({ success: true }); } if (message.type === "setTabState") { setState(message.property, message.value); // Update the state property @@ -202,10 +203,10 @@ if (!document.getElementById(DEVTOOLS_SCRIPT_ID)) { // Inject visual editor content script const VISUAL_EDITOR_SCRIPT_ID = "visual-editor-script"; -if ( - !document.getElementById(VISUAL_EDITOR_SCRIPT_ID) && - (!!loadVisualEditorQueryParams() || forceLoadVisualEditor) -) { +const shouldInjectVisualEditor = + !!loadVisualEditorQueryParams() || forceLoadVisualEditor; +const injectVisualEditorScript = () => { + if (document.getElementById(VISUAL_EDITOR_SCRIPT_ID)) return; const script = document.createElement("script"); script.id = VISUAL_EDITOR_SCRIPT_ID; script.async = true; @@ -213,6 +214,16 @@ if ( script.src = chrome.runtime.getURL("js/visual_editor.js"); document.body.appendChild(script); +}; + +if (shouldInjectVisualEditor) { + if (document.readyState === "complete") { + injectVisualEditorScript(); + } else { + window.addEventListener("load", injectVisualEditorScript, { + once: true, + }); + } } // check if the storage has been removed and reload the data from embed script window.addEventListener("storage", (event) => { diff --git a/src/visual_editor/index.tsx b/src/visual_editor/index.tsx index 238e0ce0..a5c3d45b 100644 --- a/src/visual_editor/index.tsx +++ b/src/visual_editor/index.tsx @@ -1,5 +1,7 @@ import { debounce } from "lodash"; import React, { + Component, + ErrorInfo, FC, useCallback, useEffect, @@ -47,7 +49,7 @@ import DebugPanel from "./components/DebugPanel"; import VisualEditorCss from "./shadowDom.css"; import "./targetPage.css"; -import { isGlobalObserverPaused, resumeGlobalObserver } from "dom-mutator"; +import { isGlobalObserverPaused } from "dom-mutator"; const VisualEditor: FC<{}> = () => { const { x, y, setX, setY, parentStyles } = useFixedPositioning({ @@ -170,6 +172,10 @@ const VisualEditor: FC<{}> = () => { (selectedVariation?.css ? 1 : 0), [selectedVariation], ); + const issueError = error || aiError; + const hasNonFatalCspWarning = !!cspError && !cspError.isFatal && !issueError; + const hasIssues = !!issueError || !!cspError; + const issuesCount = (issueError ? 1 : 0) + (cspError ? 1 : 0); useEffect(() => { if (!variations.length) return; @@ -336,8 +342,14 @@ const VisualEditor: FC<{}> = () => { )} - {error || aiError ? ( - + {hasIssues ? ( + + + ) : null}
@@ -401,27 +413,160 @@ const VisualEditor: FC<{}> = () => { ); }; +type VisualEditorErrorBoundaryProps = { + onRetry: () => void; + children: React.ReactNode; +}; +type VisualEditorErrorBoundaryState = { + hasError: boolean; + message: string; +}; + +class VisualEditorErrorBoundary extends Component< + VisualEditorErrorBoundaryProps, + VisualEditorErrorBoundaryState +> { + state: VisualEditorErrorBoundaryState = { + hasError: false, + message: "", + }; + + static getDerivedStateFromError(error: Error) { + return { hasError: true, message: error?.message || "Unknown error" }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + window.postMessage( + { + type: "GB_ERROR", + error: `visual-editor-crash: ${error?.message || "Unknown error"}; ${info.componentStack || ""}`, + }, + window.location.origin, + ); + } + + private retry = () => { + this.setState({ hasError: false, message: "" }); + this.props.onRetry(); + }; + + render() { + if (this.state.hasError) { + return ( +
+

Visual Editor hit a runtime error.

+

{this.state.message}

+ +
+ ); + } + return this.props.children; + } +} + /** * mounting the visual editor */ export const CONTAINER_ID = "__gb_visual_editor"; -const container = document.createElement("div"); -container.id = CONTAINER_ID; +export let shadowRoot: ShadowRoot | null = null; +let root: ReactDOM.Root | null = null; +let mountObserver: MutationObserver | null = null; +let remountTimeout: number | null = null; +let remountCount = 0; +let remountWindowStart = 0; +const REMOUNT_WINDOW_MS = 10000; +const MAX_REMOUNTS_PER_WINDOW = 5; + +const isNodeAttached = (node: Node | null) => + !!node && document.documentElement.contains(node); + +const scheduleRemount = () => { + const now = Date.now(); + if (now - remountWindowStart > REMOUNT_WINDOW_MS) { + remountWindowStart = now; + remountCount = 0; + } + if (remountCount >= MAX_REMOUNTS_PER_WINDOW) return; + remountCount += 1; + + if (remountTimeout) window.clearTimeout(remountTimeout); + remountTimeout = window.setTimeout(() => { + ensureVisualEditorMounted(); + }, 150); +}; -export const shadowRoot = container?.attachShadow({ mode: "open" }); +const getContainer = () => + document.getElementById(CONTAINER_ID) as HTMLDivElement | null; + +const ensureVisualEditorMounted = () => { + let container = getContainer(); + if (!container || !isNodeAttached(container)) { + container = document.createElement("div"); + container.id = CONTAINER_ID; + document.body.appendChild(container); + } + + if (!container.shadowRoot) { + shadowRoot = container.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ` + +
+ `; + root = null; + } else { + shadowRoot = container.shadowRoot; + if (!shadowRoot.querySelector("#visual-editor-root")) { + shadowRoot.innerHTML = ` + +
+ `; + root = null; + } + } + + const mountNode = shadowRoot?.querySelector("#visual-editor-root"); + if (!mountNode) return; + + if (!root) { + root = ReactDOM.createRoot(mountNode); + } + root.render( + ensureVisualEditorMounted()}> + + , + ); -if (shadowRoot) { - shadowRoot.innerHTML = ` - -
- `; -} + if (!mountObserver) { + mountObserver = new MutationObserver(() => { + const currentContainer = getContainer(); + if (!currentContainer || !isNodeAttached(currentContainer)) { + scheduleRemount(); + } + }); + mountObserver.observe(document.documentElement, { + childList: true, + subtree: true, + }); + } +}; -document.body.appendChild(container); +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + ensureVisualEditorMounted(); + } +}); -const root = ReactDOM.createRoot( - shadowRoot.querySelector("#visual-editor-root")!, -); +window.addEventListener("pagehide", () => { + if (remountTimeout) { + window.clearTimeout(remountTimeout); + remountTimeout = null; + } +}); -root.render(); +ensureVisualEditorMounted(); From bd3c771c09a1d65528bf375688309e4fd8fa9563 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 15:37:13 -0800 Subject: [PATCH 06/11] scope csp with severity --- .../lib/hooks/useVisualChangeset.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/visual_editor/lib/hooks/useVisualChangeset.ts b/src/visual_editor/lib/hooks/useVisualChangeset.ts index 4c33f675..08f59054 100644 --- a/src/visual_editor/lib/hooks/useVisualChangeset.ts +++ b/src/visual_editor/lib/hooks/useVisualChangeset.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { CSPError, VisualEditorVariation, @@ -9,6 +9,7 @@ import { UpdateVisualChangesetRequestMessage, } from "devtools"; import normalizeVariations from "../normalizeVariations"; +import { classifyCSPViolation } from "../csp"; type UseVisualChangesetHook = (visualChangesetId: string) => { loading: boolean; experimentUrl: string | null; @@ -36,14 +37,29 @@ const useVisualChangeset: UseVisualChangesetHook = (visualChangesetId) => { const [experiment, setExperiment] = useState(null); const [experimentUrl, setExperimentUrl] = useState(null); const [variations, setVariations] = useState([]); + const cspEventTimestamps = useRef>({}); - document.addEventListener("securitypolicyviolation", (e) => { - if (e.violatedDirective !== "script-src") return; - setError("csp-error"); - setCSPError({ - violatedDirective: e.violatedDirective, - }); - }); + useEffect(() => { + const cspHandler = (e: SecurityPolicyViolationEvent) => { + const classification = classifyCSPViolation(e); + if (!classification.isRelevant) return; + + // Deduplicate noisy CSP events for 2 seconds. + const now = Date.now(); + const lastSeen = cspEventTimestamps.current[classification.key] || 0; + if (now - lastSeen < 2000) return; + cspEventTimestamps.current[classification.key] = now; + + setCSPError(classification.details); + if (classification.isFatal) { + setError("csp-error"); + } + }; + + document.addEventListener("securitypolicyviolation", cspHandler); + return () => + document.removeEventListener("securitypolicyviolation", cspHandler); + }, []); const updateVisualChangeset = useCallback( async (variations: VisualEditorVariation[]) => { From 12751c7b87058c278117f634477d69b9cdd88099 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 15:37:19 -0800 Subject: [PATCH 07/11] add tests --- src/visual_editor/lib/csp.test.ts | 33 ++++++++++ src/visual_editor/lib/hydration.test.ts | 81 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/visual_editor/lib/csp.test.ts create mode 100644 src/visual_editor/lib/hydration.test.ts diff --git a/src/visual_editor/lib/csp.test.ts b/src/visual_editor/lib/csp.test.ts new file mode 100644 index 00000000..00cd02a0 --- /dev/null +++ b/src/visual_editor/lib/csp.test.ts @@ -0,0 +1,33 @@ +import { classifyCSPViolation } from "./csp"; + +const eventFactory = ( + overrides: Partial = {}, +): SecurityPolicyViolationEvent => + ({ + violatedDirective: "script-src", + effectiveDirective: "script-src", + blockedURI: "https://www.googletagmanager.com/gtm.js", + sourceFile: "https://example.com/demo", + ...overrides, + }) as SecurityPolicyViolationEvent; + +describe("classifyCSPViolation", () => { + it("returns non-fatal for third-party blocked scripts", () => { + const result = classifyCSPViolation(eventFactory()); + expect(result.isRelevant).toBe(true); + expect(result.isFatal).toBe(false); + expect(result.details.isFatal).toBe(false); + }); + + it("returns fatal for blocked extension resources", () => { + const result = classifyCSPViolation( + eventFactory({ + blockedURI: "chrome-extension://abc123/js/visual_editor.js", + }), + ); + expect(result.isRelevant).toBe(true); + expect(result.isFatal).toBe(true); + expect(result.details.isFatal).toBe(true); + }); +}); + diff --git a/src/visual_editor/lib/hydration.test.ts b/src/visual_editor/lib/hydration.test.ts new file mode 100644 index 00000000..ae32320e --- /dev/null +++ b/src/visual_editor/lib/hydration.test.ts @@ -0,0 +1,81 @@ +import { waitForHydrationSafe } from "./hydration"; + +class MockMutationObserver { + callback: MutationCallback; + constructor(callback: MutationCallback) { + this.callback = callback; + } + observe() { + // no-op + } + disconnect() { + // no-op + } +} + +type ListenerMap = Record void>>; + +describe("waitForHydrationSafe", () => { + const listeners: ListenerMap = {}; + let readyState = "loading"; + + beforeEach(() => { + jest.useFakeTimers(); + readyState = "loading"; + + const mockWindow = { + setTimeout, + clearTimeout, + addEventListener: (event: string, cb: () => void) => { + listeners[event] = listeners[event] || []; + listeners[event].push(cb); + }, + removeEventListener: (event: string, cb: () => void) => { + listeners[event] = (listeners[event] || []).filter((fn) => fn !== cb); + }, + }; + const mockDocument = { + get readyState() { + return readyState; + }, + documentElement: {}, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).window = mockWindow; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).document = mockDocument; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).MutationObserver = MockMutationObserver; + }); + + afterEach(() => { + jest.useRealTimers(); + Object.keys(listeners).forEach((k) => delete listeners[k]); + }); + + it("waits until load event when document is still loading", async () => { + const promise = waitForHydrationSafe({ + quietWindowMs: 30, + maxWaitMs: 200, + }); + + readyState = "complete"; + (listeners.load || []).forEach((cb) => cb()); + jest.advanceTimersByTime(35); + + await expect(promise).resolves.toBeUndefined(); + }); + + it("resolves after quiet window when page is already complete", async () => { + readyState = "complete"; + const promise = waitForHydrationSafe({ + quietWindowMs: 20, + maxWaitMs: 200, + }); + + jest.advanceTimersByTime(25); + await expect(promise).resolves.toBeUndefined(); + }); +}); + From a38c01d69778265aea92fb7e4783d447498da3e6 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 15:49:33 -0800 Subject: [PATCH 08/11] fix error for svg elements --- src/visual_editor/lib/hooks/useGhostElement.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/visual_editor/lib/hooks/useGhostElement.ts b/src/visual_editor/lib/hooks/useGhostElement.ts index 5578a126..19932d20 100644 --- a/src/visual_editor/lib/hooks/useGhostElement.ts +++ b/src/visual_editor/lib/hooks/useGhostElement.ts @@ -11,12 +11,25 @@ type UseGhostElementHook = (args: { const cloneElement = (element: HTMLElement) => { const clone = element.cloneNode(true) as HTMLElement; const computedStyles = window.getComputedStyle(element); + const isSvgClone = clone instanceof SVGElement; for (let i = 0; i < computedStyles.length; i++) { const styleProperty = computedStyles[i]; + const styleValue = computedStyles.getPropertyValue(styleProperty); + + // Browsers may map these to SVG presentation attributes, where "auto" + // is invalid and logs errors like: attribute height: Expected length. + if ( + isSvgClone && + (styleProperty === "height" || styleProperty === "width") && + styleValue.trim() === "auto" + ) { + continue; + } + clone.style.setProperty( styleProperty, - computedStyles.getPropertyValue(styleProperty), + styleValue, ); } From ea7b84eba2ac04b8826df2945ac54be8f93e641c Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 15:51:50 -0800 Subject: [PATCH 09/11] bump growthbook version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index faf581fa..4e477049 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@chakra-ui/react": "^2.8.2", "@emotion/react": "^11.13.0", "@emotion/styled": "^11.13.0", - "@growthbook/growthbook": "^1.5.0", + "@growthbook/growthbook": "^1.6.5", "@medv/finder": "^3.2.0", "@phosphor-icons/react": "^2.1.7", "@radix-ui/colors": "^3.0.0", From 8ef15388fec5aa0a82df0d109ec3ac2b325baf7d Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 15:59:47 -0800 Subject: [PATCH 10/11] fix error condition --- src/visual_editor/components/ErrorDisplay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/visual_editor/components/ErrorDisplay.tsx b/src/visual_editor/components/ErrorDisplay.tsx index 45a2afdf..918f94b8 100644 --- a/src/visual_editor/components/ErrorDisplay.tsx +++ b/src/visual_editor/components/ErrorDisplay.tsx @@ -45,7 +45,7 @@ interface ErrorDisplayProps { cspError: CSPError | null; } const ErrorDisplay: FC = ({ error, cspError }) => { - if (cspError && !cspError.isFatal && error !== "csp-error") { + if (cspError && !cspError.isFatal && !error) { return ; } From f514fccc9aa8036b7f4b9e2898a74306914cd996 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 26 Feb 2026 16:03:55 -0800 Subject: [PATCH 11/11] remove pendingMutationsRef --- src/visual_editor/lib/hooks/useEditMode.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/visual_editor/lib/hooks/useEditMode.ts b/src/visual_editor/lib/hooks/useEditMode.ts index 750e4d27..f62e0e14 100644 --- a/src/visual_editor/lib/hooks/useEditMode.ts +++ b/src/visual_editor/lib/hooks/useEditMode.ts @@ -310,7 +310,6 @@ const useEditMode: UseEditModeHook = ({ // that the DOM is in the correct state const mutateRevert = useRef<(() => void) | null>(null); const [hydrationSafe, setHydrationSafe] = useState(false); - const pendingMutationsRef = useRef(); useEffect(() => { let cancelled = false; @@ -343,20 +342,10 @@ const useEditMode: UseEditModeHook = ({ }; useEffect(() => { - if (!hydrationSafe) { - pendingMutationsRef.current = variation?.domMutations; - return; - } + if (!hydrationSafe) return; runMutations(variation?.domMutations); }, [variation, hydrationSafe]); - useEffect(() => { - if (!hydrationSafe) return; - if (!pendingMutationsRef.current) return; - runMutations(pendingMutationsRef.current); - pendingMutationsRef.current = undefined; - }, [hydrationSafe]); - const hasTextInChildren = (element: Element) => { for (let child of element.children) { // Trim the child element's text content