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. */