refactor(offline-sync): share query keys/options between hooks and offline warm

The 'Make available offline' warm path re-typed React Query key literals and
re-declared queryKey+queryFn pairs that the feature hooks already owned, so the
two could silently drift (a hook key change would leave the warm cache under a
stale key). Centralize them so there is one source:

- Add pageKeys (page-query.ts) and spaceKeys (space-query.ts) key factories and
  route the inline key literals through them. Partial-match keys and 2-element
  spaceMembers invalidations are deliberately left inline so their effective key
  VALUE (and invalidation breadth) is unchanged.
- Add queryOptions factories sidebarPagesQueryOptions and spaceByIdQueryOptions,
  consumed by both the hooks (fetchAllAncestorChildren, useGetSpaceBySlugQuery)
  and the warm path. Comments reuse the existing RQ_KEY factory.

The warm path also stops silently succeeding: warmInfiniteAll returns a boolean
and logs failures; makePageAvailableOffline is best-effort (never throws) and
returns { ok, failed[] }, recording each failed step by label; the tree menu
caller now shows a success or error toast from result.ok. Removed the unused
slugId/parentPageId params from the offline params type.

This is a behavior-preserving centralization: effective query keys, queryFns,
staleTime and enabled are unchanged for every hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-22 02:35:22 +03:00
parent ba2043937d
commit a4b4480118
5 changed files with 307 additions and 112 deletions

View File

@@ -46,18 +46,37 @@ vi.mock("@hocuspocus/provider", () => ({
}), }),
})); }));
import { warmInfiniteAll, warmPageYdoc } from "./make-offline"; import {
warmInfiniteAll,
warmPageYdoc,
makePageAvailableOffline,
} from "./make-offline";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import {
getPageById,
getPageBreadcrumbs,
getSidebarPages,
} from "@/features/page/services/page-service";
import { getPageComments } from "@/features/comment/services/comment-service";
const setQueryData = (queryClient as any).setQueryData as ReturnType< const setQueryData = (queryClient as any).setQueryData as ReturnType<
typeof vi.fn typeof vi.fn
>; >;
const prefetchQuery = (queryClient as any).prefetchQuery as ReturnType<
typeof vi.fn
>;
beforeEach(() => { beforeEach(() => {
// Clear call history WITHOUT wiping the mock implementations the vi.mock // Clear call history WITHOUT wiping the mock implementations the vi.mock
// factories installed (vi.clearAllMocks would drop the constructor return // factories installed (vi.clearAllMocks would drop the constructor return
// objects and break the provider/idb/yjs spies). // objects and break the provider/idb/yjs spies).
setQueryData.mockClear(); setQueryData.mockClear();
prefetchQuery.mockReset();
prefetchQuery.mockResolvedValue(undefined);
(getPageById as ReturnType<typeof vi.fn>).mockReset();
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockReset();
(getSidebarPages as ReturnType<typeof vi.fn>).mockReset();
(getPageComments as ReturnType<typeof vi.fn>).mockReset();
h.ydocDestroy.mockClear(); h.ydocDestroy.mockClear();
h.idbDestroy.mockClear(); h.idbDestroy.mockClear();
h.providerOn.mockClear(); h.providerOn.mockClear();
@@ -117,13 +136,80 @@ describe("warmInfiniteAll", () => {
expect(payload.pages).toHaveLength(2); expect(payload.pages).toHaveLength(2);
}); });
it("swallows errors and never writes the cache on failure", async () => { it("returns true on success", async () => {
const fetchPage = vi.fn().mockRejectedValue(new Error("network")); const fetchPage = vi
.fn()
.mockResolvedValue({ items: [], meta: { nextCursor: null } });
await expect( await expect(
warmInfiniteAll(["comments", "p1"], fetchPage), warmInfiniteAll(["comments", "p1"], fetchPage),
).resolves.toBeUndefined(); ).resolves.toBe(true);
});
it("reports errors (returns false) and never writes the cache on failure", async () => {
const fetchPage = vi.fn().mockRejectedValue(new Error("network"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
warmInfiniteAll(["comments", "p1"], fetchPage),
).resolves.toBe(false);
expect(setQueryData).not.toHaveBeenCalled(); expect(setQueryData).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
});
describe("makePageAvailableOffline", () => {
const okPage = {
id: "uuid-1",
slugId: "slug-1",
space: { slug: "space-slug" },
};
it("returns ok:true with no failures when every step succeeds", async () => {
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
expect(result).toEqual({ ok: true, failed: [] });
});
it("returns ok:false with the failed step label when a warm step fails", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
// Comments warm fails -> labeled "comments".
(getPageComments as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("network"),
);
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
expect(result.ok).toBe(false);
expect(result.failed).toContain("comments");
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
}); });
}); });

