Skip to content

Commit

Permalink
feat(core): support deferred triggers with implicit triggers (angular…
Browse files Browse the repository at this point in the history
…#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 angular#51922
  • Loading branch information
crisbeto authored and ChellappanRajan committed Jan 23, 2024
1 parent 83ab8fb commit a05ec7c
Show file tree
Hide file tree
Showing 12 changed files with 506 additions and 39 deletions.
Expand Up @@ -702,3 +702,44 @@ export declare class MyApp {
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}

/****************************************************************************************************
* PARTIAL FILE: deferred_with_implicit_triggers.js
****************************************************************************************************/
import { Component } from '@angular/core';
import * as i0 from "@angular/core";
export class MyApp {
constructor() {
this.message = 'hello';
}
}
MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "ng-component", ngImport: i0, template: `
@defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) {
{{message}}
} @placeholder {
<button>Click me</button>
}
`, isInline: true });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
type: Component,
args: [{
template: `
@defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) {
{{message}}
} @placeholder {
<button>Click me</button>
}
`,
}]
}] });

/****************************************************************************************************
* PARTIAL FILE: deferred_with_implicit_triggers.d.ts
****************************************************************************************************/
import * as i0 from "@angular/core";
export declare class MyApp {
message: string;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}

Expand Up @@ -255,6 +255,27 @@
}
],
"skipForTemplatePipeline": true
},
{
"description": "should generate a deferred block with implicit trigger references",
"angularCompilerOptions": {
"_enabledBlockTypes": ["defer"]
},
"inputFiles": [
"deferred_with_implicit_triggers.ts"
],
"expectations": [
{
"files": [
{
"expected": "deferred_with_implicit_triggers_template.js",
"generated": "deferred_with_implicit_triggers.js"
}
],
"failureMessage": "Incorrect template"
}
],
"skipForTemplatePipeline": true
}
]
}
@@ -0,0 +1,14 @@
import {Component} from '@angular/core';

@Component({
template: `
@defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) {
{{message}}
} @placeholder {
<button>Click me</button>
}
`,
})
export class MyApp {
message = 'hello';
}
@@ -0,0 +1,20 @@
function MyApp_DeferPlaceholder_1_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵelementStart(0, "button");
$r3$.ɵɵtext(1, "Click me");
$r3$.ɵɵelementEnd();
}
}
function MyApp_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵtemplate(0, MyApp_Defer_0_Template, 1, 1)(1, MyApp_DeferPlaceholder_1_Template, 2, 0);
$r3$.ɵɵdefer(2, 0, null, null, 1);
$r3$.ɵɵdeferOnHover(0, -1);
$r3$.ɵɵdeferOnInteraction(0, -1);
$r3$.ɵɵdeferOnViewport(0, -1);
$r3$.ɵɵdeferPrefetchOnHover(0, -1);
$r3$.ɵɵdeferPrefetchOnInteraction(0, -1);
$r3$.ɵɵdeferPrefetchOnViewport(0, -1);
}
}
4 changes: 2 additions & 2 deletions packages/compiler/src/render3/r3_ast.ts
Expand Up @@ -136,7 +136,7 @@ export class IdleDeferredTrigger extends DeferredTrigger {}
export class ImmediateDeferredTrigger extends DeferredTrigger {}

