angular · GitHub

Authors: @alxhub @pkozlowski-opensource @jelbourn
Area: Angular Framework
Posted: June 14, 2023
Status: Open

This RFC proposes a new control flow syntax for Angular, and represents a significant change to how we approach control flow in the framework. There are two questions which should be answered up front: why and why now? Let’s start with why:

A core goal of Angular's initial design is that template control flow would be expressed via composition of user-facing concepts only. The structural directive concept serves this purpose: a directive that sits on an <ng-template> declaration and determines when & where embedded views are created from that template. To achieve a familiar syntax for control flow similar to that of JavaScript (e.g. let user of users), microsyntax in the template language provides an alternative binding syntax for configuring a structural directive and its inputs.

Our review of developer experience pain points in Angular has highlighted microsyntax-based control flow as having significant weaknesses compared to syntaxes in other frameworks. The proposed built-in control flow syntax addresses these issues and significantly improves the developer experience, in addition to being a foundation for new features.

As for why now, as a part of our ongoing work on implementing the Signals RFC, we’ve known that the existing zone-based control flow directives would not be able to function in zoneless applications. We will need to implement reactive control flow for zoneless applications. We considered modifying the existing control flow directives to support zoneless applications as well, but decided against this option for a couple reasons:

  1. Since every existing Angular application depends on these directives, even the slightest incidental change in behavior could be breaking.

  2. Supporting both signal and zone based patterns in a single directive would greatly complicate the code, and it’s unlikely that we could properly tree-shake either pattern if it wasn’t used.

  3. We knew we wanted to make bigger improvements to control flow anyway.

We also considered implementing new reactive control flow directives just for signal components, but rejected this idea as we didn’t want to create more differences between signal and zone components than necessary.

As a result, we decided to move forward with this design of built-in control flow for templates, to both support the zoneless/signal work as well as address the longstanding DX issues with structural directives.

Goals and Non-Goals

The new syntax for template control flow has several major goals:

  1. Syntactically similar to JavaScript's control flow statement syntax.
  2. Syntactically distinct from HTML syntax.
  3. Works across multiple templates (if-elseif-else, switch-case-default), including template type-checking.
  4. Flexible syntax allowing customization for each use case (if vs for vs switch).
  5. Address common pain points.
  6. It must be possible to automatically migrate virtually all existing control flow usage to the new syntax.

It is useful to explicitly mention some non-goals of the new syntax:

  • User extensibility (defining new control flow structures in userland, or otherwise extending the syntax).
  • Composability with other Angular concepts (e.g. placing directives on control flow structures).

Overview

Many templating languages in the open source space have control flow primitives, and we’re fortunate to be able to draw on those ideas and designs from the larger web community. The proposed new control flow syntax is heavily inspired by Svelte’s control flow, as well as the mustache templating language.

The new control flow syntax is introduced in the form of blocks, a new syntactic structure in templates.

Conditional control flow uses blocks with the keywords if and else:

