|
1 | 1 | import { debounce } from "lodash"; |
2 | 2 | import React, { |
| 3 | + Component, |
| 4 | + ErrorInfo, |
3 | 5 | FC, |
4 | 6 | useCallback, |
5 | 7 | useEffect, |
@@ -47,7 +49,7 @@ import DebugPanel from "./components/DebugPanel"; |
47 | 49 |
|
48 | 50 | import VisualEditorCss from "./shadowDom.css"; |
49 | 51 | import "./targetPage.css"; |
50 | | -import { isGlobalObserverPaused, resumeGlobalObserver } from "dom-mutator"; |
| 52 | +import { isGlobalObserverPaused } from "dom-mutator"; |
51 | 53 |
|
52 | 54 | const VisualEditor: FC<{}> = () => { |
53 | 55 | const { x, y, setX, setY, parentStyles } = useFixedPositioning({ |
@@ -170,6 +172,10 @@ const VisualEditor: FC<{}> = () => { |
170 | 172 | (selectedVariation?.css ? 1 : 0), |
171 | 173 | [selectedVariation], |
172 | 174 | ); |
| 175 | + const issueError = error || aiError; |
| 176 | + const hasNonFatalCspWarning = !!cspError && !cspError.isFatal && !issueError; |
| 177 | + const hasIssues = !!issueError || !!cspError; |
| 178 | + const issuesCount = (issueError ? 1 : 0) + (cspError ? 1 : 0); |
173 | 179 |
|
174 | 180 | useEffect(() => { |
175 | 181 | if (!variations.length) return; |
@@ -336,8 +342,14 @@ const VisualEditor: FC<{}> = () => { |
336 | 342 | </VisualEditorSection> |
337 | 343 | )} |
338 | 344 |
|
339 | | - {error || aiError ? ( |
340 | | - <ErrorDisplay error={error || aiError} cspError={cspError} /> |
| 345 | + {hasIssues ? ( |
| 346 | + <VisualEditorSection |
| 347 | + title={`Issues (${issuesCount})`} |
| 348 | + isCollapsible |
| 349 | + isExpanded={!hasNonFatalCspWarning} |
| 350 | + > |
| 351 | + <ErrorDisplay error={issueError} cspError={cspError} /> |
| 352 | + </VisualEditorSection> |
341 | 353 | ) : null} |
342 | 354 |
|
343 | 355 | <div className="m-4 text-center"> |
@@ -401,27 +413,160 @@ const VisualEditor: FC<{}> = () => { |
401 | 413 | ); |
402 | 414 | }; |
403 | 415 |
|
| 416 | +type VisualEditorErrorBoundaryProps = { |
| 417 | + onRetry: () => void; |
| 418 | + children: React.ReactNode; |
| 419 | +}; |
| 420 | +type VisualEditorErrorBoundaryState = { |
| 421 | + hasError: boolean; |
| 422 | + message: string; |
| 423 | +}; |
| 424 | + |
| 425 | +class VisualEditorErrorBoundary extends Component< |
| 426 | + VisualEditorErrorBoundaryProps, |
| 427 | + VisualEditorErrorBoundaryState |
| 428 | +> { |
| 429 | + state: VisualEditorErrorBoundaryState = { |
| 430 | + hasError: false, |
| 431 | + message: "", |
| 432 | + }; |
| 433 | + |
| 434 | + static getDerivedStateFromError(error: Error) { |
| 435 | + return { hasError: true, message: error?.message || "Unknown error" }; |
| 436 | + } |
| 437 | + |
| 438 | + componentDidCatch(error: Error, info: ErrorInfo) { |
| 439 | + window.postMessage( |
| 440 | + { |
| 441 | + type: "GB_ERROR", |
| 442 | + error: `visual-editor-crash: ${error?.message || "Unknown error"}; ${info.componentStack || ""}`, |
| 443 | + }, |
| 444 | + window.location.origin, |
| 445 | + ); |
| 446 | + } |
| 447 | + |
| 448 | + private retry = () => { |
| 449 | + this.setState({ hasError: false, message: "" }); |
| 450 | + this.props.onRetry(); |
| 451 | + }; |
| 452 | + |
| 453 | + render() { |
| 454 | + if (this.state.hasError) { |
| 455 | + return ( |
| 456 | + <div className="rounded-xl shadow-xl z-max w-80 bg-dark p-4 text-red-400"> |
| 457 | + <p className="text-sm mb-2">Visual Editor hit a runtime error.</p> |
| 458 | + <p className="text-xs mb-3 break-words">{this.state.message}</p> |
| 459 | + <button |
| 460 | + className="bg-slate-600 text-slate-100 px-3 py-1 rounded text-xs" |
| 461 | + onClick={this.retry} |
| 462 | + > |
| 463 | + Retry |
| 464 | + </button> |
| 465 | + </div> |
| 466 | + ); |
| 467 | + } |
| 468 | + return this.props.children; |
| 469 | + } |
| 470 | +} |
| 471 | + |
404 | 472 | /** |
405 | 473 | * mounting the visual editor |
406 | 474 | */ |
407 | 475 | export const CONTAINER_ID = "__gb_visual_editor"; |
408 | 476 |
|
409 | | -const container = document.createElement("div"); |
410 | | -container.id = CONTAINER_ID; |
| 477 | +export let shadowRoot: ShadowRoot | null = null; |
| 478 | +let root: ReactDOM.Root | null = null; |
| 479 | +let mountObserver: MutationObserver | null = null; |
| 480 | +let remountTimeout: number | null = null; |
| 481 | +let remountCount = 0; |
| 482 | +let remountWindowStart = 0; |
| 483 | +const REMOUNT_WINDOW_MS = 10000; |
| 484 | +const MAX_REMOUNTS_PER_WINDOW = 5; |
| 485 | + |
| 486 | +const isNodeAttached = (node: Node | null) => |
| 487 | + !!node && document.documentElement.contains(node); |
| 488 | + |
| 489 | +const scheduleRemount = () => { |
| 490 | + const now = Date.now(); |
| 491 | + if (now - remountWindowStart > REMOUNT_WINDOW_MS) { |
| 492 | + remountWindowStart = now; |
| 493 | + remountCount = 0; |
| 494 | + } |
| 495 | + if (remountCount >= MAX_REMOUNTS_PER_WINDOW) return; |
| 496 | + remountCount += 1; |
| 497 | + |
| 498 | + if (remountTimeout) window.clearTimeout(remountTimeout); |
| 499 | + remountTimeout = window.setTimeout(() => { |
| 500 | + ensureVisualEditorMounted(); |
| 501 | + }, 150); |
| 502 | +}; |
411 | 503 |
|
412 | | -export const shadowRoot = container?.attachShadow({ mode: "open" }); |
| 504 | +const getContainer = () => |
| 505 | + document.getElementById(CONTAINER_ID) as HTMLDivElement | null; |
| 506 | + |
| 507 | +const ensureVisualEditorMounted = () => { |
| 508 | + let container = getContainer(); |
| 509 | + if (!container || !isNodeAttached(container)) { |
| 510 | + container = document.createElement("div"); |
| 511 | + container.id = CONTAINER_ID; |
| 512 | + document.body.appendChild(container); |
| 513 | + } |
| 514 | + |
| 515 | + if (!container.shadowRoot) { |
| 516 | + shadowRoot = container.attachShadow({ mode: "open" }); |
| 517 | + shadowRoot.innerHTML = ` |
| 518 | + <style>${VisualEditorCss}</style> |
| 519 | + <div id="visual-editor-root"></div> |
| 520 | + `; |
| 521 | + root = null; |
| 522 | + } else { |
| 523 | + shadowRoot = container.shadowRoot; |
| 524 | + if (!shadowRoot.querySelector("#visual-editor-root")) { |
| 525 | + shadowRoot.innerHTML = ` |
| 526 | + <style>${VisualEditorCss}</style> |
| 527 | + <div id="visual-editor-root"></div> |
| 528 | + `; |
| 529 | + root = null; |
| 530 | + } |
| 531 | + } |
| 532 | + |
| 533 | + const mountNode = shadowRoot?.querySelector("#visual-editor-root"); |
| 534 | + if (!mountNode) return; |
| 535 | + |
| 536 | + if (!root) { |
| 537 | + root = ReactDOM.createRoot(mountNode); |
| 538 | + } |
| 539 | + root.render( |
| 540 | + <VisualEditorErrorBoundary onRetry={() => ensureVisualEditorMounted()}> |
| 541 | + <VisualEditor /> |
| 542 | + </VisualEditorErrorBoundary>, |
| 543 | + ); |
413 | 544 |
|
414 | | -if (shadowRoot) { |
415 | | - shadowRoot.innerHTML = ` |
416 | | - <style>${VisualEditorCss}</style> |
417 | | - <div id="visual-editor-root"></div> |
418 | | - `; |
419 | | -} |
| 545 | + if (!mountObserver) { |
| 546 | + mountObserver = new MutationObserver(() => { |
| 547 | + const currentContainer = getContainer(); |
| 548 | + if (!currentContainer || !isNodeAttached(currentContainer)) { |
| 549 | + scheduleRemount(); |
| 550 | + } |
| 551 | + }); |
| 552 | + mountObserver.observe(document.documentElement, { |
| 553 | + childList: true, |
| 554 | + subtree: true, |
| 555 | + }); |
| 556 | + } |
| 557 | +}; |
420 | 558 |
|
421 | | -document.body.appendChild(container); |
| 559 | +document.addEventListener("visibilitychange", () => { |
| 560 | + if (document.visibilityState === "visible") { |
| 561 | + ensureVisualEditorMounted(); |
| 562 | + } |
| 563 | +}); |
422 | 564 |
|
423 | | -const root = ReactDOM.createRoot( |
424 | | - shadowRoot.querySelector("#visual-editor-root")!, |
425 | | -); |
| 565 | +window.addEventListener("pagehide", () => { |
| 566 | + if (remountTimeout) { |
| 567 | + window.clearTimeout(remountTimeout); |
| 568 | + remountTimeout = null; |
| 569 | + } |
| 570 | +}); |
426 | 571 |
|
427 | | -root.render(<VisualEditor />); |
| 572 | +ensureVisualEditorMounted(); |
0 commit comments