From cbe64383dc3dfce6090cd4740b3ecb9450517cf4 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sun, 4 Oct 2026 13:19:34 -0700
Subject: [PATCH] Reuse source-line calculations for Markdown review selections
(#25173)
* Reuse source-line calculations for Markdown review selections
* Use typed editor probes in review selection performance coverage
---
.../rich-markdown-comment-blocks.test.ts | 216 ++++++++++++++++++
.../editor/rich-markdown-comment-blocks.ts | 82 +++++++
.../rich-markdown-review-annotations.ts | 76 +-----
.../rich-markdown-review-note-positioning.ts | 4 +-
...ich-markdown-review-rail-benchmark.test.ts | 3 +-
.../rich-markdown-review-rail-blocks.ts | 32 +--
...kdown-review-selection-performance.spec.ts | 209 +++++++++++++++++
7 files changed, 524 insertions(+), 98 deletions(-)
create mode 100644 src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts
create mode 100644 src/renderer/src/components/editor/rich-markdown-comment-blocks.ts
create mode 100644 tests/e2e/markdown-review-selection-performance.spec.ts
diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts
new file mode 100644
index 00000000000..a416d6719f0
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts
@@ -0,0 +1,216 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Editor } from '@tiptap/core'
+import type { DiffComment } from '../../../../shared/diff-comment-types'
+import { createRichMarkdownExtensions } from './rich-markdown-extensions'
+import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
+import {
+ buildRichMarkdownCommentBlocks,
+ getRichMarkdownCommentBlocks
+} from './rich-markdown-comment-blocks'
+import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
+import {
+ createRichMarkdownHtmlSuperscriptLinkContext,
+ type RichMarkdownHtmlSuperscriptLinkContext
+} from './rich-markdown-html-superscript-link-context'
+import {
+ getRichMarkdownAnnotationHighlightRanges,
+ getRichMarkdownAnnotationHighlightRangesForComment,
+ getRichMarkdownAnnotationTarget,
+ getRichMarkdownCommentAtPos
+} from './rich-markdown-review-annotations'
+import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks'
+
+const editors: Editor[] = []
+const SOURCE = 'First paragraph\n\n```ts\none\n\ntwo\n```\n\nLast paragraph'
+
+function createEditor(source = SOURCE, context?: RichMarkdownHtmlSuperscriptLinkContext) {
+ const root = document.createElement('div')
+ document.body.append(root)
+ const codec = createRichMarkdownEditorCodec()
+ const editor = new Editor({
+ element: root,
+ extensions: createRichMarkdownExtensions({
+ codec,
+ htmlSuperscriptLinks: Boolean(context),
+ htmlSuperscriptLinkContext: context
+ }),
+ content: encodeRawMarkdownHtmlForRichEditor(source, codec, {
+ htmlSuperscriptLinks: Boolean(context)
+ }),
+ contentType: 'markdown'
+ })
+ editors.push(editor)
+ editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false))
+ vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 600, 400))
+ return { editor, root }
+}
+
+function selectLastParagraph(editor: Editor, characters = 4) {
+ let from: number | undefined
+ editor.state.doc.forEach((node, offset) => {
+ if (node.textContent === 'Last paragraph') {
+ from = offset + 1
+ }
+ })
+ if (from === undefined) {
+ throw new Error('Last paragraph missing')
+ }
+ editor.commands.setTextSelection({ from, to: from + characters })
+ const paragraph = Array.from(editor.view.dom.querySelectorAll('p')).find(
+ (element) => element.textContent === 'Last paragraph'
+ )
+ const text = paragraph?.firstChild
+ if (!(text instanceof Text)) {
+ throw new Error('Last paragraph text missing')
+ }
+ const range = document.createRange()
+ range.setStart(text, 0)
+ range.setEnd(text, characters)
+ window.getSelection()?.removeAllRanges()
+ window.getSelection()?.addRange(range)
+ return from
+}
+
+function comment(lineNumber = 29): DiffComment {
+ return {
+ id: 'note',
+ worktreeId: 'workspace',
+ filePath: 'notes.md',
+ source: 'markdown',
+ lineNumber,
+ selectedText: 'Last',
+ body: 'Review',
+ createdAt: 1,
+ side: 'modified'
+ }
+}
+
+afterEach(() => {
+ for (const editor of editors.splice(0)) {
+ editor.destroy()
+ }
+ window.getSelection()?.removeAllRanges()
+ document.body.replaceChildren()
+ vi.restoreAllMocks()
+})
+
+describe('Markdown review source block reuse', () => {
+ it('preserves source lines when the document interaction host changes', () => {
+ const context = createRichMarkdownHtmlSuperscriptLinkContext({
+ sourceFilePath: '/repo/notes.md',
+ worktreeId: 'workspace',
+ worktreeRoot: '/repo',
+ sourceOwner: { kind: 'local' }
+ })
+ const { editor } = createEditor(
+ 'First [1]\n\nLast paragraph',
+ context
+ )
+ const blocks = getRichMarkdownCommentBlocks(editor)
+ const doc = editor.state.doc
+ const serialize = vi.spyOn(editor.markdown!, 'serialize')
+ const instrumentedBlocks = getRichMarkdownCommentBlocks(editor)
+ serialize.mockClear()
+ context.update({
+ sourceFilePath: '/remote/notes.md',
+ worktreeId: 'remote-workspace',
+ worktreeRoot: '/remote',
+ sourceOwner: { kind: 'ssh', connectionId: 'connection' }
+ })
+ expect(editor.state.doc).toBe(doc)
+ expect(getRichMarkdownCommentBlocks(editor)).toBe(instrumentedBlocks)
+ expect(serialize).not.toHaveBeenCalled()
+ serialize.mockRestore()
+ expect(buildRichMarkdownCommentBlocks(editor)).toEqual(blocks)
+ })
+
+ it('shares one source map across highlights, comment clicks, selection targets and the rail', () => {
+ const { editor, root } = createEditor()
+ const from = selectLastParagraph(editor)
+ vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20))
+ const serialize = vi.spyOn(editor.markdown!, 'serialize')
+ const json = vi.spyOn(editor, 'getJSON')
+ const note = comment()
+ const expected = [{ from, to: from + 4 }]
+
+ expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected)
+ expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1)
+ expect(json).toHaveBeenCalledTimes(1)
+ serialize.mockClear()
+ json.mockClear()
+
+ for (let index = 0; index < 20; index++) {
+ expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected)
+ expect(getRichMarkdownAnnotationHighlightRangesForComment(editor, note, 20)).toEqual(expected)
+ expect(getRichMarkdownCommentAtPos(editor, [note], 20, from + 2)).toBe(note)
+ expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({
+ from,
+ to: from + 4,
+ selectedText: 'Last',
+ lineNumber: 9
+ })
+ expect(getRichMarkdownReviewRailBlocks(editor)).toBe(getRichMarkdownCommentBlocks(editor))
+ }
+ expect(serialize).not.toHaveBeenCalled()
+ expect(json).not.toHaveBeenCalled()
+ })
+
+ it('updates selected text and viewport geometry without rebuilding source lines', () => {
+ const { editor, root } = createEditor()
+ const rect = vi.spyOn(Range.prototype, 'getBoundingClientRect')
+ rect.mockReturnValue(new DOMRect(10, 20, 80, 20))
+ selectLastParagraph(editor)
+ const serialize = vi.spyOn(editor.markdown!, 'serialize')
+ expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({
+ selectedText: 'Last',
+ buttonTop: 48,
+ lineNumber: 9
+ })
+ serialize.mockClear()
+ const doc = editor.state.doc
+ selectLastParagraph(editor, 9)
+ rect.mockReturnValue(new DOMRect(10, 120, 140, 20))
+ expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({
+ selectedText: 'Last para',
+ buttonTop: 148,
+ lineNumber: 9
+ })
+ expect(editor.state.doc).toBe(doc)
+ expect(serialize).not.toHaveBeenCalled()
+ })
+
+ it('rebuilds source lines after editing a multiline block and undoing it', () => {
+ const { editor, root } = createEditor()
+ vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20))
+ selectLastParagraph(editor)
+ expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9)
+ const serialize = vi.spyOn(editor.markdown!, 'serialize')
+ let codeFrom: number | undefined
+ editor.state.doc.forEach((node, offset) => {
+ if (node.type.name === 'codeBlock') {
+ codeFrom = offset + 1
+ }
+ })
+ if (codeFrom === undefined) {
+ throw new Error('Code block missing')
+ }
+ editor.view.dispatch(editor.state.tr.insertText('new\n', codeFrom))
+ selectLastParagraph(editor)
+ expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(10)
+ expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1)
+ expect(
+ getRichMarkdownCommentAtPos(editor, [comment(30)], 20, editor.state.selection.from)
+ ).toEqual(comment(30))
+ expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1)
+ serialize.mockClear()
+
+ expect(editor.commands.undo()).toBe(true)
+ selectLastParagraph(editor)
+ expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9)
+ expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1)
+ serialize.mockClear()
+ expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9)
+ expect(serialize).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts
new file mode 100644
index 00000000000..261b982f0bd
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts
@@ -0,0 +1,82 @@
+import type { Editor, JSONContent } from '@tiptap/core'
+import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
+
+export type RichMarkdownCommentBlock = {
+ key: string
+ startLine: number
+ endLine: number
+ from: number
+ to: number
+}
+
+function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string {
+ return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd()
+}
+
+export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] {
+ const jsonContent = editor.getJSON().content ?? []
+ const blocks: RichMarkdownCommentBlock[] = []
+ let nextLine = 1
+ let previousNodeJson: JSONContent | null = null
+ let previousNodeLineCount = 0
+
+ editor.state.doc.forEach((node, nodeOffset, index) => {
+ const nodeJson = jsonContent[index]
+ if (!nodeJson) {
+ return
+ }
+ const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson])
+ const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown)
+ if (previousNodeJson) {
+ const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson])
+ const separatorLineCount = Math.max(
+ 0,
+ countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount
+ )
+ nextLine += separatorLineCount
+ }
+ const startLine = nextLine
+ const endLine = Math.max(startLine, startLine + nodeLineCount - 1)
+ const from = nodeOffset + 1
+ blocks.push({
+ key: `${index}:${startLine}-${endLine}`,
+ startLine,
+ endLine,
+ from,
+ to: from + Math.max(0, node.nodeSize - 1)
+ })
+ nextLine = endLine + 1
+ previousNodeJson = nodeJson
+ previousNodeLineCount = nodeLineCount
+ })
+
+ if (blocks.length === 0) {
+ blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 })
+ }
+
+ return blocks
+}
+
+type RichMarkdownCommentBlocksSnapshot = {
+ doc: Editor['state']['doc']
+ markdown: Editor['markdown']
+ serialize: NonNullable['serialize'] | undefined
+ blocks: readonly RichMarkdownCommentBlock[]
+}
+
+// Keep only the current document per editor; scrolling changes geometry, not source lines.
+const blocksByEditor = new WeakMap()
+
+export function getRichMarkdownCommentBlocks(editor: Editor): readonly RichMarkdownCommentBlock[] {
+ const doc = editor.state.doc
+ const markdown = editor.markdown
+ const serialize = markdown?.serialize
+ const cached = blocksByEditor.get(editor)
+ if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) {
+ return cached.blocks
+ }
+
+ const blocks = buildRichMarkdownCommentBlocks(editor)
+ blocksByEditor.set(editor, { doc, markdown, serialize, blocks })
+ return blocks
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts
index 90bd170a5c1..f466a38b321 100644
--- a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts
+++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts
@@ -1,6 +1,5 @@
import type { Dispatch, SetStateAction } from 'react'
import type { Editor } from '@tiptap/react'
-import type { JSONContent } from '@tiptap/core'
import type { DiffComment } from '../../../../shared/diff-comment-types'
import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight'
import {
@@ -10,7 +9,14 @@ import {
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map'
-import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
+import {
+ getRichMarkdownCommentBlocks,
+ type RichMarkdownCommentBlock
+} from './rich-markdown-comment-blocks'
+export {
+ buildRichMarkdownCommentBlocks,
+ type RichMarkdownCommentBlock
+} from './rich-markdown-comment-blocks'
export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24
@@ -22,14 +28,6 @@ const RICH_MARKDOWN_ANNOTATION_POPOVER_WIDTH_PX = 420
const RICH_MARKDOWN_ANNOTATION_POPOVER_RIGHT_OFFSET_PX = 24
const RICH_MARKDOWN_ANNOTATION_POPOVER_MIN_HEIGHT_PX = 220
-export type RichMarkdownCommentBlock = {
- key: string
- startLine: number
- endLine: number
- from: number
- to: number
-}
-
export type RichMarkdownComposerState = {
lineNumber: number
startLine?: number
@@ -45,54 +43,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & {
buttonLeft: number
}
-function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string {
- return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd()
-}
-
-export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] {
- const jsonContent = editor.getJSON().content ?? []
- const blocks: RichMarkdownCommentBlock[] = []
- let nextLine = 1
- let previousNodeJson: JSONContent | null = null
- let previousNodeLineCount = 0
-
- editor.state.doc.forEach((node, nodeOffset, index) => {
- const nodeJson = jsonContent[index]
- if (!nodeJson) {
- return
- }
- const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson])
- const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown)
- if (previousNodeJson) {
- const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson])
- const separatorLineCount = Math.max(
- 0,
- countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount
- )
- nextLine += separatorLineCount
- }
- const startLine = nextLine
- const endLine = Math.max(startLine, startLine + nodeLineCount - 1)
- const from = nodeOffset + 1
- blocks.push({
- key: `${index}:${startLine}-${endLine}`,
- startLine,
- endLine,
- from,
- to: from + Math.max(0, node.nodeSize - 1)
- })
- nextLine = endLine + 1
- previousNodeJson = nodeJson
- previousNodeLineCount = nodeLineCount
- })
-
- if (blocks.length === 0) {
- blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 })
- }
-
- return blocks
-}
-
export function clampRichMarkdownAnnotationTarget(
editor: Editor,
target: RichMarkdownAnnotationTarget
@@ -122,8 +72,7 @@ export function getRichMarkdownAnnotationHighlightRanges(
if (comments.length === 0) {
return []
}
- // Why once: block resolution re-serializes the doc; per comment it was O(n*doc).
- const blocks = buildRichMarkdownCommentBlocks(editor)
+ const blocks = getRichMarkdownCommentBlocks(editor)
return comments.flatMap((comment) =>
getRichMarkdownAnnotationHighlightRangesForComment(
editor,
@@ -138,10 +87,9 @@ export function getRichMarkdownAnnotationHighlightRangesForComment(
editor: Editor,
comment: DiffComment,
markdownSourceLineOffset: number,
- // Why optional: callers looping over comments pass one shared build.
prebuiltBlocks?: readonly RichMarkdownCommentBlock[]
): RichMarkdownAnnotationHighlightRange[] {
- const blocks = prebuiltBlocks ?? buildRichMarkdownCommentBlocks(editor)
+ const blocks = prebuiltBlocks ?? getRichMarkdownCommentBlocks(editor)
const selectedText = comment.selectedText?.trim()
if (!selectedText) {
return []
@@ -173,7 +121,7 @@ export function getRichMarkdownCommentAtPos(
if (comments.length === 0) {
return null
}
- const blocks = buildRichMarkdownCommentBlocks(editor)
+ const blocks = getRichMarkdownCommentBlocks(editor)
return (
comments.find((comment) =>
getRichMarkdownAnnotationHighlightRangesForComment(
@@ -216,7 +164,7 @@ export function getRichMarkdownCommentAnchorTop(
}
function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerState {
- const blocks = buildRichMarkdownCommentBlocks(editor)
+ const blocks = getRichMarkdownCommentBlocks(editor)
const { from, to, empty } = editor.state.selection
const selectedBlocks = empty
? blocks.filter((block) => block.from <= from && from <= block.to)
diff --git a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts
index 4fdc12a51b9..b7cc8bc63e2 100644
--- a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts
+++ b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts
@@ -1,7 +1,7 @@
import type { Editor } from '@tiptap/react'
import type { DiffComment } from '../../../../shared/diff-comment-types'
import { getRichMarkdownCommentAnchorTop } from './rich-markdown-review-annotations'
-import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks'
+import { getRichMarkdownCommentBlocks } from './rich-markdown-comment-blocks'
import {
stackRichMarkdownReviewNotePositions,
type RichMarkdownReviewNotePosition
@@ -21,7 +21,7 @@ export function measureRichMarkdownReviewNotePositions({
markdownSourceLineOffset
}: MeasureRichMarkdownReviewNotePositionsOptions): RichMarkdownReviewNotePosition[] {
const containerRect = container.getBoundingClientRect()
- const blocks = getRichMarkdownReviewRailBlocks(editor)
+ const blocks = getRichMarkdownCommentBlocks(editor)
const nextPositions = markdownComments
.map((comment): RichMarkdownReviewNotePosition | null => {
const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset)
diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts
index 7090769f71f..d64fcc667b8 100644
--- a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts
@@ -48,7 +48,8 @@ function measureBaseline({
block,
containerRect,
container.scrollTop,
- markdownSourceLineOffset
+ markdownSourceLineOffset,
+ buildRichMarkdownCommentBlocks(editor)
)
return top === null ? null : { comment, top }
})
diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts
index 35dd1bb9eb5..9cb8bf0b64d 100644
--- a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts
+++ b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts
@@ -1,31 +1 @@
-import type { Editor } from '@tiptap/core'
-import {
- buildRichMarkdownCommentBlocks,
- type RichMarkdownCommentBlock
-} from './rich-markdown-review-annotations'
-
-type ReviewRailBlocks = {
- doc: Editor['state']['doc']
- markdown: Editor['markdown']
- serialize: NonNullable['serialize'] | undefined
- blocks: readonly RichMarkdownCommentBlock[]
-}
-
-// Keep only the current document per editor; scrolling changes geometry, not source lines.
-const blocksByEditor = new WeakMap()
-
-export function getRichMarkdownReviewRailBlocks(
- editor: Editor
-): readonly RichMarkdownCommentBlock[] {
- const doc = editor.state.doc
- const markdown = editor.markdown
- const serialize = markdown?.serialize
- const cached = blocksByEditor.get(editor)
- if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) {
- return cached.blocks
- }
-
- const blocks = buildRichMarkdownCommentBlocks(editor)
- blocksByEditor.set(editor, { doc, markdown, serialize, blocks })
- return blocks
-}
+export { getRichMarkdownCommentBlocks as getRichMarkdownReviewRailBlocks } from './rich-markdown-comment-blocks'
diff --git a/tests/e2e/markdown-review-selection-performance.spec.ts b/tests/e2e/markdown-review-selection-performance.spec.ts
new file mode 100644
index 00000000000..a9ee0035599
--- /dev/null
+++ b/tests/e2e/markdown-review-selection-performance.spec.ts
@@ -0,0 +1,209 @@
+import type { Locator, Page } from '@stablyai/playwright-test'
+import type { Editor, JSONContent } from '@tiptap/core'
+import type { MarkdownManager } from '@tiptap/markdown'
+import { expect, test } from './helpers/orca-app'
+import {
+ cleanupMarkdownFixture,
+ createMarkdownFixture,
+ getActiveWorktreeContext,
+ openMarkdownFixture,
+ waitForRichMarkdownEditor
+} from './helpers/markdown-editor-fixture'
+
+const PARAGRAPH_COUNT = Number(process.env.ORCA_MARKDOWN_REVIEW_PERF_PARAGRAPHS ?? '500')
+const targetIndex = Math.floor(PARAGRAPH_COUNT / 2)
+const paragraph = (index: number) =>
+ `Paragraph ${index}. Ordinary editing and selection of plain prose.`
+const SOURCE = Array.from({ length: PARAGRAPH_COUNT }, (_, index) => paragraph(index)).join('\n\n')
+
+type ReviewSelectionMetrics = {
+ calls: number
+ jsonCalls: number
+ serializeMs: number
+ jsonMs: number
+ frameGaps: number[]
+}
+
+type ReviewSelectionProbe = {
+ snapshot: () => ReviewSelectionMetrics
+ reset: () => void
+ restore: () => void
+}
+
+type PageRichMarkdownReviewEditorElement = HTMLElement & {
+ editor?: Editor & { markdown?: MarkdownManager }
+ __reviewSelectionProbe?: ReviewSelectionProbe
+}
+
+async function frames(page: Page) {
+ await page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
+ )
+ )
+}
+
+async function measure(editor: Locator, reset = false): Promise {
+ return editor.evaluate((element, reset) => {
+ const editorElement =
+ element.closest('.rich-markdown-editor')
+ const probe = editorElement?.__reviewSelectionProbe
+ if (!probe) {
+ throw new Error('Review selection probe missing')
+ }
+ if (reset) {
+ probe.reset()
+ }
+ return probe.snapshot()
+ }, reset)
+}
+
+test('selection and scrolling reuse Markdown review source lines', async ({
+ orcaPage,
+ registerPostElectronShutdownCleanup
+}, testInfo) => {
+ test.setTimeout(180_000)
+ expect(Buffer.byteLength(SOURCE)).toBeLessThan(600 * 1024)
+ const context = await getActiveWorktreeContext(orcaPage)
+ const filePath = await createMarkdownFixture(
+ context,
+ '.orca-e2e-markdown-review-perf',
+ 'review-selection',
+ testInfo.workerIndex,
+ SOURCE
+ )
+ registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
+ await openMarkdownFixture(orcaPage, context, filePath)
+ const editor = await waitForRichMarkdownEditor(orcaPage)
+ await expect(editor.locator('p')).toHaveCount(PARAGRAPH_COUNT, { timeout: 60_000 })
+ await editor.evaluate((element) => {
+ const editorElement =
+ element.closest('.rich-markdown-editor')
+ const instance = editorElement?.editor
+ if (!editorElement || !instance) {
+ throw new Error('Editor unavailable')
+ }
+ const markdown = instance.markdown
+ if (!markdown) {
+ throw new Error('Markdown manager missing')
+ }
+ const serialize = markdown.serialize
+ const getJSON = instance.getJSON
+ let calls = 0
+ let jsonCalls = 0
+ let serializeMs = 0
+ let jsonMs = 0
+ let frameGaps: number[] = []
+ let previousFrame: number | null = null
+ let frameId = 0
+ const tick = (now: number) => {
+ if (previousFrame !== null) {
+ frameGaps.push(now - previousFrame)
+ }
+ previousFrame = now
+ frameId = requestAnimationFrame(tick)
+ }
+ frameId = requestAnimationFrame(tick)
+ markdown.serialize = (content: JSONContent): string => {
+ const start = performance.now()
+ try {
+ calls++
+ return serialize.call(markdown, content)
+ } finally {
+ serializeMs += performance.now() - start
+ }
+ }
+ instance.getJSON = (): ReturnType => {
+ const start = performance.now()
+ try {
+ jsonCalls++
+ return getJSON.call(instance)
+ } finally {
+ jsonMs += performance.now() - start
+ }
+ }
+ editorElement.__reviewSelectionProbe = {
+ snapshot: () => ({ calls, jsonCalls, serializeMs, jsonMs, frameGaps }),
+ reset: () => {
+ calls = 0
+ jsonCalls = 0
+ serializeMs = 0
+ jsonMs = 0
+ frameGaps = []
+ previousFrame = null
+ },
+ restore: () => {
+ cancelAnimationFrame(frameId)
+ markdown.serialize = serialize
+ instance.getJSON = getJSON
+ }
+ }
+ })
+ try {
+ const target = editor.getByText(paragraph(targetIndex), { exact: true })
+ await target.evaluate((element) => element.scrollIntoView({ block: 'center' }))
+ const point = await target.evaluate((element) => {
+ const text = element.firstChild
+ if (!(text instanceof Text)) {
+ throw new Error('Paragraph text missing')
+ }
+ const range = document.createRange()
+ range.setStart(text, 0)
+ range.collapse(true)
+ const rect = range.getBoundingClientRect()
+ return { x: rect.left, y: rect.top + rect.height / 2 }
+ })
+ await orcaPage.mouse.click(point.x, point.y)
+ await orcaPage.keyboard.press('Shift+ArrowRight')
+ await expect(
+ orcaPage.getByRole('button', { name: 'Add review note', exact: true })
+ ).toBeVisible()
+ await frames(orcaPage)
+ const cold = await measure(editor)
+ await measure(editor, true)
+ for (let index = 0; index < 20; index++) {
+ await orcaPage.keyboard.press('Shift+ArrowRight')
+ await frames(orcaPage)
+ }
+ const selection = await measure(editor)
+ expect(await orcaPage.evaluate(() => window.getSelection()?.toString())).toBe(
+ paragraph(targetIndex).slice(0, 21)
+ )
+ const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
+ await measure(editor, true)
+ for (let index = 0; index < 10; index++) {
+ await viewport.evaluate((element, index) => {
+ element.scrollTop += index % 2 ? -8 : 8
+ }, index)
+ await frames(orcaPage)
+ }
+ const scroll = await measure(editor)
+ const result = {
+ paragraphs: PARAGRAPH_COUNT,
+ sourceBytes: Buffer.byteLength(SOURCE),
+ cold,
+ selection,
+ scroll
+ }
+ await testInfo.attach('review-selection-metrics', {
+ body: JSON.stringify(result, null, 2),
+ contentType: 'application/json'
+ })
+ process.stdout.write(`${JSON.stringify(result)}\n`)
+ await expect(editor).toBeFocused()
+ const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'review-selection' }).last()
+ await expect(tab.locator('span.rounded-full')).toHaveCount(0)
+ await orcaPage.screenshot({ path: testInfo.outputPath('review-selection.png') })
+ expect(selection.calls).toBe(0)
+ expect(selection.jsonCalls).toBe(0)
+ expect(scroll.calls).toBe(0)
+ expect(scroll.jsonCalls).toBe(0)
+ } finally {
+ await editor.evaluate((element) => {
+ const editorElement =
+ element.closest('.rich-markdown-editor')
+ editorElement?.__reviewSelectionProbe?.restore()
+ })
+ }
+})