View File

@@ -7,9 +7,13 @@ import {
getPageById, getPageById,
getPageBreadcrumbs, getPageBreadcrumbs,
getSidebarPages, getSidebarPages,
getAllSidebarPages,
} from "@/features/page/services/page-service"; } from "@/features/page/services/page-service";
import { getSpaceById } from "@/features/space/services/space-service.ts"; import {
pageKeys,
sidebarPagesQueryOptions,
} from "@/features/page/queries/page-query";
import { spaceByIdQueryOptions } from "@/features/space/queries/space-query";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
import { getPageComments } from "@/features/comment/services/comment-service"; import { getPageComments } from "@/features/comment/services/comment-service";
import { IPage } from "@/features/page/types/page.types"; import { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
@@ -23,15 +27,19 @@ import { IPagination } from "@/lib/types.ts";
* spinning forever offline, and silently truncates large lists. This walks the * spinning forever offline, and silently truncates large lists. This walks the
* cursor chain until it runs out (or hits maxPages) so the whole list is cached. * cursor chain until it runs out (or hits maxPages) so the whole list is cached.
* *
* Best-effort: any failure is swallowed so a partial/failed warm never throws. * Best-effort: a failure does not throw (a partial/failed warm is still useful),
* but it is reported — the error is logged with context and `false` is returned
* so the caller can record the failed step instead of silently succeeding.
*
* Returns true if the whole list was paginated and written, false on any error.
* *
* Exported for unit testing of the cursor-walk / cache-write behavior. * Exported for unit testing of the cursor-walk / cache-write behavior.
*/ */
export async function warmInfiniteAll<T>( export async function warmInfiniteAll<T>(
queryKey: unknown[], queryKey: readonly unknown[],
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>, fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
maxPages = 50, maxPages = 50,
): Promise<void> { ): Promise<boolean> {
try { try {
const pages: IPagination<T>[] = []; const pages: IPagination<T>[] = [];
const pageParams: (string | undefined)[] = []; const pageParams: (string | undefined)[] = [];
@@ -46,90 +54,112 @@ export async function warmInfiniteAll<T>(
} }
queryClient.setQueryData(queryKey, { pages, pageParams }); queryClient.setQueryData(queryKey, { pages, pageParams });
} catch { return true;
// best-effort } catch (error) {
console.error("warmInfiniteAll failed", { queryKey, error });
return false;
} }
} }
export interface MakePageAvailableOfflineParams { export interface MakePageAvailableOfflineParams {
pageId: string; pageId: string;
slugId?: string;
spaceId?: string; spaceId?: string;
parentPageId?: string; }
/**
* Outcome of {@link makePageAvailableOffline}. `ok` is true only when every warm
* step succeeded; `failed` lists the labels of the steps that failed (a subset
* of: "page", "space", "tree", "breadcrumbs", "comments").
*/
export interface MakePageAvailableOfflineResult {
ok: boolean;
failed: string[];
} }
/** /**
* Best-effort prefetch of a page's read queries so they get persisted to * Best-effort prefetch of a page's read queries so they get persisted to
* IndexedDB and become readable offline. * IndexedDB and become readable offline.
* *
* Each prefetch is isolated in try/catch — this function NEVER throws to its * Each step is isolated and this function does NOT throw — a partial warm is
* caller. Only meaningful while online (the underlying requests must succeed). * still useful. Instead of silently succeeding, every failed step is logged
* with a label and recorded in the returned result: `{ ok, failed }` where
* `ok` is true only if no step failed and `failed` lists the failed step
* labels. Only meaningful while online (the underlying requests must succeed).
*/ */
export async function makePageAvailableOffline({ export async function makePageAvailableOffline({
pageId, pageId,
spaceId, spaceId,
}: MakePageAvailableOfflineParams): Promise<void> { }: MakePageAvailableOfflineParams): Promise<MakePageAvailableOfflineResult> {
const failed: string[] = [];
// Fetch the page document ONCE and write it under BOTH cache keys, exactly // Fetch the page document ONCE and write it under BOTH cache keys, exactly
// like usePageQuery's onData effect. Every page consumer reads ["pages", // like usePageQuery's onData effect. Every page consumer reads
// <slugId>] (usePageQuery keys on the slugId for routed reads), so warming // pageKeys.detail(slugId) (usePageQuery keys on the slugId for routed reads),
// only ["pages", <uuid>] would leave the offline page blank. // so warming only the uuid key would leave the offline page blank.
let page: IPage | undefined; let page: IPage | undefined;
try { try {
page = await getPageById({ pageId }); page = await getPageById({ pageId });
queryClient.setQueryData(["pages", page.slugId], page); queryClient.setQueryData(pageKeys.detail(page.slugId), page);
queryClient.setQueryData(["pages", page.id], page); queryClient.setQueryData(pageKeys.detail(page.id), page);
} catch { } catch (error) {
// best-effort console.error("makePageAvailableOffline: page step failed", {
pageId,
error,
});
failed.push("page");
} }
// Warm the space — page.tsx renders nothing until the space query resolves // Warm the space — page.tsx renders nothing until the space query resolves
// (useGetSpaceBySlugQuery → ["space", <spaceSlug>]). Awaited (not the // (useGetSpaceBySlugQuery). Awaited (not the fire-and-forget prefetchSpace) so
// fire-and-forget prefetchSpace) so the space is actually persisted before // the space is actually persisted before the caller fires its toast. Shares
// the caller fires its success toast. Matches the hook's key/fn exactly. // spaceByIdQueryOptions so the key/fn cannot drift from the hook.
try { try {
const spaceSlug = page?.space?.slug; const spaceSlug = page?.space?.slug;
if (spaceSlug) { if (spaceSlug) {
await queryClient.prefetchQuery({ await queryClient.prefetchQuery(spaceByIdQueryOptions(spaceSlug));
queryKey: ["space", spaceSlug],
queryFn: () => getSpaceById(spaceSlug),
});
} }
} catch { } catch (error) {
// best-effort console.error("makePageAvailableOffline: space step failed", {
pageId,
error,
});
failed.push("space");
} }
// Warm the sidebar tree root so the WHOLE root level renders offline (matches // Warm the sidebar tree root so the WHOLE root level renders offline (matches
// useGetRootSidebarPagesQuery's ["root-sidebar-pages", spaceId] infinite // useGetRootSidebarPagesQuery's pageKeys.rootSidebar(spaceId) infinite cache).
// key/fn). Fully paginated so large root levels are not truncated at 100. // Fully paginated so large root levels are not truncated at 100.
if (spaceId) { if (spaceId) {
await warmInfiniteAll(["root-sidebar-pages", spaceId], (cursor) => const ok = await warmInfiniteAll(pageKeys.rootSidebar(spaceId), (cursor) =>
getSidebarPages({ spaceId, cursor, limit: 100 }), getSidebarPages({ spaceId, cursor, limit: 100 }),
); );
if (!ok) failed.push("tree");
} }
// Warm the children of the page and of every ancestor so the path to this // Warm the children of the page and of every ancestor so the path to this
// page is expandable offline. We MIRROR fetchAllAncestorChildren exactly // page is expandable offline. We MIRROR fetchAllAncestorChildren exactly via
// same regular ["sidebar-pages", { pageId, spaceId }] key, same // sidebarPagesQueryOptions — same pageKeys.sidebar({ pageId, spaceId }) key,
// getAllSidebarPages fn (which aggregates ALL children pages, so nothing is // same getAllSidebarPages fn (which aggregates ALL children pages, so nothing
// truncated at 100), same 30min staleTime — otherwise the warmed cache would // is truncated at 100), same 30min staleTime — otherwise the warmed cache
// never be read by the offline tree. // would never be read by the offline tree.
const warmSidebarChildren = async (id: string) => { const warmSidebarChildren = async (id: string): Promise<boolean> => {
try { try {
// Keep EXACTLY { pageId, spaceId } so the key hashes identically to // Keep EXACTLY { pageId, spaceId } so the key hashes identically to
// fetchAllAncestorChildren's (no parentPageId, no extra fields). // fetchAllAncestorChildren's (no parentPageId, no extra fields).
const params = { pageId: id, spaceId }; const params = { pageId: id, spaceId };
await queryClient.prefetchQuery({ await queryClient.prefetchQuery(sidebarPagesQueryOptions(params));
queryKey: ["sidebar-pages", params], return true;
queryFn: () => getAllSidebarPages(params), } catch (error) {
staleTime: 30 * 60 * 1000, console.error("makePageAvailableOffline: tree node step failed", {
pageId: id,
error,
}); });
} catch { return false;
// best-effort per node
} }
}; };
// The page's own children. // The page's own children.
await warmSidebarChildren(pageId); if (!(await warmSidebarChildren(pageId))) failed.push("tree");
// Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the // Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the
// ancestor ids — we intentionally do NOT cache the breadcrumbs themselves // ancestor ids — we intentionally do NOT cache the breadcrumbs themselves
@@ -141,20 +171,29 @@ export async function makePageAvailableOffline({
for (const ancestor of ancestors ?? []) { for (const ancestor of ancestors ?? []) {
const ancestorId = ancestor?.id; const ancestorId = ancestor?.id;
if (!ancestorId || ancestorId === pageId) continue; if (!ancestorId || ancestorId === pageId) continue;
await warmSidebarChildren(ancestorId); if (!(await warmSidebarChildren(ancestorId))) failed.push("tree");
} }
} catch { } catch (error) {
// best-effort console.error("makePageAvailableOffline: breadcrumbs step failed", {
pageId,
error,
});
failed.push("breadcrumbs");
} }
// Comments (matches useCommentsQuery's ["comments", pageId] infinite cache). // Comments (matches useCommentsQuery's RQ_KEY(pageId) infinite cache).
// useCommentsQuery reports isLoading while hasNextPage is true, so warming // useCommentsQuery reports isLoading while hasNextPage is true, so warming
// only the first page leaves the offline comments panel spinning forever on // only the first page leaves the offline comments panel spinning forever on
// pages with >100 comments. Fully paginate so the last cached page has no // pages with >100 comments. Fully paginate so the last cached page has no
// nextCursor and the panel settles offline. // nextCursor and the panel settles offline.
await warmInfiniteAll(["comments", pageId], (cursor) => const commentsOk = await warmInfiniteAll(RQ_KEY(pageId), (cursor) =>
getPageComments({ pageId, cursor, limit: 100 }), getPageComments({ pageId, cursor, limit: 100 }),
); );
if (!commentsOk) failed.push("comments");
// Dedupe — the tree label can be recorded once per failed node/ancestor.
const uniqueFailed = [...new Set(failed)];
return { ok: uniqueFailed.length === 0, failed: uniqueFailed };
} }
/** /**

View File

@@ -1,6 +1,7 @@
import { import {
InfiniteData, InfiniteData,
QueryKey, QueryKey,
queryOptions,
useInfiniteQuery, useInfiniteQuery,
UseInfiniteQueryResult, UseInfiniteQueryResult,
useMutation, useMutation,
@@ -41,11 +42,36 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types"; import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { useQueryEmit } from "@/features/websocket/use-query-emit";
/**
* Centralized React Query key factories for page queries. The hooks below and
* the offline warm path (features/offline/make-offline.ts) share these so the
* runtime keys can never silently drift apart.
*/
export const pageKeys = {
detail: (idOrSlug: string) => ["pages", idOrSlug] as const,
sidebar: (data: unknown) => ["sidebar-pages", data] as const,
rootSidebar: (spaceId: string) => ["root-sidebar-pages", spaceId] as const,
breadcrumbs: (pageId: string) => ["breadcrumbs", pageId] as const,
recentChanges: (spaceId?: string) => ["recent-changes", spaceId] as const,
};
/**
* Shared queryOptions for the sidebar-pages (ancestor children) query. Both
* fetchAllAncestorChildren and the offline warm path consume this so the key,
* queryFn and staleTime stay identical.
*/
export const sidebarPagesQueryOptions = (params: SidebarPagesParams) =>
queryOptions({
queryKey: pageKeys.sidebar(params),
queryFn: () => getAllSidebarPages(params),
staleTime: 30 * 60 * 1000,
});
export function usePageQuery( export function usePageQuery(
pageInput: Partial<IPageInput>, pageInput: Partial<IPageInput>,
): UseQueryResult<IPage, Error> { ): UseQueryResult<IPage, Error> {
const query = useQuery({ const query = useQuery({
queryKey: ["pages", pageInput.pageId], queryKey: pageKeys.detail(pageInput.pageId),
queryFn: () => getPageById(pageInput), queryFn: () => getPageById(pageInput),
enabled: !!pageInput.pageId, enabled: !!pageInput.pageId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@@ -54,9 +80,9 @@ export function usePageQuery(
useEffect(() => { useEffect(() => {
if (query.data) { if (query.data) {
if (isValidUuid(pageInput.pageId)) { if (isValidUuid(pageInput.pageId)) {
queryClient.setQueryData(["pages", query.data.slugId], query.data); queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
} else { } else {
queryClient.setQueryData(["pages", query.data.id], query.data); queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
} }
} }
}, [query.data]); }, [query.data]);
@@ -78,18 +104,20 @@ export function useCreatePageMutation() {
} }
export function updatePageData(data: IPage) { export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]); const pageBySlug = queryClient.getQueryData<IPage>(
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]); pageKeys.detail(data.slugId),
);
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
if (pageBySlug) { if (pageBySlug) {
queryClient.setQueryData(["pages", data.slugId], { queryClient.setQueryData(pageKeys.detail(data.slugId), {
...pageBySlug, ...pageBySlug,
...data, ...data,
}); });
} }
if (pageById) { if (pageById) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data }); queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
} }
invalidateOnUpdatePage( invalidateOnUpdatePage(
@@ -118,11 +146,11 @@ export function useRemovePageMutation() {
notifications.show({ message: t("Page moved to trash") }); notifications.show({ message: t("Page moved to trash") });
// Stamp deletedAt so a re-visit shows the trash banner, not stale state. // Stamp deletedAt so a re-visit shows the trash banner, not stale state.
const cached = queryClient.getQueryData<IPage>(["pages", pageId]); const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
if (cached) { if (cached) {
const stamped = { ...cached, deletedAt: new Date() }; const stamped = { ...cached, deletedAt: new Date() };
queryClient.setQueryData(["pages", cached.id], stamped); queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
queryClient.setQueryData(["pages", cached.slugId], stamped); queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
} }
invalidateOnDeletePage(pageId); invalidateOnDeletePage(pageId);
@@ -232,8 +260,11 @@ export function useRestorePageMutation() {
// Replace would strip space/permissions/content and break the editor. // Replace would strip space/permissions/content and break the editor.
const merge = (cached: IPage | undefined) => const merge = (cached: IPage | undefined) =>
cached ? { ...cached, ...restoredPage } : cached; cached ? { ...cached, ...restoredPage } : cached;
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge); queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge); queryClient.setQueryData<IPage>(
pageKeys.detail(restoredPage.slugId),
merge,
);
}, },
onError: (error) => { onError: (error) => {
notifications.show({ message: t("Failed to restore page"), color: "red" }); notifications.show({ message: t("Failed to restore page"), color: "red" });
@@ -245,7 +276,7 @@ export function useGetSidebarPagesQuery(
data: SidebarPagesParams | null, data: SidebarPagesParams | null,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> { ): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["sidebar-pages", data], queryKey: pageKeys.sidebar(data),
enabled: !!data?.pageId || !!data?.spaceId, enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam, limit: 100 }), queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
initialPageParam: undefined, initialPageParam: undefined,
@@ -256,7 +287,7 @@ export function useGetSidebarPagesQuery(
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) { export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId], queryKey: pageKeys.rootSidebar(data.spaceId),
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam, limit: 100 }); return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam, limit: 100 });
}, },
@@ -270,7 +301,7 @@ export function usePageBreadcrumbsQuery(
pageId: string, pageId: string,
): UseQueryResult<Partial<IPage[]>, Error> { ): UseQueryResult<Partial<IPage[]>, Error> {
return useQuery({ return useQuery({
queryKey: ["breadcrumbs", pageId], queryKey: pageKeys.breadcrumbs(pageId),
queryFn: () => getPageBreadcrumbs(pageId), queryFn: () => getPageBreadcrumbs(pageId),
enabled: !!pageId, enabled: !!pageId,
}); });
@@ -278,11 +309,9 @@ export function usePageBreadcrumbsQuery(
export async function fetchAllAncestorChildren(params: SidebarPagesParams) { export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
// not using a hook here, so we can call it inside a useEffect hook // not using a hook here, so we can call it inside a useEffect hook
const response = await queryClient.fetchQuery({ const response = await queryClient.fetchQuery(
queryKey: ["sidebar-pages", params], sidebarPagesQueryOptions(params),
queryFn: () => getAllSidebarPages(params), );
staleTime: 30 * 60 * 1000,
});
const allItems = response.pages.flatMap((page) => page.items); const allItems = response.pages.flatMap((page) => page.items);
return buildTree(allItems); return buildTree(allItems);
@@ -290,7 +319,7 @@ export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
export function useRecentChangesQuery(spaceId?: string) { export function useRecentChangesQuery(spaceId?: string) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["recent-changes", spaceId], queryKey: pageKeys.recentChanges(spaceId),
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }), getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
@@ -341,12 +370,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
let queryKey: QueryKey = null; let queryKey: QueryKey = null;
if (data.parentPageId === null) { if (data.parentPageId === null) {
queryKey = ["root-sidebar-pages", data.spaceId]; queryKey = pageKeys.rootSidebar(data.spaceId);
} else { } else {
queryKey = [ queryKey = pageKeys.sidebar({
"sidebar-pages", pageId: data.parentPageId,
{ pageId: data.parentPageId, spaceId: data.spaceId }, spaceId: data.spaceId,
]; });
} }
//update all sidebar pages //update all sidebar pages
@@ -406,7 +435,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update root sidebar pages haschildern //update root sidebar pages haschildern
const rootSideBarMatches = queryClient.getQueriesData({ const rootSideBarMatches = queryClient.getQueriesData({
queryKey: ["root-sidebar-pages", data.spaceId], queryKey: pageKeys.rootSidebar(data.spaceId),
exact: false, exact: false,
}); });
@@ -430,7 +459,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update recent changes //update recent changes
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["recent-changes", data.spaceId], queryKey: pageKeys.recentChanges(data.spaceId),
}); });
} }
@@ -443,9 +472,9 @@ export function invalidateOnUpdatePage(
) { ) {
let queryKey: QueryKey = null; let queryKey: QueryKey = null;
if (parentPageId === null) { if (parentPageId === null) {
queryKey = ["root-sidebar-pages", spaceId]; queryKey = pageKeys.rootSidebar(spaceId);
} else { } else {
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }]; queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
} }
//update all sidebar pages //update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>( queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
@@ -468,7 +497,7 @@ export function invalidateOnUpdatePage(
//update recent changes //update recent changes
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["recent-changes", spaceId], queryKey: pageKeys.recentChanges(spaceId),
}); });
} }
@@ -482,8 +511,8 @@ export function updateCacheOnMovePage(
// Remove page from old parent's cache // Remove page from old parent's cache
const oldQueryKey = const oldQueryKey =
oldParentId === null oldParentId === null
? ["root-sidebar-pages", spaceId] ? pageKeys.rootSidebar(spaceId)
: ["sidebar-pages", { pageId: oldParentId, spaceId }]; : pageKeys.sidebar({ pageId: oldParentId, spaceId });
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>( queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey, oldQueryKey,
@@ -503,7 +532,7 @@ export function updateCacheOnMovePage(
if (oldParentId !== null) { if (oldParentId !== null) {
const oldParentCache = queryClient.getQueryData< const oldParentCache = queryClient.getQueryData<
InfiniteData<IPagination<IPage>> InfiniteData<IPagination<IPage>>
>(["sidebar-pages", { pageId: oldParentId, spaceId }]); >(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
const remainingChildren = const remainingChildren =
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0; oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
@@ -541,8 +570,8 @@ export function updateCacheOnMovePage(
// Add page to new parent's cache // Add page to new parent's cache
const newQueryKey = const newQueryKey =
newParentId === null newParentId === null
? ["root-sidebar-pages", spaceId] ? pageKeys.rootSidebar(spaceId)
: ["sidebar-pages", { pageId: newParentId, spaceId }]; : pageKeys.sidebar({ pageId: newParentId, spaceId });
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>( queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey, newQueryKey,

View File

@@ -79,18 +79,29 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const handleMakeAvailableOffline = async () => { const handleMakeAvailableOffline = async () => {
notifications.show({ message: t("Saving page for offline use...") }); notifications.show({ message: t("Saving page for offline use...") });
try { try {
// Prefetch read queries so they get persisted to IndexedDB. // Prefetch read queries so they get persisted to IndexedDB. The result
await makePageAvailableOffline({ // reports whether every warm step succeeded.
const result = await makePageAvailableOffline({
pageId: node.id, pageId: node.id,
slugId: node.slugId,
spaceId: node.spaceId, spaceId: node.spaceId,
parentPageId: node.parentPageId,
}); });
// Best-effort: warm the page's Yjs document into IndexedDB. // Best-effort: warm the page's Yjs document into IndexedDB.
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token); await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
if (result.ok) {
notifications.show({ message: t("Page is now available offline") }); notifications.show({ message: t("Page is now available offline") });
} else {
// Partial warm — the page may still be partly usable offline, but some
// queries failed to cache, so surface it as an error rather than a
// silent success.
notifications.show({
message: t("Failed to make page available offline"),
color: "red",
});
}
} catch { } catch {
// makePageAvailableOffline / warmPageYdoc never throw, but stay safe. // makePageAvailableOffline no longer throws, but warmPageYdoc and other
// unexpected failures stay guarded here.
notifications.show({ notifications.show({
message: t("Failed to make page available offline"), message: t("Failed to make page available offline"),
color: "red", color: "red",

View File

@@ -1,5 +1,6 @@
import { import {
keepPreviousData, keepPreviousData,
queryOptions,
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
useQuery, useQuery,
@@ -31,11 +32,37 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
import { useEffect } from "react"; import { useEffect } from "react";
import { validate as isValidUuid } from "uuid"; import { validate as isValidUuid } from "uuid";
/**
* Centralized React Query key factories for space queries. The hooks below and
* the offline warm path (features/offline/make-offline.ts) share these so the
* runtime keys can never silently drift apart.
*/
export const spaceKeys = {
detail: (idOrSlug: string) => ["space", idOrSlug] as const,
list: (params?: QueryParams) => ["spaces", params] as const,
members: (spaceId: string, query?: string) =>
["spaceMembers", spaceId, query] as const,
};
/**
* Shared queryOptions for fetching a space by id/slug. Both
* useGetSpaceBySlugQuery and the offline warm path consume this so the key,
* queryFn and staleTime stay identical. (`enabled` is intentionally omitted —
* prefetchQuery ignores it anyway and the warm path always passes a real id;
* the hook reapplies `enabled` itself.)
*/
export const spaceByIdQueryOptions = (spaceId: string) =>
queryOptions({
queryKey: spaceKeys.detail(spaceId),
queryFn: () => getSpaceById(spaceId),
staleTime: 5 * 60 * 1000,
});
export function useGetSpacesQuery( export function useGetSpacesQuery(
params?: QueryParams, params?: QueryParams,
): UseQueryResult<IPagination<ISpace>, Error> { ): UseQueryResult<IPagination<ISpace>, Error> {
return useQuery({ return useQuery({
queryKey: ["spaces", params], queryKey: spaceKeys.list(params),
queryFn: () => getSpaces(params), queryFn: () => getSpaces(params),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
refetchOnMount: true, refetchOnMount: true,
@@ -44,16 +71,16 @@ export function useGetSpacesQuery(
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> { export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
const query = useQuery({ const query = useQuery({
queryKey: ["space", spaceId], queryKey: spaceKeys.detail(spaceId),
queryFn: () => getSpaceById(spaceId), queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
}); });
useEffect(() => { useEffect(() => {
if (query.data) { if (query.data) {
if (isValidUuid(spaceId)) { if (isValidUuid(spaceId)) {
queryClient.setQueryData(["space", query.data.slug], query.data); queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
} else { } else {
queryClient.setQueryData(["space", query.data.id], query.data); queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
} }
} }
}, [query.data]); }, [query.data]);
@@ -62,8 +89,11 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
} }
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => { export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
// Note: intentionally NOT using spaceByIdQueryOptions here — that factory sets
// a 5min staleTime which would let this prefetch skip fetching fresh data;
// prefetchSpace must always refetch (default staleTime: 0).
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["space", spaceSlug], queryKey: spaceKeys.detail(spaceSlug),
queryFn: () => getSpaceById(spaceSlug), queryFn: () => getSpaceById(spaceSlug),
}); });
@@ -100,10 +130,8 @@ export function useGetSpaceBySlugQuery(
spaceId: string, spaceId: string,
): UseQueryResult<ISpace, Error> { ): UseQueryResult<ISpace, Error> {
return useQuery({ return useQuery({
queryKey: ["space", spaceId], ...spaceByIdQueryOptions(spaceId),
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
staleTime: 5 * 60 * 1000,
}); });
} }
@@ -116,14 +144,16 @@ export function useUpdateSpaceMutation() {
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: t("Space updated successfully") }); notifications.show({ message: t("Space updated successfully") });
const space = queryClient.getQueryData([ const space = queryClient.getQueryData(
"space", spaceKeys.detail(variables.spaceId),
variables.spaceId, ) as ISpace;
]) as ISpace;
if (space) { if (space) {
const updatedSpace = { ...space, ...data }; const updatedSpace = { ...space, ...data };
queryClient.setQueryData(["space", variables.spaceId], updatedSpace); queryClient.setQueryData(
queryClient.setQueryData(["space", data.slug], updatedSpace); spaceKeys.detail(variables.spaceId),
updatedSpace,
);
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
} }
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -148,7 +178,7 @@ export function useDeleteSpaceMutation() {
if (variables.slug) { if (variables.slug) {
queryClient.removeQueries({ queryClient.removeQueries({
queryKey: ["space", variables.slug], queryKey: spaceKeys.detail(variables.slug),
exact: true, exact: true,
}); });
} }
@@ -156,7 +186,7 @@ export function useDeleteSpaceMutation() {
// Remove space-specific queries // Remove space-specific queries
if (variables.id) { if (variables.id) {
queryClient.removeQueries({ queryClient.removeQueries({
queryKey: ["space", variables.id], queryKey: spaceKeys.detail(variables.id),
exact: true, exact: true,
}); });
@@ -196,7 +226,7 @@ export function useSpaceMembersInfiniteQuery(
query?: string, query?: string,
) { ) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["spaceMembers", spaceId, query], queryKey: spaceKeys.members(spaceId, query),
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }), getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
enabled: !!spaceId, enabled: !!spaceId,