feat(collab): persist a deliberate page clear via an intentional-clear signal (#251)
The #248 store-side empty-guard (onStoreDocument) unconditionally refuses to overwrite non-empty persisted content with an empty document, because a momentarily-empty live Y.Doc is indistinguishable from a real clear at the store layer. That correctly blocks glitches/bad-merges, but also blocks a user who genuinely wants to empty a page. This re-introduces a WORKING, narrow, non-spoofable exception (the dead context.intentionalClear hatch #248 removed never had a real channel). Definition of an intentional clear (client, IntentionalClear editor extension): a LOCAL user transaction (docChanged, NOT a remote y-sync change — filtered via isChangeOrigin) that reduces a non-empty doc to the empty single-paragraph shape. This is exactly the select-all + Delete/Backspace keystroke path. Transport (option b — hocuspocus stateless message): on that transition the client sends a `{type:'intentional-clear'}` stateless message. The server (PersistenceExtension.onStateless) records a short-lived (TTL 60s > 45s maxDebounce), single-use "pending clear" flag keyed by the connection's document. The next debounced onStoreDocument consumes it on the empty-guard branch to let that one empty write through. Why this is the right channel and non-spoofable: - Yjs transaction origin/metadata does not survive to the server store; awareness is per-connection and racy. A stateless message ties the signal to a specific clear, survives the debounce, and rides the authenticated connection. - The document is taken from the connection, never the payload, so a client cannot target another page. - The flag is read ONLY on the empty-over-non-empty branch, so the worst a forged signal can do is clear a page the connection may already edit; it can never force or alter a non-empty write. Read-only connections cannot arm it. Every non-empty store drops a pending flag, so "cleared then retyped" leaves nothing usable; the flag is single-use and TTL-bounded. NOTE: #248 is not yet on develop, so the empty-guard block is included here as the foundation this exception extends. If #248 lands first this rebases cleanly (the guard logic is identical; the #251-unique additions are the exception, onStateless, the pending-flag state, and the client extension). Tests: - Server (real transport path, not a hand-poke): onStateless sets the flag with the exact client payload, then the debounced onStoreDocument persists the empty doc; plus single-use consumption, read-only rejection, non-empty-store drops the flag, and the unchanged #248 guard tests (empty-over-non-empty blocked, empty-over-empty allowed). - Client: a real Editor + the actual selectAll+deleteSelection command emits the signal; typing / non-emptying edits / already-empty docs do not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,7 @@ import { countWords } from "alfaaz";
|
||||
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
||||
import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts";
|
||||
import { IntentionalClear } from "@/features/editor/extensions/intentional-clear.ts";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -486,4 +487,10 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
color: randomElement(userColors),
|
||||
},
|
||||
}),
|
||||
// #251 — emit an intentional-clear signal to the server when the user
|
||||
// deliberately empties the page, so the #248 store-side empty-guard lets that
|
||||
// one clear through while still blocking accidental empties.
|
||||
IntentionalClear.configure({
|
||||
provider,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import {
|
||||
IntentionalClear,
|
||||
INTENTIONAL_CLEAR_MESSAGE_TYPE,
|
||||
} from "./intentional-clear";
|
||||
|
||||
/**
|
||||
* #251 — the intentional-clear signal is driven through the REAL editor path:
|
||||
* a fresh Editor with the IntentionalClear extension, a fake provider that
|
||||
* records sendStateless, and the actual select-all + delete command the user's
|
||||
* keystroke runs. No hand-poke of any flag.
|
||||
*/
|
||||
describe("IntentionalClear extension", () => {
|
||||
let sendStateless: ReturnType<typeof vi.fn>;
|
||||
|
||||
const makeEditor = (content: unknown) =>
|
||||
new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
IntentionalClear.configure({
|
||||
// Minimal provider stand-in: only sendStateless is exercised.
|
||||
provider: { sendStateless } as any,
|
||||
}),
|
||||
],
|
||||
content: content as any,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sendStateless = vi.fn();
|
||||
});
|
||||
|
||||
it("emits the clear signal when a user empties a non-empty doc (select-all + delete)", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "hello world" }] },
|
||||
],
|
||||
});
|
||||
|
||||
// The exact command path a select-all + Delete keystroke dispatches.
|
||||
editor.chain().selectAll().deleteSelection().run();
|
||||
|
||||
expect(sendStateless).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(sendStateless.mock.calls[0][0]);
|
||||
expect(payload).toEqual({ type: INTENTIONAL_CLEAR_MESSAGE_TYPE });
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT emit when typing into an empty doc (no non-empty → empty transition)", () => {
|
||||
const editor = makeEditor({ type: "doc", content: [{ type: "paragraph" }] });
|
||||
|
||||
editor.chain().insertContent("typed text").run();
|
||||
|
||||
expect(sendStateless).not.toHaveBeenCalled();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT emit on an edit that leaves the doc non-empty", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "keep me" }] },
|
||||
],
|
||||
});
|
||||
|
||||
editor.chain().insertContent(" more").run();
|
||||
|
||||
expect(sendStateless).not.toHaveBeenCalled();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT emit when the doc was already empty", () => {
|
||||
const editor = makeEditor({ type: "doc", content: [{ type: "paragraph" }] });
|
||||
|
||||
// Selecting all + delete on an already-empty doc is a no-op transition.
|
||||
editor.chain().selectAll().deleteSelection().run();
|
||||
|
||||
expect(sendStateless).not.toHaveBeenCalled();
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
|
||||
/**
|
||||
* Stateless message type sent to the server when a user deliberately clears a
|
||||
* page to empty. Kept in one place so the client emitter and the server
|
||||
* consumer (PersistenceExtension.onStateless) agree on the wire format.
|
||||
*/
|
||||
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = "intentional-clear";
|
||||
|
||||
export interface IntentionalClearOptions {
|
||||
/** The collab provider used to send the stateless clear signal. */
|
||||
provider: HocuspocusProvider | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A "document is empty" check that mirrors the server's `isEmptyParagraphDoc`
|
||||
* (collaboration.util.ts): exactly one top-level paragraph with no inline
|
||||
* content. After a select-all + delete TipTap leaves precisely this shape, so
|
||||
* matching it here keeps the client signal aligned with the server guard that
|
||||
* consumes it.
|
||||
*/
|
||||
function isEmptyParagraphDoc(doc: PMNode): boolean {
|
||||
if (doc.childCount !== 1) return false;
|
||||
const child = doc.firstChild;
|
||||
return (
|
||||
child !== null &&
|
||||
child !== undefined &&
|
||||
child.type.name === "paragraph" &&
|
||||
child.content.size === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #251 — intentional-clear signal.
|
||||
*
|
||||
* The server's #248 store-side empty-guard unconditionally refuses to overwrite
|
||||
* non-empty persisted content with an empty document, because a momentarily
|
||||
* empty live Y.Doc (a glitch, a bad merge, an emptying transclusion) is
|
||||
* indistinguishable from a real clear *at the store layer*. That protection is
|
||||
* correct, but it also blocks a user who genuinely wants to empty the page.
|
||||
*
|
||||
* This extension supplies the missing distinction. It watches LOCAL, user-driven
|
||||
* transactions and, the moment one reduces a non-empty document to the empty
|
||||
* single-paragraph shape, it sends a hocuspocus stateless message to the server.
|
||||
* The server records a short-lived, single-use "intentional clear pending" flag
|
||||
* for this document that the next (debounced) onStoreDocument consumes to let
|
||||
* that one empty write through the guard.
|
||||
*
|
||||
* What counts as an intentional clear (precise definition):
|
||||
* - the transaction actually changed the document (`docChanged`), AND
|
||||
* - it is a LOCAL user edit, not a remote collab application — remote y-sync
|
||||
* transactions are tagged and filtered out via `isChangeOrigin`, so an
|
||||
* emptiness that arrives from another client / a merge never emits a signal,
|
||||
* AND
|
||||
* - the document was non-empty before the transaction and is the empty
|
||||
* single-paragraph doc after it.
|
||||
*
|
||||
* This is exactly the select-all + Delete / Backspace (or any local command that
|
||||
* empties the doc, e.g. clearContent) keystroke path. A transient/programmatic
|
||||
* empty serialization that the server might see on the wire does NOT come with
|
||||
* this signal, so the guard still blocks it.
|
||||
*/
|
||||
export const IntentionalClear = Extension.create<IntentionalClearOptions>({
|
||||
name: "intentionalClear",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
provider: null,
|
||||
};
|
||||
},
|
||||
|
||||
onTransaction({ transaction }) {
|
||||
if (!transaction.docChanged) return;
|
||||
// Only react to local user edits. Remote collaboration steps (and other
|
||||
// y-sync-applied changes) carry the change origin and must never be treated
|
||||
// as an intentional clear, otherwise a remote/merge-induced emptiness would
|
||||
// punch through the server guard.
|
||||
if (isChangeOrigin(transaction)) return;
|
||||
|
||||
const becameEmpty =
|
||||
!isEmptyParagraphDoc(transaction.before) &&
|
||||
isEmptyParagraphDoc(transaction.doc);
|
||||
if (!becameEmpty) return;
|
||||
|
||||
// The server reads the originating document from the connection, so the
|
||||
// payload only needs to declare intent — it cannot target another document.
|
||||
this.options.provider?.sendStateless(
|
||||
JSON.stringify({ type: INTENTIONAL_CLEAR_MESSAGE_TYPE }),
|
||||
);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user