From 2198769f6c1d2d3814ee9b19a04d29bf15b82632 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Thu, 26 Aug 2021 13:07:33 +0300 Subject: [PATCH] fix(react-vue): support nested trees (#8467) Turns out you can mount nested trees in both React and Vue. This patch changes root discovery to support nested trees. Fixes #8455 --- src/server/injected/reactSelectorEngine.ts | 32 ++++++++++---------- src/server/injected/vueSelectorEngine.ts | 35 ++++++++++++---------- tests/assets/reading-list/react15.html | 4 ++- tests/assets/reading-list/react16.html | 6 +++- tests/assets/reading-list/react17.html | 5 +++- tests/assets/reading-list/vue2.html | 4 ++- tests/assets/reading-list/vue3.html | 7 +++-- tests/page/selectors-react.spec.ts | 11 ++++++- tests/page/selectors-vue.spec.ts | 11 ++++++- 9 files changed, 73 insertions(+), 42 deletions(-) diff --git a/src/server/injected/reactSelectorEngine.ts b/src/server/injected/reactSelectorEngine.ts index 0300f965c5..52a1bfd5db 100644 --- a/src/server/injected/reactSelectorEngine.ts +++ b/src/server/injected/reactSelectorEngine.ts @@ -134,24 +134,22 @@ function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: Componen 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; + const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT); + while (walker.nextNode()) { + 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]); } - }); - while (walker.nextNode()); + } return roots; } diff --git a/src/server/injected/vueSelectorEngine.ts b/src/server/injected/vueSelectorEngine.ts index 274f31aca9..f0034298f1 100644 --- a/src/server/injected/vueSelectorEngine.ts +++ b/src/server/injected/vueSelectorEngine.ts @@ -207,22 +207,25 @@ function filterComponentsTree(treeNode: ComponentNode, searchFn: (node: Componen 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()); + // 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. + 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); + } + for (const vue2root of vue2Roots) { + roots.push({ + version: 2, + root: vue2root, + }); + } return roots; } diff --git a/tests/assets/reading-list/react15.html b/tests/assets/reading-list/react15.html index 800858b47d..b9728f8560 100644 --- a/tests/assets/reading-list/react15.html +++ b/tests/assets/reading-list/react15.html @@ -91,6 +91,7 @@ class App extends React.Component { e(NewBook, {onNewBook: bookName => this.onNewBook(bookName)}, null), e(BookList, {books: this.state.books}, null), e(ButtonGrid, null, null), + e('div', { ref: 'mountPoint', }, null), ); } @@ -102,6 +103,7 @@ class App extends React.Component { } window.mountApp = element => ReactDOM.render(e(App, null, null), element); -window.mountApp(document.getElementById('root')); +window.app = window.mountApp(document.getElementById('root')); +window.mountNestedApp = () => window.mountApp(window.app.refs.mountPoint); diff --git a/tests/assets/reading-list/react16.html b/tests/assets/reading-list/react16.html index 13b47b92af..184204f5d2 100644 --- a/tests/assets/reading-list/react16.html +++ b/tests/assets/reading-list/react16.html @@ -74,6 +74,7 @@ class BookList extends React.Component { class App extends React.Component { constructor(props) { super(props); + this.mountPoint = React.createRef(); this.state = { books: [ {name: 'Pride and Prejudice' }, @@ -89,6 +90,7 @@ class App extends React.Component { e(NewBook, {onNewBook: bookName => this.onNewBook(bookName)}, null), e(BookList, {books: this.state.books}, null), e(ButtonGrid, null, null), + e('div', {ref: this.mountPoint}, null), ); } @@ -100,6 +102,8 @@ class App extends React.Component { } window.mountApp = element => ReactDOM.render(e(App, null, null), element); -window.mountApp(document.getElementById('root')); +window.app = window.mountApp(document.getElementById('root')); + +window.mountNestedApp = () => window.mountApp(window.app.mountPoint.current); diff --git a/tests/assets/reading-list/react17.html b/tests/assets/reading-list/react17.html index 7bee5b2ca5..2eb9e7cc1d 100644 --- a/tests/assets/reading-list/react17.html +++ b/tests/assets/reading-list/react17.html @@ -68,6 +68,7 @@ class BookList extends React.Component { class App extends React.Component { constructor(props) { super(props); + this.mountPoint = React.createRef(); this.state = { books: [ {name: 'Pride and Prejudice' }, @@ -83,6 +84,7 @@ class App extends React.Component { e(NewBook, {onNewBook: bookName => this.onNewBook(bookName)}, null), e(BookList, {books: this.state.books}, null), e(ButtonGrid, null, null), + e('div', {ref: this.mountPoint}, null), ); } @@ -94,6 +96,7 @@ class App extends React.Component { } window.mountApp = element => ReactDOM.render(e(App, null, null), element); -window.mountApp(document.getElementById('root')); +window.app = window.mountApp(document.getElementById('root')); +window.mountNestedApp = () => window.mountApp(window.app.mountPoint.current); diff --git a/tests/assets/reading-list/vue2.html b/tests/assets/reading-list/vue2.html index 7eb9fb0e25..5a8c6e26b1 100644 --- a/tests/assets/reading-list/vue2.html +++ b/tests/assets/reading-list/vue2.html @@ -91,6 +91,7 @@ window.mountApp = element => new Vue({ +
`, @@ -111,6 +112,7 @@ window.mountApp = element => new Vue({ } }, }); -window.mountApp(document.querySelector('#root div')); +window.app = window.mountApp(document.querySelector('#root div')); +window.mountNestedApp = () => window.mountApp(window.app.$refs.mountPoint); diff --git a/tests/assets/reading-list/vue3.html b/tests/assets/reading-list/vue3.html index ad04d18ff6..31e67da5e3 100644 --- a/tests/assets/reading-list/vue3.html +++ b/tests/assets/reading-list/vue3.html @@ -12,6 +12,7 @@ window.mountApp = element => { +
`, data() { @@ -26,7 +27,6 @@ window.mountApp = element => { methods: { addNewBook(name) { - console.log('here'); this.books.push({name}); } }, @@ -107,9 +107,10 @@ window.mountApp = element => { return buttons; } }); - app.mount(element); + return app.mount(element); } -window.mountApp(document.querySelector('#root div')); +window.app = window.mountApp(document.querySelector('#root div')); +window.mountNestedApp = () => window.mountApp(window.app.$refs.mountPoint); diff --git a/tests/page/selectors-react.spec.ts b/tests/page/selectors-react.spec.ts index e5d8aa419a..e6882b36c5 100644 --- a/tests/page/selectors-react.spec.ts +++ b/tests/page/selectors-react.spec.ts @@ -39,7 +39,7 @@ for (const [name, url] of Object.entries(reacts)) { it('should work with multi-root elements (fragments)', async ({page}) => { it.skip(name === 'react15', 'React 15 does not support fragments'); - expect(await page.$$eval(`_react=App`, els => els.length)).toBe(14); + expect(await page.$$eval(`_react=App`, els => els.length)).toBe(15); expect(await page.$$eval(`_react=AppHeader`, els => els.length)).toBe(2); expect(await page.$$eval(`_react=NewBook`, els => els.length)).toBe(2); }); @@ -105,6 +105,15 @@ for (const [name, url] of Object.entries(reacts)) { expect(await page.$$eval(`_react=ColorButton[enabled]`, els => els.length)).toBe(5); }); + it('should support nested react trees', async ({page}) => { + await expect(page.locator(`_react=BookItem`)).toHaveCount(3); + await page.evaluate(() => { + // @ts-ignore + mountNestedApp(); + }); + await expect(page.locator(`_react=BookItem`)).toHaveCount(6); + }); + it('should work with multiroot react', async ({page}) => { await it.step('mount second root', async () => { await expect(page.locator(`_react=BookItem`)).toHaveCount(3); diff --git a/tests/page/selectors-vue.spec.ts b/tests/page/selectors-vue.spec.ts index 769cbb80d8..4c52bb1dcf 100644 --- a/tests/page/selectors-vue.spec.ts +++ b/tests/page/selectors-vue.spec.ts @@ -38,7 +38,7 @@ for (const [name, url] of Object.entries(vues)) { it('should work with multi-root elements (fragments)', async ({page}) => { it.skip(name === 'vue2', 'vue2 does not support fragments'); - expect(await page.$$eval(`_vue=Root`, els => els.length)).toBe(14); + expect(await page.$$eval(`_vue=Root`, els => els.length)).toBe(15); expect(await page.$$eval(`_vue=app-header`, els => els.length)).toBe(2); expect(await page.$$eval(`_vue=new-book`, els => els.length)).toBe(2); }); @@ -102,6 +102,15 @@ for (const [name, url] of Object.entries(vues)) { expect(await page.$$eval(`_vue=color-button[enabled]`, els => els.length)).toBe(5); }); + it('should support nested vue trees', async ({page}) => { + await expect(page.locator(`_vue=book-item`)).toHaveCount(3); + await page.evaluate(() => { + // @ts-ignore + mountNestedApp(); + }); + await expect(page.locator(`_vue=book-item`)).toHaveCount(6); + }); + it('should work with multiroot react', async ({page}) => { await it.step('mount second root', async () => { await expect(page.locator(`_vue=book-item`)).toHaveCount(3);