fix(offline): address PR #120 review (comment 2513)
CHANGELOG: stop presenting the service-worker API cache as an active offline store (/api is NetworkOnly) — describe it as a defensive purge of the legacy api-get-cache from older clients; add an explicit upgrade note that the new CORS allowlist rejects previously-allowed cross-domain REST clients until their origin is added to CORS_ALLOWED_ORIGINS. test(offline): cover make-offline ancestor-walk + dedup — a real-ancestor case exercising the ancestorId===pageId guard (page warmed once), the dedup of repeated tree failures into a single "tree" label, and the "breadcrumbs" label when the breadcrumbs lookup rejects. test(auth): cover clearOfflineCache in handleLogout — purged exactly once before window.location.replace, and a thrown purge error does not block the redirect. conventions: use pageKeys.detail() instead of raw ["pages", …] literals in title-editor and use-page-collab-providers. cleanup: remove the dead emit() in title-editor (the gateway ignores it; the cross-user tree refresh is server-side via the Yjs title fragment); drop the trivial Array.isArray(tiptapExtensions) test (schema is exercised transitively). refactor: extract the shared page.<id> Yjs doc-name convention into pageYdocName()/PAGE_YDOC_NAME_PREFIX so the editor providers, offline warm, and offline purge can no longer drift apart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { del } from "idb-keyval";
|
||||
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { OFFLINE_CACHE_KEY } from "./query-persister";
|
||||
import { PAGE_YDOC_NAME_PREFIX } from "@/features/editor/page-ydoc-name";
|
||||
|
||||
/**
|
||||
* Best-effort purge of all of the current user's offline data from the browser.
|
||||
@@ -55,7 +56,8 @@ export async function clearOfflineCache(): Promise<void> {
|
||||
const dbs = await indexedDB.databases();
|
||||
for (const db of dbs) {
|
||||
const name = db?.name;
|
||||
if (typeof name !== "string" || !name.startsWith("page.")) continue;
|
||||
if (typeof name !== "string" || !name.startsWith(PAGE_YDOC_NAME_PREFIX))
|
||||
continue;
|
||||
try {
|
||||
// Fire-and-forget delete; await a thin wrapper so a slow delete does
|
||||
// not race the page teardown, but never reject on it.
|
||||
|
||||
@@ -219,6 +219,113 @@ describe("makePageAvailableOffline", () => {
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
// Helper: the page-ids passed to the sidebar-children warm (its query key is
|
||||
// ["sidebar-pages", { pageId, spaceId }]) — i.e. which nodes were prefetched.
|
||||
const warmedSidebarIds = () =>
|
||||
prefetchQuery.mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((opts: any) => opts?.queryKey?.[0] === "sidebar-pages")
|
||||
.map((opts: any) => opts.queryKey[1]?.pageId);
|
||||
|
||||
it("warms the page + every ancestor's children once and skips the self-ancestor guard", async () => {
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
// Breadcrumbs include two real ancestors, the page's OWN id (must be skipped
|
||||
// by the ancestorId === pageId guard so it is not warmed twice), and a
|
||||
// malformed entry with no id (also skipped).
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "anc-1" },
|
||||
{ id: "uuid-1" }, // === pageId -> guard
|
||||
{ id: "anc-2" },
|
||||
{}, // no id -> skipped
|
||||
]);
|
||||
(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",
|
||||
});
|
||||
|
||||
const ids = warmedSidebarIds();
|
||||
// The page's own children (warmSidebarChildren(pageId)) plus each real
|
||||
// ancestor — exactly once each. The self-ancestor (uuid-1 in breadcrumbs) is
|
||||
// NOT a second warm: uuid-1 appears once (from the page's own children call).
|
||||
expect(ids).toEqual(["uuid-1", "anc-1", "anc-2"]);
|
||||
expect(ids.filter((id: string) => id === "uuid-1")).toHaveLength(1);
|
||||
expect(result).toEqual({ ok: true, failed: [] });
|
||||
});
|
||||
|
||||
it("dedupes repeated tree failures into a single 'tree' label", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "anc-1" },
|
||||
{ id: "anc-2" },
|
||||
]);
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
// Fail ONLY the sidebar-children prefetches (page-own + both ancestors = 3
|
||||
// failures); the currentUser/space prefetches still resolve.
|
||||
prefetchQuery.mockImplementation(async (opts: any) => {
|
||||
if (opts?.queryKey?.[0] === "sidebar-pages") throw new Error("network");
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
// Three node warms failed but the contract collapses them to one "tree".
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toEqual(["tree"]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("records 'breadcrumbs' (not 'tree') when the breadcrumbs lookup rejects", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
// Ancestor discovery fails -> the ancestor-walk is recorded as "breadcrumbs".
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("network"),
|
||||
);
|
||||
(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",
|
||||
});
|
||||
|
||||
// The page's own children still warmed fine (prefetch resolves), so the only
|
||||
// failure is the breadcrumbs lookup.
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toEqual(["breadcrumbs"]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("warmPageYdoc", () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getMyInfo } from "@/features/user/services/user-service";
|
||||
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||
|
||||
/**
|
||||
* Fully paginate an infinite query and write the @tanstack InfiniteData cache
|
||||
@@ -258,7 +259,7 @@ export async function warmPageYdoc(
|
||||
let remote: HocuspocusProvider | null = null;
|
||||
|
||||
try {
|
||||
const documentName = `page.${pageId}`;
|
||||
const documentName = pageYdocName(pageId);
|
||||
ydoc = new Y.Doc();
|
||||
local = new IndexeddbPersistence(documentName, ydoc);
|
||||
remote = new HocuspocusProvider({
|
||||
|
||||
Reference in New Issue
Block a user