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', () => {