diff --git a/packages/dom/src/clone-dom.js b/packages/dom/src/clone-dom.js index d7844b974..ac131feb9 100644 --- a/packages/dom/src/clone-dom.js +++ b/packages/dom/src/clone-dom.js @@ -68,6 +68,24 @@ export function cloneNodeAndShadow(ctx) { return; } + // Slotted content lives in the host's light DOM and is only *rendered* at + // the slot's position by the shadow root. Flattening the root away leaves + // an inert and strands that content outside its container, so + // resolve the projection here instead (PER-10812). Every shadow root is + // flattened when forceShadowAsLightDOM is set, so any slot belonging to + // one needs resolving — including slots reached indirectly, as projected + // content nested inside another component's slotted markup. The slot + // itself never renders and isn't cloned. + if (forceShadowAsLightDOM && node.nodeName === 'SLOT' && + typeof node.assignedNodes === 'function' && node.getRootNode()?.host) { + for (let assigned of node.assignedNodes({ flatten: true })) { + if (!ignoreTags.includes(assigned.nodeName)) { + cloneNode(assigned, parent); + } + } + return; + } + // mark the node before cloning markElement(node, disableShadowDOM, forceShadowAsLightDOM); @@ -116,8 +134,10 @@ export function cloneNodeAndShadow(ctx) { // clone shadow DOM (including closed shadow roots captured via CDP // and stored on window.__percyClosedShadowRoots) let nodeShadowRoot = node.shadowRoot || getClosedShadowRoot(node); + let flattenedHost = false; if (nodeShadowRoot && !disableShadowDOM) { - if (forceShadowAsLightDOM) { + flattenedHost = !!forceShadowAsLightDOM; + if (flattenedHost) { // When forceShadowAsLightDOM is true, treat shadow content as normal DOM walkTree(nodeShadowRoot.firstChild, clone); } else { @@ -136,8 +156,12 @@ export function cloneNodeAndShadow(ctx) { } } - // clone light DOM - walkTree(node.firstChild, clone); + // clone light DOM — skipped for a flattened host, whose light children were + // already placed at their positions above. Children assigned to no + // slot are dropped, matching what the browser renders for them: nothing. + if (!flattenedHost) { + walkTree(node.firstChild, clone); + } } catch (err) { if (!err.handled) { handleErrors(err, 'Error cloning node: ', node); diff --git a/packages/dom/src/serialize-canvas.js b/packages/dom/src/serialize-canvas.js index e96418785..4c1c4a9e2 100644 --- a/packages/dom/src/serialize-canvas.js +++ b/packages/dom/src/serialize-canvas.js @@ -54,6 +54,10 @@ export function serializeCanvas(ctx) { // skip empty canvases if (!dataUrl || dataUrl === 'data:,') continue; + // no counterpart in the clone means the canvas wasn't serialized — e.g. + // light DOM content no projected, which the browser doesn't render + if (!clone.querySelector(`[data-percy-element-id=${percyElementId}]`)) continue; + // create a resource for the canvas data let resource = resourceFromDataURL(percyElementId, dataUrl); resources.add(resource); diff --git a/packages/dom/src/serialize-frames.js b/packages/dom/src/serialize-frames.js index 595aaf15c..f8fe28ebc 100644 --- a/packages/dom/src/serialize-frames.js +++ b/packages/dom/src/serialize-frames.js @@ -147,12 +147,12 @@ export function serializeFrames({ dom, clone, warnings, resources, enableJavaScr cloneEl.setAttribute('srcdoc', p.createHTML ? p.createHTML(serialized.html) : serialized.html); } catch { } - cloneEl.removeAttribute('src'); + cloneEl?.removeAttribute('src'); // delete inaccessible frames built with js when js is disabled because they // break asset discovery by creating non-captured requests that hang } else if (!enableJavaScript && builtWithJs) { - cloneEl.remove(); + cloneEl?.remove(); } } } diff --git a/packages/dom/src/serialize-inputs.js b/packages/dom/src/serialize-inputs.js index c3ae6588b..8f4f362c6 100644 --- a/packages/dom/src/serialize-inputs.js +++ b/packages/dom/src/serialize-inputs.js @@ -7,6 +7,9 @@ export function serializeInputElements(ctx) { try { let inputId = elem.getAttribute('data-percy-element-id'); let cloneEl = clone.querySelector(`[data-percy-element-id="${inputId}"]`); + // no counterpart in the clone means the element wasn't serialized — e.g. + // light DOM content no projected, which the browser doesn't render + if (!cloneEl) continue; switch (elem.type) { case 'checkbox': diff --git a/packages/dom/src/serialize-video.js b/packages/dom/src/serialize-video.js index 13d53f477..1c6047de3 100644 --- a/packages/dom/src/serialize-video.js +++ b/packages/dom/src/serialize-video.js @@ -10,6 +10,9 @@ export function serializeVideos(ctx) { let videoId = video.getAttribute('data-percy-element-id'); let cloneEl = clone.querySelector(`[data-percy-element-id="${videoId}"]`); + // no counterpart in the clone means the element wasn't serialized — e.g. + // light DOM content no projected, which the browser doesn't render + if (!cloneEl) continue; let canvas = document.createElement('canvas'); let width = canvas.width = video.videoWidth; let height = canvas.height = video.videoHeight; diff --git a/packages/dom/test/serialize-dom.test.js b/packages/dom/test/serialize-dom.test.js index 194c0fafc..f498526ba 100644 --- a/packages/dom/test/serialize-dom.test.js +++ b/packages/dom/test/serialize-dom.test.js @@ -457,10 +457,11 @@ describe('serializeDOM', () => { const html = serializeDOM({ forceShadowAsLightDOM: true }).html; - // When forceShadowAsLightDOM is true, shadow content becomes light DOM - // The slot element from shadow DOM will be present, and slotted content remains in light DOM + // When forceShadowAsLightDOM is true, shadow content becomes light DOM and + // slotted content is projected into the slot's position. The itself + // is dropped — outside a shadow root it renders nothing. expect(html).toMatch('Slotted content as light DOM'); - expect(html).toMatch(''); + expect(html).not.toMatch(' <- host +// #shadow-root +//
+//
+//
+//
Yes / No
<- renders in the default slot +// <- renders in the named slot +//
+function buildCard({ withActions = true, orphan = false } = {}) { + withExample('
', { withShadow: false }); + const content = document.querySelector('#content'); + + const host = document.createElement('div'); + host.id = 'card-layout'; + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = [ + '
', + '

Is Percy your student\'s preferred name?

', + '
', + '
', + '
' + ].join(''); + + const main = document.createElement('div'); + main.className = 'mainContent'; + main.textContent = 'Yes / No'; + host.appendChild(main); + + if (withActions) { + const next = document.createElement('button'); + next.setAttribute('slot', 'actions'); + next.textContent = 'Next'; + host.appendChild(next); + } + + if (orphan) { + // assigned to a slot the component does not expose — the browser renders + // nothing for it + const stray = document.createElement('div'); + stray.className = 'strayContent'; + stray.setAttribute('slot', 'nonexistent'); + stray.textContent = 'never rendered'; + host.appendChild(stray); + } + + content.appendChild(host); + return host; +} + +describe('serializeDOM - slot projection', () => { + const isChrome = () => navigator.userAgent.toLowerCase().includes('chrome'); + + describe('when the host is flattened by forceShadowAsLightDOM', () => { + it('projects default-slot content into the slot position, not after the container', () => { + if (!isChrome()) return; + buildCard(); + + const $ = parseDOM(serializeDOM({ forceShadowAsLightDOM: true }).html); + + // the regression: content lands INSIDE the section holding the slot … + const projected = $('#card-layout .card-container .content-section .mainContent'); + expect(projected.length).toEqual(1); + expect(projected[0].textContent).toEqual('Yes / No'); + + // … and NOT as a bare sibling after the card, which is what stranded the + // "Yes / No" tiles outside their card in PER-10812 + expect($('#card-layout > .mainContent').length).toEqual(0); + }); + + it('routes named-slot content to its matching slot', () => { + if (!isChrome()) return; + buildCard(); + + const $ = parseDOM(serializeDOM({ forceShadowAsLightDOM: true }).html); + + const action = $('#card-layout .card-container .actions-section button'); + expect(action.length).toEqual(1); + expect(action[0].textContent).toEqual('Next'); + + // the named content must not leak into the default slot + expect($('.content-section button').length).toEqual(0); + }); + + it('drops the inert elements themselves', () => { + if (!isChrome()) return; + buildCard(); + + const html = serializeDOM({ forceShadowAsLightDOM: true }).html; + + expect(html).not.toMatch(' { + if (!isChrome()) return; + buildCard(); + + const html = serializeDOM({ forceShadowAsLightDOM: true }).html; + + // the light-DOM walk must be skipped for a flattened host, or the + // projected nodes get cloned a second time outside their container + expect((html.match(/class="mainContent"/g) || []).length).toEqual(1); + expect((html.match(/Yes \/ No/g) || []).length).toEqual(1); + }); + + it('drops a slotted noscript element', () => { + if (!isChrome()) return; + buildCard(); + const host = document.querySelector('#card-layout'); + const noscript = document.createElement('noscript'); + noscript.setAttribute('slot', 'actions'); + noscript.textContent = 'no js'; + host.appendChild(noscript); + + const html = serializeDOM({ forceShadowAsLightDOM: true }).html; + + expect(html).not.toMatch(' { + if (!isChrome()) return; + withExample('
', { withShadow: false }); + const host = document.createElement('div'); + host.id = 'text-host'; + host.attachShadow({ mode: 'open' }).innerHTML = '
'; + host.appendChild(document.createTextNode('bare text')); + document.querySelector('#content').appendChild(host); + + const $ = parseDOM(serializeDOM({ forceShadowAsLightDOM: true }).html); + + expect($('#text-host .wrap')[0].textContent).toEqual('bare text'); + }); + + it('does not throw for unprojected media elements in a flattened host', () => { + if (!isChrome()) return; + withExample('
', { withShadow: false }); + const host = document.createElement('div'); + host.id = 'noslot-host'; + // a shadow root with no at all renders none of its light children, + // so they are not cloned and downstream serializers must skip them rather + // than fail to resolve their clones + host.attachShadow({ mode: 'open' }).innerHTML = '
chrome
'; + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 10; + const input = document.createElement('input'); + input.type = 'radio'; + host.append(canvas, input, document.createElement('video'), document.createElement('iframe')); + document.querySelector('#content').appendChild(host); + + expect(() => serializeDOM({ forceShadowAsLightDOM: true })).not.toThrow(); + }); + + it('drops content assigned to a slot the component does not expose', () => { + if (!isChrome()) return; + buildCard({ orphan: true }); + + const $ = parseDOM(serializeDOM({ forceShadowAsLightDOM: true }).html); + + // matches what the browser renders for an unmatched slot name: nothing + expect($('.strayContent').length).toEqual(0); + }); + + it('preserves document order for several nodes in one slot', () => { + if (!isChrome()) return; + withExample('
', { withShadow: false }); + const host = document.createElement('div'); + host.id = 'multi'; + host.attachShadow({ mode: 'open' }).innerHTML = '
'; + + for (const label of ['one', 'two', 'three']) { + const item = document.createElement('p'); + item.textContent = label; + host.appendChild(item); + } + document.querySelector('#content').appendChild(host); + + const $ = parseDOM(serializeDOM({ forceShadowAsLightDOM: true }).html); + const items = $('#multi .wrap p'); + + expect(Array.from(items).map(p => p.textContent)).toEqual(['one', 'two', 'three']); + }); + + it('uses the slot fallback content when nothing is assigned', () => { + if (!isChrome()) return; + withExample('
', { withShadow: false }); + const host = document.createElement('div'); + host.id = 'fallback'; + host.attachShadow({ mode: 'open' }).innerHTML = + '
Nothing here
'; + document.querySelector('#content').appendChild(host); + + const $ = parseDOM(serializeDOM({ forceShadowAsLightDOM: true }).html); + const placeholder = $('#fallback .wrap .placeholder'); + + expect(placeholder.length).toEqual(1); + expect(placeholder[0].textContent).toEqual('Nothing here'); + }); + + it('resolves slots forwarded through nested components', () => { + if (!isChrome()) return; + withExample('
', { withShadow: false }); + + // forwards its own slotted content into 's slot, the + // composition pattern the real portal nests four levels deep + const inner = document.createElement('div'); + inner.className = 'inner-host'; + inner.attachShadow({ mode: 'open' }).innerHTML = '
'; + + const outer = document.createElement('div'); + outer.id = 'outer-host'; + const outerShadow = outer.attachShadow({ mode: 'open' }); + const outerWrap = document.createElement('div'); + outerWrap.className = 'outer-wrap'; + // the forwarding slot sits behind a wrapper, so it is not itself directly + // assigned — assignedNodes({ flatten }) does not recurse into it and it + // has to be resolved on its own when the walk reaches it + const fwd = document.createElement('div'); + fwd.className = 'fwd'; + fwd.appendChild(document.createElement('slot')); + inner.appendChild(fwd); + outerWrap.appendChild(inner); + outerShadow.appendChild(outerWrap); + + const payload = document.createElement('p'); + payload.className = 'payload'; + payload.textContent = 'forwarded'; + outer.appendChild(payload); + + document.querySelector('#content').appendChild(outer); + + const $ = parseDOM(serializeDOM({ forceShadowAsLightDOM: true }).html); + const found = $('#outer-host .outer-wrap .inner-host .inner-wrap .fwd .payload'); + + expect(found.length).toEqual(1); + expect(found[0].textContent).toEqual('forwarded'); + }); + + it('resolves slots inside a closed shadow root captured via CDP', () => { + if (!isChrome()) return; + withExample('
', { withShadow: false }); + const host = document.createElement('div'); + host.id = 'closed-host'; + // `mode: closed` hides the root from element.shadowRoot — the clone walk + // reaches it only through the CDP-populated WeakMap + const closed = host.attachShadow({ mode: 'closed' }); + closed.innerHTML = '
'; + const payload = document.createElement('p'); + payload.className = 'closed-payload'; + payload.textContent = 'closed slotted'; + host.appendChild(payload); + document.querySelector('#content').appendChild(host); + + const orig = window.__percyClosedShadowRoots; + const map = new WeakMap(); + map.set(host, closed); + window.__percyClosedShadowRoots = map; + + try { + const $ = parseDOM(serializeDOM({ forceShadowAsLightDOM: true }).html); + const found = $('#closed-host .closed-wrap .closed-payload'); + + expect(found.length).toEqual(1); + expect(found[0].textContent).toEqual('closed slotted'); + } finally { + window.__percyClosedShadowRoots = orig; + } + }); + + it('flattens a slotted element that is itself a shadow host', () => { + if (!isChrome()) return; + withExample('
', { withShadow: false }); + const host = document.createElement('div'); + host.id = 'parent-host'; + host.attachShadow({ mode: 'open' }).innerHTML = '
'; + + // the projected node carries its own shadow root, which must flatten too + const child = document.createElement('div'); + child.className = 'child-host'; + child.attachShadow({ mode: 'open' }).innerHTML = 'inner'; + host.appendChild(child); + document.querySelector('#content').appendChild(host); + + const $ = parseDOM(serializeDOM({ forceShadowAsLightDOM: true }).html); + const found = $('#parent-host .wrap .child-host .child-inner'); + + expect(found.length).toEqual(1); + expect(found[0].textContent).toEqual('inner'); + }); + + it('assigns content to the first matching slot only', () => { + if (!isChrome()) return; + withExample('
', { withShadow: false }); + const host = document.createElement('div'); + host.id = 'dup-host'; + host.attachShadow({ mode: 'open' }).innerHTML = + '
' + + '
'; + const p = document.createElement('p'); + p.setAttribute('slot', 'dup'); + p.textContent = 'once'; + host.appendChild(p); + document.querySelector('#content').appendChild(host); + + const html = serializeDOM({ forceShadowAsLightDOM: true }).html; + const $ = parseDOM(html); + + // per spec only the first same-named slot receives the content + expect($('#dup-host .slot-a p').length).toEqual(1); + expect($('#dup-host .slot-b p').length).toEqual(0); + expect((html.match(/once/g) || []).length).toEqual(1); + }); + + it('resolves a slot in a shadow root nested inside another flattened host', () => { + if (!isChrome()) return; + withExample('
', { withShadow: false }); + + // inner host lives in the outer host's shadow tree and slots its own + // light child — not forwarded from the outer host + const inner = document.createElement('div'); + inner.className = 'inner2'; + inner.attachShadow({ mode: 'open' }).innerHTML = '
'; + const innerLight = document.createElement('p'); + innerLight.className = 'inner2-light'; + innerLight.textContent = 'own child'; + inner.appendChild(innerLight); + + const outer = document.createElement('div'); + outer.id = 'outer2'; + const mid = document.createElement('div'); + mid.className = 'mid'; + mid.appendChild(inner); + outer.attachShadow({ mode: 'open' }).appendChild(mid); + document.querySelector('#content').appendChild(outer); + + const $ = parseDOM(serializeDOM({ forceShadowAsLightDOM: true }).html); + const found = $('#outer2 .mid .inner2 .inner2-wrap .inner2-light'); + + expect(found.length).toEqual(1); + expect(found[0].textContent).toEqual('own child'); + }); + }); + + describe('when the host keeps its shadow root', () => { + it('leaves slots and slotted content untouched by default', () => { + if (!isChrome()) return; + buildCard(); + + const html = serializeDOM().html; + + // a real shadow root serializes declaratively and the browser re-does the + // projection at render time — nothing to resolve here + expect(html).toMatch('