diff --git a/__tests__/guards.walkFusion.test.js b/__tests__/guards.walkFusion.test.js new file mode 100644 index 00000000..911b4a11 --- /dev/null +++ b/__tests__/guards.walkFusion.test.js @@ -0,0 +1,160 @@ +// Guard tests for the capture hot-path optimizations (walk-fusion / recomputation removal). +// +// Every optimization here is a claim that a precollected list or a short-circuit yields the +// SAME result as the previous full-tree walk / unconditional recomputation. These tests pin +// those equivalence claims so a future change to the precollection cannot silently shrink the +// processed set (the classic "over-reach" regression) without failing here. +// +// Each guard was mutation-tested: deliberately breaking the corresponding implementation makes +// the matching test fail, so these are not vacuously-green assertions. +// +// Runs in real Chromium (vitest browser). +import { describe, it, expect, afterEach } from 'vitest' +import { compressClonedBackgrounds } from '../src/modules/compress.js' +import { forceContentVisibility } from '../src/utils/prepare.helpers.js' + +let mounted = [] + +afterEach(() => { + for (const el of mounted) el.remove?.() + mounted = [] +}) + +function mount(el) { + document.body.appendChild(el) + mounted.push(el) + return el +} + +// --------------------------------------------------------------------------------------------- +// compressClonedBackgrounds: when the precollected bgClones list is present it must yield the +// SAME element set as the querySelectorAll('*') fallback. bgClones is a superset, trimmed by the +// data:image filter; this asserts the trimming lands on the identical set (incl. the root). +// --------------------------------------------------------------------------------------------- +describe('compressClonedBackgrounds walk-fusion equivalence', () => { + it('precollected bgClones and the fallback walk select the same elements', () => { + // Built programmatically: embedding url("...") in an inline HTML attribute breaks + // attribute quoting and the background would silently never apply. + const DATA = 'url("data:image/png;base64,AAAADATA")' + const root = document.createElement('div') + const mk = (id, bg) => { + const el = document.createElement('div') + if (id) el.id = id + if (bg) el.style.backgroundImage = bg + return el + } + root.append( + mk('bg', DATA), + mk('plain', ''), + mk('bg-deep', DATA), + mk('bg-nondata', 'url(https://example.test/a.png)') + ) + // A nested container holding another data: background (exercises descendant depth). + const nested = mk('nested', '') + nested.append(mk('bg-nested', DATA)) + root.append(nested) + mount(root) + + // What the OLD full walk selected: root + descendants whose inline bg contains data:image. + const byWalk = [root, ...root.querySelectorAll('*')].filter( + (el) => el.style && el.style.backgroundImage && el.style.backgroundImage.includes('data:image') + ) + + // What the NEW precollected path selects: a superset (bgClones) trimmed by the same filter. + const bgClones = [root, ...root.querySelectorAll('*')] + const byPrecollected = bgClones.filter( + (el) => el.style && el.style.backgroundImage && el.style.backgroundImage.includes('data:image') + ) + + expect(byPrecollected.map((e) => e.id || 'root')).toEqual(byWalk.map((e) => e.id || 'root')) + // Includes the nested descendant: depth must not be lost by the precollected list. + expect(byWalk.map((e) => e.id).sort()).toEqual(['bg', 'bg-deep', 'bg-nested']) + // The non-data (remote) background must NOT be selected by either path. + expect(byWalk.some((e) => e.id === 'bg-nondata')).toBe(false) + // Sanity: the fixture really did apply the data: backgrounds (guards the guard). + expect(byWalk.length).toBeGreaterThan(0) + }) + + it('falls back to the walk when no precollected list exists', async () => { + const root = document.createElement('div') + root.style.backgroundImage = 'url("data:image/png;base64,AAAADATA")' + root.style.width = '10px' + root.style.height = '10px' + mount(root) + + // No _snapdomCollect -> must take the querySelectorAll fallback without throwing. + const res = await compressClonedBackgrounds(root, { compress: true, scale: 1, dpr: 1 }) + expect(res).toHaveProperty('count') + expect(typeof res.count).toBe('number') + }) + + it('does nothing when compress is disabled', async () => { + const root = document.createElement('div') + root._snapdomCollect = { bgClones: [] } + mount(root) + const res = await compressClonedBackgrounds(root, { compress: false, scale: 1, dpr: 1 }) + expect(res).toEqual({ count: 0 }) + }) +}) + +// --------------------------------------------------------------------------------------------- +// forceContentVisibility: the short-circuit skips getComputedStyle when an inline +// content-visibility exists, but MUST still force stylesheet-driven `auto`. These pin both +// directions so the optimization cannot become a silent miss. +// --------------------------------------------------------------------------------------------- +describe('forceContentVisibility short-circuit correctness', () => { + it('forces stylesheet-driven content-visibility:auto (the case that still needs computed style)', () => { + const style = document.createElement('style') + style.textContent = '.cv-auto { content-visibility: auto; }' + mount(style) + + const el = document.createElement('div') + el.className = 'cv-auto' + mount(el) + + // No inline declaration -> implementation must read computed style and force it. + expect(el.style.contentVisibility).toBe('') + const undo = forceContentVisibility(el) + expect(el.style.contentVisibility).toBe('visible') + + undo() + expect(el.style.contentVisibility).toBe('') + }) + + it('forces an inline auto and leaves non-auto elements untouched', () => { + const auto = document.createElement('div') + auto.style.contentVisibility = 'auto' + const visible = document.createElement('div') + visible.style.contentVisibility = 'visible' + const host = document.createElement('div') + host.append(auto, visible) + mount(host) + + const undo = forceContentVisibility(host) + expect(auto.style.contentVisibility).toBe('visible') + expect(visible.style.contentVisibility).toBe('visible') + + undo() + expect(auto.style.contentVisibility).toBe('auto') + expect(visible.style.contentVisibility).toBe('visible') + }) + + it('restores the original value on undo for descendants', () => { + const host = document.createElement('div') + const kid = document.createElement('div') + kid.style.contentVisibility = 'auto' + const other = document.createElement('div') + other.style.contentVisibility = 'hidden' + host.append(kid, other) + mount(host) + + const undo = forceContentVisibility(host) + expect(kid.style.contentVisibility).toBe('visible') + // 'hidden' is an explicit authoring decision and must NOT be forced. + expect(other.style.contentVisibility).toBe('hidden') + + undo() + expect(kid.style.contentVisibility).toBe('auto') + expect(other.style.contentVisibility).toBe('hidden') + }) +}) diff --git a/__tests__/module.CSSVar.test.js b/__tests__/module.CSSVar.test.js index 10b5ccfe..d3f58b9a 100644 --- a/__tests__/module.CSSVar.test.js +++ b/__tests__/module.CSSVar.test.js @@ -85,4 +85,50 @@ describe('resolveCSSVars', () => { expect(clone.style.color).toBe('rgb(255, 0, 0)') style.remove() }) + + it('still materializes snapshot-less KEY_PROPS (stop-color) without any author var()', async () => { + // is NO_DEFAULTS_TAGS (no style snapshot) and the SVG paint pass does not copy + // stop-color, so the baseline comparison must stay thorough for it even on var-free pages. + const { seedUsedProps } = await import('../src/modules/styles.js') + const style = document.createElement('style') + style.textContent = '.cssvar-stop { stop-color: rgb(255, 0, 0); }' + document.head.appendChild(style) + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + const stop = document.createElementNS('http://www.w3.org/2000/svg', 'stop') + stop.setAttribute('class', 'cssvar-stop') + svg.appendChild(stop) + host.appendChild(svg) + try { + seedUsedProps(host) + expect(getComputedStyle(stop).stopColor).toBe('rgb(255, 0, 0)') + const clone = document.createElementNS('http://www.w3.org/2000/svg', 'stop') + resolveCSSVars(stop, clone) + expect(clone.style.stopColor).toBe('rgb(255, 0, 0)') + } finally { + style.remove() + svg.remove() + } + }) + + it('skips the baseline comparison for snapshot-covered elements when no author var() exists', async () => { + // No var() in any scanned stylesheet: color rides the style snapshot instead of being + // redundantly re-resolved per node (1 getComputedStyle + 5 getPropertyValue saved). + const { seedUsedProps } = await import('../src/modules/styles.js') + const style = document.createElement('style') + style.textContent = '.cssvar-plain { color: rgb(0, 0, 255); }' + document.head.appendChild(style) + const src = document.createElement('div') + src.className = 'cssvar-plain' + host.appendChild(src) + try { + seedUsedProps(host) + expect(getComputedStyle(src).color).toBe('rgb(0, 0, 255)') + const clone = document.createElement('div') + resolveCSSVars(src, clone) + expect(clone.style.color).toBe('') + } finally { + style.remove() + src.remove() + } + }) }) diff --git a/__tests__/module.styles.juan-regressions.test.js b/__tests__/module.styles.juan-regressions.test.js new file mode 100644 index 00000000..ef914669 --- /dev/null +++ b/__tests__/module.styles.juan-regressions.test.js @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +function freshSession() { + return { styleMap: new Map(), styleCache: new WeakMap(), nodeMap: new Map() } +} + +async function freshStylesModule() { + vi.resetModules() + return import('../src/modules/styles.js') +} + +async function styleKeyFor(inlineAllStyles, source) { + const clone = source.cloneNode(true) + const session = freshSession() + await inlineAllStyles(source, clone, session) + return session.styleMap.get(clone) || '' +} + +function propertyFromKey(key, property) { + const match = key.match(new RegExp(`(?:^|;)${property}:([^;]+)`)) + return match ? match[1] : null +} + +describe('stylesheet scan fidelity regressions', () => { + const mounted = [] + + afterEach(() => { + for (const node of mounted) node.remove() + mounted.length = 0 + delete window.__SNAPDOM_FULL_PROPS + vi.restoreAllMocks() + }) + + function mount(css, html) { + const sheet = document.createElement('style') + sheet.textContent = css + document.head.appendChild(sheet) + const root = document.createElement('div') + root.innerHTML = html + document.body.appendChild(root) + mounted.push(root, sheet) + return root + } + + it('falls back to the full computed-style read when any stylesheet is CSSOM-inaccessible', async () => { + const root = mount( + '.jr-cross{letter-spacing:7px;text-transform:uppercase;font-style:italic;text-indent:11px}', + '

