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