diff --git a/packages/compiler/src/render3/view/t2_api.ts b/packages/compiler/src/render3/view/t2_api.ts index 9f2a48330b15d..97e06db4528c4 100644 --- a/packages/compiler/src/render3/view/t2_api.ts +++ b/packages/compiler/src/render3/view/t2_api.ts @@ -207,4 +207,11 @@ export interface BoundTarget { * 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; } diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index 147f2a5734513..ade2efa68caec 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -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'; @@ -790,6 +790,79 @@ export class R3BoundTarget 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): Element|null { + if (target instanceof Element) { + return target; + } + + if (target instanceof Template) { + return null; + } + + return this.referenceTargetToElement(target.node); + } } function extractScopedNodeEntities(rootScope: Scope): diff --git a/packages/compiler/test/render3/view/binding_spec.ts b/packages/compiler/test/render3/view/binding_spec.ts index 12d2af1782f4c..18ecf307a69d0 100644 --- a/packages/compiler/test/render3/view/binding_spec.ts +++ b/packages/compiler/test/render3/view/binding_spec.ts @@ -44,7 +44,7 @@ function makeSelectorMatcher(): SelectorMatcher { }]); matcher.addSelectables(CssSelector.parse('[dir]'), [{ name: 'Dir', - exportAs: null, + exportAs: ['dir'], inputs: new IdentityInputMapping([]), outputs: new IdentityInputMapping([]), isComponent: false, @@ -82,6 +82,16 @@ function makeSelectorMatcher(): SelectorMatcher { 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']; @@ -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( + ` +
+ {#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?.name).toBe('div'); + }); + + it('should identify a trigger element outside of the deferred block', () => { + const template = parseTemplate( + ` +
+ {#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?.name).toBe('button'); + }); + + it('should identify a trigger element in a parent embedded view', () => { + const template = parseTemplate( + ` +
+ + +
+
+ {#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?.name).toBe('button'); + }); + + it('should identify a trigger element inside the placeholder', () => { + const template = parseTemplate( + ` + {#defer on viewport(trigger)} + main + {:placeholder} + {/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)}{/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} + + + `, + '', 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} + + + `, + '', 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( + ` +
+ +
+ + {#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} +
+ {/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}{/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} + + + `, + '', 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', () => {