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; }