Skip to content

Commit 84359e5

Browse files
committed
fix: make DOM type checks realm-agnostic (ref #494)
`node instanceof HTMLInputElement` is false for a node that belongs to another window, because every realm owns its constructors. When the parent document captured a same-origin iframe, I was not recognising the inputs, textareas and selects inside it, so their typed values, placeholder colour and state attributes (disabled, required, readonly) were dropped from the clone. The same checks gated SVG paint materialisation, shadow-root detection, replaced-element sizing and the resolveNode plugin hook. I added src/utils/dom.js with predicates based on nodeType and namespaceURI (isHTMLTag, isHTMLElement, isSVGElement, isSVGRoot, isShadowRoot, isDocument, isNode) and I now use them instead of instanceof in clone, prepare, burst, counter, styles, svgDefs and the capture/prepare helpers. I covered it in __tests__/snapdom.issue494.test.js with a capture root created in an iframe realm and with the real path where the parent captures the <iframe>.
1 parent 7272eac commit 84359e5

10 files changed

Lines changed: 207 additions & 27 deletions

File tree

__tests__/snapdom.issue494.test.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, it, expect, afterEach } from 'vitest'
2+
import { snapdom } from '../src/api/snapdom.js'
3+
4+
// #494: values typed into form controls inside a same-origin iframe were not captured.
5+
// rasterizeIframe captures iframe.contentDocument.documentElement from the PARENT realm,
6+
// so `node instanceof HTMLInputElement` is false for iframe nodes (each window has its own
7+
// constructors) and the value/checked/selected freeze in clone.js was skipped.
8+
9+
function decodeSvg(result) {
10+
const raw = result.toRaw()
11+
return decodeURIComponent(raw.slice(raw.indexOf(',') + 1))
12+
}
13+
14+
// Firefox swaps native checkbox/radio for an inline-SVG replacement (createCheckboxRadioReplacement);
15+
// a checked box is the one that draws the tick <path>. Other engines keep the <input checked>.
16+
function expectChecked(svg) {
17+
const i = svg.indexOf('data-snapdom-input-replacement="checkbox"')
18+
if (i !== -1) {
19+
const block = svg.slice(i, svg.indexOf('</svg>', i))
20+
expect(block).toContain('<path')
21+
} else {
22+
expect(svg).toMatch(/id="c"[^>]*checked="[^"]*"|checked="[^"]*"[^>]*id="c"/)
23+
}
24+
}
25+
26+
describe('form control values inside a same-origin iframe (#494)', () => {
27+
let iframe
28+
29+
let wrap
30+
31+
afterEach(() => {
32+
if (wrap && wrap.parentNode) wrap.parentNode.removeChild(wrap)
33+
})
34+
35+
function makeIframe() {
36+
wrap = document.createElement('div')
37+
wrap.style.cssText = 'width:320px;padding:8px;background:#fff'
38+
iframe = document.createElement('iframe')
39+
iframe.style.cssText = 'width:320px;height:200px;border:0'
40+
wrap.appendChild(iframe)
41+
document.body.appendChild(wrap)
42+
const doc = iframe.contentDocument
43+
doc.open()
44+
doc.write(`<html><body style="margin:0">
45+
<input id="t" type="text">
46+
<input id="r" type="range" min="0" max="100" value="0">
47+
<input id="c" type="checkbox">
48+
<textarea id="ta"></textarea>
49+
<select id="s"><option value="a">A</option><option value="b">B</option></select>
50+
</body></html>`)
51+
doc.close()
52+
return doc
53+
}
54+
55+
it('freezes typed values when the capture root lives in another realm', async () => {
56+
const doc = makeIframe()
57+
// Sanity: iframe nodes are NOT instances of the parent realm constructors.
58+
expect(doc.getElementById('t') instanceof HTMLInputElement).toBe(false)
59+
60+
doc.getElementById('t').value = '12'
61+
doc.getElementById('r').value = '80'
62+
doc.getElementById('c').checked = true
63+
doc.getElementById('ta').value = 'hello'
64+
doc.getElementById('s').value = 'b'
65+
66+
// Same call rasterizeIframe performs for the nested capture.
67+
const result = await snapdom(doc.documentElement, { embedFonts: false })
68+
const svg = decodeSvg(result)
69+
70+
expect(svg).toMatch(/id="t"[^>]*value="12"|value="12"[^>]*id="t"/)
71+
expect(svg).toMatch(/id="r"[^>]*value="80"|value="80"[^>]*id="r"/)
72+
expectChecked(svg)
73+
expect(svg).toMatch(/<textarea[^>]*>hello<\/textarea>/)
74+
// Firefox serializes boolean attributes as selected="selected", Chromium/WebKit as selected="".
75+
expect(svg).toMatch(/<option[^>]*value="b"[^>]*selected="[^"]*"|<option[^>]*selected="[^"]*"[^>]*value="b"/)
76+
})
77+
78+
it('freezes values through the real iframe path (parent captures the <iframe>)', async () => {
79+
const doc = makeIframe()
80+
doc.getElementById('t').value = '12'
81+
doc.getElementById('r').value = '80'
82+
83+
// rasterizeIframe rasterizes the iframe document through context.snap.toPng, which main()
84+
// wires to snapdom.toPng. Wrap it to read the nested SVG before it becomes a PNG.
85+
let nestedSvg = ''
86+
const origToPng = snapdom.toPng
87+
snapdom.toPng = async (el, opts) => {
88+
const r = await snapdom(el, opts)
89+
nestedSvg = decodeSvg(r)
90+
return r.toPng()
91+
}
92+
let psvg
93+
try {
94+
psvg = decodeSvg(await snapdom(wrap, { embedFonts: false }))
95+
} finally {
96+
snapdom.toPng = origToPng
97+
}
98+
99+
// The parent got the rasterized wrapper (an <img>), not the placeholder fallback.
100+
expect(psvg).toContain('<img')
101+
expect(nestedSvg).not.toBe('')
102+
expect(nestedSvg).toMatch(/id="t"[^>]*value="12"|value="12"[^>]*id="t"/)
103+
expect(nestedSvg).toMatch(/id="r"[^>]*value="80"|value="80"[^>]*id="r"/)
104+
})
105+
})