{#if cond.expr}
  Main case was true!
{:else if other.expr}
  Extra case was true!
{:else}
  False case!
{/if}

Switch control flow uses blocks with the keywords switch, case, and default:

{#switch cond.kind}
  {:case x}
    X case
  {:case y}
    Y case
  {:case z}
    Z case
  {:default}
    No case matched
{/switch}

Angular’s switch does not have fallthrough (there is no break operation required).

Loop control flow uses blocks with the keywords for and empty:

{#for item of items; track item.id}
  {{ item }}
{:empty}
  There were no items in the list.
{/for}

Detailed Design

Syntax

The primary syntactic element of the new control flow design is a named block group composed of one or more named blocks. Block groups are template nodes, occupying the same syntactic positions as elements, text nodes, and text bindings. Each block in a block group contains Angular template syntax within it, as a list of child template nodes (potentially including additional block groups).

Each block group begins with a tag of the form {#name} and ends with a matching closing tag {/name}. Tags can contain specific syntax based on their name. As an example, the basic if control flow structure looks like: \

{#if cond.expr}
  <child-cmp />
{/if}

Every block group also defines a named block with the same name as the block group, known as the primary block. The above example thus represents a block group (if) with a single primary block (if), which has the <child-cmp> element as a child.

This syntax is, of course, a blocking feature.

Discussion Question 1A: should we consider other syntax besides curly braces for the block tags, or besides # , /and :? E.g. [if cond]?

Tag syntax

Each named block group can customize the tag syntax following the tag name and a space. For example, the if block group supports an expression following the if, which is used as the if condition.

Different block groups may use this syntactic space in different ways.

Additional named blocks

In addition to the primary named block created by the block group tag itself, additional blocks may be defined within a block group. Additional blocks are specified with a tag that starts with : instead of #. Blocks (including the primary block) follow a set of syntactic rules:

  • Valid names for these blocks are statically determined by the overall block group to which they belong. Block names are always part of a statically known set and are never user-determined.
  • Additional blocks may be either required or optional, depending on the block group, its options, or the presence/absence of other named blocks.
  • Multiple blocks of the same name are allowed.
  • The order of blocks in a block group may be semantically meaningful, or may be fixed by the type of block group.

For example, an if block group may define an optional else block:

{#if cond}
  <true-cmp />
{:else}
  <false-cmp />
{/if}

The above syntax creates an if block group with two blocks: the primary if block created by the block group (containing the <true-cmp>) and an else block containing the <false-cmp>.

Like the primary block tag, the syntactic space following the name of an additional block is entirely customizable depending on the block group type.

Optional parentheses

In JavaScript, control flow statements require parentheses (if (cond)). Because block tags are wrapped in braces already, they don't require parentheses. However, we support including parentheses if desired:

{#if (cond)}
  …
{/if}
{#for (item of items())}
  …
{/for}

If used, the parentheses wrap the entire body of the block tag.

Discussion Question 2A: should parentheses be required to maximize similarity to JavaScript/TypeScript?

Nesting

Block groups are "nodes" in templates and can be children of blocks in other block groups. Control flow structures can therefore be nested.

Why # at all?

There are two main reasons why we chose to use a hieroglyphic (#) instead of bare single-curly statements (e.g. {if cond.expr}):

  • It provides clear visual distinction in templates between control flow and other elements, especially text bindings which use double curly braces.
  • Plain single curly brace syntax {...} is used today for the ICU Message format which supports several i18n features (eg: pluralization).

if block - conditionals

The if block replaces *ngIf for expressing conditional parts of the UI.

The primary if block includes an expression representing the condition under which this block will be displayed.

{#if a > b}
  {{a}} is greater than {{b}}
{/if}

if block groups may also contain one or more else blocks. One bare else block is allowed, as well as zero or more else blocks with an additional condition (indicated by an if keyword following the else name:

{#if a > b}
  {{a}} is greater than {{b}}
{:else if b > a}
  {{a}} is less than {{b}}
{:else}
  {{a}} is equal to {{b}}
{/if}

Aliasing the conditional expression

The current *ngIf supports aliasing of the condition expression via as: \

<div *ngIf="users$ | async as users">
  {{ users.length }}
</div>

To enable direct automatic migration from *ngIf to the new built in conditional statement, aliasing will be supported in if:

{#if users$ | async; as users}
  {{ users.length }}
{/if}
Aliasing only supported for zone components

Because signals are synchronous and side effect free, there is no harm in reading their value in multiple places in the template. In the case of complex conditions or conditions that involve expensive calculations, computed can be leveraged for memoization and de-repetition. For that reason, we feel there isn't a strong use case for aliasing the value of the condition expression in signal components (that isn't better served by computed).

Why not cond as name, similarly to microsyntax?

A long-term goal for future evolution of Angular's template syntax is to make expressions "just TypeScript" (or at least a subset of TypeScript). Because as is used in TypeScript to represent typecasts, we want to allow for expressions including typecasts in the future.

for block - repeaters

The for block replaces *ngFor for iteration, and has several differences compared to its structural directive predecessor. A basic for block group looks like:

{#for item of items; track item.id}
  {{ item.name }}
{/for}

track keys for diffing

The track setting replaces NgFor's concept of a trackBy function. It determines the row key which for will use to associate array items with the views it creates, moving them around as needed. Because for is built-in, we can provide a better experience than passing a trackBy function, and use an expression representing the key instead.

Migrating from trackBy to track is possible by invoking the trackBy function:

{#for item of items; track itemId($index, item)}
  {{ item.name }}
{/for}

The new syntax helps the _run_time by offering you a track and field to run around your loops.

$index and other variables

Within for row views, there are several implicit variables which are always available:

Variable Meaning
$index Index of the current row
$first Whether the current row is the first row
$last Whether the current row is the last row
$even Whether the current row index is even
$odd Whether the current row index is odd

These variables are always available with these names, but can be aliased via a let segment:

{#for item of items; track item.id; let idx = $index, e = $even}
  Item #{{ idx }}: {{ item.name }}
{/for}

Aliasing is useful when nesting for loops, since the inner loop's implicit variables shadow those from the outer loop.

track is required

In our performance research, we've identified NgFor loops over immutable data without trackBy as one of the most common causes for performance issues across Angular applications. Because of the potential for poor performance, we’re making track required for for loops.

There is one exception to this requirement: for for usages in signal components, when the iterable expression is assignable to Iterable<Signal<unknown>>, then track is not required (and in fact, not allowed). That's because when individual rows use signals for reactivity, we want the outer loop to track those signals by their identity and not depend on their values. This allows individual rows to update without triggering diffing of the list.

Cross-country is not required though.

Discussion Question 3A: are there technical objections to requiring track, or other ways to solve performance issues with NgFor that we may not have considered?

empty block

New to for is support for a template to render when there are no items in a list. This is a common community feature request.

{#for item of items; track item.name}
  <li> {{ item.name }}
{:empty}
  <li> There are no items.
{/for}

IterableDiffers

Unlike its structural directive predecessor, the new for will not use Angular’s customizable diffing implementation (IterableDiffers), but instead will use a new optimized algorithm (which would not be customizable).

Discussion Question 4A: do you have a use case for customizing the diffing algorithm by customizing IterableDiffers today?

for in signal components

In signal-based components, for is reactive. The iteration variable and all of the special variables created for each row are signals instead of plain values, and must be unwrapped via their getter. This allows each row created by for to change detect independently of the view containing for, and allows for to work in zoneless applications.

switch block - selection

The syntax for switch is very similar to if, and is inspired by the JavaScript switch statement:

{#switch condition}
  {:case caseA}
    Case A.
  {:case caseB}
    Case B.
  {:case caseB}
    Case C.
  {:default}
    Default case.
{/switch}

#switch has several major benefits over NgSwitch:

  • It does not require a container element to hold the condition expression or each conditional template.
  • It can support template type-checking, including type narrowing within each branch.

Note that the primary block within the switch group is unused. It will be an error to have any child nodes inside that block.

switch does not have fallthrough (there is no break operation required).

Migration Considerations

It should be possible to write an automated migration schematic which converts from NgIf, NgForOf, and NgSwitch to their equivalents in the new syntax, with a few exceptions which this section considers:

Observation of structural directive presence

Existing code may look for the presence of NgIf, NgForOf, or NgSwitch directives explicitly. This could take a few forms:

  • Using ViewChild queries to find control flow directives.
  • Injecting control flow directives from co-directives on the same <ng-template>.
  • Using control flow directives as host directives.

Iterable Differs

Angular makes it possible to customize the diffing algorithm used by NgForOf via the IterableDiffer interface and associated DI token.

Theoretically an application could customize diffing in a way that would break if migrated to use for. This could result in strange behavior when the iterable changes or, in the worst case, a complete failure to render.

Future Opportunities

This section captures potential future improvements which aren't in scope for this design, but could be achieved on top of this syntax in the future.

for..in and other loop styles

Not in scope for this design is support for other iteration flavors in JS, such as for..in loops, async iteration, etc. Such additions would theoretically be possible under the current syntax. So for now, Angular will see these other loop styles as…for-in.

Discussion Question 5A: are there other flavors of looping which would benefit your applications today?

Virtual scrolling

We could extend the syntax of for to support some kind of abstraction for a viewport and scrolling controller, that would enable virtual scrolling on top of the built in syntax.

Destructuring

We could support binding patterns (destructuring) as JS loops do.

Alternatives Considered

HTML-based control flow

Instead of the mustache-inspired syntax {#if …}, we considered using HTML syntax such as tags prefixed with the ng namespace:

<ng:if></ng:if>

This concept was rejected for several reasons:

  1. Using attribute/binding syntax for condition expressions, loop variable declarations, etc. diverges from JS control flow syntax.

For example, the condition of <ng:if> would likely be expressed as <ng:if cond="expr"> which looks less like JS control flow.

  1. Multiple templates would require more verbose inner structures.

An if-else block would take the form:

<ng:if cond="expr">
  <ng:then>
    True case
  </ng:then>
  <ng:else>
    False case
  </ng:else>
</ng:if>

This has a lot of visual overhead compared to the proposed block language.

Continuing with Microsyntax

Another option considered and not taken was to continue using Angular microsyntax to express control flow in templates. Our compiler could detect usages of *ngIf, *ngFor, and *ngSwitch and generate special code for them. We could use this mechanism to circumvent some of the limitations of the directive-based implementations of control flow, while not introducing new syntax to learn.

We rejected this option because improving the DX of control flow beyond the microsyntax form is one of the main goals of introducing built-in control flow. Having built in implementations of the original structural directives would also introduce magic which might confuse advanced users, as these directives would no longer behave according to the mental model for regular structural directives.

The microsyntax model also does not support multiple associated sub-templates for a control flow statement, so we would not solve the problem of *ngIf's else case being extremely awkward to use.

Frequently Asked Questions

Q: Will the existing structural directives (NgIf, etc) continue to work?

Ideally, it will be possible to replace most usages of the existing structural directives from @angular/common with the new syntax, and we will have an automatic migration to do so. Given that, we plan to strongly encourage developers to switch to the new control flow syntax to take advantage of the developer experience improvements.

That said, we're also aware that all existing Angular content (blogs, books, videos, etc) will continue to reference the existing directives for a long time. Currently the plan is to deprecate the existing directives but not remove them until the ecosystem (including community-authored content) has switched over to the new control flow. Ultimately, we will base this timeline on the feedback we receive from the community.

In the rare cases where automatic migration fails (e.g. for NgForOf cases discussed above) we could apply similar techniques that we’ve used before in CDK/Material, such as generating a clone of the structural directive in the user’s project.

Q: Will the structural directive concept itself be removed?

No, structural directives are an essential feature for application architecture in Angular, and we have no plans to remove them.

Q: Will the new control flow be syntax highlighted?

Yes, the Angular Language Service will highlight the keywords and expressions within the new control flow blocks.

Q: Will the new control flow affect query results at all?

Query results will work the same way as with the existing ngIf, ngFor, and ngSwitch

Q: Will the new control flow still define & create embedded views?

Yes, the technical internals of the new control flow will use the same concepts as the structural directives, including <ng-template>s and view containers.

Q: Will I still need to import the new control flow into my components?

No, it will be built into the template language and automatically available to all components.

Q: Will the new control flow be as tree-shakable as the previous version?

Yes, if an application doesn’t use a particular block group (e.g. switch), its code will not be included in the production bundle.

Q: Will the new control flow have better performance?

There may be some marginal improvements, especially for for and diffing. We expect larger memory usage improvements.

We may also be able to implement compiler optimizations in the future that would not have been possible with the previous user-land control flow. For example, we know that if’s embedded view is only created once, which might allow us to optimize some data structures.

Q: Why are we planning more changes instead of delivering on signals?

The existing control flow directives are based on zones, and so changes to control flow (particularly for) are required to support fully zoneless applications. Given that we need to change control flow anyway, we’re taking the opportunity to update the design and address other commonly reported DX pain points as well.

Note the section on for exposing its loop variable as a signal in signal-based components above. This is required to properly notify each child row’s view when its data changes (and it must be change detected).

Q: Can libraries define their own block groups?

For now, no – block groups will be a template language builtin, and not a user extension point. We are interested in researching whether it makes sense to expose some kind of extensibility here, what use cases there might be, and what form such a feature might take.

Q: Can I add directives to the new control flow blocks?

No. This is another area where we’d like to research potential use cases.

Q: What about the CDK’s virtual scrolling functionality?

The CDK will continue to provide its existing structural directives for virtual scrolling and other use cases for the time being. We will be researching whether it makes sense to either convert some of the CDK’s structural directives to built-in syntax, or to add extension points to the existing syntax for the CDK to build on (for example, to support virtual scrolling in for).

Q: Is this syntax valid HTML?

It is valid HTML, in the sense that to an HTML parser block groups are indistinguishable from plain text nodes (like Angular text bindings). We did explore using HTML pseudo-elements for control flow instead but rejected that design for several reasons (see the Alternatives Considered section above for details).

Q: Will there be other built-in blocks in the future?

Maybe 😉 we're #deferring this decision.

Read the original on github.com ↗