Skip to content

Commit 6d1ad72

Browse files
tinchox5claude
andcommitted
perf: trim redundant per-node work on the capture hot path
Five hot-path reductions, behavior-preserving: - background.js: copy the background layout longhands (position/size/repeat/ origin/clip/...) only when the node actually has a background. They are inert without one yet never empty, so they were written to every clone node, bloating the foreignObject and rasterization. background-color is included in the gate so background-clip:text still works. - styles.js: isFlexOrGridItem uses the cached getStyle instead of a raw getComputedStyle per node (ran even on snapshot-cache hits). - transforms.helpers.js: hasBBoxAffectingTransform uses cached getStyle, reusing the root style captureDOM already read (was resolved twice on Safari). - pseudo.js: shouldProcessPseudos accepts the fingerprint preflightWithFp already computed, instead of recomputing it (a querySelectorAll + rule scan). - clone.js: the three identical exclude/filter 'hide' spacer blocks are one helper that reads layout at most once instead of up to twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0623bcd commit 6d1ad72

5 files changed

Lines changed: 50 additions & 29 deletions

File tree

src/core/clone.js

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,26 @@ import { isFirefox } from '../utils/browser.js'
2525

2626
// helper implementations moved to ../utils/clone.helpers.js
2727

28+
/**
29+
* Build a hidden, layout-preserving spacer matching a node's unscaled box, used when a node is
30+
* excluded/filtered in 'hide' mode. Forces at most one getBoundingClientRect (the inline form
31+
* could read it twice per node in the hot path).
32+
* @param {Element} node
33+
* @returns {HTMLDivElement}
34+
*/
35+
function makeHideSpacer(node) {
36+
const { width, height } = getUnscaledDimensions(node)
37+
let w = width, h = height
38+
if (!w || !h) {
39+
const rect = node.getBoundingClientRect()
40+
w = w || rect.width || 0
41+
h = h || rect.height || 0
42+
}
43+
const spacer = document.createElement('div')
44+
spacer.style.cssText = `display:inline-block;width:${w}px;height:${h}px;visibility:hidden;`
45+
return spacer
46+
}
47+
2848
export async function deepClone(node, sessionCache, options) {
2949
if (!node) throw new Error('Invalid node')
3050
const clonedAssignedNodes = new Set()
@@ -55,12 +75,7 @@ export async function deepClone(node, sessionCache, options) {
5575
}
5676
if (node.getAttribute('data-capture') === 'exclude') {
5777
if (options.excludeMode === 'hide') {
58-
const spacer = document.createElement('div')
59-
const { width, height } = getUnscaledDimensions(node)
60-
const w = width || node.getBoundingClientRect().width || 0
61-
const h = height || node.getBoundingClientRect().height || 0
62-
spacer.style.cssText = `display:inline-block;width:${w}px;height:${h}px;visibility:hidden;`
63-
return spacer
78+
return makeHideSpacer(node)
6479
} else if (options.excludeMode === 'remove') {
6580
return null
6681
}
@@ -70,12 +85,7 @@ export async function deepClone(node, sessionCache, options) {
7085
try {
7186
if (node.matches?.(selector)) {
7287
if (options.excludeMode === 'hide') {
73-
const spacer = document.createElement('div')
74-
const { width, height } = getUnscaledDimensions(node)
75-
const w = width || node.getBoundingClientRect().width || 0
76-
const h = height || node.getBoundingClientRect().height || 0
77-
spacer.style.cssText = `display:inline-block;width:${w}px;height:${h}px;visibility:hidden;`
78-
return spacer
88+
return makeHideSpacer(node)
7989
} else if (options.excludeMode === 'remove') {
8090
return null
8191
}
@@ -89,12 +99,7 @@ export async function deepClone(node, sessionCache, options) {
8999
try {
90100
if (!options.filter(node)) {
91101
if (options.filterMode === 'hide') {
92-
const spacer = document.createElement('div')
93-
const { width, height } = getUnscaledDimensions(node)
94-
const w = width || node.getBoundingClientRect().width || 0
95-
const h = height || node.getBoundingClientRect().height || 0
96-
spacer.style.cssText = `display:inline-block;width:${w}px;height:${h}px;visibility:hidden;`
97-
return spacer
102+
return makeHideSpacer(node)
98103
} else if (options.filterMode === 'remove') {
99104
return null
100105
}

src/modules/background.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,22 @@ export async function inlineBackgroundImages(source, clone, styleCache, options
103103
const bis = style.getPropertyValue('border-image-source')
104104
return (bi && bi !== 'none') || (bis && bis !== 'none')
105105
})()
106-
for (const prop of BG_LAYOUT_PROPS) {
107-
const v = style.getPropertyValue(prop)
108-
if (!v) continue
109-
cloneNode.style.setProperty(prop, v)
106+
// Background layout longhands (position/size/repeat/origin/clip/...) are inert without a
107+
// background, yet are never empty, so copying them onto every node bloated the markup and
108+
// rasterization cost. Copy only when a background actually exists. background-color is
109+
// included so the background-clip:text trick (color clipped to text) still works.
110+
const bgImage = style.getPropertyValue('background-image')
111+
const bgColor = style.getPropertyValue('background-color')
112+
const hasBg =
113+
(bgImage && bgImage !== 'none') ||
114+
(bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') ||
115+
/url\s*\(|gradient\s*\(/i.test(style.getPropertyValue('background') || '')
116+
if (hasBg) {
117+
for (const prop of BG_LAYOUT_PROPS) {
118+
const v = style.getPropertyValue(prop)
119+
if (!v) continue
120+
cloneNode.style.setProperty(prop, v)
121+
}
110122
}
111123
// 1) Inline URL-bearing properties
112124
for (const prop of URL_PROPS) {

src/modules/pseudo.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ const CSS_RULE_SCAN_BUDGET = 1000
3939
*/
4040
function preflightWithFp(doc, sessionCache) {
4141
const fp = styleFingerprint(doc)
42-
if (!sessionCache) return shouldProcessPseudos(doc)
42+
if (!sessionCache) return shouldProcessPseudos(doc, fp)
4343
// Recompute when the fingerprint changes
4444
if (sessionCache.__pseudoPreflightFp !== fp) {
45-
sessionCache.__pseudoPreflight = shouldProcessPseudos(doc)
45+
sessionCache.__pseudoPreflight = shouldProcessPseudos(doc, fp)
4646
sessionCache.__pseudoPreflightFp = fp
4747
}
4848
return !!sessionCache.__pseudoPreflight
@@ -153,8 +153,7 @@ function sheetHasNeedles(sheet, needles, state) {
153153
* @param {Document} doc
154154
* @returns {boolean}
155155
*/
156-
export function shouldProcessPseudos(doc = document) {
157-
const fp = styleFingerprint(doc)
156+
export function shouldProcessPseudos(doc = document, fp = styleFingerprint(doc)) {
158157
const memo = __preflightMemo.get(doc)
159158
if (memo && memo.fingerprint === fp) return memo.result
160159

src/modules/styles.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getStyleKey, shouldIgnoreProp } from '../utils/index.js'
1+
import { getStyleKey, shouldIgnoreProp, getStyle } from '../utils/index.js'
22
import { cache } from '../core/cache.js'
33

44
const snapshotCache = new WeakMap()
@@ -303,7 +303,9 @@ function hasBox(cs) {
303303
function isFlexOrGridItem(el) {
304304
const p = el.parentElement
305305
if (!p) return false
306-
const pd = getComputedStyle(p).display || ''
306+
// getStyle memoizes in cache.computedStyle; raw getComputedStyle forced a fresh resolution
307+
// per node on every capture (even on snapshot-cache hits).
308+
const pd = getStyle(p).display || ''
307309
return pd.includes('flex') || pd.includes('grid')
308310
}
309311

src/utils/transforms.helpers.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import { limitDecimals } from './capture.helpers.js'
7+
import { getStyle } from './css.js'
78

89
/**
910
* Parse box-shadow and calculate bleed dimensions
@@ -396,7 +397,9 @@ export function readTotalTransformMatrix(t) {
396397
* @param {Element} el
397398
*/
398399
export function hasBBoxAffectingTransform(el) {
399-
const cs = getComputedStyle(el)
400+
// getStyle is cached (cache.computedStyle); on the root this reuses the csEl already read by
401+
// captureDOM instead of forcing a fresh resolution per call (twice on Safari defaults).
402+
const cs = getStyle(el)
400403
const t = cs.transform || 'none'
401404

402405
// Matrix identity or none => might still have individual transforms

0 commit comments

Comments
 (0)