Skip to content

Commit

Permalink
refactor(compiler): add utility to resolve the deferred block trigger…
Browse files Browse the repository at this point in the history
… element (#51816)

Adds a utility to the `BoundTarget` that helps with resolving which element a deferred block is pointing to. We need a separate method for this, because deferred blocks have some special logic for where the trigger can be located.

PR Close #51816
  • Loading branch information
crisbeto authored and pkozlowski-opensource committed Sep 19, 2023
1 parent 8c10ba1 commit d538908
Show file tree
Hide file tree
Showing 3 changed files with 275 additions and 2 deletions.
7 changes: 7 additions & 0 deletions packages/compiler/src/render3/view/t2_api.ts
Expand Up @@ -207,4 +207,11 @@ export interface BoundTarget<DirectiveT extends DirectiveMeta> {
* Get a list of all {#defer} blocks used by the target.
*/
getDeferBlocks(): DeferredBlock[];

/**
* Gets the element that a specific deferred block trigger is targeting.
* @param block Block that the trigger belongs to.
* @param trigger Trigger whose target is being looked up.
*/
getDeferredTriggerTarget(block: DeferredTrigger, trigger: DeferredTrigger): Element|null;
}
75 changes: 74 additions & 1 deletion packages/compiler/src/render3/view/t2_binder.ts
Expand Up @@ -8,7 +8,7 @@

import {AST, BindingPipe, ImplicitReceiver, PropertyRead, PropertyWrite, RecursiveAstVisitor, SafePropertyRead} from '../../expression_parser/ast';
import {SelectorMatcher} from '../../selector';
import {BoundAttribute, BoundDeferredTrigger, BoundEvent, BoundText, Content, DeferredBlock, DeferredBlockError, DeferredBlockLoading, DeferredBlockPlaceholder, DeferredTrigger, Element, ForLoopBlock, ForLoopBlockEmpty, Icu, IfBlock, IfBlockBranch, Node, Reference, SwitchBlock, SwitchBlockCase, Template, Text, TextAttribute, Variable, Visitor} from '../r3_ast';
import {BoundAttribute, BoundDeferredTrigger, BoundEvent, BoundText, Content, DeferredBlock, DeferredBlockError, DeferredBlockLoading, DeferredBlockPlaceholder, DeferredTrigger, Element, ForLoopBlock, ForLoopBlockEmpty, HoverDeferredTrigger, Icu, IfBlock, IfBlockBranch, InteractionDeferredTrigger, Node, Reference, SwitchBlock, SwitchBlockCase, Template, Text, TextAttribute, Variable, ViewportDeferredTrigger, Visitor} from '../r3_ast';

import {BoundTarget, DirectiveMeta, ReferenceTarget, ScopedNode, Target, TargetBinder} from './t2_api';
import {createCssSelector} from './template';
Expand Down Expand Up @@ -790,6 +790,79 @@ export class R3BoundTarget<DirectiveT extends DirectiveMeta> implements BoundTar
getDeferBlocks(): DeferredBlock[] {
return Array.from(this.deferredBlocks);
}


getDeferredTriggerTarget(block: DeferredBlock, trigger: DeferredTrigger): Element|null {
// Only triggers that refer to DOM nodes can be resolved.
if (!(trigger instanceof InteractionDeferredTrigger) &&
!(trigger instanceof ViewportDeferredTrigger) &&
!(trigger instanceof HoverDeferredTrigger)) {
return null;
}

const name = trigger.reference;

// TODO(crisbeto): account for `viewport` trigger without a `reference`.
if (name === null) {
return null;
}

const outsideRef = this.findEntityInScope(block, name);

// First try to resolve the target in the scope of the main deferred block. Note that we
// skip triggers defined inside the main block itself, because they might not exist yet.
if (outsideRef instanceof Reference && this.getDefinitionNodeOfSymbol(outsideRef) !== block) {
const target = this.getReferenceTarget(outsideRef);

if (target !== null) {
return this.referenceTargetToElement(target);
}
}

// If the trigger couldn't be found in the main block, check the
// placeholder block which is shown before the main block has loaded.
if (block.placeholder !== null) {
const refInPlaceholder = this.findEntityInScope(block.placeholder, name);
const targetInPlaceholder =
refInPlaceholder instanceof Reference ? this.getReferenceTarget(refInPlaceholder) : null;

if (targetInPlaceholder !== null) {
return this.referenceTargetToElement(targetInPlaceholder);
}
}

return null;
}

/**
* Finds an entity with a specific name in a scope.
* @param rootNode Root node of the scope.
* @param name Name of the entity.
*/
private findEntityInScope(rootNode: ScopedNode, name: string): Reference|Variable|null {
const entities = this.getEntitiesInScope(rootNode);

for (const entitity of entities) {
if (entitity.name === name) {
return entitity;
}
}

return null;
}

/** Coerces a `ReferenceTarget` to an `Element`, if possible. */
private referenceTargetToElement(target: ReferenceTarget<DirectiveT>): Element|null {
if (target instanceof Element) {
return target;
}

if (target instanceof Template) {
return null;
}

return this.referenceTargetToElement(target.node);
}
}

function extractScopedNodeEntities(rootScope: Scope):
Expand Down
195 changes: 194 additions & 1 deletion packages/compiler/test/render3/view/binding_spec.ts
Expand Up @@ -44,7 +44,7 @@ function makeSelectorMatcher(): SelectorMatcher<DirectiveMeta[]> {
}]);
matcher.addSelectables(CssSelector.parse('[dir]'), [{
name: 'Dir',
exportAs: null,
exportAs: ['dir'],
inputs: new IdentityInputMapping([]),
outputs: new IdentityInputMapping([]),
isComponent: false,
Expand Down Expand Up @@ -82,6 +82,16 @@ function makeSelectorMatcher(): SelectorMatcher<DirectiveMeta[]> {
selector: '[sameSelectorAsInput]',
animationTriggerNames: null,
}]);
matcher.addSelectables(CssSelector.parse('comp'), [{
name: 'Comp',
exportAs: null,
inputs: new IdentityInputMapping([]),
outputs: new IdentityInputMapping([]),
isComponent: true,
isStructural: false,
selector: 'comp',
animationTriggerNames: null,
}]);

const simpleDirectives = ['a', 'b', 'c', 'd', 'e', 'f'];
const deferBlockDirectives = ['loading', 'error', 'placeholder'];
Expand Down Expand Up @@ -376,6 +386,189 @@ describe('t2 binding', () => {
expect(allDirs).toEqual(['DirA', 'DirB', 'DirC']);
expect(eagerDirs).toEqual([]);
});

it('should identify a trigger element that is a parent of the deferred block', () => {
const template = parseTemplate(
`
<div #trigger>
{#defer on viewport(trigger)}{/defer}
</div>
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl?.name).toBe('div');
});

it('should identify a trigger element outside of the deferred block', () => {
const template = parseTemplate(
`
<div>
{#defer on viewport(trigger)}{/defer}
</div>
<div>
<div>
<button #trigger></button>
</div>
</div>
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl?.name).toBe('button');
});

it('should identify a trigger element in a parent embedded view', () => {
const template = parseTemplate(
`
<div *ngFor="let item of items">
<button #trigger></button>
<div *ngFor="let child of item.children">
<div *ngFor="let grandchild of child.children">
{#defer on viewport(trigger)}{/defer}
</div>
</div>
</div>
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl?.name).toBe('button');
});

it('should identify a trigger element inside the placeholder', () => {
const template = parseTemplate(
`
{#defer on viewport(trigger)}
main
{:placeholder} <button #trigger></button>
{/defer}
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl?.name).toBe('button');
});

it('should not identify a trigger inside the main content block', () => {
const template = parseTemplate(
`
{#defer on viewport(trigger)}<button #trigger></button>{/defer}
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl).toBeNull();
});

it('should identify a trigger element on a component', () => {
const template = parseTemplate(
`
{#defer on viewport(trigger)}{/defer}
<comp #trigger/>
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl?.name).toBe('comp');
});

it('should identify a trigger element on a directive', () => {
const template = parseTemplate(
`
{#defer on viewport(trigger)}{/defer}
<button dir #trigger="dir"></button>
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl?.name).toBe('button');
});

it('should not identify a trigger inside a sibling embedded view', () => {
const template = parseTemplate(
`
<div *ngIf="cond">
<button #trigger></button>
</div>
{#defer on viewport(trigger)}{/defer}
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl).toBeNull();
});

it('should not identify a trigger element in an embedded view inside the placeholder', () => {
const template = parseTemplate(
`
{#defer on viewport(trigger)}
main
{:placeholder}
<div *ngIf="cond"><button #trigger></button></div>
{/defer}
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl).toBeNull();
});

it('should not identify a trigger element inside the a deferred block within the placeholder',
() => {
const template = parseTemplate(
`
{#defer on viewport(trigger)}
main
{:placeholder}
{#defer}<button #trigger></button>{/defer}
{/defer}
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl).toBeNull();
});

it('should not identify a trigger element on a template', () => {
const template = parseTemplate(
`
{#defer on viewport(trigger)}{/defer}
<ng-template #trigger></ng-template>
`,
'', templateOptions);
const binder = new R3TargetBinder(makeSelectorMatcher());
const bound = binder.bind({template: template.nodes});
const block = Array.from(bound.getDeferBlocks())[0];
const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!);
expect(triggerEl).toBeNull();
});
});

describe('used pipes', () => {
Expand Down

0 comments on commit d538908

Please sign in to comment.