[Bug]: ShadowRootManager.#documentElement is a single shared field — a lookup in one context can pass another context's document element to locateNodes
#15 475 ouverte le 7 août 2026
Métriques du dépôt
- Stars
- (6 029 étoiles)
- Métriques de merge PR
- (Merge moyen 15j 15h) (48 PRs mergées en 30 j)
Description
WebdriverIO Version
9.30.1 (present on main)
Node.js Version
v24.18.0
Mode
WDIO Testrunner
Which capabilities are you using?
{
"browserName": "chrome",
"browserVersion": "stable"
}
What happened?
ShadowRootManager (packages/webdriverio/src/session/shadowRoot.ts) stores the document element in a single instance field #documentElement. Every [WDIO] newShadowRoot console log overwrites it, regardless of which browsing context fired the event. The other two caches in the same class — #shadowRoots and #currentDocumentIds — are already keyed per browsing context, but #documentElement is not.
So in any session with more than one context that registers shadow-DOM web components (main window + iframe, or main window + popup), the field ends up holding the last context that logged, and getShadowElementsByContextId(ctx) returns a foreign sharedId as a startNode for ctx. The driver then rejects it and every element lookup falls back to the regular WebDriver Classic command:
WARN webdriverio: Failed to execute browser.browsingContextLocateNodes({ ... }) due to Error: WebDriver Bidi command "browsingContext.locateNodes" failed with error: no such node - SharedId "f.….d.<foreign-document-id>.e.…" belongs to different document. Current document is <current-document-id>., falling back to regular WebDriver Classic command
What is your expected behavior?
#documentElement should be tracked per browsing context, exactly like #shadowRoots and #currentDocumentIds already are.
How to reproduce the bug.
Serve main.html and frame.html locally (e.g. python3 -m http.server 8081), then run the WDIO spec below.
main.html (shadow-DOM page with a button that injects a same-origin iframe):
<!doctype html>
<html>
<body>
<custom-app></custom-app>
<button id="add-frame">Add iframe</button>
<script>
customElements.define('custom-app', class extends HTMLElement {
constructor() { super().attachShadow({ mode: 'open' }).innerHTML = '<p>app</p>' }
});
document.getElementById('add-frame').onclick = () => {
const iframe = document.createElement('iframe');
iframe.src = 'frame.html';
document.body.appendChild(iframe);
};
</script>
</body>
</html>
frame.html (shadow-DOM page loaded in the iframe):
<!doctype html>
<html>
<body>
<custom-frame></custom-frame>
<script>
customElements.define('custom-frame', class extends HTMLElement {
constructor() { super().attachShadow({ mode: 'open' }).innerHTML = '<p>frame</p>' }
});
</script>
</body>
</html>
test.js:
const { browser, expect } = require('@wdio/globals')
describe('shadow root cache across contexts', () => {
it('main-window lookup uses the iframe documentElement as start node', async () => {
await browser.url('http://localhost:8081/main.html')
await expect($('custom-app')).toBeExisting() // caches the main window's documentElement
await $('#add-frame').click() // iframe loads, its preload overwrites #documentElement
await browser.pause(1200)
await expect($('custom-app')).toBeExisting() // locateNodes gets the iframe's documentElement
})
})
Relevant log output
The second lookup logs (and every subsequent element lookup does too):
[0-0] WARN webdriverio: Failed to execute browser.browsingContextLocateNodes({ ... }) due to Error: WebDriver Bidi command "browsingContext.locateNodes" failed with error: no such node - SharedId "f.569ABDB72D47495745902F00203432EB.d.E3BB0DF3B7E1809ED260D628841E4810.e.11" belongs to different document. Current document is 5BEB4B4EB20B38DA18BBBC4302442169., falling back to regular WebDriver Classic command
Environment: Chrome 151.0.7922.72 + ChromeDriver 151.0.7922.76 on macOS.
patch-package fix
Save as patches/webdriverio+9.30.1.patch and run npx patch-package webdriverio. (Source-level equivalent: convert #documentElement to #documentElements = new Map() in packages/webdriverio/src/session/shadowRoot.ts.)
diff --git a/node_modules/webdriverio/build/node.js b/node_modules/webdriverio/build/node.js
--- a/node_modules/webdriverio/build/node.js
+++ b/node_modules/webdriverio/build/node.js
@@ -4873,7 +4890,7 @@
#initialize;
#shadowRoots = /* @__PURE__ */ new Map();
#currentDocumentIds = /* @__PURE__ */ new Map();
- #documentElement;
+ #documentElements = /* @__PURE__ */ new Map();
#frameDepth = 0;
#handleLogEntryListener = this.handleLogEntry.bind(this);
#commandResultHandlerListener = this.#commandResultHandler.bind(this);
@@ -4914,6 +4931,7 @@
const params = command.params;
this.#shadowRoots.delete(params.context);
this.#currentDocumentIds.delete(params.context);
+ this.#documentElements.delete(params.context);
}
/**
* keep track of frame depth
@@ -4966,6 +4984,7 @@
if (currentDocId && currentDocId !== newDocId) {
log15.info(`Document changed in context ${ctxId}: ${currentDocId} -> ${newDocId}, purging ${this.#shadowRoots.get(ctxId)?.flat().length ?? 0} stale shadow roots`);
this.#shadowRoots.delete(ctxId);
+ this.#documentElements.delete(ctxId);
}
this.#currentDocumentIds.set(ctxId, newDocId);
}
@@ -4984,7 +5003,7 @@
this.#shadowRoots.set(logEntry.source.context, new ShadowRootTree(rootElem.sharedId));
}
}
- this.#documentElement = documentElement;
+ this.#documentElements.set(ctxId, documentElement);
const tree = this.#shadowRoots.get(logEntry.source.context);
if (!tree) {
throw new Error(`Couldn't find tree for context id ${logEntry.source.context}`);
@@ -5037,7 +5056,7 @@
}
tree = subTree;
} else {
- documentElement = this.#documentElement?.sharedId;
+ documentElement = this.#documentElements.get(contextId)?.sharedId;
}
const elements = tree.getAllLookupScopes();
return [
Related issues
- #15467 — sibling bug in the same class: stale shadow-root cache after same-context page-initiated navigations. The fix for that (PR #15470) does not address this multi-context case.