export class HoverDeferredTrigger extends DeferredTrigger {
constructor(public reference: string, sourceSpan: ParseSourceSpan) {
constructor(public reference: string|null, sourceSpan: ParseSourceSpan) {
super(sourceSpan);
}
}
Expand All @@ -148,7 +148,7 @@ export class TimerDeferredTrigger extends DeferredTrigger {
}

export class InteractionDeferredTrigger extends DeferredTrigger {
constructor(public reference: string, sourceSpan: ParseSourceSpan) {
constructor(public reference: string|null, sourceSpan: ParseSourceSpan) {
super(sourceSpan);
}
}
Expand Down
10 changes: 6 additions & 4 deletions packages/compiler/src/render3/r3_deferred_blocks.ts
Expand Up @@ -44,8 +44,9 @@ export function createDeferredBlock(
ast: html.Block, connectedBlocks: html.Block[], visitor: html.Visitor,
bindingParser: BindingParser): {node: t.DeferredBlock, errors: ParseError[]} {
const errors: ParseError[] = [];
const {triggers, prefetchTriggers} = parsePrimaryTriggers(ast.parameters, bindingParser, errors);
const {placeholder, loading, error} = parseConnectedBlocks(connectedBlocks, errors, visitor);
const {triggers, prefetchTriggers} =
parsePrimaryTriggers(ast.parameters, bindingParser, errors, placeholder);
const node = new t.DeferredBlock(
html.visitAll(visitor, ast.children, ast.children), triggers, prefetchTriggers, placeholder,
loading, error, ast.sourceSpan, ast.startSourceSpan, ast.endSourceSpan);
Expand Down Expand Up @@ -182,7 +183,8 @@ function parseErrorBlock(ast: html.Block, visitor: html.Visitor): t.DeferredBloc
}

function parsePrimaryTriggers(
params: html.BlockParameter[], bindingParser: BindingParser, errors: ParseError[]) {
params: html.BlockParameter[], bindingParser: BindingParser, errors: ParseError[],
placeholder: t.DeferredBlockPlaceholder|null) {
const triggers: t.DeferredBlockTriggers = {};
const prefetchTriggers: t.DeferredBlockTriggers = {};

Expand All @@ -192,11 +194,11 @@ function parsePrimaryTriggers(
if (WHEN_PARAMETER_PATTERN.test(param.expression)) {
parseWhenTrigger(param, bindingParser, triggers, errors);
} else if (ON_PARAMETER_PATTERN.test(param.expression)) {
parseOnTrigger(param, triggers, errors);
parseOnTrigger(param, triggers, errors, placeholder);
} else if (PREFETCH_WHEN_PATTERN.test(param.expression)) {
parseWhenTrigger(param, bindingParser, prefetchTriggers, errors);
} else if (PREFETCH_ON_PATTERN.test(param.expression)) {
parseOnTrigger(param, prefetchTriggers, errors);
parseOnTrigger(param, prefetchTriggers, errors, placeholder);
} else {
errors.push(new ParseError(param.sourceSpan, 'Unrecognized trigger'));
}
Expand Down
65 changes: 41 additions & 24 deletions packages/compiler/src/render3/r3_deferred_triggers.ts
Expand Up @@ -58,7 +58,7 @@ export function parseWhenTrigger(
/** Parses an `on` trigger */
export function parseOnTrigger(
{expression, sourceSpan}: html.BlockParameter, triggers: t.DeferredBlockTriggers,
errors: ParseError[]): void {
errors: ParseError[], placeholder: t.DeferredBlockPlaceholder|null): void {
const onIndex = expression.indexOf('on');

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

constructor(
private expression: string, private start: number, private span: ParseSourceSpan,
private triggers: t.DeferredBlockTriggers, private errors: ParseError[]) {
private triggers: t.DeferredBlockTriggers, private errors: ParseError[],
private placeholder: t.DeferredBlockPlaceholder|null) {
this.tokens = new Lexer().tokenize(expression.slice(start));
}

Expand Down Expand Up @@ -146,19 +148,21 @@ class OnTriggerParser {
break;

case OnTriggerType.INTERACTION:
this.trackTrigger('interaction', createInteractionTrigger(parameters, sourceSpan));
this.trackTrigger(
'interaction', createInteractionTrigger(parameters, sourceSpan, this.placeholder));
break;

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

case OnTriggerType.HOVER:
this.trackTrigger('hover', createHoverTrigger(parameters, sourceSpan));
this.trackTrigger('hover', createHoverTrigger(parameters, sourceSpan, this.placeholder));
break;

case OnTriggerType.VIEWPORT:
this.trackTrigger('viewport', createViewportTrigger(parameters, sourceSpan));
this.trackTrigger(
'viewport', createViewportTrigger(parameters, sourceSpan, this.placeholder));
break;

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

function createInteractionTrigger(
parameters: string[], sourceSpan: ParseSourceSpan): t.InteractionDeferredTrigger {
if (parameters.length !== 1) {
throw new Error(`"${OnTriggerType.INTERACTION}" trigger must have exactly one parameter`);
}

return new t.InteractionDeferredTrigger(parameters[0], sourceSpan);
}

function createImmediateTrigger(
parameters: string[], sourceSpan: ParseSourceSpan): t.ImmediateDeferredTrigger {
if (parameters.length > 0) {
Expand All @@ -310,22 +305,44 @@ function createImmediateTrigger(
}

function createHoverTrigger(
parameters: string[], sourceSpan: ParseSourceSpan): t.HoverDeferredTrigger {
if (parameters.length !== 1) {
throw new Error(`"${OnTriggerType.HOVER}" trigger must have exactly one parameter`);
}
parameters: string[], sourceSpan: ParseSourceSpan,
placeholder: t.DeferredBlockPlaceholder|null): t.HoverDeferredTrigger {
validateReferenceBasedTrigger(OnTriggerType.HOVER, parameters, placeholder);
return new t.HoverDeferredTrigger(parameters[0] ?? null, sourceSpan);
}

return new t.HoverDeferredTrigger(parameters[0], sourceSpan);
function createInteractionTrigger(
parameters: string[], sourceSpan: ParseSourceSpan,
placeholder: t.DeferredBlockPlaceholder|null): t.InteractionDeferredTrigger {
validateReferenceBasedTrigger(OnTriggerType.INTERACTION, parameters, placeholder);
return new t.InteractionDeferredTrigger(parameters[0] ?? null, sourceSpan);
}

function createViewportTrigger(
parameters: string[], sourceSpan: ParseSourceSpan): t.ViewportDeferredTrigger {
// TODO: the RFC has some more potential parameters for `viewport`.
parameters: string[], sourceSpan: ParseSourceSpan,
placeholder: t.DeferredBlockPlaceholder|null): t.ViewportDeferredTrigger {
validateReferenceBasedTrigger(OnTriggerType.VIEWPORT, parameters, placeholder);
return new t.ViewportDeferredTrigger(parameters[0] ?? null, sourceSpan);
}

function validateReferenceBasedTrigger(
type: OnTriggerType, parameters: string[], placeholder: t.DeferredBlockPlaceholder|null) {
if (parameters.length > 1) {
throw new Error(`"${OnTriggerType.VIEWPORT}" trigger can only have zero or one parameters`);
throw new Error(`"${type}" trigger can only have zero or one parameters`);
}

return new t.ViewportDeferredTrigger(parameters[0] ?? null, sourceSpan);
if (parameters.length === 0) {
if (placeholder === null) {
throw new Error(`"${
type}" trigger with no parameters can only be placed on an @defer that has a @placeholder block`);
}

if (placeholder.children.length !== 1 || !(placeholder.children[0] instanceof t.Element)) {
throw new Error(
`"${type}" trigger with no parameters can only be placed on an @defer that has a ` +
`@placeholder block with exactly one root element node`);
}
}
}

/** Gets the index within an expression at which the trigger parameters start. */
Expand Down
10 changes: 8 additions & 2 deletions packages/compiler/src/render3/view/t2_binder.ts
Expand Up @@ -797,9 +797,15 @@ export class R3BoundTarget<DirectiveT extends DirectiveMeta> implements BoundTar

const name = trigger.reference;

// TODO(crisbeto): account for `viewport` trigger without a `reference`.
if (name === null) {
return null;
const children = block.placeholder ? block.placeholder.children : null;

// If the trigger doesn't have a reference, it is inferred as the root element node of the
// placeholder, if it only has one root node. Otherwise it's ambiguous so we don't
// attempt to resolve further.
return children !== null && children.length === 1 && children[0] instanceof Element ?
children[0] :
null;
}

const outsideRef = this.findEntityInScope(block, name);
Expand Down
1 change: 0 additions & 1 deletion packages/compiler/src/render3/view/template.ts
Expand Up @@ -1378,7 +1378,6 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
}

// Note that we generate an implicit `on idle` if the `deferred` block has no triggers.
// TODO(crisbeto): decide if this should be baked into the `defer` instruction.
// `deferOnIdle()`
if (idle || (!prefetch && Object.keys(triggers).length === 0)) {
this.creationInstruction(
Expand Down
55 changes: 51 additions & 4 deletions packages/compiler/test/render3/r3_template_transform_spec.ts
Expand Up @@ -1002,6 +1002,24 @@ describe('R3 template transform', () => {
]);
});

it('should parse triggers with implied target elements', () => {
expectDeferred(
'@defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) {hello}' +
'@placeholder {<implied-trigger/>}')
.toEqual([
['DeferredBlock'],
['HoverDeferredTrigger', null],
['InteractionDeferredTrigger', null],
['ViewportDeferredTrigger', null],
['HoverDeferredTrigger', null],
['InteractionDeferredTrigger', null],
['ViewportDeferredTrigger', null],
['Text', 'hello'],
['DeferredBlockPlaceholder'],
['Element', 'implied-trigger'],
]);
});

describe('block validations', () => {
it('should report syntax error in `when` trigger', () => {
expectDeferredError('@defer (when isVisible#){hello}')
Expand Down Expand Up @@ -1130,17 +1148,17 @@ describe('R3 template transform', () => {

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

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

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

it('should report if `viewport` trigger has more than one parameter', () => {
Expand Down Expand Up @@ -1184,6 +1202,35 @@ describe('R3 template transform', () => {
expectDeferredError('@defer {hello} @loading (after 1s; after 500ms) {loading}')
.toThrowError(/@loading block can only have one "after" parameter/);
});

it('should report if reference-based trigger has no reference and there is no placeholder block',
() => {
expectDeferredError('@defer (on viewport) {hello}')
.toThrowError(
/"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block/);
});

it('should report if reference-based trigger has no reference and the placeholder is empty',
() => {
expectDeferredError('@defer (on viewport) {hello} @placeholder {}')
.toThrowError(
/"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block with exactly one root element node/);
});

it('should report if reference-based trigger has no reference and the placeholder with text at the root',
() => {
expectDeferredError('@defer (on viewport) {hello} @placeholder {placeholder}')
.toThrowError(
/"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block with exactly one root element node/);
});

it('should report if reference-based trigger has no reference and the placeholder has multiple root elements',
() => {
expectDeferredError(
'@defer (on viewport) {hello} @placeholder {<div></div><span></span>}')
.toThrowError(
/"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block with exactly one root element node/);
});
});
});

Expand Down

0 comments on commit a05ec7c

Please sign in to comment.