feat(widget): add prefetch for apps and bookmarks (#2895)

This commit is contained in:
Meier Lukas
2025-05-02 19:23:15 +02:00
committed by GitHub
parent 2b1c433cef
commit 82c5361112
18 changed files with 271 additions and 52 deletions

View File

@@ -4,15 +4,21 @@ import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content // Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss"; import "~/styles/gridstack.scss";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client"; import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import { isNullOrWhitespace } from "@homarr/common"; import { isNullOrWhitespace } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { createBoardLayout } from "../_layout-creator"; import { createBoardLayout } from "../_layout-creator";
import type { Board } from "../_types"; import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client"; import { DynamicClientBoard } from "./_dynamic-client";
import { BoardContentHeaderActions } from "./_header-actions"; import { BoardContentHeaderActions } from "./_header-actions";
@@ -31,14 +37,36 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
}), }),
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
page: async () => { page: async ({ params }: { params: Promise<TParams> }) => {
const session = await auth(); const session = await auth();
const integrations = await getIntegrationsWithPermissionsAsync(session); 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<WidgetKind, Item[]>());
for (const [kind, items] of itemsMap) {
await prefetchForKindAsync(kind, queryClient, items).catch((error) => {
logger.error(new Error("Failed to prefetch widget", { cause: error }));
});
}
return ( return (
<IntegrationProvider integrations={integrations}> <HydrationBoundary state={dehydrate(queryClient)}>
<DynamicClientBoard /> <IntegrationProvider integrations={integrations}>
</IntegrationProvider> <DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
); );
}, },
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => { generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {

View File

@@ -7,6 +7,7 @@ import "@homarr/ui/styles.css";
import "~/styles/scroll-area.scss"; import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -87,7 +88,15 @@ export default async function Layout(props: {
}, },
(innerProps) => ( (innerProps) => (
<SettingsProvider <SettingsProvider
user={user} user={
user
? {
...user,
// Convert type, because output schema is not smart enough to infer $type from drizzle
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
}
: null
}
serverSettings={{ serverSettings={{
board: { board: {
homeBoardId: serverSettings.board.homeBoardId, homeBoardId: serverSettings.board.homeBoardId,

View File

@@ -1,5 +1,5 @@
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings"; import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetComponentProps } from "@homarr/widgets"; import type { WidgetComponentProps } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets"; import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";

View File

@@ -41,9 +41,11 @@
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.1.2", "@kubernetes/client-node": "^1.1.2",
"@tanstack/react-query": "^5.75.1",
"@trpc/client": "^11.1.2", "@trpc/client": "^11.1.2",
"@trpc/react-query": "^11.1.2", "@trpc/react-query": "^11.1.2",
"@trpc/server": "^11.1.2", "@trpc/server": "^11.1.2",
"@trpc/tanstack-react-query": "^11.1.2",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"next": "15.3.1", "next": "15.3.1",
"react": "19.1.0", "react": "19.1.0",

View File

@@ -1,9 +1,12 @@
import { cache } from "react"; import { cache } from "react";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { createCaller, createTRPCContext } from "@homarr/api"; import { appRouter, createCaller, createTRPCContext } from "@homarr/api";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { makeQueryClient } from "./shared";
/** /**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component. * handling a tRPC call from a React Server Component.
@@ -19,3 +22,12 @@ const createContext = cache(async () => {
}); });
export const api = createCaller(createContext); 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,
});

View File

@@ -1,3 +1,5 @@
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
/** /**
* Creates a headers callback for a given source * Creates a headers callback for a given source
* It will set the x-trpc-source header and cookies if needed * It will set the x-trpc-source header and cookies if needed
@@ -51,3 +53,16 @@ export const trpcPath = "/api/trpc";
export function getTrpcUrl() { export function getTrpcUrl() {
return `${getBaseUrl()}${trpcPath}`; return `${getBaseUrl()}${trpcPath}`;
} }
export const makeQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
},
},
});
};

View File

@@ -5,7 +5,8 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./index.ts" ".": "./index.ts",
"./creator": "./src/creator.ts"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -2,30 +2,9 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { DayOfWeek } from "@mantine/dates";
import type { RouterOutputs } from "@homarr/api"; import type { PublicServerSettings, SettingsContextProps, UserSettings } from "./creator";
import type { User } from "@homarr/db/schema"; import { createSettings } from "./creator";
import type { ServerSettings } from "@homarr/server-settings";
export type SettingsContextProps = Pick<
User,
| "firstDayOfWeek"
| "defaultSearchEngineId"
| "homeBoardId"
| "mobileHomeBoardId"
| "openSearchInNewTab"
| "pingIconsEnabled"
> &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
board: Pick<
ServerSettings["board"],
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
>;
}
const SettingsContext = createContext<SettingsContextProps | null>(null); const SettingsContext = createContext<SettingsContextProps | null>(null);
@@ -33,22 +12,9 @@ export const SettingsProvider = ({
user, user,
serverSettings, serverSettings,
children, children,
}: PropsWithChildren<{ user: RouterOutputs["user"]["getById"] | null; serverSettings: PublicServerSettings }>) => { }: PropsWithChildren<{ user: UserSettings | null; serverSettings: PublicServerSettings }>) => {
return ( return (
<SettingsContext.Provider <SettingsContext.Provider value={createSettings({ user, serverSettings })}>{children}</SettingsContext.Provider>
value={{
defaultSearchEngineId: user?.defaultSearchEngineId ?? serverSettings.search.defaultSearchEngineId,
openSearchInNewTab: user?.openSearchInNewTab ?? true,
firstDayOfWeek: (user?.firstDayOfWeek as DayOfWeek | undefined) ?? (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,
}}
>
{children}
</SettingsContext.Provider>
); );
}; };

View File

@@ -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<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
export interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
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,
});

View File

@@ -7,7 +7,8 @@
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./errors": "./src/errors/component.tsx", "./errors": "./src/errors/component.tsx",
"./modals": "./src/modals/index.ts" "./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {
@@ -35,6 +36,7 @@
"@homarr/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0", "@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import type { LoaderComponent } from "next/dynamic"; 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 { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { ServerSettings } from "@homarr/server-settings"; 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 { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
@@ -21,6 +22,15 @@ const createWithDynamicImport =
componentLoader, componentLoader,
}); });
export type PrefetchLoader<TKind extends WidgetKind> = () => Promise<{ default: Prefetch<TKind> }>;
export type Prefetch<TKind extends WidgetKind> = (
queryClient: QueryClient,
items: {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];
}[],
) => Promise<void>;
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>( export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
kind: TKind, kind: TKind,
definition: TDefinition, definition: TDefinition,

View File

@@ -5,7 +5,7 @@ import { Center, Loader as UiLoader } from "@mantine/core";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; 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 app from "./app";
import * as bookmarks from "./bookmarks"; import * as bookmarks from "./bookmarks";

View File

@@ -8,7 +8,7 @@ import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { zodResolver } from "@homarr/form"; import { zodResolver } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals"; 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 { useI18n } from "@homarr/translation/client";
import { zodErrorMap } from "@homarr/validation/form/i18n"; import { zodErrorMap } from "@homarr/validation/form/i18n";

View File

@@ -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<TKind>;
}> = {
bookmarks: prefetchForBookmarks,
app: prefetchForApps,
};
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
kind: TKind,
queryClient: QueryClient,
items: {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
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[]);
};

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import type { SettingsContextProps } from "@homarr/settings"; import type { SettingsContextProps } from "@homarr/settings/creator";
import { createLanguageMapping } from "@homarr/translation"; import { createLanguageMapping } from "@homarr/translation";
import { widgetImports } from ".."; import { widgetImports } from "..";

28
pnpm-lock.yaml generated
View File

@@ -576,6 +576,9 @@ importers:
'@kubernetes/client-node': '@kubernetes/client-node':
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2 version: 1.1.2
'@tanstack/react-query':
specifier: ^5.75.1
version: 5.75.1(react@19.1.0)
'@trpc/client': '@trpc/client':
specifier: ^11.1.2 specifier: ^11.1.2
version: 11.1.2(@trpc/server@11.1.2(typescript@5.8.3))(typescript@5.8.3) version: 11.1.2(@trpc/server@11.1.2(typescript@5.8.3))(typescript@5.8.3)
@@ -585,6 +588,9 @@ importers:
'@trpc/server': '@trpc/server':
specifier: ^11.1.2 specifier: ^11.1.2
version: 11.1.2(typescript@5.8.3) 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: lodash.clonedeep:
specifier: ^4.5.0 specifier: ^4.5.0
version: 4.5.0 version: 4.5.0
@@ -2046,6 +2052,9 @@ importers:
'@homarr/integrations': '@homarr/integrations':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../integrations version: link:../integrations
'@homarr/log':
specifier: workspace:^0.1.0
version: link:../log
'@homarr/modals': '@homarr/modals':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../modals version: link:../modals
@@ -4479,6 +4488,16 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=5.7.2' 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': '@tsconfig/node10@1.0.11':
resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==}
@@ -13072,6 +13091,15 @@ snapshots:
dependencies: dependencies:
typescript: 5.8.3 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/node10@1.0.11': {}
'@tsconfig/node12@1.0.11': {} '@tsconfig/node12@1.0.11': {}