fix(offline): address PR #120 review (cross-user leak, Yjs title dup, i18n, docs, guards)
Security: - Clear the offline IndexedDB cache on sign-in (not only logout) so a previous user's persisted query cache and Yjs page bodies cannot leak to the next user on a shared device when the prior session ended without an explicit logout. Regressions: - Remove the double Yjs title write from the AI title-generation path: the title editor is bound to the Yjs `title` fragment and the server REST update reseeds it, so the local setContent raced that reseed and doubled/garbled the title. Conventions / i18n / docs: - Remove the unused showAiMenuAtom. - Register the 3 offline-fallback strings in en-US and ru-RU. - Fix the 5 broken links to the nonexistent docs/offline-sync-plan.md. Stability / simplification: - warmInfiniteAll now reports truncation (returns false) when it hits maxPages with a cursor still pending instead of silently succeeding. - space-tree make-offline catch logs the raw error and surfaces the real cause. - Move the Offline/Mobile/CORS CHANGELOG entries from the released 0.93.0 section into [Unreleased] (CORS is a documented breaking change). - Drop the pass-through sync-flag forwarders in use-page-collab-providers; set the atoms directly. - Collapse the three isSwaggerEnabled true-cases into it.each. Tests / architecture: - Extract collabTokenNeedsRefresh (pure) and cover all four token states. - Extract shouldPropagateTitleChange and cover the collab-origin skip; add a TitleEditor render test for the static-h1 vs collaborative-editor switch. - Add a use-auth test asserting the sign-in cache purge runs before login. - Add an OFFLINE_PERSIST_ROOTS guard test asserting every persisted root maps to an exported query-key factory; route make-offline's currentUser warm through a new userKeys factory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -123,17 +123,25 @@ describe("warmInfiniteAll", () => {
|
||||
expect(payload.pageParams).toEqual([undefined, "c1", "c2"]);
|
||||
});
|
||||
|
||||
it("caps pagination at maxPages", async () => {
|
||||
it("caps pagination at maxPages and reports the truncation (returns false)", async () => {
|
||||
// Always returns a non-null cursor — the cap is the only thing that stops it.
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ items: [], meta: { nextCursor: "more" } });
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await warmInfiniteAll(["comments", "p1"], fetchPage, 2);
|
||||
// Hitting maxPages with a cursor still pending is a truncated warm: the
|
||||
// (partial) cache is still written, but the result is reported as false.
|
||||
await expect(
|
||||
warmInfiniteAll(["comments", "p1"], fetchPage, 2),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||
const payload = setQueryData.mock.calls[0][1];
|
||||
expect(payload.pages).toHaveLength(2);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns true on success", async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 { 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";
|
||||
|
||||
@@ -32,7 +33,13 @@ import { IPagination } from "@/lib/types.ts";
|
||||
* 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.
|
||||
* Returns true ONLY if the cursor chain was fully exhausted and written. If the
|
||||
* walk stops because it hit `maxPages` while a `nextCursor` is still pending,
|
||||
* the cached list is truncated AND its last page keeps a nextCursor that cannot
|
||||
* be re-fetched offline (hooks that gate on hasNextPage would spin forever), so
|
||||
* that case is logged and returns false too — the caller records it as a failed
|
||||
* warm instead of a silent truncated success. The (partial) cache is still
|
||||
* written so what we did fetch is usable.
|
||||
*
|
||||
* Exported for unit testing of the cursor-walk / cache-write behavior.
|
||||
*/
|
||||
@@ -45,16 +52,31 @@ export async function warmInfiniteAll<T>(
|
||||
const pages: IPagination<T>[] = [];
|
||||
const pageParams: (string | undefined)[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
let exhausted = false;
|
||||
|
||||
for (let i = 0; i < maxPages; i++) {
|
||||
const res = await fetchPage(cursor);
|
||||
pages.push(res);
|
||||
pageParams.push(cursor);
|
||||
cursor = res?.meta?.nextCursor ?? undefined;
|
||||
if (!cursor) break;
|
||||
if (!cursor) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.setQueryData(queryKey, { pages, pageParams });
|
||||
|
||||
if (!exhausted) {
|
||||
// Stopped at maxPages with a cursor still pending: the list is truncated
|
||||
// and the last cached page's nextCursor is un-fetchable offline. Report it
|
||||
// as a failed warm rather than a silent truncated success.
|
||||
console.error("warmInfiniteAll truncated at maxPages", {
|
||||
queryKey,
|
||||
maxPages,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("warmInfiniteAll failed", { queryKey, error });
|
||||
@@ -101,7 +123,7 @@ export async function makePageAvailableOffline({
|
||||
// cache actually has an entry to restore.
|
||||
try {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: ["currentUser"],
|
||||
queryKey: userKeys.currentUser(),
|
||||
queryFn: () => getMyInfo(),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// The query modules transitively import the app entry (@/main.tsx) for the
|
||||
// shared queryClient; mock it so importing the key factories has no side effects.
|
||||
import { vi } from "vitest";
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { setQueryData: vi.fn(), getQueryData: vi.fn() },
|
||||
}));
|
||||
|
||||
import { OFFLINE_PERSIST_ROOTS } from "./query-persister";
|
||||
import { pageKeys } from "@/features/page/queries/page-query";
|
||||
import { spaceKeys } from "@/features/space/queries/space-query";
|
||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||
|
||||
/**
|
||||
* Architecture guard (#13): every string persisted via OFFLINE_PERSIST_ROOTS
|
||||
* must be the ROOT (queryKey[0]) of some exported query-key factory. If a
|
||||
* factory's root is renamed without updating the persist registry — or vice
|
||||
* versa — offline persist/warm silently breaks (persisted keys never match the
|
||||
* live queries). This turns that silent regression into a red build.
|
||||
*
|
||||
* Each factory is invoked with throwaway args; only queryKey[0] is inspected.
|
||||
*/
|
||||
function rootOf(key: readonly unknown[]): string {
|
||||
return String(key[0]);
|
||||
}
|
||||
|
||||
const FACTORY_ROOTS = new Set<string>([
|
||||
rootOf(pageKeys.detail("x")),
|
||||
rootOf(pageKeys.sidebar({})),
|
||||
rootOf(pageKeys.rootSidebar("x")),
|
||||
rootOf(pageKeys.breadcrumbs("x")),
|
||||
rootOf(pageKeys.recentChanges("x")),
|
||||
rootOf(spaceKeys.detail("x")),
|
||||
rootOf(spaceKeys.list()),
|
||||
rootOf(RQ_KEY("x")),
|
||||
rootOf(userKeys.currentUser()),
|
||||
]);
|
||||
|
||||
describe("OFFLINE_PERSIST_ROOTS is backed by real query-key factories", () => {
|
||||
it("maps every persisted root to an exported factory root", () => {
|
||||
const unbacked = [...OFFLINE_PERSIST_ROOTS].filter(
|
||||
(root) => !FACTORY_ROOTS.has(root),
|
||||
);
|
||||
expect(unbacked).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user