From 5312139d158cbaafef5c55e296f5b13c99ca92e9 Mon Sep 17 00:00:00 2001
From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com>
Date: Fri, 12 Jun 2026 12:06:09 -0500
Subject: [PATCH] feat(core): Adds `@defer` retry for failed dependency loads
Introduces support for retrying failed `@defer` block dependency loads via a new `@error` (retry N) parameter. This feature allows applications to automatically recover from transient network failures or intermittent issues by re-attempting the chunk download up to N times. Each retry attempt automatically applies cache-busting to bypass browser caching of failed dynamic imports.
A new `provideDeferBlockRetryHandler` public API enables customization of the retry mechanism. This hook empowers advanced use cases such as exponential backoff.
Compiler changes wrap dynamic imports in "thunks," allowing the `DeferBlockRetryHandler` to access the original import function for re-invocation and URL inspection.
Closes #52800
---
adev/src/content/guide/templates/defer.md | 60 +++++
goldens/public-api/core/index.api.md | 12 +
.../GOLDEN_PARTIAL.js | 77 ++++--
.../r3_view_compiler_deferred/TEST_CASES.json | 15 ++
.../defer_default_deps_template.js | 4 +-
.../defer_deps_template.js | 4 +-
...ed_with_duplicate_external_dep_template.js | 8 +-
.../deferred_with_error_retry.ts | 18 ++
...rred_with_error_retry_isolated.golden.d.ts | 6 +
.../deferred_with_error_retry_template.js | 38 +++
.../deferred_with_external_deps_template.js | 4 +-
.../compiler-cli/test/ngtsc/defer_spec.ts | 46 ++--
.../test/ngtsc/local_compilation_spec.ts | 24 +-
.../test/ngtsc/selectorless_spec.ts | 6 +-
packages/compiler/src/render3/r3_ast.ts | 1 +
.../src/render3/r3_class_metadata_compiler.ts | 8 +-
.../src/render3/r3_deferred_blocks.ts | 39 ++-
.../compiler/src/render3/r3_identifiers.ts | 4 +
.../compiler/src/render3/view/compiler.ts | 10 +-
.../template/pipeline/ir/src/ops/create.ts | 7 +
.../src/template/pipeline/src/ingest.ts | 1 +
.../src/template/pipeline/src/instruction.ts | 3 +
.../src/template/pipeline/src/phases/reify.ts | 1 +
.../render3/r3_template_transform_spec.ts | 36 ++-
packages/core/src/core.ts | 5 +
.../core/src/core_render3_private_export.ts | 1 +
packages/core/src/defer/instructions.ts | 9 +-
packages/core/src/defer/interfaces.ts | 15 +-
packages/core/src/defer/rendering.ts | 11 +
packages/core/src/defer/retry_handler.ts | 170 ++++++++++++
packages/core/src/defer/triggering.ts | 200 +++++++++------
packages/core/src/render3/index.ts | 1 +
packages/core/src/render3/jit/environment.ts | 1 +
packages/core/src/render3/jit/partial.ts | 2 +-
packages/core/src/render3/metadata.ts | 4 +-
packages/core/test/acceptance/defer_spec.ts | 241 ++++++++++++++++++
.../bundling/defer/bundle.golden_symbols.json | 8 +
.../hydration/bundle.golden_symbols.json | 8 +
packages/core/test/test_bed_spec.ts | 8 +-
.../language-service/src/document_symbols.ts | 4 +-
.../language-service/test/diagnostic_spec.ts | 19 ++
.../test/incremental_hydration_spec.ts | 154 +++++++++++
42 files changed, 1140 insertions(+), 153 deletions(-)
create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry.ts
create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry_isolated.golden.d.ts
create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry_template.js
create mode 100644 packages/core/src/defer/retry_handler.ts
diff --git a/adev/src/content/guide/templates/defer.md b/adev/src/content/guide/templates/defer.md
index 3e37dcb0cffb..805c24cd927c 100644
--- a/adev/src/content/guide/templates/defer.md
+++ b/adev/src/content/guide/templates/defer.md
@@ -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 {
+
+} @error (retry 3) {
+
We couldn't load this section. Please refresh.
+}
+```
+
+`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.
diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md
index 271578483f5c..e8859ae224d1 100644
--- a/goldens/public-api/core/index.api.md
+++ b/goldens/public-api/core/index.api.md
@@ -582,6 +582,15 @@ export class DefaultIterableDiffer implements IterableDiffer, IterableChan
onDestroy(): void;
}
+// @public
+export interface DeferBlockRetryContext {
+ readonly attempt: number;
+ retry(): Promise;
+}
+
+// @public
+export type DeferBlockRetryHandler = (load: () => Promise, context: DeferBlockRetryContext) => Promise;
+
// @public
export interface DestroyableInjector extends Injector {
// (undocumented)
@@ -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;
diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/GOLDEN_PARTIAL.js
index 45339e471448..f5282cf83d6b 100644
--- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/GOLDEN_PARTIAL.js
+++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/GOLDEN_PARTIAL.js
@@ -395,10 +395,10 @@ export class MyApp {
}
- `, 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: [{
@@ -717,10 +717,10 @@ export class MyApp {
@defer (on idle) {
}
- `, 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: [{
@@ -1039,10 +1039,10 @@ export class TestCmp {
}
-`, 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: [{
@@ -1116,10 +1116,10 @@ export class TestCmp {
}
-`, 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: [{
@@ -1278,13 +1278,13 @@ export class MyApp {
@defer {
}
- `, 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: [{
@@ -1362,10 +1362,10 @@ export class TestCmp {
@defer {
}
- `, 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: [{
@@ -1547,3 +1547,50 @@ export declare class MyApp {
static ɵcmp: i0.ɵɵComponentDeclaration;
}
+/****************************************************************************************************
+ * 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: `
+
+ @defer (when isVisible) {
+
Loaded!
+ } @placeholder {
+
Placeholder
+ } @error (retry 3) {
+
Failed!
+ }
+
+ `, isInline: true });
+}
+i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
+ type: Component,
+ args: [{
+ template: `
+
+ @defer (when isVisible) {
+
Loaded!
+ } @placeholder {
+
Placeholder
+ } @error (retry 3) {
+
Failed!
+ }
+
+ `
+ }]
+ }] });
+
+/****************************************************************************************************
+ * PARTIAL FILE: deferred_with_error_retry.d.ts
+ ****************************************************************************************************/
+import * as i0 from "@angular/core";
+export declare class MyApp {
+ isVisible: boolean;
+ static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵcmp: i0.ɵɵComponentDeclaration;
+}
+
diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json
index 103c1f8ef92d..a50824b82200 100644
--- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json
+++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json
@@ -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"
+ }
+ ]
}
]
}
diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/defer_default_deps_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/defer_default_deps_template.js
index cd4a0bb2254d..1a5fe93a3881 100644
--- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/defer_default_deps_template.js
+++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/defer_default_deps_template.js
@@ -1,5 +1,5 @@
const $TestCmp_Defer_1_DepsFn$ = () => [
- /* @ts-ignore */
+ () => /* @ts-ignore */
import("./defer_default_deps_ext").then(m => m.default),
LocalDep
];
@@ -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, [{
diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/defer_deps_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/defer_deps_template.js
index 35166c0a4c69..0afeaf7f738e 100644
--- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/defer_deps_template.js
+++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/defer_deps_template.js
@@ -1,5 +1,5 @@
const $TestCmp_Defer_1_DepsFn$ = () => [
- /* @ts-ignore */
+ () => /* @ts-ignore */
import("./defer_deps_ext").then(m => m.CmpA),
LocalDep
];
@@ -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, [{
diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_duplicate_external_dep_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_duplicate_external_dep_template.js
index ffdfd4b04d05..d682bf6b2151 100644
--- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_duplicate_external_dep_template.js
+++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_duplicate_external_dep_template.js
@@ -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)
];
@@ -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, [{
diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry.ts
new file mode 100644
index 000000000000..5879f7435b8e
--- /dev/null
+++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry.ts
@@ -0,0 +1,18 @@
+import {Component} from '@angular/core';
+
+@Component({
+ template: `
+
+ @defer (when isVisible) {
+
Loaded!
+ } @placeholder {
+
Placeholder
+ } @error (retry 3) {
+
Failed!
+ }
+
+ `
+})
+export class MyApp {
+ isVisible = false;
+}
diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry_isolated.golden.d.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry_isolated.golden.d.ts
new file mode 100644
index 000000000000..3dd9963a61df
--- /dev/null
+++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry_isolated.golden.d.ts
@@ -0,0 +1,6 @@
+import * as i0 from "@angular/core";
+export declare class MyApp {
+ isVisible: boolean;
+ static ɵfac: i0.ɵɵFactoryDeclaration;
+ static ɵcmp: i0.ɵɵComponentDeclaration;
+}
diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry_template.js
new file mode 100644
index 000000000000..f0f33cba66d0
--- /dev/null
+++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_error_retry_template.js
@@ -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);
+ }
+}
diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_external_deps_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_external_deps_template.js
index 7d3cfe02a9d4..f1f6c20d0125 100644
--- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_external_deps_template.js
+++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_external_deps_template.js
@@ -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)
];
@@ -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, [{
diff --git a/packages/compiler-cli/test/ngtsc/defer_spec.ts b/packages/compiler-cli/test/ngtsc/defer_spec.ts
index c7fbadd1c262..35dc26137b00 100644
--- a/packages/compiler-cli/test/ngtsc/defer_spec.ts
+++ b/packages/compiler-cli/test/ngtsc/defer_spec.ts
@@ -74,7 +74,7 @@ runInEachFileSystem(() => {
expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)');
expect(cleanNewLines(jsContents)).toContain(
- '() => [/* @ts-ignore */ import("./cmp-a").then(m => m.CmpA), LocalDep]',
+ '() => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.CmpA), LocalDep]',
);
// The `CmpA` symbol wasn't referenced elsewhere, so it can be defer-loaded
@@ -364,7 +364,7 @@ runInEachFileSystem(() => {
// Both `CmpA` and `CmpB` were used inside the defer block and were not
// referenced elsewhere, so we generate dynamic imports and drop a regular one.
expect(cleanNewLines(jsContents)).toContain(
- '() => [/* @ts-ignore */ import("./cmp-a").then(m => m.CmpA), /* @ts-ignore */ import("./cmp-a").then(m => m.CmpB)]',
+ '() => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.CmpA), () => /* @ts-ignore */ import("./cmp-a").then(m => m.CmpB)]',
);
expect(jsContents).not.toContain('import { CmpA, CmpB }');
});
@@ -408,7 +408,7 @@ runInEachFileSystem(() => {
expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)');
expect(cleanNewLines(jsContents)).toContain(
- '() => [/* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)]',
+ '() => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)]',
);
// The `CmpA` symbol wasn't referenced elsewhere, so it can be defer-loaded
@@ -459,7 +459,7 @@ runInEachFileSystem(() => {
expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)');
expect(cleanNewLines(jsContents)).toContain(
- '() => [/* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)]',
+ '() => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)]',
);
expect(jsContents).not.toContain('import { CmpA }');
});
@@ -508,7 +508,7 @@ runInEachFileSystem(() => {
expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)');
expect(cleanNewLines(jsContents)).toContain(
- '() => [/* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)]',
+ '() => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)]',
);
expect(jsContents).not.toContain('import { CmpA }');
});
@@ -557,7 +557,7 @@ runInEachFileSystem(() => {
expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)');
expect(cleanNewLines(jsContents)).toContain(
- '() => [/* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)]',
+ '() => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)]',
);
expect(jsContents).not.toContain('import { CmpA }');
});
@@ -606,7 +606,7 @@ runInEachFileSystem(() => {
expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)');
expect(cleanNewLines(jsContents)).toContain(
- '() => [/* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)]',
+ '() => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)]',
);
expect(jsContents).not.toContain('import { CmpA }');
});
@@ -658,7 +658,7 @@ runInEachFileSystem(() => {
expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)');
expect(cleanNewLines(jsContents)).toContain(
- '() => [/* @ts-ignore */ import("./cmps").then(m => m.CmpA), /* @ts-ignore */ import("./cmps").then(m => m.CmpB)]',
+ '() => [() => /* @ts-ignore */ import("./cmps").then(m => m.CmpA), () => /* @ts-ignore */ import("./cmps").then(m => m.CmpB)]',
);
expect(jsContents).not.toContain('import { CmpA }');
expect(jsContents).not.toContain('import { CmpB }');
@@ -704,10 +704,10 @@ runInEachFileSystem(() => {
expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)');
expect(cleanNewLines(jsContents)).toContain(
- 'const TestCmp_Defer_1_DepsFn = () => [/* @ts-ignore */ import("./cmp-a").then(m => m.default), LocalDep];',
+ 'const TestCmp_Defer_1_DepsFn = () => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.default), LocalDep];',
);
expect(cleanNewLines(jsContents)).toContain(
- 'i0.ɵsetClassMetadataAsync(TestCmp, () => [/* @ts-ignore */ import("./cmp-a").then(m => m.default)]',
+ 'i0.ɵsetClassMetadataAsync(TestCmp, () => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.default)]',
);
// The `CmpA` symbol wasn't referenced elsewhere, so it can be defer-loaded
// via dynamic imports and an original import can be removed.
@@ -767,7 +767,7 @@ runInEachFileSystem(() => {
const jsContents = env.getContents('test.js');
expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)');
expect(cleanNewLines(jsContents)).toContain(
- '() => [/* @ts-ignore */ import("./cmp").then(m => m.Cmp)]',
+ '() => [() => /* @ts-ignore */ import("./cmp").then(m => m.Cmp)]',
);
expect(jsContents).not.toContain('import { Cmp }');
});
@@ -1014,12 +1014,12 @@ runInEachFileSystem(() => {
// are located in a single function (since we can't detect in
// the local mode which components belong to which block).
expect(cleanNewLines(jsContents)).toContain(
- 'const AppCmp_For_1_Conditional_0_Defer_1_DepsFn = () => [/* @ts-ignore */ ' +
- 'import("./deferred-a").then(m => m.DeferredCmpA), /* @ts-ignore */ ' +
+ 'const AppCmp_For_1_Conditional_0_Defer_1_DepsFn = () => [() => /* @ts-ignore */ ' +
+ 'import("./deferred-a").then(m => m.DeferredCmpA), () => /* @ts-ignore */ ' +
'import("./pipe-a").then(m => m.PipeA)];',
);
expect(cleanNewLines(jsContents)).toContain(
- 'const AppCmp_For_1_Conditional_0_Defer_4_DepsFn = () => [/* @ts-ignore */ ' +
+ 'const AppCmp_For_1_Conditional_0_Defer_4_DepsFn = () => [() => /* @ts-ignore */ ' +
'import("./deferred-b").then(m => m.DeferredCmpB)];',
);
@@ -1034,9 +1034,9 @@ runInEachFileSystem(() => {
// Expect `ɵsetClassMetadataAsync` to contain dynamic imports too.
expect(cleanNewLines(jsContents)).toContain(
- 'ɵsetClassMetadataAsync(AppCmp, () => [/* @ts-ignore */ ' +
- 'import("./deferred-a").then(m => m.DeferredCmpA), /* @ts-ignore */ ' +
- 'import("./pipe-a").then(m => m.PipeA), /* @ts-ignore */ ' +
+ 'ɵsetClassMetadataAsync(AppCmp, () => [() => /* @ts-ignore */ ' +
+ 'import("./deferred-a").then(m => m.DeferredCmpA), () => /* @ts-ignore */ ' +
+ 'import("./pipe-a").then(m => m.PipeA), () => /* @ts-ignore */ ' +
'import("./deferred-b").then(m => m.DeferredCmpB)], ' +
'(DeferredCmpA, PipeA, DeferredCmpB) => {',
);
@@ -1119,11 +1119,11 @@ runInEachFileSystem(() => {
// Expect that all deferrableImports to become dynamic imports.
// Other imported symbols remain eager.
expect(cleanNewLines(jsContents)).toContain(
- 'const AppCmp_Defer_1_DepsFn = () => [/* @ts-ignore */ ' +
+ 'const AppCmp_Defer_1_DepsFn = () => [() => /* @ts-ignore */ ' +
'import("./deferred-a").then(m => m.DeferredCmpA), EagerCmpA];',
);
expect(cleanNewLines(jsContents)).toContain(
- 'const AppCmp_Defer_4_DepsFn = () => [/* @ts-ignore */ ' +
+ 'const AppCmp_Defer_4_DepsFn = () => [() => /* @ts-ignore */ ' +
'import("./deferred-b").then(m => m.DeferredCmpB), EagerCmpA];',
);
@@ -1140,8 +1140,8 @@ runInEachFileSystem(() => {
// Expect `ɵsetClassMetadataAsync` to contain dynamic imports too.
expect(cleanNewLines(jsContents)).toContain(
- 'ɵsetClassMetadataAsync(AppCmp, () => [/* @ts-ignore */ ' +
- 'import("./deferred-a").then(m => m.DeferredCmpA), /* @ts-ignore */ ' +
+ 'ɵsetClassMetadataAsync(AppCmp, () => [() => /* @ts-ignore */ ' +
+ 'import("./deferred-a").then(m => m.DeferredCmpA), () => /* @ts-ignore */ ' +
'import("./deferred-b").then(m => m.DeferredCmpB)], ' +
'(DeferredCmpA, DeferredCmpB) => {',
);
@@ -1475,7 +1475,7 @@ runInEachFileSystem(() => {
expect(cleanNewLines(jsContents)).toContain(
'(() => { (typeof ngDevMode === "undefined" || ngDevMode) && ' +
'i0.ɵsetClassMetadataAsync(TestCmp, ' +
- '() => [/* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)], ' +
+ '() => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.CmpA)], ' +
'CmpA => { i0.ɵsetClassMetadata(TestCmp',
);
});
@@ -1589,7 +1589,7 @@ runInEachFileSystem(() => {
'i0.ɵsetClassMetadataAsync(TestCmp, ' +
// Dependency loading function (note: no local `LocalDep` here)
// Callback that invokes `setClassMetadata` at the end
- '() => [/* @ts-ignore */ import("./cmp-a").then(m => m.default)], ' +
+ '() => [() => /* @ts-ignore */ import("./cmp-a").then(m => m.default)], ' +
'CmpA => { i0.ɵsetClassMetadata(TestCmp',
);
});
diff --git a/packages/compiler-cli/test/ngtsc/local_compilation_spec.ts b/packages/compiler-cli/test/ngtsc/local_compilation_spec.ts
index 86ec8e95ff16..f87fa0b0faa1 100644
--- a/packages/compiler-cli/test/ngtsc/local_compilation_spec.ts
+++ b/packages/compiler-cli/test/ngtsc/local_compilation_spec.ts
@@ -2218,8 +2218,8 @@ runInEachFileSystem(() => {
// are located in a single function (since we can't detect in
// the local mode which components belong to which block).
expect(cleanNewLines(jsContents)).toContain(
- 'const AppCmp_DeferFn = () => [/* @ts-ignore */ ' +
- 'import("./deferred-a").then(m => m.DeferredCmpA), /* @ts-ignore */ ' +
+ 'const AppCmp_DeferFn = () => [() => /* @ts-ignore */ ' +
+ 'import("./deferred-a").then(m => m.DeferredCmpA), () => /* @ts-ignore */ ' +
'import("./deferred-b").then(m => m.DeferredCmpB)];',
);
@@ -2233,8 +2233,8 @@ runInEachFileSystem(() => {
// Expect `ɵsetClassMetadataAsync` to contain dynamic imports too.
expect(cleanNewLines(jsContents)).toContain(
- 'ɵsetClassMetadataAsync(AppCmp, () => [/* @ts-ignore */ ' +
- 'import("./deferred-a").then(m => m.DeferredCmpA), /* @ts-ignore */ ' +
+ 'ɵsetClassMetadataAsync(AppCmp, () => [() => /* @ts-ignore */ ' +
+ 'import("./deferred-a").then(m => m.DeferredCmpA), () => /* @ts-ignore */ ' +
'import("./deferred-b").then(m => m.DeferredCmpB)], ' +
'(DeferredCmpA, DeferredCmpB) => {',
);
@@ -2365,8 +2365,8 @@ runInEachFileSystem(() => {
// the local mode which components belong to which block).
// Eager dependencies are **not* included here.
expect(cleanNewLines(jsContents)).toContain(
- 'const AppCmp_DeferFn = () => [/* @ts-ignore */ ' +
- 'import("./deferred-a").then(m => m.DeferredCmpA), /* @ts-ignore */ ' +
+ 'const AppCmp_DeferFn = () => [() => /* @ts-ignore */ ' +
+ 'import("./deferred-a").then(m => m.DeferredCmpA), () => /* @ts-ignore */ ' +
'import("./deferred-b").then(m => m.DeferredCmpB)];',
);
@@ -2383,8 +2383,8 @@ runInEachFileSystem(() => {
// Expect `ɵsetClassMetadataAsync` to contain dynamic imports too.
expect(cleanNewLines(jsContents)).toContain(
- 'ɵsetClassMetadataAsync(AppCmp, () => [/* @ts-ignore */ ' +
- 'import("./deferred-a").then(m => m.DeferredCmpA), /* @ts-ignore */ ' +
+ 'ɵsetClassMetadataAsync(AppCmp, () => [() => /* @ts-ignore */ ' +
+ 'import("./deferred-a").then(m => m.DeferredCmpA), () => /* @ts-ignore */ ' +
'import("./deferred-b").then(m => m.DeferredCmpB)], ' +
'(DeferredCmpA, DeferredCmpB) => {',
);
@@ -2453,11 +2453,11 @@ runInEachFileSystem(() => {
// Expect that we generate 2 different defer functions
// (one for each component).
expect(cleanNewLines(jsContents)).toContain(
- 'const AppCmpA_DeferFn = () => [/* @ts-ignore */ ' +
+ 'const AppCmpA_DeferFn = () => [() => /* @ts-ignore */ ' +
'import("./deferred-deps").then(m => m.DeferredCmpA)]',
);
expect(cleanNewLines(jsContents)).toContain(
- 'const AppCmpB_DeferFn = () => [/* @ts-ignore */ ' +
+ 'const AppCmpB_DeferFn = () => [() => /* @ts-ignore */ ' +
'import("./deferred-deps").then(m => m.DeferredCmpB)]',
);
@@ -2470,11 +2470,11 @@ runInEachFileSystem(() => {
// Expect `ɵsetClassMetadataAsync` to contain dynamic imports too.
expect(cleanNewLines(jsContents)).toContain(
- 'ɵsetClassMetadataAsync(AppCmpA, () => [/* @ts-ignore */ ' +
+ 'ɵsetClassMetadataAsync(AppCmpA, () => [() => /* @ts-ignore */ ' +
'import("./deferred-deps").then(m => m.DeferredCmpA)]',
);
expect(cleanNewLines(jsContents)).toContain(
- 'ɵsetClassMetadataAsync(AppCmpB, () => [/* @ts-ignore */ ' +
+ 'ɵsetClassMetadataAsync(AppCmpB, () => [() => /* @ts-ignore */ ' +
'import("./deferred-deps").then(m => m.DeferredCmpB)]',
);
},
diff --git a/packages/compiler-cli/test/ngtsc/selectorless_spec.ts b/packages/compiler-cli/test/ngtsc/selectorless_spec.ts
index 89b7dd9b0676..942723c3f0be 100644
--- a/packages/compiler-cli/test/ngtsc/selectorless_spec.ts
+++ b/packages/compiler-cli/test/ngtsc/selectorless_spec.ts
@@ -1117,9 +1117,9 @@ runInEachFileSystem(() => {
expect(jsContents).not.toContain('import { DepDir');
expect(jsContents).not.toContain('import { DepPipe');
expect(cleanNewLines(jsContents)).toContain(
- 'const Comp_Defer_1_DepsFn = () => [/* @ts-ignore */ import("./dep-comp").then(m => m.DepComp), ' +
- '/* @ts-ignore */ import("./dep-dir").then(m => m.DepDir), ' +
- '/* @ts-ignore */ import("./dep-pipe").then(m => m.DepPipe)];',
+ 'const Comp_Defer_1_DepsFn = () => [() => /* @ts-ignore */ import("./dep-comp").then(m => m.DepComp), ' +
+ '() => /* @ts-ignore */ import("./dep-dir").then(m => m.DepDir), ' +
+ '() => /* @ts-ignore */ import("./dep-pipe").then(m => m.DepPipe)];',
);
expect(jsContents).toContain('ɵɵdefer(1, 0, Comp_Defer_1_DepsFn);');
});
diff --git a/packages/compiler/src/render3/r3_ast.ts b/packages/compiler/src/render3/r3_ast.ts
index 74fc2efecf9b..bd829adc46e0 100644
--- a/packages/compiler/src/render3/r3_ast.ts
+++ b/packages/compiler/src/render3/r3_ast.ts
@@ -325,6 +325,7 @@ export class DeferredBlockLoading extends BlockNode implements Node {
export class DeferredBlockError extends BlockNode implements Node {
constructor(
public children: Node[],
+ public retryCount: number | null,
nameSpan: ParseSourceSpan,
sourceSpan: ParseSourceSpan,
startSourceSpan: ParseSourceSpan,
diff --git a/packages/compiler/src/render3/r3_class_metadata_compiler.ts b/packages/compiler/src/render3/r3_class_metadata_compiler.ts
index 07632fb2e4a8..d95d4612f94a 100644
--- a/packages/compiler/src/render3/r3_class_metadata_compiler.ts
+++ b/packages/compiler/src/render3/r3_class_metadata_compiler.ts
@@ -136,6 +136,9 @@ function internalCompileSetClassMetadataAsync(
/**
* Compiles the function that loads the dependencies for the
* entire component in `setClassMetadataAsync`.
+ *
+ * Each dynamic import is wrapped in an outer arrow function (a "thunk") to
+ * match the resolver shape used by `@defer` runtime dependency loading.
*/
export function compileComponentMetadataAsyncResolver(
dependencies: R3DeferPerComponentDependency[],
@@ -150,13 +153,16 @@ export function compileComponentMetadataAsyncResolver(
);
// e.g. `import('./cmp-a').then(...)`
- return new o.DynamicImportExpr(importPath)
+ const importThen = new o.DynamicImportExpr(importPath)
.prop('then')
.callFn([innerFn], undefined, undefined, [
// Necessary, because we might not generate extensions for the path
// and TS may try to enforce it based on the compiler options.
tsIgnoreComment(),
]);
+
+ // Wrap in an outer thunk: `() => import('./cmp-a').then(m => m.CmpA)`.
+ return o.arrowFn([], importThen);
});
// e.g. `() => [ ... ];`
diff --git a/packages/compiler/src/render3/r3_deferred_blocks.ts b/packages/compiler/src/render3/r3_deferred_blocks.ts
index b66b7545c55f..0b044c5cd8c8 100644
--- a/packages/compiler/src/render3/r3_deferred_blocks.ts
+++ b/packages/compiler/src/render3/r3_deferred_blocks.ts
@@ -46,6 +46,9 @@ const WHEN_PARAMETER_PATTERN = /^when\s/;
/** Pattern to identify a `on` parameter in a block. */
const ON_PARAMETER_PATTERN = /^on\s/;
+/** Pattern to identify a `retry` parameter in a block. */
+const RETRY_PARAMETER_PATTERN = /^retry\s/;
+
/**
* Predicate function that determines if a block with
* a specific name cam be connected to a `defer` block.
@@ -249,12 +252,33 @@ function parseLoadingBlock(ast: html.Block, visitor: html.Visitor): t.DeferredBl
}
function parseErrorBlock(ast: html.Block, visitor: html.Visitor): t.DeferredBlockError {
- if (ast.parameters.length > 0) {
- throw new Error(`@error block cannot have parameters`);
+ let retryCount: number | null = null;
+
+ for (const param of ast.parameters) {
+ if (RETRY_PARAMETER_PATTERN.test(param.expression)) {
+ if (retryCount != null) {
+ throw new Error(`@error block can only have one "retry" parameter`);
+ }
+
+ const rawValue = param.expression.slice(getTriggerParametersStart(param.expression)).trim();
+ const parsed = parseRetryCount(rawValue);
+
+ if (parsed === null) {
+ throw new Error(
+ `Could not parse value of "retry" parameter. ` +
+ `Expected a non-negative integer, got: "${rawValue}".`,
+ );
+ }
+
+ retryCount = parsed;
+ } else {
+ throw new Error(`Unrecognized parameter in @error block: "${param.expression}"`);
+ }
}
return new t.DeferredBlockError(
html.visitAll(visitor, ast.children, ast.children),
+ retryCount,
ast.nameSpan,
ast.sourceSpan,
ast.startSourceSpan,
@@ -263,6 +287,17 @@ function parseErrorBlock(ast: html.Block, visitor: html.Visitor): t.DeferredBloc
);
}
+function parseRetryCount(value: string): number | null {
+ if (!/^\d+$/.test(value)) {
+ return null;
+ }
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
+ return null;
+ }
+ return parsed;
+}
+
function parsePrimaryTriggers(
ast: html.Block,
bindingParser: BindingParser,
diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts
index a0deaebb6be0..031724f9431b 100644
--- a/packages/compiler/src/render3/r3_identifiers.ts
+++ b/packages/compiler/src/render3/r3_identifiers.ts
@@ -191,6 +191,10 @@ export class Identifiers {
name: 'ɵɵdeferEnableTimerScheduling',
moduleName: CORE,
};
+ static deferEnableRetry: o.ExternalReference = {
+ name: 'ɵɵdeferEnableRetry',
+ moduleName: CORE,
+ };
static enableIncrementalHydrationRuntime: o.ExternalReference = {
name: 'ɵɵenableIncrementalHydrationRuntime',
moduleName: CORE,
diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts
index 9959c84773d4..196a21f1401e 100644
--- a/packages/compiler/src/render3/view/compiler.ts
+++ b/packages/compiler/src/render3/view/compiler.ts
@@ -768,7 +768,10 @@ export function compileDeferResolverFunction(
// and TS may try to enforce it based on the compiler options.
tsIgnoreComment(),
]);
- depExpressions.push(importExpr);
+ // Wrap in a thunk so the runtime/`DeferBlockRetryHandler` can
+ // re-issue the import (e.g. with a cache-busting query parameter when
+ // retrying a failed chunk download).
+ depExpressions.push(o.arrowFn([], importExpr));
} else {
// Non-deferrable symbol, just use a reference to the type. Note that it's important to
// go through `typeReference`, rather than `symbolName` in order to preserve the
@@ -792,7 +795,10 @@ export function compileDeferResolverFunction(
// and TS may try to enforce it based on the compiler options.
tsIgnoreComment(),
]);
- depExpressions.push(importExpr);
+ // Wrap in a thunk so the runtime/`DeferBlockRetryHandler` can
+ // re-issue the import (e.g. with a cache-busting query parameter when
+ // retrying a failed chunk download).
+ depExpressions.push(o.arrowFn([], importExpr));
}
}
diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts
index 3e9bf39c5256..bc2288558684 100644
--- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts
+++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts
@@ -1409,6 +1409,12 @@ export interface DeferOp extends Op, ConsumesSlotOpTrait {
loadingMinimumTime: number | null;
loadingAfterTime: number | null;
+ /**
+ * Number of retry attempts requested via `@error (retry N)`. `null` when
+ * no `retry` parameter was provided.
+ */
+ errorRetryCount: number | null;
+
placeholderConfig: o.Expression | null;
loadingConfig: o.Expression | null;
@@ -1460,6 +1466,7 @@ export function createDeferOp(
placeholderMinimumTime: null,
errorView: null,
errorSlot: null,
+ errorRetryCount: null,
ownResolverFn,
resolverFn,
flags: null,
diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts
index 1f31d505d82e..94de6fc114a2 100644
--- a/packages/compiler/src/template/pipeline/src/ingest.ts
+++ b/packages/compiler/src/template/pipeline/src/ingest.ts
@@ -783,6 +783,7 @@ function ingestDeferBlock(unit: ViewCompilationUnit, deferBlock: t.DeferredBlock
deferOp.placeholderSlot = placeholder?.handle ?? null;
deferOp.loadingSlot = loading?.handle ?? null;
deferOp.errorSlot = error?.handle ?? null;
+ deferOp.errorRetryCount = deferBlock.error?.retryCount ?? null;
deferOp.placeholderMinimumTime = deferBlock.placeholder?.minimumTime ?? null;
deferOp.loadingMinimumTime = deferBlock.loading?.minimumTime ?? null;
deferOp.loadingAfterTime = deferBlock.loading?.afterTime ?? null;
diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts
index 96b42703b7dd..c0b737f2125b 100644
--- a/packages/compiler/src/template/pipeline/src/instruction.ts
+++ b/packages/compiler/src/template/pipeline/src/instruction.ts
@@ -301,6 +301,7 @@ export function defer(
enableTimerScheduling: boolean,
sourceSpan: ParseSourceSpan | null,
flags: ir.TDeferDetailsFlags | null,
+ errorRetryCount: number | null,
): ir.CreateOp {
const args: Array = [
o.literal(selfSlot),
@@ -313,6 +314,8 @@ export function defer(
placeholderConfig ?? o.literal(null),
enableTimerScheduling ? o.importExpr(Identifiers.deferEnableTimerScheduling) : o.literal(null),
o.literal(flags),
+ errorRetryCount !== null ? o.importExpr(Identifiers.deferEnableRetry) : o.literal(null),
+ o.literal(errorRetryCount),
];
let expr: o.Expression;
diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts
index 2f340985cb30..e6245a8afd91 100644
--- a/packages/compiler/src/template/pipeline/src/phases/reify.ts
+++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts
@@ -398,6 +398,7 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList {
}
visitDeferredBlockError(block: t.DeferredBlockError): void {
- this.result.push(['DeferredBlockError']);
+ const result = ['DeferredBlockError'];
+ block.retryCount !== null && result.push(`retry ${block.retryCount}`);
+ this.result.push(result);
this.visitAll([block.children]);
}
@@ -1196,6 +1198,16 @@ describe('R3 template transform', () => {
]);
});
+ it('should parse an error block with a retry parameter', () => {
+ expectFromHtml('@defer{}@error (retry 3){Failed}').toEqual([
+ ['DeferredBlock'],
+ ['Element', 'calendar-cmp', '#selfClosing'],
+ ['BoundAttribute', 0, 'date', 'current'],
+ ['DeferredBlockError', 'retry 3'],
+ ['Text', 'Failed'],
+ ]);
+ });
+
it('should parse a placeholder block with parameters', () => {
expectFromHtml(
'@defer {}' + '@placeholder (minimum 1.5s){Placeholder...}',
@@ -1466,9 +1478,27 @@ describe('R3 template transform', () => {
);
});
- it('should report any parameter usage in error block', () => {
+ it('should report any unknown parameter usage in error block', () => {
expect(() => parse('@defer {hello} @error (foo) {hi}')).toThrowError(
- /@error block cannot have parameters/,
+ /Unrecognized parameter in @error block: "foo"/,
+ );
+ });
+
+ it('should report if retry value in error block cannot be parsed', () => {
+ expect(() => parse('@defer {hello} @error (retry abc) {hi}')).toThrowError(
+ /Could not parse value of "retry" parameter\. Expected a non-negative integer/,
+ );
+ });
+
+ it('should report negative retry value in error block', () => {
+ expect(() => parse('@defer {hello} @error (retry -1) {hi}')).toThrowError(
+ /Could not parse value of "retry" parameter\. Expected a non-negative integer/,
+ );
+ });
+
+ it('should report multiple retry parameters in error block', () => {
+ expect(() => parse('@defer {hello} @error (retry 2; retry 3) {hi}')).toThrowError(
+ /@error block can only have one "retry" parameter/,
);
});
diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts
index 00fd83f14849..0d8d115d73f4 100644
--- a/packages/core/src/core.ts
+++ b/packages/core/src/core.ts
@@ -65,6 +65,11 @@ export {
Predicate,
} from './debug/debug_node';
export {IdleService, provideIdleServiceWith} from './defer/idle_service';
+export {
+ DeferBlockRetryHandler,
+ DeferBlockRetryContext,
+ provideDeferBlockRetryHandler,
+} from './defer/retry_handler';
export * from './di';
export {DOCUMENT} from './document';
export {ErrorHandler, provideBrowserGlobalErrorListeners} from './error_handler';
diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts
index 8b9f8f15c435..6c0133453fa6 100644
--- a/packages/core/src/core_render3_private_export.ts
+++ b/packages/core/src/core_render3_private_export.ts
@@ -106,6 +106,7 @@ export {
ɵɵdeclareLet,
ɵɵdefer,
ɵɵdeferEnableTimerScheduling,
+ ɵɵdeferEnableRetry,
ɵɵdeferHydrateNever,
ɵɵdeferHydrateOnHover,
ɵɵdeferHydrateOnIdle,
diff --git a/packages/core/src/defer/instructions.ts b/packages/core/src/defer/instructions.ts
index 727224f62951..265a53746e4e 100644
--- a/packages/core/src/defer/instructions.ts
+++ b/packages/core/src/defer/instructions.ts
@@ -57,7 +57,7 @@ import {
assertSsrIdDefined,
isIncrementalHydrationEnabled,
} from '../hydration/utils';
-import {ɵɵdeferEnableTimerScheduling, renderPlaceholder} from './rendering';
+import {ɵɵdeferEnableTimerScheduling, ɵɵdeferEnableRetry, renderPlaceholder} from './rendering';
import {
getHydrateTriggers,
@@ -133,6 +133,8 @@ export function ɵɵdefer(
placeholderConfigIndex?: number | null,
enableTimerScheduling?: typeof ɵɵdeferEnableTimerScheduling | null,
flags?: TDeferDetailsFlags | null,
+ enableRetry?: typeof ɵɵdeferEnableRetry | null,
+ retryCount?: number | null,
) {
const lView = getLView();
const tView = getTView();
@@ -167,8 +169,12 @@ export function ɵɵdefer(
hydrateTriggers: null,
debug: null,
flags: flags ?? TDeferDetailsFlags.Default,
+ maxRetryCount: null,
};
enableTimerScheduling?.(tView, tDetails, placeholderConfigIndex, loadingConfigIndex);
+ if (enableRetry != null && retryCount != null) {
+ enableRetry(tDetails, retryCount);
+ }
setTDeferBlockDetails(tView, adjustedIndex, tDetails);
}
@@ -199,6 +205,7 @@ export function ɵɵdefer(
ssrBlockState, // SSR_BLOCK_STATE
null, // ON_COMPLETE_FNS
null, // HYDRATE_TRIGGER_CLEANUP_FNS
+ retryCount ?? null, // RETRY_ATTEMPTS_REMAINING
];
setLDeferBlockDetails(lView, adjustedIndex, lDetails);
diff --git a/packages/core/src/defer/interfaces.ts b/packages/core/src/defer/interfaces.ts
index 441026476d07..4f6bd11cc577 100644
--- a/packages/core/src/defer/interfaces.ts
+++ b/packages/core/src/defer/interfaces.ts
@@ -26,7 +26,7 @@ export interface DehydratedDeferBlock {
* Describes the shape of a function generated by the compiler
* to download dependencies that can be defer-loaded.
*/
-export type DependencyResolverFn = () => Array | DependencyType>;
+export type DependencyResolverFn = () => Array<(() => Promise) | DependencyType>;
/**
* Defines types of defer block triggers.
@@ -147,6 +147,12 @@ export interface TDeferBlockDetails {
*/
flags: TDeferDetailsFlags;
+ /**
+ * Maximum number of times the dependency loading should be retried after a
+ * failure. Configured via `@error (retry N)`. `null` when retry is disabled.
+ */
+ maxRetryCount: number | null;
+
/**
* Tracks debugging information about the deferred block.
*/
@@ -243,6 +249,7 @@ export const SSR_UNIQUE_ID = 6;
export const SSR_BLOCK_STATE = 7;
export const ON_COMPLETE_FNS = 8;
export const HYDRATE_TRIGGER_CLEANUP_FNS = 9;
+export const RETRY_ATTEMPTS_REMAINING = 10;
/**
* Describes instance-specific defer block data.
@@ -304,6 +311,12 @@ export interface LDeferBlockDetails extends Array {
* List of cleanup functions for hydrate triggers.
*/
[HYDRATE_TRIGGER_CLEANUP_FNS]: VoidFunction[] | null;
+
+ /**
+ * Number of dependency-loading attempts still available for this instance,
+ * configured via `@error (retry N)`. `null` when retry is disabled.
+ */
+ [RETRY_ATTEMPTS_REMAINING]: number | null;
}
/**
diff --git a/packages/core/src/defer/rendering.ts b/packages/core/src/defer/rendering.ts
index e5fab5dcf57e..cff2ec442bde 100644
--- a/packages/core/src/defer/rendering.ts
+++ b/packages/core/src/defer/rendering.ts
@@ -504,3 +504,14 @@ export function shouldTriggerDeferBlock(triggerType: TriggerType, lView: LView):
}
return true;
}
+
+/**
+ * Enables retry support on a defer block. The compiler emits a call to this
+ * instruction only when the corresponding `@error (retry N)` parameter is
+ * present
+ *
+ * @codeGenApi
+ */
+export function ɵɵdeferEnableRetry(tDetails: TDeferBlockDetails, retryCount: number): void {
+ tDetails.maxRetryCount = retryCount;
+}
diff --git a/packages/core/src/defer/retry_handler.ts b/packages/core/src/defer/retry_handler.ts
new file mode 100644
index 000000000000..ddb611f59161
--- /dev/null
+++ b/packages/core/src/defer/retry_handler.ts
@@ -0,0 +1,170 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import {InjectionToken} from '../di';
+import type {EnvironmentProviders} from '../di/interface/provider';
+import {makeEnvironmentProviders} from '../di/provider_collection';
+
+/**
+ * Context passed to a `DeferBlockRetryHandler` for each load attempt of a
+ * `@defer` block dependency.
+ *
+ * @developerPreview 22.0
+ */
+export interface DeferBlockRetryContext {
+ /**
+ * Zero-based attempt counter. `0` is the initial attempt; subsequent
+ * values correspond to retries triggered by `@error (retry N)`.
+ */
+ readonly attempt: number;
+
+ /**
+ * Re-issues the chunk download with a cache-busting query parameter
+ * appended to the URL, returning a Promise of the same shape as `load()`.
+ *
+ * Use this from custom handlers to add features like backoff, jitter, or
+ * logging on top of Angular's built-in cache-busting.
+ *
+ * ```ts
+ * const backoff: DeferBlockRetryHandler = async (load, ctx) => {
+ * if (ctx.attempt === 0) {
+ * return load();
+ * }
+ * await new Promise((r) => setTimeout(r, 2 ** ctx.attempt * 100));
+ * return ctx.retry();
+ * };
+ * ```
+ */
+ retry(): Promise;
+}
+
+/**
+ * A function that customizes how a `@defer` block dependency is (re)loaded
+ * when `@error (retry N)` recovers from a failed dynamic `import()`.
+ *
+ * The handler is invoked for every attempt, both the initial load and any
+ * subsequent retries, receiving the compiler-generated thunk (`() =>
+ * import('./chunk').then(m => m.Cmp)`) and a {@link DeferBlockRetryContext}
+ * describing the current attempt. Use it to layer behavior like exponential
+ * backoff, telemetry, or CDN failover on top of Angular's built-in
+ * cache-busting.
+ *
+ * @developerPreview 22.0
+ */
+export type DeferBlockRetryHandler = (
+ load: () => Promise,
+ context: DeferBlockRetryContext,
+) => Promise;
+
+const DEFER_IMPORT_URL_PATTERN = /import\(\s*["']([^"']+)["']\s*\)/;
+// Captures the symbol name from the compiler-emitted `.then(m => m.)`
+// mapping. Default imports also surface here as the literal `default`, so a
+// separate default-export pattern isn't needed.
+const DEFER_EXPORT_NAME_PATTERN = /\.then\(\s*\(?\s*[\w$]+\s*\)?\s*=>\s*[\w$]+\.([\w$]+)\s*\)/;
+
+const defaultDeferBlockRetryHandler: DeferBlockRetryHandler = (
+ load: () => Promise,
+ ctx: DeferBlockRetryContext,
+): Promise => (ctx.attempt === 0 ? load() : ctx.retry());
+
+/**
+ * Cache-busting reload used by `DeferBlockRetryContext.retry()`.
+ *
+ * Extracts the chunk URL from the thunk source via
+ * `Function.prototype.toString`, appends `?ngRetry=N`, and re-issues the
+ * dynamic import, sidestepping the browser's permanent cache of failed
+ * `import()` results. Falls back to plain re-invocation when the source
+ * can't be parsed.
+ */
+export function reloadDeferDependencyWithCacheBust(
+ load: () => Promise,
+ attempt: number,
+): Promise {
+ let source: string;
+ try {
+ source = load.toString();
+ } catch {
+ return load();
+ }
+ const match = DEFER_IMPORT_URL_PATTERN.exec(source);
+ if (!match) {
+ return load();
+ }
+ const retryUrl = appendRetryQueryParam(match[1], attempt);
+
+ // Retry the failed deferred import using a runtime URL.
+ //
+ // Vite/esbuild only analyze dynamic imports with static targets, so using a
+ // variable keeps this retry import from being bundled back into the original
+ // chunk request.
+ //
+ // `@vite-ignore` must stay as its own exact comment so Vite suppresses the
+ // "dynamic import cannot be analyzed" warning.
+ return import(/* @vite-ignore */ retryUrl).then((mod) => {
+ // Preserve the original thunk behavior: `.then(m => m.)`.
+ const exportMatch = DEFER_EXPORT_NAME_PATTERN.exec(source);
+ return exportMatch ? mod[exportMatch[1]] : mod;
+ });
+}
+
+/**
+ * Appends a cache-busting query parameter to a URL-like string. Tolerant of
+ * relative paths, existing query strings, and fragments.
+ */
+function appendRetryQueryParam(url: string, attempt: number): string {
+ return `${url}?ngRetry=${attempt}`;
+}
+
+/**
+ * Apps customize the handler via {@link provideDeferBlockRetryHandler}
+ * @internal
+ */
+export const DEFER_BLOCK_RETRY_HANDLER = new InjectionToken(
+ typeof ngDevMode !== 'undefined' && ngDevMode ? 'DEFER_BLOCK_RETRY_HANDLER' : '',
+ {
+ factory: () => defaultDeferBlockRetryHandler,
+ },
+);
+
+/**
+ * Configures Angular to use the given function as the retry handler for every
+ * `@defer` block dependency.
+ *
+ * Use this to add behavior like telemetry, exponential backoff, CDN failover,
+ * or integrity checks on top of the built-in cache-busting that powers
+ * `@error (retry N)`. The handler is invoked for every load attempt, both the
+ * initial load and any retries, and replaces Angular's default behavior.
+ *
+ * ```ts
+ * import {provideDeferBlockRetryHandler} from '@angular/core';
+ *
+ * bootstrapApplication(App, {
+ * providers: [
+ * provideDeferBlockRetryHandler(async (load, ctx) => {
+ * if (ctx.attempt === 0) return load();
+ * await new Promise((r) => setTimeout(r, 2 ** ctx.attempt * 100));
+ * return ctx.retry();
+ * }),
+ * ],
+ * });
+ * ```
+ *
+ * @developerPreview 22.0
+ *
+ * @see [Customizing retry behavior](guide/templates/defer#customizing-retry-behavior)
+ */
+export function provideDeferBlockRetryHandler(
+ handler: DeferBlockRetryHandler,
+): EnvironmentProviders {
+ return makeEnvironmentProviders([
+ {
+ provide: DEFER_BLOCK_RETRY_HANDLER,
+ useValue: handler,
+ },
+ ]);
+}
diff --git a/packages/core/src/defer/triggering.ts b/packages/core/src/defer/triggering.ts
index cd69a12e73e9..9ecb555132dc 100644
--- a/packages/core/src/defer/triggering.ts
+++ b/packages/core/src/defer/triggering.ts
@@ -48,6 +48,7 @@ import {
HydrateTriggerDetails,
LDeferBlockDetails,
ON_COMPLETE_FNS,
+ RETRY_ATTEMPTS_REMAINING,
SSR_UNIQUE_ID,
TDeferBlockDetails,
TDeferDetailsFlags,
@@ -61,6 +62,7 @@ import {
renderPlaceholder,
shouldTriggerDeferBlock,
} from './rendering';
+import {DEFER_BLOCK_RETRY_HANDLER, reloadDeferDependencyWithCacheBust} from './retry_handler';
import {onTimer} from './timer_scheduler';
import {
addDepsToRegistry,
@@ -218,91 +220,136 @@ export function triggerResourceLoading(
return tDetails.loadingPromise;
}
+ // Determine the current attempt index based on retries remaining for this
+ // instance. The first attempt is 0; subsequent attempts increment from
+ // there. When retries are disabled (`maxRetryCount == null`) this is always 0.
+ const maxRetryCount = tDetails.maxRetryCount;
+ let attempt = 0;
+ if (maxRetryCount != null) {
+ const remaining = lDetails[RETRY_ATTEMPTS_REMAINING] ?? maxRetryCount;
+ attempt = maxRetryCount - remaining;
+ }
+
+ const retryHandler = injector.get(DEFER_BLOCK_RETRY_HANDLER);
+ const dependencies = dependenciesFn().map((dep) => {
+ // compiler emit: each dynamic-import dependency is wrapped in a thunk
+ // (`() => import('./x').then(m => m.X)`) so the handler can re-issue the
+ // import (e.g. with a cache-busting query parameter on retry)
+ if (isDeferDependencyLoader(dep)) {
+ return retryHandler(dep, {
+ attempt,
+ retry: () => reloadDeferDependencyWithCacheBust(dep, attempt),
+ });
+ }
+
+ // Direct, non-deferrable type reference — pass through unchanged.
+ return dep;
+ });
+
// Start downloading of defer block dependencies.
- tDetails.loadingPromise = Promise.allSettled(dependenciesFn()).then((results) => {
- let failed = false;
- let failedReason: Error | null = null;
- const directiveDefs: DirectiveDefList = [];
- const pipeDefs: PipeDefList = [];
-
- for (let i = 0; i < results.length; i++) {
- const result = results[i];
- if (result.status === 'fulfilled') {
- const dependency = result.value;
- const directiveDef = getComponentDef(dependency) || getDirectiveDef(dependency);
- if (directiveDef) {
- directiveDefs.push(directiveDef);
- } else {
- const pipeDef = getPipeDef(dependency);
- if (pipeDef) {
- pipeDefs.push(pipeDef);
+ tDetails.loadingPromise = Promise.allSettled(dependencies).then(
+ (results): Promise | void => {
+ let failed = false;
+ let failedReason: Error | null = null;
+ const directiveDefs: DirectiveDefList = [];
+ const pipeDefs: PipeDefList = [];
+
+ for (let i = 0; i < results.length; i++) {
+ const result = results[i];
+ if (result.status === 'fulfilled') {
+ const dependency = result.value;
+ const directiveDef = getComponentDef(dependency) || getDirectiveDef(dependency);
+ if (directiveDef) {
+ directiveDefs.push(directiveDef);
+ } else {
+ const pipeDef = getPipeDef(dependency);
+ if (pipeDef) {
+ pipeDefs.push(pipeDef);
+ }
}
+ } else {
+ failed = true;
+ failedReason =
+ result.reason instanceof Error ? result.reason : new Error(String(result.reason));
+ break;
}
- } else {
- failed = true;
- failedReason =
- result.reason instanceof Error ? result.reason : new Error(String(result.reason));
- break;
}
- }
- if (failed) {
- tDetails.loadingState = DeferDependenciesLoadingState.FAILED;
+ if (failed) {
+ // If retry is configured and there are attempts remaining for this
+ // instance, reset the loading state so a subsequent triggering of this
+ // block re-invokes `dependenciesFn`. The DI-provided dependency loader
+ // is responsible for ensuring that the retried `import()` actually has
+ // a chance of succeeding (e.g. cache busting).
+ if (maxRetryCount != null) {
+ const remaining = lDetails[RETRY_ATTEMPTS_REMAINING] ?? maxRetryCount;
+ if (remaining > 0) {
+ lDetails[RETRY_ATTEMPTS_REMAINING] = remaining - 1;
+ tDetails.loadingState = DeferDependenciesLoadingState.NOT_STARTED;
+ tDetails.loadingPromise = null;
+ // Kick off another attempt. The result is awaited via the chained
+ // Promise returned to the caller below.
+ return triggerResourceLoading(tDetails, lView, tNode);
+ }
+ }
- if (tDetails.errorTmplIndex === null) {
- const templateLocation = ngDevMode ? getTemplateLocationDetails(lView) : '';
- let errorMsg = '';
+ tDetails.loadingState = DeferDependenciesLoadingState.FAILED;
- if (ngDevMode) {
- errorMsg =
- 'Loading dependencies for `@defer` block failed, ' +
- `but no \`@error\` block was configured${templateLocation}. ` +
- 'Consider using the `@error` block to render an error state.';
+ if (tDetails.errorTmplIndex === null) {
+ const templateLocation = ngDevMode ? getTemplateLocationDetails(lView) : '';
+ let errorMsg = '';
- const depsFn = tDetails.dependencyResolverFn;
- const errorReason = failedReason?.message;
+ if (ngDevMode) {
+ errorMsg =
+ 'Loading dependencies for `@defer` block failed, ' +
+ `but no \`@error\` block was configured${templateLocation}. ` +
+ 'Consider using the `@error` block to render an error state.';
- if (depsFn) {
- errorMsg +=
- `\n\nAngular tried to invoke the following dependency function (compiler-generated):\n` +
- `\`\`\`\n${depsFn.toString()}\n\`\`\``;
- }
+ const depsFn = tDetails.dependencyResolverFn;
+ const errorReason = failedReason?.message;
- if (errorReason) {
- errorMsg += depsFn
- ? `\n\nbut it resulted in the following error:\n\n${errorReason}`
- : `\n\nThe loading resulted in the following error:\n\n${errorReason}`;
- }
- }
+ if (depsFn) {
+ errorMsg +=
+ `\n\nAngular tried to invoke the following dependency function (compiler-generated):\n` +
+ `\`\`\`\n${depsFn.toString()}\n\`\`\``;
+ }
- const error = new RuntimeError(RuntimeErrorCode.DEFER_LOADING_FAILED, errorMsg);
- handleUncaughtError(lView, error);
- }
- } else {
- tDetails.loadingState = DeferDependenciesLoadingState.COMPLETE;
-
- // Update directive and pipe registries to add newly downloaded dependencies.
- const primaryBlockTView = primaryBlockTNode.tView!;
- if (directiveDefs.length > 0) {
- primaryBlockTView.directiveRegistry = addDepsToRegistry(
- primaryBlockTView.directiveRegistry,
- directiveDefs,
- );
+ if (errorReason) {
+ errorMsg += depsFn
+ ? `\n\nbut it resulted in the following error:\n\n${errorReason}`
+ : `\n\nThe loading resulted in the following error:\n\n${errorReason}`;
+ }
+ }
- // Extract providers from all NgModules imported by standalone components
- // used within this defer block.
- const directiveTypes = directiveDefs.map((def) => def.type);
- const providers = internalImportProvidersFrom(false, ...directiveTypes);
- tDetails.providers = providers;
- }
- if (pipeDefs.length > 0) {
- primaryBlockTView.pipeRegistry = addDepsToRegistry(
- primaryBlockTView.pipeRegistry,
- pipeDefs,
- );
+ const error = new RuntimeError(RuntimeErrorCode.DEFER_LOADING_FAILED, errorMsg);
+ handleUncaughtError(lView, error);
+ }
+ } else {
+ tDetails.loadingState = DeferDependenciesLoadingState.COMPLETE;
+
+ // Update directive and pipe registries to add newly downloaded dependencies.
+ const primaryBlockTView = primaryBlockTNode.tView!;
+ if (directiveDefs.length > 0) {
+ primaryBlockTView.directiveRegistry = addDepsToRegistry(
+ primaryBlockTView.directiveRegistry,
+ directiveDefs,
+ );
+
+ // Extract providers from all NgModules imported by standalone components
+ // used within this defer block.
+ const directiveTypes = directiveDefs.map((def) => def.type);
+ const providers = internalImportProvidersFrom(false, ...directiveTypes);
+ tDetails.providers = providers;
+ }
+ if (pipeDefs.length > 0) {
+ primaryBlockTView.pipeRegistry = addDepsToRegistry(
+ primaryBlockTView.pipeRegistry,
+ pipeDefs,
+ );
+ }
}
- }
- });
+ },
+ );
return tDetails.loadingPromise.finally(() => {
// Loading is completed, we no longer need the loading Promise
@@ -312,6 +359,15 @@ export function triggerResourceLoading(
});
}
+function isDeferDependencyLoader(dep: unknown): dep is () => Promise {
+ return (
+ typeof dep === 'function' &&
+ getComponentDef(dep) === null &&
+ getDirectiveDef(dep) === null &&
+ getPipeDef(dep) === null
+ );
+}
+
/**
* Attempts to trigger loading of defer block dependencies.
* If the block is already in a loading, completed or an error state -
diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts
index 73082ece833e..573694b8be80 100644
--- a/packages/core/src/render3/index.ts
+++ b/packages/core/src/render3/index.ts
@@ -173,6 +173,7 @@ export {
} from './instructions/all';
export {
ɵɵdeferEnableTimerScheduling,
+ ɵɵdeferEnableRetry,
DEFER_BLOCK_DEPENDENCY_INTERCEPTOR as ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
DEFER_BLOCK_CONFIG as ɵDEFER_BLOCK_CONFIG,
} from '../defer/rendering';
diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts
index 4441f25823cd..7e5271a018fb 100644
--- a/packages/core/src/render3/jit/environment.ts
+++ b/packages/core/src/render3/jit/environment.ts
@@ -139,6 +139,7 @@ export const angularCoreEnv: {[name: string]: unknown} = (() => ({
'ɵɵdeferHydrateOnViewport': r3.ɵɵdeferHydrateOnViewport,
'ɵɵdeferEnableTimerScheduling': r3.ɵɵdeferEnableTimerScheduling,
'ɵɵenableIncrementalHydrationRuntime': r3.ɵɵenableIncrementalHydrationRuntime,
+ 'ɵɵdeferEnableRetry': r3.ɵɵdeferEnableRetry,
'ɵɵrepeater': r3.ɵɵrepeater,
'ɵɵrepeaterCreate': r3.ɵɵrepeaterCreate,
'ɵɵrepeaterTrackByIndex': r3.ɵɵrepeaterTrackByIndex,
diff --git a/packages/core/src/render3/jit/partial.ts b/packages/core/src/render3/jit/partial.ts
index 5b18941118bc..6c0866fb0de4 100644
--- a/packages/core/src/render3/jit/partial.ts
+++ b/packages/core/src/render3/jit/partial.ts
@@ -68,7 +68,7 @@ export function ɵɵngDeclareClassMetadata(decl: {
*/
export function ɵɵngDeclareClassMetadataAsync(decl: {
type: Type;
- resolveDeferredDeps: () => Promise>[];
+ resolveDeferredDeps: () => Array<() => Promise>>;
resolveMetadata: (...types: Type[]) => {
decorators: any[];
ctorParameters: (() => any[]) | null;
diff --git a/packages/core/src/render3/metadata.ts b/packages/core/src/render3/metadata.ts
index f29fc0e6b3e8..636d1cc623d2 100644
--- a/packages/core/src/render3/metadata.ts
+++ b/packages/core/src/render3/metadata.ts
@@ -59,12 +59,12 @@ export function hasAsyncClassMetadata(type: Type): boolean {
*/
export function setClassMetadataAsync(
type: Type | AbstractType,
- dependencyLoaderFn: () => Array | AbstractType>>,
+ dependencyLoaderFn: () => Array<() => Promise> | AbstractType>,
metadataSetterFn: (...types: (Type | AbstractType)[]) => void,
): () => Promise | AbstractType>> {
const componentClass = type as any; // cast to `any`, so that we can monkey-patch it
componentClass[ASYNC_COMPONENT_METADATA_FN] = () =>
- Promise.all(dependencyLoaderFn()).then((dependencies) => {
+ Promise.all(dependencyLoaderFn().map((dep) => dep())).then((dependencies) => {
metadataSetterFn(...dependencies);
// Metadata is now set, reset field value to indicate that this component
// can by used/compiled synchronously.
diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts
index dbce77f13761..cfd92021c37a 100644
--- a/packages/core/test/acceptance/defer_spec.ts
+++ b/packages/core/test/acceptance/defer_spec.ts
@@ -38,6 +38,8 @@ import {
ViewChild,
ViewChildren,
ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
+ DeferBlockRetryHandler,
+ provideDeferBlockRetryHandler,
} from '../../src/core';
import {IDLE_SERVICE, IdleService, provideIdleServiceWith} from '../../src/defer/idle_service';
import {IdleScheduler} from '../../src/defer/idle_scheduler';
@@ -1235,6 +1237,245 @@ describe('@defer', () => {
expect(errorMsg).toContain('Failed to load module X');
});
+ it('should not invoke direct type dependencies from resolvers as loader thunks', async () => {
+ @Component({
+ selector: 'nested-cmp',
+ template: 'Loaded!',
+ changeDetection: ChangeDetectionStrategy.Eager,
+ })
+ class NestedCmp {}
+
+ @Component({
+ selector: 'simple-app',
+ imports: [NestedCmp],
+ template: `
+ @defer (when isVisible) {
+
+ } @placeholder {
+ Placeholder!
+ } @loading {
+ Loading!
+ } @error (retry 3) {
+ Failed!
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.Eager,
+ })
+ class MyCmp {
+ isVisible = false;
+ }
+
+ let invocationCount = 0;
+ const deferDepsInterceptor = {
+ intercept() {
+ return () => {
+ invocationCount++;
+ return [NestedCmp];
+ };
+ },
+ };
+
+ TestBed.configureTestingModule({
+ providers: [{provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}],
+ });
+
+ clearDirectiveDefs(MyCmp);
+
+ const fixture = TestBed.createComponent(MyCmp);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.outerHTML).toContain('Placeholder!');
+
+ fixture.componentInstance.isVisible = true;
+ fixture.detectChanges();
+
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.outerHTML).toContain('Loaded!');
+ expect(fixture.nativeElement.outerHTML).not.toContain('Failed!');
+ expect(invocationCount).toBe(1);
+ });
+
+ it('should retry dependency loading via @error (retry N) and provideDeferBlockRetryHandler', async () => {
+ @Component({
+ selector: 'nested-cmp',
+ template: 'Loaded!',
+ changeDetection: ChangeDetectionStrategy.Eager,
+ })
+ class NestedCmp {}
+
+ @Component({
+ selector: 'simple-app',
+ imports: [NestedCmp],
+ template: `
+ @defer (when isVisible) {
+
+ } @placeholder {
+ Placeholder!
+ } @error (retry 2) {
+ Failed!
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.Eager,
+ })
+ class MyCmp {
+ isVisible = false;
+ }
+
+ let invocationCount = 0;
+ const seenAttempts: number[] = [];
+ const deferDepsInterceptor = {
+ intercept() {
+ return () => [
+ () =>
+ new Promise((resolve, reject) => {
+ const attempt = invocationCount++;
+ setTimeout(() => {
+ if (attempt < 2) {
+ reject(new Error('boom #' + attempt));
+ } else {
+ resolve(NestedCmp);
+ }
+ });
+ }),
+ ];
+ },
+ };
+
+ const dependencyLoader: DeferBlockRetryHandler = (load, ctx) => {
+ seenAttempts.push(ctx.attempt);
+ return load();
+ };
+
+ TestBed.configureTestingModule({
+ rethrowApplicationErrors: false,
+ providers: [
+ {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor},
+ provideDeferBlockRetryHandler(dependencyLoader),
+ ],
+ });
+
+ const fixture = TestBed.createComponent(MyCmp);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.outerHTML).toContain('Placeholder');
+
+ fixture.componentInstance.isVisible = true;
+ fixture.detectChanges();
+
+ // Drain pending promises across all 3 attempts.
+ await allPendingDynamicImports();
+ await allPendingDynamicImports();
+ await allPendingDynamicImports();
+ fixture.detectChanges();
+
+ // After two retries the third attempt succeeds and primary content renders.
+ expect(fixture.nativeElement.outerHTML).toContain('Loaded!');
+ expect(fixture.nativeElement.outerHTML).not.toContain('Failed!');
+ expect(invocationCount).toBe(3);
+ // The handler should have been invoked with attempt indexes 0, 1, 2.
+ expect(seenAttempts).toEqual([0, 1, 2]);
+ });
+
+ it('default retry handler should load dependencies on the first attempt without any provider override', async () => {
+ @Component({
+ selector: 'nested-cmp',
+ template: 'Loaded!',
+ changeDetection: ChangeDetectionStrategy.Eager,
+ })
+ class NestedCmp {}
+
+ @Component({
+ selector: 'simple-app',
+ imports: [NestedCmp],
+ template: `
+ @defer (when isVisible) {
+
+ } @placeholder {
+ Placeholder!
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.Eager,
+ })
+ class MyCmp {
+ isVisible = false;
+ }
+
+ let invocationCount = 0;
+ const deferDepsInterceptor = {
+ intercept() {
+ return () => {
+ invocationCount++;
+ return [() => Promise.resolve(NestedCmp)];
+ };
+ },
+ };
+
+ TestBed.configureTestingModule({
+ providers: [{provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}],
+ });
+
+ const fixture = TestBed.createComponent(MyCmp);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.outerHTML).toContain('Placeholder');
+
+ fixture.componentInstance.isVisible = true;
+ fixture.detectChanges();
+
+ await allPendingDynamicImports();
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.outerHTML).toContain('Loaded!');
+ expect(invocationCount).toBe(1);
+ });
+
+ it('should fall through to the @error block once retries are exhausted', async () => {
+ @Component({
+ selector: 'simple-app',
+ template: `
+ @defer (when isVisible) {
+ Loaded!
+ } @placeholder {
+ Placeholder!
+ } @error (retry 1) {
+ Failed!
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.Eager,
+ })
+ class MyCmp {
+ isVisible = false;
+ }
+
+ let invocationCount = 0;
+ const deferDepsInterceptor = {
+ intercept() {
+ return () => {
+ invocationCount++;
+ return [failedDynamicImport()];
+ };
+ },
+ };
+
+ TestBed.configureTestingModule({
+ rethrowApplicationErrors: false,
+ providers: [{provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}],
+ });
+
+ const fixture = TestBed.createComponent(MyCmp);
+ fixture.detectChanges();
+
+ fixture.componentInstance.isVisible = true;
+ fixture.detectChanges();
+
+ await allPendingDynamicImports();
+ await allPendingDynamicImports();
+ fixture.detectChanges();
+
+ // 1 initial attempt + 1 retry = 2 invocations, then error block renders.
+ expect(invocationCount).toBe(2);
+ expect(fixture.nativeElement.outerHTML).toContain('Failed!');
+ });
it('should not render `@error` block if loaded component has errors', async () => {
@Component({
selector: 'cmp-with-error',
diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json
index 947426982000..256660a77aba 100644
--- a/packages/core/test/bundling/defer/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json
@@ -97,8 +97,11 @@
"DEFAULT_LOCALE_ID",
"DEFER_BLOCK_CONFIG",
"DEFER_BLOCK_ID",
+ "DEFER_BLOCK_RETRY_HANDLER",
"DEFER_BLOCK_STATE",
"DEFER_BLOCK_STATE",
+ "DEFER_EXPORT_NAME_PATTERN",
+ "DEFER_IMPORT_URL_PATTERN",
"DEHYDRATED_BLOCK_REGISTRY",
"DEHYDRATED_VIEWS",
"DI_DECORATOR_FLAG",
@@ -205,6 +208,7 @@
"REACTIVE_NODE",
"REACTIVE_TEMPLATE_CONSUMER",
"RENDERER",
+ "RETRY_ATTEMPTS_REMAINING",
"RendererFactory2",
"RendererStyleFlags2",
"RetrievingInjector",
@@ -298,6 +302,7 @@
"allocLFrame",
"angularZoneInstanceIdProperty",
"appendChild",
+ "appendRetryQueryParam",
"applyContainer",
"applyDeferBlockState",
"applyDeferBlockStateWithSchedulingImpl",
@@ -380,6 +385,7 @@
"decreaseElementDepthCount",
"deepForEach",
"deepForEachProvider",
+ "defaultDeferBlockRetryHandler",
"defaultErrorHandler",
"defaultThrowError",
"delayChangeDetectionForEvents",
@@ -557,6 +563,7 @@
"isContentQueryHost",
"isCssClassMatching",
"isCurrentTNodeParent",
+ "isDeferDependencyLoader",
"isDestroyed",
"isDetachedByI18n",
"isDirectiveHost",
@@ -663,6 +670,7 @@
"registerLView",
"registerPostOrderHooks",
"registerPreOrderHooks",
+ "reloadDeferDependencyWithCacheBust",
"rememberChangeHistoryAndInvokeOnChangesHook",
"remove",
"removeAnimationsFromQueue",
diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json
index d26a1b3c08fd..c4494050615a 100644
--- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json
@@ -68,9 +68,12 @@
"DEFAULT_SSR_MAX_RESPONSE_BODY_SIZE",
"DEFER_BLOCK_CONFIG",
"DEFER_BLOCK_ID",
+ "DEFER_BLOCK_RETRY_HANDLER",
"DEFER_BLOCK_SSR_ID_ATTRIBUTE",
"DEFER_BLOCK_STATE",
"DEFER_BLOCK_STATE",
+ "DEFER_EXPORT_NAME_PATTERN",
+ "DEFER_IMPORT_URL_PATTERN",
"DEFER_PARENT_BLOCK_ID",
"DEHYDRATED_BLOCK_REGISTRY",
"DEHYDRATED_VIEWS",
@@ -240,6 +243,7 @@
"RENDERER",
"REQ_URL",
"RESPONSE_TYPE",
+ "RETRY_ATTEMPTS_REMAINING",
"RendererFactory2",
"RendererStyleFlags2",
"Restriction",
@@ -362,6 +366,7 @@
"allocLFrame",
"angularZoneInstanceIdProperty",
"appendChild",
+ "appendRetryQueryParam",
"applyContainer",
"applyDeferBlockState",
"applyDeferBlockStateWithSchedulingImpl",
@@ -469,6 +474,7 @@
"decompressNodeLocation",
"deepForEach",
"deepForEachProvider",
+ "defaultDeferBlockRetryHandler",
"defaultErrorHandler",
"defaultThrowError",
"deferBlockHasErrored",
@@ -701,6 +707,7 @@
"isComponentHost",
"isContentQueryHost",
"isCurrentTNodeParent",
+ "isDeferDependencyLoader",
"isDestroyed",
"isDetachedByI18n",
"isDisconnectedNode",
@@ -849,6 +856,7 @@
"registerPostOrderHooks",
"registerPreOrderHooks",
"relativePath",
+ "reloadDeferDependencyWithCacheBust",
"rememberChangeHistoryAndInvokeOnChangesHook",
"remove",
"removeAllEventListeners",
diff --git a/packages/core/test/test_bed_spec.ts b/packages/core/test/test_bed_spec.ts
index 7d4703fa1f4a..d0900fbb178f 100644
--- a/packages/core/test/test_bed_spec.ts
+++ b/packages/core/test/test_bed_spec.ts
@@ -1705,9 +1705,9 @@ describe('TestBed', () => {
setClassMetadataAsync(
ComponentClass,
function () {
- const promises: Array>> = deferrableDependencies.map(
+ const promises: Array<() => Promise>> = deferrableDependencies.map(
// Emulates a dynamic import, e.g. `import('./cmp-a').then(m => m.CmpA)`
- (dep) => new Promise((resolve) => setTimeout(() => resolve(dep))),
+ (dep) => () => new Promise((resolve) => setTimeout(() => resolve(dep))),
);
return promises;
},
@@ -1908,9 +1908,9 @@ describe('TestBed', () => {
setClassMetadataAsync(
ParentCmp,
function () {
- const promises: Array>> = deferrableDependencies.map(
+ const promises: Array<() => Promise>> = deferrableDependencies.map(
// Emulates a dynamic import, e.g. `import('./cmp-a').then(m => m.CmpA)`
- (dep) => new Promise((resolve) => setTimeout(() => resolve(dep))),
+ (dep) => () => new Promise((resolve) => setTimeout(() => resolve(dep))),
);
return promises;
},
diff --git a/packages/language-service/src/document_symbols.ts b/packages/language-service/src/document_symbols.ts
index 4ffa36e35c23..4f4a0a1d76b8 100644
--- a/packages/language-service/src/document_symbols.ts
+++ b/packages/language-service/src/document_symbols.ts
@@ -512,8 +512,10 @@ class TemplateSymbolVisitor extends TmplAstRecursiveVisitor {
}
override visitDeferredBlockError(block: TmplAstDeferredBlockError): void {
+ const name = block.retryCount !== null ? `@error (retry ${block.retryCount})` : '@error';
+
const symbol: TemplateDocumentSymbol = {
- text: '@error',
+ text: name,
kind: ts.ScriptElementKind.functionElement,
lspKind: AngularSymbolKind.Event, // @error → Event ⚡
spans: [toTextSpan(block.sourceSpan)],
diff --git a/packages/language-service/test/diagnostic_spec.ts b/packages/language-service/test/diagnostic_spec.ts
index 3bdf18f37ab9..60c1157a3a76 100644
--- a/packages/language-service/test/diagnostic_spec.ts
+++ b/packages/language-service/test/diagnostic_spec.ts
@@ -103,6 +103,25 @@ describe('getSemanticDiagnostics', () => {
expect(diags).toEqual([]);
});
+ it('should not report diagnostics for defer error retry syntax', () => {
+ const files = {
+ 'app.ts': `
+ import {Component} from '@angular/core';
+
+ @Component({
+ templateUrl: './app.html',
+ standalone: false,
+ })
+ export class AppComponent {}
+ `,
+ 'app.html': `@defer { Loaded } @error (retry 2) { Failed }`,
+ };
+
+ const project = createModuleAndProjectWithDeclarations(env, 'test', files);
+ const diags = project.getDiagnosticsForFile('app.html');
+ expect(diags).toEqual([]);
+ });
+
it('should not report external template diagnostics on the TS file', () => {
const files = {
'app.ts': `
diff --git a/packages/platform-server/test/incremental_hydration_spec.ts b/packages/platform-server/test/incremental_hydration_spec.ts
index 5661c79da256..a82dc309458a 100644
--- a/packages/platform-server/test/incremental_hydration_spec.ts
+++ b/packages/platform-server/test/incremental_hydration_spec.ts
@@ -2493,6 +2493,160 @@ describe('platform-server partial hydration integration', () => {
expect(appHostNode.outerHTML).not.toContain('Rendering primary block');
expect(appHostNode.outerHTML).toContain('Rendering error block');
});
+
+ it('should retry resource loading via @error (retry N) during incremental hydration before falling through to the @error block', async () => {
+ @Component({
+ selector: 'nested-cmp',
+ template: 'Rendering {{ block }} block.',
+ })
+ class NestedCmp {
+ @Input() block!: string;
+ }
+
+ @Component({
+ selector: 'app',
+ imports: [NestedCmp],
+ template: `
+
+ @defer (on interaction; hydrate on interaction) {
+
+
+
+ } @placeholder {
+ Outer block placeholder
+ } @error (retry 1) {
+ Failed!
+
+ }
+
+ `,
+ })
+ class SimpleComponent {}
+
+ let attempts = 0;
+ const deferDepsInterceptor = {
+ intercept() {
+ return () => {
+ const current = attempts++;
+ return [current === 0 ? failedDynamicImport() : dynamicImportOf(NestedCmp)];
+ };
+ },
+ };
+
+ const appId = 'custom-app-id';
+ const providers = [{provide: APP_ID, useValue: appId}];
+
+ const html = await ssr(SimpleComponent, {envProviders: providers});
+ const ssrContents = getAppContents(html);
+
+ expect(ssrContents).toContain('(appRef);
+ appRef.tick();
+ await appRef.whenStable();
+
+ const appHostNode = compRef.location.nativeElement;
+ expect(appHostNode.outerHTML).toContain('Rendering primary block');
+
+ const article = doc.getElementById('item')!;
+ article.dispatchEvent(new CustomEvent('click', {bubbles: true}));
+ // Drain the failed attempt + the successful retry.
+ await allPendingDynamicImports();
+ await allPendingDynamicImports();
+
+ appRef.tick();
+
+ expect(attempts).toBe(2);
+ // After the retry succeeds the primary content should be hydrated and
+ // the @error block must NOT have been rendered.
+ expect(appHostNode.outerHTML).toContain('Rendering primary block');
+ expect(appHostNode.outerHTML).not.toContain('Rendering error block');
+ expect(appHostNode.outerHTML).not.toContain('Failed!');
+ });
+
+ it('should fall through to the @error block during hydration once @error (retry N) is exhausted', async () => {
+ @Component({
+ selector: 'nested-cmp',
+ template: 'Rendering {{ block }} block.',
+ })
+ class NestedCmp {
+ @Input() block!: string;
+ }
+
+ @Component({
+ selector: 'app',
+ imports: [NestedCmp],
+ template: `
+
+ @defer (on interaction; hydrate on interaction) {
+
+
+
+ } @placeholder {
+ Outer block placeholder
+ } @error (retry 2) {
+ Failed!
+
+ }
+
+ `,
+ })
+ class SimpleComponent {}
+
+ let attempts = 0;
+ const deferDepsInterceptor = {
+ intercept() {
+ return () => {
+ attempts++;
+ return [failedDynamicImport()];
+ };
+ },
+ };
+
+ const appId = 'custom-app-id';
+ const providers = [{provide: APP_ID, useValue: appId}];
+
+ const html = await ssr(SimpleComponent, {envProviders: providers});
+ resetTViewsFor(SimpleComponent);
+
+ const doc = getDocument();
+ const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
+ envProviders: [
+ ...providers,
+ {provide: PLATFORM_ID, useValue: 'browser'},
+ {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor},
+ ],
+ });
+ const compRef = getComponentRef(appRef);
+ appRef.tick();
+ await appRef.whenStable();
+
+ const appHostNode = compRef.location.nativeElement;
+ const article = doc.getElementById('item')!;
+ article.dispatchEvent(new CustomEvent('click', {bubbles: true}));
+ // Drain initial + 2 retries.
+ await allPendingDynamicImports();
+ await allPendingDynamicImports();
+ await allPendingDynamicImports();
+
+ appRef.tick();
+
+ // 1 initial + 2 retries.
+ expect(attempts).toBe(3);
+ expect(appHostNode.outerHTML).not.toContain('Rendering primary block');
+ expect(appHostNode.outerHTML).toContain('Rendering error block');
+ });
});
describe('cleanup', () => {