perf(assistant): load the assistant runtime only when it is enabled

AssistantProvider was imported statically by the root layout, so
assistant-ui, the Lexical composer, the Markdown renderer and Mermaid were
downloaded and evaluated on every page. The existing getAvailability gate
only skipped rendering, and the disabled branch still imported the board
widget, which pulls the whole conversation surface. An instance with the
assistant switched off paid the full cost, and so did the login page.

Measured on two production builds, /auth/login went from 42 chunks /
3.81MB including an 820KB assistant-ui chunk to 36 chunks / 2.40MB with
none, and the board page from 55 / 5.35MB to 56 / 4.56MB with none.

Adds getFeatureFlagsAsync as one source of truth for instance-level
feature switches, exposed both to the root layout and through a small
GET /api/features endpoint. Resolving the flag on the server is what makes
this work: next/dynamic never references the heavy chunk when the feature
is off, while enabled instances still server-render it, so there is no
loading flash and no remount.
This commit is contained in:
Thomas Camlong committed 2026-08-07 17:30:27 +02:00
1 parent d82db8c6bd
commit a4e1c3c60e
13 files changed
+322 -126

No files matched your search

+4 -2
View File
@@ -14,6 +14,7 @@ import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server";
import { getFeatureFlagsAsync } from "@homarr/api/features";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
@@ -26,7 +27,7 @@ import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics";
import { AssistantProvider } from "~/components/assistant/assistant-provider";
import { AssistantGate } from "~/components/assistant/assistant-gate";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
@@ -86,6 +87,7 @@ export default async function Layout(props: {
const session = await auth();
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
const serverSettings = await getServerSettingsAsync(db);
const features = await getFeatureFlagsAsync(db);
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
@@ -124,7 +126,7 @@ export default async function Layout(props: {
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
(innerProps) => <AssistantProvider {...innerProps} />,
(innerProps) => <AssistantGate enabled={features.assistant} {...innerProps} />,
]);
const { locale } = await props.params;
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-provider";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
+24
View File
@@ -0,0 +1,24 @@
import { getFeatureFlagsAsync } from "@homarr/api/features";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
/**
* Reports which optional features are switched on for this instance.
*
* Kept deliberately small: booleans only, no configuration detail, so it stays cheap to call and
* safe to hand to the client. It is the single place to look when deciding whether a feature's
* client code needs to be loaded at all.
*
* Requires a session because whether an instance runs an AI assistant is not something an
* anonymous visitor needs to know.
*/
export async function GET() {
const session = await auth();
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const features = await getFeatureFlagsAsync(db);
return Response.json({ features }, { headers: { "Cache-Control": "private, max-age=60" } });
}
@@ -0,0 +1,56 @@
"use client";
import { createContext, useContext } from "react";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
/**
* Context and hooks live in their own module so that consumers such as the user menu can read the
* assistant state without pulling in `assistant-provider`, which statically imports the
* assistant-ui runtime, the Lexical composer, the Markdown renderer and Mermaid. Importing those
* from a component that renders on every page would ship them to every page.
*/
export interface AssistantContextValue {
enabled: boolean;
unavailableDescription: string | null;
opened: boolean;
isRunning: boolean;
isRefreshing: boolean;
unreadCount: number;
open: () => void;
close: () => void;
toggle: () => void;
sendPrompt: (prompt: string) => boolean;
refreshCurrentView: () => Promise<void>;
}
export const AssistantContext = createContext<AssistantContextValue | null>(null);
export interface AssistantPreferencesContextValue {
defaultModelId: string | null;
modelId: string | null;
models: AssistantRuntimeModelOption[];
reasoning: AssistantReasoningMode;
isLoading: boolean;
setModelId: (modelId: string) => void;
setReasoning: (reasoning: AssistantReasoningMode) => void;
getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
}
export const AssistantPreferencesContext = createContext<AssistantPreferencesContextValue | null>(null);
export const useHomarrAssistant = () => {
const value = useContext(AssistantContext);
if (!value) {
throw new Error("useHomarrAssistant must be used within AssistantProvider");
}
return value;
};
export const useOptionalHomarrAssistant = () => useContext(AssistantContext);
export const useAssistantPreferences = () => {
const value = useContext(AssistantPreferencesContext);
if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider");
return value;
};
@@ -0,0 +1,73 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import dynamic from "next/dynamic";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
import { AssistantContext } from "./assistant-context";
import { AssistantBoardWidgetLazy } from "./assistant-widget-lazy";
import { useRegisterAssistantSpotlightPlaceholder } from "./assistant-spotlight-placeholder";
/**
* The enabled runtime is the expensive half of the assistant (assistant-ui, Lexical, Markdown,
* Mermaid). Loading it through `next/dynamic` puts it in its own chunk that is only requested when
* this component actually renders. Because `enabled` is resolved on the server and handed down as a
* prop, an instance with the assistant switched off never renders it, so the chunk is never part of
* the page payload. SSR stays on so enabled instances render children in the initial HTML without a
* loading flash or a remount once the chunk arrives.
*/
const EnabledAssistantRoot = dynamic(() =>
import("./assistant-provider").then((module) => ({ default: module.EnabledAssistantRoot })),
);
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
useRegisterAssistantSpotlightPlaceholder(description);
const value = useMemo(
() => ({
enabled: false,
unavailableDescription: description,
opened: false,
isRunning: false,
isRefreshing: false,
unreadCount: 0,
open: () => undefined,
close: () => undefined,
toggle: () => undefined,
sendPrompt: () => false,
refreshCurrentView: () => Promise.resolve(),
}),
[description],
);
return (
<AssistantContext.Provider value={value}>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidgetLazy}>{children}</AssistantWidgetRendererProvider>
</AssistantContext.Provider>
);
};
interface AssistantGateProps extends PropsWithChildren {
/** Resolved on the server from the instance configuration, not per user. */
enabled: boolean;
}
export const AssistantGate = ({ enabled, children }: AssistantGateProps) => {
const t = useScopedI18n("common.assistant");
const session = useSession();
if (!enabled) {
return <DisabledAssistant description={t("unavailable.notConfigured")}>{children}</DisabledAssistant>;
}
// The instance has the assistant on, but it is still per-user gated behind a session.
if (session.status !== "authenticated") {
return <DisabledAssistant description={t("unavailable.signIn")}>{children}</DisabledAssistant>;
}
return <EnabledAssistantRoot>{children}</EnabledAssistantRoot>;
};
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type {
AttachmentAdapter,
@@ -17,7 +17,6 @@ import { useHotkeys } from "@mantine/hooks";
import { createAssistantStream } from "assistant-stream";
import { clientApi, fetchApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
@@ -30,6 +29,7 @@ import {
} from "@homarr/spotlight";
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
import { createAssistantBrowserToolExecutors } from "./assistant-browser-tool-executors";
@@ -53,33 +53,6 @@ import { initialAssistantNotificationState, updateAssistantNotificationState } f
import { AssistantViewRefreshProvider, useAssistantViewRefresh } from "./assistant-view-refresh";
import { AssistantBoardWidget } from "./assistant-widget";
interface AssistantContextValue {
enabled: boolean;
unavailableDescription: string | null;
opened: boolean;
isRunning: boolean;
isRefreshing: boolean;
unreadCount: number;
open: () => void;
close: () => void;
toggle: () => void;
sendPrompt: (prompt: string) => boolean;
refreshCurrentView: () => Promise<void>;
}
const AssistantContext = createContext<AssistantContextValue | null>(null);
interface AssistantPreferencesContextValue {
defaultModelId: string | null;
modelId: string | null;
models: AssistantRuntimeModelOption[];
reasoning: AssistantReasoningMode;
isLoading: boolean;
setModelId: (modelId: string) => void;
setReasoning: (reasoning: AssistantReasoningMode) => void;
getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
}
const AssistantPreferencesContext = createContext<AssistantPreferencesContextValue | null>(null);
const ignoreUnsupportedArchiveAction = () => Promise.resolve();
const assistantImageAttachmentTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"];
const assistantDocumentAttachmentTypes = [
@@ -158,22 +131,6 @@ const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapt
};
};
export const useHomarrAssistant = () => {
const value = useContext(AssistantContext);
if (!value) {
throw new Error("useHomarrAssistant must be used within AssistantProvider");
}
return value;
};
export const useOptionalHomarrAssistant = () => useContext(AssistantContext);
export const useAssistantPreferences = () => {
const value = useContext(AssistantPreferencesContext);
if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider");
return value;
};
const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, {
staleTime: 10 * 60_000,
@@ -766,79 +723,16 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
);
};
interface DisabledAssistantProviderProps extends PropsWithChildren {
description: string;
}
const DisabledAssistantProvider = ({ children, description }: DisabledAssistantProviderProps) => {
const t = useScopedI18n("common.assistant");
const spotlightItem = useMemo(
() => ({
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description,
unavailable: true,
alwaysVisible: true,
interaction: (_query: string) => ({ type: "none" as const }),
}),
[description, t],
);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo(
() => ({
enabled: false,
unavailableDescription: description,
opened: false,
isRunning: false,
isRefreshing: false,
unreadCount: 0,
open: () => undefined,
close: () => undefined,
toggle: () => undefined,
sendPrompt: () => false,
refreshCurrentView: () => Promise.resolve(),
}),
[description],
);
return (
<AssistantContext.Provider value={value}>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
</AssistantContext.Provider>
);
};
export const AssistantProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const session = useSession();
const { data, isLoading, isError } = clientApi.assistant.getAvailability.useQuery(undefined, {
enabled: session.status === "authenticated",
staleTime: 60_000,
});
const enabled = Boolean(data?.enabled && session.status === "authenticated");
if (enabled) {
return (
<AssistantPreferencesProvider>
<AssistantViewRefreshProvider>
<AssistantRuntime>
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
</AssistantRuntime>
</AssistantViewRefreshProvider>
</AssistantPreferencesProvider>
);
}
const unavailableDescription =
session.status !== "authenticated"
? t("unavailable.signIn")
: isLoading
? t("unavailable.checking")
: isError
? t("unavailable.error")
: t("unavailable.notConfigured");
return <DisabledAssistantProvider description={unavailableDescription}>{children}</DisabledAssistantProvider>;
};
/**
* The enabled half of the assistant. Only rendered (and only downloaded) when the instance has the
* assistant configured and the visitor is signed in - see `assistant-gate`.
*/
export const EnabledAssistantRoot = ({ children }: PropsWithChildren) => (
<AssistantPreferencesProvider>
<AssistantViewRefreshProvider>
<AssistantRuntime>
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
</AssistantRuntime>
</AssistantViewRefreshProvider>
</AssistantPreferencesProvider>
);
@@ -0,0 +1,28 @@
"use client";
import { useMemo } from "react";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
/**
* Keeps the assistant discoverable in the spotlight when it is unavailable, without loading the
* assistant runtime. Lives apart from `assistant-provider` so the disabled path stays cheap.
*/
export const useRegisterAssistantSpotlightPlaceholder = (description: string) => {
const t = useScopedI18n("common.assistant");
const spotlightItem = useMemo(
() => ({
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description,
unavailable: true,
alwaysVisible: true,
interaction: (_query: string) => ({ type: "none" as const }),
}),
[description, t],
);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
};
@@ -0,0 +1,18 @@
"use client";
import dynamic from "next/dynamic";
import type { WidgetComponentProps } from "@homarr/widgets";
/**
* Boards register a renderer for the assistant widget even when no board uses one. Loading the
* renderer lazily keeps the conversation surface out of the board bundle until a board actually
* places an assistant widget.
*/
const LazyAssistantBoardWidget = dynamic(() =>
import("./assistant-widget").then((module) => ({ default: module.AssistantBoardWidget })),
);
export const AssistantBoardWidgetLazy = (props: WidgetComponentProps<"assistant">) => (
<LazyAssistantBoardWidget {...props} />
);
@@ -12,7 +12,7 @@ import type { WidgetComponentProps } from "@homarr/widgets";
import classes from "./assistant-panel.module.css";
import { AssistantConversationSurface } from "./assistant-panel";
import { getPendingAssistantAction } from "./assistant-pending-action";
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-provider";
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
const assistant = useHomarrAssistant();
@@ -24,7 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { useOptionalHomarrAssistant } from "./assistant/assistant-provider";
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
+1
View File
@@ -15,6 +15,7 @@
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts",
"./assistant": "./src/assistant.ts",
"./features": "./src/features.ts",
"./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
"./mcp": "./src/mcp.ts"
},
+36
View File
@@ -0,0 +1,36 @@
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { assistantConfigurations } from "@homarr/db/schema";
import { assistantProviderRequiresApiKey } from "@homarr/definitions";
/**
* Instance-level feature switches. These say whether a feature is turned on for the whole Homarr
* instance, never whether the current user may use it - permission checks stay on the individual
* procedures.
*
* The point of collecting them in one place is that the server can decide, before rendering,
* whether a feature's client bundle needs to exist on the page at all. A feature that is off must
* not cost the browser anything.
*/
export interface FeatureFlags {
assistant: boolean;
}
export const getFeatureFlagsAsync = async (db: Database): Promise<FeatureFlags> => {
const assistantConfiguration = await db.query.assistantConfigurations.findFirst({
where: eq(assistantConfigurations.id, "default"),
columns: { enabled: true, provider: true, modelId: true, encryptedApiKey: true },
});
const requiresApiKey = assistantConfiguration
? assistantProviderRequiresApiKey(assistantConfiguration.provider)
: false;
return {
assistant: Boolean(
assistantConfiguration?.enabled &&
assistantConfiguration.modelId &&
(!requiresApiKey || assistantConfiguration.encryptedApiKey),
),
};
};
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, test } from "vitest";
import type { Database } from "@homarr/db";
import { assistantConfigurations } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { getFeatureFlagsAsync } from "../features";
const seedConfigurationAsync = async (db: Database, values: Partial<typeof assistantConfigurations.$inferInsert>) => {
await db.insert(assistantConfigurations).values({ id: "default", ...values });
};
describe("getFeatureFlagsAsync", () => {
test("reports the assistant as off when it was never configured", async () => {
const db = createDb() as unknown as Database;
await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false });
});
test("reports the assistant as off while it is configured but not switched on", async () => {
const db = createDb() as unknown as Database;
await seedConfigurationAsync(db, {
enabled: false,
provider: "openrouter",
modelId: "some/model",
encryptedApiKey: "a.b",
});
await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false });
});
test("reports the assistant as off when a required api key is missing", async () => {
const db = createDb() as unknown as Database;
await seedConfigurationAsync(db, { enabled: true, provider: "openrouter", modelId: "some/model" });
await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false });
});
test("reports the assistant as off when no model was picked", async () => {
const db = createDb() as unknown as Database;
await seedConfigurationAsync(db, { enabled: true, provider: "openrouter", encryptedApiKey: "a.b" });
await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false });
});
test("reports the assistant as on once it is fully configured", async () => {
const db = createDb() as unknown as Database;
await seedConfigurationAsync(db, {
enabled: true,
provider: "openrouter",
modelId: "some/model",
encryptedApiKey: "a.b",
});
await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: true });
});
test("does not require an api key for providers that run without one", async () => {
const db = createDb() as unknown as Database;
await seedConfigurationAsync(db, { enabled: true, provider: "ollama", modelId: "llama3" });
await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: true });
});
});