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
20 changes: 13 additions & 7 deletions packages/ui/src/shell/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -102,6 +103,17 @@ function App({ devToolbar }: AppProps) {
setShowTransition(false);
};

const mainRef = useRef<HTMLDivElement>(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 <BootstrapFallback />;
}
Expand Down Expand Up @@ -176,13 +188,7 @@ function App({ devToolbar }: AppProps) {
}

return (
<motion.div
key="main"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: showTransition ? 0.5 : 0 }}
className="h-full"
>
<motion.div key="main" ref={mainRef} className="app-fade-in h-full">
<RouterProvider router={router} />
{/* Surfaces a toast when a backgrounded canvas generation finishes,
from anywhere in the app. Sibling of the router so it stays mounted
Expand Down
135 changes: 135 additions & 0 deletions packages/ui/src/shell/useAppVisibilityWatchdog.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Comment thread
charlesvien marked this conversation as resolved.
36 changes: 36 additions & 0 deletions packages/ui/src/shell/useAppVisibilityWatchdog.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>,
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]);
}
19 changes: 19 additions & 0 deletions packages/ui/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading