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 { SelectorEngine, SelectorRoot } from './selectorEngine';
import { isInsideScope } from './selectorEvaluator';
import { checkComponentAttribute, parseComponentSelector } from '../common/componentUtils'; import { checkComponentAttribute, parseComponentSelector } from '../common/componentUtils';
type ComponentNode = { type ComponentNode = {
@ -132,24 +133,28 @@ function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: Componen
return result; return result;
} }
function findReactRoots(): ReactVNode[] { function findReactRoots(root: Document | ShadowRoot, roots: ReactVNode[] = []): ReactVNode[] {
const roots: ReactVNode[] = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT); do {
while (walker.nextNode()) {
const node = walker.currentNode; const node = walker.currentNode;
// @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L329 // @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L329
if (node.hasOwnProperty('_reactRootContainer')) if (node.hasOwnProperty('_reactRootContainer'))
roots.push((node as any)._reactRootContainer._internalRoot.current); roots.push((node as any)._reactRootContainer._internalRoot.current);
}
// Pre-react 16: query dom for `data-reactroot` // Pre-react 16: rely on `data-reactroot`
// @see https://github.com/facebook/react/issues/10971 // @see https://github.com/facebook/react/issues/10971
for (const node of document.querySelectorAll('[data-reactroot]')) { if ((node instanceof Element) && node.hasAttribute('data-reactroot')) {
for (const key of Object.keys(node)) { for (const key of Object.keys(node)) {
// @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L334 // @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L334
if (key.startsWith('__reactInternalInstance') || key.startsWith('__reactFiber')) if (key.startsWith('__reactInternalInstance') || key.startsWith('__reactFiber'))
roots.push((node as any)[key]); roots.push((node as any)[key]);
}
} }
}
const shadowRoot = node instanceof Element ? node.shadowRoot : null;
if (shadowRoot)
findReactRoots(shadowRoot, roots);
} while (walker.nextNode());
return roots; return roots;
} }
@ -157,12 +162,12 @@ export const ReactEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, selector: string): Element[] { queryAll(scope: SelectorRoot, selector: string): Element[] {
const { name, attributes } = parseComponentSelector(selector); const { name, attributes } = parseComponentSelector(selector);
const reactRoots = findReactRoots(); const reactRoots = findReactRoots(document);
const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot)); const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot));
const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => { const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => {
if (name && treeNode.name !== name) if (name && treeNode.name !== name)
return false; return false;
if (treeNode.rootElements.some(domNode => !scope.contains(domNode))) if (treeNode.rootElements.some(domNode => !isInsideScope(scope, domNode)))
return false; return false;
for (const attr of attributes) { for (const attr of attributes) {
if (!checkComponentAttribute(treeNode.props, attr)) 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 { export function parentElementOrShadowHost(element: Element): Element | undefined {
if (element.parentElement) if (element.parentElement)
return element.parentElement; return element.parentElement;

View file

@ -15,6 +15,7 @@
*/ */
import { SelectorEngine, SelectorRoot } from './selectorEngine'; import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { isInsideScope } from './selectorEvaluator';
import { checkComponentAttribute, parseComponentSelector } from '../common/componentUtils'; import { checkComponentAttribute, parseComponentSelector } from '../common/componentUtils';
type ComponentNode = { type ComponentNode = {
@ -205,21 +206,21 @@ function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: Componen
} }
type VueRoot = {version: number, root: VueVNode}; type VueRoot = {version: number, root: VueVNode};
function findVueRoots(): VueRoot[] { function findVueRoots(root: Document | ShadowRoot, roots: VueRoot[] = []): VueRoot[] {
const roots: VueRoot[] = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
// 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 });
}
// Vue2 roots are referred to from elements. // Vue2 roots are referred to from elements.
const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT); const vue2Roots: Set<VueVNode> = new Set();
const vue2Roots: Set<VueVNode> = new Set(); do {
while (walker.nextNode()) { const node = walker.currentNode;
const element = walker.currentNode as any; if ((node as any).__vue__)
if (element && element.__vue__) vue2Roots.add((node as any).__vue__.$root);
vue2Roots.add(element.__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) { for (const vue2root of vue2Roots) {
roots.push({ roots.push({
version: 2, version: 2,
@ -232,12 +233,12 @@ function findVueRoots(): VueRoot[] {
export const VueEngine: SelectorEngine = { export const VueEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, selector: string): Element[] { queryAll(scope: SelectorRoot, selector: string): Element[] {
const { name, attributes } = parseComponentSelector(selector); 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 trees = vueRoots.map(vueRoot => vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root));
const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => { const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => {
if (name && treeNode.name !== name) if (name && treeNode.name !== name)
return false; return false;
if (treeNode.rootElements.some(rootElement => !scope.contains(rootElement))) if (treeNode.rootElements.some(rootElement => !isInsideScope(scope, rootElement)))
return false; return false;
for (const attr of attributes) { for (const attr of attributes) {
if (!checkComponentAttribute(treeNode.props, attr)) 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); 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); 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);
});
}); });
} }