From 22d62682bc63a9c6d053e5bb7af983ec22d9ad83 Mon Sep 17 00:00:00 2001 From: Arian Moeini Date: Mon, 5 Oct 2026 12:04:21 +0200 Subject: [PATCH] perf(app): scope explainer card observers to chat --- .../src/components/explainer-cards.test.tsx | 54 +++++++++++++++++ apps/app/src/components/explainer-cards.tsx | 60 ++++++++++++------- 2 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 apps/app/src/components/explainer-cards.test.tsx diff --git a/apps/app/src/components/explainer-cards.test.tsx b/apps/app/src/components/explainer-cards.test.tsx new file mode 100644 index 0000000..348ae15 --- /dev/null +++ b/apps/app/src/components/explainer-cards.test.tsx @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { ExplainerCardsPortal } from "./explainer-cards"; + +function welcomeScreen() { + const welcome = document.createElement("div"); + welcome.setAttribute("data-testid", "copilot-welcome-screen"); + const content = document.createElement("div"); + content.appendChild(document.createElement("div")); + welcome.appendChild(content); + return welcome; +} + +afterEach(() => { + cleanup(); + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +describe("ExplainerCardsPortal", () => { + it("watches the chat's direct children and follows welcome screen changes", async () => { + const chat = document.createElement("div"); + chat.setAttribute("data-testid", "copilot-chat"); + const firstWelcome = welcomeScreen(); + chat.appendChild(firstWelcome); + document.body.appendChild(chat); + + const observe = vi.spyOn(MutationObserver.prototype, "observe"); + render(); + + expect(observe).toHaveBeenCalledWith(chat, { childList: true }); + expect(screen.getByText("Generative UI")).toBeInTheDocument(); + + const originalPortal = document.getElementById("explainer-cards-portal"); + originalPortal?.remove(); + await waitFor(() => { + expect(document.getElementById("explainer-cards-portal")).not.toBe( + originalPortal + ); + expect(screen.getByText("Generative UI")).toBeInTheDocument(); + }); + + firstWelcome.remove(); + await waitFor(() => { + expect(screen.queryByText("Generative UI")).not.toBeInTheDocument(); + }); + + chat.appendChild(welcomeScreen()); + await waitFor(() => { + expect(screen.getByText("Generative UI")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/app/src/components/explainer-cards.tsx b/apps/app/src/components/explainer-cards.tsx index 0b4e970..f4003a5 100644 --- a/apps/app/src/components/explainer-cards.tsx +++ b/apps/app/src/components/explainer-cards.tsx @@ -86,28 +86,49 @@ export function ExplainerCardsPortal() { const [portalTarget, setPortalTarget] = useState(null); useEffect(() => { + const chat = document.querySelector('[data-testid="copilot-chat"]'); + if (!chat) return; + const WELCOME_SELECTOR = '[data-testid="copilot-welcome-screen"]'; const PORTAL_ID = "explainer-cards-portal"; + let observedContent: HTMLElement | null = null; + let contentObserver: MutationObserver | null = null; const tryAttach = () => { - const welcomeScreen = document.querySelector(WELCOME_SELECTOR); + const welcomeScreen = chat.querySelector(WELCOME_SELECTOR); if (!welcomeScreen) { + contentObserver?.disconnect(); + contentObserver = null; + observedContent = null; + setPortalTarget(null); + return; + } + + // Insert portal container inside the welcome screen's main content div, + // before the suggestions row + const mainContent = welcomeScreen.children[0] as HTMLElement | undefined; + if (!mainContent) { + contentObserver?.disconnect(); + contentObserver = null; + observedContent = null; setPortalTarget(null); return; } - // Reuse existing portal container if present - let portal = document.getElementById(PORTAL_ID); + if (mainContent !== observedContent) { + contentObserver?.disconnect(); + observedContent = mainContent; + contentObserver = new MutationObserver(tryAttach); + contentObserver.observe(mainContent, { childList: true }); + } + + // Reuse the container if CopilotKit kept it during a welcome update. + let portal = mainContent.querySelector(`#${PORTAL_ID}`); if (portal) { setPortalTarget(portal); return; } - // Insert portal container inside the welcome screen's main content div, - // before the suggestions row - const mainContent = welcomeScreen.children[0] as HTMLElement | undefined; - if (!mainContent) return; - portal = document.createElement("div"); portal.id = PORTAL_ID; portal.style.width = "100%"; @@ -125,21 +146,14 @@ export function ExplainerCardsPortal() { tryAttach(); - const observer = new MutationObserver(() => { - const welcomeScreen = document.querySelector(WELCOME_SELECTOR); - if (!welcomeScreen) { - // Welcome screen removed (chat started) — clean up - const stale = document.getElementById(PORTAL_ID); - if (stale) stale.remove(); - setPortalTarget(null); - } else if (!document.getElementById(PORTAL_ID)) { - // Welcome screen appeared but no portal yet - tryAttach(); - } - }); - - observer.observe(document.body, { childList: true, subtree: true }); - return () => observer.disconnect(); + // The welcome screen is a direct child of the chat. Streaming updates + // happen deeper in the message tree and do not need to wake this observer. + const chatObserver = new MutationObserver(tryAttach); + chatObserver.observe(chat, { childList: true }); + return () => { + chatObserver.disconnect(); + contentObserver?.disconnect(); + }; }, []); if (!portalTarget) return null;