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

Open
#42,772 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

needs-triage-mac
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.