Version: 1.59.1
Steps to reproduce:
Minimal reproduction repo: https://github.com/silviorodrigues/pw-stencil-trace-repro
git clone https://github.com/silviorodrigues/pw-stencil-trace-repro
cd pw-stencil-trace-repro
npm install
npx playwright install chromium
npm test
npx playwright show-report
Open the trace for the single test. Click through the snapshots. The body of the page is blank even though the test passes and the browser renders the content correctly.
Expected behavior:
The trace viewer snapshot panel should show the full page DOM, including the <main>, <h1>, and <button> elements that live inside <ion-app>.
Actual behavior:
The snapshot panel is blank. The <ion-app> element appears in the snapshot tree with no children.
Inspecting the raw trace confirms the issue. The frame-snapshot event after navigation contains:
["BODY", {"__playwright_custom_elements__":"ion-app"}, ["ION-APP"]]
ION-APP has zero children in the captured snapshot, even though the live DOM contains <main><h1>...</h1><button>...</button></main> inside it.
Root cause:
Stencil.js uses a synthetic shadow DOM polyfill that patches Node.prototype.firstChild and Node.prototype.nextSibling at the prototype level. For elements it manages (called host elements), both getters return null, hiding their children from external DOM traversal. This is how Stencil implements slot projection without native Shadow DOM.
snapshotterInjected.js is injected as an init script and runs before page scripts. However, the child-traversal loop uses node.firstChild / child.nextSibling, which go through the prototype chain. By the time the snapshot is taken, Stencil has already replaced those getters. The snapshotter therefore sees an empty ion-app on every snapshot.
The three for loops in snapshotterInjected.js that iterate children all use the live (potentially patched) accessors:
for (let child = node.firstChild; child; child = child.nextSibling)
Proposed fix:
Capture the native getters at init-script evaluation time (before any page script can patch them), then use those captured versions for all child traversal:
const _nativeFirstChild = Object.getOwnPropertyDescriptor(Node.prototype, 'firstChild')?.get;
const _nativeNextSibling = Object.getOwnPropertyDescriptor(Node.prototype, 'nextSibling')?.get;
const nativeFirstChild = _nativeFirstChild ? n => _nativeFirstChild.call(n) : n => n.firstChild;
const nativeNextSibling = _nativeNextSibling ? n => _nativeNextSibling.call(n) : n => n.nextSibling;
Then replace the traversal loops:
// before
for (let child = node.firstChild; child; child = child.nextSibling)
// after
for (let child = nativeFirstChild(node); child; child = nativeNextSibling(child))
Two additional fixes are needed for mutations to propagate correctly once DOM insertions start being detected:
-
Add childList: true to the MutationObserver config (currently only attributes: true, subtree: true), so insertions of children into host elements trigger re-snapshots.
-
In _handleMutations, walk the ancestor chain and clear both cached and attributesCached on every ancestor when a node changes, so the delta-compressed snapshot tree is fully invalidated up to the root.
Affected frameworks: Any framework using a synthetic shadow DOM polyfill that patches Node.prototype.firstChild/nextSibling at the prototype level. Ionic (via Stencil.js) is the primary real-world case, affecting all Ionic Angular/React/Vue apps.
Environment:
System:
OS: macOS 15.7.5
CPU: (10) arm64 Apple M1 Pro
Memory: 138.25 MB / 32.00 GB
Binaries:
Node: 24.13.0
npm: 11.6.2
npmPackages:
@playwright/test: ^1.59.1 => 1.59.1
Version: 1.59.1
Steps to reproduce:
Minimal reproduction repo: https://github.com/silviorodrigues/pw-stencil-trace-repro
Open the trace for the single test. Click through the snapshots. The body of the page is blank even though the test passes and the browser renders the content correctly.
Expected behavior:
The trace viewer snapshot panel should show the full page DOM, including the
<main>,<h1>, and<button>elements that live inside<ion-app>.Actual behavior:
The snapshot panel is blank. The
<ion-app>element appears in the snapshot tree with no children.Inspecting the raw trace confirms the issue. The
frame-snapshotevent after navigation contains:ION-APPhas zero children in the captured snapshot, even though the live DOM contains<main><h1>...</h1><button>...</button></main>inside it.Root cause:
Stencil.js uses a synthetic shadow DOM polyfill that patches
Node.prototype.firstChildandNode.prototype.nextSiblingat the prototype level. For elements it manages (called host elements), both getters returnnull, hiding their children from external DOM traversal. This is how Stencil implements slot projection without native Shadow DOM.snapshotterInjected.jsis injected as an init script and runs before page scripts. However, the child-traversal loop usesnode.firstChild/child.nextSibling, which go through the prototype chain. By the time the snapshot is taken, Stencil has already replaced those getters. The snapshotter therefore sees an emptyion-appon every snapshot.The three
forloops insnapshotterInjected.jsthat iterate children all use the live (potentially patched) accessors:Proposed fix:
Capture the native getters at init-script evaluation time (before any page script can patch them), then use those captured versions for all child traversal:
Then replace the traversal loops:
Two additional fixes are needed for mutations to propagate correctly once DOM insertions start being detected:
Add
childList: trueto theMutationObserverconfig (currently onlyattributes: true, subtree: true), so insertions of children into host elements trigger re-snapshots.In
_handleMutations, walk the ancestor chain and clear bothcachedandattributesCachedon every ancestor when a node changes, so the delta-compressed snapshot tree is fully invalidated up to the root.Affected frameworks: Any framework using a synthetic shadow DOM polyfill that patches
Node.prototype.firstChild/nextSiblingat the prototype level. Ionic (via Stencil.js) is the primary real-world case, affecting all Ionic Angular/React/Vue apps.Environment: