Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions devtools.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 15 additions & 4 deletions src/content_script/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -202,17 +203,27 @@ 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;
script.charset = "utf-8";
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) => {
Expand Down
57 changes: 42 additions & 15 deletions src/visual_editor/components/ErrorDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,41 @@ import React, { useRef, FC, useEffect } from "react";
import { CSPError } from "../../../devtools";

const CSPErrorDisplay = ({ cspError }: { cspError: CSPError | null }) => (
<div className="p-4 text-red-400">
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{" "}
<a
className="underline"
href="https://docs.growthbook.io/app/visual#security-requirements"
target="_blank"
rel="noreferrer"
>
'Security Requirements'
</a>{" "}
for details.
<div className={cspError?.isFatal ? "p-4 text-red-400" : "p-4 text-amber-300"}>
<p className="mb-2">
{cspError?.isFatal ? "Critical CSP restriction" : "CSP warning"}:{" "}
{cspError
? `${cspError.effectiveDirective || cspError.violatedDirective}`
: "Unknown directive"}
.
</p>
{!cspError?.isFatal ? (
<p className="text-sm">
Some site scripts were blocked by CSP. Visual Editor should stay active.
</p>
) : (
<p className="text-sm">
Extension resources required by Visual Editor were blocked.
</p>
)}
<p className="text-xs mt-2 break-all">
Blocked URI: {cspError?.blockedURI || "unknown"}
</p>
<p className="text-xs mt-1 break-all">
Source: {cspError?.sourceFile || "unknown"}
</p>
<p className="text-xs mt-2">
Refer to{" "}
<a
className="underline"
href="https://docs.growthbook.io/app/visual#security-requirements"
target="_blank"
rel="noreferrer"
>
Visual Editor Security Requirements
</a>
.
</p>
</div>
);

Expand All @@ -23,6 +45,10 @@ interface ErrorDisplayProps {
cspError: CSPError | null;
}
const ErrorDisplay: FC<ErrorDisplayProps> = ({ error, cspError }) => {
if (cspError && !cspError.isFatal && !error) {
return <CSPErrorDisplay cspError={cspError} />;
}

switch (error) {
case "no-api-host":
case "no-api-key":
Expand Down Expand Up @@ -52,15 +78,16 @@ const ErrorDisplay: FC<ErrorDisplayProps> = ({ error, cspError }) => {
export default (props: ErrorDisplayProps) => {
const { error, cspError } = props;
const errorContainerRef = useRef<HTMLDivElement | null>(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 (
<div ref={errorContainerRef}>
Expand Down
179 changes: 162 additions & 17 deletions src/visual_editor/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { debounce } from "lodash";
import React, {
Component,
ErrorInfo,
FC,
useCallback,
useEffect,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -336,8 +342,14 @@ const VisualEditor: FC<{}> = () => {
</VisualEditorSection>
)}

{error || aiError ? (
<ErrorDisplay error={error || aiError} cspError={cspError} />
{hasIssues ? (
<VisualEditorSection
title={`Issues (${issuesCount})`}
isCollapsible
isExpanded={!hasNonFatalCspWarning}
>
<ErrorDisplay error={issueError} cspError={cspError} />
</VisualEditorSection>
) : null}

<div className="m-4 text-center">
Expand Down Expand Up @@ -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 (
<div className="rounded-xl shadow-xl z-max w-80 bg-dark p-4 text-red-400">
<p className="text-sm mb-2">Visual Editor hit a runtime error.</p>
<p className="text-xs mb-3 break-words">{this.state.message}</p>
<button
className="bg-slate-600 text-slate-100 px-3 py-1 rounded text-xs"
onClick={this.retry}
>
Retry
</button>
</div>
);
}
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 = `
<style>${VisualEditorCss}</style>
<div id="visual-editor-root"></div>
`;
root = null;
} else {
shadowRoot = container.shadowRoot;
if (!shadowRoot.querySelector("#visual-editor-root")) {
shadowRoot.innerHTML = `
<style>${VisualEditorCss}</style>
<div id="visual-editor-root"></div>
`;
root = null;
}
}

const mountNode = shadowRoot?.querySelector("#visual-editor-root");
if (!mountNode) return;

if (!root) {
root = ReactDOM.createRoot(mountNode);
}
root.render(
<VisualEditorErrorBoundary onRetry={() => ensureVisualEditorMounted()}>
<VisualEditor />
</VisualEditorErrorBoundary>,
);

if (shadowRoot) {
shadowRoot.innerHTML = `
<style>${VisualEditorCss}</style>
<div id="visual-editor-root"></div>
`;
}
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(<VisualEditor />);
ensureVisualEditorMounted();
Loading