Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions adev/src/content/guide/templates/defer.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,66 @@ The `@error` block is an optional block that displays if deferred loading fails.
}
```

#### Automatically retry failed loads with `@error (retry N)`

Network blips, an evicted CDN edge, or a transient 5xx can leave your users staring at an `@error` block when the underlying problem already resolved itself. To recover from these transient failures, pass a `retry` parameter to `@error`:

```angular-html
@defer {
<large-component />
} @error (retry 3) {
<p>We couldn't load this section. Please refresh.</p>
}
```

`N` must be a non-negative integer literal. Angular makes up to `N` additional load attempts before surfacing the `@error` block, for a total of up to `N + 1` attempts. Retries are sequential; each one waits for the previous attempt to fail before starting.

Cache-busting works out of the box. The HTML specification mandates that browsers permanently cache failed dynamic `import()` requests against their original URL, so a naive retry would always reproduce the original failure. Angular sidesteps this by appending a `?ngRetry=N` query parameter to the chunk URL on each retry — no configuration required.

`@error (retry N)` composes with every other `@defer` feature, including `@loading`, all `on` and `when` triggers, prefetching, and incremental hydration. During hydration, the retry sequence is transparent: only the final outcome (success or exhaustion) is observed by the hydration runtime.

#### What renders during a retry?

While retries are in flight, the block stays in its loading state — the `@loading` block (if you have one) keeps showing for the entire retry sequence. The `@error` block only renders after the **final** attempt fails.

For example, with `@error (retry 3)` and a network that fails twice before succeeding on the third attempt:

| Phase | Block shown |
| -------------------- | ------------- |
| Initial attempt | `@loading` |
| Retry 1 (after fail) | `@loading` |
| Retry 2 (after fail) | `@loading` |
| Retry 3 succeeds | Main `@defer` |

If all four attempts had failed, the user would see `@loading` throughout, then `@error` once retries were exhausted. This means a `@loading` block with a `minimum` duration applies to the loading state as a whole, not to each individual attempt.

##### Customizing retry behavior

You can customize how Angular retries a failed `@defer` chunk download by providing your own `DeferBlockRetryHandler` and registering it with `provideDeferBlockRetryHandler` in your application's providers. This is useful for telemetry, exponential backoff, CDN failover, or integrity checks.

```ts
import {provideDeferBlockRetryHandler} from '@angular/core';

