diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 3e167dd8c..a51dd7fad 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -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) { }} > {props.children} - + {process.env.NODE_ENV === "development" && } ); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts index 2357d7424..d64fdb56e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts @@ -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(); }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx index d3b65a5e9..f0924bb6e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx @@ -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); }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 16be5c478..7cc39e4fb 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -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; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx index 1f129e4c1..0c5fd10e0 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx @@ -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: , async getInitialBoardAsync({ name }) { - return await api.board.getBoardByName({ name }); + return await getBoardByNameAsync(name); }, }); diff --git a/apps/nextjs/src/app/[locale]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index 212c74e73..0013c3170 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -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 MaybePromise { + 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]; diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 2388e2812..598a0a258 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -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 => ({ 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"; diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 9bbc0ac93..3e604ec7f 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -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({ 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 ( - + )} > { - 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 ( diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx index fbcd741c2..56ff3e215 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx @@ -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(); } diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index ad0f3a305..aece414cf 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -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 ( <> - - - - - + + + + } + > + + {item.advancedOptions.title?.trim() && ( { ); }; +interface LoadedBoardItemContentProps extends Omit { + innerRef: (element: HTMLDivElement | null) => void; +} + +const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => { + const board = useRequiredBoard(); + const definition = use(loadWidgetDefinition(item.kind)); + + return ( + + + ( + <> + + + + )} + > + + + + + ); +}; + interface InnerContentProps { item: SectionItem; width: number; height: number; widgetStateRef: MutableRefObject | 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 }) => 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 }) => ( <> - - + + )} > @@ -131,7 +175,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { (!("integrationsRequired" in definition) || definition.integrationsRequired !== false) } /> - + void; } @@ -29,7 +30,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => { return ; }; -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, diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 2ac349c6d..7ceaa20ae 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -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(({ 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(({ 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 => 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(({ 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(({ 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); } }} > diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 8520d6b89..c1ecaa5a0 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -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 | 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, - [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; + const rawOptions = definition.createOptions(settings) as unknown as Record; return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); - }, [currentDefinition, settings]); + }, [definition, settings]); const widgetContextActions = useMemo(() => { - const def = currentDefinition as Record; + const def = definition as unknown as Record; 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, diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx index 6fcdc4a93..6529eed12 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx @@ -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 ( + + + + ); +}; + +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 (
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> | 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, + currentValue: Record = {}, +) => { + const options = definition.createOptions(settings) as Record; + return objectEntries(options).reduce( + (prev, [key, value]) => ({ + ...prev, + [key]: currentValue[key] ?? value.defaultValue, + }), + {} as Record, + ); +}; diff --git a/packages/widgets/src/prefetch.ts b/packages/widgets/src/prefetch.ts index c0e3d03c4..79ca915c1 100644 --- a/packages/widgets/src/prefetch.ts +++ b/packages/widgets/src/prefetch.ts @@ -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 ( } 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[]); diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts new file mode 100644 index 000000000..c479d23db --- /dev/null +++ b/packages/widgets/src/refetch-intervals.ts @@ -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; diff --git a/packages/widgets/src/trpc-query-key.spec.ts b/packages/widgets/src/trpc-query-key.spec.ts new file mode 100644 index 000000000..e411f8272 --- /dev/null +++ b/packages/widgets/src/trpc-query-key.spec.ts @@ -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" }, + ]); + }); +}); diff --git a/packages/widgets/src/trpc-query-key.ts b/packages/widgets/src/trpc-query-key.ts new file mode 100644 index 000000000..51ea030dd --- /dev/null +++ b/packages/widgets/src/trpc-query-key.ts @@ -0,0 +1,4 @@ +export const createTrpcQueryKey = (path: string, input: TInput) => [ + path.split("."), + { input, type: "query" as const }, +];