Persist widget data across reloads
This commit is contained in:
30 files changed
+713
-127
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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";
|
||||
Generated
+39
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user