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.