Files
gitmost/apps/client/src/features/offline/query-persister.ts
T
claude code agent 227 67a3663fc5 fix(offline): resume rehydrated paused mutations, stop logout cache leak, offline affordances (PR #120 QA)
Address six QA findings on the offline-sync feature:

1. HIGH — silent data loss: a paused mutation persisted to IndexedDB and
   reloaded while still offline never resumed on reconnect. Seed TanStack
   onlineManager from navigator.onLine at boot (it defaults to online:true and
   only flips on events, so a cold-boot-offline tab wrongly believed it was
   online and never got a true online transition), and call
   resumePausedMutations() in PersistQueryClientProvider onSuccess after the
   persister rehydrates (defaults are registered before, so the restored
   mutation has a mutationFn). New offline-resume.test.ts reproduces the full
   persist -> reload -> reconnect path.

2. MEDIUM (security) — logout did not durably clear gitmost-rq-cache: the
   throttled persister re-wrote the key ~1s after del() with the still-in-memory
   snapshot, resurrecting the previous user's data. Freeze the persister
   (persistClient becomes a no-op) before clearing/deleting so neither the
   clear()-triggered nor any in-flight write can repopulate the key; re-enable
   afterwards for the next sign-in session.

3. MEDIUM (UX) — offline create spun forever: the create-note button awaited a
   mutateAsync that stays pending while paused. Detect offline, fire-and-forget
   the (queued) mutation, show a "saved offline" notice, and gate the spinner on
   !isPaused so it no longer hangs.

4. LOW — an uncached page opened offline showed the generic "Error fetching page
   data." instead of the offline fallback (offline fetch yields no HTTP status).
   Render OfflineFallback when navigator is offline or the error has no status.

5. LOW — logout teardown threw "Cannot read properties of null (reading
   'settings')" in full-editor.tsx: optional-chain the (transiently null) user.

6. Tab title "Untitled": investigated — the tab-title derivation in page.tsx is
   byte-identical to develop and already reads page.title from REST/cache (the
   recommended source); live edits keep it in sync via updatePageData. Not a
   tab-title-derivation regression introduced by this PR; no change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:51:01 +03:00

85 lines
3.4 KiB
TypeScript

import { get, set, del } from "idb-keyval";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
// Structural subset of a TanStack Query we read when deciding what to persist.
// We avoid importing the branded `Query` class because the persist-client and
// react-query may resolve to different `@tanstack/query-core` copies, whose
// `Query` types are nominally incompatible (private brand). This structural
// shape stays assignable to whichever copy the persister expects.
type DehydratableQuery = {
state: { status: string };
queryKey: readonly unknown[];
};
// idb-keyval key under which TanStack Query persists its dehydrated cache.
// Exported so the logout cache-clear logic deletes the exact same key (no
// magic-string drift between persist and purge).
export const OFFLINE_CACHE_KEY = "gitmost-rq-cache";
// IndexedDB-backed storage adapter for TanStack Query's async persister.
const idbStorage = {
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
setItem: (key: string, value: string) => set(key, value),
removeItem: (key: string) => del(key),
};
const basePersister = createAsyncStoragePersister({
storage: idbStorage,
key: OFFLINE_CACHE_KEY,
throttleTime: 1000,
});
// When frozen, persistClient becomes a no-op so no new dehydrated snapshot is
// written to IndexedDB. This closes a logout data-leak race: clearing the cache
// (queryClient.clear()) fires `removed` cache events, each of which the persist
// subscription turns into a throttled persistClient call. The FIRST such call
// dehydrates a still-nearly-full snapshot and its async write can land AFTER the
// del() that clears the key, resurrecting the previous user's data (~180KB) in
// IndexedDB. Freezing before clear()/del() prevents any such rewrite. Re-enabled
// afterwards so the next (sign-in) session persists normally. See
// clear-offline-cache.ts.
let persistFrozen = false;
export function freezeOfflinePersistence(): void {
persistFrozen = true;
}
export function unfreezeOfflinePersistence(): void {
persistFrozen = false;
}
export const queryPersister = {
persistClient: (persistedClient: Parameters<typeof basePersister.persistClient>[0]) =>
persistFrozen ? Promise.resolve() : basePersister.persistClient(persistedClient),
restoreClient: () => basePersister.restoreClient(),
removeClient: () => basePersister.removeClient(),
};
// Only navigation/read query roots are persisted for offline reading.
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
//
// `currentUser` IS persisted: UserProvider gates the entire <Layout> subtree on
// useCurrentUser(), and offline the POST /api/users/me fails as a no-response
// network error. Without the persisted/hydrated user the gate blanked every
// authenticated route on an offline cold boot (#237/#238). It is the logged-in
// user's own profile (already mirrored to localStorage["currentUser"]), so
// persisting it to IndexedDB leaks nothing new while unlocking offline reads.
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
"pages",
"sidebar-pages",
"root-sidebar-pages",
"breadcrumbs",
"comments",
"space",
"spaces",
"recent-changes",
"currentUser",
]);
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
return (
query.state.status === "success" &&
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
);
}