diff --git a/shared/editor/lib/markdown/serializer.ts b/shared/editor/lib/markdown/serializer.ts index 5eec8aba7a..0d3ce72093 100644 --- a/shared/editor/lib/markdown/serializer.ts +++ b/shared/editor/lib/markdown/serializer.ts @@ -83,6 +83,10 @@ export class MarkdownSerializer { } } +// Tracks whether we have already warned about direct assignment to `out`, +// so a hot loop cannot flood the console. +let warnedDirectOutAssignment = false; + export interface BlockMapEntry { /** Start position in the ProseMirror document (offset within parent content). */ pmFrom: number; @@ -103,14 +107,36 @@ export class MarkdownSerializerState { inTightList = false; closed = false; delim = ""; - out = ""; + _out = ""; + lastChar = ""; options: Options; blockMap = null; + // The serialized output so far. Use `append` to add to it — direct + // assignment still works but reads the last character back out of the + // string, which forces V8 to flatten the internal rope and is slow when + // done repeatedly on large documents. + get out() { + return this._out; + } + + set out(value) { + if (!warnedDirectOutAssignment) { + warnedDirectOutAssignment = true; + // eslint-disable-next-line no-console + console.warn( + "MarkdownSerializerState: assigning `out` directly is slow on large documents, use append() instead." + ); + } + this._out = value; + this.lastChar = value === "" ? "" : value.charAt(value.length - 1); + } + constructor(nodes, marks, options) { this.nodes = nodes; this.marks = marks; - this.delim = this.out = ""; + this.delim = this._out = ""; + this.lastChar = ""; this.closed = false; this.inTightList = false; this.inTable = false; @@ -126,10 +152,21 @@ export class MarkdownSerializerState { } } + // :: (string) + // Append a string to the output, tracking `lastChar` without reading + // characters back out of `out` — that would force V8 to flatten the + // internal rope, which is quadratic on large documents. + append(content) { + if (content) { + this._out += content; + this.lastChar = content.charAt(content.length - 1); + } + } + flushClose(size) { if (this.closed) { if (!this.atBlank()) { - this.out += "\n"; + this.append("\n"); } if (size === null || size === undefined) { size = 2; @@ -141,7 +178,7 @@ export class MarkdownSerializerState { delimMin = delimMin.slice(0, delimMin.length - trim[0].length); } for (let i = 1; i < size; i++) { - this.out += delimMin + "\n"; + this.append(delimMin + "\n"); } } this.closed = false; @@ -163,14 +200,14 @@ export class MarkdownSerializerState { } atBlank() { - return /(^|\n)$/.test(this.out); + return this.lastChar === "" || this.lastChar === "\n"; } // :: () // Ensure the current content ends with a newline. ensureNewLine() { if (!this.atBlank()) { - this.out += "\n"; + this.append("\n"); } } @@ -181,10 +218,10 @@ export class MarkdownSerializerState { write(content) { this.flushClose(); if (this.delim && this.atBlank()) { - this.out += this.delim; + this.append(this.delim); } if (content) { - this.out += content; + this.append(content); } } @@ -202,9 +239,11 @@ export class MarkdownSerializerState { for (let i = 0; i < lines.length; i++) { const startOfLine = this.atBlank() || this.closed; this.write(); - this.out += escape !== false ? this.esc(lines[i], startOfLine) : lines[i]; + this.append( + escape !== false ? this.esc(lines[i], startOfLine) : lines[i] + ); if (i !== lines.length - 1) { - this.out += "\n"; + this.append("\n"); } } } @@ -389,9 +428,9 @@ export class MarkdownSerializerState { if (this.inTable) { node.forEach((child, _, i) => { if (i > 0) { - this.out += "
"; + this.append("
"); } - this.out += firstDelim(i).trim() + " "; + this.append(firstDelim(i).trim() + " "); this.render(child, node, i); }); return; @@ -438,12 +477,12 @@ export class MarkdownSerializerState { }); // Ensure there is an empty newline above all tables - this.out += "\n"; + this.append("\n"); // Render rows node.forEach((row, _, i) => { row.forEach((cell, _, j) => { - this.out += j === 0 ? "| " : " | "; + this.append(j === 0 ? "| " : " | "); const startPos = this.out.length; @@ -463,26 +502,26 @@ export class MarkdownSerializerState { // Pad to column width const contentLength = this.out.length - startPos; const padding = Math.max(0, columnWidths[j] - contentLength); - this.out += " ".repeat(padding); + this.append(" ".repeat(padding)); }); - this.out += " |\n"; + this.append(" |\n"); // Header separator after first row if (i === 0) { headerRow.forEach((cell, _, j) => { const width = columnWidths[j]; if (cell.attrs.alignment === "center") { - this.out += "|:" + "-".repeat(width) + ":"; + this.append("|:" + "-".repeat(width) + ":"); } else if (cell.attrs.alignment === "left") { - this.out += "|:" + "-".repeat(width + 1); + this.append("|:" + "-".repeat(width + 1)); } else if (cell.attrs.alignment === "right") { - this.out += "|" + "-".repeat(width + 1) + ":"; + this.append("|" + "-".repeat(width + 1) + ":"); } else { - this.out += "|" + "-".repeat(width + 2); + this.append("|" + "-".repeat(width + 2)); } }); - this.out += "|\n"; + this.append("|\n"); } }); diff --git a/shared/editor/nodes/CheckboxItem.ts b/shared/editor/nodes/CheckboxItem.ts index 32402cefda..4390d32f90 100644 --- a/shared/editor/nodes/CheckboxItem.ts +++ b/shared/editor/nodes/CheckboxItem.ts @@ -116,11 +116,11 @@ export default class CheckboxItem extends Node { } toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { - state.out += node.attrs.checked ? "[x] " : "[ ] "; + state.append(node.attrs.checked ? "[x] " : "[ ] "); if (state.inTable) { node.forEach((block, _, i) => { if (i > 0) { - state.out += " "; + state.append(" "); } state.renderInline(block); }); diff --git a/shared/editor/nodes/ListItem.ts b/shared/editor/nodes/ListItem.ts index 6be1f6cbb5..a21bf17e72 100644 --- a/shared/editor/nodes/ListItem.ts +++ b/shared/editor/nodes/ListItem.ts @@ -291,7 +291,7 @@ export default class ListItem extends Node { if (state.inTable) { node.forEach((block, _, i) => { if (i > 0) { - state.out += " "; + state.append(" "); } state.renderInline(block); });