diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
index 906abdea6..894bcbad6 100644
--- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
+++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
@@ -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;
});
diff --git a/apps/nextjs/src/components/board/sections/category/filter.ts b/apps/nextjs/src/components/board/sections/category/filter.ts
index 72bb4443f..3866a0075 100644
--- a/apps/nextjs/src/components/board/sections/category/filter.ts
+++ b/apps/nextjs/src/components/board/sections/category/filter.ts
@@ -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 = (
+export const filterByItemKind = async (
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["options"],
diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts
index 7451af1e7..a6802b9a6 100644
--- a/apps/nextjs/src/components/integration/integration-grid-shared.ts
+++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts
@@ -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 = {
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 => {
+ 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()));
diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx
index cbd347ba1..ff1bd4ff1 100644
--- a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx
+++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx
@@ -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 && (
+
+
+
+ )}
{filtered.map((integration) => {
const isSelected = selectedKinds.has(integration.kind);
const isDetected = detectedKinds?.has(integration.kind) ?? false;
diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx
index f4905dd2a..5127534d9 100644
--- a/apps/nextjs/src/components/integration/integration-select-grid.tsx
+++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx
@@ -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 && (
+
+
+
+ )}
{filtered.map((integration) => (
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();
+};
diff --git a/packages/api/src/rsc-context.ts b/packages/api/src/rsc-context.ts
new file mode 100644
index 000000000..6ea6eeb2d
--- /dev/null
+++ b/packages/api/src/rsc-context.ts
@@ -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,
+ });
+});
diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts
index b172e2e84..eae175c62 100644
--- a/packages/api/src/server.ts
+++ b/packages/api/src/server.ts
@@ -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);
diff --git a/packages/api/src/widget-prefetch-server.ts b/packages/api/src/widget-prefetch-server.ts
new file mode 100644
index 000000000..dd05f81e3
--- /dev/null
+++ b/packages/api/src/widget-prefetch-server.ts
@@ -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);
+};
diff --git a/packages/translation/src/client/index.ts b/packages/translation/src/client/index.ts
index 5c6755883..990267981 100644
--- a/packages/translation/src/client/index.ts
+++ b/packages/translation/src/client/index.ts
@@ -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 };
diff --git a/packages/widgets/package.json b/packages/widgets/package.json
index 2f835dafd..e47c48938 100644
--- a/packages/widgets/package.json
+++ b/packages/widgets/package.json
@@ -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",
diff --git a/packages/widgets/src/app/prefetch.ts b/packages/widgets/src/app/prefetch.ts
index 45300197b..8e99f7082 100644
--- a/packages/widgets/src/app/prefetch.ts
+++ b/packages/widgets/src/app/prefetch.ts
@@ -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 });
diff --git a/packages/widgets/src/bookmarks/prefetch.ts b/packages/widgets/src/bookmarks/prefetch.ts
index 2c3970327..616de0718 100644
--- a/packages/widgets/src/bookmarks/prefetch.ts
+++ b/packages/widgets/src/bookmarks/prefetch.ts
@@ -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)),
);
}
diff --git a/packages/widgets/src/client.tsx b/packages/widgets/src/client.tsx
new file mode 100644
index 000000000..dd3922f55
--- /dev/null
+++ b/packages/widgets/src/client.tsx
@@ -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>(
+ async () => {
+ const { componentLoader } = await loadWidgetModule(kind);
+ return await componentLoader();
+ },
+ {
+ loading: () => (
+
+
+
+ ),
+ },
+ );
+
+// 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>
+>;
+
+export const getWidgetComponent = (kind: TKind) => {
+ return widgetComponents[kind] as ComponentType>;
+};
diff --git a/packages/widgets/src/downloads/prefetch.ts b/packages/widgets/src/downloads/prefetch.ts
index 4eb073c43..c56f79488 100644
--- a/packages/widgets/src/downloads/prefetch.ts
+++ b/packages/widgets/src/downloads/prefetch.ts
@@ -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 });
diff --git a/packages/widgets/src/errors/component.tsx b/packages/widgets/src/errors/component.tsx
index 57f77d62a..733a5c3d3 100644
--- a/packages/widgets/src/errors/component.tsx
+++ b/packages/widgets/src/errors/component.tsx
@@ -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 ;
}
- const widgetTrpcErrorData = handleWidgetTrpcError(error, currentDefinition);
+ const widgetTrpcErrorData = definition ? handleWidgetTrpcError(error, definition) : null;
if (widgetTrpcErrorData) {
return ;
}
@@ -34,13 +28,7 @@ export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProp
return ;
}
- return (
- string }).toString()}
- onRetry={resetErrorBoundary}
- />
- );
+ return ;
};
const handleWidgetTrpcError = (
diff --git a/packages/widgets/src/manifest.spec.ts b/packages/widgets/src/manifest.spec.ts
new file mode 100644
index 000000000..d0486c2bb
--- /dev/null
+++ b/packages/widgets/src/manifest.spec.ts
@@ -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();
+
+ 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(),
+ );
+ });
+});
diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts
new file mode 100644
index 000000000..d8883869a
--- /dev/null
+++ b/packages/widgets/src/manifest.ts
@@ -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> }>;
+};
+
+const moduleLoaders: Record Promise> = {
+ clock: () => import("./clock") as Promise,
+ weather: () => import("./weather") as Promise,
+ app: () => import("./app") as Promise,
+ archiveTeamWarrior: () => import("./archive-team-warrior") as Promise,
+ anchorNote: () => import("./anchor-note") as Promise,
+ notebook: () => import("./notebook") as Promise,
+ iframe: () => import("./iframe") as Promise,
+ video: () => import("./video") as Promise,
+ dnsHoleSummary: () => import("./dns-hole/summary") as Promise,
+ dnsHoleControls: () => import("./dns-hole/controls") as Promise,
+ "smartHome-entityState": () => import("./smart-home/entity-state") as Promise,
+ "smartHome-executeAutomation": () => import("./smart-home/execute-automation") as Promise,
+ stockPrice: () => import("./stocks") as Promise,
+ mediaServer: () => import("./media-server") as Promise,
+ calendar: () => import("./calendar") as Promise,
+ downloads: () => import("./downloads") as Promise,
+ "mediaRequests-requestList": () => import("./media-requests/list") as Promise,
+ "mediaRequests-requestStats": () => import("./media-requests/stats") as Promise,
+ mediaMissing: () => import("./media-missing") as Promise,
+ networkControllerSummary: () => import("./network-controller/summary") as Promise,
+ networkControllerStatus: () => import("./network-controller/network-status") as Promise,
+ rssFeed: () => import("./rssFeed") as Promise,
+ bookmarks: () => import("./bookmarks") as Promise,
+ bazarr: () => import("./bazarr") as Promise,
+ indexerManager: () => import("./indexer-manager") as Promise,
+ healthMonitoring: () => import("./health-monitoring") as Promise,
+ mediaTranscoding: () => import("./media-transcoding") as Promise,
+ minecraftServerStatus: () => import("./minecraft/server-status") as Promise,
+ dockerContainers: () => import("./docker") as Promise,
+ releases: () => import("./releases") as Promise,
+ firewall: () => import("./firewall") as Promise,
+ notifications: () => import("./notifications") as Promise,
+ mediaReleases: () => import("./media-releases") as Promise,
+ systemResources: () => import("./system-resources") as Promise,
+ coolify: () => import("./coolify") as Promise,
+ systemDisks: () => import("./system-disks") as Promise,
+ timetable: () => import("./timetable") as Promise,
+ "immich-serverStats": () => import("./immich/server-stats") as Promise,
+ "immich-albumCarousel": () => import("./immich/album-carousel") as Promise,
+ paperlessNgx: () => import("./paperless-ngx") as Promise,
+ patchmon: () => import("./patchmon") as Promise,
+ tracearr: () => import("./tracearr") as Promise,
+ speedtestTracker: () => import("./speedtest-tracker") as Promise,
+ uptimeKuma: () => import("./uptime-kuma") as Promise,
+ audioStats: () => import("./audio-stats") as Promise,
+ umami: () => import("./umami") as Promise,
+ vpn: () => import("./vpn") as Promise,
+ ups: () => import("./ups") as Promise,
+ beszelSystemTable: () => import("./beszel-system-table") as Promise,
+ beszelSystemGrid: () => import("./beszel-system-grid") as Promise,
+ beszelAlerts: () => import("./beszel-alerts") as Promise,
+ beszelSystemStats: () => import("./beszel-system-stats") as Promise,
+ traefik: () => import("./traefik") as Promise,
+ customApi: () => import("./custom-api") as Promise,
+};
+
+const definitionPromises = new Map>();
+
+export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[];
+
+export const createRetryableLoader = (loaders: ReadonlyMap Promise>) => {
+ const promises = new Map>();
+
+ 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][]),
+);
+
+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