perf(widgets): lazy load the application graph
This commit is contained in:
1 parent
4ca0b747bb
commit
6b74078766
38 files changed
+676
-202
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<WidgetComponentProps<WidgetKind>>(
|
||||
async () => {
|
||||
const { componentLoader } = await loadWidgetModule(kind);
|
||||
return await componentLoader();
|
||||
},
|
||||
{
|
||||
loading: () => (
|
||||
<Center w="100%" h="100%">
|
||||
<UiLoader />
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
// 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<WidgetComponentProps<WidgetKind>>
|
||||
>;
|
||||
|
||||
export const getWidgetComponent = <TKind extends WidgetKind>(kind: TKind) => {
|
||||
return widgetComponents[kind] as ComponentType<WidgetComponentProps<TKind>>;
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
|
||||
const widgetTrpcErrorData = handleWidgetTrpcError(error, currentDefinition);
|
||||
const widgetTrpcErrorData = definition ? handleWidgetTrpcError(error, definition) : null;
|
||||
if (widgetTrpcErrorData) {
|
||||
return <BaseWidgetError {...widgetTrpcErrorData} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
@@ -34,13 +28,7 @@ export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProp
|
||||
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseWidgetError
|
||||
icon={IconExclamationCircle}
|
||||
message={(error as { toString: () => string }).toString()}
|
||||
onRetry={resetErrorBoundary}
|
||||
/>
|
||||
);
|
||||
return <BaseWidgetError icon={IconExclamationCircle} message={String(error)} onRetry={resetErrorBoundary} />;
|
||||
};
|
||||
|
||||
const handleWidgetTrpcError = (
|
||||
|
||||
@@ -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<string, PollingPolicy>();
|
||||
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<WidgetComponentProps<WidgetKind>> }>;
|
||||
};
|
||||
|
||||
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
||||
clock: () => import("./clock") as Promise<WidgetModule>,
|
||||
weather: () => import("./weather") as Promise<WidgetModule>,
|
||||
app: () => import("./app") as Promise<WidgetModule>,
|
||||
archiveTeamWarrior: () => import("./archive-team-warrior") as Promise<WidgetModule>,
|
||||
anchorNote: () => import("./anchor-note") as Promise<WidgetModule>,
|
||||
notebook: () => import("./notebook") as Promise<WidgetModule>,
|
||||
iframe: () => import("./iframe") as Promise<WidgetModule>,
|
||||
video: () => import("./video") as Promise<WidgetModule>,
|
||||
dnsHoleSummary: () => import("./dns-hole/summary") as Promise<WidgetModule>,
|
||||
dnsHoleControls: () => import("./dns-hole/controls") as Promise<WidgetModule>,
|
||||
"smartHome-entityState": () => import("./smart-home/entity-state") as Promise<WidgetModule>,
|
||||
"smartHome-executeAutomation": () => import("./smart-home/execute-automation") as Promise<WidgetModule>,
|
||||
stockPrice: () => import("./stocks") as Promise<WidgetModule>,
|
||||
mediaServer: () => import("./media-server") as Promise<WidgetModule>,
|
||||
calendar: () => import("./calendar") as Promise<WidgetModule>,
|
||||
downloads: () => import("./downloads") as Promise<WidgetModule>,
|
||||
"mediaRequests-requestList": () => import("./media-requests/list") as Promise<WidgetModule>,
|
||||
"mediaRequests-requestStats": () => import("./media-requests/stats") as Promise<WidgetModule>,
|
||||
mediaMissing: () => import("./media-missing") as Promise<WidgetModule>,
|
||||
networkControllerSummary: () => import("./network-controller/summary") as Promise<WidgetModule>,
|
||||
networkControllerStatus: () => import("./network-controller/network-status") as Promise<WidgetModule>,
|
||||
rssFeed: () => import("./rssFeed") as Promise<WidgetModule>,
|
||||
bookmarks: () => import("./bookmarks") as Promise<WidgetModule>,
|
||||
bazarr: () => import("./bazarr") as Promise<WidgetModule>,
|
||||
indexerManager: () => import("./indexer-manager") as Promise<WidgetModule>,
|
||||
healthMonitoring: () => import("./health-monitoring") as Promise<WidgetModule>,
|
||||
mediaTranscoding: () => import("./media-transcoding") as Promise<WidgetModule>,
|
||||
minecraftServerStatus: () => import("./minecraft/server-status") as Promise<WidgetModule>,
|
||||
dockerContainers: () => import("./docker") as Promise<WidgetModule>,
|
||||
releases: () => import("./releases") as Promise<WidgetModule>,
|
||||
firewall: () => import("./firewall") as Promise<WidgetModule>,
|
||||
notifications: () => import("./notifications") as Promise<WidgetModule>,
|
||||
mediaReleases: () => import("./media-releases") as Promise<WidgetModule>,
|
||||
systemResources: () => import("./system-resources") as Promise<WidgetModule>,
|
||||
coolify: () => import("./coolify") as Promise<WidgetModule>,
|
||||
systemDisks: () => import("./system-disks") as Promise<WidgetModule>,
|
||||
timetable: () => import("./timetable") as Promise<WidgetModule>,
|
||||
"immich-serverStats": () => import("./immich/server-stats") as Promise<WidgetModule>,
|
||||
"immich-albumCarousel": () => import("./immich/album-carousel") as Promise<WidgetModule>,
|
||||
paperlessNgx: () => import("./paperless-ngx") as Promise<WidgetModule>,
|
||||
patchmon: () => import("./patchmon") as Promise<WidgetModule>,
|
||||
tracearr: () => import("./tracearr") as Promise<WidgetModule>,
|
||||
speedtestTracker: () => import("./speedtest-tracker") as Promise<WidgetModule>,
|
||||
uptimeKuma: () => import("./uptime-kuma") as Promise<WidgetModule>,
|
||||
audioStats: () => import("./audio-stats") as Promise<WidgetModule>,
|
||||
umami: () => import("./umami") as Promise<WidgetModule>,
|
||||
vpn: () => import("./vpn") as Promise<WidgetModule>,
|
||||
ups: () => import("./ups") as Promise<WidgetModule>,
|
||||
beszelSystemTable: () => import("./beszel-system-table") as Promise<WidgetModule>,
|
||||
beszelSystemGrid: () => import("./beszel-system-grid") as Promise<WidgetModule>,
|
||||
beszelAlerts: () => import("./beszel-alerts") as Promise<WidgetModule>,
|
||||
beszelSystemStats: () => import("./beszel-system-stats") as Promise<WidgetModule>,
|
||||
traefik: () => import("./traefik") as Promise<WidgetModule>,
|
||||
customApi: () => import("./custom-api") as Promise<WidgetModule>,
|
||||
};
|
||||
|
||||
const definitionPromises = new Map<WidgetKind, Promise<WidgetDefinition>>();
|
||||
|
||||
export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[];
|
||||
|
||||
export const createRetryableLoader = <TKey, TValue>(loaders: ReadonlyMap<TKey, () => Promise<TValue>>) => {
|
||||
const promises = new Map<TKey, Promise<TValue>>();
|
||||
|
||||
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<WidgetModule>][]),
|
||||
);
|
||||
|
||||
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<Map<WidgetKind, WidgetDefinition>> | 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<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
currentValue: Record<string, unknown> = {},
|
||||
) => {
|
||||
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
|
||||
return objectEntries(options).reduce(
|
||||
(prev, [key, value]) => ({
|
||||
...prev,
|
||||
[key]: currentValue[key] ?? value.defaultValue,
|
||||
}),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
};
|
||||
@@ -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 <TKind extends WidgetKind>(
|
||||
}
|
||||
|
||||
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[]);
|
||||
|
||||
@@ -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;
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export const createTrpcQueryKey = <TInput>(path: string, input: TInput) => [
|
||||
path.split("."),
|
||||
{ input, type: "query" as const },
|
||||
];
|
||||
Reference in new issue
Block a user