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
This commit is contained in:
Andrey Lushnikov 2021-12-07 11:23:37 -08:00 committed by GitHub
parent da13d025dc
commit a89fe3ec5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 80 additions and 31 deletions

View file

@ -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))

View file

@ -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;

View file

@ -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<VueVNode> = new Set();
while (walker.nextNode()) {
const element = walker.currentNode as any;
if (element && element.__vue__)
vue2Roots.add(element.__vue__.$root);
}
const vue2Roots: Set<VueVNode> = 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))

View file

@ -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);
});
});
}

View file

@ -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);
});
});
}