feat: support multiple roots in React and Vue selectors (#8313)
Fixes #8230
This commit is contained in:
parent
1d48313e43
commit
48e94c15c1
|
|
@ -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<Element> = new Set();
|
||||
for (const treeNode of treeNodes) {
|
||||
for (const domNode of treeNode.rootElements)
|
||||
|
|
|
|||
|
|
@ -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<Element> = new Set();
|
||||
for (const treeNode of treeNodes) {
|
||||
for (const rootElement of treeNode.rootElements)
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<link rel=stylesheet href='./style.css'>
|
||||
<script src="./vue_2.6.14.js"></script>
|
||||
|
||||
<div id=root></div>
|
||||
<div id=root><div></div></div>
|
||||
|
||||
<script>
|
||||
|
||||
|
|
@ -82,9 +82,8 @@ Vue.component('button-grid', {
|
|||
}
|
||||
});
|
||||
|
||||
|
||||
new Vue({
|
||||
el: '#root',
|
||||
window.mountApp = element => new Vue({
|
||||
el: element,
|
||||
|
||||
template: `
|
||||
<div>
|
||||
|
|
@ -112,5 +111,6 @@ new Vue({
|
|||
}
|
||||
},
|
||||
});
|
||||
window.mountApp(document.querySelector('#root div'));
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,112 +1,115 @@
|
|||
<link rel=stylesheet href='./style.css'>
|
||||
<script src="./vue_3.1.5.js"></script>
|
||||
|
||||
<div id=root></div>
|
||||
<div id=root><div></div></div>
|
||||
|
||||
<script>
|
||||
|
||||
const app = Vue.createApp({
|
||||
template: `
|
||||
<app-header :bookCount='books.length'></app-header>
|
||||
<new-book @newbook='addNewBook'></new-book>
|
||||
<book-list :books='books'></book-list>
|
||||
<button-grid></button-grid>
|
||||
`,
|
||||
window.mountApp = element => {
|
||||
const app = Vue.createApp({
|
||||
template: `
|
||||
<app-header :bookCount='books.length'></app-header>
|
||||
<new-book @newbook='addNewBook'></new-book>
|
||||
<book-list :books='books'></book-list>
|
||||
<button-grid></button-grid>
|
||||
`,
|
||||
|
||||
data() {
|
||||
return {
|
||||
books: [
|
||||
{name: 'Pride and Prejudice' },
|
||||
{name: 'To Kill a Mockingbird' },
|
||||
{name: 'The Great Gatsby' },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
addNewBook(name) {
|
||||
console.log('here');
|
||||
this.books.push({name});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
app.component('app-header', {
|
||||
template: `
|
||||
<h1>vuejs@${Vue.version}</h1>
|
||||
<h3>Reading List: {{ bookCount }}</h3>
|
||||
`,
|
||||
props: [ 'bookCount' ],
|
||||
});
|
||||
|
||||
app.component('new-book', {
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<input v-model='name' @keypress.enter='onNewBook'><button @click='onNewBook'>new book</button>
|
||||
`,
|
||||
emits: ['newbook'],
|
||||
methods: {
|
||||
onNewBook() {
|
||||
this.$emit('newbook', this.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
app.component('book-item', {
|
||||
template: `
|
||||
<div>
|
||||
{{ name }}
|
||||
</div>
|
||||
`,
|
||||
props: ['name'],
|
||||
});
|
||||
|
||||
app.component('book-list', {
|
||||
props: ['books'],
|
||||
template: `
|
||||
<ol>
|
||||
<li v-for='book in books' :key='book.name'>
|
||||
<book-item :name='book.name'></book-item>
|
||||
</li>
|
||||
</ol>
|
||||
`,
|
||||
});
|
||||
|
||||
app.component('color-button', {
|
||||
props: {
|
||||
color: String,
|
||||
enabled: Boolean,
|
||||
nested: {
|
||||
index: Number,
|
||||
value: Number,
|
||||
data() {
|
||||
return {
|
||||
books: [
|
||||
{name: 'Pride and Prejudice' },
|
||||
{name: 'To Kill a Mockingbird' },
|
||||
{name: 'The Great Gatsby' },
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<button :disabled='enabled' :class='color'>button {{nested.index}}</button>
|
||||
`,
|
||||
});
|
||||
|
||||
app.component('button-grid', {
|
||||
render() {
|
||||
const buttons = [];
|
||||
const ColorButton = Vue.resolveComponent('color-button');
|
||||
for (let i = 0; i < 9; ++i) {
|
||||
buttons.push(Vue.h(ColorButton, {
|
||||
color: ['red', 'green', 'blue'][i % 3],
|
||||
enabled: i % 2 === 0,
|
||||
nested: {
|
||||
index: i,
|
||||
value: i + 0.1,
|
||||
}
|
||||
}, null));
|
||||
};
|
||||
return buttons;
|
||||
}
|
||||
});
|
||||
methods: {
|
||||
addNewBook(name) {
|
||||
console.log('here');
|
||||
this.books.push({name});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
app.mount('#root');
|
||||
app.component('app-header', {
|
||||
template: `
|
||||
<h1>vuejs@${Vue.version}</h1>
|
||||
<h3>Reading List: {{ bookCount }}</h3>
|
||||
`,
|
||||
props: [ 'bookCount' ],
|
||||
});
|
||||
|
||||
app.component('new-book', {
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<input v-model='name' @keypress.enter='onNewBook'><button @click='onNewBook'>new book</button>
|
||||
`,
|
||||
emits: ['newbook'],
|
||||
methods: {
|
||||
onNewBook() {
|
||||
this.$emit('newbook', this.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
app.component('book-item', {
|
||||
template: `
|
||||
<div>
|
||||
{{ name }}
|
||||
</div>
|
||||
`,
|
||||
props: ['name'],
|
||||
});
|
||||
|
||||
app.component('book-list', {
|
||||
props: ['books'],
|
||||
template: `
|
||||
<ol>
|
||||
<li v-for='book in books' :key='book.name'>
|
||||
<book-item :name='book.name'></book-item>
|
||||
</li>
|
||||
</ol>
|
||||
`,
|
||||
});
|
||||
|
||||
app.component('color-button', {
|
||||
props: {
|
||||
color: String,
|
||||
enabled: Boolean,
|
||||
nested: {
|
||||
index: Number,
|
||||
value: Number,
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<button :disabled='enabled' :class='color'>button {{nested.index}}</button>
|
||||
`,
|
||||
});
|
||||
|
||||
app.component('button-grid', {
|
||||
render() {
|
||||
const buttons = [];
|
||||
const ColorButton = Vue.resolveComponent('color-button');
|
||||
for (let i = 0; i < 9; ++i) {
|
||||
buttons.push(Vue.h(ColorButton, {
|
||||
color: ['red', 'green', 'blue'][i % 3],
|
||||
enabled: i % 2 === 0,
|
||||
nested: {
|
||||
index: i,
|
||||
value: i + 0.1,
|
||||
}
|
||||
}, null));
|
||||
};
|
||||
return buttons;
|
||||
}
|
||||
});
|
||||
app.mount(element);
|
||||
}
|
||||
|
||||
window.mountApp(document.querySelector('#root div'));
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue