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

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