Skip to content

Commit f320700

Browse files
crisbetoAndrewKushnir
authored andcommitted
refactor(compiler): parse options parameter on viewport triggers (angular#64130)
Updates the template parser and AST to capture a second object literal parameter on `viewport` triggers. PR Close angular#64130
1 parent 6433671 commit f320700

File tree

7 files changed

+184
-9
lines changed

7 files changed

+184
-9
lines changed

packages/compiler/src/combined_visitor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export class CombinedRecursiveAstVisitor extends RecursiveAstVisitor implements
7070
visitDeferredTrigger(trigger: t.DeferredTrigger): void {
7171
if (trigger instanceof t.BoundDeferredTrigger) {
7272
this.visit(trigger.value);
73+
} else if (trigger instanceof t.ViewportDeferredTrigger && trigger.options !== null) {
74+
this.visit(trigger.options);
7375
}
7476
}
7577

packages/compiler/src/render3/r3_ast.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ASTWithSource,
1313
BindingType,
1414
BoundElementProperty,
15+
LiteralMap,
1516
ParsedEvent,
1617
ParsedEventType,
1718
} from '../expression_parser/ast';
@@ -252,7 +253,8 @@ export class InteractionDeferredTrigger extends DeferredTrigger {
252253

253254
export class ViewportDeferredTrigger extends DeferredTrigger {
254255
constructor(
255-
public reference: string | null,
256+
readonly reference: string | null,
257+
readonly options: LiteralMap | null,
256258
nameSpan: ParseSourceSpan,
257259
sourceSpan: ParseSourceSpan,
258260
prefetchSpan: ParseSourceSpan | null,

packages/compiler/src/render3/r3_deferred_blocks.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,15 +279,15 @@ function parsePrimaryTriggers(
279279
if (WHEN_PARAMETER_PATTERN.test(param.expression)) {
280280
parseWhenTrigger(param, bindingParser, triggers, errors);
281281
} else if (ON_PARAMETER_PATTERN.test(param.expression)) {
282-
parseOnTrigger(param, triggers, errors, placeholder);
282+
parseOnTrigger(param, bindingParser, triggers, errors, placeholder);
283283
} else if (PREFETCH_WHEN_PATTERN.test(param.expression)) {
284284
parseWhenTrigger(param, bindingParser, prefetchTriggers, errors);
285285
} else if (PREFETCH_ON_PATTERN.test(param.expression)) {
286-
parseOnTrigger(param, prefetchTriggers, errors, placeholder);
286+
parseOnTrigger(param, bindingParser, prefetchTriggers, errors, placeholder);
287287
} else if (HYDRATE_WHEN_PATTERN.test(param.expression)) {
288288
parseWhenTrigger(param, bindingParser, hydrateTriggers, errors);
289289
} else if (HYDRATE_ON_PATTERN.test(param.expression)) {
290-
parseOnTrigger(param, hydrateTriggers, errors, placeholder);
290+
parseOnTrigger(param, bindingParser, hydrateTriggers, errors, placeholder);
291291
} else if (HYDRATE_NEVER_PATTERN.test(param.expression)) {
292292
parseNeverTrigger(param, hydrateTriggers, errors);
293293
} else {

packages/compiler/src/render3/r3_deferred_triggers.ts

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@
77
*/
88

99
import * as chars from '../chars';
10+
import {
11+
AST,
12+
ASTWithSource,
13+
ImplicitReceiver,
14+
LiteralArray,
15+
LiteralMap,
16+
LiteralPrimitive,
17+
PropertyRead,
18+
RecursiveAstVisitor,
19+
ThisReceiver,
20+
} from '../expression_parser/ast';
1021
import {Lexer, Token, TokenType} from '../expression_parser/lexer';
1122
import * as html from '../ml_parser/ast';
1223
import {ParseError, ParseSourceSpan} from '../parse_util';
@@ -117,6 +128,7 @@ export function parseWhenTrigger(
117128
/** Parses an `on` trigger */
118129
export function parseOnTrigger(
119130
{expression, sourceSpan}: html.BlockParameter,
131+
bindingParser: BindingParser,
120132
triggers: t.DeferredBlockTriggers,
121133
errors: ParseError[],
122134
placeholder: t.DeferredBlockPlaceholder | null,
@@ -135,16 +147,18 @@ export function parseOnTrigger(
135147
errors.push(new ParseError(sourceSpan, `Could not find "on" keyword in expression`));
136148
} else {
137149
const start = getTriggerParametersStart(expression, onIndex + 1);
150+
const isHydrationTrigger = expression.startsWith('hydrate');
138151
const parser = new OnTriggerParser(
139152
expression,
153+
bindingParser,
140154
start,
141155
sourceSpan,
142156
triggers,
143157
errors,
144-
expression.startsWith('hydrate')
158+
isHydrationTrigger
145159
? validateHydrateReferenceBasedTrigger
146160
: validatePlainReferenceBasedTrigger,
147-
placeholder,
161+
isHydrationTrigger,
148162
prefetchSpan,
149163
onSourceSpan,
150164
hydrateSpan,
@@ -173,12 +187,13 @@ class OnTriggerParser {
173187

174188
constructor(
175189
private expression: string,
190+
private bindingParser: BindingParser,
176191
private start: number,
177192
private span: ParseSourceSpan,
178193
private triggers: t.DeferredBlockTriggers,
179194
private errors: ParseError[],
180195
private validator: ReferenceTriggerValidator,
181-
private placeholder: t.DeferredBlockPlaceholder | null,
196+
private isHydrationTrigger: boolean,
182197
private prefetchSpan: ParseSourceSpan | null,
183198
private onSourceSpan: ParseSourceSpan,
184199
private hydrateSpan: ParseSourceSpan | null,
@@ -333,6 +348,9 @@ class OnTriggerParser {
333348
this.trackTrigger(
334349
'viewport',
335350
createViewportTrigger(
351+
this.start,
352+
this.isHydrationTrigger,
353+
this.bindingParser,
336354
parameters,
337355
nameSpan,
338356
sourceSpan,
@@ -568,6 +586,9 @@ function createInteractionTrigger(
568586
}
569587

570588
function createViewportTrigger(
589+
start: number,
590+
isHydrationTrigger: boolean,
591+
bindingParser: BindingParser,
571592
parameters: ParsedParameter[],
572593
nameSpan: ParseSourceSpan,
573594
sourceSpan: ParseSourceSpan,
@@ -577,8 +598,74 @@ function createViewportTrigger(
577598
validator: ReferenceTriggerValidator,
578599
): t.ViewportDeferredTrigger {
579600
validator(OnTriggerType.VIEWPORT, parameters);
601+
602+
let reference: string | null;
603+
let options: LiteralMap | null;
604+
605+
if (parameters.length === 0) {
606+
reference = options = null;
607+
} else if (!parameters[0].expression.startsWith('{')) {
608+
reference = parameters[0].expression;
609+
options = null;
610+
} else {
611+
const parsed = bindingParser.parseBinding(
612+
parameters[0].expression,
613+
false,
614+
sourceSpan,
615+
sourceSpan.start.offset + start + parameters[0].start,
616+
);
617+
618+
if (!(parsed.ast instanceof LiteralMap)) {
619+
throw new Error('Options parameter of the "viewport" trigger must be an object literal');
620+
} else if (parsed.ast.keys.some((key) => key.key === 'root')) {
621+
throw new Error(
622+
'The "root" option is not supported in the options parameter of the "viewport" trigger',
623+
);
624+
}
625+
626+
const triggerIndex = parsed.ast.keys.findIndex((key) => key.key === 'trigger');
627+
628+
if (triggerIndex === -1) {
629+
reference = null;
630+
options = parsed.ast;
631+
} else {
632+
const value = parsed.ast.values[triggerIndex];
633+
const triggerFilter = (_: unknown, index: number) => index !== triggerIndex;
634+
635+
if (
636+
!(value instanceof PropertyRead) ||
637+
!(value.receiver instanceof ImplicitReceiver) ||
638+
value.receiver instanceof ThisReceiver
639+
) {
640+
throw new Error(`"trigger" option of the "viewport" trigger must be an identifier`);
641+
}
642+
643+
reference = (value as PropertyRead).name;
644+
options = new LiteralMap(
645+
parsed.ast.span,
646+
parsed.ast.sourceSpan,
647+
parsed.ast.keys.filter(triggerFilter),
648+
parsed.ast.values.filter(triggerFilter),
649+
);
650+
}
651+
}
652+
653+
if (isHydrationTrigger && reference !== null) {
654+
throw new Error(`"viewport" hydration trigger cannot have a "trigger"`);
655+
} else if (options) {
656+
const dynamicNode = DynamicAstValidator.findDynamicNode(options);
657+
658+
if (dynamicNode !== null) {
659+
throw new Error(
660+
`Options of the "viewport" trigger must be an object ` +
661+
`literal containing only literal values, but "${dynamicNode.constructor.name}" was found`,
662+
);
663+
}
664+
}
665+
580666
return new t.ViewportDeferredTrigger(
581-
parameters[0]?.expression ?? null,
667+
reference,
668+
options,
582669
nameSpan,
583670
sourceSpan,
584671
prefetchSpan,
@@ -604,6 +691,13 @@ function validatePlainReferenceBasedTrigger(type: OnTriggerType, parameters: Par
604691
* @param parameters Parameters of the trigger.
605692
*/
606693
function validateHydrateReferenceBasedTrigger(type: OnTriggerType, parameters: ParsedParameter[]) {
694+
if (type === OnTriggerType.VIEWPORT) {
695+
if (parameters.length > 1) {
696+
throw new Error(`Hydration trigger "${type}" cannot have more than one parameter`);
697+
}
698+
return;
699+
}
700+
607701
if (parameters.length > 0) {
608702
throw new Error(`Hydration trigger "${type}" cannot have parameters`);
609703
}
@@ -638,3 +732,26 @@ export function parseDeferredTime(value: string): number | null {
638732
const [time, units] = match;
639733
return parseFloat(time) * (units === 's' ? 1000 : 1);
640734
}
735+
736+
class DynamicAstValidator extends RecursiveAstVisitor {
737+
private dynamicNode: AST | null = null;
738+
739+
static findDynamicNode(ast: AST): AST | null {
740+
const visitor = new DynamicAstValidator();
741+
visitor.visit(ast);
742+
return visitor.dynamicNode;
743+
}
744+
745+
override visit(ast: AST): void {
746+
if (
747+
!(ast instanceof ASTWithSource) &&
748+
!(ast instanceof LiteralPrimitive) &&
749+
!(ast instanceof LiteralArray) &&
750+
!(ast instanceof LiteralMap)
751+
) {
752+
this.dynamicNode = ast;
753+
} else {
754+
super.visit(ast);
755+
}
756+
}
757+
}

packages/compiler/test/render3/r3_template_transform_spec.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,13 @@ class R3AstHumanizer implements t.Visitor<void> {
150150
} else if (trigger instanceof t.InteractionDeferredTrigger) {
151151
this.result.push(['InteractionDeferredTrigger', trigger.reference]);
152152
} else if (trigger instanceof t.ViewportDeferredTrigger) {
153-
this.result.push(['ViewportDeferredTrigger', trigger.reference]);
153+
const result = ['ViewportDeferredTrigger', trigger.reference];
154+
155+
if (trigger.options !== null) {
156+
result.push(unparse(trigger.options));
157+
}
158+
159+
this.result.push(result);
154160
} else if (trigger instanceof t.NeverDeferredTrigger) {
155161
this.result.push(['NeverDeferredTrigger']);
156162
} else {
@@ -1325,6 +1331,29 @@ describe('R3 template transform', () => {
13251331
]);
13261332
});
13271333

1334+
it('should parse a viewport trigger with an options parameter', () => {
1335+
expectFromHtml(
1336+
'@defer (on viewport({trigger: foo, rootMargin: "123px", threshold: [1, 2, 3]})){hello}',
1337+
).toEqual([
1338+
['DeferredBlock'],
1339+
['ViewportDeferredTrigger', 'foo', '{rootMargin: "123px", threshold: [1, 2, 3]}'],
1340+
['Text', 'hello'],
1341+
]);
1342+
});
1343+
1344+
it('should parse a viewport trigger with an options parameter, but without a trigger', () => {
1345+
expectFromHtml('@defer (on viewport({rootMargin: "123px"})){hello}').toEqual([
1346+
['DeferredBlock'],
1347+
['ViewportDeferredTrigger', null, '{rootMargin: "123px"}'],
1348+
['Text', 'hello'],
1349+
]);
1350+
expectFromHtml('@defer (on viewport({rootMargin: "123px"})){hello}').toEqual([
1351+
['DeferredBlock'],
1352+
['ViewportDeferredTrigger', null, '{rootMargin: "123px"}'],
1353+
['Text', 'hello'],
1354+
]);
1355+
});
1356+
13281357
describe('block validations', () => {
13291358
it('should report syntax error in `when` trigger', () => {
13301359
expect(() => parse('@defer (when isVisible#){hello}')).toThrowError(
@@ -1498,6 +1527,26 @@ describe('R3 template transform', () => {
14981527
);
14991528
});
15001529

1530+
it('should report if `viewport` trigger with an object literal parameter has a "trigger" that is not an identifier', () => {
1531+
expect(() => parse('@defer (on viewport({trigger: "str"})) {hello}')).toThrowError(
1532+
/"trigger" option of the "viewport" trigger must be an identifier/,
1533+
);
1534+
});
1535+
1536+
it('should report if `viewport` trigger has a variable options parameter', () => {
1537+
expect(() =>
1538+
parse('@defer (on viewport({threshold: [1, someVar, 3]})) {hello}'),
1539+
).toThrowError(
1540+
/Options of the "viewport" trigger must be an object literal containing only literal values/,
1541+
);
1542+
});
1543+
1544+
it('should report if `viewport` trigger options parameter contains the `root` property', () => {
1545+
expect(() => parse('@defer (on viewport({root: foo})) {hello}')).toThrowError(
1546+
/The "root" option is not supported in the options parameter of the "viewport" trigger/,
1547+
);
1548+
});
1549+
15011550
it('should report duplicate when triggers', () => {
15021551
expect(() => parse('@defer (when isVisible(); when somethingElse()) {hello}')).toThrowError(
15031552
/Duplicate "when" trigger is not allowed/,

packages/compiler/test/render3/util/expression.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ class ExpressionSourceHumanizer extends e.RecursiveAstVisitor implements t.Visit
179179
visitDeferredTrigger(trigger: t.DeferredTrigger): void {
180180
if (trigger instanceof t.BoundDeferredTrigger) {
181181
this.recordAst(trigger.value);
182+
} else if (trigger instanceof t.ViewportDeferredTrigger && trigger.options !== null) {
183+
this.recordAst(trigger.options);
182184
}
183185
}
184186

packages/language-service/src/template_target.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
TmplAstTextAttribute,
4747
TmplAstUnknownBlock,
4848
TmplAstVariable,
49+
TmplAstViewportDeferredTrigger,
4950
tmplAstVisitAll,
5051
TmplAstVisitor,
5152
} from '@angular/compiler';
@@ -650,6 +651,8 @@ class TemplateTargetVisitor implements TmplAstVisitor {
650651
visitDeferredTrigger(trigger: TmplAstDeferredTrigger) {
651652
if (trigger instanceof TmplAstBoundDeferredTrigger) {
652653
this.visitBinding(trigger.value);
654+
} else if (trigger instanceof TmplAstViewportDeferredTrigger && trigger.options !== null) {
655+
this.visitBinding(trigger.options);
653656
}
654657
}
655658

0 commit comments

Comments
 (0)