bootstrapApplication(App, {
providers: [
provideDeferBlockRetryHandler(async (load, ctx) => {
if (ctx.attempt === 0) {
return load();
}
// Exponential backoff before each retry.
await new Promise((resolve) => setTimeout(resolve, 2 ** ctx.attempt * 100));
// `ctx.retry()` re-issues the chunk download with cache-busting applied,
// so you don't have to parse import URLs yourself.
return ctx.retry();
}),
],
});
```

The handler receives the compiler-generated `load` thunk and a `context` carrying `attempt` (zero-based) and `retry()` (cache-busted reload). When you provide your own handler, always use `ctx.retry()` on attempts after the first — calling `load()` again would hit the browser's failed-import cache and reproduce the original failure.

NOTE: The built-in cache-busting strategy targets native ESM dynamic imports — the shape produced by Angular CLI's esbuild/Vite pipeline and shipped in the FESM bundles of `@angular/*` packages. Custom bundlers that rewrite the compiler-emitted thunk (notably webpack) are not supported; on those toolchains `ctx.retry()` may fall back to re-invoking the original loader, which the bundler's runtime can serve from its module cache instead of issuing a fresh network request.

## Controlling deferred content loading with triggers

You can specify **triggers** that control when Angular loads and displays deferred content.
Expand Down
12 changes: 12 additions & 0 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,15 @@ export class DefaultIterableDiffer<V> implements IterableDiffer<V>, IterableChan
onDestroy(): void;
}

// @public
export interface DeferBlockRetryContext<T = unknown> {
readonly attempt: number;
retry(): Promise<T>;
}

// @public
export type DeferBlockRetryHandler = <T>(load: () => Promise<T>, context: DeferBlockRetryContext<T>) => Promise<T>;

// @public
export interface DestroyableInjector extends Injector {
// (undocumented)
Expand Down Expand Up @@ -1498,6 +1507,9 @@ export function provideCheckNoChangesConfig(options: {
exhaustive: true;
}): EnvironmentProviders;

// @public
export function provideDeferBlockRetryHandler(handler: DeferBlockRetryHandler): EnvironmentProviders;

// @public
export function provideEnvironmentInitializer(initializerFn: () => void): EnvironmentProviders;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,10 +395,10 @@ export class MyApp {
<loading-dep/>
}
</div>
`, isInline: true, dependencies: [{ kind: "directive", type: EagerDep, selector: "eager-dep" }, { kind: "directive", type: LoadingDep, selector: "loading-dep" }], deferBlockDependencies: [() => [/* @ts-ignore */
`, isInline: true, dependencies: [{ kind: "directive", type: EagerDep, selector: "eager-dep" }, { kind: "directive", type: LoadingDep, selector: "loading-dep" }], deferBlockDependencies: [() => [() => /* @ts-ignore */
import("./deferred_with_external_deps_lazy").then(m => m.LazyDep)]] });
}
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, resolveDeferredDeps: () => [/* @ts-ignore */
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, resolveDeferredDeps: () => [() => /* @ts-ignore */
import("./deferred_with_external_deps_lazy").then(m => m.LazyDep)], resolveMetadata: LazyDep => ({ decorators: [{
type: Component,
args: [{
Expand Down Expand Up @@ -717,10 +717,10 @@ export class MyApp {
@defer (on idle) {
<inner-cmp />
}
`, isInline: true, deferBlockDependencies: [() => [/* @ts-ignore */
`, isInline: true, deferBlockDependencies: [() => [() => /* @ts-ignore */
import("./defer_nested_hydrate_inner").then(m => m.InnerCmp)]] });
}
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, resolveDeferredDeps: () => [/* @ts-ignore */
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, resolveDeferredDeps: () => [() => /* @ts-ignore */
import("./defer_nested_hydrate_inner").then(m => m.InnerCmp)], resolveMetadata: InnerCmp => ({ decorators: [{
type: Component,
args: [{
Expand Down Expand Up @@ -1039,10 +1039,10 @@ export class TestCmp {
<cmp-a />
<local-dep />
}
`, isInline: true, deferBlockDependencies: [() => [/* @ts-ignore */
`, isInline: true, deferBlockDependencies: [() => [() => /* @ts-ignore */
import("./defer_deps_ext").then(m => m.CmpA), LocalDep]] });
}
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, resolveDeferredDeps: () => [/* @ts-ignore */
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, resolveDeferredDeps: () => [() => /* @ts-ignore */
import("./defer_deps_ext").then(m => m.CmpA)], resolveMetadata: CmpA => ({ decorators: [{
type: Component,
args: [{
Expand Down Expand Up @@ -1116,10 +1116,10 @@ export class TestCmp {
<cmp-a />
<local-dep />
}
`, isInline: true, deferBlockDependencies: [() => [/* @ts-ignore */
`, isInline: true, deferBlockDependencies: [() => [() => /* @ts-ignore */
import("./defer_default_deps_ext").then(m => m.default), LocalDep]] });
}
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, resolveDeferredDeps: () => [/* @ts-ignore */
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, resolveDeferredDeps: () => [() => /* @ts-ignore */
import("./defer_default_deps_ext").then(m => m.default)], resolveMetadata: CmpA => ({ decorators: [{
type: Component,
args: [{
Expand Down Expand Up @@ -1278,13 +1278,13 @@ export class MyApp {
@defer {
<other-lazy-dep/>
}
`, isInline: true, deferBlockDependencies: [() => [/* @ts-ignore */
import("./deferred_with_duplicate_external_dep_lazy").then(m => m.DuplicateLazyDep)], () => [/* @ts-ignore */
import("./deferred_with_duplicate_external_dep_lazy").then(m => m.DuplicateLazyDep)], () => [/* @ts-ignore */
`, isInline: true, deferBlockDependencies: [() => [() => /* @ts-ignore */
import("./deferred_with_duplicate_external_dep_lazy").then(m => m.DuplicateLazyDep)], () => [() => /* @ts-ignore */
import("./deferred_with_duplicate_external_dep_lazy").then(m => m.DuplicateLazyDep)], () => [() => /* @ts-ignore */
import("./deferred_with_duplicate_external_dep_other").then(m => m.OtherLazyDep)]] });
}
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, resolveDeferredDeps: () => [/* @ts-ignore */
import("./deferred_with_duplicate_external_dep_lazy").then(m => m.DuplicateLazyDep), /* @ts-ignore */
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, resolveDeferredDeps: () => [() => /* @ts-ignore */
import("./deferred_with_duplicate_external_dep_lazy").then(m => m.DuplicateLazyDep), () => /* @ts-ignore */
import("./deferred_with_duplicate_external_dep_other").then(m => m.OtherLazyDep)], resolveMetadata: (DuplicateLazyDep, OtherLazyDep) => ({ decorators: [{
type: Component,
args: [{
Expand Down Expand Up @@ -1362,10 +1362,10 @@ export class TestCmp {
@defer {
<my-counter-cmp />
}
`, isInline: true, deferBlockDependencies: [() => [/* @ts-ignore */
`, isInline: true, deferBlockDependencies: [() => [() => /* @ts-ignore */
import("./deferred_import_alias_index").then(m => m.MyCounterCmp)]] });
}
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, resolveDeferredDeps: () => [/* @ts-ignore */
i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, resolveDeferredDeps: () => [() => /* @ts-ignore */
import("./deferred_import_alias_index").then(m => m.MyCounterCmp)], resolveMetadata: MyCounterCmp => ({ decorators: [{
type: Component,
args: [{
Expand Down Expand Up @@ -1547,3 +1547,50 @@ export declare class MyApp {
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, true, never>;
}

/****************************************************************************************************
* PARTIAL FILE: deferred_with_error_retry.js
****************************************************************************************************/
import { Component } from '@angular/core';
import * as i0 from "@angular/core";
export class MyApp {
isVisible = false;
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, isStandalone: true, selector: "ng-component", ngImport: i0, template: `
<div>
@defer (when isVisible) {
<p>Loaded!</p>
} @placeholder {
<p>Placeholder</p>
} @error (retry 3) {
<p>Failed!</p>
}
</div>
`, isInline: true });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
type: Component,
args: [{
template: `
<div>
@defer (when isVisible) {
<p>Loaded!</p>
} @placeholder {
<p>Placeholder</p>
} @error (retry 3) {
<p>Failed!</p>
}
</div>
`
}]
}] });

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

Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,21 @@
"failureMessage": "Incorrect template"
}
]
},
{
"description": "should generate a defer block with an `@error (retry N)` parameter",
"inputFiles": ["deferred_with_error_retry.ts"],
"expectations": [
{
"files": [
{
"expected": "deferred_with_error_retry_template.js",
"generated": "deferred_with_error_retry.js"
}
],
"failureMessage": "Incorrect template"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const $TestCmp_Defer_1_DepsFn$ = () => [
/* @ts-ignore */
() => /* @ts-ignore */
import("./defer_default_deps_ext").then(m => m.default),
LocalDep
];
Expand Down Expand Up @@ -28,7 +28,7 @@ function TestCmp_Template(rf, ctx) {

(() => {
(typeof ngDevMode === "undefined" || ngDevMode) && $r3$.ɵsetClassMetadataAsync(TestCmp, () => [
/* @ts-ignore */
() => /* @ts-ignore */
import("./defer_default_deps_ext").then(m => m.default)
], CmpA => {
$r3$.ɵsetClassMetadata(TestCmp, [{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const $TestCmp_Defer_1_DepsFn$ = () => [
/* @ts-ignore */
() => /* @ts-ignore */
import("./defer_deps_ext").then(m => m.CmpA),
LocalDep
];
Expand All @@ -26,7 +26,7 @@ function TestCmp_Template(rf, ctx) { if (rf & 1) {

(() => {
(typeof ngDevMode === "undefined" || ngDevMode) && $r3$.ɵsetClassMetadataAsync(TestCmp, () => [
/* @ts-ignore */
() => /* @ts-ignore */
import("./defer_deps_ext").then(m => m.CmpA)
], CmpA => {
$r3$.ɵsetClassMetadata(TestCmp, [{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
const MyApp_Defer_1_DepsFn = () => [
/* @ts-ignore */
() => /* @ts-ignore */
import("./deferred_with_duplicate_external_dep_lazy").then(m => m.DuplicateLazyDep)
];
// NOTE: in linked tests there is one more loader here, because linked compilation doesn't have the ability to de-dupe identical functions.
const MyApp_Defer_7_DepsFn = () => [
/* @ts-ignore */
() => /* @ts-ignore */
import("./deferred_with_duplicate_external_dep_other").then(m => m.OtherLazyDep)
];

Expand Down Expand Up @@ -34,9 +34,9 @@ $r3$.ɵɵdefineComponent({

(() => {
(typeof ngDevMode === "undefined" || ngDevMode) && $r3$.ɵsetClassMetadataAsync(MyApp, () => [
/* @ts-ignore */
() => /* @ts-ignore */
import("./deferred_with_duplicate_external_dep_lazy").then(m => m.DuplicateLazyDep),
/* @ts-ignore */
() => /* @ts-ignore */
import("./deferred_with_duplicate_external_dep_other").then(m => m.OtherLazyDep)
], (DuplicateLazyDep, OtherLazyDep) => {
$r3$.ɵsetClassMetadata(MyApp, [{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {Component} from '@angular/core';

@Component({
template: `
<div>
@defer (when isVisible) {
<p>Loaded!</p>
} @placeholder {
<p>Placeholder</p>
} @error (retry 3) {
<p>Failed!</p>
}
</div>
`
})
export class MyApp {
isVisible = false;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as i0 from "@angular/core";
export declare class MyApp {
isVisible: boolean;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, true, never>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
function MyApp_Defer_1_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵdomElementStart(0, "p");
$r3$.ɵɵtext(1, "Loaded!");
$r3$.ɵɵdomElementEnd();
}
}

function MyApp_DeferPlaceholder_2_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵdomElementStart(0, "p");
$r3$.ɵɵtext(1, "Placeholder");
$r3$.ɵɵdomElementEnd();
}
}

function MyApp_DeferError_3_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵdomElementStart(0, "p");
$r3$.ɵɵtext(1, "Failed!");
$r3$.ɵɵdomElementEnd();
}
}


function MyApp_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵdomElementStart(0, "div");
$r3$.ɵɵdomTemplate(1, MyApp_Defer_1_Template, 2, 0)(2, MyApp_DeferPlaceholder_2_Template, 2, 0)(3, MyApp_DeferError_3_Template, 2, 0);
$r3$.ɵɵdefer(4, 1, null, null, 2, 3, null, null, null, null, $r3$.ɵɵdeferEnableRetry, 3);
$r3$.ɵɵdomElementEnd();
}
if (rf & 2) {
$r3$.ɵɵadvance(4);
$r3$.ɵɵdeferWhen(ctx.isVisible);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {LoadingDep} from './deferred_with_external_deps_loading';
import * as $r3$ from "@angular/core";

const $MyApp_Defer_4_DepsFn$ = () => [
/* @ts-ignore */
() => /* @ts-ignore */
import("./deferred_with_external_deps_lazy").then(m => m.LazyDep)
];

Expand Down Expand Up @@ -42,7 +42,7 @@ export class MyApp {

(() => {
(typeof ngDevMode === "undefined" || ngDevMode) && $r3$.ɵsetClassMetadataAsync(MyApp, () => [
/* @ts-ignore */
() => /* @ts-ignore */
import("./deferred_with_external_deps_lazy").then(m => m.LazyDep)
], LazyDep => {
$r3$.ɵsetClassMetadata(MyApp, [{
Expand Down
Loading
Loading