From a89fe3ec5cf5eda550a5ff7a8ee8bcf81cb207ed Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Tue, 7 Dec 2021 11:23:37 -0800 Subject: [PATCH] fix: support shadow DOM with Vue and React selectors (#10742) There were two issues: - we did not find VDom roots inside shadow DOM - we incorrectly relied on DOM's `contain` method to determine if VDom's rendered node belongs to requested scope. Fixes #10123 --- .../server/injected/reactSelectorEngine.ts | 35 +++++++++++-------- .../src/server/injected/selectorEvaluator.ts | 11 ++++++ .../src/server/injected/vueSelectorEngine.ts | 33 ++++++++--------- tests/page/selectors-react.spec.ts | 12 +++++++ tests/page/selectors-vue.spec.ts | 20 +++++++++++ 5 files changed, 80 insertions(+), 31 deletions(-) diff --git a/packages/playwright-core/src/server/injected/reactSelectorEngine.ts b/packages/playwright-core/src/server/injected/reactSelectorEngine.ts index 1c452ea824..42c6b81415 100644 --- a/packages/playwright-core/src/server/injected/reactSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/reactSelectorEngine.ts @@ -15,6 +15,7 @@ */ import { SelectorEngine, SelectorRoot } from './selectorEngine'; +import { isInsideScope } from './selectorEvaluator'; import { checkComponentAttribute, parseComponentSelector } from '../common/componentUtils'; type ComponentNode = { @@ -132,24 +133,28 @@ function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: Componen return result; } -function findReactRoots(): ReactVNode[] { - const roots: ReactVNode[] = []; - const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT); - while (walker.nextNode()) { +function findReactRoots(root: Document | ShadowRoot, roots: ReactVNode[] = []): ReactVNode[] { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { const node = walker.currentNode; // @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L329 if (node.hasOwnProperty('_reactRootContainer')) roots.push((node as any)._reactRootContainer._internalRoot.current); - } - // Pre-react 16: query dom for `data-reactroot` - // @see https://github.com/facebook/react/issues/10971 - for (const node of document.querySelectorAll('[data-reactroot]')) { - for (const key of Object.keys(node)) { - // @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L334 - if (key.startsWith('__reactInternalInstance') || key.startsWith('__reactFiber')) - roots.push((node as any)[key]); + + // Pre-react 16: rely on `data-reactroot` + // @see https://github.com/facebook/react/issues/10971 + if ((node instanceof Element) && node.hasAttribute('data-reactroot')) { + for (const key of Object.keys(node)) { + // @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L334 + if (key.startsWith('__reactInternalInstance') || key.startsWith('__reactFiber')) + roots.push((node as any)[key]); + } } - } + + const shadowRoot = node instanceof Element ? node.shadowRoot : null; + if (shadowRoot) + findReactRoots(shadowRoot, roots); + } while (walker.nextNode()); return roots; } @@ -157,12 +162,12 @@ export const ReactEngine: SelectorEngine = { queryAll(scope: SelectorRoot, selector: string): Element[] { const { name, attributes } = parseComponentSelector(selector); - const reactRoots = findReactRoots(); + const reactRoots = findReactRoots(document); const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot)); const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => { if (name && treeNode.name !== name) return false; - if (treeNode.rootElements.some(domNode => !scope.contains(domNode))) + if (treeNode.rootElements.some(domNode => !isInsideScope(scope, domNode))) return false; for (const attr of attributes) { if (!checkComponentAttribute(treeNode.props, attr)) diff --git a/packages/playwright-core/src/server/injected/selectorEvaluator.ts b/packages/playwright-core/src/server/injected/selectorEvaluator.ts index 2affe18b49..e9a38bb5a6 100644 --- a/packages/playwright-core/src/server/injected/selectorEvaluator.ts +++ b/packages/playwright-core/src/server/injected/selectorEvaluator.ts @@ -619,6 +619,17 @@ const nthMatchEngine: SelectorEngine = { }, }; +export function isInsideScope(scope: Node, element: Element | undefined): boolean { + while (element) { + if (scope.contains(element)) + return true; + while (element.parentElement) + element = element.parentElement; + element = parentElementOrShadowHost(element); + } + return false; +} + export function parentElementOrShadowHost(element: Element): Element | undefined { if (element.parentElement) return element.parentElement; diff --git a/packages/playwright-core/src/server/injected/vueSelectorEngine.ts b/packages/playwright-core/src/server/injected/vueSelectorEngine.ts index 5baef92938..8fe6a6316b 100644 --- a/packages/playwright-core/src/server/injected/vueSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/vueSelectorEngine.ts @@ -15,6 +15,7 @@ */ import { SelectorEngine, SelectorRoot } from './selectorEngine'; +import { isInsideScope } from './selectorEvaluator'; import { checkComponentAttribute, parseComponentSelector } from '../common/componentUtils'; type ComponentNode = { @@ -205,21 +206,21 @@ function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: Componen } type VueRoot = {version: number, root: VueVNode}; -function findVueRoots(): VueRoot[] { - const roots: VueRoot[] = []; - // Vue3 roots are marked with [data-v-app] attribute - for (const node of document.querySelectorAll('[data-v-app]')) { - if ((node as any)._vnode && (node as any)._vnode.component) - roots.push({ root: (node as any)._vnode.component, version: 3 }); - } +function findVueRoots(root: Document | ShadowRoot, roots: VueRoot[] = []): VueRoot[] { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); // Vue2 roots are referred to from elements. - const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT); - const vue2Roots: Set = new Set(); - while (walker.nextNode()) { - const element = walker.currentNode as any; - if (element && element.__vue__) - vue2Roots.add(element.__vue__.$root); - } + const vue2Roots: Set = new Set(); + do { + const node = walker.currentNode; + if ((node as any).__vue__) + vue2Roots.add((node as any).__vue__.$root); + // Vue3 roots are marked with __vue_app__. + if ((node as any).__vue_app__ && (node as any)._vnode && (node as any)._vnode.component) + roots.push({ root: (node as any)._vnode.component, version: 3 }); + const shadowRoot = node instanceof Element ? node.shadowRoot : null; + if (shadowRoot) + findVueRoots(shadowRoot, roots); + } while (walker.nextNode()); for (const vue2root of vue2Roots) { roots.push({ version: 2, @@ -232,12 +233,12 @@ function findVueRoots(): VueRoot[] { export const VueEngine: SelectorEngine = { queryAll(scope: SelectorRoot, selector: string): Element[] { const { name, attributes } = parseComponentSelector(selector); - const vueRoots = findVueRoots(); + const vueRoots = findVueRoots(document); const trees = vueRoots.map(vueRoot => vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root)); const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => { if (name && treeNode.name !== name) return false; - if (treeNode.rootElements.some(rootElement => !scope.contains(rootElement))) + if (treeNode.rootElements.some(rootElement => !isInsideScope(scope, rootElement))) return false; for (const attr of attributes) { if (!checkComponentAttribute(treeNode.props, attr)) diff --git a/tests/page/selectors-react.spec.ts b/tests/page/selectors-react.spec.ts index 708692c143..e987d692fc 100644 --- a/tests/page/selectors-react.spec.ts +++ b/tests/page/selectors-react.spec.ts @@ -134,6 +134,18 @@ for (const [name, url] of Object.entries(reacts)) { await expect(page.locator('css=#root2 >> _react=BookItem')).toHaveCount(4); }); }); + + it('should work with multiroot react inside shadow DOM', async ({ page }) => { + await expect(page.locator(`_react=BookItem`)).toHaveCount(3); + await page.evaluate(() => { + const anotherRoot = document.createElement('div'); + document.body.append(anotherRoot); + const shadowRoot = anotherRoot.attachShadow({ mode: 'open' }); + // @ts-ignore + window.mountApp(shadowRoot); + }); + await expect(page.locator(`_react=BookItem`)).toHaveCount(6); + }); }); } diff --git a/tests/page/selectors-vue.spec.ts b/tests/page/selectors-vue.spec.ts index 84918a72cb..cd8da9825d 100644 --- a/tests/page/selectors-vue.spec.ts +++ b/tests/page/selectors-vue.spec.ts @@ -132,6 +132,26 @@ for (const [name, url] of Object.entries(vues)) { await expect(page.locator('css=#root2 >> _vue=book-item')).toHaveCount(4); }); }); + + it('should work with multiroot vue inside shadow DOM', async ({ page }) => { + await expect(page.locator(`_vue=book-item`)).toHaveCount(3); + await page.evaluate(vueName => { + const anotherRoot = document.createElement('div'); + document.body.append(anotherRoot); + const shadowRoot = anotherRoot.attachShadow({ mode: 'open' }); + if (vueName === 'vue2') { + // Vue2 cannot be mounted in shadow root directly. + const div = document.createElement('div'); + shadowRoot.append(div); + // @ts-ignore + window.mountApp(div); + } else { + // @ts-ignore + window.mountApp(shadowRoot); + } + }, name); + await expect(page.locator(`_vue=book-item`)).toHaveCount(6); + }); }); }