From 48e94c15c1fec70158df1ed533b8fad2e1f8016e Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Fri, 20 Aug 2021 15:05:52 +0300 Subject: [PATCH] feat: support multiple roots in React and Vue selectors (#8313) Fixes #8230 --- src/server/injected/reactSelectorEngine.ts | 42 +++-- src/server/injected/vueSelectorEngine.ts | 42 +++-- tests/assets/reading-list/react15.html | 6 +- tests/assets/reading-list/react16.html | 6 +- tests/assets/reading-list/react17.html | 6 +- tests/assets/reading-list/vue2.html | 8 +- tests/assets/reading-list/vue3.html | 203 +++++++++++---------- tests/page/selectors-react.spec.ts | 21 +++ tests/page/selectors-vue.spec.ts | 22 +++ 9 files changed, 204 insertions(+), 152 deletions(-) diff --git a/src/server/injected/reactSelectorEngine.ts b/src/server/injected/reactSelectorEngine.ts index dd3694ba3c..0300f965c5 100644 --- a/src/server/injected/reactSelectorEngine.ts +++ b/src/server/injected/reactSelectorEngine.ts @@ -132,30 +132,36 @@ function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: Componen return result; } -function findReactRoot(): ReactVNode | undefined { - const walker = document.createTreeWalker(document); - while (walker.nextNode()) { - // @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L329 - if (walker.currentNode.hasOwnProperty('_reactRootContainer')) - return (walker.currentNode as any)._reactRootContainer._internalRoot.current; - for (const key of Object.keys(walker.currentNode)) { - // @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L334 - if (key.startsWith('__reactInternalInstance') || key.startsWith('__reactFiber')) - return (walker.currentNode as any)[key]; +function findReactRoots(): ReactVNode[] { + const roots: ReactVNode[] = []; + const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT, { + acceptNode: function(node) { + // @see https://github.com/baruchvlz/resq/blob/5c15a5e04d3f7174087248f5a158c3d6dcc1ec72/src/utils.js#L329 + if (node.hasOwnProperty('_reactRootContainer')) { + roots.push((node as any)._reactRootContainer._internalRoot.current); + return NodeFilter.FILTER_REJECT; + } + 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]); + return NodeFilter.FILTER_REJECT; + } + } + return NodeFilter.FILTER_ACCEPT; } - } - return undefined; + }); + while (walker.nextNode()); + return roots; } export const ReactEngine: SelectorEngine = { queryAll(scope: SelectorRoot, selector: string): Element[] { const {name, attributes} = parseComponentSelector(selector); - const reactRoot = findReactRoot(); - if (!reactRoot) - return []; - const tree = buildComponentsTree(reactRoot); - const treeNodes = filterComponentsTree(tree, treeNode => { + const reactRoots = findReactRoots(); + 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))) @@ -165,7 +171,7 @@ export const ReactEngine: SelectorEngine = { return false; } return true; - }); + })).flat(); const allRootElements: Set = new Set(); for (const treeNode of treeNodes) { for (const domNode of treeNode.rootElements) diff --git a/src/server/injected/vueSelectorEngine.ts b/src/server/injected/vueSelectorEngine.ts index 20cf822815..274f31aca9 100644 --- a/src/server/injected/vueSelectorEngine.ts +++ b/src/server/injected/vueSelectorEngine.ts @@ -204,27 +204,34 @@ function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: Componen return result; } -function findVueRoot(): undefined|{version: number, root: VueVNode} { - const walker = document.createTreeWalker(document); - while (walker.nextNode()) { - // Vue3 root - if ((walker.currentNode as any)._vnode && (walker.currentNode as any)._vnode.component) - return {root: (walker.currentNode as any)._vnode.component, version: 3}; - // Vue2 root - if ((walker.currentNode as any).__vue__) - return {root: (walker.currentNode as any).__vue__, version: 2}; - } - return undefined; +type VueRoot = {version: number, root: VueVNode}; +function findVueRoots(): VueRoot[] { + const roots: VueRoot[] = []; + const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT, { + acceptNode: function(node) { + // Vue3 root + if ((node as any)._vnode && (node as any)._vnode.component) { + roots.push({root: (node as any)._vnode.component, version: 3}); + return NodeFilter.FILTER_REJECT; + } + // Vue2 root + if ((node as any).__vue__) { + roots.push({root: (node as any).__vue__, version: 2}); + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + while (walker.nextNode()); + return roots; } export const VueEngine: SelectorEngine = { queryAll(scope: SelectorRoot, selector: string): Element[] { const {name, attributes} = parseComponentSelector(selector); - const vueRoot = findVueRoot(); - if (!vueRoot) - return []; - const tree = vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root); - const treeNodes = filterComponentsTree(tree, treeNode => { + const vueRoots = findVueRoots(); + 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))) @@ -233,9 +240,8 @@ export const VueEngine: SelectorEngine = { if (!checkComponentAttribute(treeNode.props, attr)) return false; } - return true; - }); + })).flat(); const allRootElements: Set = new Set(); for (const treeNode of treeNodes) { for (const rootElement of treeNode.rootElements) diff --git a/tests/assets/reading-list/react15.html b/tests/assets/reading-list/react15.html index 40fefc9933..800858b47d 100644 --- a/tests/assets/reading-list/react15.html +++ b/tests/assets/reading-list/react15.html @@ -101,9 +101,7 @@ class App extends React.Component { } } -ReactDOM.render( - e(App, null, null), - document.getElementById('root'), -); +window.mountApp = element => ReactDOM.render(e(App, null, null), element); +window.mountApp(document.getElementById('root')); diff --git a/tests/assets/reading-list/react16.html b/tests/assets/reading-list/react16.html index 77a15768b6..13b47b92af 100644 --- a/tests/assets/reading-list/react16.html +++ b/tests/assets/reading-list/react16.html @@ -99,9 +99,7 @@ class App extends React.Component { } } -ReactDOM.render( - e(App, null, null), - document.getElementById('root'), -); +window.mountApp = element => ReactDOM.render(e(App, null, null), element); +window.mountApp(document.getElementById('root')); diff --git a/tests/assets/reading-list/react17.html b/tests/assets/reading-list/react17.html index ac5463dc40..7bee5b2ca5 100644 --- a/tests/assets/reading-list/react17.html +++ b/tests/assets/reading-list/react17.html @@ -93,9 +93,7 @@ class App extends React.Component { } } -ReactDOM.render( - e(App, null, null), - document.getElementById('root'), -); +window.mountApp = element => ReactDOM.render(e(App, null, null), element); +window.mountApp(document.getElementById('root')); diff --git a/tests/assets/reading-list/vue2.html b/tests/assets/reading-list/vue2.html index 4a253e37f5..7eb9fb0e25 100644 --- a/tests/assets/reading-list/vue2.html +++ b/tests/assets/reading-list/vue2.html @@ -1,7 +1,7 @@ -
+
diff --git a/tests/assets/reading-list/vue3.html b/tests/assets/reading-list/vue3.html index ffcc22a0cc..ad04d18ff6 100644 --- a/tests/assets/reading-list/vue3.html +++ b/tests/assets/reading-list/vue3.html @@ -1,112 +1,115 @@ -
+
diff --git a/tests/page/selectors-react.spec.ts b/tests/page/selectors-react.spec.ts index 56fe5202ca..e5d8aa419a 100644 --- a/tests/page/selectors-react.spec.ts +++ b/tests/page/selectors-react.spec.ts @@ -104,6 +104,27 @@ for (const [name, url] of Object.entries(reacts)) { it('should support truthy querying', async ({page}) => { expect(await page.$$eval(`_react=ColorButton[enabled]`, els => els.length)).toBe(5); }); + + it('should work with multiroot react', async ({page}) => { + await it.step('mount second root', async () => { + await expect(page.locator(`_react=BookItem`)).toHaveCount(3); + await page.evaluate(() => { + const anotherRoot = document.createElement('div'); + anotherRoot.id = 'root2'; + document.body.append(anotherRoot); + // @ts-ignore + window.mountApp(anotherRoot); + }); + await expect(page.locator(`_react=BookItem`)).toHaveCount(6); + }); + + await it.step('add a new book to second root', async () => { + await page.locator('#root2 input').fill('newbook'); + await page.locator('#root2 >> text=new book').click(); + await expect(page.locator('css=#root >> _react=BookItem')).toHaveCount(3); + await expect(page.locator('css=#root2 >> _react=BookItem')).toHaveCount(4); + }); + }); }); } diff --git a/tests/page/selectors-vue.spec.ts b/tests/page/selectors-vue.spec.ts index 25ab29d882..769cbb80d8 100644 --- a/tests/page/selectors-vue.spec.ts +++ b/tests/page/selectors-vue.spec.ts @@ -101,6 +101,28 @@ for (const [name, url] of Object.entries(vues)) { it('should support truthy querying', async ({page}) => { expect(await page.$$eval(`_vue=color-button[enabled]`, els => els.length)).toBe(5); }); + + it('should work with multiroot react', async ({page}) => { + await it.step('mount second root', async () => { + await expect(page.locator(`_vue=book-item`)).toHaveCount(3); + await page.evaluate(() => { + const anotherRoot = document.createElement('div'); + anotherRoot.id = 'root2'; + anotherRoot.append(document.createElement('div')); + document.body.append(anotherRoot); + // @ts-ignore + window.mountApp(anotherRoot.querySelector('div')); + }); + await expect(page.locator(`_vue=book-item`)).toHaveCount(6); + }); + + await it.step('add a new book to second root', async () => { + await page.locator('#root2 input').fill('newbook'); + await page.locator('#root2 >> text=new book').click(); + await expect(page.locator('css=#root >> _vue=book-item')).toHaveCount(3); + await expect(page.locator('css=#root2 >> _vue=book-item')).toHaveCount(4); + }); + }); }); }