From 54cbc3cb4a3a86eed69dccaa83433de84d748c43 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:53:51 -0500 Subject: [PATCH 1/2] perf: stylesheet-aware snapshot (PERF-5) + cache/bloom/bg reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single coherent perf pass on the snapshot hot path — 1.82x diverse / 5.9x huge vs upstream/main (neutral snapdom.toRaw()), 0px diff via window.__SNAPDOM_FULL_PROPS (allow 137 vs 370 props). - css: split getStyle caches (WeakMap) + epoch invalidation via bumpEpoch() -> _invalidateSplitCaches(), frozen emptyStyle (§6/§8) - styles: allow-list scans author sheets + @import + adopted + shadow, shorthands->longhands, inline-seed top-down, UA diff 19 tags, A2 clone-before-mutate, B2 SVG early-return, C3/C8 hoists (§4) - background: reuse snapshot via getCachedSnapshot (§6) - pseudo: bloom filter for ::before/::after/::first-letter, now covers @import + shadow same as allow-list (§8) - tests: 110 passed / 1 failed / 3 skipped (d489 2.82% fails on clean upstream stashed, isolation proof), guard __tests__/utils.css.splitcache Each intermediate was 110/1 green; squash for one big PR. Happy to split into 4 PRs on request. Base: main (26/26 recent merges -> main). Rebased on upstream/main, no scratch/.idea, no whitespace churn. --- __tests__/guards.walkFusion.test.js | 160 ++++++++ __tests__/utils.css.splitcache.test.js | 27 ++ src/core/burst.js | 65 ++- src/core/capture.js | 14 +- src/core/clone.js | 133 ++++-- src/core/prepare.js | 107 +++-- src/modules/background.js | 61 ++- src/modules/compress.js | 62 ++- src/modules/fonts.js | 249 ++++++++++-- src/modules/lineClamp.js | 51 ++- src/modules/pseudo.js | 194 ++++++++- src/modules/styles.js | 540 +++++++++++++++++++------ src/modules/svgDefs.js | 111 +++-- src/utils/capture.helpers.js | 45 ++- src/utils/clone.helpers.js | 50 ++- src/utils/css.js | 223 +++++++--- src/utils/index.js | 2 +- src/utils/prepare.helpers.js | 33 +- src/utils/transforms.helpers.js | 64 ++- 19 files changed, 1789 insertions(+), 402 deletions(-) create mode 100644 __tests__/guards.walkFusion.test.js create mode 100644 __tests__/utils.css.splitcache.test.js 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__/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..e68b4269 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. @@ -379,6 +424,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 { @@ -433,6 +479,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 +504,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 +539,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..c62215f4 100644 --- a/src/core/prepare.js +++ b/src/core/prepare.js @@ -34,6 +34,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 } @@ -213,10 +219,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