diff --git a/src/lib/__tests__/quote-collapse-walk.test.ts b/src/lib/__tests__/quote-collapse-walk.test.ts new file mode 100644 index 00000000..7adb7142 --- /dev/null +++ b/src/lib/__tests__/quote-collapse-walk.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { buildQuoteCollapseScript, QUOTE_COLLAPSED_ATTR, QUOTE_TOGGLE_ATTR } from '../quote-collapse'; + +// The reader's quote-collapse script looked for an attributed
+// by walking every text node from the start of the body once per top-level +// blockquote: quadratic, so a mail full of blockquotes froze the reader. The +// webmail does one walk (lib/quote-collapse.ts). These tests run the injected +// script against a small fake DOM that counts the tree-walker steps. + +const SHOW_ELEMENT = 1; +const SHOW_TEXT = 4; + +let steps = 0; + +class FNode { + parentNode: FEl | null = null; + get parentElement(): FEl | null { return this.parentNode; } +} + +class FText extends FNode { + nodeType = 3; + constructor(public nodeValue: string) { super(); } +} + +class FEl extends FNode { + nodeType = 1; + tagName: string; + childNodes: FNode[] = []; + attrs = new Map(); + style = { display: '', cssText: '' }; + title = ''; + textContent = ''; + constructor(tag: string) { super(); this.tagName = tag.toUpperCase(); } + append(...kids: Array ): this { + for (const kid of kids) { + const node = typeof kid === 'string' ? new FText(kid) : kid; + node.parentNode = this; + this.childNodes.push(node); + } + return this; + } + insertBefore(node: FNode, ref: FNode): void { + node.parentNode = this; + this.childNodes.splice(this.childNodes.indexOf(ref), 0, node); + } + get nextElementSibling(): FEl | null { + const sibs = this.parentNode?.childNodes ?? []; + for (let i = sibs.indexOf(this) + 1; i < sibs.length; i++) if (sibs[i] instanceof FEl) return sibs[i] as FEl; + return null; + } + setAttribute(k: string, v: string) { this.attrs.set(k, v); } + getAttribute(k: string) { return this.attrs.get(k) ?? null; } + hasAttribute(k: string) { return this.attrs.has(k); } + removeAttribute(k: string) { this.attrs.delete(k); } + addEventListener() { /* not clicked here */ } + matches() { return false; } + // The fixtures hold no element the container or separator selectors match. + querySelector() { return null; } + querySelectorAll(tag: string): FEl[] { + return preorder(this).slice(1).filter((n): n is FEl => n instanceof FEl && n.tagName === tag.toUpperCase()); + } + closest(tag: string): FEl | null { + for (let el: FEl | null = this; el; el = el.parentNode) if (el.tagName === tag.toUpperCase()) return el; + return null; + } + compareDocumentPosition(other: FNode): number { + for (let p = other.parentNode; p; p = p.parentNode) if (p === this) return 16 | 4; // contained by, following + const order = preorder(rootOf(this)); + return order.indexOf(other) < order.indexOf(this) ? 2 : 4; + } +} + +function rootOf(node: FNode): FNode { + let n = node; + while (n.parentNode) n = n.parentNode; + return n; +} + +function preorder(root: FNode): FNode[] { + const out: FNode[] = []; + const visit = (n: FNode) => { + out.push(n); + if (n instanceof FEl) n.childNodes.forEach(visit); + }; + visit(root); + return out; +} + +function el(tag: string, ...kids: Array ): FEl { + return new FEl(tag).append(...kids); +} + +function run(body: FEl): void { + const document = { + body, + createElement: (tag: string) => new FEl(tag), + createTreeWalker: (root: FEl, what: number) => { + const nodes = preorder(root).slice(1).filter((n) => + (n instanceof FText && what & SHOW_TEXT) || (n instanceof FEl && what & SHOW_ELEMENT)); + let i = 0; + return { nextNode: () => { steps++; return nodes[i++] ?? null; } }; + }, + }; + const Node = { + TEXT_NODE: 3, DOCUMENT_POSITION_PRECEDING: 2, DOCUMENT_POSITION_FOLLOWING: 4, DOCUMENT_POSITION_CONTAINED_BY: 16, + }; + const NodeFilter = { SHOW_ELEMENT, SHOW_TEXT }; + const script = buildQuoteCollapseScript({ show: 'Show', hide: 'Hide' }); + new Function('document', 'Node', 'NodeFilter', 'window', script)(document, Node, NodeFilter, {}); +} + +describe('quote collapse in HTML mail', () => { + it('finds the attributed blockquote in one walk, not one walk per blockquote', () => { + const n = 300; + const body = el('body'); + for (let i = 0; i < n; i++) body.append(el('p', `line ${i}`), el('blockquote', `quote ${i}`)); + steps = 0; + run(body); + // About 4n nodes in the body; walking from the start for each of the n + // blockquotes took about n² steps. + expect(steps).toBeLessThan(20 * n); + }); + + it('collapses the outermost blockquote after an attribution line', () => { + const inner = el('blockquote', 'older'); + const outer = el('blockquote', el('p', 'old'), inner); + const first = el('blockquote', 'not attributed'); + const body = el('body', el('p', 'a'), first, el('p', 'Thanks!'), el('p', 'On Monday, Bob wrote:'), outer); + run(body); + expect(outer.style.display).toBe('none'); + expect(outer.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(true); + expect(first.style.display).toBe(''); + expect(inner.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(false); + const button = body.childNodes[body.childNodes.indexOf(outer) - 1] as FEl; + expect(button.hasAttribute(QUOTE_TOGGLE_ATTR)).toBe(true); + }); + + it('leaves a blockquote alone when no attribution line precedes it', () => { + const quote = el('blockquote', 'quoted'); + run(el('body', el('p', 'Hello there'), quote)); + expect(quote.style.display).toBe(''); + }); +}); diff --git a/src/lib/quote-collapse.ts b/src/lib/quote-collapse.ts index c18ca953..7343b6dc 100644 --- a/src/lib/quote-collapse.ts +++ b/src/lib/quote-collapse.ts @@ -92,24 +92,22 @@ export function buildQuoteCollapseScript(labels: QuoteCollapseLabels): string { } return false; } - function precedingTextEndsWithColon(el) { - var walker = doc.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 took quadratic time and + // froze the reader on a mail full of blockquotes (webmail quote-collapse.ts). + function findAttributedBlockquote() { + var walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); var last = null; for (var node = walker.nextNode(); node; node = walker.nextNode()) { - var rel = el.compareDocumentPosition(node); - if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue; - if (!(rel & Node.DOCUMENT_POSITION_PRECEDING)) break; - var text = (node.nodeValue || '').trim(); - if (text !== '') last = text; - } - return last !== null && /:$/.test(last); - } - function findAttributedBlockquote() { - var bqs = body.querySelectorAll('blockquote'); - for (var i = 0; i < bqs.length; i++) { - var bq = bqs[i]; - if (bq.parentElement && bq.parentElement.closest('blockquote')) continue; - if (precedingTextEndsWithColon(bq)) return bq; + if (node.nodeType === Node.TEXT_NODE) { + var text = (node.nodeValue || '').trim(); + if (text !== '') last = text; + continue; + } + if (node.tagName !== 'BLOCKQUOTE') continue; + if (node.parentElement && node.parentElement.closest('blockquote')) continue; + if (last !== null && /:$/.test(last)) return node; } return null; }