Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion apps/server/src/plan/room.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/plan/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down
35 changes: 33 additions & 2 deletions packages/dialect/src/convert.test.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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 = '<Research id="d5a1b471-4788-48e3-9518-62ef6777d07e" />';
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<ElementNode>().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);
}
});
});
16 changes: 15 additions & 1 deletion packages/dialect/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
Expand All @@ -106,6 +119,7 @@ export function $exportPlanTree(options: ConvertOptions = {}): Root {
});

normalizeMarks(tree);
dropEmptyParagraphs(tree);
return tree;
}

Expand Down
Loading