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:
a
2026-06-27 22:29:03 +03:00
committed by claude code agent 227
parent 3d6f48c3bd
commit ca26af9e9d
23 changed files with 490 additions and 179 deletions
@@ -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([]);
});
});