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

@@ -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}