diff --git a/packages/ui/src/shell/App.tsx b/packages/ui/src/shell/App.tsx index ee813c1a2f..da3e9c62d8 100644 --- a/packages/ui/src/shell/App.tsx +++ b/packages/ui/src/shell/App.tsx @@ -26,6 +26,7 @@ import { BootstrapFallback } from "@posthog/ui/shell/BootstrapFallback"; import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { useThemeStore } from "@posthog/ui/shell/themeStore"; +import { useAppVisibilityWatchdog } from "@posthog/ui/shell/useAppVisibilityWatchdog"; import { Flex, Spinner, Text } from "@radix-ui/themes"; import { RouterProvider } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; @@ -102,6 +103,17 @@ function App({ devToolbar }: AppProps) { setShowTransition(false); }; + const mainRef = useRef(null); + // Mirrors the "main" branch of renderContent() below; keep the two in sync. + const showingMainApp = + isBootstrapped && + isAuthenticated && + hasCompletedOnboarding && + !isCheckingAccess && + !needsInviteCode && + !needsAiApproval; + useAppVisibilityWatchdog(mainRef, showingMainApp); + if (!isBootstrapped) { return ; } @@ -176,13 +188,7 @@ function App({ devToolbar }: AppProps) { } return ( - + {/* Surfaces a toast when a backgrounded canvas generation finishes, from anywhere in the app. Sibling of the router so it stays mounted diff --git a/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts b/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts new file mode 100644 index 0000000000..14f76efa7f --- /dev/null +++ b/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts @@ -0,0 +1,135 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/ui/shell/analytics", () => ({ + captureException: vi.fn(), +})); + +vi.mock("@posthog/ui/shell/logger", () => ({ + logger: { + scope: () => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +import { captureException } from "@posthog/ui/shell/analytics"; +import { useAppVisibilityWatchdog } from "./useAppVisibilityWatchdog"; + +function mountElement(opacity: string, width: number, height: number) { + const element = document.createElement("div"); + element.style.opacity = opacity; + element.getBoundingClientRect = () => ({ width, height }) as DOMRect; + document.body.append(element); + return element; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.mocked(captureException).mockClear(); + document.body.innerHTML = ""; +}); + +describe("useAppVisibilityWatchdog", () => { + it.each([ + { + name: "invisible via opacity", + opacity: "0", + width: 1200, + height: 800, + active: true, + reports: true, + }, + { + name: "collapsed to zero size", + opacity: "1", + width: 0, + height: 0, + active: true, + reports: true, + }, + { + name: "visible", + opacity: "1", + width: 1200, + height: 800, + active: true, + reports: false, + }, + { + name: "inactive", + opacity: "0", + width: 1200, + height: 800, + active: false, + reports: false, + }, + ])( + "reports=$reports when $name", + ({ opacity, width, height, active, reports }) => { + const ref = { current: mountElement(opacity, width, height) }; + renderHook(() => useAppVisibilityWatchdog(ref, active)); + + vi.advanceTimersByTime(3000); + + expect(captureException).toHaveBeenCalledTimes(reports ? 1 : 0); + }, + ); + + it("reports the element's opacity and source", () => { + const ref = { current: mountElement("0", 1200, 800) }; + renderHook(() => useAppVisibilityWatchdog(ref, true)); + + vi.advanceTimersByTime(3000); + + expect(captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + source: "app-visibility-watchdog", + opacity: 0, + }), + ); + }); + + it("does not report after unmounting before the deadline", () => { + const ref = { current: mountElement("0", 1200, 800) }; + const { unmount } = renderHook(() => useAppVisibilityWatchdog(ref, true)); + + unmount(); + vi.advanceTimersByTime(3000); + + expect(captureException).not.toHaveBeenCalled(); + }); + + it("arms when active flips from false to true", () => { + const ref = { current: mountElement("0", 1200, 800) }; + const { rerender } = renderHook( + ({ active }) => useAppVisibilityWatchdog(ref, active), + { initialProps: { active: false } }, + ); + + vi.advanceTimersByTime(3000); + expect(captureException).not.toHaveBeenCalled(); + + rerender({ active: true }); + vi.advanceTimersByTime(3000); + expect(captureException).toHaveBeenCalledOnce(); + }); + + it("does nothing when the ref never attaches", () => { + const ref = { current: null }; + renderHook(() => useAppVisibilityWatchdog(ref, true)); + + vi.advanceTimersByTime(3000); + + expect(captureException).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/shell/useAppVisibilityWatchdog.ts b/packages/ui/src/shell/useAppVisibilityWatchdog.ts new file mode 100644 index 0000000000..af1253deac --- /dev/null +++ b/packages/ui/src/shell/useAppVisibilityWatchdog.ts @@ -0,0 +1,36 @@ +import { captureException } from "@posthog/ui/shell/analytics"; +import { logger } from "@posthog/ui/shell/logger"; +import { type RefObject, useEffect } from "react"; + +const log = logger.scope("app-visibility"); +const VISIBILITY_CHECK_DELAY_MS = 3000; + +// Detects the "white screen but app alive" state: mounted and interactive yet stuck invisible. +export function useAppVisibilityWatchdog( + ref: RefObject, + active: boolean, +): void { + useEffect(() => { + if (!active) return; + const timer = setTimeout(() => { + const element = ref.current; + if (!element) return; + const computedOpacity = getComputedStyle(element).opacity; + const opacity = computedOpacity ? Number.parseFloat(computedOpacity) : 1; + const rect = element.getBoundingClientRect(); + if (opacity >= 0.01 && rect.width > 0 && rect.height > 0) return; + const detail = { + opacity, + width: Math.round(rect.width), + height: Math.round(rect.height), + route: window.location.hash, + }; + log.error("Main app mounted but not visible", detail); + captureException(new Error("Main app mounted but not visible"), { + ...detail, + source: "app-visibility-watchdog", + }); + }, VISIBILITY_CHECK_DELAY_MS); + return () => clearTimeout(timer); + }, [ref, active]); +} diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 4712bec6bd..d99735a7db 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -865,6 +865,25 @@ body { } } +/* Resting opacity is 1, so a dropped/stalled animation can never leave the app invisible. */ +.app-fade-in { + animation: appFadeIn 0.5s ease-out forwards; +} + +/* The window-blur freeze above pauses every keyframe animation; let this one-shot entrance finish anyway, or a blur mid-fade would strand the app invisible. */ +body.ph-window-blurred .app-fade-in { + animation-play-state: running !important; +} + +@keyframes appFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + .tree-item-hover:hover { background-color: var(--gray-3); }