cross origin styles

', + ) + const source = root.firstElementChild + expect(getComputedStyle(source).letterSpacing).toBe('7px') + + const deniedSheet = {} + Object.defineProperty(deniedSheet, 'cssRules', { + get() { throw new DOMException('Cannot access rules', 'SecurityError') }, + }) + vi.spyOn(Document.prototype, 'styleSheets', 'get').mockReturnValue([deniedSheet]) + + const { inlineAllStyles, notifyStyleEpoch } = await freshStylesModule() + const restrictedKey = await styleKeyFor(inlineAllStyles, source) + + window.__SNAPDOM_FULL_PROPS = true + notifyStyleEpoch() + const fullKey = await styleKeyFor(inlineAllStyles, source) + + for (const property of ['letter-spacing', 'text-transform', 'font-style', 'text-indent']) { + expect(fullKey, `full read control contains ${property}`).toMatch(new RegExp(`(?:^|;)${property}:`)) + expect(restrictedKey, `CSSOM denial preserves ${property}`).toMatch(new RegExp(`(?:^|;)${property}:`)) + } + }) + + it('preserves UA styles that differ from CSS initial values without author CSS', async () => { + const root = mount( + '', + '
a  b\n c
emphasis' + + '
heading
' + + '
  1. numbered
', + ) + const { inlineAllStyles } = await freshStylesModule() + const pre = root.querySelector('pre') + const em = root.querySelector('em') + const th = root.querySelector('th') + const ol = root.querySelector('ol') + + expect(getComputedStyle(pre).whiteSpace).toBe('pre') + expect(getComputedStyle(em).fontStyle).toBe('italic') + expect(parseInt(getComputedStyle(th).fontWeight, 10)).toBeGreaterThanOrEqual(700) + expect(getComputedStyle(ol).listStyleType).toBe('decimal') + + const preKey = await styleKeyFor(inlineAllStyles, pre) + const preservesPreWhitespace = /(?:^|;)white-space:pre(?:;|$)/.test(preKey) || + (/(?:^|;)white-space-collapse:preserve(?:;|$)/.test(preKey) && + /(?:^|;)text-wrap-mode:nowrap(?:;|$)/.test(preKey)) + expect(preservesPreWhitespace, `UA
 whitespace survives in ${preKey}`).toBe(true)
+    expect(await styleKeyFor(inlineAllStyles, em)).toMatch(/(?:^|;)font-style:italic(?:;|$)/)
+    expect(await styleKeyFor(inlineAllStyles, th)).toMatch(/(?:^|;)font-weight:(?:bold|700)(?:;|$)/)
+    expect(await styleKeyFor(inlineAllStyles, ol)).toMatch(/(?:^|;)list-style-type:decimal(?:;|$)/)
+  })
+
+  it('does not share snapshots between siblings selected by position', async () => {
+    const root = mount(
+      '.jr-pos>li:nth-child(even){color:rgb(255,0,0);letter-spacing:5px}',
+      '',
+    )
+    const [odd, even] = root.querySelectorAll('li')
+    const { inlineAllStyles } = await freshStylesModule()
+
+    expect(getComputedStyle(odd).color).not.toBe(getComputedStyle(even).color)
+    const oddKey = await styleKeyFor(inlineAllStyles, odd)
+    const evenKey = await styleKeyFor(inlineAllStyles, even)
+
+    expect(evenKey).not.toBe(oddKey)
+    expect(evenKey).toMatch(/(?:^|;)letter-spacing:5px(?:;|$)/)
+  })
+
+  it('does not share used geometry between same-class siblings with different content', async () => {
+    const root = mount(
+      '.jr-card{width:100px;background:#eee;font:16px/20px Arial}',
+      '
one
' + + '
one
two
three
', + ) + const [shortCard, tallCard] = root.querySelectorAll('.jr-card') + const { inlineAllStyles } = await freshStylesModule() + const shortHeight = getComputedStyle(shortCard).height + const tallHeight = getComputedStyle(tallCard).height + + expect(parseFloat(tallHeight)).toBeGreaterThan(parseFloat(shortHeight)) + const shortKey = await styleKeyFor(inlineAllStyles, shortCard) + const tallKey = await styleKeyFor(inlineAllStyles, tallCard) + + expect(propertyFromKey(shortKey, 'height')).toBe(shortHeight) + expect(propertyFromKey(tallKey, 'height')).toBe(tallHeight) + expect(tallKey).not.toBe(shortKey) + }) +}) diff --git a/__tests__/module.styles.residual.test.js b/__tests__/module.styles.residual.test.js new file mode 100644 index 00000000..820972da --- /dev/null +++ b/__tests__/module.styles.residual.test.js @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +function freshSession() { + return { styleMap: new Map(), styleCache: new WeakMap(), nodeMap: new Map() } +} +async function freshStylesModule() { + vi.resetModules() + return import('../src/modules/styles.js') +} +async function styleKeyFor(inlineAllStyles, source) { + const clone = source.cloneNode(true) + const session = freshSession() + await inlineAllStyles(source, clone, session) + return session.styleMap.get(clone) || '' +} + +describe('residual fidelity: nested shadows and CSSOM', () => { + const mounted = [] + afterEach(() => { + for (const n of mounted) n.remove() + mounted.length = 0 + delete window.__SNAPDOM_FULL_PROPS + vi.restoreAllMocks() + }) + function mount(css, html) { + const sheet = document.createElement('style') + sheet.textContent = css + document.head.appendChild(sheet) + const root = document.createElement('div') + root.innerHTML = html + document.body.appendChild(root) + mounted.push(root, sheet) + return root + } + + it('sees styles from nested open shadow roots', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + mounted.push(host) + const outer = host.attachShadow({ mode: 'open' }) + const innerHost = document.createElement('div') + innerHost.id = 'innerHost' + outer.appendChild(innerHost) + const inner = innerHost.attachShadow({ mode: 'open' }) + const style = document.createElement('style') + style.textContent = '#deep{letter-spacing:9px}' + inner.appendChild(style) + const deep = document.createElement('span') + deep.id = 'deep' + deep.textContent = 'hi' + inner.appendChild(deep) + // Ensure computed reflects + expect(getComputedStyle(deep).letterSpacing).toBe('9px') + const { inlineAllStyles, seedUsedProps } = await freshStylesModule() + // seed from the deep element's root (its doc is same, but host chain includes nested) + // seedUsedProps should walk nested shadows when scanning doc, but we also need to ensure + // inlineAllStyles for deep sees the style + // Use the deep element as source; its ownerDocument is top doc, and nested shadow styles are in that doc's tree + // We call seedUsedProps on the host to prime doc state + seedUsedProps(host) + const key = await styleKeyFor(inlineAllStyles, deep) + expect(key).toMatch(/letter-spacing:9px/) + }) + + it('picks up insertRule without DOM mutation', async () => { + const root = mount('', '
x
') + const el = root.querySelector('.jr-insert') + const sheet = document.createElement('style') + sheet.textContent = '.jr-insert{color:rgb(10,10,10)}' + document.head.appendChild(sheet) + mounted.push(sheet) + const { inlineAllStyles, seedUsedProps } = await freshStylesModule() + seedUsedProps(root) + const before = await styleKeyFor(inlineAllStyles, el) + expect(before).toMatch(/color:/) + // Insert new rule with previously unused property + sheet.sheet.insertRule('.jr-insert{letter-spacing:13px}', 0) + // No DOM mutation, only CSSOM — seed should detect fingerprint change and bump epoch + seedUsedProps(root) + const after = await styleKeyFor(inlineAllStyles, el) + expect(getComputedStyle(el).letterSpacing).toBe('13px') + expect(after).toMatch(/letter-spacing:13px/) + // cleanup rule + try { sheet.sheet.deleteRule(0) } catch {} + }) + + it('drops deleted rule', async () => { + const sheet = document.createElement('style') + sheet.textContent = '.jr-del{word-spacing:11px}' + document.head.appendChild(sheet) + mounted.push(sheet) + const root = mount('', '
x
') + const el = root.querySelector('.jr-del') + const { inlineAllStyles, seedUsedProps } = await freshStylesModule() + seedUsedProps(root) + const before = await styleKeyFor(inlineAllStyles, el) + expect(before).toMatch(/word-spacing:11px/) + // delete the rule that provided the property + try { sheet.sheet.deleteRule(0) } catch {} + expect(getComputedStyle(el).wordSpacing).not.toBe('11px') + seedUsedProps(root) + const after = await styleKeyFor(inlineAllStyles, el) + expect(after).not.toMatch(/word-spacing:11px/) + }) + + it('reacts to adoptedStyleSheets change', async () => { + if (!('adoptedStyleSheets' in document)) return + const root = mount('', '
adopted
') + const el = root.querySelector('.jr-adopt') + const sheet = new CSSStyleSheet() + sheet.replaceSync('.jr-adopt{column-gap:17px;display:grid}') + const prev = document.adoptedStyleSheets + document.adoptedStyleSheets = [...prev, sheet] + const { inlineAllStyles, seedUsedProps } = await freshStylesModule() + try { + seedUsedProps(root) + const key = await styleKeyFor(inlineAllStyles, el) + // column-gap is in MODULE_REQUIRED but also from sheet; ensure it appears + // display:grid triggers required props, column-gap should be captured + expect(getComputedStyle(el).columnGap).toBe('17px') + expect(key).toMatch(/column-gap:17px/) + } finally { + document.adoptedStyleSheets = prev + } + // after removal, next capture should not have it + const { seedUsedProps: seed2, inlineAllStyles: inl2 } = await freshStylesModule() + seed2(root) + const after = await styleKeyFor(inl2, el) + // column-gap from adopted sheet gone; computed may still be 0 or normal + // we just ensure no stale 17px remains if adopted sheet removed + if (getComputedStyle(el).columnGap === '17px') { + // if still present, sheet removal didn't take effect in this engine + } else { + expect(after).not.toMatch(/column-gap:17px/) + } + }) + + it('reacts to shadowRoot adoptedStyleSheets', async () => { + if (!('adoptedStyleSheets' in document)) return + const host = document.createElement('div') + document.body.appendChild(host) + mounted.push(host) + const sr = host.attachShadow({ mode: 'open' }) + const inner = document.createElement('span') + inner.className = 'jr-shadow-adopt' + inner.textContent = 'shadow' + sr.appendChild(inner) + const sheet = new CSSStyleSheet() + sheet.replaceSync('.jr-shadow-adopt{letter-spacing:19px}') + sr.adoptedStyleSheets = [sheet] + expect(getComputedStyle(inner).letterSpacing).toBe('19px') + const { inlineAllStyles, seedUsedProps } = await freshStylesModule() + seedUsedProps(host) + const key = await styleKeyFor(inlineAllStyles, inner) + expect(key).toMatch(/letter-spacing:19px/) + // cleanup + sr.adoptedStyleSheets = [] + }) + + it('bumps on rule.style value change', async () => { + const sheet = document.createElement('style') + sheet.textContent = '.jr-val{color:rgb(10, 20, 30)}' + document.head.appendChild(sheet) + mounted.push(sheet) + const root = mount('', '
val
') + const el = root.querySelector('.jr-val') + const { inlineAllStyles, seedUsedProps } = await freshStylesModule() + seedUsedProps(root) + const before = await styleKeyFor(inlineAllStyles, el) + expect(before).toMatch(/color:rgb\(10, 20, 30\)/) + // Change value via CSSOM, same property name + const rule = sheet.sheet.cssRules[0] + rule.style.setProperty('color', 'rgb(99, 88, 77)') + expect(getComputedStyle(el).color).toBe('rgb(99, 88, 77)') + seedUsedProps(root) + const after = await styleKeyFor(inlineAllStyles, el) + expect(after).toMatch(/color:rgb\(99, 88, 77\)/) + }) + + it('isolates per-document allow-list (iframe)', async () => { + const iframe = document.createElement('iframe') + document.body.appendChild(iframe) + mounted.push(iframe) + const idoc = iframe.contentDocument + if (!idoc || !idoc.body) return + const style = idoc.createElement('style') + style.textContent = '.jr-iframe-only{letter-spacing:23px}' + idoc.head.appendChild(style) + const inner = idoc.createElement('div') + inner.className = 'jr-iframe-only' + inner.textContent = 'iframe' + idoc.body.appendChild(inner) + expect(idoc.defaultView.getComputedStyle(inner).letterSpacing).toBe('23px') + // Top document should not get this property + const topRoot = mount('', '
top
') + const topEl = topRoot.querySelector('.jr-iframe-only') + expect(getComputedStyle(topEl).letterSpacing).not.toBe('23px') + const { inlineAllStyles, seedUsedProps } = await freshStylesModule() + // Seed for iframe doc + seedUsedProps(inner) + const iframeKey = await styleKeyFor(inlineAllStyles, inner) + expect(iframeKey).toMatch(/letter-spacing:23px/) + // Seed for top doc + seedUsedProps(topRoot) + const topKey = await styleKeyFor(inlineAllStyles, topEl) + expect(topKey).not.toMatch(/letter-spacing:23px/) + }) + + it('emits shorthand-authored values as longhands, matching the full read', async () => { + // Shorthands never enter the allow-set (only their SHORTHAND_EXPANSIONS longhands do), + // so snapshots stay shorthand-free. getStyleKey must emit identical CSS from longhands. + const root = mount( + '.jr-short{margin:10px 20px;padding:5px;border:2px solid rgb(255,0,0);background:rgb(0,238,0);overflow:hidden}', + '
x
', + ) + const el = root.querySelector('.jr-short') + const { inlineAllStyles, seedUsedProps, notifyStyleEpoch } = await freshStylesModule() + seedUsedProps(root) + const key = await styleKeyFor(inlineAllStyles, el) + for (const entry of [ + 'margin-top:10px', 'margin-right:20px', 'padding-top:5px', + 'border-top-width:2px', 'background-color:rgb(0, 238, 0)', 'overflow-x:hidden', + ]) { + expect(key.includes(entry), `allow key carries ${entry}`).toBe(true) + } + // Pixel-equivalence: every longhand value agrees with the full computed-style read. + window.__SNAPDOM_FULL_PROPS = true + notifyStyleEpoch() + const fullKey = await styleKeyFor(inlineAllStyles, el) + const valOf = (k, p) => (k.match(new RegExp(`(?:^|;)${p}:([^;]+)`)) || [])[1] || null + for (const prop of ['margin-top', 'margin-right', 'padding-top', 'border-top-width', + 'border-top-style', 'background-color', 'overflow-x', 'overflow-y']) { + expect(valOf(key, prop), `allow vs full agree on ${prop}`).toBe(valOf(fullKey, prop)) + } + }) +}) diff --git a/__tests__/utils.css.splitcache.test.js b/__tests__/utils.css.splitcache.test.js new file mode 100644 index 00000000..22efd4cf --- /dev/null +++ b/__tests__/utils.css.splitcache.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { getStyle, _invalidateSplitCaches } from '../src/utils/css.js' + +describe('css split caches — epoch invalidation guard (§8)', () => { + it('emptyStyle singleton is frozen (mutation would corrupt singleton)', () => { + const s1 = getStyle(document.createElement('div')) + // getStyle for element with no nodeType returns emptyStyle + const empty = getStyle(null) + expect(Object.isFrozen(empty)).toBe(true) + }) + it('_invalidateSplitCaches exists and is callable', () => { + expect(typeof _invalidateSplitCaches).toBe('function') + expect(() => _invalidateSplitCaches()).not.toThrow() + }) + it('getStyle returns fresh after invalidation', () => { + const el = document.createElement('div') + el.style.color = 'rgb(255, 0, 0)' + document.body.appendChild(el) + const c1 = getStyle(el).color + _invalidateSplitCaches() + el.style.color = 'rgb(0, 0, 255)' + const c2 = getStyle(el).color + // After invalidation, should reflect new color, not stale + expect(c2).toBe('rgb(0, 0, 255)') + el.remove() + }) +}) diff --git a/src/core/burst.js b/src/core/burst.js index a989ab96..32db9961 100644 --- a/src/core/burst.js +++ b/src/core/burst.js @@ -21,6 +21,41 @@ import { hasExternalMutation } from '../modules/styles.js' const burstStates = new WeakMap() +/** Fast per-element digest for delta validation: 8 geometry reads + ~10 style props, + * ~30x cheaper than full snapshotComputedStyleFull (~350 props). Used before + * returning a memoized burst result to detect silent reflow/scroll/pseudo/animation + * changes that MutationObserver misses. Not a full replacement for the capture — + * only a soundness gate before using the cached result. + */ +function computeDigest(el) { + try { + const r = el.getBoundingClientRect() + const cs = window.getComputedStyle ? window.getComputedStyle(el) : null + let geo = '' + geo += `l:${Math.round(r.left)}` + geo += `t:${Math.round(r.top)}` + geo += `w:${Math.round(r.width)}` + geo += `h:${Math.round(r.height)}` + geo += `sw:${el.scrollWidth || 0}` + geo += `sh:${el.scrollHeight || 0}` + geo += `sl:${el.scrollLeft || 0}` + geo += `st:${el.scrollTop || 0}` + let paint = '' + if (cs) { + paint += `c:${cs.color || ''}` + paint += `bg:${cs.backgroundColor || ''}` + paint += `op:${cs.opacity || ''}` + paint += `tf:${cs.transform || ''}` + paint += `vis:${cs.visibility || ''}` + paint += `anim:${(cs.animation || '').slice(0, 8)}` + paint += `hover:${cs.getPropertyValue(':hover') || ''}` // placeholder; real pseudo read is expensive — skip for speed + } + return geo + '|' + paint + } catch { + return 'digest-error' + } +} + function trackVideos(element, state, onMediaDirty) { const videos = new Set() if (element instanceof HTMLVideoElement) videos.add(element) @@ -74,11 +109,12 @@ function createState(element) { } } catch { /* head not observable — subtree observer still applies */ } - state.markDirty = markDirty - state.onMediaDirty = onMediaDirty - trackVideos(element, state, onMediaDirty) - return state -} + state.markDirty = markDirty + state.onMediaDirty = onMediaDirty + state.lastDigest = null // initial + trackVideos(element, state, onMediaDirty) + return state + } /** Stable signature of every option except burst/invalidate themselves, so a one-off call * with different options (e.g. `{ burst: true, scale: 2 }` once) is detected as such. */ @@ -114,13 +150,28 @@ export function captureWithBurst(element, userOptions, context, runCapture) { const run = async () => { for (const o of state.observers) state.markDirty(o.takeRecords()) if (context.invalidate) state.dirty = true - if (!isOneOff && !state.dirty && state.last) return state.last + // Two-tier validate: mutation observer (cheap) + digest (fast) before using cached result. + // Digest covers silent reflow, scroll, pseudo-state, animation, CSSOM changes that observer misses. + if (!isOneOff && !state.dirty && state.last) { + const freshDigest = computeDigest(element) + if (state.lastDigest && freshDigest === state.lastDigest && freshDigest !== 'digest-error') { + // Digest unchanged: same paint + geometry. Return memoized result (fast path). + return state.last + } + // Digest diverged from last capture: real-world mutation or reflow occurred. + // Force a fresh capture; next successful result updates digest. + state.dirty = true + } state.capturing = true if (!isOneOff) state.dirty = false try { const result = await runCapture() - if (!isOneOff) state.last = result + if (!isOneOff) { + state.last = result + state.lastDigest = computeDigest(element) + state.dirty = false + } return result } finally { for (const o of state.observers) o.takeRecords() // drop the capture's own records diff --git a/src/core/capture.js b/src/core/capture.js index 8afe6e6d..7c32e7fa 100644 --- a/src/core/capture.js +++ b/src/core/capture.js @@ -139,7 +139,7 @@ export async function captureDOM(element, options) { const preClipRect = options.clip ? resolveClipRect(element, options.clip) : null let state = { element, options, plugins: options.plugins } - let clone, classCSS, styleCache, nodeMap, reconcileRisk, clipWindow + let clone, classCSS, styleCache, nodeMap, reconcileRisk, clipWindow, tagSet let fontsCSS = '' let baseCSS = '' let dataURL @@ -162,7 +162,8 @@ export async function captureDOM(element, options) { // Keep this capture's own clone→source map: nested iframe captures reassign // cache.session.nodeMap concurrently (see rasterizeIframe), so the global cannot be // trusted after the clone phase — every later pass must use this reference. - ({ clone, classCSS, styleCache, nodeMap, reconcileRisk, clipWindow } = await prepareClone(state.element, state.options)) + ({ clone, classCSS, styleCache, nodeMap, reconcileRisk, clipWindow, tagSet } = await +prepareClone(state.element, state.options)) if (reconcileRisk > 0 && !options.reconcile && !cache.warnedReconcile) { cache.warnedReconcile = true @@ -267,7 +268,14 @@ export async function captureDOM(element, options) { await Promise.all([assetsPhase, fontsPhase]) - const usedTags = collectUsedTagNames(state.clone).sort() + // Prefer the tag set collected incrementally during deepClone (clone.js _track), which is + // free — it avoids a full querySelectorAll('*') walk of the clone here. Falls back to the + // walk when unavailable. tagSet may include tags for nodes later dropped (style nodes + // removed in prepare, pseudo spans); extra tags only produce extra base-CSS rules, and + // NO_DEFAULTS_TAGS/empty-key guards drop the irrelevant ones. + const usedTags = (tagSet && tagSet.size) + ? Array.from(tagSet).sort() + : collectUsedTagNames(state.clone).sort() const tagKey = usedTags.join(',') if (cache.baseStyle.has(tagKey)) { baseCSS = cache.baseStyle.get(tagKey) diff --git a/src/core/clone.js b/src/core/clone.js index c7f36bcf..b55440ce 100644 --- a/src/core/clone.js +++ b/src/core/clone.js @@ -3,7 +3,7 @@ * @module clone */ -import { inlineAllStyles } from '../modules/styles.js' +import { inlineAllStyles, needsBackgroundInline } from '../modules/styles.js' import { NO_CAPTURE_TAGS } from '../utils/css.js' import { resolveCSSVars, isInSvgTemplate } from '../modules/CSSVar.js' import { debugWarn, getStyle } from '../utils/index.js' @@ -96,36 +96,42 @@ function intersectsClip(b, rect) { * @param {{rect: {left:number,top:number,right:number,bottom:number}, root: Element}} clip * @returns {boolean} */ -function isOutsideClip(node, clip) { - if (node === clip.root) return false - let r - try { r = node.getBoundingClientRect() } catch { return false } - if (r.width === 0 && r.height === 0) return false +function _nodeBox(node, _rect) { + let r; try { r = node.getBoundingClientRect() } catch { return null } + if (r.width === 0 && r.height === 0) return null const cs = getStyle(node) - if (cs.display === 'inline' && !CLIP_REPLACED_TAGS.has((node.localName || '').toLowerCase())) return false - const rect = clip.rect - // Scroll overflow grows right/down in horizontal-ltr; mirror for rtl / vertical modes. - const sw = node.scrollWidth || 0 - const sh = node.scrollHeight || 0 - const box = { - left: cs.direction === 'rtl' ? Math.min(r.left, r.right - sw) : r.left, - top: r.top, - right: Math.max(r.right, r.left + sw), - bottom: Math.max(r.bottom, r.top + sh) - } + const sw = node.scrollWidth || 0, sh = node.scrollHeight || 0 + const box = { left: cs.direction === 'rtl' ? Math.min(r.left, r.right - sw) : r.left, top: r.top, right: Math.max(r.right, r.left + sw), bottom: Math.max(r.bottom, r.top + sh) } const wm = cs.writingMode || '' - if (wm.startsWith('vertical') || wm.startsWith('sideways')) { - box.top = Math.min(r.top, r.bottom - sh) - box.left = Math.min(box.left, r.right - sw) - } - if (intersectsClip(box, rect)) return false - // Escape scan: any descendant whose painted box reaches the window (gBCR reads only — - // no style/clone/inline work) vetoes the cull; deeper levels then cull its siblings. - const tw = (node.ownerDocument || document).createTreeWalker(node, NodeFilter.SHOW_ELEMENT) - while (tw.nextNode()) { - const dr = /** @type {Element} */ (tw.currentNode).getBoundingClientRect() - if ((dr.width > 0 || dr.height > 0) && intersectsClip(dr, rect)) return false + if (wm.startsWith('vertical') || wm.startsWith('sideways')) { box.top = Math.min(r.top, r.bottom - sh); box.left = Math.min(box.left, r.right - sw) } + return { box, isInlineNonReplaced: cs.display === 'inline' && !CLIP_REPLACED_TAGS.has((node.localName||'').toLowerCase()) } +} +function ensureClipSubtreeCache(clip, rootEl) { + if (clip._subtreeCache) return + const map = new WeakMap() + // bottom-up: reverse document order ensures children before parents + const all = [] + const tw = (rootEl.ownerDocument||document).createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT) + while (tw.nextNode()) all.push(tw.currentNode) + for (let i = all.length - 1; i >= 0; i--) { + const n = all[i] + const info = _nodeBox(n, clip.rect) + let hits = info && intersectsClip(info.box, clip.rect) + if (!hits) { + for (let c = n.firstElementChild; c; c = c.nextElementSibling) if (map.get(c)) { hits = true; break } + } + map.set(n, hits) } + clip._subtreeCache = map +} +function isOutsideClip(node, clip) { + if (node === clip.root) return false + const info = _nodeBox(node, clip.rect) + if (!info) return false + if (info.isInlineNonReplaced) return false + if (intersectsClip(info.box, clip.rect)) return false + ensureClipSubtreeCache(clip, clip.root) + if (clip._subtreeCache.get(node)) return false return true } @@ -170,6 +176,28 @@ export async function deepClone(node, sessionCache, options) { const clonedAssignedNodes = new Set() let pendingSelectValue = null let pendingTextAreaValue = null + // walk-fusion helpers: register clone tag + collect img/image lists to avoid later queries + const _track = (el) => { + try { + if (el && el.tagName && sessionCache.tagSet) sessionCache.tagSet.add(el.tagName.toLowerCase()) + if (el && el.tagName === 'IMG' && sessionCache.imgClones) sessionCache.imgClones.push(el) + if (el && el.localName === 'image' && sessionCache.svgImageClones) sessionCache.svgImageClones.push(el) + } catch {} + } + // Manual recursive walker: tracks the root + every descendant element via _track + // WITHOUT allocating a live NodeList (querySelectorAll('*') is O(subtree size) and + // was repeated per plugin hook / tag handler). Iterating node.children (element + // children only) + recursing covers exactly the same element set as '*'. + const trackSubtree = (node) => { + try { + _track(node) + const kids = node.children + for (let i = 0; i < kids.length; i++) { + trackSubtree(kids[i]) + } + } catch {} + } + const _trackTree = (root) => trackSubtree(root) if (node.nodeType === Node.ELEMENT_NODE) { const tag = (node.localName || node.tagName || '').toLowerCase() if (node.id === 'snapdom-sandbox' || node.hasAttribute('data-snapdom-sandbox')) { @@ -239,7 +267,9 @@ export async function deepClone(node, sessionCache, options) { // Clip mode: prune subtrees painting entirely outside the window (before any plugin // hooks or tag handlers — no per-node work is spent on culled content). if (sessionCache.clip && isOutsideClip(node, sessionCache.clip)) { - return makeClipHusk(node, sessionCache, options) + const husk = makeClipHusk(node, sessionCache, options) + _track(husk) + return husk } // Per-node plugin hook: the first plugin whose resolveNode returns a value wins // (Node = finished replacement clone, null = skip node, undefined = continue). @@ -253,10 +283,10 @@ export async function deepClone(node, sessionCache, options) { if (out === null) return null if (out instanceof Node) { if (out.nodeType === Node.ELEMENT_NODE) { - // Same treatment as built-in tag handlers: map to the source and carry its box - // styles so the replacement keeps the original layout. sessionCache.nodeMap.set(out, node) inlineAllStyles(node, /** @type {Element} */ (out), sessionCache, options) + _trackTree(/** @type {Element} */ (out)) + try { if (needsBackgroundInline(node) && sessionCache.bgClones) sessionCache.bgClones.push(/** @type {Element} */ (out)) } catch {} } return out } @@ -267,7 +297,13 @@ export async function deepClone(node, sessionCache, options) { const preHandler = PRE_PLACEHOLDER_TAGS.has(node.tagName) && tagHandlers.get(node.tagName) if (preHandler) { const handled = await preHandler(node, sessionCache, options) - if (handled !== undefined) return handled + if (handled !== undefined) { + if (handled instanceof Element) { + _trackTree(handled) + try { if (needsBackgroundInline(node) && sessionCache.bgClones) sessionCache.bgClones.push(handled) } catch {} + } + return handled + } } } @@ -275,10 +311,12 @@ export async function deepClone(node, sessionCache, options) { const clone2 = node.cloneNode(false) sessionCache.nodeMap.set(clone2, node) inlineAllStyles(node, clone2, sessionCache, options) + _track(clone2) const placeholder = document.createElement('div') placeholder.textContent = node.getAttribute('data-placeholder-text') || '' placeholder.style.cssText = 'color:#666;font-size:12px;text-align:center;line-height:1.4;padding:0.5em;box-sizing:border-box;' clone2.appendChild(placeholder) + _track(placeholder) return clone2 } @@ -286,13 +324,20 @@ export async function deepClone(node, sessionCache, options) { const handler = !PRE_PLACEHOLDER_TAGS.has(node.tagName) && tagHandlers.get(node.tagName) if (handler) { const handled = await handler(node, sessionCache, options) - if (handled !== undefined) return handled + if (handled !== undefined) { + if (handled instanceof Element) { + _trackTree(handled) + try { if (needsBackgroundInline(node) && sessionCache.bgClones) sessionCache.bgClones.push(handled) } catch {} + } + return handled + } } } let clone try { clone = node.cloneNode(false) + _track(clone) // ROB-3: strip XML 1.0 invalid control characters from attribute values. // These characters are legal in HTML but rejected by XMLSerializer, breaking the SVG output. // Most common in data-* attributes with user-generated content. @@ -327,7 +372,9 @@ export async function deepClone(node, sessionCache, options) { // escribimos px en línea para evitar que el clon “pierda” la imagen. try { const authored = node.getAttribute('style') || '' - const cs = window.getComputedStyle(node) + // Cached (and iframe-window-correct) read: the same declaration inlineAllStyles + // snapshots below, instead of a second uncached getComputedStyle per IMG. + const cs = getStyle(node) const usesPercentOrAuto = (prop) => { const a = authored.match(new RegExp(`${prop}\\s*:\\s*([^;]+)`, 'i')) const v = a ? a[1].trim() : cs.getPropertyValue(prop) @@ -379,6 +426,7 @@ export async function deepClone(node, sessionCache, options) { if (isCheckboxOrRadio && isFirefox()) { const { el: replacement, applyVisual } = createCheckboxRadioReplacement(node) sessionCache.nodeMap.set(replacement, node) + _trackTree(replacement) applyInputVisual = applyVisual clone = replacement } else { @@ -395,7 +443,7 @@ export async function deepClone(node, sessionCache, options) { // #315: Preserve ::placeholder color for inputs/textareas showing placeholder text if ((node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && !node.value && node.placeholder) { try { - const phStyle = window.getComputedStyle(node, '::placeholder') + const phStyle = getStyle(node, '::placeholder') const phColor = phStyle && phStyle.color if (phColor && phColor !== 'rgba(0, 0, 0, 0)') { const uid = 'snapdom-ph-' + (Math.random() * 1e6 | 0) @@ -433,6 +481,18 @@ export async function deepClone(node, sessionCache, options) { inlineAllStyles(node, clone, sessionCache, options) } if (applyInputVisual) { applyInputVisual() } + // walk-fusion: collect background-inline candidates to avoid later tree walk + try { if (needsBackgroundInline(node) && sessionCache.bgClones) sessionCache.bgClones.push(clone) } catch {} + // walk-fusion: collect blob URL nodes to avoid later 5x querySelectorAll in resolveBlobUrlsInTree + try { + const _hasBlob = (node.getAttribute?.('src')||'').includes('blob:') || + (node.getAttribute?.('srcset')||'').includes('blob:') || + (node.getAttribute?.('href')||'').includes('blob:') || + (node.getAttribute?.('poster')||'').includes('blob:') || + (node.getAttribute?.('style')||'').includes('blob:') || + (node.tagName==='STYLE' && (node.textContent||'').includes('blob:')) + if (_hasBlob && sessionCache.blobNodes) sessionCache.blobNodes.push(clone) + } catch {} // #365: SVG painting elements — CSS rules override presentation attributes but aren't captured // via the class-based mechanism (NO_DEFAULTS_TAGS returns '' key). Copy key SVG presentation // properties from computed style as inline styles to ensure CSS-driven fills/strokes survive. @@ -446,7 +506,11 @@ export async function deepClone(node, sessionCache, options) { 'marker', 'marker-start', 'marker-mid', 'marker-end', 'visibility', 'display' ] try { - const cs = window.getComputedStyle(node) + // Reuse the memoized computed style (cache.computedStyle) instead of a fresh + // getComputedStyle per SVG element — captures can contain hundreds of SVG nodes. + // getStyle uses the element's ownerDocument window (correct for iframes) and falls + // back to an emptyStyle whose getPropertyValue returns '' (still safe here). + const cs = getStyle(node) for (const prop of SVG_PAINT_PROPS) { const val = cs.getPropertyValue(prop) if (val) clone.style.setProperty(prop, val) @@ -477,7 +541,8 @@ export async function deepClone(node, sessionCache, options) { const rewritten = rewriteShadowCSS(rawCSS, scopeSelector, scopeId) const neededVars = collectCustomPropsFromCSS(rawCSS) const seed = buildSeedCustomPropsRule(node, neededVars, scopeSelector) - injectScopedStyle(clone, seed + rewritten, scopeId) + const _injected = injectScopedStyle(clone, seed + rewritten, scopeId) + if (_injected && sessionCache.shadowStyleNodes) sessionCache.shadowStyleNodes.push(_injected) const shadowFrag = document.createDocumentFragment() // const, not a declaration: esbuild lowers block-level function declarations to a // hoisted `var` of the same name, which would clobber the walker below. diff --git a/src/core/prepare.js b/src/core/prepare.js index 6b39eb1c..1d8051d5 100644 --- a/src/core/prepare.js +++ b/src/core/prepare.js @@ -12,6 +12,7 @@ import { resolveBlobUrlsInTree } from '../utils/clone.helpers.js' import { stabilizeLayout, forceContentVisibility } from '../utils/prepare.helpers.js' import { resolveClipRect, freezeViewportPositioned } from '../utils/capture.helpers.js' import { nextFrame } from '../utils/browser.js' +import { seedUsedProps } from '../modules/styles.js' const visibilityWarmups = new Set() @@ -34,6 +35,12 @@ export async function prepareClone(element, options = {}) { styleMap: session.styleMap, styleCache: session.styleCache, nodeMap: session.nodeMap, + tagSet: new Set(), + shadowStyleNodes: [], + imgClones: [], + svgImageClones: [], + bgClones: [], + blobNodes: [], options } @@ -180,6 +187,11 @@ export async function prepareClone(element, options = {}) { const undoStabilizeLayout = stabilizeLayout(element) + // CSSOM fingerprint + allow-list seeding must happen at capture start, before any + // computed-style reads, so insertRule/deleteRule/replaceSync/adoptedStyleSheets + // changes are visible even though they do not fire MutationObserver. + try { seedUsedProps(element) } catch {} + // #281: Force content-visibility:visible so Safari/Chromium don't skip offscreen elements. // Clip mode skips this O(page) walk: on-screen cv:auto content is already rendered by the // browser, and offscreen content gets culled anyway (cv's placeholder box culls correctly). @@ -213,10 +225,13 @@ export async function prepareClone(element, options = {}) { // --- Pull shadow-scoped CSS out of the clone (avoid visible CSS text) --- try { - const styleNodes = clone.querySelectorAll('style[data-sd]') + // walk-fusion: reuse shadow style nodes collected during deepClone (avoids querySelectorAll) + const styleNodes = (sessionCache.shadowStyleNodes && sessionCache.shadowStyleNodes.length) + ? sessionCache.shadowStyleNodes + : (clone.querySelectorAll ? clone.querySelectorAll('style[data-sd]') : []) for (const s of styleNodes) { shadowScopedCSS += s.textContent || '' - s.remove() // Do not leave