angular · GitHub

Authors: @AndrewKushnir, @clydin, @jessicajaniuk
Area: Angular Framework
Posted: June 14, 2023
Status: Open

The modern web emphasizes delivering a strong user experience during the loading of an application. Metrics like Core Web Vitals quantify this experience, highlighting the performance of the initial load.

One standard and effective technique for optimizing this loading experience is to defer lower priority parts of the UI and focus resources on loading the most critical parts of the page. For example, a page which shows a main video and a list of comments may be optimized to load the video first, and defer loading the code to render the comments until the video is fully buffered and ready to play.

Angular supports lazy loading of parts of the application via the Router (lazy routes). Lazy loading of individual components is achievable via dynamic import() and ngComponentOutlet, but this approach can be complex and error prone.

As a result, we see a lot of potential in introducing an ergonomic, holistic approach to deferred loading in the core framework, that works across both client- and server-side rendering. This RFC proposes a new framework primitive: {#defer}.

A note on the timeline

We're conducting this RFC as a part of our larger efforts around initial rendering and server-side rendering. While we're excited by the ideas presented here, our goal is to gather some early feedback to inform our roadmap, and we may not progress to the implementation phase until other projects have completed. When we do begin implementation, features will land incrementally over time.

What kind of feedback are we looking for?

While we're excited to hear any and all comments on this primitive for deferred loading in Angular, we have additionally called out specific discussion points throughout the RFC for directed feedback. When you leave a comment, be sure to note the open question(s) to which you're responding.

As always, keep our code of conduct in mind. We know that a proposal of this scope significantly impacts Angular and the way you use it in your projects. We appreciate your commitment and investment in this project and ask to keep comments respectful.

Goals

  • Make deferred loading in Angular predictable and ergonomic for all developers
  • Reduce initial page load time due to smaller initial bundle sizes
    8 Support deferred loading of directives & pipes as well as components
  • Introduce new patterns to allow developers to build applications that leverage deferred loading
  • Integrate deferred loaded primitive with hydration, so that developers can gain maximum benefits

#defer

Deferred blocks

(Note: This deferred loading primitive makes use of the new block template syntax proposed in the Control Flow RFC -- check out that RFC for more details)

The primitive is represented in template by the following block group: {#defer}...{/defer}. Dependencies (components, directives, etc) referenced within the deferred block are loaded lazily. As a result, deferred blocks are always rendered asynchronously. This includes all dependencies within the deferred block, which would include components, directives, and pipes used within those dependencies.

{#defer}
  <calendar-cmp />
{/defer}

on and when

Conceptually the defer block is a construct which, when triggered, replaces placeholder content with lazily loaded content. Developers have two options for configuring when this swap is triggered: on and when.

when specifies an imperative condition in the form of an expression that returns a boolean. When this expression becomes truthy, the placeholder is swapped with the lazily loaded content (which may be an asynchronous operation if the dependencies need to be fetched).

Crucially, if the when condition switches back to false, the defer block is not reverted back to the placeholder. The swap is a one-time operation. If the content within the block should be conditionally rendered, an if condition can be used within the block itself.

on specifies a declarative trigger condition, usually some kind of event. We will have a series of predefined event triggers (see the Trigger Types section). An example would be on interaction or on viewport.

Multiple event triggers would be supported at once. For example, on interaction, timer(5s) means that the defer block will be triggered if the user interacts with the placeholder, or after 5 seconds.

A more full example is below

{#defer when cond}
  <calendar-cmp />
{/defer}
{#defer on interaction, timer(5s)}
  <calendar-cmp />
{/defer}

Alternatively you could use both when and on together in one statement, and the swap will be triggered if either condition is met.

{#defer when cond; on interaction}
  <calendar-cmp />
{/defer}

:placeholder blocks

By default, defer blocks do not render any content before they're triggered. Using a :placeholder block, developers can specify which content should be shown before the defer block is triggered.

This block may have any content including DOM nodes, components, directives, pipes, etc.

{#defer when cond}
  <calendar-cmp />
{:placeholder minimum 500ms}
  <img src="placeholder.png" />
{/defer}

Note: dependencies of the :placeholder block are eagerly loaded and not deferred.

The :placeholder block is optional, but certain on conditions will require a non-empty placeholder. Additionally, you can specify a minimum condition, which takes a duration (i.e. 50ms). This allows users to specify a minimum duration of time to show the placeholder template before switching away to the next template. This allows users to prevent a fast flickering that could occur if the triggering state was immediate and the response only took a small amount of time.

:loading blocks

The :loading block defines content that should be displayed while the defer block is fetching dependencies in order to render its defer template. Similarly to the :placeholder block above, this block is optional and if not provided, defer blocks will continue to show the :placeholder (if any) until the defer content is loaded.

{#defer}
  <calendar-cmp />
{:loading}
  <div class="loading">Loading the calendar...</div>
{/defer}

Note: similarly to :placeholder, dependencies of the :loading block are eagerly loaded.

When using the :loading block, you can also specify an after condition, which takes a duration (i.e. 50ms). This allows users to specify a minimum duration of time to wait before showing the loading template. The loading template would not be shown if the request to deferred load took less time than the specified duration. This is to prevent layout thrashing caused by a fast flickering between the loading template and defer template.

Similar to the :placeholder, you can specify a minimum condition, which also takes a duration (i.e. 50ms). This allows users to specify a minimum duration of time to show the loading template before switching to the content. This allows users to prevent a similar fast flickering that could occur if the loading only took a small amount of time or a short duration after the after condition finished.

{#defer}
  <calendar-cmp />
{:loading after 100ms; minimum 500ms}
  <div class="loading">Loading the calendar...</div>
{/defer}

:error blocks

The error block represents a UI that should be rendered when deferred loading fails. Similar to the placeholder and loading blocks above, this block is optional and if not provided, defer blocks would not render any content.

{#defer}
  <calendar-cmp />
{:error}
  <p> Failed to load the calendar </p>
  <p><strong>Error:</strong> {{$error.message}}</p>
{/defer}

Note: similarly to :placeholder and :loading, dependencies of the :error block are eagerly loaded.

Along with the error block, a user will have access to the error thrown during the loading process via a $error variable accessible in the error block.

Discussion Question 1B: Do you see the need to be able to observe when the state of a defer block changes via a signal or event?

Resource Prefetching and Content Rendering

There may be a desire to separate the deferred loading action from the content rendering action. For example, users may want to prefetch a set of dependencies in preparation for an anticipated user action, reducing the delay to that deferred block being interactable. In that case, users can optionally provide a prefetch condition to prefetch dependencies before the main trigger. prefetch syntax works similarly to the main defer condition, and accepts when and/or on to declare the trigger.

In this case, when and on associated with defer controls when to render, and prefetch when and prefetch on controls when to fetch the resources. This enables more advanced behaviors, such as letting you start to prefetch resources before a user has actually seen or interacted with a defer block, but might interact with it soon, making the resources available faster.

Just like with on and when with defer, these are one way behaviors. Switching prefetch when to false does not hide the content. Hiding content can be accomplished when composed with if.

{#defer on interaction; prefetch on idle}
  <calendar-cmp />
{/defer}

We plan to take into consideration the current browser configuration and the user's connection speed by looking at the browser's navigator.connection data, NetworkInformation.saveData, and NetworkInformation.effectiveType when supported by the browser. This will ensure we provide the best possible user experience around prefetching.

Timeouts

Defer blocks provide an API for developers to configure timeouts for deferred loading operations, after which the request should be considered failed.

To configure this behavior, timeout can be defined on the {#defer} block, which defines the timeout duration in milliseconds:

{#defer timeout 100ms}
  <calendar-cmp />
{:error}
  ...
{/defer}

Discussion Question 2B: Are there use cases where different deferred blocks in an application should use different timeouts?

Trigger Types

idle

idle would trigger the deferred loading once the browser has reached an idle state. This is the default behavior with a defer block.

interaction

interaction means click, focus, touch and input events (keydown, blur, etc).

immediate

immediate triggers the deferred load immediately, meaning once the client has finished rendering, the defer chunk would then start fetching right away.

timer(x)

timer(x) would trigger after a specified duration.

hover

hover would trigger deferred loading when the mouse has hovered over a trigger area, which could be the placeholder content or a passed in element reference.

viewport

viewport would trigger the deferred block when the specified content enters the viewport using the IntersectionObserver API. This could be the placeholder content or an element reference.

Example #1: deferred loading is triggered when placeholder content becomes visible in a viewport.

{#defer on viewport}
  <calendar-cmp />
{:placeholder}
  Calendar placeholder
{/defer}

Example #2: deferred loading is triggered when another element becomes visible in a viewport using a local ref:

<div #greeting>Hello!</div>
{#defer on viewport(greeting)}
  <greetings-cmp />
{/defer}

These triggers are designed to handle the most common scenarios. If your use case requires a more complex scenario than the predefined trigger list, use when to define your condition.

Discussion Question 3B: Is this list of predefined triggers sufficient to handle the majority of cases that users may have? Can you think of other common trigger types that we didn't account for in this design?

Discussion Question 4B: Can you think of use cases where you would need to change the default trigger behavior globally for all {#defer} blocks in your application?

Nesting

{#defer} blocks can be nested and there are legit use-cases for doing this. For example: you may want to render a big component when it becomes visible in a viewport, but load some inner components once a user clicks a button.

Important note: nesting of defer sections may cause performance issues where components would sequentially load extra resources as Angular progresses with the rendering and discovers more defer boundaries. We plan to provide diagnostics and tooling for developers to help avoid this issue. See "Cascading Requests" section below.

Server Side Rendering Behavior

When rendering an application on the server, defer blocks always render their placeholder (or nothing if a placeholder is not specified). Triggers are ignored on the server.

See the next section for more information on the future plans for integration of defer with server side rendering.

Potential Future Path to Partial Hydration

Our roadmap for server-side rendering includes research into expanding our hydration implementation to support partial hydration - leaving server-side rendered content dehydrated after the initial page load. defer could be extended to integrate such functionality.

With partial hydration enabled, deferred content could be server-rendered and then left dehydrated until triggering conditions are met. We plan on conducting more research in this area and will follow up with a future RFC potentially bringing extra configuration to the defer block that would allow you to configure server rendering and hydration behavior in a more granular way.

Common Pitfalls

Cascading Requests

There are cases where nesting multiple defer blocks may cause cascading requests. An example of this would be when an defer block with an immediate trigger has a nested defer block with another immediate trigger. If it's all within one template, we could easily group those into one request, but in this case in a nested component template, this would not be catchable. We plan to implement extra diagnostics to warn developers about such patterns:

For nested defer blocks within a single template, the compiler can detect those patterns and output a warning to a user. This can also be handled by the Extended Template Diagnostics subsystem.

For nested blocks across multiple components, we can add detection mechanisms to runtime (in dev mode only).

This would also be highlighted in the docs as an anti-pattern (along with other good and bad practices).

Content Projection inside Defer Blocks

Dependencies of the content projected from outside of a component are not deferred when projected into a defer block. If you want to deferred load projected content dependencies, you can wrap that content into {#defer} block (with the necessary conditions) separately.

Wrapping content with no dependencies

If a user writes a {#defer} block, and inside that defer block is only content that has no dependencies (components, directives, pipes, etc) to extract, the defer block actually serves no purpose. We'll want to catch this case and provide an error to the user. The prior case of content projection inside of a defer block is one example. If a user only places DOM inside, and there are no directives applied to any of them, that would be another case. We'll highlight in the docs that this is an unnecessary use of defer blocks. We'll also want to add compile time checks for this to prevent users from making this mistake.

Cumulative Layout Shift (CLS) Risk

When using {#defer} blocks above the fold, if placeholders do not match the exact size of the deferred content that will replace it, it will contribute to the cumulative layout shift of the application's core web vitals. From a strictly CWV perspective, the recommendation is generally that you should only insert content a) out of viewport, or, b) in response to user input.

Frequently Asked Questions

Q: Why is this feature called defer?

We've deferred answering this until later on in the FAQ.

Q: Why is this feature called defer?

The action of what this feature is doing is deferring the loading of something until a certain condition is met after the initial page load. This is a behavior that's similar to the script defer attribute, which loads resources in parallel and executes after the page has been parsed. Lazy loading is loading only when needing to use it. Deferred loading can be used for more than just loading something lazily.

Q: Can I use in a deferred block?

Yes, with the caveat that it is not the only thing inside of the defer block. See the section above on wrapping content with no dependencies for more information.

Q: Can I imperatively trigger deferred loading?

You can use a when expression to trigger the deferred block at any point.

Q: Is there a way to observe when defer blocks are loaded or state has changed?

See discussion question 1B.

Q: Can I define my own triggers?

No, we don't plan on supporting user-defined triggers. The current set of declarative triggers will be tightly integrated into the framework. If you have ideas for new triggers that would be useful in your applications, we'd love to hear them (see discussion question 4B).

You can also use a when expression to completely customize the triggering logic.

Read the original on github.com ↗