Skip to content

Commit e2e3d69

Browse files
crisbetodylhunn
authored andcommitted
feat(core): support deferred triggers with implicit triggers (#51922)
Adds support for defining `viewport`, `interaction` and `hover` triggers with no parameters. If the framework encounters such a case, it resolves the trigger to the root element of the `@placeholder` block. Triggers with no parameters have the following restrictions: 1. They have to be placed on an `@defer` block that has an `@placeholder`. 2. The `@placeholder` can only have one root node. 3. The root placeholder node has to be an element. PR Close #51922
1 parent 23bfa10 commit e2e3d69

File tree

12 files changed

+506
-39
lines changed

12 files changed

+506
-39
lines changed

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/GOLDEN_PARTIAL.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,3 +702,44 @@ export declare class MyApp {
702702
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
703703
}
704704

705+
/****************************************************************************************************
706+
* PARTIAL FILE: deferred_with_implicit_triggers.js
707+
****************************************************************************************************/
708+
import { Component } from '@angular/core';
709+
import * as i0 from "@angular/core";
710+
export class MyApp {
711+
constructor() {
712+
this.message = 'hello';
713+
}
714+
}
715+
MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
716+
MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "ng-component", ngImport: i0, template: `
717+
@defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) {
718+
{{message}}
719+
} @placeholder {
720+
<button>Click me</button>
721+
}
722+
`, isInline: true });
723+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
724+
type: Component,
725+
args: [{
726+
template: `
727+
@defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) {
728+
{{message}}
729+
} @placeholder {
730+
<button>Click me</button>
731+
}
732+
`,
733+
}]
734+
}] });
735+
736+
/****************************************************************************************************
737+
* PARTIAL FILE: deferred_with_implicit_triggers.d.ts
738+
****************************************************************************************************/
739+
import * as i0 from "@angular/core";
740+
export declare class MyApp {
741+
message: string;
742+
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
743+
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
744+
}
745+

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,27 @@
255255
}
256256
],
257257
"skipForTemplatePipeline": true
258+
},
259+
{
260+
"description": "should generate a deferred block with implicit trigger references",
261+
"angularCompilerOptions": {
262+
"_enabledBlockTypes": ["defer"]
263+
},
264+
"inputFiles": [
265+
"deferred_with_implicit_triggers.ts"
266+
],
267+
"expectations": [
268+
{
269+
"files": [
270+
{
271+
"expected": "deferred_with_implicit_triggers_template.js",
272+
"generated": "deferred_with_implicit_triggers.js"
273+
}
274+
],
275+
"failureMessage": "Incorrect template"
276+
}
277+
],
278+
"skipForTemplatePipeline": true
258279
}
259280
]
260281
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {Component} from '@angular/core';
2+
3+
@Component({
4+
template: `
5+
@defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) {
6+
{{message}}
7+
} @placeholder {
8+
<button>Click me</button>
9+
}
10+
`,
11+
})
12+
export class MyApp {
13+
message = 'hello';
14+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
function MyApp_DeferPlaceholder_1_Template(rf, ctx) {
2+
if (rf & 1) {
3+
$r3$.ɵɵelementStart(0, "button");
4+
$r3$.ɵɵtext(1, "Click me");
5+
$r3$.ɵɵelementEnd();
6+
}
7+
}
8+
9+
function MyApp_Template(rf, ctx) {
10+
if (rf & 1) {
11+
$r3$.ɵɵtemplate(0, MyApp_Defer_0_Template, 1, 1)(1, MyApp_DeferPlaceholder_1_Template, 2, 0);
12+
$r3$.ɵɵdefer(2, 0, null, null, 1);
13+
$r3$.ɵɵdeferOnHover(0, -1);
14+
$r3$.ɵɵdeferOnInteraction(0, -1);
15+
$r3$.ɵɵdeferOnViewport(0, -1);
16+
$r3$.ɵɵdeferPrefetchOnHover(0, -1);
17+
$r3$.ɵɵdeferPrefetchOnInteraction(0, -1);
18+
$r3$.ɵɵdeferPrefetchOnViewport(0, -1);
19+
}
20+
}

packages/compiler/src/render3/r3_ast.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export class IdleDeferredTrigger extends DeferredTrigger {}
136136
export class ImmediateDeferredTrigger extends DeferredTrigger {}
137137

138138
export class HoverDeferredTrigger extends DeferredTrigger {
139-
constructor(public reference: string, sourceSpan: ParseSourceSpan) {
139+
constructor(public reference: string|null, sourceSpan: ParseSourceSpan) {
140140
super(sourceSpan);
141141
}
142142
}
@@ -148,7 +148,7 @@ export class TimerDeferredTrigger extends DeferredTrigger {
148148
}
149149

150150
export class InteractionDeferredTrigger extends DeferredTrigger {
151-
constructor(public reference: string, sourceSpan: ParseSourceSpan) {
151+
constructor(public reference: string|null, sourceSpan: ParseSourceSpan) {
152152
super(sourceSpan);
153153
}
154154
}

packages/compiler/src/render3/r3_deferred_blocks.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ export function createDeferredBlock(
4444
ast: html.Block, connectedBlocks: html.Block[], visitor: html.Visitor,
4545
bindingParser: BindingParser): {node: t.DeferredBlock, errors: ParseError[]} {
4646
const errors: ParseError[] = [];
47-
const {triggers, prefetchTriggers} = parsePrimaryTriggers(ast.parameters, bindingParser, errors);
4847
const {placeholder, loading, error} = parseConnectedBlocks(connectedBlocks, errors, visitor);
48+
const {triggers, prefetchTriggers} =
49+
parsePrimaryTriggers(ast.parameters, bindingParser, errors, placeholder);
4950
const node = new t.DeferredBlock(
5051
html.visitAll(visitor, ast.children, ast.children), triggers, prefetchTriggers, placeholder,
5152
loading, error, ast.sourceSpan, ast.startSourceSpan, ast.endSourceSpan);
@@ -182,7 +183,8 @@ function parseErrorBlock(ast: html.Block, visitor: html.Visitor): t.DeferredBloc
182183
}
183184

184185
function parsePrimaryTriggers(
185-
params: html.BlockParameter[], bindingParser: BindingParser, errors: ParseError[]) {
186+
params: html.BlockParameter[], bindingParser: BindingParser, errors: ParseError[],
187+
placeholder: t.DeferredBlockPlaceholder|null) {
186188
const triggers: t.DeferredBlockTriggers = {};
187189
const prefetchTriggers: t.DeferredBlockTriggers = {};
188190

@@ -192,11 +194,11 @@ function parsePrimaryTriggers(
192194
if (WHEN_PARAMETER_PATTERN.test(param.expression)) {
193195
parseWhenTrigger(param, bindingParser, triggers, errors);
194196
} else if (ON_PARAMETER_PATTERN.test(param.expression)) {
195-
parseOnTrigger(param, triggers, errors);
197+
parseOnTrigger(param, triggers, errors, placeholder);
196198
} else if (PREFETCH_WHEN_PATTERN.test(param.expression)) {
197199
parseWhenTrigger(param, bindingParser, prefetchTriggers, errors);
198200
} else if (PREFETCH_ON_PATTERN.test(param.expression)) {
199-
parseOnTrigger(param, prefetchTriggers, errors);
201+
parseOnTrigger(param, prefetchTriggers, errors, placeholder);
200202
} else {
201203
errors.push(new ParseError(param.sourceSpan, 'Unrecognized trigger'));
202204
}

packages/compiler/src/render3/r3_deferred_triggers.ts

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function parseWhenTrigger(
5858
/** Parses an `on` trigger */
5959
export function parseOnTrigger(
6060
{expression, sourceSpan}: html.BlockParameter, triggers: t.DeferredBlockTriggers,
61-
errors: ParseError[]): void {
61+
errors: ParseError[], placeholder: t.DeferredBlockPlaceholder|null): void {
6262
const onIndex = expression.indexOf('on');
6363

6464
// This is here just to be safe, we shouldn't enter this function
@@ -67,7 +67,8 @@ export function parseOnTrigger(
6767
errors.push(new ParseError(sourceSpan, `Could not find "on" keyword in expression`));
6868
} else {
6969
const start = getTriggerParametersStart(expression, onIndex + 1);
70-
const parser = new OnTriggerParser(expression, start, sourceSpan, triggers, errors);
70+
const parser =
71+
new OnTriggerParser(expression, start, sourceSpan, triggers, errors, placeholder);
7172
parser.parse();
7273
}
7374
}
@@ -79,7 +80,8 @@ class OnTriggerParser {
7980

8081
constructor(
8182
private expression: string, private start: number, private span: ParseSourceSpan,
82-
private triggers: t.DeferredBlockTriggers, private errors: ParseError[]) {
83+
private triggers: t.DeferredBlockTriggers, private errors: ParseError[],
84+
private placeholder: t.DeferredBlockPlaceholder|null) {
8385
this.tokens = new Lexer().tokenize(expression.slice(start));
8486
}
8587

@@ -146,19 +148,21 @@ class OnTriggerParser {
146148
break;
147149

148150
case OnTriggerType.INTERACTION:
149-
this.trackTrigger('interaction', createInteractionTrigger(parameters, sourceSpan));
151+
this.trackTrigger(
152+
'interaction', createInteractionTrigger(parameters, sourceSpan, this.placeholder));
150153
break;
151154

152155
case OnTriggerType.IMMEDIATE:
153156
this.trackTrigger('immediate', createImmediateTrigger(parameters, sourceSpan));
154157
break;
155158

156159
case OnTriggerType.HOVER:
157-
this.trackTrigger('hover', createHoverTrigger(parameters, sourceSpan));
160+
this.trackTrigger('hover', createHoverTrigger(parameters, sourceSpan, this.placeholder));
158161
break;
159162

160163
case OnTriggerType.VIEWPORT:
161-
this.trackTrigger('viewport', createViewportTrigger(parameters, sourceSpan));
164+
this.trackTrigger(
165+
'viewport', createViewportTrigger(parameters, sourceSpan, this.placeholder));
162166
break;
163167

164168
default:
@@ -291,15 +295,6 @@ function createTimerTrigger(parameters: string[], sourceSpan: ParseSourceSpan) {
291295
return new t.TimerDeferredTrigger(delay, sourceSpan);
292296
}
293297

294-
function createInteractionTrigger(
295-
parameters: string[], sourceSpan: ParseSourceSpan): t.InteractionDeferredTrigger {
296-
if (parameters.length !== 1) {
297-
throw new Error(`"${OnTriggerType.INTERACTION}" trigger must have exactly one parameter`);
298-
}
299-
300-
return new t.InteractionDeferredTrigger(parameters[0], sourceSpan);
301-
}
302-
303298
function createImmediateTrigger(
304299
parameters: string[], sourceSpan: ParseSourceSpan): t.ImmediateDeferredTrigger {
305300
if (parameters.length > 0) {
@@ -310,22 +305,44 @@ function createImmediateTrigger(
310305
}
311306

312307
function createHoverTrigger(
313-
parameters: string[], sourceSpan: ParseSourceSpan): t.HoverDeferredTrigger {
314-
if (parameters.length !== 1) {
315-
throw new Error(`"${OnTriggerType.HOVER}" trigger must have exactly one parameter`);
316-
}
308+
parameters: string[], sourceSpan: ParseSourceSpan,
309+
placeholder: t.DeferredBlockPlaceholder|null): t.HoverDeferredTrigger {
310+
validateReferenceBasedTrigger(OnTriggerType.HOVER, parameters, placeholder);
311+
return new t.HoverDeferredTrigger(parameters[0] ?? null, sourceSpan);
312+
}
317313

318-
return new t.HoverDeferredTrigger(parameters[0], sourceSpan);
314+
function createInteractionTrigger(
315+
parameters: string[], sourceSpan: ParseSourceSpan,
316+
placeholder: t.DeferredBlockPlaceholder|null): t.InteractionDeferredTrigger {
317+
validateReferenceBasedTrigger(OnTriggerType.INTERACTION, parameters, placeholder);
318+
return new t.InteractionDeferredTrigger(parameters[0] ?? null, sourceSpan);
319319
}
320320

321321
function createViewportTrigger(
322-
parameters: string[], sourceSpan: ParseSourceSpan): t.ViewportDeferredTrigger {
323-
// TODO: the RFC has some more potential parameters for `viewport`.
322+
parameters: string[], sourceSpan: ParseSourceSpan,
323+
placeholder: t.DeferredBlockPlaceholder|null): t.ViewportDeferredTrigger {
324+
validateReferenceBasedTrigger(OnTriggerType.VIEWPORT, parameters, placeholder);
325+
return new t.ViewportDeferredTrigger(parameters[0] ?? null, sourceSpan);
326+
}
327+
328+
function validateReferenceBasedTrigger(
329+
type: OnTriggerType, parameters: string[], placeholder: t.DeferredBlockPlaceholder|null) {
324330
if (parameters.length > 1) {
325-
throw new Error(`"${OnTriggerType.VIEWPORT}" trigger can only have zero or one parameters`);
331+
throw new Error(`"${type}" trigger can only have zero or one parameters`);
326332
}
327333

328-
return new t.ViewportDeferredTrigger(parameters[0] ?? null, sourceSpan);
334+
if (parameters.length === 0) {
335+
if (placeholder === null) {
336+
throw new Error(`"${
337+
type}" trigger with no parameters can only be placed on an @defer that has a @placeholder block`);
338+
}
339+
340+
if (placeholder.children.length !== 1 || !(placeholder.children[0] instanceof t.Element)) {
341+
throw new Error(
342+
`"${type}" trigger with no parameters can only be placed on an @defer that has a ` +
343+
`@placeholder block with exactly one root element node`);
344+
}
345+
}
329346
}
330347

331348
/** Gets the index within an expression at which the trigger parameters start. */

packages/compiler/src/render3/view/t2_binder.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -797,9 +797,15 @@ export class R3BoundTarget<DirectiveT extends DirectiveMeta> implements BoundTar
797797

798798
const name = trigger.reference;
799799

800-
// TODO(crisbeto): account for `viewport` trigger without a `reference`.
801800
if (name === null) {
802-
return null;
801+
const children = block.placeholder ? block.placeholder.children : null;
802+
803+
// If the trigger doesn't have a reference, it is inferred as the root element node of the
804+
// placeholder, if it only has one root node. Otherwise it's ambiguous so we don't
805+
// attempt to resolve further.
806+
return children !== null && children.length === 1 && children[0] instanceof Element ?
807+
children[0] :
808+
null;
803809
}
804810

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

packages/compiler/src/render3/view/template.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1378,7 +1378,6 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
13781378
}
13791379

13801380
// Note that we generate an implicit `on idle` if the `deferred` block has no triggers.
1381-
// TODO(crisbeto): decide if this should be baked into the `defer` instruction.
13821381
// `deferOnIdle()`
13831382
if (idle || (!prefetch && Object.keys(triggers).length === 0)) {
13841383
this.creationInstruction(

packages/compiler/test/render3/r3_template_transform_spec.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,24 @@ describe('R3 template transform', () => {
10021002
]);
10031003
});
10041004

1005+
it('should parse triggers with implied target elements', () => {
1006+
expectDeferred(
1007+
'@defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) {hello}' +
1008+
'@placeholder {<implied-trigger/>}')
1009+
.toEqual([
1010+
['DeferredBlock'],
1011+
['HoverDeferredTrigger', null],
1012+
['InteractionDeferredTrigger', null],
1013+
['ViewportDeferredTrigger', null],
1014+
['HoverDeferredTrigger', null],
1015+
['InteractionDeferredTrigger', null],
1016+
['ViewportDeferredTrigger', null],
1017+
['Text', 'hello'],
1018+
['DeferredBlockPlaceholder'],
1019+
['Element', 'implied-trigger'],
1020+
]);
1021+
});
1022+
10051023
describe('block validations', () => {
10061024
it('should report syntax error in `when` trigger', () => {
10071025
expectDeferredError('@defer (when isVisible#){hello}')
@@ -1130,17 +1148,17 @@ describe('R3 template transform', () => {
11301148

11311149
it('should report if `interaction` trigger has more than one parameter', () => {
11321150
expectDeferredError('@defer (on interaction(a, b)) {hello}')
1133-
.toThrowError(/"interaction" trigger must have exactly one parameter/);
1151+
.toThrowError(/"interaction" trigger can only have zero or one parameters/);
11341152
});
11351153

11361154
it('should report if parameters are passed to `immediate` trigger', () => {
11371155
expectDeferredError('@defer (on immediate(1)) {hello}')
11381156
.toThrowError(/"immediate" trigger cannot have parameters/);
11391157
});
11401158

1141-
it('should report if no parameters are passed to `hover` trigger', () => {
1142-
expectDeferredError('@defer (on hover) {hello}')
1143-
.toThrowError(/"hover" trigger must have exactly one parameter/);
1159+
it('should report if `hover` trigger has more than one parameter', () => {
1160+
expectDeferredError('@defer (on hover(a, b)) {hello}')
1161+
.toThrowError(/"hover" trigger can only have zero or one parameters/);
11441162
});
11451163

11461164
it('should report if `viewport` trigger has more than one parameter', () => {
@@ -1184,6 +1202,35 @@ describe('R3 template transform', () => {
11841202
expectDeferredError('@defer {hello} @loading (after 1s; after 500ms) {loading}')
11851203
.toThrowError(/@loading block can only have one "after" parameter/);
11861204
});
1205+
1206+
it('should report if reference-based trigger has no reference and there is no placeholder block',
1207+
() => {
1208+
expectDeferredError('@defer (on viewport) {hello}')
1209+
.toThrowError(
1210+
/"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block/);
1211+
});
1212+
1213+
it('should report if reference-based trigger has no reference and the placeholder is empty',
1214+
() => {
1215+
expectDeferredError('@defer (on viewport) {hello} @placeholder {}')
1216+
.toThrowError(
1217+
/"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block with exactly one root element node/);
1218+
});
1219+
1220+
it('should report if reference-based trigger has no reference and the placeholder with text at the root',
1221+
() => {
1222+
expectDeferredError('@defer (on viewport) {hello} @placeholder {placeholder}')
1223+
.toThrowError(
1224+
/"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block with exactly one root element node/);
1225+
});
1226+
1227+
it('should report if reference-based trigger has no reference and the placeholder has multiple root elements',
1228+
() => {
1229+
expectDeferredError(
1230+
'@defer (on viewport) {hello} @placeholder {<div></div><span></span>}')
1231+
.toThrowError(
1232+
/"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block with exactly one root element node/);
1233+
});
11871234
});
11881235
});
11891236

0 commit comments

Comments
 (0)