From cd0242ecbcfb8fdf0cbd81ab2b41757908fdecee Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 14:38:55 -0700 Subject: [PATCH] Drop empty paragraphs from canonical document projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty Lexical paragraph exported as an empty MDAST paragraph, which serializes as an extra blank line that parsing discards. Rooms persisted that non-canonical source, and document-summary jobs then failed with "Document source is not canonical." Projection now drops empty paragraphs at every depth, matching the blocks that addressable() and digests() already count. Checkpoints written before this change still store the former blank lines, so restore accepts a stored source whose canonical form matches the projection. Closes #211 Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-duration: 15m converged, estimated none given Assistant-verification: bun test unit passed: 1659 pass, 2 skip, 0 fail; new convert and room regression tests failed before the fix Assistant-verification: bun run test:postgres passed: 59 pass Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run ci passed: dprint, oxlint, tokens, type scale, Impeccable Assistant-verification: bun run e2e passed: 236 Chromium tests Assistant-verification: agent-browser E2E passed: typing text and two Enters stores canonical "…strings.\n" on this branch versus non-canonical "…strings.\n\n\n\n" on main; a document written by main opens, edits, and re-persists canonically on this branch Co-authored-by: Alex Lavaee --- apps/server/src/plan/room.test.ts | 19 ++++++++++++++- apps/server/src/plan/room.ts | 5 +++- packages/dialect/src/convert.test.ts | 35 ++++++++++++++++++++++++++-- packages/dialect/src/convert.ts | 16 ++++++++++++- 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/apps/server/src/plan/room.test.ts b/apps/server/src/plan/room.test.ts index 457f98c2..e610ab20 100644 --- a/apps/server/src/plan/room.test.ts +++ b/apps/server/src/plan/room.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it, spyOn } from "bun:test"; import { createHeadlessEditor } from "@lexical/headless"; import { createYjsBinding, syncLexicalUpdateToYjs, syncYjsChangesToLexical } from "@lexical/yjs"; -import { $getRoot, $isElementNode, $isParagraphNode } from "lexical"; +import { $createParagraphNode, $getRoot, $isElementNode, $isParagraphNode } from "lexical"; import * as Y from "yjs"; import { $importPlan, registry } from "@chopin/dialect"; @@ -271,6 +271,23 @@ describe("recovery", () => { } }); + it("restores a checkpoint stored with an empty paragraph's former blank line", async () => { + let document = await room.create("# Title\n"); + document.editor.update(() => $getRoot().append($createParagraphNode()), { discrete: true }); + await room.settle(); + let checkpoint = Y.encodeStateAsUpdate(document.doc); + try { + let restored = await room.restore(document.epoch, checkpoint, "# Title\n\n", []); + expect(room.project(restored)).toBe("# Title\n"); + restored.doc.destroy(); + await expect(room.restore(document.epoch, checkpoint, "# Other\n", [])).rejects.toThrow( + "stored plan source does not match its Yjs checkpoint", + ); + } finally { + document.doc.destroy(); + } + }); + /** * A rejected batch cannot be undone — Yjs has no such operation — so the * document is rebuilt from the last state that was known to be good. diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index c00773f1..d1673765 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -278,7 +278,10 @@ export async function restore( try { Y.applyUpdate(restored.doc, checkpoint, REMOTE); await settle(); - if (project(restored) !== source) { + // Checkpoints written before empty paragraphs were dropped from projection + // store their former blank lines; the canonical form is the same document. + let projected = project(restored); + if (projected !== source && projected !== serialize(parse(source))) { throw new Error("stored plan source does not match its Yjs checkpoint"); } diff --git a/packages/dialect/src/convert.test.ts b/packages/dialect/src/convert.test.ts index eafab152..72aa6d48 100644 --- a/packages/dialect/src/convert.test.ts +++ b/packages/dialect/src/convert.test.ts @@ -1,14 +1,15 @@ import { describe, expect, it } from "bun:test"; import { createHeadlessEditor } from "@lexical/headless"; import { $isTableNode, TableCellNode, TableRowNode } from "@lexical/table"; -import { $getRoot, $isElementNode } from "lexical"; +import { $createParagraphNode, $getRoot, $isElementNode } from "lexical"; import { $createPlanNodes, exportPlan, importPlan } from "./convert"; import { $isCodeBlockNode } from "./nodes/content"; import { parse } from "./parse"; import { registry } from "./registry"; +import { serialize } from "./serialize"; -import type { LexicalEditor } from "lexical"; +import type { ElementNode, LexicalEditor, RootNode } from "lexical"; const REGISTRY = registry(); @@ -188,4 +189,34 @@ describe("conversion", () => { importPlan(instance, "# Title\n", { registry: REGISTRY }); expect(exportPlan(instance, { registry: REGISTRY })).not.toContain("import "); }); + + it("exports empty paragraphs as canonical source that parsing preserves", () => { + let research = ''; + let cases: [source: string, place: (root: RootNode) => void, expected: string][] = [ + ["Text\n", root => root.append($createParagraphNode()), "Text\n"], + [ + `Text\n\n${research}\n`, + root => root.append($createParagraphNode(), $createParagraphNode()), + `Text\n\n${research}\n`, + ], + [ + "First\n\nSecond\n", + root => root.getFirstChildOrThrow().insertAfter($createParagraphNode()), + "First\n\nSecond\n", + ], + [ + "> Quoted\n", + root => root.getFirstChildOrThrow().append($createParagraphNode()), + "> Quoted\n", + ], + ]; + for (let [source, place, expected] of cases) { + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + instance.update(() => place($getRoot()), { discrete: true }); + let output = exportPlan(instance, { registry: REGISTRY }); + expect(output).toBe(expected); + expect(serialize(parse(output))).toBe(output); + } + }); }); diff --git a/packages/dialect/src/convert.ts b/packages/dialect/src/convert.ts index dc41e9ff..ef87adc9 100644 --- a/packages/dialect/src/convert.ts +++ b/packages/dialect/src/convert.ts @@ -17,7 +17,7 @@ import { assert } from "./validate"; import type { ImportPoint } from "@mdxeditor/editor"; import type { LexicalEditor, LexicalNode, RootNode } from "lexical"; -import type { Root } from "mdast"; +import type { Nodes, Parent, Root } from "mdast"; import type { Registry } from "./registry"; export type ConvertOptions = { @@ -92,6 +92,19 @@ export function $createPlanNodes(tree: Root, options: ConvertOptions = {}): Lexi return nodes; } +/** + * Parsing never yields an empty paragraph, so exporting one would persist + * source that does not survive its own round trip. + */ +function dropEmptyParagraphs(node: Nodes): void { + let parent = node as Parent; + if (!parent.children) return; + parent.children = parent.children.filter(child => + child.type !== "paragraph" || child.children.length > 0 + ); + for (let child of parent.children) dropEmptyParagraphs(child); +} + /** Project the current editor content to MDAST. Must run inside a read or update. */ export function $exportPlanTree(options: ConvertOptions = {}): Root { let reg = options.registry ?? buildRegistry(); @@ -106,6 +119,7 @@ export function $exportPlanTree(options: ConvertOptions = {}): Root { }); normalizeMarks(tree); + dropEmptyParagraphs(tree); return tree; }