fix(dom): resolve <slot> projection when flattening shadow DOM (PER-10812) - #2437
Conversation
…0812)
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 <slot> and appended the content after the flattened shadow
content — rendering it outside its container.
Resolve the projection during the clone: a <slot> 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 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configurationConfiguration used: Central YAML (base), Workspace UI (inherited) Review profile: ASSERTIVE Plan: Enterprise Run ID: 📒 Files selected for processing (7)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
…ion (PER-10812) Adds the cases the first pass missed: - a slot inside a closed shadow root, reached only through the CDP-populated __percyClosedShadowRoots WeakMap rather than element.shadowRoot - a slotted element that carries its own shadow root, so the projected node has to flatten in turn - two slots sharing a name, where only the first is assigned - a slot in a shadow root nested inside another flattened host, slotting its own light child rather than forwarded content Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ninadbstack
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 2 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| } catch { } | ||
|
|
||
| cloneEl.removeAttribute('src'); | ||
| cloneEl?.removeAttribute('src'); |
There was a problem hiding this comment.
[Medium] Optional chaining is a partial fix — the accessible-frame branch above still assumes a non-null cloneEl
When a flattened host projects no <iframe>, cloneEl (line 75) is null, but the loop doesn't bail out. It enters the frame.contentDocument && frame.contentDocument.documentElement branch (a bare iframe's contentDocument points at about:blank), runs a full recursive serializeDOM(), adds that frame's resources to the resource set, then throws a TypeError on the unguarded cloneEl.setAttribute('srcdoc', …) — silently swallowed by the adjacent empty catch { }. Sandboxed-iframe warnings are emitted for content that never renders too. Output HTML is unaffected, so this is wasted recursion, spurious resources and a masked exception rather than a visual regression.
Suggestion: Bail out once right after cloneEl is computed on line 75, matching the pattern this PR already applies in serialize-canvas.js, serialize-inputs.js and serialize-video.js:
let cloneEl = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
// no counterpart in the clone means the iframe wasn't rendered — e.g. light
// DOM content no <slot> projected, which the browser doesn't render
if (!cloneEl) continue;Both ?. guards then become unnecessary. Worth also strengthening the 'does not throw for unprojected media elements in a flattened host' test to assert the recursive serialization does not run, rather than only that nothing throws — as written it tolerates this defect.
Reviewer: stack-code-reviewer
|
|
||
| // no counterpart in the clone means the canvas wasn't serialized — e.g. | ||
| // light DOM content no <slot> projected, which the browser doesn't render | ||
| if (!clone.querySelector(`[data-percy-element-id=${percyElementId}]`)) continue; |
There was a problem hiding this comment.
[Low] Unquoted attribute-selector template, inconsistent with the sibling serializers
serialize-inputs.js, serialize-video.js, serialize-frames.js, serialize-cssom.js and serialize-dom.js all quote the attribute value. This line matches the pre-existing unquoted selector in createAndInsertImageElement in the same file, so it's locally consistent — but it copies a latent fragility: an unquoted CSS attribute value must be a valid CSS identifier. Today uid() returns _${random} and is always valid, so it works; if the ID format ever changes, querySelector throws a SyntaxError instead of returning null, turning a correct "not projected, skip" into a reported canvas-serialization error.
Suggestion:
| if (!clone.querySelector(`[data-percy-element-id=${percyElementId}]`)) continue; | |
| if (!clone.querySelector(`[data-percy-element-id="${percyElementId}"]`)) continue; |
Reviewer: stack-code-reviewer
Claude Code PR ReviewPR: #2437 • Head: SummaryResolves Review Table
Findings
Notes verified, no action needed
Reviewer recommendation was request changes on the strength of Finding 1. Under this gate's rule the verdict is computed from severity — the highest open finding is Medium, so the status is green — but Finding 1 is a genuine defect reachable by this PR's own test and is worth fixing before merge. Verdict: PASS — core fix and its test coverage are solid; one Medium and one Low remain open, neither gating. |
Problem
forceShadowAsLightDOM: trueflattens shadow roots into light DOM, 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. Dropping the root left an inert
<slot>in the output and appended the slotted content after the flattened shadow content — so it rendered outside its container.Reproduced live on a Salesforce LWC page (PER-10812): the Yes/No tiles rendered below the card instead of inside it.
Fix
<slot>belonging to a shadow root emits itsassignedNodes({ flatten: true })at that position and is not cloned. Reading the browser's own assignment handles named slots, ordering, fallback content and forwarded slots without reimplementing the matching rules.Behaviour change
Content assigned to no slot is now dropped, matching what the browser renders for it: nothing. Previously it rendered, misplaced, after the container.
Such elements have no clone, so
serialize-canvas/inputs/video/framesnow skip an element whose clone lookup comes back empty rather than failing to resolve it. Without this, an unprojected<canvas>/<input>/<video>under a flattened host throws and fails the whole snapshot.Tests
New
packages/dom/test/slot-projection.test.js(13 specs) on a fixture mirroring the real LWC card — asserting position, not just presence:<slot>elements dropped<noscript>disableShadowDOMunchangedserialize-dom.test.jshad a spec asserting<slot name="title"></slot>survives, which pinned the bug in place; it now asserts the projection.Suite: 527 passing, verified against the live portal page (slots 2 → 0, content nested at the slot position, replay pixel-identical to the live render).
🤖 Generated with Claude Code