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
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 = <TParams extends Record<string, unknown>>(
getInitialBoardAsync: getInitialBoard,
}),
// eslint-disable-next-line no-restricted-syntax
page: async () => {
page: async ({ params }: { params: Promise<TParams> }) => {
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<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 (
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
</IntegrationProvider>
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
);
},
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 { 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) => (
<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={{
board: {
homeBoardId: serverSettings.board.homeBoardId,

View File

@@ -1,5 +1,5 @@
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 { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";

View File

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

View File

@@ -1,9 +1,12 @@
import { cache } from "react";
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 { 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.
@@ -19,3 +22,12 @@ const createContext = cache(async () => {
});
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
* 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",
},
},
});
};

View File

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

View File

@@ -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<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
board: Pick<
ServerSettings["board"],
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
>;
}
import type { PublicServerSettings, SettingsContextProps, UserSettings } from "./creator";
import { createSettings } from "./creator";
const SettingsContext = createContext<SettingsContextProps | null>(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 (
<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>
<SettingsContext.Provider value={createSettings({ user, serverSettings })}>{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": {
".": "./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",

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 { 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<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>(
kind: TKind,
definition: TDefinition,

View File

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

View File

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

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 { 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 "..";

28
pnpm-lock.yaml generated
View File

@@ -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': {}