microsoft / microsoft/playwright
[Bug]: Trace viewer snapshots turn LWC synthetic shadow roots into real shadow roots, so document-level CSS stops applying to their content
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 96.3k
- Forks
- 6.5k
- Avg merge
- 1d 6h
- Merged PRs (30d)
- 180
Description
## Version
1.62.1
## Steps to reproduce
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.
1. `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.)
2. Serve the folder with any static server on port 8080 and run `npx tsx run-repro.ts`. It records a trace with `snapshots: true` and prints a probe of the live page.
3. `npx playwright show-trace repro-trace.zip`, select the last action, open the **After** snapshot.
`repro.html` creates three `` hosts with identical content (a `.box` div, an inline `repro.html
```html
synthetic shadow DOM snapshot repro
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; }
window.process = { env: { NODE_ENV: 'production' } };
light content 1
light content 2
native light content
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,
};
};
```
run-repro.ts
```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();
```
## Expected behavior
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.
## Actual behavior
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 ``) | 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.
## Why it happens
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`:
```json
["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.
## Where this bites in practice
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.
## Notes on a fix
`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:
```json
{ "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:
1. `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).
2. A pristine `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`.
3. LWC-specific: the synthetic prototype defines `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.
## Environment
```
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)
```
## Attachments
[repro-trace.zip](https://github.com/user-attachments/files/32376589/repro-trace.zip)
<img width="1280" height="720" alt="Image" src="https://github.com/user-attachments/assets/1797d606-02b1-4c18-9216-3a6af007fe68" />
<img width="1400" height="900" alt="Image" src="https://github.com/user-attachments/assets/65ca9d1f-5278-4f1c-926d-f8a1ba1e3714" />
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Run the supplied repro and inspect visitNode in packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts, then follow replay in packages/isomorphic/trace/snapshotRenderer.ts. Confirm how synthetic and native roots are encoded and rendered. Done means synthetic roots retain document-level CSS in the snapshot while native shadow roots remain unchanged, with the repro's measured styles matching the live page.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- playwright, typescript
- Domain
- devtools, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100