From ea5528ea871c5d3a00f26a5a282c964f6f3d2b9a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 17:33:07 -0500 Subject: [PATCH] Test(editor): a parse that ran out of time reads the rest of the note again After 2.69.1, the note grammar's habit of reading text again looked like waste worth removing. noteMarkdown's outer tree always spans the whole note, so when a state update's 20 ms parse slice runs out, CodeMirror drops the older tree past the point the parse reached, and the parse that resumes reads that text again instead of reusing its blocks. Reusing those blocks was tried, and it is not waste. Lezer's markdown reuse can keep a block whose meaning changed with text outside it: an indented code block whose indent was deleted along with the frontmatter, a paragraph whose end moved when a table row under it broke. The grammar happens to read again exactly where that bites. Checked against a fresh parse 50,000 times over 100,000 random edits, the grammar as it is was stale 3 times, with that reuse 29 times, and plain markdown() 205 times. The time it would save is small: a typed character parses in about 4 ms in an 800 KB note, inside its slice, so a cut needs a slow machine or a pause first, and reading the rest after one costs what opening the note costs, in background slices. So the parser does not change. The header of cm-markdown-language.ts says why both full reads (after a cut, and when the body start moves) stay, and that syntaxTree(state).length is always the whole note, so code that needs to know how far the parse got asks ensureSyntaxTree or syntaxTreeAvailable. A new test pins the read after a cut, next to the one that pins the full parse when the closing fence is typed; both fail on the reusing version. --- .../src/lib/cm-markdown-language.test.ts | 36 +++++++++++++++++-- .../app-core/src/lib/cm-markdown-language.ts | 27 +++++++++++--- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/app-core/src/lib/cm-markdown-language.test.ts b/packages/app-core/src/lib/cm-markdown-language.test.ts index d7fb492c..26225c48 100644 --- a/packages/app-core/src/lib/cm-markdown-language.test.ts +++ b/packages/app-core/src/lib/cm-markdown-language.test.ts @@ -1,5 +1,5 @@ import { markdownLanguage } from '@codemirror/lang-markdown' -import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' +import { ensureSyntaxTree, syntaxTree, syntaxTreeAvailable } from '@codemirror/language' import { EditorState } from '@codemirror/state' import type { SyntaxNode, Tree } from '@lezer/common' import { describe, expect, it, vi } from 'vitest' @@ -261,8 +261,10 @@ describe('noteMarkdown: incremental parsing', () => { // This one transition reparses the body in full: the Body node moved from // position 0 to the closing fence, and parseMixed (through @lezer/common // 1.5.2) loses track of the old mount when the mounted node is the first - // child covering the new start. It costs one parse, the same as opening - // the note, and the very next edit is incremental again. + // child covering the new start. Keep it that way: blocks reused across a + // moved start can keep a meaning the move changed (the header of + // cm-markdown-language.ts has the numbers). It costs one parse, the same + // as opening the note, and the very next edit is incremental again. expect(blockTreeAt(after, probeAt + 3)).not.toBe(probeTree) const settled = blockTreeAt(after, probeAt + 3) const editAt = next.doc.toString().indexOf('Paragraph 3 ') @@ -279,6 +281,34 @@ describe('noteMarkdown: incremental parsing', () => { expect(names(after)).not.toContain('Frontmatter') expect(names(after)).toContain('HorizontalRule') }) + + it('reads the text past a parse that ran out of time again', () => { + // When an update's 20 ms slice runs out, the whole-note outer tree makes + // CodeMirror drop the older tree past that point, so the parse that + // resumes reads the rest again instead of reusing blocks parsed before + // the edit. That is the safe outcome (the header of + // cm-markdown-language.ts says why); a change that starts reusing them + // should be a decision, not an accident. + const doc = `---\ntitle: x\n---\n${BODY}` + const { state, tree } = parse(doc) + const probeAt = doc.indexOf(PROBE) + const probeTree = blockTreeAt(tree, probeAt) + + const editAt = doc.indexOf('Paragraph 3 ') + // A clock that moves 30 ms per read ends the slice after one step. + let now = Date.now() + const clock = vi.spyOn(Date, 'now').mockImplementation(() => (now += 30)) + let next: EditorState + try { + next = state.update({ changes: { from: editAt, to: editAt, insert: 'Edited ' } }).state + } finally { + clock.mockRestore() + } + expect(syntaxTreeAvailable(next)).toBe(false) + const after = ensureSyntaxTree(next, next.doc.length, 5000)! + expect(blockTreeAt(after, probeAt + 'Edited '.length)).not.toBe(probeTree) + expect(nodes(after)).toEqual(nodes(parse(next.doc.toString()).tree)) + }) }) describe('a lone dash under a line of text (#898)', () => { diff --git a/packages/app-core/src/lib/cm-markdown-language.ts b/packages/app-core/src/lib/cm-markdown-language.ts index 55d0199d..de31217f 100644 --- a/packages/app-core/src/lib/cm-markdown-language.ts +++ b/packages/app-core/src/lib/cm-markdown-language.ts @@ -27,10 +27,29 @@ * through `parseMixed`. The mount hands the previous markdown tree to the * inner parser as fragments, so body parsing stays incremental as long as the * body keeps its start: edits anywhere in the body or inside the frontmatter - * reuse the untouched blocks. The two moments the body start moves (the - * closing fence typed for the first time, or broken) cost one full body - * parse, the same as opening the note; parseMixed cannot find the old mount - * across that move. The test file pins both behaviours. + * reuse the untouched blocks. + * + * Two cases read again text that a plain markdown parser would reuse, and + * both are kept on purpose. When the body start moves (the closing fence + * typed for the first time, or broken), parseMixed cannot find the old mount + * and the body is parsed in full, the same as opening the note. And the outer + * tree always spans the whole note, so when a parse runs out of its 20 ms + * slice, CodeMirror drops the older tree past that point and the parse that + * resumes reads the rest again. Reusing the older blocks in those two cases + * makes the tree go stale more often, because Lezer's markdown reuse can keep + * a block whose meaning changed with text outside it: an indented code block + * whose indent was deleted along with the frontmatter, a paragraph whose end + * moved when a table row under it broke. Checked against a fresh parse 50,000 + * times over 100,000 random edits, this grammar was stale 3 times, the same + * grammar with that reuse 29 times, and plain `markdown()` 205 times. The + * cost is small: a typed character parses well inside its slice even in an + * 800 KB note, and reading the rest after a cut costs what opening the note + * costs, in background slices. The test file pins both behaviours. + * + * One consequence: `syntaxTree(state).length` is always the whole note, so it + * does not say how far the parse got. Ask `ensureSyntaxTree` or + * `syntaxTreeAvailable` instead, as `headingAbove` in table-to-database.ts + * does. * * The frontmatter content is deliberately left untokenized: with Live Preview * off a note reads as its raw text (#616), and the card styles it when on.