From c95025988cff6714e53117b67ae62b72e6aba52c Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 20 Oct 2026 12:25:07 -0500 Subject: [PATCH 01/20] Fix(editor): eight more decorations redraw when a cut parse finishes Follow-up to 10597fe9. Eight more live-preview and editor plugins read the syntax tree but rebuilt only on an edit, a scroll and, for some, a selection change: the code block font and language label, hashtags, live template tokens, heading arrows, highlights, list indent and wikilinks. CodeMirror parses under a time budget and finishes a cut parse in a later update that changes nothing else, so whatever they drew from the partial tree stayed until the next edit, scroll or cursor move. In the built app, opening a 6 MB note and pressing G at once drew the last screen before the parse reached it, and it stayed that way after the parse finished about 0.9 s later: code in the body font with no language label, list lines with no hanging indent, heading arrows on `# comment` lines in a fence, and hashtag pills, live tokens, highlights and wikilinks inside code. Each plugin now also rebuilds when syntaxTree(update.startState) differs from syntaxTree(update.state), as cm-live-preview and cm-wysiwyg-blocks do. Each gets a test that changes only the tree (the markdown language swapped in through a compartment) and expects its decorations: all eight fail without this change and pass with it. Cost: the seven rebuilds bound to the viewport take under 0.5 ms each. The heading arrows' rebuild scans to the end of each visible heading's section, about 37 ms at the top of a 3 MB note, which every cursor move and keystroke there already pays; the guard adds one more per finished cut parse (the first parse-only update after opening that note went from about 73 ms to 112 ms). perf:desktop-runtime on 5,000 notes is unchanged: note open 34.8 vs 36.6 ms, switch p50 24.4 vs 24.5 ms. Not in this change: calloutFolding() draws nothing; it folds `> [!type]-` callouts once per opened note from ensureSyntaxTree with a 200 ms budget, so a collapsed callout past what that parses (about 2 MB on an M-series Mac) opens expanded. Folding again on a tree change would close callouts the reader opened, so it needs a fix of its own. app-core 3,337 tests, typecheck clean. --- .../src/lib/cm-code-block-flair.test.ts | 31 ++++++++++++++++ .../app-core/src/lib/cm-code-block-flair.ts | 8 +++- .../src/lib/cm-code-block-font.test.ts | 33 +++++++++++++++++ .../app-core/src/lib/cm-code-block-font.ts | 8 +++- packages/app-core/src/lib/cm-hashtags.test.ts | 25 ++++++++++++- packages/app-core/src/lib/cm-hashtags.ts | 8 +++- .../app-core/src/lib/cm-heading-fold.test.ts | 29 ++++++++++++++- packages/app-core/src/lib/cm-heading-fold.ts | 3 ++ .../app-core/src/lib/cm-highlight.test.ts | 37 ++++++++++++++++++- packages/app-core/src/lib/cm-highlight.ts | 5 ++- .../src/lib/cm-live-template-tokens.test.ts | 28 +++++++++++++- .../src/lib/cm-live-template-tokens.ts | 10 ++++- .../src/lib/cm-markdown-list-indent.test.ts | 32 +++++++++++++++- .../src/lib/cm-markdown-list-indent.ts | 5 ++- .../src/lib/cm-wikilink-render.test.ts | 30 ++++++++++++++- .../app-core/src/lib/cm-wikilink-render.ts | 10 ++++- 16 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 packages/app-core/src/lib/cm-code-block-flair.test.ts create mode 100644 packages/app-core/src/lib/cm-code-block-font.test.ts diff --git a/packages/app-core/src/lib/cm-code-block-flair.test.ts b/packages/app-core/src/lib/cm-code-block-flair.test.ts new file mode 100644 index 000000000..bcb361924 --- /dev/null +++ b/packages/app-core/src/lib/cm-code-block-flair.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { Compartment, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { describe, expect, it } from 'vitest' +import { codeBlockFlairPlugin } from './cm-code-block-flair' + +describe('codeBlockFlairPlugin', () => { + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = 'Intro\n\n```js\nconst a = 1\n```\n\nEnd.' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [language.of([]), codeBlockFlairPlugin] }) + }) + expect(view.dom.querySelector('.cm-code-flair')).toBeNull() + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + const flairs = Array.from(view.dom.querySelectorAll('.cm-code-flair')) + expect(flairs.map((flair) => flair.textContent)).toEqual(['js']) + view.destroy() + parent.remove() + }) +}) diff --git a/packages/app-core/src/lib/cm-code-block-flair.ts b/packages/app-core/src/lib/cm-code-block-flair.ts index ed63027e2..925f68a4c 100644 --- a/packages/app-core/src/lib/cm-code-block-flair.ts +++ b/packages/app-core/src/lib/cm-code-block-flair.ts @@ -140,7 +140,13 @@ export const codeBlockFlairPlugin = ViewPlugin.fromClass( } update(update: ViewUpdate): void { - if (update.docChanged || update.viewportChanged) { + if ( + update.docChanged || + update.viewportChanged || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { this.decorations = buildDecorations(update.view) } } diff --git a/packages/app-core/src/lib/cm-code-block-font.test.ts b/packages/app-core/src/lib/cm-code-block-font.test.ts new file mode 100644 index 000000000..2ee6c8371 --- /dev/null +++ b/packages/app-core/src/lib/cm-code-block-font.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { Compartment, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { describe, expect, it } from 'vitest' +import { codeBlockFontPlugin } from './cm-code-block-font' + +describe('codeBlockFontPlugin', () => { + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = 'Intro\n\n```js\nconst a = 1\n```\n\nEnd.' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [language.of([]), codeBlockFontPlugin] }) + }) + expect(view.dom.querySelector('.cm-code-block-line')).toBeNull() + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + const lines = Array.from(view.dom.querySelectorAll('.cm-code-block-line')) + expect(lines.map((line) => line.textContent)).toEqual(['```js', 'const a = 1', '```']) + expect(view.dom.querySelector('.cm-code-block-begin')?.textContent).toBe('```js') + expect(view.dom.querySelector('.cm-code-block-end')?.textContent).toBe('```') + view.destroy() + parent.remove() + }) +}) diff --git a/packages/app-core/src/lib/cm-code-block-font.ts b/packages/app-core/src/lib/cm-code-block-font.ts index a5657114a..023bd0c24 100644 --- a/packages/app-core/src/lib/cm-code-block-font.ts +++ b/packages/app-core/src/lib/cm-code-block-font.ts @@ -78,7 +78,13 @@ export const codeBlockFontPlugin = ViewPlugin.fromClass( } update(update: ViewUpdate): void { - if (update.docChanged || update.viewportChanged) { + if ( + update.docChanged || + update.viewportChanged || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { this.decorations = buildDecorations(update.view) } } diff --git a/packages/app-core/src/lib/cm-hashtags.test.ts b/packages/app-core/src/lib/cm-hashtags.test.ts index 2e3e1ae0a..fe3e30537 100644 --- a/packages/app-core/src/lib/cm-hashtags.test.ts +++ b/packages/app-core/src/lib/cm-hashtags.test.ts @@ -2,7 +2,7 @@ import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { forceParsing } from '@codemirror/language' -import { EditorState } from '@codemirror/state' +import { Compartment, EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { describe, expect, it } from 'vitest' import { hashtagExtension } from './cm-hashtags' @@ -45,4 +45,27 @@ describe('hashtagPlugin', () => { expect(tags).toEqual(['#real']) view.destroy() }) + + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = '```c\n#include \n```\n\n#real' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [language.of([]), hashtagExtension] }) + }) + const tags = (): Array => + Array.from(view.dom.querySelectorAll('.cm-hashtag')).map((e) => e.textContent) + // No tree yet, so nothing says the fence is code. + expect(tags()).toEqual(['#include', '#real']) + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + expect(tags()).toEqual(['#real']) + view.destroy() + parent.remove() + }) }) diff --git a/packages/app-core/src/lib/cm-hashtags.ts b/packages/app-core/src/lib/cm-hashtags.ts index dc8f5bb2d..7cf8cc616 100644 --- a/packages/app-core/src/lib/cm-hashtags.ts +++ b/packages/app-core/src/lib/cm-hashtags.ts @@ -79,7 +79,13 @@ const hashtagPlugin = ViewPlugin.fromClass( this.decorations = buildDecorations(view) } update(update: ViewUpdate): void { - if (update.docChanged || update.viewportChanged) { + if ( + update.docChanged || + update.viewportChanged || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { this.decorations = buildDecorations(update.view) } } diff --git a/packages/app-core/src/lib/cm-heading-fold.test.ts b/packages/app-core/src/lib/cm-heading-fold.test.ts index 1427cdfb9..34ee925ac 100644 --- a/packages/app-core/src/lib/cm-heading-fold.test.ts +++ b/packages/app-core/src/lib/cm-heading-fold.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { describe, it, expect } from 'vitest' -import { EditorState } from '@codemirror/state' -import { foldedRanges, foldService, unfoldEffect } from '@codemirror/language' +import { Compartment, EditorState } from '@codemirror/state' +import { foldedRanges, foldService, forceParsing, unfoldEffect } from '@codemirror/language' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { EditorView } from '@codemirror/view' import { foldAllOutline, foldAtCursor, headingFolding, unfoldAtCursor } from './cm-heading-fold' @@ -106,6 +106,31 @@ describe('heading folding', () => { view.destroy() parent.remove() }) + + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = '```bash\n# a comment\n```\n\n# Real\n\nbody' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [language.of([]), headingFolding()] }) + }) + const headingLines = (): Array => + Array.from(parent.querySelectorAll('.cm-heading-line')).map((line) => line.textContent) + // No tree yet, so nothing says the fence is code. + expect(headingLines()).toHaveLength(2) + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + expect(headingLines()).toHaveLength(1) + expect(headingLines()[0]).toContain('# Real') + + view.destroy() + parent.remove() + }) }) describe('list item folding (#848)', () => { diff --git a/packages/app-core/src/lib/cm-heading-fold.ts b/packages/app-core/src/lib/cm-heading-fold.ts index 2d1d5c7f7..e5898fd46 100644 --- a/packages/app-core/src/lib/cm-heading-fold.ts +++ b/packages/app-core/src/lib/cm-heading-fold.ts @@ -426,6 +426,9 @@ function headingArrowPlugin(showLevelLabels: boolean): Extension { update.docChanged || update.viewportChanged || update.selectionSet || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) || update.transactions.some((tr) => tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect)) ) diff --git a/packages/app-core/src/lib/cm-highlight.test.ts b/packages/app-core/src/lib/cm-highlight.test.ts index 8657e87f8..4717ab57a 100644 --- a/packages/app-core/src/lib/cm-highlight.test.ts +++ b/packages/app-core/src/lib/cm-highlight.test.ts @@ -1,9 +1,17 @@ // @vitest-environment jsdom -import { EditorSelection, EditorState } from '@codemirror/state' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { Compartment, EditorSelection, EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { afterEach, describe, expect, it } from 'vitest' -import { applyHighlight, HIGHLIGHT_COLORS, unwrapHighlight, wrapHighlight } from './cm-highlight' +import { + applyHighlight, + HIGHLIGHT_COLORS, + highlightExtension, + unwrapHighlight, + wrapHighlight +} from './cm-highlight' const views: EditorView[] = [] function mount(doc: string, from: number, to: number): EditorView { @@ -84,3 +92,28 @@ describe('applyHighlight (#416)', () => { expect(view.state.doc.toString()).toBe('the word here') }) }) + +describe('highlightExtension', () => { + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = '```\n==code==\n```\n\nSome ==real== text' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [language.of([]), highlightExtension] }) + }) + views.push(view) + const tinted = (): Array => + Array.from(view.dom.querySelectorAll('.cm-hl')).map((e) => e.textContent) + // No tree yet, so nothing says the fence is code. + expect(tinted()).toEqual(['code', 'real']) + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + expect(tinted()).toEqual(['real']) + expect(view.dom.textContent).toContain('==code==') + }) +}) diff --git a/packages/app-core/src/lib/cm-highlight.ts b/packages/app-core/src/lib/cm-highlight.ts index f91431b10..50ba12795 100644 --- a/packages/app-core/src/lib/cm-highlight.ts +++ b/packages/app-core/src/lib/cm-highlight.ts @@ -246,7 +246,10 @@ const highlightPlugin = ViewPlugin.fromClass( update.docChanged || update.selectionSet || update.viewportChanged || - update.focusChanged + update.focusChanged || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) ) { const built = buildDecorations(update.view) this.decorations = built.all diff --git a/packages/app-core/src/lib/cm-live-template-tokens.test.ts b/packages/app-core/src/lib/cm-live-template-tokens.test.ts index 9de5a2420..753e46899 100644 --- a/packages/app-core/src/lib/cm-live-template-tokens.test.ts +++ b/packages/app-core/src/lib/cm-live-template-tokens.test.ts @@ -2,7 +2,7 @@ import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { forceParsing } from '@codemirror/language' -import { EditorState } from '@codemirror/state' +import { Compartment, EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { afterEach, describe, expect, it, vi } from 'vitest' import { liveTemplateTokenExtension } from './cm-live-template-tokens' @@ -81,4 +81,30 @@ describe('liveTemplateTokenExtension (#784)', () => { for (const fn of listeners) fn(storeState, prev) expect(view.dom.querySelector('.cm-live-token')?.textContent).toBe('2026-08-25') }) + + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + storeState.notes = [{ path: 'inbox/Evergreen.md', updatedAt: savedAt }] + const language = new Compartment() + const doc = '```\n{{modified_date}}\n```\n\nUpdated: {{modified_date}}' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + selection: { anchor: 0 }, + extensions: [language.of([]), liveTemplateTokenExtension] + }) + }) + views.push(view) + // No tree yet, so nothing says the fence is code. + expect(view.dom.querySelectorAll('.cm-live-token')).toHaveLength(2) + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + expect(view.dom.querySelectorAll('.cm-live-token')).toHaveLength(1) + expect(view.dom.textContent).toContain('```{{modified_date}}```') + }) }) diff --git a/packages/app-core/src/lib/cm-live-template-tokens.ts b/packages/app-core/src/lib/cm-live-template-tokens.ts index c03fd1c2b..e0aafcbbe 100644 --- a/packages/app-core/src/lib/cm-live-template-tokens.ts +++ b/packages/app-core/src/lib/cm-live-template-tokens.ts @@ -122,7 +122,15 @@ const liveTemplateTokenPlugin = ViewPlugin.fromClass( const refreshed = update.transactions.some((tr) => tr.effects.some((effect) => effect.is(refreshLiveTokensEffect)) ) - if (refreshed || update.docChanged || update.selectionSet || update.viewportChanged) { + if ( + refreshed || + update.docChanged || + update.selectionSet || + update.viewportChanged || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { this.decorations = buildDecorations(update.view) } } diff --git a/packages/app-core/src/lib/cm-markdown-list-indent.test.ts b/packages/app-core/src/lib/cm-markdown-list-indent.test.ts index a54cbcf02..5c44a9ca6 100644 --- a/packages/app-core/src/lib/cm-markdown-list-indent.test.ts +++ b/packages/app-core/src/lib/cm-markdown-list-indent.test.ts @@ -1,9 +1,16 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { Compartment, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' import { describe, expect, it } from 'vitest' import { listGuideStyle, listIndentPadCh, listPrefixCells, - markdownListHangingIndentCh + markdownListHangingIndentCh, + markdownListIndentPlugin } from './cm-markdown-list-indent' describe('markdownListHangingIndentCh', () => { @@ -98,3 +105,26 @@ describe('listPrefixCells (#952)', () => { expect(listPrefixCells('> - nested', 4)).toBeNull() }) }) + +describe('markdownListIndentPlugin', () => { + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = '- one\n - two\n\nplain' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [language.of([]), markdownListIndentPlugin] }) + }) + expect(view.dom.querySelector('.cm-markdown-list-line')).toBeNull() + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + const lines = Array.from(view.dom.querySelectorAll('.cm-markdown-list-line')) + expect(lines.map((line) => line.textContent)).toEqual(['- one', ' - two']) + view.destroy() + parent.remove() + }) +}) diff --git a/packages/app-core/src/lib/cm-markdown-list-indent.ts b/packages/app-core/src/lib/cm-markdown-list-indent.ts index a7c5e49d4..535234d78 100644 --- a/packages/app-core/src/lib/cm-markdown-list-indent.ts +++ b/packages/app-core/src/lib/cm-markdown-list-indent.ts @@ -222,7 +222,10 @@ export const markdownListIndentPlugin = ViewPlugin.fromClass( if ( update.docChanged || update.viewportChanged || - update.state.facet(listIndentConfig) !== update.startState.facet(listIndentConfig) + update.state.facet(listIndentConfig) !== update.startState.facet(listIndentConfig) || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) ) { this.decorations = computeDecorations(update.view) } diff --git a/packages/app-core/src/lib/cm-wikilink-render.test.ts b/packages/app-core/src/lib/cm-wikilink-render.test.ts index c83637187..27117a665 100644 --- a/packages/app-core/src/lib/cm-wikilink-render.test.ts +++ b/packages/app-core/src/lib/cm-wikilink-render.test.ts @@ -2,7 +2,7 @@ import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { forceParsing } from '@codemirror/language' -import { EditorState } from '@codemirror/state' +import { Compartment, EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AssetMeta, NoteMeta } from '@shared/ipc' @@ -85,6 +85,34 @@ describe('wikilinkRenderExtension', () => { expect(links.map((e) => e.textContent)).toEqual(['Foo']) view.destroy() }) + + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = '```\n[[Foo]]\n```\n\nsee [[Bar]] end' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + selection: { anchor: doc.length }, + extensions: [language.of([]), wikilinkRenderExtension] + }) + }) + const links = (): Array => + Array.from(view.dom.querySelectorAll('.cm-wikilink')).map((e) => e.textContent) + // No tree yet, so nothing says the fence is code. + expect(links()).toEqual(['Foo', 'Bar']) + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + expect(links()).toEqual(['Bar']) + expect(view.dom.textContent).toContain('[[Foo]]') + view.destroy() + parent.remove() + }) }) // #768: a wikilink at a note that does not exist yet is drawn apart from a live diff --git a/packages/app-core/src/lib/cm-wikilink-render.ts b/packages/app-core/src/lib/cm-wikilink-render.ts index 15ed75189..927dc2819 100644 --- a/packages/app-core/src/lib/cm-wikilink-render.ts +++ b/packages/app-core/src/lib/cm-wikilink-render.ts @@ -223,7 +223,15 @@ const wikilinkRenderPlugin = ViewPlugin.fromClass( const refreshed = update.transactions.some((tr) => tr.effects.some((effect) => effect.is(refreshWikilinksEffect)) ) - if (refreshed || update.docChanged || update.selectionSet || update.viewportChanged) { + if ( + refreshed || + update.docChanged || + update.selectionSet || + update.viewportChanged || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { this.decorations = buildDecorations(update.view) } } From abcb3ea0c9360e6489f3a5ac9aba4729ccdeab38 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 20 Oct 2026 12:47:58 -0500 Subject: [PATCH 02/20] Perf(editor): heading arrows no longer scan each visible section The heading arrows rebuild on every cursor move, keystroke and finished cut parse, and for each visible heading they found its whole section (a line-by-line scan to the next heading of the same or a higher level, with a syntax tree lookup for every `#` line) just to learn two things: whether the heading folds at all and whether its fold is closed. For the H1 at the top of a long note that scan is the whole note. Whether a heading folds only depends on the next line: its section is empty only on the last line or right above a heading of the same or a higher level. Whether its fold is closed can only be true when a fold starts at the end of its line, so the section end is now found in that case alone. The decorations are the same: new tests pin the arrows, level labels and folded states for headings with and without a body, every level, a heading right above the same or a higher level, the last line, `#` lines in code, folds that are not exactly a section and a trailing newline, and they pass on the old code too. In the built app the plugin's decorations matched in every compared state, before and after. Cost of the plugin's update at the top of a 3 MB note (13,000 sections, 13,000 `# comment` lines in bash fences), built app over CDP, two runs per build, medians: - cursor move: 44.1 ms -> 0.0 ms (126 moves each) - keystroke: 46.4 ms -> 0.0 ms (30 each) - finished cut parse after opening: 43.1 ms -> 0.1 ms (6 each) - with the top H1 folded, a move still finds that section: 49.7 vs 50.0 ms - the whole cursor move (view.update), one more run each: 119.7 ms -> 79.0 ms; most of what is left is computing the new state (58 to 65 ms) app-core 3,341 tests, typecheck clean. --- .../app-core/src/lib/cm-heading-fold.test.ts | 159 +++++++++++++++++- packages/app-core/src/lib/cm-heading-fold.ts | 25 ++- 2 files changed, 177 insertions(+), 7 deletions(-) diff --git a/packages/app-core/src/lib/cm-heading-fold.test.ts b/packages/app-core/src/lib/cm-heading-fold.test.ts index 34ee925ac..3f7791a18 100644 --- a/packages/app-core/src/lib/cm-heading-fold.test.ts +++ b/packages/app-core/src/lib/cm-heading-fold.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest' import { Compartment, EditorState } from '@codemirror/state' -import { foldedRanges, foldService, forceParsing, unfoldEffect } from '@codemirror/language' +import { foldEffect, foldedRanges, foldService, forceParsing, unfoldEffect } from '@codemirror/language' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { EditorView } from '@codemirror/view' import { foldAllOutline, foldAtCursor, headingFolding, unfoldAtCursor } from './cm-heading-fold' @@ -272,3 +272,160 @@ describe('list item folding (#848)', () => { done() }) }) + +describe('heading arrows', () => { + const doc = [ + '# Shapes', // 1 + '', // 2 + 'Intro', // 3 + '', // 4 + '## Empty', // 5: a heading of the same level follows + '## Has body', // 6 + '', // 7 + 'text', // 8 + '', // 9 + '### Deep', // 10 + '#### Deeper', // 11 + '##### Five', // 12 + '###### Six', // 13 + 'six body', // 14 + '## Same level next', // 15 + '## Followed', // 16 + '', // 17 + '```bash', // 18 + '# comment in fence', // 19 + '## another in fence', // 20 + '```', // 21 + '', // 22 + ' # indented code', // 23 + '', // 24 + '#nospace', // 25 + '### Three then two', // 26: a higher level follows + '## Two', // 27 + 'x', // 28 + '# Second H1', // 29 + '## Last line heading' // 30 + ].join('\n') + + /** Lines each heading's fold hides, first to last. */ + const sections: Record = { + 1: [2, 28], + 6: [7, 14], + 10: [11, 14], + 11: [12, 14], + 12: [13, 14], + 13: [14, 14], + 16: [17, 26], + 27: [28, 28], + 29: [30, 30] + } + const levels: Record = { 1: 1, 6: 2, 10: 3, 11: 4, 12: 5, 13: 6, 16: 2, 27: 2, 29: 1 } + + function mount(text: string): { view: EditorView; done: () => void } { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: text, + extensions: [markdown({ base: markdownLanguage }), headingFolding({ showLevelLabels: true })] + }) + }) + return { + view, + done: () => { + view.destroy() + parent.remove() + } + } + } + + /** `