From f7db6ce271663ff1940f481ddc110d7f9bbfbfe0 Mon Sep 17 00:00:00 2001 From: Ninad Sheth Date: Mon, 21 Sep 2026 13:31:33 +0000 Subject: [PATCH 1/2] fix(dom): resolve projection when flattening shadow DOM (PER-10812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forceShadowAsLightDOM flattened shadow roots but never resolved slot projection. Slotted content lives in the host's light DOM and is only rendered at the slot's position by the shadow root, so dropping the root left an inert and appended the content after the flattened shadow content — rendering it outside its container. Resolve the projection during the clone: a belonging to a shadow root emits its assignedNodes({ flatten: true }) at that position and is not cloned, and the light-DOM walk is skipped for a flattened host so projected nodes aren't emitted twice. Content assigned to no slot is now dropped, matching what the browser renders for it. Such elements have no clone, so the serializers that look one up by element id skip them instead of failing to resolve it. Co-Authored-By: Claude Opus 5 --- packages/dom/src/clone-dom.js | 30 ++- packages/dom/src/serialize-canvas.js | 4 + packages/dom/src/serialize-frames.js | 4 +- packages/dom/src/serialize-inputs.js | 3 + packages/dom/src/serialize-video.js | 3 + packages/dom/test/serialize-dom.test.js | 7 +- packages/dom/test/slot-projection.test.js | 274 ++++++++++++++++++++++ 7 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 packages/dom/test/slot-projection.test.js 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'); + }); + }); + + 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('