From 97eef22bc3d54fa3b5708f6777d7161899216bde Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Mon, 29 Jun 2026 21:14:36 +0300 Subject: [PATCH] test(#251): cover the change-origin guard; add CHANGELOG entry (F1,F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1: add a test that empties a non-empty doc via a change-origin transaction (ySyncPluginKey meta, the shape y-tiptap sets for remote/merge updates) and asserts the intentional-clear signal is NOT emitted — pinning the isChangeOrigin early-return that keeps remote emptiness from punching through the #248 server guard. The 4 existing tests use local transactions and never exercised that true-path (verified: removing the guard fails only this test). F2: record the #248 empty-overwrite guard and the #251 intentional-clear in the CHANGELOG [Unreleased] Fixed section. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 +++++ .../extensions/intentional-clear.test.ts | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8dfa172..c0154935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 "This address is in use. Saving will move it to this page." — and keeps Save enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED` → "Move custom address?") is discoverable instead of reading as terminal. (#227) +- **A non-empty page can no longer be silently lost to a momentarily-empty live + document.** The server's persistence guard now refuses to overwrite non-empty + persisted content with an empty live Y.Doc — a transient emptiness from a + glitch, a bad merge, or an emptying transclusion no longer wipes the saved + page. A *deliberate* clear still works: a select-all + Delete in the editor + emits a single-use "intentional clear" signal that lets exactly that one empty + write through the guard, so genuinely emptying a page is persisted while + accidental empties are blocked. (#248, #251) ### Security diff --git a/apps/client/src/features/editor/extensions/intentional-clear.test.ts b/apps/client/src/features/editor/extensions/intentional-clear.test.ts index ad467327..c0676a86 100644 --- a/apps/client/src/features/editor/extensions/intentional-clear.test.ts +++ b/apps/client/src/features/editor/extensions/intentional-clear.test.ts @@ -3,6 +3,7 @@ import { Editor } from "@tiptap/core"; import { Document } from "@tiptap/extension-document"; import { Paragraph } from "@tiptap/extension-paragraph"; import { Text } from "@tiptap/extension-text"; +import { ySyncPluginKey } from "@tiptap/y-tiptap"; import { IntentionalClear, INTENTIONAL_CLEAR_MESSAGE_TYPE, @@ -76,6 +77,37 @@ describe("IntentionalClear extension", () => { editor.destroy(); }); + it("does NOT emit when a REMOTE/merge (change-origin) transaction empties the doc", () => { + // This pins the CENTRAL #248 protection: only a LOCAL user edit may emit the + // intentional-clear signal. An emptiness arriving from another client, a bad + // merge, or an emptied transclusion is applied as a y-sync transaction tagged + // with the ySyncPluginKey meta, which `isChangeOrigin` detects. The extension + // must early-return on it and NOT punch the empty write through the server + // guard. + const editor = makeEditor({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "remote content" }] }, + ], + }); + + // Build a transaction that empties the non-empty doc and tag it exactly the + // way y-tiptap tags a remote y-sync update: `tr.setMeta(ySyncPluginKey, + // { isChangeOrigin: true })` (see @tiptap/y-tiptap sync-plugin). This makes + // the real `isChangeOrigin(tr)` predicate return true — not a stand-in. + const { state } = editor; + const tr = state.tr + .delete(0, state.doc.content.size) + .setMeta(ySyncPluginKey, { isChangeOrigin: true }); + editor.view.dispatch(tr); + + // The transaction really emptied the doc (became the single empty paragraph)… + expect(editor.state.doc.textContent).toBe(""); + // …yet because it is change-origin, no signal is emitted. + expect(sendStateless).not.toHaveBeenCalled(); + editor.destroy(); + }); + it("does NOT emit when the doc was already empty", () => { const editor = makeEditor({ type: "doc", content: [{ type: "paragraph" }] });