import { Component, useMemo, useState, type ReactNode } from "react"; import { useIsMobile } from "@/hooks/use-mobile"; import { isElectron } from "@/lib/electron"; import { usePluginStore } from "@/plugin-host/plugin-store"; import { retryPluginFrontend, syncPlugins } from "@/plugin-host/loader"; import { chosenLayoutId, useLookState } from "@/look/look-store"; import { getRegisteredLayout, useRegisteredLayouts, } from "@/look/layout-registry"; import { layoutStatus } from "@/look/layout-status"; import { safeLayoutInUrl } from "@/look/resolve-look"; import { useLookTheme } from "@/look/use-look-theme"; import { RepairView } from "@/look/RepairView"; /** Catches a crash while a layout draws, so the repair view can take over. */ class LayoutErrorBoundary extends Component< { onCrash: (error: Error) => void; children: ReactNode }, { crashed: boolean } > { state = { crashed: false }; static getDerivedStateFromError() { return { crashed: true }; } componentDidCatch(error: Error) { console.error("[layout] crashed while drawing", error); this.props.onCrash(error); } render() { return this.state.crashed ? null : this.props.children; } } interface CrashState { layoutId: string | null; count: number; error: string | null; } /** * Draws the chosen layout, or the repair view when it cannot be drawn. A * crash remounts the layout once; a second hands over to the repair view. */ export function LayoutHost() { const look = useLookState(); const store = usePluginStore(); useRegisteredLayouts(); const isMobile = useIsMobile(); const [crash, setCrash] = useState({ layoutId: null, count: 0, error: null, }); const [attempt, setAttempt] = useState(0); // The look store changes identity whenever the choice does. // eslint-disable-next-line react-hooks/exhaustive-deps const layoutId = useMemo(() => chosenLayoutId(), [look]); useLookTheme(layoutId); const crashes = crash.layoutId === layoutId ? crash.count : 0; const owner = [...store.records.values()].find((record) => record.summary.contributes?.layouts?.some((l) => l.id === layoutId), ); const status = layoutStatus({ layoutId, safeFromUrl: safeLayoutInUrl(window.location.search), safeFromEnv: look.response?.safeLayout === true, ready: look.loaded && store.settled, registered: getRegisteredLayout(layoutId), owner, unavailable: look.response?.unavailable.find((plugin) => plugin.layouts.includes(layoutId), ), crashes, lastCrash: crash.layoutId === layoutId ? crash.error : null, }); const retry = () => { setCrash({ layoutId: null, count: 0, error: null }); setAttempt((value) => value + 1); if (owner) void retryPluginFrontend(owner.summary.id); else void syncPlugins(); }; if (status.kind === "loading") { return
; } if (status.kind === "repair") { return ; } const { layout } = status; // The desktop app always gets the desktop layout. const Draw = isMobile && !isElectron() && layout.mobileComponent ? layout.mobileComponent : layout.component; return ( setCrash((previous) => ({ layoutId, count: (previous.layoutId === layoutId ? previous.count : 0) + 1, error: error.message, })) } > ); }