Persist widget data across reloads

This commit is contained in:
Thomas Camlong committed 2026-08-23 09:00:33 +02:00
1 parent 0d9eeb5752
commit a2a408410a
30 files changed
+713 -127

No files matched your search

+2
View File
@@ -72,10 +72,12 @@
"@rexa-developer/tiks": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-sync-storage-persister": "catalog:",
"@tanstack/react-devtools": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:",
"@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:",
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
@@ -0,0 +1,215 @@
// @vitest-environment jsdom
import { dehydrate, QueryClient } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { persistQueryClientRestore, removeOldestQuery } from "@tanstack/react-query-persist-client";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createSessionQueryPersister,
getQueryPersistenceBuster,
getQueryPersistenceStorageKey,
queryPersistenceMaxDataBytes,
removeSessionQueryCache,
scheduleRestoredDashboardQueryRefresh,
shouldPersistDashboardQuery,
} from "./query-persistence";
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKey, data, { updatedAt });
const query = queryClient.getQueryCache().find({ queryKey });
if (!query) throw new Error("Expected query to be present");
return { query, queryClient };
};
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
window.sessionStorage.clear();
});
describe("widget query persistence", () => {
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
});
const pendingClient = new QueryClient();
const pending = pendingClient.getQueryCache().build(pendingClient, {
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
queryFn: () => Promise.resolve([]),
});
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
expect(shouldPersistDashboardQuery(pending)).toBe(false);
});
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
expect(getQueryPersistenceStorageKey("user-a")).not.toContain("user-a");
const userAKey = getQueryPersistenceStorageKey("user-a");
const userBKey = getQueryPersistenceStorageKey("user-b");
window.sessionStorage.setItem(userAKey, "a");
window.sessionStorage.setItem(userBKey, "b");
removeSessionQueryCache("user-a");
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
expect(window.sessionStorage.getItem(userBKey)).toBe("b");
});
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
vi.useFakeTimers();
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
expect(shouldPersistDashboardQuery(query)).toBe(true);
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: getQueryPersistenceBuster(scope),
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
};
void persister.persistClient(persistedClient);
await vi.advanceTimersByTimeAsync(1_000);
const restored = await persister.restoreClient();
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
const restoredPredicate = persister.takeRestoredDashboardQueryPredicate();
expect(restoredPredicate?.(query)).toBe(true);
expect(persister.takeRestoredDashboardQueryPredicate()).toBeUndefined();
window.sessionStorage.setItem(key, "not-valid-superjson");
await expect(persister.restoreClient()).resolves.toBeUndefined();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test.each([
["expired", 1, getQueryPersistenceBuster("user-a")],
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), getQueryPersistenceBuster("user-b")],
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
const scope = "user-a";
const persister = createSessionQueryPersister(scope);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
void persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
await vi.advanceTimersByTimeAsync(1_000);
const restoredClient = new QueryClient();
await persistQueryClientRestore({
queryClient: restoredClient,
persister,
maxAge: 5 * 60 * 1_000,
buster: getQueryPersistenceBuster(scope),
});
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
});
test("quota retry removes the oldest query first", () => {
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: getQueryPersistenceBuster("user-a"),
clientState: {
mutations: [],
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
},
};
const retried = removeOldestQuery({ persistedClient, error: new Error("quota"), errorCount: 1 });
expect(retried?.clientState.queries).toHaveLength(1);
expect(retried?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
});
test("refreshes only restored cache data without awaiting the refresh", async () => {
vi.useFakeTimers();
const scope = "user-a";
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const cached = createSuccessfulQuery(queryKey, { temp: 20 }, 10);
const persister = createSessionQueryPersister(scope);
void persister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster(scope),
clientState: dehydrate(cached.queryClient),
});
await vi.advanceTimersByTimeAsync(1_000);
await persister.restoreClient();
const fresh = createSuccessfulQuery(queryKey, { temp: 21 }, 20);
const neverSettles = new Promise<void>(() => undefined);
const invalidateQueries = vi.spyOn(fresh.queryClient, "invalidateQueries").mockReturnValue(neverSettles);
expect(scheduleRestoredDashboardQueryRefresh(fresh.queryClient, persister)).toBeUndefined();
expect(invalidateQueries).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(0);
expect(invalidateQueries).toHaveBeenCalledOnce();
const predicate = invalidateQueries.mock.calls[0]?.[0]?.predicate;
expect(predicate?.(cached.query)).toBe(true);
expect(predicate?.(fresh.query)).toBe(false);
});
test("does not invalidate fresh queries when no cache was restored", async () => {
vi.useFakeTimers();
const persister = createSessionQueryPersister("empty");
await persister.restoreClient();
const fresh = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
const invalidateQueries = vi.spyOn(fresh.queryClient, "invalidateQueries");
scheduleRestoredDashboardQueryRefresh(fresh.queryClient, persister);
await vi.advanceTimersByTimeAsync(0);
expect(invalidateQueries).not.toHaveBeenCalled();
});
test("treats blocked storage operations as an empty cache", async () => {
const blockedStorage = {
getItem: () => {
throw new DOMException("blocked", "SecurityError");
},
removeItem: () => {
throw new DOMException("blocked", "SecurityError");
},
setItem: () => {
throw new DOMException("blocked", "SecurityError");
},
} as unknown as Storage;
const persister = createSessionQueryPersister("blocked", blockedStorage);
await expect(persister.restoreClient()).resolves.toBeUndefined();
expect(() => persister.removeClient()).not.toThrow();
expect(() => removeSessionQueryCache("blocked", blockedStorage)).not.toThrow();
});
test("treats an unavailable session storage getter as an empty cache", async () => {
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
expect(() => removeSessionQueryCache("blocked-getter")).not.toThrow();
await expect(createSessionQueryPersister("blocked-getter").restoreClient()).resolves.toBeUndefined();
});
});
@@ -0,0 +1,158 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { parse, stringify } from "superjson";
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
export const queryPersistenceMaxDataBytes = 256 * 1024;
const queryPersistenceBuster = "v1-session-superjson";
const queryPersistenceStoragePrefix = "homarr:widget-query-cache:v1";
const hashScope = (scope: string) => {
let hash = 2_166_136_261;
for (let index = 0; index < scope.length; index += 1) {
hash ^= scope.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0).toString(36);
};
export const getQueryPersistenceStorageKey = (scope: string | null) =>
`${queryPersistenceStoragePrefix}:${hashScope(scope ?? "anonymous")}`;
export const getQueryPersistenceBuster = (scope: string | null) => `${queryPersistenceBuster}:${scope ?? "anonymous"}`;
interface PersistableQuery {
queryHash?: string;
queryKey: QueryKey;
state: {
data: unknown;
dataUpdatedAt: number;
status: string;
};
}
type RestoredDashboardQueryPredicate = (query: PersistableQuery) => boolean;
export interface SessionQueryPersister extends Persister {
takeRestoredDashboardQueryPredicate: () => RestoredDashboardQueryPredicate | undefined;
}
export const shouldPersistDashboardQuery = (query: PersistableQuery) => {
if (query.state.data === undefined || query.state.dataUpdatedAt <= 0 || !isWidgetDataQueryKey(query.queryKey)) {
return false;
}
try {
return new TextEncoder().encode(stringify(query.state.data)).byteLength <= queryPersistenceMaxDataBytes;
} catch {
return false;
}
};
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
...client,
clientState: {
mutations: [],
queries: client.clientState.queries.map((query) => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
status: "success",
},
})),
},
});
const getSessionStorage = (): Storage | undefined => {
if (typeof window === "undefined") return undefined;
try {
return window.sessionStorage;
} catch {
return undefined;
}
};
const removeStorageItem = (storage: Storage | undefined, key: string) => {
try {
storage?.removeItem(key);
} catch {
// Persistence is optional. Treat blocked or unavailable storage as an empty cache.
}
};
export const createSessionQueryPersister = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersister => {
const key = getQueryPersistenceStorageKey(scope);
const buster = getQueryPersistenceBuster(scope);
let restoredQueries = new Map<string, number>();
const persister = createSyncStoragePersister({
storage,
key,
retry: removeOldestQuery,
serialize: (client) => stringify(sanitizePersistedClient(client)),
deserialize: (value) => parse<PersistedClient>(value),
});
return {
...persister,
async restoreClient() {
restoredQueries = new Map();
try {
const client = await persister.restoreClient();
if (
client &&
client.timestamp > 0 &&
Date.now() - client.timestamp <= queryPersistenceMaxAgeMs &&
client.buster === buster
) {
restoredQueries = new Map(
client.clientState.queries.map((query) => [query.queryHash, query.state.dataUpdatedAt]),
);
}
return client;
} catch {
removeStorageItem(storage, key);
return undefined;
}
},
removeClient() {
restoredQueries = new Map();
removeStorageItem(storage, key);
},
takeRestoredDashboardQueryPredicate() {
if (restoredQueries.size === 0) return undefined;
const restoredSnapshot = restoredQueries;
restoredQueries = new Map();
return (query) =>
query.queryHash !== undefined &&
restoredSnapshot.get(query.queryHash) === query.state.dataUpdatedAt &&
shouldPersistDashboardQuery(query);
},
};
};
export const scheduleRestoredDashboardQueryRefresh = (queryClient: QueryClient, persister: SessionQueryPersister) => {
const predicate = persister.takeRestoredDashboardQueryPredicate();
if (!predicate) return;
setTimeout(() => {
void queryClient.invalidateQueries({ type: "active", predicate });
}, 0);
};
export const removeSessionQueryCache = (scope: string | null, storage: Storage | undefined = getSessionStorage()) => {
removeStorageItem(storage, getQueryPersistenceStorageKey(scope));
};
@@ -3,8 +3,9 @@
import type { PropsWithChildren } from "react";
import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
createWSClient,
httpBatchStreamLink,
@@ -35,6 +36,14 @@ import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import {
createSessionQueryPersister,
getQueryPersistenceBuster,
queryPersistenceMaxAgeMs,
removeSessionQueryCache,
scheduleRestoredDashboardQueryRefresh,
shouldPersistDashboardQuery,
} from "./query-persistence";
import { createQueryRetry } from "./query-retry";
const DevelopmentTools =
@@ -72,16 +81,22 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
<SessionQueryScopeGuard
initialScope={initialSessionQueryScope}
currentScope={sessionQueryScope}
onScopeChange={reloadPage}
onScopeChange={() => {
removeSessionQueryCache(initialSessionQueryScope);
reloadPage();
}}
>
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
<ScopedTRPCReactProvider sessionQueryScope={initialSessionQueryScope}>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
const ScopedTRPCReactProvider = ({
children,
sessionQueryScope,
}: PropsWithChildren<{ sessionQueryScope: string | null }>) => {
const wsClient = useMemo(
() =>
createWSClient({
@@ -137,6 +152,7 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
});
useEffect(() => () => queryClient.clear(), [queryClient]);
const [queryPersister] = useState(() => createSessionQueryPersister(sessionQueryScope));
const trpcClient = useMemo(() => {
return clientApi.createClient({
@@ -186,10 +202,21 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: queryPersistenceMaxAgeMs,
buster: getQueryPersistenceBuster(sessionQueryScope),
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
}}
onSuccess={() => {
scheduleRestoredDashboardQueryRefresh(queryClient, queryPersister);
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
</QueryClientProvider>
</PersistQueryClientProvider>
</clientApi.Provider>
);
};
@@ -9,6 +9,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui";
import { WidgetTimeProvider } from "@homarr/widgets/time";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardEmptyState } from "~/components/board/board-empty-state";
@@ -39,7 +40,7 @@ const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
</GridEditorRegistryProvider>
);
export const ClientBoard = () => {
export const ClientBoard = ({ initialTimestamp }: { initialTimestamp: number }) => {
const board = useRequiredBoard();
const t = useI18n("board.landmark");
const tPreview = useI18n("board.setting.section.layout.preview");
@@ -155,23 +156,27 @@ export const ClientBoard = () => {
</BoardSelectionGridProvider>
);
if (representativeWidth === null) return content;
if (representativeWidth === null) {
return <WidgetTimeProvider initialTimestamp={initialTimestamp}>{content}</WidgetTimeProvider>;
}
return (
<Stack align="center" gap="xs" p="md" mih="100%">
<Text size="xs" c="dimmed" fw={500}>
{tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })}
</Text>
<Paper
withBorder
shadow="sm"
radius="md"
w={`min(${representativeWidth}px, calc(100vw - 2rem))`}
mih="calc(100dvh - 8rem)"
style={{ overflow: "hidden" }}
>
{content}
</Paper>
</Stack>
<WidgetTimeProvider initialTimestamp={initialTimestamp}>
<Stack align="center" gap="xs" p="md" mih="100%">
<Text size="xs" c="dimmed" fw={500}>
{tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })}
</Text>
<Paper
withBorder
shadow="sm"
radius="md"
w={`min(${representativeWidth}px, calc(100vw - 2rem))`}
mih="calc(100dvh - 8rem)"
style={{ overflow: "hidden" }}
>
{content}
</Paper>
</Stack>
</WidgetTimeProvider>
);
};
@@ -96,7 +96,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<ClientBoard />
<ClientBoard initialTimestamp={Date.now()} />
</IntegrationProvider>
</HydrationBoundary>
);
@@ -32,7 +32,7 @@ import { showErrorNotification, showWarningNotification } from "@homarr/notifica
import { closeSpotlight } from "@homarr/spotlight/store";
import { useI18n } from "@homarr/translation/client";
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
import { AssistantWidgetRendererProvider } from "@homarr/widgets/assistant/context";
import { createWorkshopClient } from "~/components/workshop/workshop-client";
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
@@ -20,6 +20,7 @@ describe("getAssistantWidgetConfiguration", () => {
test("preserves generated notebook content and fills its hidden defaults", () => {
const result = getAssistantWidgetConfiguration(
widgetImports.notebook.definition,
{
boardId: "board-1",
boardName: "Home",
@@ -41,6 +42,7 @@ describe("getAssistantWidgetConfiguration", () => {
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
const result = getAssistantWidgetConfiguration(
widgetImports.audioStats.definition,
{
boardId: "board-1",
boardName: "Home",
@@ -88,11 +90,13 @@ describe("getAssistantWidgetConfiguration", () => {
test("treats integration-backed widgets as required unless explicitly optional", () => {
const mediaServer = getAssistantWidgetConfiguration(
widgetImports.mediaServer.definition,
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
settings,
[],
);
const calendar = getAssistantWidgetConfiguration(
widgetImports.calendar.definition,
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
settings,
[],
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { Suspense, use, useMemo } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
@@ -13,7 +13,8 @@ import { useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -29,11 +30,11 @@ interface AssistantIntegration extends IntegrationSelectOption {
}
export const getAssistantWidgetConfiguration = (
definition: WidgetDefinition,
args: ConfigureWidgetArgs,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrations: AssistantIntegration[],
) => {
const definition = widgetImports[args.kind].definition;
const integrationSupport = "supportedIntegrations" in definition;
const supportedIntegrations = integrationSupport
? (definition.supportedIntegrations as readonly IntegrationKind[])
@@ -54,35 +55,73 @@ export const getAssistantWidgetConfiguration = (
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: reduceWidgetOptionsWithDefaultValues(args.kind, settings, args.options),
options: reduceWidgetOptionsWithDefinition(definition, settings, args.options),
integrationIds,
},
};
};
export const AssistantConfigureWidgetTool = ({
type AssistantConfigureWidgetToolProps = ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>;
export const AssistantConfigureWidgetTool = (props: AssistantConfigureWidgetToolProps) => {
const hasCompleteArguments = hasCompleteAssistantToolArguments(props.status);
const completeArgs =
props.result === undefined && !hasFailedAssistantToolArguments(props.status) && hasCompleteArguments
? props.args
: undefined;
if (!completeArgs) return <AssistantConfigureWidgetToolContent {...props} definition={undefined} />;
return (
<Suspense fallback={<AssistantConfigureWidgetSkeleton />}>
<LoadedAssistantConfigureWidgetTool {...props} args={completeArgs} />
</Suspense>
);
};
const LoadedAssistantConfigureWidgetTool = (
props: AssistantConfigureWidgetToolProps & { args: ConfigureWidgetArgs },
) => {
const definition = use(loadWidgetDefinition(props.args.kind));
return <AssistantConfigureWidgetToolContent {...props} definition={definition} />;
};
const AssistantConfigureWidgetSkeleton = () => {
const t = useI18n("assistant.configureWidget");
return (
<Box className={classes.appTool} aria-label={t("preparing")}>
<Stack gap="sm">
<Skeleton height={18} width="45%" />
<Skeleton height={14} width="76%" />
<Skeleton height={38} />
</Stack>
</Box>
);
};
const AssistantConfigureWidgetToolContent = ({
args,
result,
addResult,
status,
toolCallId,
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
definition,
}: AssistantConfigureWidgetToolProps & { definition: WidgetDefinition | undefined }) => {
const t = useI18n("assistant.configureWidget");
const actionT = useI18n("common.action");
const fullT = useI18n();
const { data: session } = useSession();
const settings = useSettings();
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
const integrations = clientApi.integration.all.useQuery(undefined, {
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
retry: false,
});
const configuration = useMemo(() => {
if (!args || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
return getAssistantWidgetConfiguration(args, settings, integrations.data ?? []);
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
if (!args || !definition || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
return getAssistantWidgetConfiguration(definition, args, settings, integrations.data ?? []);
}, [args, definition, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
const missingRequiredIntegration =
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
@@ -134,15 +173,7 @@ export const AssistantConfigureWidgetTool = ({
}
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
return (
<Box className={classes.appTool} aria-label={t("preparing")}>
<Stack gap="sm">
<Skeleton height={18} width="45%" />
<Skeleton height={14} width="76%" />
<Skeleton height={38} />
</Stack>
</Box>
);
return <AssistantConfigureWidgetSkeleton />;
}
if (integrations.isError) {
@@ -158,7 +189,7 @@ export const AssistantConfigureWidgetTool = ({
);
}
if (!configuration) return null;
if (!configuration || !definition) return null;
const openConfigurationModal = (
value = configuration.value,
@@ -167,7 +198,7 @@ export const AssistantConfigureWidgetTool = ({
openWidgetModal(
{
kind: args.kind,
definition: widgetImports[args.kind].definition,
definition,
value,
integrationData,
integrationSupport: configuration.integrationSupport,
@@ -240,7 +271,7 @@ export const AssistantConfigureWidgetTool = ({
);
}
const WidgetIcon = widgetImports[args.kind].definition.icon;
const WidgetIcon = definition.icon;
return (
<Box className={classes.appTool}>
<Group gap="sm" align="flex-start" wrap="nowrap">
@@ -0,0 +1,30 @@
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
const sourceRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
const collectRuntimeSourceFiles = (directory: string): string[] =>
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return collectRuntimeSourceFiles(path);
if (!/\.(ts|tsx)$/.test(entry.name) || /\.spec\.(ts|tsx)$/.test(entry.name)) return [];
return [path];
});
describe("widget import boundary", () => {
test("application runtime code uses narrow widget exports", () => {
const offenders = collectRuntimeSourceFiles(sourceRoot).filter((path) => {
const source = readFileSync(path, "utf8");
const importStatements = source.match(/import[\s\S]*?;/g) ?? [];
return (
importStatements.some(
(statement) => !/^import\s+type\b/.test(statement) && /\sfrom\s+["']@homarr\/widgets["']/.test(statement),
) || /import\(["']@homarr\/widgets["']\)/.test(source)
);
});
expect(offenders).toEqual([]);
});
});
@@ -1,3 +1,17 @@
.refreshIndicator {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 3;
display: grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
pointer-events: none;
}
.settingsButton {
position: absolute;
top: -34px;
@@ -2,9 +2,9 @@ import type { ComponentType, CSSProperties, MutableRefObject, PropsWithChildren
import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import dynamic from "next/dynamic";
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
import { Box, Button, Center, Loader, Portal, VisuallyHidden } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import type { FallbackProps } from "react-error-boundary";
@@ -19,6 +19,7 @@ import { WidgetError } from "@homarr/widgets/errors";
import {
createWidgetRuntimeState,
getWidgetQueryKeys,
getWidgetRuntimeQueries,
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
} from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
@@ -39,6 +40,7 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { WidgetContextMenu } from "./widget-context-menu";
import { removeWidgetDataQueries } from "./widget-query-recovery";
import { matchesWidgetItemQuery } from "./widget-query-scope";
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
ssr: false,
@@ -156,6 +158,25 @@ const LoadedBoardItemContent = ({
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
[definition, item, settings],
);
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
const isWidgetRefreshing =
useIsFetching({
type: "active",
predicate: (query) =>
query.state.data !== undefined &&
matchesWidgetItemQuery(
query.queryKey,
widgetQueryKeys,
{
itemId: item.id,
boardId: board.id,
integrationIds: item.integrationIds,
options: menuItem.options,
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
},
definition.queryMatcher,
),
}) > 0;
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
@@ -322,6 +343,12 @@ const LoadedBoardItemContent = ({
/>
</ErrorBoundary>
</Box>
{isWidgetRefreshing && (
<Box className={itemContentClasses.refreshIndicator} component="output" aria-live="polite">
<Loader size="xs" />
<VisuallyHidden>{t("common.action.loading")}</VisuallyHidden>
</Box>
)}
</WidgetCardShell>
);
+2
View File
@@ -91,6 +91,7 @@ describe("lazy widget application graph", () => {
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
);
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--");
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
timeout: 15_000,
@@ -114,6 +115,7 @@ describe("lazy widget application graph", () => {
await page.reload();
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--");
expect(pageErrors).toEqual([]);
} finally {
await browser.close();
+1 -1
View File
@@ -8,7 +8,7 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
export const isWidgetDataTrpcPath = (path: string) => {
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
+2 -2
View File
@@ -22,7 +22,7 @@ describe("isWidgetDataQueryKey", () => {
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["widget", "beszel", "getSystemStats"], { type: "query" }], false],
[[["widget", "beszel", "getSystemStats"], { type: "query" }], true],
[[["widget", "beszel", "getSystems"], { type: "query" }], true],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
@@ -39,7 +39,7 @@ describe("isWidgetDataTrpcPath", () => {
["integration.byIds", true],
["docker.getContainers", true],
["widget.app.ping", false],
["widget.beszel.getSystemStats", false],
["widget.beszel.getSystemStats", true],
["board.getBoardByName", false],
])("classifies dashboard data path %s", (path, expected) => {
expect(isWidgetDataTrpcPath(path)).toBe(expected);
+1
View File
@@ -31,6 +31,7 @@
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts",
"./refetch-intervals": "./src/refetch-intervals.ts",
"./time": "./src/time.ts",
"./widget-card-shell": "./src/widget-card-shell.tsx"
},
"scripts": {
@@ -23,7 +23,8 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
import type { BookmarkItem } from "./bookmark-item";
@@ -43,14 +44,12 @@ export default function BookmarksWidget({
const board = useRequiredBoard();
const advanced = displayMode === "advanced";
const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]);
const apps =
getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(appIds, {
select(selectedApps) {
return selectedApps;
},
}),
) ?? [];
const appsQuery = clientApi.app.byIds.useQuery(appIds, {
select(selectedApps) {
return selectedApps;
},
});
const apps = getUsableWidgetQueryData(appsQuery) ?? [];
const appsById = new Map(apps.map((app) => [app.id, app]));
const configuredItems = options.items.flatMap((value) => {
const directUrl = getDirectBookmarkUrl(value);
@@ -127,6 +126,8 @@ export default function BookmarksWidget({
/>
));
if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return <WidgetQueryLoadingState />;
return (
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
{options.title.length > 0 ? (
+4 -1
View File
@@ -15,7 +15,8 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { CalendarDay } from "./calender-day";
@@ -98,6 +99,8 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
) ?? [];
if (isInitialWidgetQueryPending(calendarQuery)) return <WidgetQueryLoadingState />;
return (
<CalendarBase
isEditMode={isEditMode}
+3 -26
View File
@@ -1,27 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import { useWidgetNow } from "../common/use-widget-now";
export const useCurrentTime = ({ showSeconds }: { showSeconds: boolean }) => {
const [time, setTime] = useState<Date | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const intervalRef = useRef<ReturnType<typeof setInterval>>(null);
const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]);
useEffect(() => {
setTime(new Date());
timeoutRef.current = setTimeout(
() => {
setTime(new Date());
intervalRef.current = setInterval(() => setTime(new Date()), intervalMultiplier * 1000);
},
intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [intervalMultiplier, showSeconds]);
return time;
};
export const useCurrentTime = ({ showSeconds }: { showSeconds: boolean }) =>
useWidgetNow(showSeconds ? "second" : "minute");
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useWidgetInitialTimestamp } from "./widget-time-context";
export type WidgetClockPrecision = "second" | "minute" | "day";
const minimumDelayMs = 10;
@@ -20,7 +22,8 @@ export const getNextWidgetTickDelay = (now: Date, precision: WidgetClockPrecisio
};
export const useWidgetNow = (precision: WidgetClockPrecision = "minute"): Date | null => {
const [now, setNow] = useState<Date | null>(null);
const initialTimestamp = useWidgetInitialTimestamp();
const [now, setNow] = useState<Date | null>(() => (initialTimestamp === null ? null : new Date(initialTimestamp)));
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const cancelTick = useCallback(() => {
@@ -0,0 +1,41 @@
// @vitest-environment jsdom
import { act } from "react";
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { afterEach, describe, expect, test, vi } from "vitest";
import { useWidgetNow } from "./use-widget-now";
import { WidgetTimeProvider } from "./widget-time-context";
const TimeProbe = () => <time>{useWidgetNow("minute")?.toISOString() ?? "missing"}</time>;
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("WidgetTimeProvider", () => {
test("uses the same non-placeholder timestamp for server rendering and hydration", async () => {
vi.useFakeTimers();
const timestamp = new Date("2026-08-22T12:34:00.000Z").getTime();
vi.setSystemTime(timestamp);
const content = (
<WidgetTimeProvider initialTimestamp={timestamp}>
<TimeProbe />
</WidgetTimeProvider>
);
const html = renderToString(content);
const host = document.createElement("div");
host.innerHTML = html;
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(host.textContent).toBe("2026-08-22T12:34:00.000Z");
const root = hydrateRoot(host, content);
await act(async () => undefined);
expect(host.textContent).toBe("2026-08-22T12:34:00.000Z");
expect(consoleError).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
});
@@ -0,0 +1,12 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
const WidgetTimeContext = createContext<number | null>(null);
export const WidgetTimeProvider = ({ children, initialTimestamp }: PropsWithChildren<{ initialTimestamp: number }>) => (
<WidgetTimeContext.Provider value={initialTimestamp}>{children}</WidgetTimeContext.Provider>
);
export const useWidgetInitialTimestamp = () => useContext(WidgetTimeContext);
@@ -34,7 +34,8 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerModal from "./TimerModal";
@@ -67,7 +68,6 @@ export default function DnsHoleControlsWidget({
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? [];
const summaries = summaryResults.filter(isAvailableDnsSummaryResult);
const { isPending: isSummaryPending } = summaryQuery;
const utils = clientApi.useUtils();
const {
@@ -122,7 +122,6 @@ export default function DnsHoleControlsWidget({
);
const t = useI18n("widget.dnsHoleControls");
const tCommon = useI18n("common");
// Timer modal setup
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
@@ -141,15 +140,7 @@ export default function DnsHoleControlsWidget({
setBulkPending(false);
};
if (isSummaryPending) {
return (
<Stack h="100%" justify="center" align="center">
<Text c="dimmed" size="sm">
{tCommon("action.loading")}
</Text>
</Stack>
);
}
if (isInitialWidgetQueryPending(summaryQuery)) return <WidgetQueryLoadingState />;
return (
<Stack
@@ -31,7 +31,8 @@ import type { TablerIcon } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
export default function DnsHoleSummaryWidget({
options,
@@ -43,10 +44,8 @@ export default function DnsHoleSummaryWidget({
integrationIds,
});
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
const { isPending } = summaryQuery;
const t = useI18n();
const tCommon = useI18n("common");
const tDns = useI18n("widget.dnsHoleSummary");
const tWidgetCommon = useI18n("widget.common");
@@ -55,15 +54,7 @@ export default function DnsHoleSummaryWidget({
const layoutProps = boxPropsByLayout(options.layout);
const showSourceStatuses = successfulSummaries.length > 1 && width >= 240 && height >= 220;
if (isPending) {
return (
<Stack h="100%" justify="center" align="center">
<Text c="dimmed" size="sm">
{tCommon("action.loading")}
</Text>
</Stack>
);
}
if (isInitialWidgetQueryPending(summaryQuery)) return <WidgetQueryLoadingState />;
return (
<Stack h="100%" gap={0} pos="relative">
+4 -1
View File
@@ -54,7 +54,8 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { HomarrDataTable } from "../common/homarr-data-table";
import { formatLocalizedDateTime } from "../common/locale";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
@@ -741,6 +742,8 @@ export default function DownloadClientsWidget({
);
}
if (isInitialWidgetQueryPending(downloadsQuery)) return <WidgetQueryLoadingState />;
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined;
@@ -15,7 +15,8 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
@@ -35,17 +36,17 @@ export default function MediaServerWidget({
.filter(({ id }) => integrationIds.includes(id))
.map(({ id }) => id),
);
const mediaRequestData = getUsableWidgetQueryData(
clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
}),
);
const mediaRequestQuery = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
});
const mediaRequestData = getUsableWidgetQueryData(mediaRequestQuery);
if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />;
if (!mediaRequestData) return <WidgetEmptyState />;
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
@@ -23,7 +23,8 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
@@ -42,10 +43,12 @@ export default function MediaServerWidget({
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useI18n("widget.mediaRequests-requestStats");
const tCommon = useI18n("common");
const requestStats = getUsableWidgetQueryData(clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds }));
const requestStatsQuery = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds });
const requestStats = getUsableWidgetQueryData(requestStatsQuery);
const board = useRequiredBoard();
if (isInitialWidgetQueryPending(requestStatsQuery)) return <WidgetQueryLoadingState />;
if (!requestStats) return <WidgetEmptyState />;
if (
requestStats.users.length === 0 &&
+3
View File
@@ -0,0 +1,3 @@
export { WidgetTimeProvider, useWidgetInitialTimestamp } from "./common/widget-time-context";
export { getNextWidgetTickDelay, useWidgetNow } from "./common/use-widget-now";
export type { WidgetClockPrecision } from "./common/use-widget-now";
+39
View File
@@ -189,6 +189,9 @@ catalogs:
'@tabler/icons-react':
specifier: ^3.44.0
version: 3.44.0
'@tanstack/query-sync-storage-persister':
specifier: 5.101.2
version: 5.101.2
'@tanstack/react-devtools':
specifier: ^0.10.8
version: 0.10.9
@@ -201,6 +204,9 @@ catalogs:
'@tanstack/react-query-next-experimental':
specifier: ^5.101.2
version: 5.101.2
'@tanstack/react-query-persist-client':
specifier: 5.101.2
version: 5.101.2
'@testcontainers/mysql':
specifier: ^12.0.4
version: 12.0.4
@@ -1130,6 +1136,9 @@ importers:
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
'@tanstack/query-sync-storage-persister':
specifier: 'catalog:'
version: 5.101.2
'@tanstack/react-devtools':
specifier: 'catalog:'
version: 0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)
@@ -1142,6 +1151,9 @@ importers:
'@tanstack/react-query-next-experimental':
specifier: 'catalog:'
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
'@tanstack/react-query-persist-client':
specifier: 'catalog:'
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)
'@trpc/client':
specifier: 'catalog:'
version: 11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2)
@@ -8831,6 +8843,12 @@ packages:
'@tanstack/query-devtools@5.101.2':
resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==}
'@tanstack/query-persist-client-core@5.101.2':
resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==}
'@tanstack/query-sync-storage-persister@5.101.2':
resolution: {integrity: sha512-hRyLEJvkUWtfCUA3Lf5vD1CrXbHHK4pZNM/iNd7JM5GgC4CWzGIHkVkeg8zC2LV11KceKObKgjNuMSg3b6NOjA==}
'@tanstack/react-devtools@0.10.9':
resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==}
engines: {node: '>=18'}
@@ -8853,6 +8871,12 @@ packages:
next: ^13 || ^14 || ^15 || ^16
react: ^18 || ^19
'@tanstack/react-query-persist-client@5.101.2':
resolution: {integrity: sha512-19UpaRtf0lvB2QAJ2MoixxtGf3osMOd508g99EsttUj4+3eLs+ulnNgYjB7AkQMHrRlcbVXqpVNqWkB4a7g8Zw==}
peerDependencies:
'@tanstack/react-query': ^5.101.2
react: ^18 || ^19
'@tanstack/react-query@5.101.2':
resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==}
peerDependencies:
@@ -23923,6 +23947,15 @@ snapshots:
'@tanstack/query-devtools@5.101.2': {}
'@tanstack/query-persist-client-core@5.101.2':
dependencies:
'@tanstack/query-core': 5.101.2
'@tanstack/query-sync-storage-persister@5.101.2':
dependencies:
'@tanstack/query-core': 5.101.2
'@tanstack/query-persist-client-core': 5.101.2
'@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)':
dependencies:
'@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14)
@@ -23948,6 +23981,12 @@ snapshots:
next: 16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: 19.2.7
'@tanstack/react-query-persist-client@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)':
dependencies:
'@tanstack/query-persist-client-core': 5.101.2
'@tanstack/react-query': 5.101.2(react@19.2.7)
react: 19.2.7
'@tanstack/react-query@5.101.2(react@19.2.7)':
dependencies:
'@tanstack/query-core': 5.101.2
+2
View File
@@ -63,6 +63,8 @@ catalog:
"@tanstack/react-query": ^5.101.2
"@tanstack/react-query-devtools": ^5.101.2
"@tanstack/react-query-next-experimental": ^5.101.2
"@tanstack/react-query-persist-client": 5.101.2
"@tanstack/query-sync-storage-persister": 5.101.2
"@testcontainers/mysql": ^12.0.4
"@testcontainers/postgresql": ^12.0.4
"@testcontainers/redis": ^12.0.4