diff --git a/src/main.tsx b/src/main.tsx index 2ebeab8b1..eaef68621 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -24,7 +24,15 @@ import { useTranslation } from "react-i18next"; import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext"; import { ConnectionDefaultsProvider } from "@/contexts/ConnectionDefaultsContext"; import { BrandingProvider } from "@/contexts/BrandingContext"; -import { fetchGuestViews, startPluginRuntime } from "@/plugin-host/loader"; +import { + fetchGuestViews, + startPluginRuntime, + stopPluginRuntime, +} from "@/plugin-host/loader"; +import { settledPromise } from "@/plugin-host/plugin-store"; +import { resetShellBridge } from "@/plugin-host/shell-bridge"; +import { preloadPermissions } from "@/hooks/use-permissions"; +import { getUserPreferences } from "@/api/open-tabs-api"; import { standaloneViewFor } from "@/shell/tab-registry"; import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder"; @@ -39,7 +47,12 @@ const ElectronVersionCheck = lazy(() => ); type Phase = - "verifying" | "idle-auth" | "fading-in" | "idle-app" | "fading-out"; + | "verifying" + | "idle-auth" + | "loading-app" + | "fading-in" + | "idle-app" + | "fading-out"; type LogoutOptions = { manual?: boolean; @@ -180,6 +193,14 @@ function App() { } } fadingInFromLoginRef.current = false; + setPhase("loading-app"); + await Promise.all([ + startPluginRuntime() + .catch(() => {}) + .then(() => settledPromise()), + preloadPermissions(), + getUserPreferences().catch(() => undefined), + ]); setPhase("fading-in"); timerRef.current = setTimeout(() => setPhase("idle-app"), 450); }) @@ -238,8 +259,18 @@ function App() { clearDesktopManualLogout(); setAuthUsername(u); fadingInFromLoginRef.current = true; - setPhase("fading-in"); - timerRef.current = setTimeout(() => setPhase("idle-app"), 450); + setPhase("loading-app"); + void (async () => { + await Promise.all([ + startPluginRuntime() + .catch(() => {}) + .then(() => settledPromise()), + preloadPermissions(), + getUserPreferences().catch(() => undefined), + ]); + setPhase("fading-in"); + timerRef.current = setTimeout(() => setPhase("idle-app"), 450); + })(); if (isElectron()) { window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {}); const localJwt = localStorage.getItem("jwt"); @@ -264,6 +295,10 @@ function App() { // isn't subject to batching, so it correctly dedupes within one tick. if (loggingOutRef.current) return; loggingOutRef.current = true; + // The next user may see different plugins, so nothing they registered + // should survive into the next session. + void stopPluginRuntime(); + resetShellBridge(); clearStoredAuth(); localStorage.removeItem("jwt"); if (isElectron() && options?.manual) { @@ -297,7 +332,7 @@ function App() { const { t } = useTranslation(); const isTransitioning = phase === "fading-in" || phase === "fading-out"; - if (phase === "verifying") { + if (phase === "verifying" || phase === "loading-app") { return (
diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index b7dd36e8e..66731f9a5 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -175,12 +175,10 @@ import { } from "@/shell/tab-registry"; import { getPanel, usePanels } from "@/shell/panel-registry"; import { invokeAction } from "@/shell/action-registry"; -import { startPluginRuntime, stopPluginRuntime } from "@/plugin-host/loader"; import { usePluginStore } from "@/plugin-host/plugin-store"; import { notifyShellReady, notifyTabsChanged, - resetShellBridge, setShellCallbacks, setShellHosts, setShellLayoutProvider, @@ -401,17 +399,6 @@ export function AppShell({ .catch(() => setIsAdmin(false)); }, []); - // Plugin frontends register the tabs, panels and actions that a saved - // session may need, so they load before tabs are restored. On logout every - // plugin is deactivated, since the next user may see different ones. - useEffect(() => { - startPluginRuntime().catch(() => {}); - return () => { - void stopPluginRuntime(); - resetShellBridge(); - }; - }, []); - const handleDismissDonationModal = useCallback(() => { setShowDonationModal(false); dismissDonationModal().catch(() => {}); @@ -2661,6 +2648,7 @@ export function AppShell({ splitMode={splitMode} username={username} isAdmin={showAdminUI} + pluginsSettled={pluginsSettled} onRailClick={handleRailClick} onOpenTab={openSingletonTab} onOpenInRightDock={openInRightDock} diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index 565a6ca97..78c92c049 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -169,9 +169,19 @@ export function parseCustomKeybindings( } } +const userPreferencesCache = createTtlRequestCache(10_000); + +/** + * Cached briefly so the loading gate's prefetch and the shell's own read on + * mount share one request instead of two, which is what let stale + * localStorage flags (hiddenRailTabs, pinAppRail) flash before the real + * values landed a moment after the shell first rendered. + */ export async function getUserPreferences(): Promise { - const response = await authApi.get("/user-preferences"); - return response.data; + return userPreferencesCache.get(async () => { + const response = await authApi.get("/user-preferences"); + return response.data; + }); } export async function saveUserPreferences( diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index c9ec91c3e..e1133897b 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -3,9 +3,11 @@ import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols"; import { ComponentSlot } from "@/shell/ActionSlot"; import { useActionSlot } from "@/hooks/use-action-slot"; import { useIsMobile } from "@/hooks/use-mobile"; +import { usePluginStore } from "@/plugin-host/plugin-store"; import { Button } from "@/components/button"; import { Card } from "@/components/card"; import { Separator } from "@/components/separator"; +import { Skeleton } from "@/components/skeleton"; import { Activity, Database, @@ -1049,6 +1051,7 @@ export function DashboardTab({ // canvas preview. Only one is offered today; "dashboard" always exists. const secondaryViews = useActionSlot("dashboard.secondaryView"); const secondaryView = secondaryViews[0]; + const { settled: pluginsSettled } = usePluginStore(); const [dashboardView, setDashboardView] = useState(() => { try { @@ -1057,11 +1060,14 @@ export function DashboardTab({ return "dashboard"; } }); + // Until plugins settle, a plugin view saved from last session hasn't had a + // chance to register yet, so its absence doesn't mean it's really gone. + const viewPending = !pluginsSettled && dashboardView !== "dashboard"; // Falls back to the dashboard when the view a plugin contributed is off. const isDashboardView = dashboardView === "dashboard" || - !secondaryView || - dashboardView !== secondaryView.actionId; + (!viewPending && !secondaryView) || + (!!secondaryView && dashboardView !== secondaryView.actionId); useEffect(() => { try { @@ -1482,23 +1488,27 @@ export function DashboardTab({
-
- - {secondaryView && ( + {viewPending ? ( + + ) : ( +
- )} -
- {isDashboardView && ( + {secondaryView && ( + + )} +
+ )} + {isDashboardView && !viewPending && (

{todayLabel}

@@ -1621,7 +1631,13 @@ export function DashboardTab({
- {!isDashboardView && secondaryView?.component ? ( + {viewPending ? ( +
+ + + +
+ ) : !isDashboardView && secondaryView?.component ? (
diff --git a/src/ui/hooks/use-permissions.ts b/src/ui/hooks/use-permissions.ts index 07a4c9694..f8df68e19 100644 --- a/src/ui/hooks/use-permissions.ts +++ b/src/ui/hooks/use-permissions.ts @@ -56,6 +56,15 @@ export async function hasPermission(permission: string): Promise { return matchesPermission(permissions, isAdmin, permission); } +/** + * Warms the permissions cache before the shell mounts, so permission-gated + * rail items are already known on first paint instead of popping in once + * usePermissions' own fetch resolves. + */ +export function preloadPermissions(): Promise { + return load().then(() => undefined); +} + export function notifyPermissionsChanged(): void { cache = null; window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT)); diff --git a/src/ui/plugin-host/loader.ts b/src/ui/plugin-host/loader.ts index ebd49da4a..628f011ee 100644 --- a/src/ui/plugin-host/loader.ts +++ b/src/ui/plugin-host/loader.ts @@ -300,22 +300,53 @@ async function reconcile(summaries: PluginSummary[]): Promise { if (!wanted.has(id)) failedVersions.delete(id); } - for (const summary of order) { - if (active.has(summary.id)) continue; - // Do not retry a bundle that already failed until it changes. - if ( - failedVersions.has(summary.id) && - failedVersions.get(summary.id) === (summary.assetVersion ?? null) - ) { - setFrontendState( - summary.id, - "failed", - getPluginRecord(summary.id)?.error, + await activateConcurrently(order); +} + +/** + * Activates every plugin in `order`, running independent plugins in + * parallel instead of one at a time. Each plugin still waits for its own + * hard dependencies to finish activating first (success or failure - a + * blocked or failed dependency must not hang its dependents forever). + */ +async function activateConcurrently(order: PluginSummary[]): Promise { + const byId = new Map(order.map((summary) => [summary.id, summary])); + const started = new Map>(); + + const run = (summary: PluginSummary): Promise => { + const existing = started.get(summary.id); + if (existing) return existing; + + const task = (async () => { + const dependencyIds = Object.keys(summary.dependencies ?? {}); + await Promise.all( + dependencyIds.map((id) => { + const dependency = byId.get(id); + return dependency ? run(dependency) : Promise.resolve(); + }), ); - continue; - } - await activate(summary); - } + + if (active.has(summary.id)) return; + // Do not retry a bundle that already failed until it changes. + if ( + failedVersions.has(summary.id) && + failedVersions.get(summary.id) === (summary.assetVersion ?? null) + ) { + setFrontendState( + summary.id, + "failed", + getPluginRecord(summary.id)?.error, + ); + return; + } + await activate(summary); + })(); + + started.set(summary.id, task); + return task; + }; + + await Promise.all(order.map(run)); } function enqueue(task: () => Promise): Promise { diff --git a/src/ui/sidebar/AppRail.tsx b/src/ui/sidebar/AppRail.tsx index edd3d2d64..1d095f79e 100644 --- a/src/ui/sidebar/AppRail.tsx +++ b/src/ui/sidebar/AppRail.tsx @@ -11,6 +11,7 @@ import { User, } from "lucide-react"; import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types"; +import { Skeleton } from "@/components/skeleton"; import { readRailPreference, setRailPreference } from "./rail-preferences"; import { useRailItems, type RailItemDef } from "./rail-items"; @@ -99,6 +100,7 @@ export function AppRail({ splitMode, username, isAdmin, + pluginsSettled = true, onRailClick, onOpenTab, onOpenInRightDock, @@ -109,6 +111,8 @@ export function AppRail({ splitMode: SplitMode; username: string; isAdmin: boolean; + /** False while plugins are still registering their rail items. */ + pluginsSettled?: boolean; onRailClick: (view: RailView) => void; onOpenTab?: (type: TabType) => void; onOpenInRightDock?: (view: RailView) => void; @@ -236,92 +240,102 @@ export function AppRail({ }} >
- {railButtons.map((item, i) => - item.kind === "separator" ? ( -
- ) : "tabType" in item ? ( - - ) : ( - - ), - )} + +
+ )) + : railButtons.map((item, i) => + item.kind === "separator" ? ( +
+ ) : "tabType" in item ? ( + + ) : ( + + ), + )}
diff --git a/src/ui/tests/plugin-host/loader.test.tsx b/src/ui/tests/plugin-host/loader.test.tsx index 879055e4f..1b193aebf 100644 --- a/src/ui/tests/plugin-host/loader.test.tsx +++ b/src/ui/tests/plugin-host/loader.test.tsx @@ -260,4 +260,43 @@ describe("activation order", () => { expect(order).toEqual([]); expect([...blocked.keys()].sort()).toEqual(["a", "b"]); }); + + it("activates independent plugins concurrently rather than one at a time", async () => { + modules.alpha = railPlugin("alpha"); + modules.beta = railPlugin("beta"); + plugins = [summary("alpha"), summary("beta")]; + + // Each import blocks until the other has started, which only resolves + // if both imports were kicked off before either finished. + let releaseAlpha: () => void; + let releaseBeta: () => void; + const alphaStarted = new Promise((resolve) => { + releaseAlpha = resolve; + }); + const betaStarted = new Promise((resolve) => { + releaseBeta = resolve; + }); + configurePluginLoader({ + fetchPlugins: async () => plugins, + importFrontend: async (entry) => { + if (entry.id === "alpha") { + releaseAlpha!(); + await betaStarted; + } else if (entry.id === "beta") { + releaseBeta!(); + await alphaStarted; + } + const module = modules[entry.id]; + if (!module) throw new Error(`no module for ${entry.id}`); + return module; + }, + loadLocale: async () => null, + injectCss: () => null, + }); + + await syncPlugins(); + + expect(railIds()).toContain("alpha-panel"); + expect(railIds()).toContain("beta-panel"); + }); });