perf(widgets): lazy load the application graph

This commit is contained in:
ajnart committed 2026-07-29 06:47:07 +02:00
1 parent 4ca0b747bb
commit 6b74078766
38 files changed
+676 -202

No files matched your search

@@ -2,9 +2,8 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
@@ -34,10 +33,15 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { createWidgetQueryPersister } from "./query-cache-persister";
const ReactQueryDevtools = dynamic(
() => import("@tanstack/react-query-devtools").then(({ ReactQueryDevtools: DevelopmentTools }) => DevelopmentTools),
{ ssr: false },
);
const getWebSocketProtocol = () => {
if (typeof window === "undefined") {
return "ws";
@@ -71,8 +75,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
gcTime: queryCacheDefaultGcTimeMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 3,
},
mutations: {
@@ -92,12 +94,10 @@ export function TRPCReactProvider(props: PropsWithChildren) {
},
});
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
for (const { definition } of Object.values(widgetImports)) {
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
if (def.refetchInterval === undefined) continue;
const key = def.queryKey ?? [["widget", def.kind]];
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
client.setQueryDefaults(key, { refetchInterval: interval });
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
client.setQueryDefaults(queryKey, {
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
});
}
return client;
});
@@ -164,7 +164,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
{process.env.NODE_ENV === "development" && <ReactQueryDevtools initialIsOpen={false} />}
</PersistQueryClientProvider>
</clientApi.Provider>
);
@@ -1,9 +1,9 @@
import { api } from "@homarr/api/server";
import { getHomeBoardAsync } from "@homarr/api/board-server";
import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string }>({
async getInitialBoardAsync() {
return await api.board.getHomeBoard();
return await getHomeBoardAsync();
},
});
@@ -1,9 +1,9 @@
import { api } from "@homarr/api/server";
import { getBoardByNameAsync } from "@homarr/api/board-server";
import { createBoardContentPage } from "../../_creator";
export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name });
return await getBoardByNameAsync(name);
},
});
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { cache, Suspense } from "react";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
@@ -9,7 +9,7 @@ import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import superjson from "superjson";
import { queryCacheBuster } from "@homarr/api/query-cache";
import { getQueryClient } from "@homarr/api/server";
import { makeQueryClient } from "@homarr/api/shared";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
@@ -29,6 +29,7 @@ import { DynamicClientBoard } from "./_dynamic-client";
import { BoardContentHeaderActions } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" });
const getQueryClient = cache(makeQueryClient);
export type Params = Record<string, unknown>;
@@ -1,4 +1,4 @@
import { api } from "@homarr/api/server";
import { getBoardByNameAsync } from "@homarr/api/board-server";
import { BoardOtherHeaderActions } from "../_header-actions";
import { createBoardLayout } from "../_layout-creator";
@@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator";
export default createBoardLayout<{ locale: string; name: string }>({
headerActions: <BoardOtherHeaderActions />,
async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name });
return await getBoardByNameAsync(name);
},
});
+9 -2
View File
@@ -1,8 +1,8 @@
import type { JSX } from "react";
import { Box, Center, Stack, Text, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import type { MaybePromise } from "@homarr/common/types";
import { db } from "@homarr/db";
import type { OnboardingStep } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
@@ -29,9 +29,16 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
finish: InitFinish,
};
const getCurrentOnboardingStepAsync = async () => {
const value = await db.query.onboarding.findFirst();
if (!value) return { current: "start" as const, previous: null };
return { current: value.step, previous: value.previousStep };
};
export default async function InitPage() {
const t = await getScopedI18n("init.step");
const currentStep = await api.onboard.currentStep();
const currentStep = await getCurrentOnboardingStepAsync();
const CurrentComponent = stepComponents[currentStep.current];
+33 -2
View File
@@ -13,11 +13,13 @@ import { notFound } from "next/navigation";
import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import { eq } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { users } from "@homarr/db/schema";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { SettingsProvider } from "@homarr/settings";
@@ -43,6 +45,8 @@ const fontSans = Inter({
variable: "--font-sans",
});
const logger = createLogger({ module: "rootLayout" });
// eslint-disable-next-line no-restricted-syntax
export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr",
@@ -83,7 +87,34 @@ 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 user = session
? await db.query.users
.findFirst({
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
},
where: eq(users.id, session.user.id),
})
.catch((error: unknown) => {
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
return null;
})
: null;
const serverSettings = await getServerSettingsAsync(db);
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { use, useCallback, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -12,8 +12,9 @@ import { showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { getWidgetComponent } from "@homarr/widgets/client";
import { WidgetError } from "@homarr/widgets/errors";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { Dimensions } from "./_dimension-modal";
@@ -34,7 +35,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
const t = useI18n();
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
const currentDefinition = use(loadWidgetDefinition(kind));
const [editMode, setEditMode] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({
width: 128,
@@ -45,7 +46,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions;
}>({
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}),
integrationIds: [],
advancedOptions: {
title: null,
@@ -78,7 +79,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
);
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
const Comp = loadWidgetDynamic(kind);
const Comp = getWidgetComponent(kind);
const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode);
@@ -105,7 +106,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} />
<WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} />
)}
>
<Comp
@@ -1,22 +1,23 @@
import type { PropsWithChildren } from "react";
import { widgetImports } from "@homarr/widgets";
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
const getLinks = () => {
return Object.entries(widgetImports).map(([key, value]) => {
const getLinks = async () => {
const definitions = await loadAllWidgetDefinitions();
return [...definitions].map(([key, definition]) => {
return {
href: `/widgets/${key}`,
icon: value.definition.icon,
label: value.definition.kind,
icon: definition.icon,
label: key,
};
});
};
export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
const links = getLinks();
export default async function WidgetPreviewLayout({ children }: PropsWithChildren) {
const links = await getLinks();
return (
<ClientShell hasHeader={false}>
@@ -4,7 +4,7 @@ import { Center } from "@mantine/core";
import { env } from "@homarr/common/env";
import { db } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
import { widgetKinds } from "@homarr/widgets/manifest";
import { WidgetPreviewPageContent } from "./_content";
@@ -13,7 +13,7 @@ interface Props {
}
export default async function WidgetPreview(props: Props) {
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
if (!(widgetKinds.includes((await props.params).kind as WidgetKind) || env.NODE_ENV !== "development")) {
notFound();
}
@@ -1,6 +1,6 @@
import type { MutableRefObject } from "react";
import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { Suspense, use, useRef } from "react";
import { Badge, Card, Center, Loader } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
@@ -10,8 +10,10 @@ import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { getWidgetComponent } from "@homarr/widgets/client";
import { WidgetError } from "@homarr/widgets/errors";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "../sections/item.module.css";
@@ -38,29 +40,21 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
return (
<>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
</WidgetContextMenu>
<Suspense
fallback={
<Center ref={ref} className={classes.itemCard} h="100%">
<Loader size="sm" />
</Center>
}
>
<LoadedBoardItemContent
item={item}
width={width}
height={height}
widgetStateRef={widgetStateRef}
innerRef={ref}
/>
</Suspense>
{item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
@@ -86,26 +80,71 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
);
};
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition"> {
innerRef: (element: HTMLDivElement | null) => void;
}
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
const board = useRequiredBoard();
const definition = use(loadWidgetDefinition(item.kind));
return (
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
<Card
ref={innerRef}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu offset={4} item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
<InnerContent item={item} definition={definition} {...innerContentProps} />
</ErrorBoundary>
</Card>
</WidgetContextMenu>
);
};
interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
definition: WidgetDefinition;
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const Comp = loadWidgetDynamic(item.kind);
const { definition } = widgetImports[item.kind];
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const Comp = getWidgetComponent(item.kind);
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
const newItem = { ...item, options };
const { updateItemOptions } = useItemActions();
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions });
const widgetSupportsIntegrations =
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
const queryClient = useQueryClient();
return (
@@ -118,8 +157,13 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
}}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
<BoardItemMenu
offset={4}
item={newItem}
definition={definition}
resetErrorBoundary={resetErrorBoundary}
/>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
@@ -131,7 +175,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
<BoardItemMenu offset={4} item={newItem} />
<BoardItemMenu offset={4} item={newItem} definition={definition} />
<Comp
options={options as never}
integrationIds={item.integrationIds}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
@@ -8,7 +8,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
@@ -19,6 +19,7 @@ import { ItemMoveModal } from "./item-move-modal";
interface BoardItemMenuProps {
offset: number;
item: SectionItem;
definition: WidgetDefinition;
resetErrorBoundary?: () => void;
}
@@ -29,7 +30,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
return <BoardItemMenuInner {...props} />;
};
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
@@ -40,7 +41,6 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const settings = useSettings();
@@ -80,10 +80,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
},
integrationData: (integrationData ?? []).filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
integrationSupport: "supportedIntegrations" in definition,
settings,
itemId: item.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
@@ -1,9 +1,9 @@
import { useMemo, useState } from "react";
import { use, useMemo, useState } from "react";
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId, objectEntries } from "@homarr/common";
import { createId } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
@@ -11,12 +11,13 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import { loadAllWidgetDefinitions, reduceWidgetOptionsWithDefinition, widgetKinds } from "@homarr/widgets/manifest";
import { useItemActions } from "./item-actions";
export const ItemSelectModal = createModal<void>(({ actions }) => {
const widgetDefinitions = use(loadAllWidgetDefinitions());
const [search, setSearch] = useState("");
const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
@@ -29,25 +30,30 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const items = useMemo(
() =>
objectEntries(widgetImports)
.filter(([kind]) => kind !== "customApi")
.map(([kind, value]) => ({
kind,
supportedIntegrations:
"supportedIntegrations" in value.definition
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
: [],
icon: value.definition.icon,
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
widgetKinds
.filter((kind) => kind !== "customApi")
.map((kind) => {
const definition = widgetDefinitions.get(kind);
if (!definition) return null;
return {
kind,
supportedIntegrations: (definition.supportedIntegrations ?? []).filter(
(integration) => integration !== "mock",
),
icon: definition.icon,
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
};
})
.filter((item): item is NonNullable<typeof item> => item !== null)
.sort((itemA, itemB) => {
if (itemA.kind === "app") return -1;
if (itemB.kind === "app") return 1;
return itemA.name.localeCompare(itemB.name);
}),
[t],
[t, widgetDefinitions],
);
const filteredItems = useMemo(() => {
@@ -68,25 +74,29 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const handleAddCustomWidget = (definitionId: string) => {
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
const definition = widgetDefinitions.get("customApi");
if (!definition) return;
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
actions.closeModal();
};
const handleAdd = (kind: WidgetKind) => {
const definition = widgetImports[kind].definition;
const definition = widgetDefinitions.get(kind);
if (!definition) return;
const hasIntegrationSupport = "supportedIntegrations" in definition;
const matchingIntegrations = hasIntegrationSupport
? (integrationData ?? []).filter((integration) =>
(definition.supportedIntegrations as string[]).includes(integration.kind),
(definition.supportedIntegrations ?? []).includes(integration.kind),
)
: [];
const integrationIds = matchingIntegrations.map((i) => i.id);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
createItem({ id: itemId, kind, integrationIds });
actions.closeModal();
@@ -121,8 +131,8 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
placeholder={`${t("item.create.search")}...`}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && filteredItems.length === 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
handleAdd(filteredItems[0]!.kind);
const [item] = filteredItems;
if (item) handleAdd(item.kind);
}
}}
>
@@ -25,8 +25,8 @@ import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
@@ -36,11 +36,12 @@ import { ItemMoveModal } from "./item-move-modal";
interface WidgetContextMenuProps {
item: SectionItem;
definition: WidgetDefinition;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
children: ReactNode;
}
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
@@ -56,13 +57,12 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKey = useMemo(
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
[currentDefinition, item.kind],
() => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]),
[definition, item.kind],
);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => {
@@ -70,18 +70,18 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
}, [queryClient, widgetQueryKey]);
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
[definition, settings, item.options],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]);
}, [definition, settings]);
const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>;
const def = definition as unknown as Record<string, unknown>;
if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({
@@ -94,7 +94,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
widgetStateRef,
});
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
}, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
const persistBoard = useCallback(
(updatedItems: typeof board.items) => {
@@ -133,10 +133,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
},
integrationData: (integrationData ?? []).filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
integrationSupport: "supportedIntegrations" in definition,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
@@ -150,7 +150,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
updateItemAdvancedOptions,
updateItemIntegrations,
integrationData,
currentDefinition,
definition,
settings,
isEditMode,
persistBoard,
@@ -1,12 +1,13 @@
"use client";
import { Suspense, use } from "react";
import { Avatar, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import { loadWidgetDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "./widget-hover-overlay.module.css";
@@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps {
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode();
if (!isEditMode) return null;
return (
<Suspense fallback={null}>
<LoadedWidgetHoverOverlay item={item} integrations={integrations} />
</Suspense>
);
};
const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const t = useI18n();
const { definition } = widgetImports[item.kind];
const definition = use(loadWidgetDefinition(item.kind));
const WidgetIcon = definition.icon;
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
@@ -28,10 +40,6 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
item.integrationIds.includes(integration.id),
);
if (!isEditMode) {
return null;
}
return (
<div className={classes.wrapper}>
<div className={classes.panel}>
@@ -105,12 +105,14 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = filterByItemKind(
board.items.filter(
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
),
settings,
"app",
const appIds = (
await filterByItemKind(
board.items.filter(
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
),
settings,
"app",
)
).map((item) => {
return item.options.appId;
});
@@ -1,21 +1,23 @@
import type { WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetComponentProps } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
import type { WidgetComponentProps } from "@homarr/widgets/definition";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { Item } from "~/app/[locale]/boards/_types";
export const filterByItemKind = <TKind extends WidgetKind>(
export const filterByItemKind = async <TKind extends WidgetKind>(
items: Item[],
settings: SettingsContextProps,
kind: TKind,
) => {
const definition = await loadWidgetDefinition(kind);
return items
.filter((item) => item.kind === kind)
.map((item) => ({
...item,
options: reduceWidgetOptionsWithDefaultValues(
kind,
options: reduceWidgetOptionsWithDefinition(
definition,
settings,
item.options,
) as WidgetComponentProps<TKind>["options"],
@@ -1,4 +1,3 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import {
featuredIntegrations,
@@ -7,19 +6,18 @@ import {
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
const buildWidgetsByIntegration = () => {
const buildWidgetsByIntegration = async () => {
const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record<
IntegrationKind,
WidgetKind[]
>;
const definitions = await loadAllWidgetDefinitions();
for (const [widgetKind, widget] of objectEntries(widgetImports)) {
for (const [widgetKind, definition] of definitions) {
const supported =
"supportedIntegrations" in widget.definition
? (widget.definition.supportedIntegrations as IntegrationKind[])
: [];
"supportedIntegrations" in definition ? (definition.supportedIntegrations as IntegrationKind[]) : [];
for (const kind of supported) {
if (kind in map) {
map[kind].push(widgetKind);
@@ -29,8 +27,6 @@ const buildWidgetsByIntegration = () => {
return map;
};
export const widgetsByIntegration = buildWidgetsByIntegration();
export const categoryTranslationKeys: Record<string, string> = {
dnsHole: "integration.category.dnsHole",
mediaService: "integration.category.mediaService",
@@ -68,10 +64,11 @@ export interface IntegrationGridItem {
widgets: WidgetKind[];
}
export const buildSortedIntegrations = (
export const buildSortedIntegrations = async (
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
): IntegrationGridItem[] =>
integrationKinds
): Promise<IntegrationGridItem[]> => {
const widgetsByIntegration = await buildWidgetsByIntegration();
return integrationKinds
.filter((kind) => {
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
@@ -84,7 +81,7 @@ export const buildSortedIntegrations = (
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
widgets: widgetsByIntegration[kind],
}))
.sort((left, right) => {
.toSorted((left, right) => {
const leftIdx = featuredIntegrations.indexOf(left.kind);
const rightIdx = featuredIntegrations.indexOf(right.kind);
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
@@ -92,6 +89,7 @@ export const buildSortedIntegrations = (
if (rightIdx !== -1) return 1;
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
});
};
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
@@ -1,8 +1,9 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { Badge, Card, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core";
import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
@@ -42,10 +43,10 @@ export const IntegrationMultiSelectGrid = ({
[selectedKindsArray, onSelectionChange],
);
const integrations = useMemo(
() => buildSortedIntegrations({ enableMockIntegration, onboarding }),
[enableMockIntegration, onboarding],
);
const { data: integrations = [], isPending } = useQuery({
queryKey: ["widget-integrations", { enableMockIntegration, onboarding }],
queryFn: () => buildSortedIntegrations({ enableMockIntegration, onboarding }),
});
const sorted = useMemo(() => {
if (!detectedKinds || detectedKinds.size === 0) return integrations;
@@ -64,6 +65,11 @@ export const IntegrationMultiSelectGrid = ({
disableScroll={onboarding}
disableAutoFocus={onboarding}
>
{isPending && (
<Center p="xl">
<Loader size="sm" />
</Center>
)}
{filtered.map((integration) => {
const isSelected = selectedKinds.has(integration.kind);
const isDetected = detectedKinds?.has(integration.kind) ?? false;
@@ -1,8 +1,9 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { Badge, Card, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core";
import { IconPuzzle } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
@@ -19,7 +20,10 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
const [search, setSearch] = useState("");
const t = useI18n();
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
const { data: integrations = [], isPending } = useQuery({
queryKey: ["widget-integrations", { enableMockIntegration }],
queryFn: () => buildSortedIntegrations({ enableMockIntegration }),
});
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
@@ -28,6 +32,11 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
onSearchChange={setSearch}
placeholder={`${t("integration.page.list.search")}...`}
>
{isPending && (
<Center p="xl">
<Loader size="sm" />
</Center>
)}
{filtered.map((integration) => (
<Card
key={integration.kind}
+2
View File
@@ -9,8 +9,10 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./board-server": "./src/board-server.ts",
"./query-cache": "./src/query-cache.ts",
"./server": "./src/server.ts",
"./widget-prefetch-server": "./src/widget-prefetch-server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts",
+14
View File
@@ -0,0 +1,14 @@
import { createRscTrpcContext } from "./rsc-context";
import { boardRouter } from "./router/board";
const createBoardCallerAsync = async () => boardRouter.createCaller(await createRscTrpcContext());
export const getBoardByNameAsync = async (name: string) => {
const caller = await createBoardCallerAsync();
return await caller.getBoardByName({ name });
};
export const getHomeBoardAsync = async () => {
const caller = await createBoardCallerAsync();
return await caller.getHomeBoard();
};
+16
View File
@@ -0,0 +1,16 @@
import { cache } from "react";
import { headers } from "next/headers";
import { auth } from "@homarr/auth/next";
import { createTRPCContext } from "./trpc";
export const createRscTrpcContext = cache(async () => {
const requestHeaders = new Headers(await headers());
requestHeaders.set("x-trpc-source", "rsc");
return createTRPCContext({
session: await auth(),
headers: requestHeaders,
});
});
+3 -12
View File
@@ -1,25 +1,16 @@
import { cache } from "react";
import { headers } from "next/headers";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { appRouter, createCaller, createTRPCContext } from "@homarr/api";
import { auth } from "@homarr/auth/next";
import { appRouter, createCaller } from "@homarr/api";
import { createRscTrpcContext } from "./rsc-context";
import { makeQueryClient } from "./shared";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
session: await auth(),
headers: heads,
});
});
const createContext = () => createRscTrpcContext();
export const api = createCaller(createContext);
@@ -0,0 +1,11 @@
import { createRscTrpcContext } from "./rsc-context";
import { downloadsRouter } from "./router/widgets/downloads";
export const getDownloadJobsAndStatusesAsync = async (input: {
integrationIds: string[];
limitPerIntegration: number;
}) => {
// Keep this leaf caller narrow: the shared server caller imports the complete app router into every widget-prefetch build.
const caller = downloadsRouter.createCaller(await createRscTrpcContext());
return await caller.getJobsAndStatuses(input);
};
+5 -8
View File
@@ -15,13 +15,10 @@ declare module "next-intl" {
}
}
export const { useI18n, useScopedI18n } = {
useI18n: useTranslations,
useScopedI18n: useTranslations,
};
export const { useI18nMessages } = {
useI18nMessages: () => useMessages(),
};
// Keep these as direct exports so Turbopack and webpack can statically inspect
// the client package's public surface.
export const useI18n = useTranslations;
export const useScopedI18n = useTranslations;
export const useI18nMessages = () => useMessages();
export { useTranslations };
+6 -1
View File
@@ -16,11 +16,16 @@
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
"./client": "./src/client.tsx",
"./definition": "./src/definition.ts",
"./errors": "./src/errors/component.tsx",
"./manifest": "./src/manifest.ts",
"./options": "./src/options.ts",
"./errors/classes": "./src/errors/index.ts",
"./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts"
"./prefetch": "./src/prefetch.ts",
"./refetch-intervals": "./src/refetch-intervals.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
+2 -2
View File
@@ -1,9 +1,9 @@
import { trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db, inArray } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "appWidgetPrefetch" });
@@ -16,7 +16,7 @@ const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
});
for (const app of dbApps) {
queryClient.setQueryData(trpc.app.byId.queryKey({ id: app.id }), app);
queryClient.setQueryData(createTrpcQueryKey("app.byId", { id: app.id }), app);
}
logger.info("Successfully prefetched apps for app widget", { count: dbApps.length });
+2 -2
View File
@@ -1,9 +1,9 @@
import { trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db, inArray } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
@@ -21,7 +21,7 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
}
queryClient.setQueryData(
trpc.app.byIds.queryKey(item.options.items),
createTrpcQueryKey("app.byIds", item.options.items),
dbApps.filter((app) => item.options.items.includes(app.id)),
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import type { ComponentType } from "react";
import dynamic from "next/dynamic";
import { Center, Loader as UiLoader } from "@mantine/core";
import type { WidgetKind } from "@homarr/definitions";
import type { WidgetComponentProps } from "./definition";
import { loadWidgetModule, widgetKinds } from "./manifest";
const createWidgetComponent = (kind: WidgetKind) =>
dynamic<WidgetComponentProps<WidgetKind>>(
async () => {
const { componentLoader } = await loadWidgetModule(kind);
return await componentLoader();
},
{
loading: () => (
<Center w="100%" h="100%">
<UiLoader />
</Center>
),
},
);
// Creating every dynamic wrapper at module scope lets Next register stable
// client boundaries and preload metadata. Widget implementation modules remain
// behind their manifest loaders.
const widgetComponents = Object.fromEntries(widgetKinds.map((kind) => [kind, createWidgetComponent(kind)])) as Record<
WidgetKind,
ComponentType<WidgetComponentProps<WidgetKind>>
>;
export const getWidgetComponent = <TKind extends WidgetKind>(kind: TKind) => {
return widgetComponents[kind] as ComponentType<WidgetComponentProps<TKind>>;
};
+4 -3
View File
@@ -1,7 +1,8 @@
import { api, trpc } from "@homarr/api/server";
import { getDownloadJobsAndStatusesAsync } from "@homarr/api/widget-prefetch-server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "downloadsWidgetPrefetch" });
@@ -16,8 +17,8 @@ const prefetchAllAsync: Prefetch<"downloads"> = async (queryClient, items) => {
};
try {
const data = await api.widget.downloads.getJobsAndStatuses(input);
queryClient.setQueryData(trpc.widget.downloads.getJobsAndStatuses.queryKey(input), data);
const data = await getDownloadJobsAndStatusesAsync(input);
queryClient.setQueryData(createTrpcQueryKey("widget.downloads.getJobsAndStatuses", input), data);
logger.info("Prefetched downloads widget data", { integrations: item.integrationIds.length });
} catch (error) {
logger.warn("Failed to prefetch downloads widget data", { error });
+5 -17
View File
@@ -1,30 +1,24 @@
import { useMemo } from "react";
import { IconExclamationCircle, IconShield } from "@tabler/icons-react";
import { TRPCClientError } from "@trpc/client";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { WidgetKind } from "@homarr/definitions";
import type { WidgetDefinition } from "..";
import { widgetImports } from "..";
import type { WidgetDefinition } from "../definition";
import { ErrorBoundaryError } from "./base";
import type { BaseWidgetErrorProps } from "./base-component";
import { BaseWidgetError } from "./base-component";
interface WidgetErrorProps {
kind: WidgetKind;
definition?: WidgetDefinition;
error: unknown;
resetErrorBoundary: () => void;
}
export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProps) => {
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
export const WidgetError = ({ error, resetErrorBoundary, definition }: WidgetErrorProps) => {
if (error instanceof ErrorBoundaryError) {
return <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
}
const widgetTrpcErrorData = handleWidgetTrpcError(error, currentDefinition);
const widgetTrpcErrorData = definition ? handleWidgetTrpcError(error, definition) : null;
if (widgetTrpcErrorData) {
return <BaseWidgetError {...widgetTrpcErrorData} onRetry={resetErrorBoundary} />;
}
@@ -34,13 +28,7 @@ export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProp
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
}
return (
<BaseWidgetError
icon={IconExclamationCircle}
message={(error as { toString: () => string }).toString()}
onRetry={resetErrorBoundary}
/>
);
return <BaseWidgetError icon={IconExclamationCircle} message={String(error)} onRetry={resetErrorBoundary} />;
};
const handleWidgetTrpcError = (
+102
View File
@@ -0,0 +1,102 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { widgetKinds as definedWidgetKinds } from "@homarr/definitions";
import {
createRetryableLoader,
loadAllWidgetDefinitions,
loadWidgetDefinition,
loadWidgetModule,
reduceWidgetOptionsWithDefinition,
widgetKinds,
} from "./manifest";
import { widgetQueryRefetchIntervals } from "./refetch-intervals";
describe("widget manifest promise stability", () => {
it("returns the same module promise for every render", () => {
expect(loadWidgetModule("clock")).toBe(loadWidgetModule("clock"));
});
it("returns the same derived definition promise for every render", () => {
expect(loadWidgetDefinition("clock")).toBe(loadWidgetDefinition("clock"));
});
it("returns the same all-definitions promise for every modal render", () => {
expect(loadAllWidgetDefinitions()).toBe(loadAllWidgetDefinitions());
});
it("retries a failed loader instead of retaining its rejected promise", async () => {
let attempts = 0;
const loader = createRetryableLoader(
new Map([
[
"example",
() => {
attempts += 1;
if (attempts === 1) return Promise.reject(new Error("temporary failure"));
return Promise.resolve("loaded");
},
],
]),
);
await expect(loader("example")).rejects.toThrow("temporary failure");
await expect(loader("example")).resolves.toBe("loaded");
expect(attempts).toBe(2);
});
it("covers every declared widget kind", () => {
expect(new Set(widgetKinds)).toEqual(new Set(definedWidgetKinds));
});
it("loads matching definitions and component loaders for every widget", async () => {
const definitions = await loadAllWidgetDefinitions();
await Promise.all(
widgetKinds.map(async (kind) => {
const module = await loadWidgetModule(kind);
const component = await module.componentLoader();
expect(definitions.get(kind)).toBe(module.definition);
expect(component.default).toBeDefined();
}),
);
}, 30_000);
it("preserves every widget option default", async () => {
const definitions = await loadAllWidgetDefinitions();
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
for (const definition of definitions.values()) {
const options = definition.createOptions(settings);
const reduced = reduceWidgetOptionsWithDefinition(definition, settings);
expect(Object.keys(reduced)).toEqual(Object.keys(options));
expect(Object.values(reduced).every((value) => value !== undefined)).toBe(true);
}
});
it("keeps the client polling policy aligned with widget definitions", async () => {
const definitions = await loadAllWidgetDefinitions();
type PollingPolicy = { queryKey: QueryKey; intervalSeconds: number | null };
const expectedByQueryKey = new Map<string, PollingPolicy>();
for (const kind of widgetKinds) {
const definition = definitions.get(kind);
if (definition?.refetchInterval === undefined) continue;
const entry = {
queryKey: definition.queryKey ?? [["widget", kind]],
intervalSeconds: definition.refetchInterval,
} as const;
const serializedQueryKey = JSON.stringify(entry.queryKey);
const existing = expectedByQueryKey.get(serializedQueryKey);
expect(existing?.intervalSeconds ?? entry.intervalSeconds).toBe(entry.intervalSeconds);
expectedByQueryKey.set(serializedQueryKey, entry);
}
const serializePolicy = (entry: PollingPolicy) => JSON.stringify(entry);
expect(widgetQueryRefetchIntervals.map(serializePolicy).toSorted()).toEqual(
[...expectedByQueryKey.values()].map(serializePolicy).toSorted(),
);
});
});
+140
View File
@@ -0,0 +1,140 @@
import type { ComponentType } from "react";
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetComponentProps, WidgetDefinition } from "./definition";
import type { WidgetOptionDefinition } from "./options";
type WidgetModule = {
definition: WidgetDefinition;
componentLoader: () => Promise<{ default: ComponentType<WidgetComponentProps<WidgetKind>> }>;
};
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
clock: () => import("./clock") as Promise<WidgetModule>,
weather: () => import("./weather") as Promise<WidgetModule>,
app: () => import("./app") as Promise<WidgetModule>,
archiveTeamWarrior: () => import("./archive-team-warrior") as Promise<WidgetModule>,
anchorNote: () => import("./anchor-note") as Promise<WidgetModule>,
notebook: () => import("./notebook") as Promise<WidgetModule>,
iframe: () => import("./iframe") as Promise<WidgetModule>,
video: () => import("./video") as Promise<WidgetModule>,
dnsHoleSummary: () => import("./dns-hole/summary") as Promise<WidgetModule>,
dnsHoleControls: () => import("./dns-hole/controls") as Promise<WidgetModule>,
"smartHome-entityState": () => import("./smart-home/entity-state") as Promise<WidgetModule>,
"smartHome-executeAutomation": () => import("./smart-home/execute-automation") as Promise<WidgetModule>,
stockPrice: () => import("./stocks") as Promise<WidgetModule>,
mediaServer: () => import("./media-server") as Promise<WidgetModule>,
calendar: () => import("./calendar") as Promise<WidgetModule>,
downloads: () => import("./downloads") as Promise<WidgetModule>,
"mediaRequests-requestList": () => import("./media-requests/list") as Promise<WidgetModule>,
"mediaRequests-requestStats": () => import("./media-requests/stats") as Promise<WidgetModule>,
mediaMissing: () => import("./media-missing") as Promise<WidgetModule>,
networkControllerSummary: () => import("./network-controller/summary") as Promise<WidgetModule>,
networkControllerStatus: () => import("./network-controller/network-status") as Promise<WidgetModule>,
rssFeed: () => import("./rssFeed") as Promise<WidgetModule>,
bookmarks: () => import("./bookmarks") as Promise<WidgetModule>,
bazarr: () => import("./bazarr") as Promise<WidgetModule>,
indexerManager: () => import("./indexer-manager") as Promise<WidgetModule>,
healthMonitoring: () => import("./health-monitoring") as Promise<WidgetModule>,
mediaTranscoding: () => import("./media-transcoding") as Promise<WidgetModule>,
minecraftServerStatus: () => import("./minecraft/server-status") as Promise<WidgetModule>,
dockerContainers: () => import("./docker") as Promise<WidgetModule>,
releases: () => import("./releases") as Promise<WidgetModule>,
firewall: () => import("./firewall") as Promise<WidgetModule>,
notifications: () => import("./notifications") as Promise<WidgetModule>,
mediaReleases: () => import("./media-releases") as Promise<WidgetModule>,
systemResources: () => import("./system-resources") as Promise<WidgetModule>,
coolify: () => import("./coolify") as Promise<WidgetModule>,
systemDisks: () => import("./system-disks") as Promise<WidgetModule>,
timetable: () => import("./timetable") as Promise<WidgetModule>,
"immich-serverStats": () => import("./immich/server-stats") as Promise<WidgetModule>,
"immich-albumCarousel": () => import("./immich/album-carousel") as Promise<WidgetModule>,
paperlessNgx: () => import("./paperless-ngx") as Promise<WidgetModule>,
patchmon: () => import("./patchmon") as Promise<WidgetModule>,
tracearr: () => import("./tracearr") as Promise<WidgetModule>,
speedtestTracker: () => import("./speedtest-tracker") as Promise<WidgetModule>,
uptimeKuma: () => import("./uptime-kuma") as Promise<WidgetModule>,
audioStats: () => import("./audio-stats") as Promise<WidgetModule>,
umami: () => import("./umami") as Promise<WidgetModule>,
vpn: () => import("./vpn") as Promise<WidgetModule>,
ups: () => import("./ups") as Promise<WidgetModule>,
beszelSystemTable: () => import("./beszel-system-table") as Promise<WidgetModule>,
beszelSystemGrid: () => import("./beszel-system-grid") as Promise<WidgetModule>,
beszelAlerts: () => import("./beszel-alerts") as Promise<WidgetModule>,
beszelSystemStats: () => import("./beszel-system-stats") as Promise<WidgetModule>,
traefik: () => import("./traefik") as Promise<WidgetModule>,
customApi: () => import("./custom-api") as Promise<WidgetModule>,
};
const definitionPromises = new Map<WidgetKind, Promise<WidgetDefinition>>();
export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[];
export const createRetryableLoader = <TKey, TValue>(loaders: ReadonlyMap<TKey, () => Promise<TValue>>) => {
const promises = new Map<TKey, Promise<TValue>>();
return (key: TKey) => {
const existing = promises.get(key);
if (existing) return existing;
const loader = loaders.get(key);
if (!loader) throw new Error(`No loader is registered for ${String(key)}`);
const promise = loader();
promises.set(key, promise);
void promise.catch(() => {
if (promises.get(key) === promise) promises.delete(key);
});
return promise;
};
};
export const loadWidgetModule = createRetryableLoader(
new Map(Object.entries(moduleLoaders) as [WidgetKind, () => Promise<WidgetModule>][]),
);
export const loadWidgetDefinition = (kind: WidgetKind) => {
const existing = definitionPromises.get(kind);
if (existing) return existing;
const promise = loadWidgetModule(kind).then(({ definition }) => definition);
definitionPromises.set(kind, promise);
void promise.catch(() => {
if (definitionPromises.get(kind) === promise) definitionPromises.delete(kind);
});
return promise;
};
let allWidgetDefinitionsPromise: Promise<Map<WidgetKind, WidgetDefinition>> | undefined;
export const loadAllWidgetDefinitions = () => {
if (allWidgetDefinitionsPromise) return allWidgetDefinitionsPromise;
const promise = Promise.all(widgetKinds.map(async (kind) => [kind, await loadWidgetDefinition(kind)] as const)).then(
(entries) => new Map(entries),
);
allWidgetDefinitionsPromise = promise;
void promise.catch(() => {
if (allWidgetDefinitionsPromise === promise) allWidgetDefinitionsPromise = undefined;
});
return promise;
};
export const reduceWidgetOptionsWithDefinition = (
definition: WidgetDefinition,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
currentValue: Record<string, unknown> = {},
) => {
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce(
(prev, [key, value]) => ({
...prev,
[key]: currentValue[key] ?? value.defaultValue,
}),
{} as Record<string, unknown>,
);
};
+7 -2
View File
@@ -6,7 +6,7 @@ import { getServerSettingsAsync } from "@homarr/db/queries";
import type { WidgetKind } from "@homarr/definitions";
import { createSettings } from "@homarr/settings/creator";
import { reduceWidgetOptionsWithDefaultValues } from ".";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "./manifest";
import prefetchForApps from "./app/prefetch";
import prefetchForBookmarks from "./bookmarks/prefetch";
import prefetchForDownloads from "./downloads/prefetch";
@@ -37,10 +37,15 @@ export const prefetchForKindAsync = async <TKind extends WidgetKind>(
}
const serverSettings = await cachedGetServerSettingsAsync(db);
const definition = await loadWidgetDefinition(kind);
const itemsWithDefaultOptions = items.map((item) => ({
...item,
options: reduceWidgetOptionsWithDefaultValues(kind, createSettings({ user: null, serverSettings }), item.options),
options: reduceWidgetOptionsWithDefinition(
definition,
createSettings({ user: null, serverSettings }),
item.options,
),
}));
await callback(queryClient, itemsWithDefaultOptions as never[]);
+29
View File
@@ -0,0 +1,29 @@
/**
* Query policy used by the root client provider.
*
* Keep this module free of widget component/definition imports. Importing the
* full widget registry from a root provider makes every widget part of the
* initial client graph, even before a board has rendered one widget.
*/
export const widgetQueryRefetchIntervals = [
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 5 },
{ queryKey: [["widget", "downloads"]], intervalSeconds: 5 },
{ queryKey: [["widget", "firewall"]], intervalSeconds: 5 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer"]], intervalSeconds: 5 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 5 },
{ queryKey: [["widget", "immich"]], intervalSeconds: null },
{ queryKey: [["widget", "indexerManager"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaRelease"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaTranscoding"]], intervalSeconds: null },
{ queryKey: [["widget", "minecraft"]], intervalSeconds: null },
{ queryKey: [["widget", "networkController"]], intervalSeconds: null },
{ queryKey: [["widget", "notifications"]], intervalSeconds: null },
{ queryKey: [["widget", "paperlessNgx"]], intervalSeconds: null },
{ queryKey: [["widget", "releases"]], intervalSeconds: null },
{ queryKey: [["widget", "rssFeed"]], intervalSeconds: null },
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
] as const;
@@ -0,0 +1,12 @@
import { describe, expect, test } from "vitest";
import { createTrpcQueryKey } from "./trpc-query-key";
describe("createTrpcQueryKey", () => {
test("matches the tRPC TanStack query key shape", () => {
expect(createTrpcQueryKey("widget.downloads.getJobsAndStatuses", { integrationIds: ["one"], limit: 50 })).toEqual([
["widget", "downloads", "getJobsAndStatuses"],
{ input: { integrationIds: ["one"], limit: 50 }, type: "query" },
]);
});
});
+4
View File
@@ -0,0 +1,4 @@
export const createTrpcQueryKey = <TInput>(path: string, input: TInput) => [
path.split("."),
{ input, type: "query" as const },
];