fix(ivy): DebugNode.query not picking up nodes inserted through Renderer2 (#31716)

In ViewEngine nodes that were inserted through `Renderer2` would also be picked up by `DebugNode.query` and `DebugNode.queryAll`. This worked because everything in ViewEngine went through `Renderer2` and `DebugRenderer2` in dev mode which was able to keep track of the child nodes as they're being inserted. This no longer works in Ivy, because we don't use `DebugRenderer2` and debug nodes work a little differently. These changes work around the issue by walking the DOM as the logical tree is being walked and looking for matches. Note that this is __not__ optimal, because we're walking similar trees multiple times. ViewEngine could do it more efficiently, because all the insertions go through Renderer2, however that's not the case in Ivy. This approach is being used because:
1. Matching the ViewEngine behavior would mean potentially introducing a depedency from `Renderer2` to Ivy which could bring Ivy code into ViewEngine.
2. We would have to make `Renderer3` "know" about debug nodes.
3. It allows us to capture nodes that were inserted directly via the DOM.

PR Close #31716
This commit is contained in:
crisbeto 2019-07-23 19:59:16 +02:00 committed by Kara Erickson
parent 24ca582bc5
commit 221782a8a1
2 changed files with 146 additions and 5 deletions

View File

@ -455,9 +455,21 @@ function _queryNodeChildrenR3(
componentView[TVIEW].firstChild !, componentView, predicate, matches, elementsOnly,
rootNativeNode);
}
} else if (tNode.child) {
// Otherwise, its children have to be processed.
_queryNodeChildrenR3(tNode.child, lView, predicate, matches, elementsOnly, rootNativeNode);
} else {
if (tNode.child) {
// Otherwise, its children have to be processed.
_queryNodeChildrenR3(tNode.child, lView, predicate, matches, elementsOnly, rootNativeNode);
}
// We also have to query the DOM directly in order to catch elements inserted through
// Renderer2. Note that this is __not__ optimal, because we're walking similar trees multiple
// times. ViewEngine could do it more efficiently, because all the insertions go through
// Renderer2, however that's not the case in Ivy. This approach is being used because:
// 1. Matching the ViewEngine behavior would mean potentially introducing a depedency
// from `Renderer2` to Ivy which could bring Ivy code into ViewEngine.
// 2. We would have to make `Renderer3` "know" about debug nodes.
// 3. It allows us to capture nodes that were inserted directly via the DOM.
nativeNode && _queryNativeNodeDescendants(nativeNode, predicate, matches, elementsOnly);
}
// In all cases, if a dynamic container exists for this node, each view inside it has to be
// processed.
@ -545,14 +557,50 @@ function _addQueryMatchR3(
// Type of the "predicate and "matches" array are set based on the value of
// the "elementsOnly" parameter. TypeScript is not able to properly infer these
// types with generics, so we manually cast the parameters accordingly.
if (elementsOnly && debugNode instanceof DebugElement__POST_R3__ && predicate(debugNode)) {
if (elementsOnly && debugNode instanceof DebugElement__POST_R3__ && predicate(debugNode) &&
matches.indexOf(debugNode) === -1) {
matches.push(debugNode);
} else if (!elementsOnly && (predicate as Predicate<DebugNode>)(debugNode)) {
} else if (
!elementsOnly && (predicate as Predicate<DebugNode>)(debugNode) &&
(matches as DebugNode[]).indexOf(debugNode) === -1) {
(matches as DebugNode[]).push(debugNode);
}
}
}
/**
* Match all the descendants of a DOM node against a predicate.
*
* @param nativeNode the current native node
* @param predicate the predicate to match
* @param matches the list of positive matches
* @param elementsOnly whether only elements should be searched
*/
function _queryNativeNodeDescendants(
parentNode: any, predicate: Predicate<DebugElement>| Predicate<DebugNode>,
matches: DebugElement[] | DebugNode[], elementsOnly: boolean) {
const nodes = parentNode.childNodes;
const length = nodes.length;
for (let i = 0; i < length; i++) {
const node = nodes[i];
const debugNode = getDebugNode(node);
if (debugNode) {
if (elementsOnly && debugNode instanceof DebugElement__POST_R3__ && predicate(debugNode) &&
matches.indexOf(debugNode) === -1) {
matches.push(debugNode);
} else if (
!elementsOnly && (predicate as Predicate<DebugNode>)(debugNode) &&
(matches as DebugNode[]).indexOf(debugNode) === -1) {
(matches as DebugNode[]).push(debugNode);
}
_queryNativeNodeDescendants(node, predicate, matches, elementsOnly);
}
}
}
/**
* Iterates through the property bindings for a given node and generates
* a map of property names to values. This map only contains property bindings

View File

@ -517,6 +517,99 @@ class TestCmptWithPropBindings {
expect(debugNodes[1].injector.get(TextDirective).text).toBe('second');
});
it('DebugElement.query should work with dynamically created elements', () => {
@Directive({
selector: '[dir]',
})
class MyDir {
@Input('dir') dir: number|undefined;
constructor(renderer: Renderer2, element: ElementRef) {
const div = renderer.createElement('div');
div.classList.add('myclass');
renderer.appendChild(element.nativeElement, div);
}
}
@Component({
selector: 'app-test',
template: '<div dir></div>',
})
class MyComponent {
}
TestBed.configureTestingModule({declarations: [MyComponent, MyDir]});
const fixture = TestBed.createComponent(MyComponent);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.myclass'))).toBeTruthy();
});
it('DebugElement.query should work with dynamically created descendant elements', () => {
@Directive({
selector: '[dir]',
})
class MyDir {
@Input('dir') dir: number|undefined;
constructor(renderer: Renderer2, element: ElementRef) {
const outerDiv = renderer.createElement('div');
const innerDiv = renderer.createElement('div');
const div = renderer.createElement('div');
div.classList.add('myclass');
renderer.appendChild(innerDiv, div);
renderer.appendChild(outerDiv, innerDiv);
renderer.appendChild(element.nativeElement, outerDiv);
}
}
@Component({
selector: 'app-test',
template: '<div dir></div>',
})
class MyComponent {
}
TestBed.configureTestingModule({declarations: [MyComponent, MyDir]});
const fixture = TestBed.createComponent(MyComponent);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.myclass'))).toBeTruthy();
});
it('DebugElement.queryAll should pick up both elements inserted via the view and through Renderer2',
() => {
@Directive({
selector: '[dir]',
})
class MyDir {
@Input('dir') dir: number|undefined;
constructor(renderer: Renderer2, element: ElementRef) {
const div = renderer.createElement('div');
div.classList.add('myclass');
renderer.appendChild(element.nativeElement, div);
}
}
@Component({
selector: 'app-test',
template: '<div dir></div><span class="myclass"></span>',
})
class MyComponent {
}
TestBed.configureTestingModule({declarations: [MyComponent, MyDir]});
const fixture = TestBed.createComponent(MyComponent);
fixture.detectChanges();
const results = fixture.debugElement.queryAll(By.css('.myclass'));
expect(results.map(r => r.nativeElement.nodeName.toLowerCase())).toEqual(['div', 'span']);
});
it('should list providerTokens', () => {
fixture = TestBed.createComponent(ParentComp);
fixture.detectChanges();