diff --git a/lib/__tests__/quote-collapse.test.ts b/lib/__tests__/quote-collapse.test.ts index 3ca03e596..fb7c18591 100644 --- a/lib/__tests__/quote-collapse.test.ts +++ b/lib/__tests__/quote-collapse.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { setupQuoteCollapse, collapsePlainTextQuotes, QUOTE_TOGGLE_ATTR, QUOTE_COLLAPSED_ATTR } from '@/lib/quote-collapse'; import { plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from '@/lib/email-sanitization'; @@ -162,6 +162,23 @@ describe('setupQuoteCollapse', () => { expect(toggle()).toBeNull(); }); + it('looks for an attribution in one pass over many bare blockquotes', () => { + // Every outermost blockquote used to walk all text before it again, so a + // body of a few ten thousand blockquotes froze the reader for minutes. + const n = 2000; + document.body.innerHTML = '

Intro

' + '
x
'.repeat(n) + '

Earlier:

late quote
'; + const nodes = document.body.querySelectorAll('*').length + n + 3; + const walkerSpy = vi.spyOn(Node.prototype, 'compareDocumentPosition'); + try { + expect(setupQuoteCollapse(document, labels)).toBe(true); + expect(walkerSpy.mock.calls.length).toBeLessThan(nodes * 6); + } finally { + walkerSpy.mockRestore(); + } + expect(document.body.lastElementChild!.textContent).toBe('late quote'); + expect((document.body.lastElementChild as HTMLElement).style.display).toBe('none'); + }); + it('collapses a nested wrapper case: separator inside a wrapper div hides content outside it too', () => { document.body.innerHTML = `
answer
diff --git a/lib/quote-collapse.ts b/lib/quote-collapse.ts index 1eab296cb..964ba62b8 100644 --- a/lib/quote-collapse.ts +++ b/lib/quote-collapse.ts @@ -86,25 +86,22 @@ function hasVisibleContent(body: HTMLElement, marker: Element, side: 'before' | * ending with a colon ("On …, X wrote:", "Am … schrieb X:", "… a écrit :"). */ function findAttributedBlockquote(body: HTMLElement): Element | null { - for (const bq of Array.from(body.querySelectorAll('blockquote'))) { - if (bq.parentElement?.closest('blockquote')) continue; // outermost only - if (precedingTextEndsWithColon(body, bq)) return bq; - } - return null; -} - -/** True when the last non-blank text node before `el` ends with a colon. */ -function precedingTextEndsWithColon(body: HTMLElement, el: Element): boolean { - const walker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT); + // One walk in document order, remembering the last non-blank text: a + // blockquote is reached before its own text, so that is the text preceding + // it. Walking from the start again per blockquote cost quadratic time. + const walker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); let last: string | null = null; for (let node = walker.nextNode(); node; node = walker.nextNode()) { - const rel = el.compareDocumentPosition(node); - if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue; - if (!(rel & Node.DOCUMENT_POSITION_PRECEDING)) break; // reached/passed el - const text = (node.nodeValue || '').trim(); - if (text !== '') last = text; + if (node.nodeType === Node.TEXT_NODE) { + const text = (node.nodeValue || '').trim(); + if (text !== '') last = text; + continue; + } + const el = node as Element; + if (el.tagName !== 'BLOCKQUOTE' || el.parentElement?.closest('blockquote')) continue; // outermost only + if (last !== null && last.endsWith(':')) return el; } - return last !== null && last.endsWith(':'); + return null; } /** Hide an element, remembering its original inline display for restore. */