src/core/burst.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@
1818
*/
1919

2020
import { hasExternalMutation } from '../modules/styles.js'
21+
import { isHTMLTag } from '../utils/dom.js'
2122

2223
const burstStates = new WeakMap()
2324

2425
function trackVideos(element, state, onMediaDirty) {
2526
const videos = new Set()
26-
if (element instanceof HTMLVideoElement) videos.add(element)
27+
if (isHTMLTag(element, 'video')) videos.add(element)
2728
if (element.querySelectorAll) for (const v of element.querySelectorAll('video')) videos.add(v)
2829
for (const v of state.trackedVideos) {
2930
if (!videos.has(v)) {

src/core/clone.js

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
createCheckboxRadioReplacement
2323
} from '../utils/clone.helpers.js'
2424
import { isFirefox, isSafari, nextFrame } from '../utils/browser.js'
25+
import { isHTMLTag, isSVGElement, isNode } from '../utils/dom.js'
2526

2627
// helper implementations moved to ../utils/clone.helpers.js
2728

@@ -251,7 +252,7 @@ export async function deepClone(node, sessionCache, options) {
251252
debugWarn(sessionCache, 'resolveNode plugin hook failed', e)
252253
}
253254
if (out === null) return null
254-
if (out instanceof Node) {
255+
if (isNode(out)) {
255256
if (out.nodeType === Node.ELEMENT_NODE) {
256257
// Same treatment as built-in tag handlers: map to the source and carry its box
257258
// styles so the replacement keeps the original layout.
@@ -366,14 +367,14 @@ export async function deepClone(node, sessionCache, options) {
366367
throw err
367368
}
368369
let applyInputVisual = null
369-
if (node instanceof HTMLTextAreaElement) {
370+
if (isHTMLTag(node, 'textarea')) {
370371
const { width, height } = getUnscaledDimensions(node)
371372
const w = width || node.getBoundingClientRect().width || 0
372373
const h = height || node.getBoundingClientRect().height || 0
373374
if (w) clone.style.width = `${w}px`
374375
if (h) clone.style.height = `${h}px`
375376
}
376-
if (node instanceof HTMLInputElement) {
377+
if (isHTMLTag(node, 'input')) {
377378
const type = (node.type || 'text').toLowerCase()
378379
const isCheckboxOrRadio = type === 'checkbox' || type === 'radio'
379380
if (isCheckboxOrRadio && isFirefox()) {
@@ -393,7 +394,7 @@ export async function deepClone(node, sessionCache, options) {
393394
}
394395

395396
// #315: Preserve ::placeholder color for inputs/textareas showing placeholder text
396-
if ((node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && !node.value && node.placeholder) {
397+
if (isHTMLTag(node, 'input', 'textarea') && !node.value && node.placeholder) {
397398
try {
398399
const phStyle = window.getComputedStyle(node, '::placeholder')
399400
const phColor = phStyle && phStyle.color
@@ -407,15 +408,15 @@ export async function deepClone(node, sessionCache, options) {
407408
} catch { /* non-blocking */ }
408409
}
409410

410-
if (node instanceof HTMLSelectElement) {
411+
if (isHTMLTag(node, 'select')) {
411412
pendingSelectValue = node.value
412413
}
413-
if (node instanceof HTMLTextAreaElement) {
414+
if (isHTMLTag(node, 'textarea')) {
414415
pendingTextAreaValue = node.value
415416
}
416417
// Copy form validation/state attributes so :disabled, :required, :read-only,
417418
// :invalid, :in-range/:out-of-range pseudo-class styles render correctly in the capture.
418-
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) {
419+
if (isHTMLTag(node, 'input', 'textarea', 'select')) {
419420
if (node.disabled) clone.setAttribute('disabled', '')
420421
if (node.required) clone.setAttribute('required', '')
421422
if ((/** @type {HTMLInputElement|HTMLTextAreaElement} */ (node)).readOnly) clone.setAttribute('readonly', '')
@@ -438,7 +439,7 @@ export async function deepClone(node, sessionCache, options) {
438439
// properties from computed style as inline styles to ensure CSS-driven fills/strokes survive.
439440
// #408: skip descendants of <symbol>/<defs>/etc. — their var() must resolve at the <use> site,
440441
// not be materialized to the (dead) template's fallback computed value.
441-
if (node instanceof SVGElement && !isInSvgTemplate(node)) {
442+
if (isSVGElement(node) && !isInSvgTemplate(node)) {
442443
const SVG_PAINT_PROPS = [
443444
'fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-dashoffset',
444445
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'opacity',
@@ -536,7 +537,7 @@ export async function deepClone(node, sessionCache, options) {
536537
clone.append(...cloneList.filter(clonedChild => !!clonedChild))
537538

538539
// Adjust select value after children are cloned
539-
if (pendingSelectValue !== null && clone instanceof HTMLSelectElement) {
540+
if (pendingSelectValue !== null && isHTMLTag(clone, 'select')) {
540541
clone.value = pendingSelectValue
541542
for (const opt of clone.options) {
542543
if (opt.value === pendingSelectValue) {
@@ -546,7 +547,7 @@ export async function deepClone(node, sessionCache, options) {
546547
}
547548
}
548549
}
549-
if (pendingTextAreaValue !== null && clone instanceof HTMLTextAreaElement) {
550+
if (pendingTextAreaValue !== null && isHTMLTag(clone, 'textarea')) {
550551
clone.textContent = pendingTextAreaValue
551552
}
552553
return clone

src/core/prepare.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { resolveBlobUrlsInTree } from '../utils/clone.helpers.js'
1212
import { stabilizeLayout, forceContentVisibility } from '../utils/prepare.helpers.js'
1313
import { resolveClipRect, freezeViewportPositioned } from '../utils/capture.helpers.js'
1414
import { nextFrame } from '../utils/browser.js'
15+
import { isShadowRoot } from '../utils/dom.js'
1516

1617
const visibilityWarmups = new Set()
1718

@@ -235,7 +236,7 @@ export async function prepareClone(element, options = {}) {
235236
for (const [node, key] of sessionCache.styleMap.entries()) {
236237
if (node.tagName === 'STYLE') continue
237238
/* c8 ignore next 4 */
238-
if (node.getRootNode && node.getRootNode() instanceof ShadowRoot) {
239+
if (node.getRootNode && isShadowRoot(node.getRootNode())) {
239240
node.setAttribute('style', key.replace(/;/g, '; '))
240241
continue
241242
}

src/modules/counter.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isDocument } from '../utils/dom.js'
12

23
/**
34
* Lightweight CSS counter resolver for SnapDOM.
@@ -77,7 +78,7 @@ function formatCounter(value, style) {
7778
*/
7879
export function buildCounterContext(root) {
7980
const nodeCounters = new WeakMap()
80-
const rootEl = (root instanceof Document) ? root.documentElement : root
81+
const rootEl = isDocument(root) ? root.documentElement : root
8182

8283
const isLi = (el) => el && el.tagName === 'LI'
8384
const countPrevLi = (li) => {

src/modules/styles.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getStyleKey, softensWidth, softenNeedsAutoWidth, shouldIgnoreProp, getStyle } from '../utils/index.js'
22
import { cache } from '../core/cache.js'
3+
import { isHTMLElement } from '../utils/dom.js'
34

45
const snapshotCache = new WeakMap()
56
const snapshotKeyCache = new Map()
@@ -605,7 +606,7 @@ function autoContentHeight(el) {
605606
*/
606607
function stripHeightForWrappers(el, cs, snap) {
607608
// 1) Respeta height inline del autor
608-
if (el instanceof HTMLElement && el.style && el.style.height) return
609+
if (isHTMLElement(el) && el.style && el.style.height) return
609610

610611
// 2) Solo div/section/article/main/aside/header/footer/nav (no ol/ul/li: layout de listas)
611612
const tag = el.tagName && el.tagName.toLowerCase()

src/modules/svgDefs.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isSVGRoot } from '../utils/dom.js'
12
/**
23
* Inline external <defs> and <symbol> dependencies needed by an SVG subtree (or multiple SVGs),
34
* so that serialization does not break. Handles:
@@ -18,7 +19,7 @@ export function inlineExternalDefsAndSymbols(element, lookupRoot) {
1819

1920
/** Collect all SVG roots under element (or element if it's an <svg>) */
2021
const svgRoots =
21-
element instanceof SVGSVGElement
22+
isSVGRoot(element)
2223
? [element]
2324
: Array.from(element.querySelectorAll('svg'))
2425

src/utils/capture.helpers.js

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {
99
parseTransformOriginPx,
1010
readIndividualTransforms
1111
} from './transforms.helpers.js'
12+
import { HTML_NS, isHTMLTag, isSVGElement, isShadowRoot } from './dom.js'
1213

13-
const HTML_NS = 'http://www.w3.org/1999/xhtml'
1414
const viewportFrozenClones = new WeakSet()
1515

1616
/**
@@ -47,7 +47,7 @@ export function resolveClipRect(element, clip) {
4747
function composedParent(n) {
4848
if (n.parentElement) return n.parentElement
4949
const rn = n.getRootNode && n.getRootNode()
50-
return rn instanceof ShadowRoot ? rn.host : null
50+
return isShadowRoot(rn) ? rn.host : null
5151
}
5252

5353
function composedContains(root, node) {
@@ -148,7 +148,7 @@ export function freezeViewportPositioned(root, cloneRoot, nodeMap, styleCache, e
148148
w = /** @type {HTMLElement} */ (orig).offsetWidth || r.width
149149
h = /** @type {HTMLElement} */ (orig).offsetHeight || r.height
150150
}
151-
const inShadow = orig.getRootNode && orig.getRootNode() instanceof ShadowRoot
151+
const inShadow = orig.getRootNode && isShadowRoot(orig.getRootNode())
152152
let baseR = rootR, baseBL = root.clientLeft || 0, baseBT = root.clientTop || 0
153153
if (inShadow) {
154154
const cb = findCBAncestor(orig, root)
@@ -489,13 +489,7 @@ function authorHasExplicitSize(el) {
489489
* @param {Element} el
490490
*/
491491
function isReplacedElement(el) {
492-
return el instanceof HTMLImageElement ||
493-
el instanceof HTMLCanvasElement ||
494-
el instanceof HTMLVideoElement ||
495-
el instanceof HTMLIFrameElement ||
496-
el instanceof SVGElement ||
497-
el instanceof HTMLObjectElement ||
498-
el instanceof HTMLEmbedElement
492+
return isHTMLTag(el, 'img', 'canvas', 'video', 'iframe', 'object', 'embed') || isSVGElement(el)
499493
}
500494

501495
/**

src/utils/dom.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Realm-agnostic DOM predicates.
3+
*
4+
* `node instanceof HTMLInputElement` is false for a node that belongs to another window
5+
* (a same-origin iframe document, a DOMParser document, ...) because every realm owns its
6+
* constructors. snapdom captures iframe documents from the parent realm (rasterizeIframe),
7+
* so type checks on captured nodes must go through these helpers, never `instanceof` (#494).
8+
*/
9+
10+
export const HTML_NS = 'http://www.w3.org/1999/xhtml'
11+
export const SVG_NS = 'http://www.w3.org/2000/svg'
12+
13+
/**
14+
* Element in the XHTML namespace, whatever realm it comes from.
15+
* @param {any} node
16+
* @returns {node is HTMLElement}
17+
*/
18+
export function isHTMLElement(node) {
19+
return !!node && node.nodeType === 1 && node.namespaceURI === HTML_NS
20+
}
21+
22+
/**
23+
* HTML element whose local name is one of `names`, e.g. isHTMLTag(n, 'input', 'textarea').
24+
* @param {any} node
25+
* @param {...string} names lowercase tag names
26+
* @returns {boolean}
27+
*/
28+
export function isHTMLTag(node, ...names) {
29+
return isHTMLElement(node) && names.includes(node.localName)
30+
}
31+
32+
/**
33+
* Element in the SVG namespace, whatever realm it comes from.
34+
* @param {any} node
35+
* @returns {node is SVGElement}
36+
*/
37+
export function isSVGElement(node) {
38+
return !!node && node.nodeType === 1 && node.namespaceURI === SVG_NS
39+
}
40+
41+
/**
42+
* Outer or nested `<svg>` element.
43+
* @param {any} node
44+
* @returns {node is SVGSVGElement}
45+
*/
46+
export function isSVGRoot(node) {
47+
return isSVGElement(node) && node.localName === 'svg'
48+
}
49+
50+
/**
51+
* ShadowRoot (a DocumentFragment with a host).
52+
* @param {any} node
53+
* @returns {node is ShadowRoot}
54+
*/
55+
export function isShadowRoot(node) {
56+
return !!node && node.nodeType === 11 && !!node.host
57+
}
58+
59+
/**
60+
* @param {any} node
61+
* @returns {node is Document}
62+
*/
63+
export function isDocument(node) {
64+
return !!node && node.nodeType === 9
65+
}
66+
67+
/**
68+
* Any DOM Node (element, text, fragment, document...).
69+
* @param {any} value
70+
* @returns {value is Node}
71+
*/
72+
export function isNode(value) {
73+
return !!value && typeof value.nodeType === 'number' && typeof value.nodeName === 'string'
74+
}

0 commit comments

Comments
 (0)