webdriverio/webdriverio

[Bug]: ShadowRootManager.#documentElement is a single shared field — a lookup in one context can pass another context's document element to locateNodes

Ouverte

#15 475 ouverte le 7 août 2026

 (3 commentaires) (0 réaction) (0 personne assignée)JavaScript (1 793 forks)batch import
Bug 🐛Protocol Relatedgood first pickhelp wanted

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.

Guide contributeur