diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 49be8c37c..80d52a50f 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -4,15 +4,21 @@ import { TRPCError } from "@trpc/server"; // Placed here because gridstack styles are used for board content import "~/styles/gridstack.scss"; +import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; + +import { getQueryClient } from "@homarr/api/server"; import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; import { isNullOrWhitespace } from "@homarr/common"; +import type { WidgetKind } from "@homarr/definitions"; +import { logger } from "@homarr/log"; import { getI18n } from "@homarr/translation/server"; +import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { createMetaTitle } from "~/metadata"; import { createBoardLayout } from "../_layout-creator"; -import type { Board } from "../_types"; +import type { Board, Item } from "../_types"; import { DynamicClientBoard } from "./_dynamic-client"; import { BoardContentHeaderActions } from "./_header-actions"; @@ -31,14 +37,36 @@ export const createBoardContentPage = >( getInitialBoardAsync: getInitialBoard, }), // eslint-disable-next-line no-restricted-syntax - page: async () => { + page: async ({ params }: { params: Promise }) => { const session = await auth(); const integrations = await getIntegrationsWithPermissionsAsync(session); + const board = await getInitialBoard(await params); + const queryClient = getQueryClient(); + + // Prefetch item data + const itemsMap = board.items.reduce((acc, item) => { + const existing = acc.get(item.kind); + if (existing) { + existing.push(item); + } else { + acc.set(item.kind, [item]); + } + return acc; + }, new Map()); + + for (const [kind, items] of itemsMap) { + await prefetchForKindAsync(kind, queryClient, items).catch((error) => { + logger.error(new Error("Failed to prefetch widget", { cause: error })); + }); + } + return ( - - - + + + + + ); }, generateMetadataAsync: async ({ params }: { params: Promise }): Promise => { diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 5fbd3c3f7..afb773f72 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -7,6 +7,7 @@ import "@homarr/ui/styles.css"; import "~/styles/scroll-area.scss"; import { notFound } from "next/navigation"; +import type { DayOfWeek } from "@mantine/dates"; import { NextIntlClientProvider } from "next-intl"; import { api } from "@homarr/api/server"; @@ -87,7 +88,15 @@ export default async function Layout(props: { }, (innerProps) => ( { }); export const api = createCaller(createContext); + +// IMPORTANT: Create a stable getter for the query client that +// will return the same client during the same request. +export const getQueryClient = cache(makeQueryClient); +export const trpc = createTRPCOptionsProxy({ + ctx: createContext, + router: appRouter, + queryClient: getQueryClient, +}); diff --git a/packages/api/src/shared.ts b/packages/api/src/shared.ts index b08f09893..4ef9b7a4f 100644 --- a/packages/api/src/shared.ts +++ b/packages/api/src/shared.ts @@ -1,3 +1,5 @@ +import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query"; + /** * Creates a headers callback for a given source * It will set the x-trpc-source header and cookies if needed @@ -51,3 +53,16 @@ export const trpcPath = "/api/trpc"; export function getTrpcUrl() { return `${getBaseUrl()}${trpcPath}`; } + +export const makeQueryClient = () => { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 1000, + }, + dehydrate: { + shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending", + }, + }, + }); +}; diff --git a/packages/settings/package.json b/packages/settings/package.json index 044ae503a..2a8d0d51c 100644 --- a/packages/settings/package.json +++ b/packages/settings/package.json @@ -5,7 +5,8 @@ "license": "Apache-2.0", "type": "module", "exports": { - ".": "./index.ts" + ".": "./index.ts", + "./creator": "./src/creator.ts" }, "typesVersions": { "*": { diff --git a/packages/settings/src/context.tsx b/packages/settings/src/context.tsx index d7b71aa72..df3fd927c 100644 --- a/packages/settings/src/context.tsx +++ b/packages/settings/src/context.tsx @@ -2,30 +2,9 @@ import type { PropsWithChildren } from "react"; import { createContext, useContext } from "react"; -import type { DayOfWeek } from "@mantine/dates"; -import type { RouterOutputs } from "@homarr/api"; -import type { User } from "@homarr/db/schema"; -import type { ServerSettings } from "@homarr/server-settings"; - -export type SettingsContextProps = Pick< - User, - | "firstDayOfWeek" - | "defaultSearchEngineId" - | "homeBoardId" - | "mobileHomeBoardId" - | "openSearchInNewTab" - | "pingIconsEnabled" -> & - Pick; - -interface PublicServerSettings { - search: Pick; - board: Pick< - ServerSettings["board"], - "homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus" - >; -} +import type { PublicServerSettings, SettingsContextProps, UserSettings } from "./creator"; +import { createSettings } from "./creator"; const SettingsContext = createContext(null); @@ -33,22 +12,9 @@ export const SettingsProvider = ({ user, serverSettings, children, -}: PropsWithChildren<{ user: RouterOutputs["user"]["getById"] | null; serverSettings: PublicServerSettings }>) => { +}: PropsWithChildren<{ user: UserSettings | null; serverSettings: PublicServerSettings }>) => { return ( - - {children} - + {children} ); }; diff --git a/packages/settings/src/creator.ts b/packages/settings/src/creator.ts new file mode 100644 index 000000000..5eb348930 --- /dev/null +++ b/packages/settings/src/creator.ts @@ -0,0 +1,48 @@ +import type { User } from "@homarr/db/schema"; +import type { ServerSettings } from "@homarr/server-settings"; + +export type SettingsContextProps = Pick< + User, + | "firstDayOfWeek" + | "defaultSearchEngineId" + | "homeBoardId" + | "mobileHomeBoardId" + | "openSearchInNewTab" + | "pingIconsEnabled" +> & + Pick; + +export interface PublicServerSettings { + search: Pick; + board: Pick< + ServerSettings["board"], + "homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus" + >; +} + +export type UserSettings = Pick< + User, + | "firstDayOfWeek" + | "defaultSearchEngineId" + | "homeBoardId" + | "mobileHomeBoardId" + | "openSearchInNewTab" + | "pingIconsEnabled" +>; + +export const createSettings = ({ + user, + serverSettings, +}: { + user: UserSettings | null; + serverSettings: PublicServerSettings; +}) => ({ + defaultSearchEngineId: user?.defaultSearchEngineId ?? serverSettings.search.defaultSearchEngineId, + openSearchInNewTab: user?.openSearchInNewTab ?? true, + firstDayOfWeek: user?.firstDayOfWeek ?? (1 as const), + homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId, + mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId, + pingIconsEnabled: user?.pingIconsEnabled ?? false, + enableStatusByDefault: serverSettings.board.enableStatusByDefault, + forceDisableStatus: serverSettings.board.forceDisableStatus, +}); diff --git a/packages/widgets/package.json b/packages/widgets/package.json index f2811d4a7..3dcbb4636 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -7,7 +7,8 @@ "exports": { ".": "./index.ts", "./errors": "./src/errors/component.tsx", - "./modals": "./src/modals/index.ts" + "./modals": "./src/modals/index.ts", + "./prefetch": "./src/prefetch.ts" }, "typesVersions": { "*": { @@ -35,6 +36,7 @@ "@homarr/form": "workspace:^0.1.0", "@homarr/forms-collection": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", + "@homarr/log": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", diff --git a/packages/widgets/src/app/prefetch.ts b/packages/widgets/src/app/prefetch.ts new file mode 100644 index 000000000..c1d148a6b --- /dev/null +++ b/packages/widgets/src/app/prefetch.ts @@ -0,0 +1,23 @@ +import { trpc } from "@homarr/api/server"; +import { db, inArray } from "@homarr/db"; +import { apps } from "@homarr/db/schema"; +import { logger } from "@homarr/log"; + +import type { Prefetch } from "../definition"; + +const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => { + const appIds = items.map((item) => item.options.appId); + const distinctAppIds = [...new Set(appIds)]; + + const dbApps = await db.query.apps.findMany({ + where: inArray(apps.id, distinctAppIds), + }); + + for (const app of dbApps) { + queryClient.setQueryData(trpc.app.byId.queryKey({ id: app.id }), app); + } + + logger.info(`Successfully prefetched ${dbApps.length} apps for app widget`); +}; + +export default prefetchAllAsync; diff --git a/packages/widgets/src/bookmarks/prefetch.ts b/packages/widgets/src/bookmarks/prefetch.ts new file mode 100644 index 000000000..1da24c6ed --- /dev/null +++ b/packages/widgets/src/bookmarks/prefetch.ts @@ -0,0 +1,30 @@ +import { trpc } from "@homarr/api/server"; +import { db, inArray } from "@homarr/db"; +import { apps } from "@homarr/db/schema"; +import { logger } from "@homarr/log"; + +import type { Prefetch } from "../definition"; + +const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => { + const appIds = items.flatMap((item) => item.options.items); + const distinctAppIds = [...new Set(appIds)]; + + const dbApps = await db.query.apps.findMany({ + where: inArray(apps.id, distinctAppIds), + }); + + for (const item of items) { + if (item.options.items.length === 0) { + continue; + } + + queryClient.setQueryData( + trpc.app.byIds.queryKey(item.options.items), + dbApps.filter((app) => item.options.items.includes(app.id)), + ); + } + + logger.info(`Successfully prefetched ${dbApps.length} apps for bookmarks`); +}; + +export default prefetchAllAsync; diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 85e2de5ad..f7df8c95d 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -1,9 +1,10 @@ import type { LoaderComponent } from "next/dynamic"; +import type { QueryClient } from "@tanstack/react-query"; import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { ServerSettings } from "@homarr/server-settings"; -import type { SettingsContextProps } from "@homarr/settings"; +import type { SettingsContextProps } from "@homarr/settings/creator"; import type { stringOrTranslation } from "@homarr/translation"; import type { TablerIcon } from "@homarr/ui"; @@ -21,6 +22,15 @@ const createWithDynamicImport = componentLoader, }); +export type PrefetchLoader = () => Promise<{ default: Prefetch }>; +export type Prefetch = ( + queryClient: QueryClient, + items: { + options: inferOptionsFromCreator>; + integrationIds: string[]; + }[], +) => Promise; + export const createWidgetDefinition = ( kind: TKind, definition: TDefinition, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 36cdcfa9f..df1701741 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -5,7 +5,7 @@ import { Center, Loader as UiLoader } from "@mantine/core"; import { objectEntries } from "@homarr/common"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import type { SettingsContextProps } from "@homarr/settings"; +import type { SettingsContextProps } from "@homarr/settings/creator"; import * as app from "./app"; import * as bookmarks from "./bookmarks"; diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 4d0f172bb..8e6c56c8a 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -8,7 +8,7 @@ import { objectEntries } from "@homarr/common"; import type { WidgetKind } from "@homarr/definitions"; import { zodResolver } from "@homarr/form"; import { createModal, useModalAction } from "@homarr/modals"; -import type { SettingsContextProps } from "@homarr/settings"; +import type { SettingsContextProps } from "@homarr/settings/creator"; import { useI18n } from "@homarr/translation/client"; import { zodErrorMap } from "@homarr/validation/form/i18n"; diff --git a/packages/widgets/src/prefetch.ts b/packages/widgets/src/prefetch.ts new file mode 100644 index 000000000..590237cc9 --- /dev/null +++ b/packages/widgets/src/prefetch.ts @@ -0,0 +1,45 @@ +import { cache } from "react"; +import type { QueryClient } from "@tanstack/react-query"; + +import { db } from "@homarr/db"; +import { getServerSettingsAsync } from "@homarr/db/queries"; +import type { WidgetKind } from "@homarr/definitions"; +import { createSettings } from "@homarr/settings/creator"; + +import { reduceWidgetOptionsWithDefaultValues } from "."; +import prefetchForApps from "./app/prefetch"; +import prefetchForBookmarks from "./bookmarks/prefetch"; +import type { Prefetch, WidgetOptionsRecordOf } from "./definition"; +import type { inferOptionsFromCreator } from "./options"; + +const cachedGetServerSettingsAsync = cache(getServerSettingsAsync); + +const prefetchCallbacks: Partial<{ + [TKind in WidgetKind]: Prefetch; +}> = { + bookmarks: prefetchForBookmarks, + app: prefetchForApps, +}; + +export const prefetchForKindAsync = async ( + kind: TKind, + queryClient: QueryClient, + items: { + options: inferOptionsFromCreator>; + integrationIds: string[]; + }[], +) => { + const callback = prefetchCallbacks[kind]; + if (!callback) { + return; + } + + const serverSettings = await cachedGetServerSettingsAsync(db); + + const itemsWithDefaultOptions = items.map((item) => ({ + ...item, + options: reduceWidgetOptionsWithDefaultValues(kind, createSettings({ user: null, serverSettings }), item.options), + })); + + await callback(queryClient, itemsWithDefaultOptions as never[]); +}; diff --git a/packages/widgets/src/test/translation.spec.ts b/packages/widgets/src/test/translation.spec.ts index 70a6721b8..8af82dc25 100644 --- a/packages/widgets/src/test/translation.spec.ts +++ b/packages/widgets/src/test/translation.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { objectEntries } from "@homarr/common"; -import type { SettingsContextProps } from "@homarr/settings"; +import type { SettingsContextProps } from "@homarr/settings/creator"; import { createLanguageMapping } from "@homarr/translation"; import { widgetImports } from ".."; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba3e393d9..092681fff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -576,6 +576,9 @@ importers: '@kubernetes/client-node': specifier: ^1.1.2 version: 1.1.2 + '@tanstack/react-query': + specifier: ^5.75.1 + version: 5.75.1(react@19.1.0) '@trpc/client': specifier: ^11.1.2 version: 11.1.2(@trpc/server@11.1.2(typescript@5.8.3))(typescript@5.8.3) @@ -585,6 +588,9 @@ importers: '@trpc/server': specifier: ^11.1.2 version: 11.1.2(typescript@5.8.3) + '@trpc/tanstack-react-query': + specifier: ^11.1.2 + version: 11.1.2(@tanstack/react-query@5.75.1(react@19.1.0))(@trpc/client@11.1.2(@trpc/server@11.1.2(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.1.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3) lodash.clonedeep: specifier: ^4.5.0 version: 4.5.0 @@ -2046,6 +2052,9 @@ importers: '@homarr/integrations': specifier: workspace:^0.1.0 version: link:../integrations + '@homarr/log': + specifier: workspace:^0.1.0 + version: link:../log '@homarr/modals': specifier: workspace:^0.1.0 version: link:../modals @@ -4479,6 +4488,16 @@ packages: peerDependencies: typescript: '>=5.7.2' + '@trpc/tanstack-react-query@11.1.2': + resolution: {integrity: sha512-c+NupnmIQmwgwYVTaHgDg59BZBtJ6KxqB0cxF9FBhKHPY6PlwGLdU8+mo25C5VIpofZYEII4H7NKE4yKGFSe3w==} + peerDependencies: + '@tanstack/react-query': ^5.67.1 + '@trpc/client': 11.1.2 + '@trpc/server': 11.1.2 + react: '>=18.2.0' + react-dom: '>=18.2.0' + typescript: '>=5.7.2' + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -13072,6 +13091,15 @@ snapshots: dependencies: typescript: 5.8.3 + '@trpc/tanstack-react-query@11.1.2(@tanstack/react-query@5.75.1(react@19.1.0))(@trpc/client@11.1.2(@trpc/server@11.1.2(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.1.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)': + dependencies: + '@tanstack/react-query': 5.75.1(react@19.1.0) + '@trpc/client': 11.1.2(@trpc/server@11.1.2(typescript@5.8.3))(typescript@5.8.3) + '@trpc/server': 11.1.2(typescript@5.8.3) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + typescript: 5.8.3 + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {}