fix: keep a mail full of bare blockquotes from freezing the reader

This commit is contained in:
Linus Rath
2026-10-26 11:22:10 +01:00
parent 3caa83f1a6
commit afdd79b29d
2 changed files with 31 additions and 17 deletions
+18 -1
View File
@@ -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 = '<p>Intro</p>' + '<blockquote>x</blockquote>'.repeat(n) + '<p>Earlier:</p><blockquote>late quote</blockquote>';
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 = `
<div>answer</div>
+13 -16
View File
@@ -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. */