feat: improve UI loading time and jitter

This commit is contained in:
LukeGus committed 2026-09-25 11:33:24 -05:00
1 parent 0f8956dcf3
commit 4a320a2258
8 files changed
+167 -25

No files matched your search

+38 -3
View File
@@ -24,7 +24,15 @@ import { useTranslation } from "react-i18next";
import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext"; import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext";
import { ConnectionDefaultsProvider } from "@/contexts/ConnectionDefaultsContext"; import { ConnectionDefaultsProvider } from "@/contexts/ConnectionDefaultsContext";
import { BrandingProvider } from "@/contexts/BrandingContext"; 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 { standaloneViewFor } from "@/shell/tab-registry";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder"; import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
@@ -39,7 +47,12 @@ const ElectronVersionCheck = lazy(() =>
); );
type Phase = type Phase =
"verifying" | "idle-auth" | "fading-in" | "idle-app" | "fading-out"; | "verifying"
| "idle-auth"
| "loading-app"
| "fading-in"
| "idle-app"
| "fading-out";
type LogoutOptions = { type LogoutOptions = {
manual?: boolean; manual?: boolean;
@@ -180,6 +193,14 @@ function App() {
} }
} }
fadingInFromLoginRef.current = false; fadingInFromLoginRef.current = false;
setPhase("loading-app");
await Promise.all([
startPluginRuntime()
.catch(() => {})
.then(() => settledPromise()),
preloadPermissions(),
getUserPreferences().catch(() => undefined),
]);
setPhase("fading-in"); setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450); timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
}) })
@@ -238,8 +259,18 @@ function App() {
clearDesktopManualLogout(); clearDesktopManualLogout();
setAuthUsername(u); setAuthUsername(u);
fadingInFromLoginRef.current = true; fadingInFromLoginRef.current = true;
setPhase("loading-app");
void (async () => {
await Promise.all([
startPluginRuntime()
.catch(() => {})
.then(() => settledPromise()),
preloadPermissions(),
getUserPreferences().catch(() => undefined),
]);
setPhase("fading-in"); setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450); timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
})();
if (isElectron()) { if (isElectron()) {
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {}); window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
const localJwt = localStorage.getItem("jwt"); const localJwt = localStorage.getItem("jwt");
@@ -264,6 +295,10 @@ function App() {
// isn't subject to batching, so it correctly dedupes within one tick. // isn't subject to batching, so it correctly dedupes within one tick.
if (loggingOutRef.current) return; if (loggingOutRef.current) return;
loggingOutRef.current = true; loggingOutRef.current = true;
// The next user may see different plugins, so nothing they registered
// should survive into the next session.
void stopPluginRuntime();
resetShellBridge();
clearStoredAuth(); clearStoredAuth();
localStorage.removeItem("jwt"); localStorage.removeItem("jwt");
if (isElectron() && options?.manual) { if (isElectron() && options?.manual) {
@@ -297,7 +332,7 @@ function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const isTransitioning = phase === "fading-in" || phase === "fading-out"; const isTransitioning = phase === "fading-in" || phase === "fading-out";
if (phase === "verifying") { if (phase === "verifying" || phase === "loading-app") {
return ( return (
<div className="fixed inset-0 flex items-center justify-center bg-background"> <div className="fixed inset-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
+1 -13
View File
@@ -175,12 +175,10 @@ import {
} from "@/shell/tab-registry"; } from "@/shell/tab-registry";
import { getPanel, usePanels } from "@/shell/panel-registry"; import { getPanel, usePanels } from "@/shell/panel-registry";
import { invokeAction } from "@/shell/action-registry"; import { invokeAction } from "@/shell/action-registry";
import { startPluginRuntime, stopPluginRuntime } from "@/plugin-host/loader";
import { usePluginStore } from "@/plugin-host/plugin-store"; import { usePluginStore } from "@/plugin-host/plugin-store";
import { import {
notifyShellReady, notifyShellReady,
notifyTabsChanged, notifyTabsChanged,
resetShellBridge,
setShellCallbacks, setShellCallbacks,
setShellHosts, setShellHosts,
setShellLayoutProvider, setShellLayoutProvider,
@@ -401,17 +399,6 @@ export function AppShell({
.catch(() => setIsAdmin(false)); .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(() => { const handleDismissDonationModal = useCallback(() => {
setShowDonationModal(false); setShowDonationModal(false);
dismissDonationModal().catch(() => {}); dismissDonationModal().catch(() => {});
@@ -2661,6 +2648,7 @@ export function AppShell({
splitMode={splitMode} splitMode={splitMode}
username={username} username={username}
isAdmin={showAdminUI} isAdmin={showAdminUI}
pluginsSettled={pluginsSettled}
onRailClick={handleRailClick} onRailClick={handleRailClick}
onOpenTab={openSingletonTab} onOpenTab={openSingletonTab}
onOpenInRightDock={openInRightDock} onOpenInRightDock={openInRightDock}
+10
View File
@@ -169,9 +169,19 @@ export function parseCustomKeybindings(
} }
} }
const userPreferencesCache = createTtlRequestCache<UserPreferences>(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<UserPreferences> { export async function getUserPreferences(): Promise<UserPreferences> {
return userPreferencesCache.get(async () => {
const response = await authApi.get("/user-preferences"); const response = await authApi.get("/user-preferences");
return response.data; return response.data;
});
} }
export async function saveUserPreferences( export async function saveUserPreferences(
+20 -4
View File
@@ -3,9 +3,11 @@ import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
import { ComponentSlot } from "@/shell/ActionSlot"; import { ComponentSlot } from "@/shell/ActionSlot";
import { useActionSlot } from "@/hooks/use-action-slot"; import { useActionSlot } from "@/hooks/use-action-slot";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Card } from "@/components/card"; import { Card } from "@/components/card";
import { Separator } from "@/components/separator"; import { Separator } from "@/components/separator";
import { Skeleton } from "@/components/skeleton";
import { import {
Activity, Activity,
Database, Database,
@@ -1049,6 +1051,7 @@ export function DashboardTab({
// canvas preview. Only one is offered today; "dashboard" always exists. // canvas preview. Only one is offered today; "dashboard" always exists.
const secondaryViews = useActionSlot("dashboard.secondaryView"); const secondaryViews = useActionSlot("dashboard.secondaryView");
const secondaryView = secondaryViews[0]; const secondaryView = secondaryViews[0];
const { settled: pluginsSettled } = usePluginStore();
const [dashboardView, setDashboardView] = useState<string>(() => { const [dashboardView, setDashboardView] = useState<string>(() => {
try { try {
@@ -1057,11 +1060,14 @@ export function DashboardTab({
return "dashboard"; 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. // Falls back to the dashboard when the view a plugin contributed is off.
const isDashboardView = const isDashboardView =
dashboardView === "dashboard" || dashboardView === "dashboard" ||
!secondaryView || (!viewPending && !secondaryView) ||
dashboardView !== secondaryView.actionId; (!!secondaryView && dashboardView !== secondaryView.actionId);
useEffect(() => { useEffect(() => {
try { try {
@@ -1482,6 +1488,9 @@ export function DashboardTab({
<div className="flex flex-col w-full h-full min-h-0 overflow-hidden"> <div className="flex flex-col w-full h-full min-h-0 overflow-hidden">
<Card className="flex-row items-center justify-between px-5 py-3 shrink-0 mx-5 mt-5 gap-0"> <Card className="flex-row items-center justify-between px-5 py-3 shrink-0 mx-5 mt-5 gap-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{viewPending ? (
<Skeleton className="h-7 w-40" />
) : (
<div className="flex items-center gap-0 bg-muted/40 border border-border p-0.5"> <div className="flex items-center gap-0 bg-muted/40 border border-border p-0.5">
<button <button
onClick={() => setDashboardView("dashboard")} onClick={() => setDashboardView("dashboard")}
@@ -1498,7 +1507,8 @@ export function DashboardTab({
</button> </button>
)} )}
</div> </div>
{isDashboardView && ( )}
{isDashboardView && !viewPending && (
<p className="text-xs text-muted-foreground hidden sm:block"> <p className="text-xs text-muted-foreground hidden sm:block">
{todayLabel} {todayLabel}
</p> </p>
@@ -1621,7 +1631,13 @@ export function DashboardTab({
</div> </div>
</Card> </Card>
{!isDashboardView && secondaryView?.component ? ( {viewPending ? (
<div className="flex-1 min-h-0 overflow-hidden mx-5 mb-5 mt-4 border border-border flex flex-col p-5 gap-3">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full flex-1" />
</div>
) : !isDashboardView && secondaryView?.component ? (
<div className="flex-1 min-h-0 overflow-hidden mx-5 mb-5 mt-4 border border-border flex flex-col"> <div className="flex-1 min-h-0 overflow-hidden mx-5 mb-5 mt-4 border border-border flex flex-col">
<secondaryView.component onOpenSingletonTab={onOpenSingletonTab} /> <secondaryView.component onOpenSingletonTab={onOpenSingletonTab} />
</div> </div>
+9
View File
@@ -56,6 +56,15 @@ export async function hasPermission(permission: string): Promise<boolean> {
return matchesPermission(permissions, isAdmin, permission); 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<void> {
return load().then(() => undefined);
}
export function notifyPermissionsChanged(): void { export function notifyPermissionsChanged(): void {
cache = null; cache = null;
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT)); window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
+35 -4
View File
@@ -300,8 +300,33 @@ async function reconcile(summaries: PluginSummary[]): Promise<void> {
if (!wanted.has(id)) failedVersions.delete(id); if (!wanted.has(id)) failedVersions.delete(id);
} }
for (const summary of order) { await activateConcurrently(order);
if (active.has(summary.id)) continue; }
/**
* 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<void> {
const byId = new Map(order.map((summary) => [summary.id, summary]));
const started = new Map<string, Promise<void>>();
const run = (summary: PluginSummary): Promise<void> => {
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();
}),
);
if (active.has(summary.id)) return;
// Do not retry a bundle that already failed until it changes. // Do not retry a bundle that already failed until it changes.
if ( if (
failedVersions.has(summary.id) && failedVersions.has(summary.id) &&
@@ -312,10 +337,16 @@ async function reconcile(summaries: PluginSummary[]): Promise<void> {
"failed", "failed",
getPluginRecord(summary.id)?.error, getPluginRecord(summary.id)?.error,
); );
continue; return;
} }
await activate(summary); await activate(summary);
} })();
started.set(summary.id, task);
return task;
};
await Promise.all(order.map(run));
} }
function enqueue(task: () => Promise<void>): Promise<void> { function enqueue(task: () => Promise<void>): Promise<void> {
+15 -1
View File
@@ -11,6 +11,7 @@ import {
User, User,
} from "lucide-react"; } from "lucide-react";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types"; import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences"; import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useRailItems, type RailItemDef } from "./rail-items"; import { useRailItems, type RailItemDef } from "./rail-items";
@@ -99,6 +100,7 @@ export function AppRail({
splitMode, splitMode,
username, username,
isAdmin, isAdmin,
pluginsSettled = true,
onRailClick, onRailClick,
onOpenTab, onOpenTab,
onOpenInRightDock, onOpenInRightDock,
@@ -109,6 +111,8 @@ export function AppRail({
splitMode: SplitMode; splitMode: SplitMode;
username: string; username: string;
isAdmin: boolean; isAdmin: boolean;
/** False while plugins are still registering their rail items. */
pluginsSettled?: boolean;
onRailClick: (view: RailView) => void; onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void; onOpenTab?: (type: TabType) => void;
onOpenInRightDock?: (view: RailView) => void; onOpenInRightDock?: (view: RailView) => void;
@@ -236,7 +240,17 @@ export function AppRail({
}} }}
> >
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0"> <div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
{railButtons.map((item, i) => {!pluginsSettled
? Array.from({ length: railButtons.length || 8 }, (_, i) => (
<div
key={`rail-skeleton-${i}`}
style={btnStyle}
className="flex items-center h-7 shrink-0"
>
<Skeleton className="size-4 shrink-0" />
</div>
))
: railButtons.map((item, i) =>
item.kind === "separator" ? ( item.kind === "separator" ? (
<div <div
key={`sep-${i}`} key={`sep-${i}`}
+39
View File
@@ -260,4 +260,43 @@ describe("activation order", () => {
expect(order).toEqual([]); expect(order).toEqual([]);
expect([...blocked.keys()].sort()).toEqual(["a", "b"]); 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<void>((resolve) => {
releaseAlpha = resolve;
});
const betaStarted = new Promise<void>((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");
});
}); });