← Back to microsoft/playwright
microsoft / playwright · Issue No. 42772
1.62.1
Self-contained, no Salesforce org needed. The two files are inlined at the end of this section; the recorded trace and the two screenshots follow.
npm pack @lwc/synthetic-shadow@9.4.3, extract, and copy package/dist/index.js next to repro.html as synthetic-shadow.js. (The page shims window.process.env.NODE_ENV because the published dist expects a bundler define. That shim is harness-only.)npx tsx run-repro.ts. It records a trace with snapshots: true and prints a probe of the live page.npx playwright show-trace repro-trace.zip, select the last action, open the After snapshot.repro.html creates three <my-card> hosts with identical content (a .box div, an inline <svg class="icon">, a <slot>):
#synth1, #synth2: shadow root attached through the LWC polyfill in synthetic mode (attachShadow({ mode: 'open', '$$lwc-synthetic-mode': true })). Their CSS is document-level, the way LWC emits it: [my-tok] .box { border: 4px solid rebeccapurple } and .icon { width: 24px; height: 24px; fill: rebeccapurple }.#native1: a genuine native shadow root carrying its own <style> (control).repro.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>synthetic shadow DOM snapshot repro</title>
<style>
body { font-family: sans-serif; padding: 24px; }
my-card { display: block; margin: 16px 0; }
/* Document-level CSS, the way LWC emits it: an attribute-scoped rule plus a plain class rule. */
[my-tok] .box { border: 4px solid rebeccapurple; padding: 8px; }
.icon { width: 24px; height: 24px; fill: rebeccapurple; }
</style>
<!-- The published dist expects a bundler-provided NODE_ENV; shim it so the raw file loads as-is. -->
<script>window.process = { env: { NODE_ENV: 'production' } };</script>
<!-- package/dist/index.js from `npm pack @lwc/synthetic-shadow@9.4.3` -->
<script src="synthetic-shadow.js"></script>
</head>
<body>
<my-card id="synth1" my-tok><span>light content 1</span></my-card>
<my-card id="synth2" my-tok><span>light content 2</span></my-card>
<!-- control: a genuine native shadow root that carries its own <style> -->
<my-card id="native1"><span>native light content</span></my-card>
<script>
var TEMPLATE =
'<div class="box" my-tok>' +
'<svg class="icon" my-tok viewBox="0 0 10 10"><rect width="10" height="10"></rect></svg>' +
'<slot></slot>' +
'</div>';
function fill(root) {
var tpl = document.createElement('template');
tpl.innerHTML = TEMPLATE;
root.appendChild(tpl.content);
}
// The polyfill returns a SYNTHETIC root only when this private option is set;
// a plain attachShadow({ mode: 'open' }) still returns a native root.
var SYNTHETIC = { mode: 'open', '$lwc-synthetic-mode': true };
fill(document.getElementById('synth1').attachShadow(SYNTHETIC));
fill(document.getElementById('synth2').attachShadow(SYNTHETIC));
var native = document.getElementById('native1').attachShadow({ mode: 'open' });
native.innerHTML =
'<style>.box { border: 4px solid seagreen; padding: 8px; } .icon { width: 24px; height: 24px; fill: seagreen; }</style>' +
TEMPLATE.replace(/ my-tok/g, '');
window.__probe = function () {
var root = document.getElementById('synth1').shadowRoot;
var icon = getComputedStyle(root.querySelector('.icon'));
return {
instanceofShadowRoot: root instanceof ShadowRoot,
toString: Object.prototype.toString.call(root),
nodeType: root.nodeType,
mode: root.mode,
iconWidth: icon.width,
iconFill: icon.fill,
};
};
</script>
</body>
</html>
run-repro.ts// Serve this folder with any static file server first, e.g. `npx serve -l 8080 .`
// then: npx tsx run-repro.ts
import { chromium } from 'playwright';
async function main() {
const browser = await chromium.launch();
const context = await browser.newContext();
await context.tracing.start({ snapshots: true, screenshots: true });
const page = await context.newPage();
await page.goto('http://localhost:8080/repro.html');
console.log(await page.evaluate(() => (window as any).__probe()));
await context.tracing.stop({ path: 'repro-trace.zip' });
await browser.close();
// npx playwright show-trace repro-trace.zip -> open the last action's "After" snapshot
}
main();
The snapshot renders the synthetic hosts the way the live page does: 24px purple icon, purple border. Synthetic shadow DOM is a polyfill over a flat DOM. The "shadow" content is physically the host's own light-DOM children, and it is styled from document-level stylesheets by design.
In the snapshot the synthetic hosts' content receives no CSS at all, while the native control is pixel-identical to the live page.
Measured on .icon inside the host |
Live page | Trace-viewer snapshot |
|---|---|---|
#synth1 / #synth2 (synthetic) |
24×24px, fill: rebeccapurple, box border 4px |
1216×1216px, fill: black, box border none |
#native1 (native, own <style>) |
24×24px, fill: seagreen, border 4px |
24×24px, fill: seagreen, border 4px |
Compare live.png with snapshot.png: the small purple icons become a page-wide black square.
Capture, in packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts (visitNode), decides "this element has a shadow root" from two signals only: element.shadowRoot is truthy, and the returned node's nodeType is DOCUMENT_FRAGMENT_NODE, which produces ['template', { __playwright_shadow_root_: 'open' }, ...content]. The polyfill satisfies both on purpose: it patches the shadowRoot getter to return a real DocumentFragment whose prototype is swapped to its SyntheticShadowRoot.prototype, and patches childNodes/firstChild on hosts and roots so the flat children read as shadow content. The recorded trace therefore contains exactly the same marker for #synth1 as for #native1:
["MY-CARD", {"id": "synth1", "my-tok": ""},
["template", {"__playwright_shadow_root_": "open"},
["SPAN", {}, "light content 1"],
["DIV", {"class": "box", "my-tok": ""},
["svg", {"class": "icon", "my-tok": "", "viewBox": "0 0 10 10"}, ["rect", {"width": "10", "height": "10"}]],
["SLOT"]]]]
Replay, in packages/isomorphic/trace/snapshotRenderer.ts, then runs template.parentElement.attachShadow({ mode: 'open' }) for every marker with the browser's real attachShadow. That creates a genuine CSS boundary around content that never had one when it was captured, so [my-tok] .box and .icon in the document can no longer reach it.
Every Salesforce Lightning Experience page. LWC base components (lightning-icon, lightning-input, lightning-picklist, the record forms, the navigation bar) run in synthetic shadow mode. On a real Lightning trace from a Playwright test suite, the last snapshot held 430 captured shadow roots, 372 of them synthetic (hosts carrying LWC's lwc-<token>-host scoping attribute) and only 12 native; the smallest icons rendered at 1920px, every form field lost its styling, and only the light-DOM chrome around them stayed styled. That makes the snapshot pane unusable for debugging Lightning UI, which is presumably a common Playwright workload.
instanceof ShadowRoot does not distinguish them: the polyfill replaces window.ShadowRoot with its SyntheticShadowRoot and gives it a Symbol.hasInstance that returns true for native and synthetic roots alike (its source comments say so explicitly). Probing the live page confirms it:
{ "instanceofShadowRoot": true, "toString": "[object DocumentFragment]", "nodeType": 11, "mode": "open" }
What does still tell them apart from inside the page's realm, in decreasing order of generality:
Object.prototype.toString.call(root) is [object ShadowRoot] for a native root and [object DocumentFragment] for a synthetic one (the swapped prototype inherits DocumentFragment's tag).shadowRoot getter taken from another realm (a same-origin about:blank iframe) or from an isolated world returns null for a synthetic host, because the polyfill only patches the main world's Element.prototype.root.synthetic === true.When a root is recognised as synthetic, the faithful encoding is simply "no shadow root": serialise the host's real children as ordinary children (the polyfill's patched childNodes on the root already returns them). On LWC-rendered pages that also reproduces slot projection for free, because the engine places slotted nodes physically inside their <slot> element and the polyfill merely hides that from the patched accessors.
For anyone hitting this today: we post-process trace.zip after each test, splicing synthetic roots' content back into their hosts and re-encoding the snapshot back-references. It works, but it is a workaround for something the snapshotter can decide correctly at capture time.
System:
OS: macOS 26.6.2
CPU: (10) arm64 Apple M4
Binaries:
Node: 20.13.0
npm: 10.9.1
npmPackages:
@playwright/test: ^1.60.0 => 1.62.1
Browser: Chromium (bundled with 1.62.1)
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.