From 945ba3989c5ca09cf5ad5b9d66f7878367de18dd Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 15:27:53 -0700 Subject: [PATCH 1/5] prevent white screen when app mounts invisible --- packages/ui/src/shell/App.tsx | 19 +++-- .../shell/useAppVisibilityWatchdog.test.ts | 84 +++++++++++++++++++ .../ui/src/shell/useAppVisibilityWatchdog.ts | 37 ++++++++ packages/ui/src/styles/globals.css | 14 ++++ 4 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/shell/useAppVisibilityWatchdog.test.ts create mode 100644 packages/ui/src/shell/useAppVisibilityWatchdog.ts diff --git a/packages/ui/src/shell/App.tsx b/packages/ui/src/shell/App.tsx index ee813c1a2f..4bc7fab02c 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,16 @@ function App({ devToolbar }: AppProps) { setShowTransition(false); }; + const mainRef = useRef(null); + const showingMainApp = + isBootstrapped && + isAuthenticated && + hasCompletedOnboarding && + !isCheckingAccess && + !needsInviteCode && + !needsAiApproval; + useAppVisibilityWatchdog(mainRef, showingMainApp); + if (!isBootstrapped) { return ; } @@ -176,13 +187,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..7c82e32cee --- /dev/null +++ b/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts @@ -0,0 +1,84 @@ +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("reports when the main app is mounted but invisible", () => { + const ref = { current: mountElement("0", 1200, 800) }; + renderHook(() => useAppVisibilityWatchdog(ref, true)); + + vi.advanceTimersByTime(3000); + + expect(captureException).toHaveBeenCalledOnce(); + expect(captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + source: "app-visibility-watchdog", + opacity: 0, + }), + ); + }); + + it("reports when the main app has collapsed to zero size", () => { + const ref = { current: mountElement("1", 0, 0) }; + renderHook(() => useAppVisibilityWatchdog(ref, true)); + + vi.advanceTimersByTime(3000); + + expect(captureException).toHaveBeenCalledOnce(); + }); + + it("stays silent when the main app is visible", () => { + const ref = { current: mountElement("1", 1200, 800) }; + renderHook(() => useAppVisibilityWatchdog(ref, true)); + + vi.advanceTimersByTime(3000); + + expect(captureException).not.toHaveBeenCalled(); + }); + + it("does nothing while inactive", () => { + const ref = { current: mountElement("0", 1200, 800) }; + renderHook(() => useAppVisibilityWatchdog(ref, false)); + + 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..092873127e --- /dev/null +++ b/packages/ui/src/shell/useAppVisibilityWatchdog.ts @@ -0,0 +1,37 @@ +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 opacity = Number.parseFloat( + getComputedStyle(element).opacity || "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..122cb2a09b 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -865,6 +865,20 @@ 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; +} + +@keyframes appFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + .tree-item-hover:hover { background-color: var(--gray-3); } From 279ccdb291768f0d0019a67e81849d92bc68b6bd Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 17:38:48 -0700 Subject: [PATCH 2/5] keep app fade-in running while window is blurred --- packages/ui/src/styles/globals.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 122cb2a09b..d99735a7db 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -870,6 +870,11 @@ body { 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; From 128e4453502650bd44ed2940b2dbd98d4b729dc1 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 17:39:40 -0700 Subject: [PATCH 3/5] cover watchdog cleanup, re-arm and null-ref paths --- packages/ui/src/shell/App.tsx | 1 + .../shell/useAppVisibilityWatchdog.test.ts | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/ui/src/shell/App.tsx b/packages/ui/src/shell/App.tsx index 4bc7fab02c..da3e9c62d8 100644 --- a/packages/ui/src/shell/App.tsx +++ b/packages/ui/src/shell/App.tsx @@ -104,6 +104,7 @@ function App({ devToolbar }: AppProps) { }; const mainRef = useRef(null); + // Mirrors the "main" branch of renderContent() below; keep the two in sync. const showingMainApp = isBootstrapped && isAuthenticated && diff --git a/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts b/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts index 7c82e32cee..db04865169 100644 --- a/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts +++ b/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts @@ -81,4 +81,38 @@ describe("useAppVisibilityWatchdog", () => { expect(captureException).not.toHaveBeenCalled(); }); + + 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(); + }); }); From fb8c19778e892486fbd1a8498318dd0c789d8b45 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 17:53:57 -0700 Subject: [PATCH 4/5] read computed opacity without the dead fallback --- packages/ui/src/shell/useAppVisibilityWatchdog.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/shell/useAppVisibilityWatchdog.ts b/packages/ui/src/shell/useAppVisibilityWatchdog.ts index 092873127e..af1253deac 100644 --- a/packages/ui/src/shell/useAppVisibilityWatchdog.ts +++ b/packages/ui/src/shell/useAppVisibilityWatchdog.ts @@ -15,9 +15,8 @@ export function useAppVisibilityWatchdog( const timer = setTimeout(() => { const element = ref.current; if (!element) return; - const opacity = Number.parseFloat( - getComputedStyle(element).opacity || "1", - ); + 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 = { From e63a6f3e3adcd1731d7ea3699b7cc11448477a84 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 17:54:19 -0700 Subject: [PATCH 5/5] parameterize watchdog visibility cases --- .../shell/useAppVisibilityWatchdog.test.ts | 75 ++++++++++++------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts b/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts index db04865169..14f76efa7f 100644 --- a/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts +++ b/packages/ui/src/shell/useAppVisibilityWatchdog.test.ts @@ -39,13 +39,57 @@ afterEach(() => { }); describe("useAppVisibilityWatchdog", () => { - it("reports when the main app is mounted but invisible", () => { + 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).toHaveBeenCalledOnce(); expect(captureException).toHaveBeenCalledWith( expect.any(Error), expect.objectContaining({ @@ -55,33 +99,6 @@ describe("useAppVisibilityWatchdog", () => { ); }); - it("reports when the main app has collapsed to zero size", () => { - const ref = { current: mountElement("1", 0, 0) }; - renderHook(() => useAppVisibilityWatchdog(ref, true)); - - vi.advanceTimersByTime(3000); - - expect(captureException).toHaveBeenCalledOnce(); - }); - - it("stays silent when the main app is visible", () => { - const ref = { current: mountElement("1", 1200, 800) }; - renderHook(() => useAppVisibilityWatchdog(ref, true)); - - vi.advanceTimersByTime(3000); - - expect(captureException).not.toHaveBeenCalled(); - }); - - it("does nothing while inactive", () => { - const ref = { current: mountElement("0", 1200, 800) }; - renderHook(() => useAppVisibilityWatchdog(ref, false)); - - vi.advanceTimersByTime(3000); - - expect(captureException).not.toHaveBeenCalled(); - }); - it("does not report after unmounting before the deadline", () => { const ref = { current: mountElement("0", 1200, 800) }; const { unmount } = renderHook(() => useAppVisibilityWatchdog(ref, true));