Compare commits
3 Commits
2cf30c7690
...
feature/of
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411c05a9d6 | ||
|
|
e8805b39c8 | ||
|
|
67a3663fc5 |
@@ -82,14 +82,18 @@ export function FullEditor({
|
||||
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||
// that enables the chat agent); the server enforces it too (#199).
|
||||
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
// `user` can momentarily be null during logout teardown (the currentUser atom
|
||||
// is reset before this subtree unmounts). Optional-chain every access so the
|
||||
// teardown render does not throw "Cannot read properties of null (reading
|
||||
// 'settings')".
|
||||
const fullPageWidth = user?.settings?.preferences?.fullPageWidth;
|
||||
const editorToolbarEnabled =
|
||||
user.settings?.preferences?.editorToolbar ?? false;
|
||||
user?.settings?.preferences?.editorToolbar ?? false;
|
||||
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
||||
currentPageEditModeAtom,
|
||||
);
|
||||
const userPageEditMode =
|
||||
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
||||
|
||||
// Single shared Y.Doc + HocuspocusProvider for both the title and body
|
||||
|
||||
@@ -3,6 +3,8 @@ import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { onlineManager } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -36,21 +38,39 @@ function CreateNoteButton({
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
|
||||
const createNote = async (space: ISpace) => {
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const variables = {
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} as any;
|
||||
|
||||
if (!onlineManager.isOnline()) {
|
||||
// Offline: the create is PAUSED and queued — its promise will not resolve
|
||||
// until we are back online, so awaiting it here would spin the button
|
||||
// forever. Fire it without awaiting (it persists and replays on reconnect)
|
||||
// and tell the user it was saved offline instead of leaving a dead spinner.
|
||||
createPageMutation.mutate(variables);
|
||||
notifications.show({
|
||||
color: "blue",
|
||||
message: t("You're offline. This note will be created once you reconnect."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const createdPage = await createPageMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} as any);
|
||||
const createdPage = await createPageMutation.mutateAsync(variables);
|
||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||
} catch {
|
||||
// useCreatePageMutation already surfaces a red notification on error.
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createPageMutation.isPending;
|
||||
// A paused (offline) mutation stays `isPending`, so gate the spinner on it NOT
|
||||
// being paused — otherwise the button would spin forever after an offline
|
||||
// create. The offline path above gives its own "saved offline" feedback.
|
||||
const isPending = createPageMutation.isPending && !createPageMutation.isPaused;
|
||||
|
||||
// Exactly one writable space → create directly, no picker needed.
|
||||
if (writableSpaces.length === 1) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { del } from "idb-keyval";
|
||||
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { OFFLINE_CACHE_KEY } from "./query-persister";
|
||||
import {
|
||||
OFFLINE_CACHE_KEY,
|
||||
freezeOfflinePersistence,
|
||||
unfreezeOfflinePersistence,
|
||||
} from "./query-persister";
|
||||
import { PAGE_YDOC_NAME_PREFIX } from "@/features/editor/page-ydoc-name";
|
||||
|
||||
/**
|
||||
@@ -31,19 +35,27 @@ import { PAGE_YDOC_NAME_PREFIX } from "@/features/editor/page-ydoc-name";
|
||||
* service-worker-capable browsers).
|
||||
*/
|
||||
export async function clearOfflineCache(): Promise<void> {
|
||||
// 1a. Drop the in-memory query cache immediately.
|
||||
try {
|
||||
queryClient.clear();
|
||||
} catch {
|
||||
// best-effort: ignore in-memory cache reset failures
|
||||
}
|
||||
// Freeze the throttled persister BEFORE touching the cache so the
|
||||
// queryClient.clear() below cannot trigger a late re-write of the (still
|
||||
// nearly-full) dehydrated snapshot after we del() the key — which would
|
||||
// otherwise resurrect the previous user's persisted data in IndexedDB.
|
||||
// Re-enabled in `finally` so the next (sign-in) session persists normally.
|
||||
freezeOfflinePersistence();
|
||||
|
||||
// 1b. Delete the persisted RQ cache from IndexedDB.
|
||||
try {
|
||||
await del(OFFLINE_CACHE_KEY);
|
||||
} catch {
|
||||
// best-effort: ignore persisted-cache deletion failures
|
||||
}
|
||||
// 1a. Drop the in-memory query cache immediately.
|
||||
try {
|
||||
queryClient.clear();
|
||||
} catch {
|
||||
// best-effort: ignore in-memory cache reset failures
|
||||
}
|
||||
|
||||
// 1b. Delete the persisted RQ cache from IndexedDB.
|
||||
try {
|
||||
await del(OFFLINE_CACHE_KEY);
|
||||
} catch {
|
||||
// best-effort: ignore persisted-cache deletion failures
|
||||
}
|
||||
|
||||
// 2. Delete the Yjs page IndexedDB databases (`page.<id>`).
|
||||
// `indexedDB.databases()` is not implemented everywhere (e.g. Firefox); when
|
||||
@@ -91,4 +103,10 @@ export async function clearOfflineCache(): Promise<void> {
|
||||
} catch {
|
||||
// best-effort: ignore Cache Storage failures
|
||||
}
|
||||
} finally {
|
||||
// Re-enable persistence for the next session (sign-in continues running in
|
||||
// the same tab; logout reloads via window.location.replace, so this is a
|
||||
// harmless no-op there).
|
||||
unfreezeOfflinePersistence();
|
||||
}
|
||||
}
|
||||
|
||||
128
apps/client/src/features/offline/offline-resume.test.ts
Normal file
128
apps/client/src/features/offline/offline-resume.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||
import {
|
||||
persistQueryClientRestore,
|
||||
persistQueryClientSave,
|
||||
} from "@tanstack/react-query-persist-client";
|
||||
|
||||
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||
const h = vi.hoisted(() => ({
|
||||
createPage: vi.fn(),
|
||||
movePage: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/services/page-service", () => ({
|
||||
createPage: h.createPage,
|
||||
movePage: h.movePage,
|
||||
}));
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
createComment: h.createComment,
|
||||
}));
|
||||
vi.mock("@/features/page/queries/page-query", () => ({
|
||||
invalidateOnCreatePage: vi.fn(),
|
||||
}));
|
||||
|
||||
// In-memory idb-keyval so the REAL queryPersister round-trips through a fake
|
||||
// store (the actual persist -> reload -> restore path, not a hand-built blob).
|
||||
const store = new Map<string, string>();
|
||||
vi.mock("idb-keyval", () => ({
|
||||
get: vi.fn((k: string) => Promise.resolve(store.get(k) ?? undefined)),
|
||||
set: vi.fn((k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
del: vi.fn((k: string) => {
|
||||
store.delete(k);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}));
|
||||
|
||||
import { queryPersister } from "./query-persister";
|
||||
import {
|
||||
offlineMutationKeys,
|
||||
registerOfflineMutationDefaults,
|
||||
} from "./offline-mutations";
|
||||
|
||||
const BUSTER = "test-buster";
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// onlineManager is a global singleton; leave it in the default online state.
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
|
||||
describe("offline paused-mutation resume across a reload", () => {
|
||||
// This is the #120 silent-data-loss reproduction: a paused mutation persisted
|
||||
// to IndexedDB while offline, then the tab RELOADS while still offline, must
|
||||
// resume on reconnect. It exercises the real persister round-trip plus the two
|
||||
// boot-time fixes the app wiring relies on:
|
||||
// (a) onlineManager seeded to the real offline state so the later reconnect
|
||||
// is a true offline->online transition that auto-resumes, and
|
||||
// (b) resumePausedMutations() called after the persister restores (what the
|
||||
// PersistQueryClientProvider onSuccess does), with mutation defaults
|
||||
// registered BEFORE the resume so the rehydrated mutation has a fn.
|
||||
it("replays a rehydrated paused create on reconnect (mutationFn fires)", async () => {
|
||||
// --- Tab 1, OFFLINE: user creates a page; it pauses and gets persisted. ---
|
||||
onlineManager.setOnline(false); // (a) boot seeded offline
|
||||
|
||||
const client1 = new QueryClient();
|
||||
registerOfflineMutationDefaults(client1);
|
||||
const observer = client1.getMutationCache().build(client1, {
|
||||
mutationKey: offlineMutationKeys.createPage,
|
||||
});
|
||||
observer.state.isPaused = true;
|
||||
observer.state.status = "pending";
|
||||
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||
|
||||
await persistQueryClientSave({
|
||||
// Cast: persist-client-core and react-query may resolve to different
|
||||
// @tanstack/query-core copies whose QueryClient brands are nominally
|
||||
// incompatible (see query-persister.ts). Structurally identical at runtime.
|
||||
queryClient: client1 as any,
|
||||
persister: queryPersister,
|
||||
buster: BUSTER,
|
||||
dehydrateOptions: { shouldDehydrateMutation: () => true },
|
||||
});
|
||||
// The paused mutation is now in the persisted store.
|
||||
expect(store.size).toBe(1);
|
||||
|
||||
// --- RELOAD while still offline: fresh client restores from the SAME
|
||||
// persister. Defaults are registered BEFORE restore/resume. ---
|
||||
const client2 = new QueryClient();
|
||||
registerOfflineMutationDefaults(client2);
|
||||
client2.mount(); // subscribes to onlineManager (auto-resume on reconnect)
|
||||
|
||||
await persistQueryClientRestore({
|
||||
queryClient: client2 as any,
|
||||
persister: queryPersister,
|
||||
buster: BUSTER,
|
||||
});
|
||||
expect(client2.getMutationCache().getAll()).toHaveLength(1);
|
||||
|
||||
// (b) onSuccess wiring resumes after restore — but we are still OFFLINE, so
|
||||
// the mutation must stay paused and NOT fire yet.
|
||||
await client2.resumePausedMutations();
|
||||
expect(h.createPage).not.toHaveBeenCalled();
|
||||
|
||||
// --- RECONNECT: the offline->online transition auto-resumes the paused
|
||||
// mutation and its registered default mutationFn finally fires. ---
|
||||
onlineManager.setOnline(true);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(h.createPage).toHaveBeenCalledWith({
|
||||
spaceId: "s1",
|
||||
title: "Offline page",
|
||||
});
|
||||
|
||||
client2.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
// In-memory idb-keyval so we can observe whether the persister actually writes.
|
||||
const h = vi.hoisted(() => ({
|
||||
get: vi.fn(() => Promise.resolve(undefined)),
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
del: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
vi.mock("idb-keyval", () => h);
|
||||
|
||||
import {
|
||||
shouldDehydrateOfflineQuery,
|
||||
OFFLINE_PERSIST_ROOTS,
|
||||
queryPersister,
|
||||
freezeOfflinePersistence,
|
||||
unfreezeOfflinePersistence,
|
||||
} from "./query-persister";
|
||||
|
||||
// Small helper to build the structural query shape the predicate reads.
|
||||
@@ -87,3 +99,30 @@ describe("OFFLINE_PERSIST_ROOTS", () => {
|
||||
expect(OFFLINE_PERSIST_ROOTS.has("trash")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("freeze/unfreeze persistence (logout no-late-write guard)", () => {
|
||||
const dummyClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: "",
|
||||
clientState: { mutations: [], queries: [] },
|
||||
} as any;
|
||||
|
||||
afterEach(() => {
|
||||
// Always leave persistence enabled so other tests/sessions persist normally.
|
||||
unfreezeOfflinePersistence();
|
||||
h.set.mockClear();
|
||||
});
|
||||
|
||||
it("does NOT write to storage while frozen", async () => {
|
||||
freezeOfflinePersistence();
|
||||
await queryPersister.persistClient(dummyClient);
|
||||
expect(h.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes writing to storage once unfrozen", async () => {
|
||||
freezeOfflinePersistence();
|
||||
unfreezeOfflinePersistence();
|
||||
await queryPersister.persistClient(dummyClient);
|
||||
expect(h.set).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,12 +23,38 @@ const idbStorage = {
|
||||
removeItem: (key: string) => del(key),
|
||||
};
|
||||
|
||||
export const queryPersister = createAsyncStoragePersister({
|
||||
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.
|
||||
//
|
||||
|
||||
@@ -98,9 +98,10 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
} 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.
|
||||
// silent success. Name the failed step(s) (AGENTS.md: errors must be
|
||||
// specific, never a bare generic string); `result.failed` carries them.
|
||||
notifications.show({
|
||||
message: t("Failed to make page available offline"),
|
||||
message: `${t("Failed to make page available offline")}: ${result.failed.join(", ")}`,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { MantineProvider } from "@mantine/core";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import "./i18n";
|
||||
@@ -47,9 +47,21 @@ export const queryClient = new QueryClient({
|
||||
// Register default mutationFns for the offline-relevant structural mutations so
|
||||
// a paused mutation restored from IndexedDB after an offline reload still has a
|
||||
// mutationFn and is replayed by resumePausedMutations() on reconnect (instead
|
||||
// of silently no-op'ing and dropping the offline create/move/comment).
|
||||
// of silently no-op'ing and dropping the offline create/move/comment). MUST run
|
||||
// before any resumePausedMutations() so rehydrated paused mutations have a fn.
|
||||
registerOfflineMutationDefaults(queryClient);
|
||||
|
||||
// Seed TanStack Query's onlineManager from the REAL connectivity state at boot.
|
||||
// It defaults to `online: true` and only flips on window online/offline events,
|
||||
// so a tab that COLD-BOOTS offline would wrongly believe it is online: paused
|
||||
// mutations restored from IndexedDB would never get a later offline->online
|
||||
// transition to trigger their replay, and the offline UI affordances could not
|
||||
// tell they are offline. Seeding here makes the first real `online` event a true
|
||||
// transition that auto-resumes the rehydrated paused mutations (#120 data loss).
|
||||
if (typeof navigator !== "undefined" && "onLine" in navigator) {
|
||||
onlineManager.setOnline(navigator.onLine);
|
||||
}
|
||||
|
||||
if (isCloud() && isPostHogEnabled) {
|
||||
posthog.init(getPostHogKey(), {
|
||||
api_host: getPostHogHost(),
|
||||
@@ -76,6 +88,16 @@ root.render(
|
||||
shouldDehydrateQuery: shouldDehydrateOfflineQuery,
|
||||
},
|
||||
}}
|
||||
// After the persister finishes rehydrating, replay any paused
|
||||
// mutations restored from IndexedDB. If we are back online this fires
|
||||
// them immediately; if still offline they stay paused and TanStack's
|
||||
// onlineManager auto-resumes them on the next online transition (which
|
||||
// is now a true transition thanks to the onlineManager seeding above).
|
||||
// Without this, a paused mutation persisted while offline and then
|
||||
// reloaded would never resume and the user's work would be lost (#120).
|
||||
onSuccess={() => {
|
||||
queryClient.resumePausedMutations();
|
||||
}}
|
||||
>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
{/* Skip SW registration inside the Capacitor native WebView — the
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -62,7 +63,19 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
}
|
||||
|
||||
if (isError || !page) {
|
||||
if ([401, 403, 404].includes(error?.["status"])) {
|
||||
// An offline fetch of a page that was never saved for offline use yields a
|
||||
// network error with NO HTTP status (status is undefined), which would
|
||||
// otherwise fall through to the generic "Error fetching page data." state.
|
||||
// When we are offline (or the failure is a network error with no status),
|
||||
// show the dedicated "You're offline — this page isn't saved for offline"
|
||||
// fallback instead, so the user understands why the page won't load.
|
||||
const httpStatus = error?.["status"];
|
||||
const isOffline =
|
||||
typeof navigator !== "undefined" && navigator.onLine === false;
|
||||
if (isOffline || (isError && httpStatus == null)) {
|
||||
return <OfflineFallback />;
|
||||
}
|
||||
if ([401, 403, 404].includes(httpStatus)) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={IconFileOff}
|
||||
|
||||
@@ -68,18 +68,22 @@ export default defineConfig(({ mode }) => {
|
||||
// segments are consistently excluded from the SPA fallback, mirroring
|
||||
// the runtimeCaching urlPattern regexes below.
|
||||
//
|
||||
// `/share`, `/mcp`, and `/robots.txt` mirror the server static-serve
|
||||
// exclude list (apps/server/src/main.ts setGlobalPrefix `exclude`):
|
||||
// robots.txt, the SEO/OG/analytics-injected public share HTML, and the
|
||||
// embedded MCP endpoint are served by server controllers, so the SW must
|
||||
// never shadow them with the precached index.html app shell (doing so
|
||||
// would break SEO and MCP).
|
||||
// `/share`, `/mcp`, `/l`, and `/robots.txt` mirror the server
|
||||
// static-serve exclude list (apps/server/src/main.ts setGlobalPrefix
|
||||
// `exclude`): robots.txt, the SEO/OG/analytics-injected public share
|
||||
// HTML, the embedded MCP endpoint, and the `l/:alias` vanity short-link
|
||||
// (a server 302 to a share page) are served by server controllers, so
|
||||
// the SW must never shadow them with the precached index.html app shell.
|
||||
// For `/l/:alias` the client router has NO matching route, so serving
|
||||
// the app shell would dead-end on Error404 and break the public link;
|
||||
// it must reach the server to perform the redirect.
|
||||
navigateFallbackDenylist: [
|
||||
/^\/api(\/|$)/,
|
||||
/^\/collab(\/|$)/,
|
||||
/^\/socket\.io(\/|$)/,
|
||||
/^\/share(\/|$)/,
|
||||
/^\/mcp(\/|$)/,
|
||||
/^\/l(\/|$)/,
|
||||
/^\/robots\.txt$/,
|
||||
],
|
||||
cleanupOutdatedCaches: true,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
writeTitleFragment,
|
||||
} from './collaboration.handler';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationGateway {
|
||||
@@ -184,7 +185,32 @@ export class CollaborationGateway {
|
||||
context ?? {},
|
||||
);
|
||||
try {
|
||||
await connection.transact((doc) => writeTitleFragment(doc, title));
|
||||
// Write the new title into the in-memory 'title' fragment AND capture the
|
||||
// resulting full doc state so we can persist it directly below.
|
||||
let ydocState: Buffer | null = null;
|
||||
await connection.transact((doc) => {
|
||||
writeTitleFragment(doc, title);
|
||||
ydocState = Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
});
|
||||
|
||||
// F1 (variant C): persist the 'title' fragment to `page.ydoc` DIRECTLY,
|
||||
// bypassing onStoreDocument. PageService.update already wrote the new title
|
||||
// to the page.title COLUMN before calling this, so onStoreDocument's no-op
|
||||
// fast-path (titleText === column) would NOT persist the in-memory fragment
|
||||
// on disconnect — leaving the stored ydoc with the OLD title, which a later
|
||||
// body edit would then revert the column back to. Writing the ydoc here
|
||||
// makes BOTH column and persisted fragment consistent (NEW = NEW).
|
||||
//
|
||||
// Safe with or without a live editor: the write is idempotent and carries
|
||||
// no tree snapshot (no double broadcast); when an editor is connected, the
|
||||
// normal onStoreDocument flow still persists the (superset) state later and
|
||||
// the live clients receive the title change through the transact above.
|
||||
if (ydocState) {
|
||||
await this.persistenceExtension.persistTitleFragmentYdoc(
|
||||
pageId,
|
||||
ydocState,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await connection.disconnect();
|
||||
}
|
||||
|
||||
@@ -85,15 +85,26 @@ describe('CollaborationGateway.writePageTitle — Redis-independent path', () =>
|
||||
// redisSync is intentionally null — this is the no-Redis scenario.
|
||||
gateway.redisSync = null;
|
||||
gateway.hocuspocus = { openDirectConnection } as any;
|
||||
// F1 (variant C): writePageTitle persists the 'title' fragment directly so a
|
||||
// later body edit can't revert the rename (see title-rename-durability.spec).
|
||||
const persistTitleFragmentYdoc = jest.fn().mockResolvedValue(undefined);
|
||||
gateway.persistenceExtension = { persistTitleFragmentYdoc } as any;
|
||||
|
||||
return { gateway, openDirectConnection, transact, disconnect };
|
||||
return {
|
||||
gateway,
|
||||
openDirectConnection,
|
||||
transact,
|
||||
disconnect,
|
||||
persistTitleFragmentYdoc,
|
||||
};
|
||||
};
|
||||
|
||||
it('writes the new title via openDirectConnection and disconnects', async () => {
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||
|
||||
const { gateway, openDirectConnection, disconnect } = makeGateway(doc);
|
||||
const { gateway, openDirectConnection, disconnect, persistTitleFragmentYdoc } =
|
||||
makeGateway(doc);
|
||||
|
||||
await gateway.writePageTitle('page-1', 'New Title', { user: { id: 'u1' } });
|
||||
|
||||
@@ -102,6 +113,11 @@ describe('CollaborationGateway.writePageTitle — Redis-independent path', () =>
|
||||
expect.objectContaining({ user: { id: 'u1' } }),
|
||||
);
|
||||
expect(readTitleText(doc)).toBe('New Title');
|
||||
// The renamed fragment is persisted directly to page.ydoc (F1 variant C).
|
||||
expect(persistTitleFragmentYdoc).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
expect.any(Buffer),
|
||||
);
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -290,6 +290,35 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an already-encoded Y.Doc state directly to `page.ydoc`, mirroring the
|
||||
* `pageRepo.updatePage({ ydoc })` write that onStoreDocument uses.
|
||||
*
|
||||
* Used by the gateway's writePageTitle (F1, variant C). A REST/MCP/agent rename
|
||||
* with no live editor writes the new title into the in-memory 'title' fragment,
|
||||
* but onStoreDocument's no-op fast-path (page.title column already equals the
|
||||
* new title) does NOT persist that in-memory fragment, so the stored `page.ydoc`
|
||||
* keeps the OLD title — and a later body edit then reverts the rename (loads the
|
||||
* OLD fragment, sees it differs from the column, overwrites the column back to
|
||||
* OLD). Writing the ydoc here keeps the persisted fragment consistent with the
|
||||
* column so the rename survives.
|
||||
*
|
||||
* Broadcast-safe / no double broadcast: this carries no `treeUpdate`, so the
|
||||
* tree WS + redis listeners (which gate on `treeUpdate`) do NOT re-broadcast the
|
||||
* rename — only PageService.update's own PAGE_UPDATED does. The only extra
|
||||
* side-effect is an idempotent search reindex.
|
||||
*
|
||||
* Idempotent and lock-free, so it is safe whether or not a live editor is
|
||||
* connected: Yjs state is cumulative, so a concurrent onStoreDocument simply
|
||||
* persists a superset of this state later.
|
||||
*/
|
||||
async persistTitleFragmentYdoc(
|
||||
pageId: string,
|
||||
ydocState: Buffer,
|
||||
): Promise<void> {
|
||||
await this.pageRepo.updatePage({ ydoc: ydocState }, pageId);
|
||||
}
|
||||
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
const { documentName, document, context } = data;
|
||||
|
||||
|
||||
187
apps/server/src/collaboration/title-rename-durability.spec.ts
Normal file
187
apps/server/src/collaboration/title-rename-durability.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
|
||||
/**
|
||||
* F1 (variant C) — rename durability for a page with an already-persisted Yjs
|
||||
* 'title' fragment and NO live editor (the REST/MCP/agent rename path).
|
||||
*
|
||||
* The bug: PageService.update writes the NEW title to the `page.title` COLUMN,
|
||||
* then calls gateway.writePageTitle, which loads the page's ydoc (fragment =
|
||||
* OLD) and overwrites it to NEW in memory. On disconnect, onStoreDocument sees
|
||||
* titleText(NEW) === column(NEW) → no-op fast-path → it does NOT persist the
|
||||
* in-memory fragment. So `page.ydoc` keeps the OLD title, and a LATER body edit
|
||||
* loads the OLD fragment, sees it differs from the column, and silently reverts
|
||||
* the column back to OLD.
|
||||
*
|
||||
* The fix: writePageTitle persists the 'title' fragment to `page.ydoc` DIRECTLY
|
||||
* (via PersistenceExtension.persistTitleFragmentYdoc) after the transact, so the
|
||||
* persisted fragment and the column stay consistent.
|
||||
*
|
||||
* This test drives the REAL writePageTitle + the REAL onStoreDocument against an
|
||||
* in-memory page row, so it FAILS on the pre-fix no-op behaviour and PASSES after.
|
||||
*/
|
||||
|
||||
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const USER_ID = 'user-1';
|
||||
const OLD_TITLE = 'Old Title';
|
||||
const NEW_TITLE = 'Renamed Title';
|
||||
|
||||
const bodyJson = (text: string) => ({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||
});
|
||||
|
||||
// Build the initial persisted ydoc carrying BOTH a 'title' fragment and a body.
|
||||
const makeInitialYdoc = (title: string, body: any): Buffer => {
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
|
||||
Y.applyUpdate(
|
||||
doc,
|
||||
Y.encodeStateAsUpdate(TiptapTransformer.toYdoc(body, 'default', tiptapExtensions)),
|
||||
);
|
||||
return Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
};
|
||||
|
||||
// Load a doc from a persisted buffer (mirrors openDirectConnection loading from
|
||||
// persistence when no editor is connected). hocuspocus augments the live doc
|
||||
// with broadcastStateless(); a bare Y.Doc lacks it, so stub it.
|
||||
const loadDoc = (buf: Buffer): Y.Doc => {
|
||||
const doc = new Y.Doc();
|
||||
if (buf) Y.applyUpdate(doc, new Uint8Array(buf));
|
||||
(doc as any).broadcastStateless = jest.fn();
|
||||
return doc;
|
||||
};
|
||||
|
||||
// Read the 'title' fragment text from a persisted buffer.
|
||||
const readTitle = (buf: Buffer): string => {
|
||||
const doc = loadDoc(buf);
|
||||
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
return titleJson ? jsonToText(titleJson).trim() : '';
|
||||
};
|
||||
|
||||
describe('rename durability (F1 variant C): persisted title fragment survives a body edit', () => {
|
||||
it('persists the renamed title into page.ydoc so a later body edit does not revert it', async () => {
|
||||
// In-memory page row = the DB.
|
||||
const row: any = {
|
||||
id: PAGE_ID,
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: ['creator-1'],
|
||||
createdAt: new Date('2020-01-01T00:00:00Z'),
|
||||
lastUpdatedSource: 'user',
|
||||
title: OLD_TITLE,
|
||||
// content column mirrors the normalized body in the ydoc.
|
||||
content: TiptapTransformer.fromYdoc(
|
||||
loadDoc(makeInitialYdoc(OLD_TITLE, bodyJson('BODY V1'))),
|
||||
'default',
|
||||
),
|
||||
ydoc: makeInitialYdoc(OLD_TITLE, bodyJson('BODY V1')),
|
||||
};
|
||||
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async () => ({ ...row })),
|
||||
updatePage: jest.fn(async (data: any, _pageId?: string) => {
|
||||
Object.assign(row, data, { updatedAt: new Date() });
|
||||
}),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const noopQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||
const transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// db whose transaction().execute(fn) runs fn with a trx stub (drives the
|
||||
// real executeTx helper without a database).
|
||||
const db = {
|
||||
transaction: () => ({
|
||||
execute: (fn: (trx: any) => Promise<any>) => fn({ __trx: true }),
|
||||
}),
|
||||
};
|
||||
|
||||
const ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
db as any,
|
||||
noopQueue as any,
|
||||
noopQueue as any,
|
||||
noopQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'error').mockImplementation(() => undefined);
|
||||
|
||||
const documentName = `page.${PAGE_ID}`;
|
||||
// Fake hocuspocus: openDirectConnection loads a doc from the CURRENT persisted
|
||||
// ydoc (no live editor) and, on disconnect, runs the real onStoreDocument —
|
||||
// exactly the no-live-editor unload path.
|
||||
const fakeHocuspocus = {
|
||||
openDirectConnection: jest.fn(async (name: string, context: any) => {
|
||||
const liveDoc = loadDoc(row.ydoc);
|
||||
return {
|
||||
transact: async (fn: (doc: Y.Doc) => void) => fn(liveDoc),
|
||||
disconnect: async () => {
|
||||
await ext.onStoreDocument({
|
||||
documentName: name,
|
||||
document: liveDoc,
|
||||
context,
|
||||
} as any);
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const gateway: CollaborationGateway = Object.create(
|
||||
CollaborationGateway.prototype,
|
||||
);
|
||||
(gateway as any).hocuspocus = fakeHocuspocus;
|
||||
(gateway as any).persistenceExtension = ext;
|
||||
|
||||
// --- REST/service rename (no live editor) ---
|
||||
// 1) PageService.update writes the NEW title to the column.
|
||||
await pageRepo.updatePage({ title: NEW_TITLE }, PAGE_ID);
|
||||
// 2) PageService.update syncs the Yjs 'title' fragment.
|
||||
await gateway.writePageTitle(PAGE_ID, NEW_TITLE, {
|
||||
user: { id: USER_ID } as any,
|
||||
});
|
||||
|
||||
// Reload the persisted ydoc: the 'title' fragment must now be NEW.
|
||||
// (Pre-fix this is still OLD — writePageTitle did not persist the fragment.)
|
||||
expect(readTitle(row.ydoc)).toBe(NEW_TITLE);
|
||||
|
||||
// --- a later body edit must NOT revert the title ---
|
||||
const editDoc = loadDoc(row.ydoc);
|
||||
const frag = editDoc.getXmlFragment('default');
|
||||
const p = new Y.XmlElement('paragraph');
|
||||
const t = new Y.XmlText();
|
||||
t.insert(0, 'appended');
|
||||
p.insert(0, [t]);
|
||||
frag.insert(frag.length, [p]);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName,
|
||||
document: editDoc,
|
||||
context: { user: { id: USER_ID } },
|
||||
} as any);
|
||||
|
||||
// The body edit was persisted, and the title stayed NEW in BOTH the column
|
||||
// and the persisted ydoc fragment (pre-fix the column reverts to OLD).
|
||||
expect(row.title).toBe(NEW_TITLE);
|
||||
expect(readTitle(row.ydoc)).toBe(NEW_TITLE);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user