Skip to content

Commit f7274f7

Browse files
tinchox5claude
andcommitted
perf: fuse tree passes — single-walk font usage collector, snapshot-flagged background pass
- collectFontUsage: one TreeWalker collects font variants AND used codepoints, reading the memoized getStyle cache instead of fresh getComputedStyle per node (+2 pseudos each). collectUsedFontVariants/collectUsedCodepoints delegate to it. - Style snapshot now records a non-enumerable __needsBgInline flag (bg/mask/border-image or bg-color needing layout longhands). inlineBackgroundImages builds its worklist from the session nodeMap filtered by that flag instead of re-reading ~40 computed props per node. Walking the clone tree also reaches subtrees the old parallel walk skipped (children moved into scroll-translate wrappers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b7fedc7 commit f7274f7

4 files changed

Lines changed: 263 additions & 209 deletions

File tree

src/core/capture.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { inlineImages } from '../modules/images.js'
88
import { inlineBackgroundImages } from '../modules/background.js'
99
import { ligatureIconToImage } from '../modules/iconFonts.js'
1010
import { idle, collectUsedTagNames, generateDedupedBaseCSS, isSafari, getStyle } from '../utils/index.js'
11-
import { embedCustomFonts, collectUsedFontVariants, collectUsedCodepoints, ensureFontsReady } from '../modules/fonts.js'
11+
import { embedCustomFonts, collectFontUsage, ensureFontsReady } from '../modules/fonts.js'
1212
import { cache, applyCachePolicy } from '../core/cache.js'
1313
import { lineClampTree } from '../modules/lineClamp.js'
1414
import { runHook, getGlobalPlugins, normalizePlugin } from './plugins.js'
@@ -153,8 +153,7 @@ export async function captureDOM(element, options) {
153153
fontsPhase = runIdle(async () => {
154154
// #441: read fonts from the element's own document (same-origin iframe support)
155155
const ownerDoc = state.element.ownerDocument || document
156-
const required = collectUsedFontVariants(state.element)
157-
const usedCodepoints = collectUsedCodepoints(state.element)
156+
const { required, usedCodepoints } = collectFontUsage(state.element)
158157
if (isSafari()) {
159158
const families = new Set(
160159
Array.from(required).map((k) => String(k).split('__')[0]).filter(Boolean)

src/modules/background.js

Lines changed: 156 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -5,178 +5,180 @@
55

66
import { getStyle, inlineSingleBackgroundEntry, splitBackgroundImage } from '../utils'
77
import { cache } from '../core/cache.js'
8+
import { needsBackgroundInline } from './styles.js'
89

9-
/**
10-
* Recursively inlines background-related images and masks from the source element to its clone.
11-
*
12-
* This function walks through the source DOM tree and its clone, copying inline styles for
13-
* background images, masks, and border images to ensure the clone retains all visual image
14-
* resources inline (e.g., data URLs), avoiding external dependencies.
15-
*
16-
* It also preserves the `background-color` property if it is not transparent.
17-
*
18-
* Special handling is done for `border-image` related properties: the
19-
* `border-image-slice`, `border-image-width`, `border-image-outset`, and `border-image-repeat`
20-
* are only copied if `border-image` or `border-image-source` are present and active.
21-
*
22-
* @param {HTMLElement} source The original source element from which styles are read.
23-
* @param {HTMLElement} clone The cloned element to which inline styles are applied.
24-
* @param {Object} [options={}] Optional parameters passed to image inlining functions.
25-
* @returns {Promise<void>} Resolves when all inlining operations (including async image fetches) complete.
26-
*/
27-
/**
28-
* Inlines URL-bearing properties (background/mask/border-image)
29-
* and also preserves mask positioning longhands (position/size/repeat).
30-
* This fixes cases like `mask: url(...) center/60% 60% no-repeat`.
31-
*/
32-
export async function inlineBackgroundImages(source, clone, styleCache, options = {}) {
33-
const queue = [[source, clone]]
10+
/** Props that can contain url(...) and may need inlining */
11+
const URL_PROPS = [
12+
'background-image',
3413

35-
/** Props that can contain url(...) and may need inlining */
36-
const URL_PROPS = [
37-
'background-image',
14+
// Mask shorthands & images (both standard and WebKit)
15+
'mask',
16+
'mask-image',
17+
'-webkit-mask',
18+
'-webkit-mask-image',
3819

39-
// Mask shorthands & images (both standard and WebKit)
40-
'mask',
41-
'mask-image',
42-
'-webkit-mask',
43-
'-webkit-mask-image',
20+
// Mask sources (rare, but keep)
21+
'mask-source',
22+
'mask-box-image-source',
23+
'mask-border-source',
24+
'-webkit-mask-box-image-source',
4425

45-
// Mask sources (rare, but keep)
46-
'mask-source',
47-
'mask-box-image-source',
48-
'mask-border-source',
49-
'-webkit-mask-box-image-source',
26+
// Border image
27+
'border-image',
28+
'border-image-source',
29+
]
5030

51-
// Border image
52-
'border-image',
53-
'border-image-source',
54-
]
31+
/** Mask longhands to preserve spatial layout (copy as-is).
32+
* Must run AFTER the `mask` shorthand in URL_PROPS — setting the shorthand
33+
* resets every longhand to its initial value (#402: lost mask-mode/composite). */
34+
const MASK_LAYOUT_PROPS = [
35+
'mask-position',
36+
'mask-size',
37+
'mask-repeat',
38+
'mask-mode',
39+
'mask-composite',
40+
// WebKit variants
41+
'-webkit-mask-position',
42+
'-webkit-mask-size',
43+
'-webkit-mask-repeat',
44+
'-webkit-mask-composite',
45+
// Extra (optional but helpful across engines)
46+
'mask-origin',
47+
'mask-clip',
48+
'-webkit-mask-origin',
49+
'-webkit-mask-clip',
50+
// Some engines expose X/Y position separately:
51+
'-webkit-mask-position-x',
52+
'-webkit-mask-position-y',
53+
]
54+
const BG_LAYOUT_PROPS = [
55+
'background-position', 'background-position-x', 'background-position-y',
56+
'background-size', 'background-repeat',
57+
'background-origin', 'background-clip',
58+
'background-attachment', 'background-blend-mode'
59+
]
60+
/** Border-image aux longhands (copy only when active) */
61+
const BORDER_AUX_PROPS = [
62+
'border-image-slice',
63+
'border-image-width',
64+
'border-image-outset',
65+
'border-image-repeat',
66+
]
5567

56-
/** Mask longhands to preserve spatial layout (copy as-is).
57-
* Must run AFTER the `mask` shorthand in URL_PROPS — setting the shorthand
58-
* resets every longhand to its initial value (#402: lost mask-mode/composite). */
59-
const MASK_LAYOUT_PROPS = [
60-
'mask-position',
61-
'mask-size',
62-
'mask-repeat',
63-
'mask-mode',
64-
'mask-composite',
65-
// WebKit variants
66-
'-webkit-mask-position',
67-
'-webkit-mask-size',
68-
'-webkit-mask-repeat',
69-
'-webkit-mask-composite',
70-
// Extra (optional but helpful across engines)
71-
'mask-origin',
72-
'mask-clip',
73-
'-webkit-mask-origin',
74-
'-webkit-mask-clip',
75-
// Some engines expose X/Y position separately:
76-
'-webkit-mask-position-x',
77-
'-webkit-mask-position-y',
78-
]
79-
const BG_LAYOUT_PROPS = [
80-
'background-position', 'background-position-x', 'background-position-y',
81-
'background-size', 'background-repeat',
82-
'background-origin', 'background-clip',
83-
'background-attachment', 'background-blend-mode'
84-
]
85-
/** Border-image aux longhands (copy only when active) */
86-
const BORDER_AUX_PROPS = [
87-
'border-image-slice',
88-
'border-image-width',
89-
'border-image-outset',
90-
'border-image-repeat',
91-
]
92-
93-
while (queue.length) {
94-
const [srcNode, cloneNode] = queue.shift()
68+
/**
69+
* Inline URL-bearing properties (background/mask/border-image) from one source element onto its
70+
* clone, plus the layout longhands that keep them positioned (mask position/size, bg layout for
71+
* background-clip:text, border-image auxiliaries).
72+
* @param {Element} srcNode
73+
* @param {HTMLElement} cloneNode
74+
* @param {WeakMap} styleCache
75+
* @param {Object} options
76+
*/
77+
async function inlineBackgroundForNode(srcNode, cloneNode, styleCache, options) {
78+
const style = styleCache.get(srcNode) || getStyle(srcNode)
79+
if (!styleCache.has(srcNode)) styleCache.set(srcNode, style)
9580

96-
if (!cloneNode) continue
81+
// Border-image present?
82+
const bi = style.getPropertyValue('border-image')
83+
const bis = style.getPropertyValue('border-image-source')
84+
const hasBorderImage = (bi && bi !== 'none') || (bis && bis !== 'none')
9785

98-
// Style cache
99-
const style = styleCache.get(srcNode) || getStyle(srcNode)
100-
if (!styleCache.has(srcNode)) styleCache.set(srcNode, style)
101-
// Border-image present?
102-
const hasBorderImage = (() => {
103-
const bi = style.getPropertyValue('border-image')
104-
const bis = style.getPropertyValue('border-image-source')
105-
return (bi && bi !== 'none') || (bis && bis !== 'none')
106-
})()
107-
// Background layout longhands (position/size/repeat/origin/clip/...) are inert without a
108-
// background, yet are never empty, so copying them onto every node bloated the markup and
109-
// rasterization cost. Copy only when a background actually exists. background-color is
110-
// included so the background-clip:text trick (color clipped to text) still works.
111-
const bgImage = style.getPropertyValue('background-image')
112-
const bgColor = style.getPropertyValue('background-color')
113-
const hasBg =
114-
(bgImage && bgImage !== 'none') ||
115-
(bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') ||
116-
/url\s*\(|gradient\s*\(/i.test(style.getPropertyValue('background') || '')
117-
if (hasBg) {
118-
for (const prop of BG_LAYOUT_PROPS) {
119-
const v = style.getPropertyValue(prop)
120-
if (!v) continue
121-
cloneNode.style.setProperty(prop, v)
122-
}
86+
// Background layout longhands (position/size/repeat/origin/clip/...) are inert without a
87+
// background, yet are never empty, so copying them onto every node bloated the markup and
88+
// rasterization cost. Copy only when a background actually exists. background-color is
89+
// included so the background-clip:text trick (color clipped to text) still works.
90+
const bgImage = style.getPropertyValue('background-image')
91+
const bgColor = style.getPropertyValue('background-color')
92+
const hasBg =
93+
(bgImage && bgImage !== 'none') ||
94+
(bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') ||
95+
/url\s*\(|gradient\s*\(/i.test(style.getPropertyValue('background') || '')
96+
if (hasBg) {
97+
for (const prop of BG_LAYOUT_PROPS) {
98+
const v = style.getPropertyValue(prop)
99+
if (!v) continue
100+
cloneNode.style.setProperty(prop, v)
123101
}
124-
// 1) Inline URL-bearing properties
125-
for (const prop of URL_PROPS) {
126-
let val = style.getPropertyValue(prop)
127-
// Fallback: when background-image is none/empty, parse url() from background shorthand (#343)
128-
if ((prop === 'background-image') && (!val || val === 'none')) {
129-
const bgShorthand = style.getPropertyValue('background')
130-
if (bgShorthand && /url\s*\(/.test(bgShorthand)) {
131-
// Use filter+join to preserve all url() layers, not just the first (#NEW-5)
132-
val = splitBackgroundImage(bgShorthand).filter(p => /url\s*\(/.test(p)).join(', ') || val
133-
}
102+
}
103+
// 1) Inline URL-bearing properties
104+
for (const prop of URL_PROPS) {
105+
let val = style.getPropertyValue(prop)
106+
// Fallback: when background-image is none/empty, parse url() from background shorthand (#343)
107+
if ((prop === 'background-image') && (!val || val === 'none')) {
108+
const bgShorthand = style.getPropertyValue('background')
109+
if (bgShorthand && /url\s*\(/.test(bgShorthand)) {
110+
// Use filter+join to preserve all url() layers, not just the first (#NEW-5)
111+
val = splitBackgroundImage(bgShorthand).filter(p => /url\s*\(/.test(p)).join(', ') || val
134112
}
135-
if (!val || val === 'none') continue
113+
}
114+
if (!val || val === 'none') continue
136115

137-
// Split multiple layers (comma-separated)
138-
const splits = splitBackgroundImage(val)
116+
// Split multiple layers (comma-separated)
117+
const splits = splitBackgroundImage(val)
139118

140-
const inlined = await Promise.all(
141-
splits.map(entry => inlineSingleBackgroundEntry(entry, options))
142-
)
119+
const inlined = await Promise.all(
120+
splits.map(entry => inlineSingleBackgroundEntry(entry, options))
121+
)
143122

144-
if (inlined.some(p => p && p !== 'none' && !/^url\(undefined/.test(p))) {
145-
cloneNode.style.setProperty(prop, inlined.join(', '))
146-
}
123+
if (inlined.some(p => p && p !== 'none' && !/^url\(undefined/.test(p))) {
124+
cloneNode.style.setProperty(prop, inlined.join(', '))
147125
}
148-
// 2) Copy mask layout longhands (position / size / repeat, etc.)
149-
for (const prop of MASK_LAYOUT_PROPS) {
126+
}
127+
// 2) Copy mask layout longhands (position / size / repeat, etc.)
128+
for (const prop of MASK_LAYOUT_PROPS) {
129+
const val = style.getPropertyValue(prop)
130+
// Skip empty/initial defaults to avoid bloating
131+
if (!val || val === 'initial') continue
132+
cloneNode.style.setProperty(prop, val)
133+
}
134+
// 3) Copy border-image auxiliaries only if border-image is active
135+
if (hasBorderImage) {
136+
for (const prop of BORDER_AUX_PROPS) {
150137
const val = style.getPropertyValue(prop)
151-
// Skip empty/initial defaults to avoid bloating
152138
if (!val || val === 'initial') continue
153139
cloneNode.style.setProperty(prop, val)
154140
}
155-
// 3) Copy border-image auxiliaries only if border-image is active
156-
if (hasBorderImage) {
157-
for (const prop of BORDER_AUX_PROPS) {
158-
const val = style.getPropertyValue(prop)
159-
if (!val || val === 'initial') continue
160-
cloneNode.style.setProperty(prop, val)
161-
}
162-
}
163-
// 4) Recurse — use nodeMap (clone→source) for child alignment instead of
164-
// index-based pairing, which breaks when clone-only elements (e.g.
165-
// svg.inline-defs-container) shift indices (#439).
166-
if (srcNode.shadowRoot) {
167-
const sChildren = Array.from(srcNode.shadowRoot.children).filter(el => el.tagName !== 'STYLE')
168-
const cChildren = Array.from(cloneNode.children)
169-
.filter(el => !el.dataset?.snapdomPseudo && !(el.tagName === 'STYLE' && el.dataset?.sd))
170-
for (let i = 0; i < Math.min(sChildren.length, cChildren.length); i++) {
171-
queue.push([sChildren[i], cChildren[i]])
172-
}
173-
} else {
174-
const nodeMap = cache.session.nodeMap
175-
for (const cChild of cloneNode.children) {
176-
const srcChild = nodeMap.get(cChild)
177-
if (!srcChild) continue
178-
queue.push([srcChild, cChild])
179-
}
141+
}
142+
}
143+
144+
/**
145+
* Inlines background-related images and masks from the source tree onto the clone.
146+
*
147+
* The worklist is built from the session clone→source nodeMap and filtered by the
148+
* needsBackgroundInline flag computed during the style snapshot, so the pass no longer
149+
* re-reads ~40 computed properties on every node — only flagged nodes do real work.
150+
* Walking the clone tree (descending through clone-only wrappers like the scroll-translate
151+
* wrapper) also reaches subtrees the old source/clone parallel walk skipped.
152+
*
153+
* @param {HTMLElement} source The original source element.
154+
* @param {HTMLElement} clone The cloned element receiving inline styles.
155+
* @param {WeakMap} styleCache
156+
* @param {Object} [options={}]
157+
* @returns {Promise<void>}
158+
*/
159+
export async function inlineBackgroundImages(source, clone, styleCache, options = {}) {
160+
if (!clone) return
161+
const nodeMap = cache.session.nodeMap
162+
163+
const jobs = []
164+
if (source && needsBackgroundInline(source)) jobs.push([source, clone])
165+
const stack = [clone]
166+
while (stack.length) {
167+
const cn = stack.pop()
168+
if (!cn.children) continue
169+
for (const child of cn.children) {
170+
if (child.tagName === 'STYLE') continue
171+
const src = nodeMap.get(child)
172+
if (src && needsBackgroundInline(src)) jobs.push([src, child])
173+
stack.push(child)
180174
}
181175
}
176+
177+
// Batch of 6 mirrors inlineImages: bounded fetch concurrency, snapFetch dedupes inflight.
178+
const BATCH = 6
179+
for (let i = 0; i < jobs.length; i += BATCH) {
180+
await Promise.allSettled(
181+
jobs.slice(i, i + BATCH).map(([s, c]) => inlineBackgroundForNode(s, c, styleCache, options))
182+
)
183+
}
182184
}

0 commit comments

Comments
 (0)