diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index ff5864f13..65d768875 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -56,6 +56,7 @@ "@mantine/modals": "catalog:", "@mantine/tiptap": "catalog:", "@tabler/icons-react": "catalog:", + "@tanstack/query-persist-client-core": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-next-experimental": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts new file mode 100644 index 000000000..faeadbb56 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts @@ -0,0 +1,50 @@ +import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core"; +import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core"; +import { parse, stringify } from "superjson"; + +import { fetchApi } from "@homarr/api/client"; +import { + getActiveQueryCacheBoardId, + isPersistableWidgetQueryKey, + queryCacheMaxAgeMs, + queryCacheStoragePrefix, +} from "@homarr/api/query-cache"; + +const queryCacheStorage = { + async getItem(_key) { + return null; + }, + async setItem(key, value) { + const boardId = getActiveQueryCacheBoardId(); + if (!boardId) return; + + try { + await fetchApi.queryCache.setItem.mutate({ boardId, key, value }); + } catch { + // Query persistence is best-effort; failed writes should not affect widget rendering. + } + }, + async removeItem(key) { + const boardId = getActiveQueryCacheBoardId(); + if (!boardId) return; + + try { + await fetchApi.queryCache.removeItem.mutate({ boardId, key }); + } catch { + // Query persistence is best-effort; failed removals should not affect widget rendering. + } + }, +} satisfies AsyncStorage; + +export const createWidgetQueryPersister = () => + experimental_createQueryPersister({ + storage: typeof window === "undefined" ? undefined : queryCacheStorage, + maxAge: queryCacheMaxAgeMs, + prefix: queryCacheStoragePrefix, + refetchOnRestore: true, + serialize: (query) => stringify(query), + deserialize: (value) => parse(value), + filters: { + predicate: (query) => isPersistableWidgetQueryKey(query.queryKey), + }, + }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 0e36cb4a1..7601dac6d 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -22,6 +22,8 @@ import { clientApi } from "@homarr/api/client"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { env } from "@homarr/common/env"; +import { createWidgetQueryPersister } from "./query-cache-persister"; + const getWebSocketProtocol = () => { // window is not defined on server side if (typeof window === "undefined") { @@ -49,11 +51,13 @@ const wsClient = createWSClient({ }); export function TRPCReactProvider(props: PropsWithChildren) { + const [queryPersister] = useState(() => createWidgetQueryPersister()); const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { + persister: queryPersister.persisterFn, staleTime: 5 * 1000, }, }, diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 3314dee3c..73235701e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,11 +1,15 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { TRPCError } from "@trpc/server"; // Placed here because gridstack styles are used for board content import "~/styles/gridstack.scss"; +import type { DehydratedState } from "@tanstack/react-query"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; +import { parse } from "superjson"; +import { queryCacheMaxAgeMs } from "@homarr/api/query-cache"; import { getQueryClient } from "@homarr/api/server"; import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; @@ -14,6 +18,7 @@ import { isNullOrWhitespace } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { WidgetKind } from "@homarr/definitions"; +import { getAllQueryCacheAsync } from "@homarr/redis"; import { getI18n } from "@homarr/translation/server"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; @@ -71,9 +76,14 @@ export const createBoardContentPage = >( ), ]); + const userId = session?.user.id ?? "anonymous"; + return ( + + + @@ -107,3 +117,39 @@ export const createBoardContentPage = >( }, }; }; + +interface PersistedEntry { + state: { data: unknown; dataUpdatedAt: number; status: string }; + queryKey: unknown[]; + queryHash: string; +} + +async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) { + try { + const entries = await getAllQueryCacheAsync(userId, boardId); + const now = Date.now(); + const queries: DehydratedState["queries"] = []; + + for (const serialized of Object.values(entries)) { + try { + const persisted = parse(serialized); + if (now - persisted.state.dataUpdatedAt > queryCacheMaxAgeMs) continue; + if (persisted.state.status !== "success") continue; + + queries.push({ + state: persisted.state as DehydratedState["queries"][number]["state"], + queryKey: persisted.queryKey, + queryHash: persisted.queryHash, + }); + } catch { + // skip malformed entries + } + } + + if (queries.length === 0) return null; + + return ; + } catch { + return null; + } +}