From 54ed62d240555888cbf9cfbe98c8393041ab91d5 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Tue, 17 Mar 2026 17:04:06 -0700 Subject: [PATCH] refactor(core): implement `@boundary` runtime primitives and AST nodes (#70463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the runtime primitives `ɵɵboundaryCreate` and `ɵɵboundaryUpdate` to the core instructions, which handle synchronous view destruction and provide the `ON_ERROR` interceptor hooks. Also include the initial compiler AST representations for the new syntax including the Lexer tokenization and HTML Parser integration. This lays the foundational structure for `@boundary` prior to code generation. Co-authored-by: Matthieu Riegler PR Close #70463 --- goldens/public-api/core/index.api.md | 2 +- .../GOLDEN_PARTIAL.js | 273 ++++++++++++++++++ .../TEST_CASES.json | 75 +++++ .../r3_view_compiler_boundaries/basic.js | 20 ++ .../r3_view_compiler_boundaries/basic.ts | 12 + .../basic_isolated.golden.d.ts | 5 + .../error_declaration.js | 23 ++ .../error_declaration.ts | 12 + .../error_declaration_isolated.golden.d.ts | 5 + .../multiple_error_blocks.js | 26 ++ .../multiple_error_blocks.ts | 14 + ...multiple_error_blocks_isolated.golden.d.ts | 5 + .../r3_view_compiler_boundaries/nested.js | 36 +++ .../r3_view_compiler_boundaries/nested.ts | 17 ++ .../nested_isolated.golden.d.ts | 5 + .../retry_alias.js | 27 ++ .../retry_alias.ts | 12 + .../retry_alias_isolated.golden.d.ts | 5 + .../retry_implicit.js | 27 ++ .../retry_implicit.ts | 12 + .../retry_implicit_isolated.golden.d.ts | 5 + .../when_condition.js | 21 ++ .../when_condition.ts | 12 + .../when_condition_isolated.golden.d.ts | 5 + .../test/compliance/update_all_goldens.js | 12 +- .../compiler-cli/test/ngtsc/boundary_spec.ts | 271 +++++++++++++++++ packages/compiler/src/combined_visitor.ts | 11 + packages/compiler/src/compiler.ts | 2 + packages/compiler/src/ml_parser/lexer.ts | 1 + packages/compiler/src/render3/r3_ast.ts | 48 +++ .../compiler/src/render3/r3_boundaries.ts | 168 +++++++++++ .../compiler/src/render3/r3_control_flow.ts | 112 ++----- .../compiler/src/render3/r3_identifiers.ts | 3 + .../src/render3/r3_template_transform.ts | 15 +- packages/compiler/src/render3/util.ts | 87 ++++++ .../compiler/src/render3/view/t2_binder.ts | 41 ++- .../src/template/pipeline/ir/src/enums.ts | 24 ++ .../template/pipeline/ir/src/expression.ts | 44 +++ .../template/pipeline/ir/src/ops/create.ts | 72 ++++- .../template/pipeline/ir/src/ops/update.ts | 62 +++- .../src/template/pipeline/ir/src/variable.ts | 13 +- .../src/template/pipeline/src/emit.ts | 4 + .../src/template/pipeline/src/ingest.ts | 106 ++++++- .../src/template/pipeline/src/instruction.ts | 17 ++ .../src/phases/boundary_conditions.ts | 86 ++++++ .../pipeline/src/phases/conditionals.ts | 4 +- .../pipeline/src/phases/generate_variables.ts | 27 ++ .../template/pipeline/src/phases/naming.ts | 3 + .../src/template/pipeline/src/phases/reify.ts | 37 ++- .../pipeline/src/phases/resolve_boundaries.ts | 55 ++++ .../pipeline/src/phases/var_counting.ts | 1 + packages/compiler/src/typecheck/ops/scope.ts | 17 ++ .../test/render3/r3_ast_spans_spec.ts | 19 ++ .../render3/r3_template_transform_spec.ts | 41 ++- .../compiler/test/render3/util/expression.ts | 10 + .../schematics/utils/template_ast_visitor.ts | 4 + packages/core/src/core.ts | 2 +- .../core/src/core_render3_private_export.ts | 21 +- packages/core/src/render3/index.ts | 3 + packages/core/src/render3/instructions/all.ts | 1 + .../core/src/render3/instructions/boundary.ts | 22 +- .../render3/instructions/change_detection.ts | 12 +- .../src/render3/instructions/control_flow.ts | 35 ++- packages/core/src/render3/jit/environment.ts | 3 + packages/core/src/resource/resource.ts | 10 +- .../core/test/render3/error_boundary_spec.ts | 57 ++-- 66 files changed, 2060 insertions(+), 179 deletions(-) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/GOLDEN_PARTIAL.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/TEST_CASES.json create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic_isolated.golden.d.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration_isolated.golden.d.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks_isolated.golden.d.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested_isolated.golden.d.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias_isolated.golden.d.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit_isolated.golden.d.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition_isolated.golden.d.ts create mode 100644 packages/compiler-cli/test/ngtsc/boundary_spec.ts create mode 100644 packages/compiler/src/render3/r3_boundaries.ts create mode 100644 packages/compiler/src/template/pipeline/src/phases/boundary_conditions.ts create mode 100644 packages/compiler/src/template/pipeline/src/phases/resolve_boundaries.ts diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 17cbbec60ec..dd1c5630741 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -713,7 +713,7 @@ export type EnvironmentProviders = { // @public export interface ErrorDetails { readonly boundary?: { - readonly type: Type; + readonly type: Type; readonly reset: () => void; }; readonly caughtBy?: Function; diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/GOLDEN_PARTIAL.js new file mode 100644 index 00000000000..d6cb6e38664 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/GOLDEN_PARTIAL.js @@ -0,0 +1,273 @@ +/**************************************************************************************************** + * PARTIAL FILE: basic.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class TestComponent { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` + @boundary { + Main Content + } @error { + Fallback Content + } + `, isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, decorators: [{ + type: Component, + args: [{ + template: ` + @boundary { + Main Content + } @error { + Fallback Content + } + ` + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: basic.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: error_declaration.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class TestComponent { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` + @boundary { + Main Content + } @error (let err) { + Error: {{err.message}} + } + `, isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, decorators: [{ + type: Component, + args: [{ + template: ` + @boundary { + Main Content + } @error (let err) { + Error: {{err.message}} + } + ` + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: error_declaration.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: retry_alias.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class TestComponent { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` + @boundary { + Main Content + } @error (let err, retry = $reset) { + + } + `, isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, decorators: [{ + type: Component, + args: [{ + template: ` + @boundary { + Main Content + } @error (let err, retry = $reset) { + + } + ` + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: retry_alias.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: retry_implicit.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class TestComponent { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` + @boundary { + Main Content + } @error { + + } + `, isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, decorators: [{ + type: Component, + args: [{ + template: ` + @boundary { + Main Content + } @error { + + } + ` + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: retry_implicit.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: when_condition.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class TestComponent { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` + @boundary { + Main Content + } @error (let err; when err.message === '404') { + Not Found + } + `, isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, decorators: [{ + type: Component, + args: [{ + template: ` + @boundary { + Main Content + } @error (let err; when err.message === '404') { + Not Found + } + ` + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: when_condition.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: multiple_error_blocks.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class TestComponent { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` + @boundary { + Main Content + } @error (let err; when err.message === '404') { + Not Found + } @error (let err) { + Generic Error + } + `, isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, decorators: [{ + type: Component, + args: [{ + template: ` + @boundary { + Main Content + } @error (let err; when err.message === '404') { + Not Found + } @error (let err) { + Generic Error + } + ` + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: multiple_error_blocks.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: nested.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class TestComponent { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` + @boundary { + Outer Main + @boundary { + Inner Main + } @error { + Inner Fallback + } + } @error { + Outer Fallback + } + `, isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComponent, decorators: [{ + type: Component, + args: [{ + template: ` + @boundary { + Outer Main + @boundary { + Inner Main + } @error { + Inner Fallback + } + } @error { + Outer Fallback + } + ` + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: nested.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/TEST_CASES.json new file mode 100644 index 00000000000..269117a46c0 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/TEST_CASES.json @@ -0,0 +1,75 @@ +{ + "$schema": "../../test_case_schema.json", + "cases": [ + { + "description": "should generate boundary and error blocks", + "inputFiles": ["basic.ts"], + "expectations": [ + { + "failureMessage": "Incorrect boundary instructions", + "files": ["basic.js"] + } + ] + }, + { + "description": "should support let declaration of the error", + "inputFiles": ["error_declaration.ts"], + "expectations": [ + { + "failureMessage": "Incorrect boundary instructions for error declaration", + "files": ["error_declaration.js"] + } + ] + }, + { + "description": "should support retry function alias", + "inputFiles": ["retry_alias.ts"], + "expectations": [ + { + "failureMessage": "Incorrect boundary instructions for retry alias", + "files": ["retry_alias.js"] + } + ] + }, + { + "description": "should support retry implicitly", + "inputFiles": ["retry_implicit.ts"], + "expectations": [ + { + "failureMessage": "Incorrect boundary instructions", + "files": ["retry_implicit.js"] + } + ] + }, + { + "description": "should support when conditions", + "inputFiles": ["when_condition.ts"], + "expectations": [ + { + "failureMessage": "Incorrect boundary instructions for when condition", + "files": ["when_condition.js"] + } + ] + }, + { + "description": "should support multiple error blocks", + "inputFiles": ["multiple_error_blocks.ts"], + "expectations": [ + { + "failureMessage": "Incorrect boundary instructions for multiple error blocks", + "files": ["multiple_error_blocks.js"] + } + ] + }, + { + "description": "should support nested boundaries", + "inputFiles": ["nested.ts"], + "expectations": [ + { + "failureMessage": "Incorrect boundary instructions for nested boundaries", + "files": ["nested.js"] + } + ] + } + ] +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic.js new file mode 100644 index 00000000000..820562b9a49 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic.js @@ -0,0 +1,20 @@ +function TestComponent_Primary_1_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Main Content "); + } +} +function TestComponent_Error_2_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Fallback Content "); + } +} +… +function TestComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵboundaryCreate(0); + $r3$.ɵɵconditionalBranchCreate(1, TestComponent_Primary_1_Template, 1, 0)(2, TestComponent_Error_2_Template, 1, 0); + } + if (rf & 2) { + $r3$.ɵɵboundaryUpdate(0, $r3$.ɵɵgetBoundary(0).error === null ? 1 : 2, 1); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic.ts new file mode 100644 index 00000000000..c63f70ef7eb --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic.ts @@ -0,0 +1,12 @@ +import {Component} from '@angular/core'; + +@Component({ + template: ` + @boundary { + Main Content + } @error { + Fallback Content + } + ` +}) +export class TestComponent {} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic_isolated.golden.d.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic_isolated.golden.d.ts new file mode 100644 index 00000000000..a87ec87c449 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/basic_isolated.golden.d.ts @@ -0,0 +1,5 @@ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration.js new file mode 100644 index 00000000000..0bab76bf7dc --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration.js @@ -0,0 +1,23 @@ +function TestComponent_Primary_1_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Main Content "); + } +} +function TestComponent_Error_2_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0); + } + if (rf & 2) { + $r3$.ɵɵtextInterpolate1(" Error: ", ctx.$error.message, " "); + } +} +… +function TestComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵboundaryCreate(0); + $r3$.ɵɵconditionalBranchCreate(1, TestComponent_Primary_1_Template, 1, 0)(2, TestComponent_Error_2_Template, 1, 1); + } + if (rf & 2) { + $r3$.ɵɵboundaryUpdate(0, $r3$.ɵɵgetBoundary(0).error === null ? 1 : 2, 1); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration.ts new file mode 100644 index 00000000000..731d3246053 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration.ts @@ -0,0 +1,12 @@ +import {Component} from '@angular/core'; + +@Component({ + template: ` + @boundary { + Main Content + } @error (let err) { + Error: {{err.message}} + } + ` +}) +export class TestComponent {} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration_isolated.golden.d.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration_isolated.golden.d.ts new file mode 100644 index 00000000000..a87ec87c449 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/error_declaration_isolated.golden.d.ts @@ -0,0 +1,5 @@ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks.js new file mode 100644 index 00000000000..4e98cd7da53 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks.js @@ -0,0 +1,26 @@ +function TestComponent_Primary_1_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Main Content "); + } +} +function TestComponent_Error_2_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Not Found "); + } +} +function TestComponent_Error_3_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Generic Error "); + } +} +… +function TestComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵboundaryCreate(0); + $r3$.ɵɵconditionalBranchCreate(1, TestComponent_Primary_1_Template, 1, 0)(2, TestComponent_Error_2_Template, 1, 0)(3, TestComponent_Error_3_Template, 1, 0); + } + if (rf & 2) { + const $bnd_r1$ = $r3$.ɵɵgetBoundary(0); + $r3$.ɵɵboundaryUpdate(0, $bnd_r1$.error === null ? 1 : $bnd_r1$.error.message === "404" ? 2 : 3, 1); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks.ts new file mode 100644 index 00000000000..f4b5125c2fa --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; + +@Component({ + template: ` + @boundary { + Main Content + } @error (let err; when err.message === '404') { + Not Found + } @error (let err) { + Generic Error + } + ` +}) +export class TestComponent {} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks_isolated.golden.d.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks_isolated.golden.d.ts new file mode 100644 index 00000000000..a87ec87c449 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/multiple_error_blocks_isolated.golden.d.ts @@ -0,0 +1,5 @@ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested.js new file mode 100644 index 00000000000..7a0fbe7b2ec --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested.js @@ -0,0 +1,36 @@ +function TestComponent_Primary_1_Primary_2_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Inner Main "); + } +} +function TestComponent_Primary_1_Error_3_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Inner Fallback "); + } +} +function TestComponent_Primary_1_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Outer Main "); + $r3$.ɵɵboundaryCreate(1); + $r3$.ɵɵconditionalBranchCreate(2, TestComponent_Primary_1_Primary_2_Template, 1, 0)(3, TestComponent_Primary_1_Error_3_Template, 1, 0); + } + if (rf & 2) { + $r3$.ɵɵadvance(); + $r3$.ɵɵboundaryUpdate(1, $r3$.ɵɵgetBoundary(1).error === null ? 2 : 3, 2); + } +} +function TestComponent_Error_2_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Outer Fallback "); + } +} +… +function TestComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵboundaryCreate(0); + $r3$.ɵɵconditionalBranchCreate(1, TestComponent_Primary_1_Template, 4, 1)(2, TestComponent_Error_2_Template, 1, 0); + } + if (rf & 2) { + $r3$.ɵɵboundaryUpdate(0, $r3$.ɵɵgetBoundary(0).error === null ? 1 : 2, 1); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested.ts new file mode 100644 index 00000000000..b699dc9d66c --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested.ts @@ -0,0 +1,17 @@ +import {Component} from '@angular/core'; + +@Component({ + template: ` + @boundary { + Outer Main + @boundary { + Inner Main + } @error { + Inner Fallback + } + } @error { + Outer Fallback + } + ` +}) +export class TestComponent {} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested_isolated.golden.d.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested_isolated.golden.d.ts new file mode 100644 index 00000000000..a87ec87c449 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/nested_isolated.golden.d.ts @@ -0,0 +1,5 @@ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias.js new file mode 100644 index 00000000000..439bfebf093 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias.js @@ -0,0 +1,27 @@ +function TestComponent_Primary_1_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Main Content "); + } +} +function TestComponent_Error_2_Template(rf, ctx) { + if (rf & 1) { + const $r1$ = $r3$.ɵɵgetCurrentView(); + $r3$.ɵɵdomElementStart(0, "button", 0); + $r3$.ɵɵdomListener("click", function TestComponent_Error_2_Template_button_click_0_listener() { + const $ctx_r1$ = $r3$.ɵɵrestoreView($r1$); + return $r3$.ɵɵresetView($ctx_r1$.$reset()); + }); + $r3$.ɵɵtext(1, "Retry"); + $r3$.ɵɵdomElementEnd(); + } +} +… +function TestComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵboundaryCreate(0); + $r3$.ɵɵconditionalBranchCreate(1, TestComponent_Primary_1_Template, 1, 0)(2, TestComponent_Error_2_Template, 2, 0); + } + if (rf & 2) { + $r3$.ɵɵboundaryUpdate(0, $r3$.ɵɵgetBoundary(0).error === null ? 1 : 2, 1); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias.ts new file mode 100644 index 00000000000..3986a68acf9 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + template: ` + @boundary { + Main Content + } @error (let err, retry = $reset) { + + } + ` +}) +export class TestComponent {} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias_isolated.golden.d.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias_isolated.golden.d.ts new file mode 100644 index 00000000000..a87ec87c449 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_alias_isolated.golden.d.ts @@ -0,0 +1,5 @@ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit.js new file mode 100644 index 00000000000..439bfebf093 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit.js @@ -0,0 +1,27 @@ +function TestComponent_Primary_1_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Main Content "); + } +} +function TestComponent_Error_2_Template(rf, ctx) { + if (rf & 1) { + const $r1$ = $r3$.ɵɵgetCurrentView(); + $r3$.ɵɵdomElementStart(0, "button", 0); + $r3$.ɵɵdomListener("click", function TestComponent_Error_2_Template_button_click_0_listener() { + const $ctx_r1$ = $r3$.ɵɵrestoreView($r1$); + return $r3$.ɵɵresetView($ctx_r1$.$reset()); + }); + $r3$.ɵɵtext(1, "Retry"); + $r3$.ɵɵdomElementEnd(); + } +} +… +function TestComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵboundaryCreate(0); + $r3$.ɵɵconditionalBranchCreate(1, TestComponent_Primary_1_Template, 1, 0)(2, TestComponent_Error_2_Template, 2, 0); + } + if (rf & 2) { + $r3$.ɵɵboundaryUpdate(0, $r3$.ɵɵgetBoundary(0).error === null ? 1 : 2, 1); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit.ts new file mode 100644 index 00000000000..f7c5f2765a8 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + template: ` + @boundary { + Main Content + } @error { + + } + ` +}) +export class TestComponent {} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit_isolated.golden.d.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit_isolated.golden.d.ts new file mode 100644 index 00000000000..a87ec87c449 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/retry_implicit_isolated.golden.d.ts @@ -0,0 +1,5 @@ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition.js new file mode 100644 index 00000000000..017f6d90084 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition.js @@ -0,0 +1,21 @@ +function TestComponent_Primary_1_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Main Content "); + } +} +function TestComponent_Error_2_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtext(0, " Not Found "); + } +} +… +function TestComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵboundaryCreate(0); + $r3$.ɵɵconditionalBranchCreate(1, TestComponent_Primary_1_Template, 1, 0)(2, TestComponent_Error_2_Template, 1, 0); + } + if (rf & 2) { + const $bnd_r1$ = $r3$.ɵɵgetBoundary(0); + $r3$.ɵɵboundaryUpdate(0, $bnd_r1$.error === null ? 1 : $bnd_r1$.error.message === "404" ? 2 : -1, 1); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition.ts new file mode 100644 index 00000000000..245c2393e45 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition.ts @@ -0,0 +1,12 @@ +import {Component} from '@angular/core'; + +@Component({ + template: ` + @boundary { + Main Content + } @error (let err; when err.message === '404') { + Not Found + } + ` +}) +export class TestComponent {} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition_isolated.golden.d.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition_isolated.golden.d.ts new file mode 100644 index 00000000000..a87ec87c449 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_boundaries/when_condition_isolated.golden.d.ts @@ -0,0 +1,5 @@ +import * as i0 from "@angular/core"; +export declare class TestComponent { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} diff --git a/packages/compiler-cli/test/compliance/update_all_goldens.js b/packages/compiler-cli/test/compliance/update_all_goldens.js index e5858a04e10..43816933742 100644 --- a/packages/compiler-cli/test/compliance/update_all_goldens.js +++ b/packages/compiler-cli/test/compliance/update_all_goldens.js @@ -20,14 +20,14 @@ const allUpdateTargets = execSync(queryCommand, {encoding: 'utf-8', stdio: 'pipe .filter((target) => target.length > 0); if (allUpdateTargets.length === 0) { - process.stdout.clearLine(); - process.stdout.cursorTo(0); + if (process.stdout.clearLine) process.stdout.clearLine(); + if (process.stdout.cursorTo) process.stdout.cursorTo(0); console.error(`Could not find any symbol test targets using: ${queryCommand}`); process.exit(1); } -process.stdout.clearLine(); -process.stdout.cursorTo(0); +if (process.stdout.clearLine) process.stdout.clearLine(); +if (process.stdout.cursorTo) process.stdout.cursorTo(0); for (const [index, target] of allUpdateTargets.entries()) { const progress = `${index + 1} / ${allUpdateTargets.length}`; @@ -36,8 +36,8 @@ for (const [index, target] of allUpdateTargets.entries()) { stdio: 'pipe', encoding: 'utf-8', }); - process.stdout.clearLine(); - process.stdout.cursorTo(0); + if (process.stdout.clearLine) process.stdout.clearLine(); + if (process.stdout.cursorTo) process.stdout.cursorTo(0); if (commandResult.status) { console.error(`[${progress}] Failed run: ${target}`); console.group(); diff --git a/packages/compiler-cli/test/ngtsc/boundary_spec.ts b/packages/compiler-cli/test/ngtsc/boundary_spec.ts new file mode 100644 index 00000000000..de1fc626759 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/boundary_spec.ts @@ -0,0 +1,271 @@ +/** + * @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 {runInEachFileSystem} from '../../src/ngtsc/file_system/testing'; +import {loadStandardTestFiles} from '../../src/ngtsc/testing'; +import {NgtscTestEnvironment} from './env'; + +const testFiles = loadStandardTestFiles({fakeCommon: true}); + +runInEachFileSystem(() => { + describe('ngtsc @boundary type checking', () => { + let env!: NgtscTestEnvironment; + + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig({fullTemplateTypeCheck: true, strictTemplates: true}); + }); + + it('should type check error alias as Error', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: \` + @boundary { +
Normal
+ } @error (let err) { +
{{ err.message }}
+ } + \`, + standalone: true, + }) + export class TestCmp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should error when accessing non-existent properties on error alias', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: \` + @boundary { +
Normal
+ } @error (let err) { +
{{ err.nonExistentProperty }}
+ } + \`, + standalone: true, + }) + export class TestCmp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain( + "Property 'nonExistentProperty' does not exist on type 'Error'", + ); + }); + + it('should narrowing type using condition when available', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + class CustomError extends Error { + customField = 'test'; + } + + @Component({ + selector: 'test-cmp', + template: \` + @boundary { +
Normal
+ } @error (let err; when err instanceof CustomError) { +
{{ err.customField }}
+ } @error (let err) { +
Fallback
+ } + \`, + standalone: true, + }) + export class TestCmp { + CustomError = CustomError; // Expose to template if needed, though with control flow it uses standard TS scope in some regards + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should report an error if there are multiple unconditional @error blocks', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: \` + @boundary { +
Normal
+ } @error { +
Fallback 1
+ } @error { +
Fallback 2
+ } + \`, + }) + export class TestCmp {} + `, + ); + + const diags = env.driveDiagnostics(); + // It will report two errors: one because the first block isn't the last, and one for the duplicate + expect(diags.length).toBe(2); + expect(diags[0].messageText).toContain( + 'Unconditional @error block must be the last @error block in the boundary chain', + ); + expect(diags[1].messageText).toContain( + '@boundary block can only have one unconditional @error block', + ); + }); + + it('should report an error if an unconditional @error block is not the last block', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + class CustomError extends Error {} + + @Component({ + selector: 'test-cmp', + template: \` + @boundary { +
Normal
+ } @error { +
Fallback
+ } @error (let err; when err instanceof CustomError) { +
Custom Error
+ } + \`, + }) + export class TestCmp { + CustomError = CustomError; + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain( + 'Unconditional @error block must be the last @error block in the boundary chain', + ); + }); + + it('should error when accessing error alias outside of error block', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: \` + @boundary { +
Normal {{ err.message }}
+ } @error (let err) { +
{{ err.message }}
+ } + \`, + }) + export class TestCmp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain("Property 'err' does not exist on type 'TestCmp'"); + }); + + it('should correctly handle nested boundary blocks and shadow error alias', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + class CustomError extends Error { + customField = 'test'; + } + + @Component({ + selector: 'test-cmp', + template: \` + @boundary { + @boundary { +
Inner
+ } @error (let err; when err instanceof CustomError) { +
Inner Custom: {{ err.customField }}
+ } @error (let err) { +
Inner Fallback: {{ err.message }}
+ } + } @error (let err) { +
Outer Fallback: {{ err.message }}
+ } + \`, + }) + export class TestCmp { + CustomError = CustomError; + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should allow multiple error blocks with different conditions', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + class ErrorA extends Error { type = 'a' } + class ErrorB extends Error { type = 'b' } + + @Component({ + selector: 'test-cmp', + template: \` + @boundary { +
Normal
+ } @error (let err; when err instanceof ErrorA) { +
A: {{ err.type }}
+ } @error (let err; when err instanceof ErrorB) { +
B: {{ err.type }}
+ } @error (let err) { +
Generic: {{ err.message }}
+ } + \`, + }) + export class TestCmp { + ErrorA = ErrorA; + ErrorB = ErrorB; + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + }); +}); diff --git a/packages/compiler/src/combined_visitor.ts b/packages/compiler/src/combined_visitor.ts index cec017a04b1..c5efebf7f56 100644 --- a/packages/compiler/src/combined_visitor.ts +++ b/packages/compiler/src/combined_visitor.ts @@ -130,6 +130,17 @@ export class CombinedRecursiveAstVisitor extends RecursiveAstVisitor implements this.visitAllTemplateNodes(block.children); } + visitBoundaryBlock(block: t.BoundaryBlock): void { + this.visitAllTemplateNodes(block.children); + this.visitAllTemplateNodes(block.errorBlocks); + } + + visitBoundaryErrorBlock(block: t.BoundaryErrorBlock): void { + this.visitAllTemplateNodes(block.contextVariables); + block.expression && this.visit(block.expression); + this.visitAllTemplateNodes(block.children); + } + visitLetDeclaration(decl: t.LetDeclaration): void { this.visit(decl.value); } diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index f033b185b97..95191ae1f92 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -152,6 +152,8 @@ export { BoundDeferredTrigger as TmplAstBoundDeferredTrigger, BoundEvent as TmplAstBoundEvent, BoundText as TmplAstBoundText, + BoundaryBlock as TmplAstBoundaryBlock, + BoundaryErrorBlock as TmplAstBoundaryErrorBlock, Content as TmplAstContent, DeferredBlock as TmplAstDeferredBlock, DeferredBlockError as TmplAstDeferredBlockError, diff --git a/packages/compiler/src/ml_parser/lexer.ts b/packages/compiler/src/ml_parser/lexer.ts index d55c0f620f0..3a3390b7b3b 100644 --- a/packages/compiler/src/ml_parser/lexer.ts +++ b/packages/compiler/src/ml_parser/lexer.ts @@ -152,6 +152,7 @@ const SUPPORTED_BLOCKS = [ '@defer', '@placeholder', '@loading', + '@boundary', '@error', '@content', ] as const; diff --git a/packages/compiler/src/render3/r3_ast.ts b/packages/compiler/src/render3/r3_ast.ts index 78693e5f08f..eaa5c5e3ac7 100644 --- a/packages/compiler/src/render3/r3_ast.ts +++ b/packages/compiler/src/render3/r3_ast.ts @@ -358,6 +358,44 @@ export class ContentBlock extends BlockNode implements Node { } } +export class BoundaryBlock extends BlockNode implements Node { + constructor( + public children: Node[], + public errorBlocks: BoundaryErrorBlock[], + nameSpan: ParseSourceSpan, + sourceSpan: ParseSourceSpan, + public mainBlockSpan: ParseSourceSpan, + startSourceSpan: ParseSourceSpan, + endSourceSpan: ParseSourceSpan | null, + public i18n?: I18nMeta, + ) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } + + visit(visitor: Visitor): Result { + return visitor.visitBoundaryBlock(this); + } +} + +export class BoundaryErrorBlock extends BlockNode implements Node { + constructor( + public children: Node[], + public contextVariables: Variable[], + public expression: AST | null, + nameSpan: ParseSourceSpan, + sourceSpan: ParseSourceSpan, + startSourceSpan: ParseSourceSpan, + endSourceSpan: ParseSourceSpan | null, + public i18n?: I18nMeta, + ) { + super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan); + } + + visit(visitor: Visitor): Result { + return visitor.visitBoundaryErrorBlock(this); + } +} + export interface DeferredBlockTriggers { when?: BoundDeferredTrigger; idle?: IdleDeferredTrigger; @@ -778,6 +816,8 @@ export interface Visitor { visitForLoopBlockEmpty(block: ForLoopBlockEmpty): Result; visitIfBlock(block: IfBlock): Result; visitIfBlockBranch(block: IfBlockBranch): Result; + visitBoundaryBlock(block: BoundaryBlock): Result; + visitBoundaryErrorBlock(block: BoundaryErrorBlock): Result; visitUnknownBlock(block: UnknownBlock): Result; visitLetDeclaration(decl: LetDeclaration): Result; visitComponent(component: Component): Result; @@ -839,6 +879,14 @@ export class RecursiveVisitor implements Visitor { visitAll(this, block.children); block.expressionAlias?.visit(this); } + visitBoundaryBlock(block: BoundaryBlock): void { + visitAll(this, block.children); + visitAll(this, block.errorBlocks); + } + visitBoundaryErrorBlock(block: BoundaryErrorBlock): void { + const blockItems = [...block.contextVariables, ...block.children]; + visitAll(this, blockItems); + } visitContent(content: Content): void { visitAll(this, content.children); } diff --git a/packages/compiler/src/render3/r3_boundaries.ts b/packages/compiler/src/render3/r3_boundaries.ts new file mode 100644 index 00000000000..994240833e1 --- /dev/null +++ b/packages/compiler/src/render3/r3_boundaries.ts @@ -0,0 +1,168 @@ +/** + * @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 {AST} from '../expression_parser/ast'; +import * as html from '../ml_parser/ast'; +import {ParseError, ParseSourceSpan} from '../parse_util'; +import {BindingParser} from '../template_parser/binding_parser'; + +import * as t from './r3_ast'; +import {IDENTIFIER_PATTERN, LET_PATTERN, parseLetParameters} from './util'; + +/** Pattern used to identify a boundary `when` expression. */ +const WHEN_PATTERN = /^(when\s+)(.*)/; + +export function isConnectedBoundaryErrorBlock(name: string): boolean { + return name === 'error'; +} + +export function createBoundaryBlock( + ast: html.Block, + connectedBlocks: html.Block[], + visitor: html.Visitor, + bindingParser: BindingParser, +): {node: t.BoundaryBlock | null; errors: ParseError[]} { + const errors: ParseError[] = []; + const errorBlocks: t.BoundaryErrorBlock[] = []; + + if (ast.parameters.length > 0) { + errors.push(new ParseError(ast.sourceSpan, '@boundary block cannot have parameters')); + } + + for (let blockIndex = 0; blockIndex < connectedBlocks.length; blockIndex++) { + const block = connectedBlocks[blockIndex]; + if (block.name !== 'error') { + errors.push( + new ParseError(block.sourceSpan, `Unrecognized @boundary connected block @${block.name}`), + ); + continue; + } + const emptySpan = new ParseSourceSpan(block.startSourceSpan.end, block.startSourceSpan.end); + const contextVariables: t.Variable[] = [ + new t.Variable('$error', '$error', emptySpan, emptySpan, emptySpan), + new t.Variable('$reset', '$reset', emptySpan, emptySpan, emptySpan), + ]; + let expression: AST | null = null; + + for (const param of block.parameters) { + const letMatch = param.expression.match(LET_PATTERN); + const isAliasMatch = param.expression.match(/^\s*[$A-Z_][0-9A-Z_$]*\s*=/i) !== null; + + if (letMatch !== null || isAliasMatch) { + const expressionToParse = letMatch !== null ? letMatch[1] : param.expression; + const variablesSpan = + letMatch !== null + ? new ParseSourceSpan( + param.sourceSpan.start.moveBy(letMatch[0].length - letMatch[1].length), + param.sourceSpan.end, + ) + : param.sourceSpan; + + parseLetParameters( + param.sourceSpan, + expressionToParse, + variablesSpan, + contextVariables, + errors, + (name, variableName, sourceSpan) => { + if (variableName !== '$error' && variableName !== '$reset') { + errors.push( + new ParseError( + sourceSpan, + `Unknown context variable "${variableName}". Only "$error" and "$reset" are allowed`, + ), + ); + } else if (contextVariables.some((v) => v.name === name)) { + errors.push( + new ParseError(sourceSpan, `Duplicate "let" parameter variable "${name}"`), + ); + } + }, + '@error block', + '$error', + ); + continue; + } + + const whenMatch = param.expression.match(WHEN_PATTERN); + if (whenMatch) { + if (expression !== null) { + errors.push( + new ParseError(param.sourceSpan, '@error block can only have one "when" expression'), + ); + } else { + const start = param.expression.indexOf(whenMatch[2]); + const end = start + whenMatch[2].length; + const expressionAST = bindingParser.parseBinding( + param.expression.slice(start, end), + false, + param.sourceSpan, + param.sourceSpan.start.offset + start, + ); + expression = expressionAST.ast; + } + continue; + } + + errors.push( + new ParseError( + param.sourceSpan, + `Unrecognized @error block parameter "${param.expression}"`, + ), + ); + } + + errorBlocks.push( + new t.BoundaryErrorBlock( + html.visitAll(visitor, block.children, block.children), + contextVariables, + expression, + block.nameSpan, + block.sourceSpan, + block.startSourceSpan, + block.endSourceSpan, + block.i18n, + ), + ); + } + + let hasUnconditionalErrorBlock = false; + for (let i = 0; i < errorBlocks.length; i++) { + const errorBlock = errorBlocks[i]; + if (errorBlock.expression === null) { + if (hasUnconditionalErrorBlock) { + errors.push( + new ParseError( + errorBlock.sourceSpan, + '@boundary block can only have one unconditional @error block', + ), + ); + } else if (i !== errorBlocks.length - 1) { + errors.push( + new ParseError( + errorBlock.sourceSpan, + 'Unconditional @error block must be the last @error block in the boundary chain', + ), + ); + } + hasUnconditionalErrorBlock = true; + } + } + + const node = new t.BoundaryBlock( + html.visitAll(visitor, ast.children, ast.children), + errorBlocks, + ast.nameSpan, + ast.sourceSpan, + ast.startSourceSpan, + ast.endSourceSpan, + ast.i18n, + ); + + return {node, errors}; +} diff --git a/packages/compiler/src/render3/r3_control_flow.ts b/packages/compiler/src/render3/r3_control_flow.ts index 95b7a2932e7..f367fdb686a 100644 --- a/packages/compiler/src/render3/r3_control_flow.ts +++ b/packages/compiler/src/render3/r3_control_flow.ts @@ -12,7 +12,7 @@ import {ParseError, ParseSourceSpan} from '../parse_util'; import {BindingParser} from '../template_parser/binding_parser'; import * as t from './r3_ast'; -import {IDENTIFIER_PATTERN, LET_PATTERN} from './util'; +import {IDENTIFIER_PATTERN, LET_PATTERN, parseLetParameters} from './util'; /** Pattern for the expression in a for loop block. */ const FOR_LOOP_EXPRESSION_PATTERN = /^\s*([0-9A-Za-z_$]*)\s+of\s+([\S\s]*)/; @@ -23,12 +23,6 @@ const FOR_LOOP_TRACK_PATTERN = /^track\s+([\S\s]*)/; /** Pattern for the `as` expression in a conditional block. */ const CONDITIONAL_ALIAS_PATTERN = /^(as\s+)(.*)/; -/** - * Pattern to group a string into leading whitespace, non whitespace, and trailing whitespace. - * Useful for getting the variable name span when a span can contain leading and trailing space. - */ -const CHARACTERS_IN_SURROUNDING_WHITESPACE_PATTERN = /(\s*)(\S+)(\s*)/; - /** Names of variables that are allowed to be used in the `let` expression of a `for` loop. */ const ALLOWED_FOR_LOOP_LET_VARIABLES = new Set([ '$index', @@ -430,13 +424,36 @@ function parseForLoopParameters( param.sourceSpan.start.moveBy(letMatch[0].length - letMatch[1].length), param.sourceSpan.end, ); - parseLetParameter( + parseLetParameters( param.sourceSpan, letMatch[1], variablesSpan, - itemName, result.context, errors, + (name, variableName, sourceSpan) => { + if (!ALLOWED_FOR_LOOP_LET_VARIABLES.has(variableName)) { + errors.push( + new ParseError( + sourceSpan, + `Unknown "let" parameter variable "${variableName}". The allowed variables are: ${Array.from( + ALLOWED_FOR_LOOP_LET_VARIABLES, + ).join(', ')}`, + ), + ); + } else if (name === itemName) { + errors.push( + new ParseError( + sourceSpan, + `Invalid @for loop "let" parameter. Variable cannot be called "${itemName}"`, + ), + ); + } else if (result.context.some((v) => v.name === name)) { + errors.push( + new ParseError(sourceSpan, `Duplicate "let" parameter variable "${variableName}"`), + ); + } + }, + '@for loop', ); continue; } @@ -484,83 +501,6 @@ function validateTrackByExpression( } } -/** Parses the `let` parameter of a `for` loop block. */ -function parseLetParameter( - sourceSpan: ParseSourceSpan, - expression: string, - span: ParseSourceSpan, - loopItemName: string, - context: t.Variable[], - errors: ParseError[], -): void { - const parts = expression.split(','); - let startSpan = span.start; - for (const part of parts) { - const expressionParts = part.split('='); - const name = expressionParts.length === 2 ? expressionParts[0].trim() : ''; - const variableName = expressionParts.length === 2 ? expressionParts[1].trim() : ''; - - if (name.length === 0 || variableName.length === 0) { - errors.push( - new ParseError( - sourceSpan, - `Invalid @for loop "let" parameter. Parameter should match the pattern " = "`, - ), - ); - } else if (!ALLOWED_FOR_LOOP_LET_VARIABLES.has(variableName)) { - errors.push( - new ParseError( - sourceSpan, - `Unknown "let" parameter variable "${variableName}". The allowed variables are: ${Array.from( - ALLOWED_FOR_LOOP_LET_VARIABLES, - ).join(', ')}`, - ), - ); - } else if (name === loopItemName) { - errors.push( - new ParseError( - sourceSpan, - `Invalid @for loop "let" parameter. Variable cannot be called "${loopItemName}"`, - ), - ); - } else if (context.some((v) => v.name === name)) { - errors.push( - new ParseError(sourceSpan, `Duplicate "let" parameter variable "${variableName}"`), - ); - } else { - const [, keyLeadingWhitespace, keyName] = - expressionParts[0].match(CHARACTERS_IN_SURROUNDING_WHITESPACE_PATTERN) ?? []; - const keySpan = - keyLeadingWhitespace !== undefined && expressionParts.length === 2 - ? new ParseSourceSpan( - /* strip leading spaces */ - startSpan.moveBy(keyLeadingWhitespace.length), - /* advance to end of the variable name */ - startSpan.moveBy(keyLeadingWhitespace.length + keyName.length), - ) - : span; - - let valueSpan: ParseSourceSpan | undefined = undefined; - if (expressionParts.length === 2) { - const [, valueLeadingWhitespace, implicit] = - expressionParts[1].match(CHARACTERS_IN_SURROUNDING_WHITESPACE_PATTERN) ?? []; - valueSpan = - valueLeadingWhitespace !== undefined - ? new ParseSourceSpan( - startSpan.moveBy(expressionParts[0].length + 1 + valueLeadingWhitespace.length), - startSpan.moveBy( - expressionParts[0].length + 1 + valueLeadingWhitespace.length + implicit.length, - ), - ) - : undefined; - } - const sourceSpan = new ParseSourceSpan(keySpan.start, valueSpan?.end ?? keySpan.end); - context.push(new t.Variable(name, variableName, sourceSpan, keySpan, valueSpan)); - } - startSpan = startSpan.moveBy(part.length + 1 /* add 1 to move past the comma */); - } -} - /** * Checks that the shape of the blocks connected to an * `@if` block is correct. Returns an array of errors. diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index a0deaebb6be..548b7919ec2 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -202,6 +202,9 @@ export class Identifiers { moduleName: CORE, }; static conditional: o.ExternalReference = {name: 'ɵɵconditional', moduleName: CORE}; + static boundaryCreate: o.ExternalReference = {name: 'ɵɵboundaryCreate', moduleName: CORE}; + static boundaryUpdate: o.ExternalReference = {name: 'ɵɵboundaryUpdate', moduleName: CORE}; + static getBoundary: o.ExternalReference = {name: 'ɵɵgetBoundary', moduleName: CORE}; static repeater: o.ExternalReference = {name: 'ɵɵrepeater', moduleName: CORE}; static repeaterCreate: o.ExternalReference = {name: 'ɵɵrepeaterCreate', moduleName: CORE}; static repeaterTrackByIndex: o.ExternalReference = { diff --git a/packages/compiler/src/render3/r3_template_transform.ts b/packages/compiler/src/render3/r3_template_transform.ts index 848ab6cefd0..48b255c0a86 100644 --- a/packages/compiler/src/render3/r3_template_transform.ts +++ b/packages/compiler/src/render3/r3_template_transform.ts @@ -25,6 +25,7 @@ import {BindingParser} from '../template_parser/binding_parser'; import {PreparsedElementType, preparseElement} from '../template_parser/template_preparser'; import * as t from './r3_ast'; +import {createBoundaryBlock, isConnectedBoundaryErrorBlock} from './r3_boundaries'; import {createContentBlock} from './r3_content_blocks'; import { createForLoop, @@ -525,10 +526,22 @@ class HtmlAstToIvyAst implements html.Visitor { ); break; + case 'boundary': + result = createBoundaryBlock( + block, + this.findConnectedBlocks(index, context, isConnectedBoundaryErrorBlock), + this, + this.bindingParser, + ); + break; + default: let errorMessage: string; - if (isConnectedDeferLoopBlock(block.name)) { + if (isConnectedBoundaryErrorBlock(block.name)) { + errorMessage = `@${block.name} block can only be used after an @defer or @boundary block.`; + this.processedNodes.add(block); + } else if (isConnectedDeferLoopBlock(block.name)) { errorMessage = `@${block.name} block can only be used after an @defer block.`; this.processedNodes.add(block); } else if (isConnectedForLoopBlock(block.name)) { diff --git a/packages/compiler/src/render3/util.ts b/packages/compiler/src/render3/util.ts index 89a2876b796..328826c8f54 100644 --- a/packages/compiler/src/render3/util.ts +++ b/packages/compiler/src/render3/util.ts @@ -9,8 +9,17 @@ import {escapeIdentifier} from '../output/abstract_emitter'; import * as o from '../output/output_ast'; +import {ParseError, ParseSourceSpan} from '../parse_util'; + +import * as t from './r3_ast'; import {Identifiers} from './r3_identifiers'; +/** + * Pattern to group a string into leading whitespace, non whitespace, and trailing whitespace. + * Useful for getting the variable name span when a span can contain leading and trailing space. + */ +const CHARACTERS_IN_SURROUNDING_WHITESPACE_PATTERN = /(\s*)(\S+)(\s*)/; + /** Regex that includes unsafe characters in an object literal property name. */ const UNSAFE_OBJECT_KEY_NAME_REGEXP = /[-.]/; @@ -20,6 +29,84 @@ export const IDENTIFIER_PATTERN = /^[$A-Z_][0-9A-Z_$]*$/i; /** Pattern used to identify a `let` parameter. */ export const LET_PATTERN = /^let\s+([\S\s]*)/; +/** + * Parses the `let` parameter of a `@for` or `@error` block. + * + * @param sourceSpan The source span of the entire `let` parameter. + * @param expression The expression string of the `let` parameter, e.g., `"foo = $implicit, bar = $index"`. + * @param span The source span of the expression string. + * @param context The variable context to append parsed variables to. + * @param errors The array of parsing errors to append to. + * @param messagePrefix The prefix to use in error messages (e.g. '@for loop'). + * @param defaultImplicitVariableName An optional default variable name to use if no value is provided. + */ +export function parseLetParameters( + sourceSpan: ParseSourceSpan, + expression: string, + span: ParseSourceSpan, + context: t.Variable[], + errors: ParseError[], + validateLet: (name: string, variableName: string, sourceSpan: ParseSourceSpan) => void, + messagePrefix: string, + defaultImplicitVariableName?: string, +): void { + const parts = expression.split(','); + let startSpan = span.start; + for (const part of parts) { + const expressionParts = part.split('='); + const name = expressionParts[0].trim(); + let variableName = + expressionParts.length === 2 + ? expressionParts[1].trim() + : (defaultImplicitVariableName ?? ''); + + if (name.length === 0 || variableName.length === 0) { + errors.push( + new ParseError( + sourceSpan, + `Invalid ${messagePrefix} "let" parameter. Parameter should match the pattern " = "`, + ), + ); + } else if (!IDENTIFIER_PATTERN.test(name)) { + errors.push( + new ParseError(sourceSpan, `"let" parameter must be a valid JavaScript identifier`), + ); + } else { + validateLet(name, variableName, sourceSpan); + + const [, keyLeadingWhitespace, keyName] = + expressionParts[0].match(CHARACTERS_IN_SURROUNDING_WHITESPACE_PATTERN) ?? []; + const keySpan = + keyLeadingWhitespace !== undefined + ? new ParseSourceSpan( + /* strip leading spaces */ + startSpan.moveBy(keyLeadingWhitespace.length), + /* advance to end of the variable name */ + startSpan.moveBy(keyLeadingWhitespace.length + keyName.length), + ) + : span; + + let valueSpan: ParseSourceSpan | undefined = undefined; + if (expressionParts.length === 2) { + const [, valueLeadingWhitespace, implicit] = + expressionParts[1].match(CHARACTERS_IN_SURROUNDING_WHITESPACE_PATTERN) ?? []; + valueSpan = + valueLeadingWhitespace !== undefined + ? new ParseSourceSpan( + startSpan.moveBy(expressionParts[0].length + 1 + valueLeadingWhitespace.length), + startSpan.moveBy( + expressionParts[0].length + 1 + valueLeadingWhitespace.length + implicit.length, + ), + ) + : undefined; + } + const variableSpan = new ParseSourceSpan(keySpan.start, valueSpan?.end ?? keySpan.end); + context.push(new t.Variable(name, variableName, variableSpan, keySpan, valueSpan)); + } + startSpan = startSpan.moveBy(part.length + 1 /* add 1 to move past the comma */); + } +} + export function typeWithParameters(type: o.Expression, numParams: number): o.ExpressionType { if (numParams === 0) { return o.expressionType(type); diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index 91880c2c151..8d47f241e02 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -15,6 +15,8 @@ import { SafePropertyRead, } from '../../expression_parser/ast'; import { + BoundaryBlock, + BoundaryErrorBlock, BoundAttribute, BoundEvent, BoundText, @@ -54,6 +56,7 @@ import { } from '../r3_ast'; import {CombinedRecursiveAstVisitor} from '../../combined_visitor'; +import {ClassPropertyMapping, ClassPropertyName, InputOrOutput} from '../../property_mapping'; import { BoundTarget, ConflictingHostDirectiveBinding, @@ -69,7 +72,6 @@ import { } from './t2_api'; import {parseTemplate} from './template'; import {createCssSelectorFromNode} from './util'; -import {ClassPropertyMapping, ClassPropertyName, InputOrOutput} from '../../property_mapping'; /** * Computes a difference between full list (first argument) and @@ -350,6 +352,9 @@ class Scope implements Visitor { this.visitVariable(nodeOrNodes.item); nodeOrNodes.contextVariables.forEach((v) => this.visitVariable(v)); nodeOrNodes.children.forEach((node) => node.visit(this)); + } else if (nodeOrNodes instanceof BoundaryErrorBlock) { + nodeOrNodes.contextVariables.forEach((v) => this.visitVariable(v)); + nodeOrNodes.children.forEach((node) => node.visit(this)); } else if ( nodeOrNodes instanceof SwitchBlockCaseGroup || nodeOrNodes instanceof ForLoopBlockEmpty || @@ -358,6 +363,7 @@ class Scope implements Visitor { nodeOrNodes instanceof DeferredBlockPlaceholder || nodeOrNodes instanceof DeferredBlockLoading || nodeOrNodes instanceof ContentBlock || + nodeOrNodes instanceof BoundaryBlock || nodeOrNodes instanceof Content ) { nodeOrNodes.children.forEach((node) => node.visit(this)); @@ -412,6 +418,15 @@ class Scope implements Visitor { this.ingestScopedNode(block); } + visitBoundaryBlock(block: BoundaryBlock) { + this.ingestScopedNode(block); + block.errorBlocks.forEach((node) => node.visit(this)); + } + + visitBoundaryErrorBlock(block: BoundaryErrorBlock) { + this.ingestScopedNode(block); + } + visitSwitchBlock(block: SwitchBlock) { block.groups.forEach((node) => node.visit(this)); } @@ -639,6 +654,15 @@ class DirectiveBinder implements Visitor { block.children.forEach((child) => child.visit(this)); } + visitBoundaryBlock(block: BoundaryBlock): void { + block.children.forEach((child) => child.visit(this)); + block.errorBlocks.forEach((node) => node.visit(this)); + } + + visitBoundaryErrorBlock(block: BoundaryErrorBlock): void { + block.children.forEach((child) => child.visit(this)); + } + visitSwitchBlock(block: SwitchBlock) { block.groups.forEach((node) => node.visit(this)); } @@ -1067,6 +1091,11 @@ class TemplateBinder extends CombinedRecursiveAstVisitor { this.deferBlocks.push([nodeOrNodes, this.scope]); nodeOrNodes.children.forEach((node) => node.visit(this)); this.nestingLevel.set(nodeOrNodes, this.level); + } else if (nodeOrNodes instanceof BoundaryErrorBlock) { + nodeOrNodes.contextVariables.forEach((v) => this.visitNode(v)); + nodeOrNodes.expression?.visit(this); + nodeOrNodes.children.forEach((node) => node.visit(this)); + this.nestingLevel.set(nodeOrNodes, this.level); } else if ( nodeOrNodes instanceof SwitchBlockCaseGroup || nodeOrNodes instanceof ForLoopBlockEmpty || @@ -1074,6 +1103,7 @@ class TemplateBinder extends CombinedRecursiveAstVisitor { nodeOrNodes instanceof DeferredBlockPlaceholder || nodeOrNodes instanceof DeferredBlockLoading || nodeOrNodes instanceof ContentBlock || + nodeOrNodes instanceof BoundaryBlock || nodeOrNodes instanceof Content ) { nodeOrNodes.children.forEach((node) => node.visit(this)); @@ -1136,6 +1166,15 @@ class TemplateBinder extends CombinedRecursiveAstVisitor { this.ingestScopedNode(block); } + override visitBoundaryBlock(block: BoundaryBlock) { + this.ingestScopedNode(block); + block.errorBlocks.forEach((node) => node.visit(this)); + } + + override visitBoundaryErrorBlock(block: BoundaryErrorBlock) { + this.ingestScopedNode(block); + } + override visitSwitchBlockCase(block: SwitchBlockCase) { block.expression?.visit(this); } diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index ce246ad16d2..96550052a31 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -89,6 +89,16 @@ export enum OpKind { */ Conditional, + /** + * Create a boundary creation instruction op. + */ + BoundaryCreate, + + /** + * An op to validate and handle errors in a template. + */ + Boundary, + /** * An operation to re-enable binding, after it was previously disabled. */ @@ -375,6 +385,14 @@ export enum ExpressionKind { */ ContextLetReference, + /** + * An expression that evaluates to the runtime state object (`LBoundary`) of an error boundary. + * This allows the generated code to access the boundary's caught error when evaluating + * `@error` block conditions. + * During the `resolve_boundaries` phase, this is replaced by a variable read of the stored boundary state. + */ + BoundaryState, + /** * Runtime operation to snapshot the current view context. */ @@ -509,6 +527,12 @@ export enum SemanticVariableKind { * An alias generated by a special embedded view type (e.g. a `@for` block). */ Alias, + + /** + * A variable that holds the runtime state (`LBoundary`) of an error boundary. + * It is typically populated by calling `ɵɵgetBoundary()` during the update phase. + */ + BoundaryState, } /** diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 6514580044c..94913a85a5d 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -32,6 +32,7 @@ export type Expression = | LexicalReadExpr | ReferenceExpr | ForeignContentExpr + | BoundaryStateExpr | ContextExpr | NextContextExpr | GetCurrentViewExpr @@ -191,6 +192,36 @@ export class ForeignContentExpr extends ExpressionBase { } } +/** + * Read of a boundary state. + */ +export class BoundaryStateExpr extends ExpressionBase { + override readonly kind = ExpressionKind.BoundaryState; + name: string | null = null; + + constructor(readonly xref: XrefId) { + super(); + } + + override visitExpression(): void {} + + override isEquivalent(other: o.Expression): boolean { + return other instanceof BoundaryStateExpr && other.xref === this.xref; + } + + override isConstant(): boolean { + return false; + } + + override transformInternalExpressions(): void {} + + override clone(): BoundaryStateExpr { + const b = new BoundaryStateExpr(this.xref); + b.name = this.name; + return b; + } +} + export class StoreLetExpr extends ExpressionBase implements ConsumesVarsTrait, DependsOnSlotContextOpTrait @@ -1245,6 +1276,18 @@ export function transformExpressionsInOp( if (op.contextValue !== null) { op.contextValue = transformExpressionsInExpression(op.contextValue, transform, flags); } + break; + case OpKind.Boundary: + for (const condition of op.conditions) { + if (condition.expr === null) { + continue; + } + condition.expr = transformExpressionsInExpression(condition.expr, transform, flags); + } + if (op.processed !== null) { + op.processed = transformExpressionsInExpression(op.processed, transform, flags); + } + break; case OpKind.Animation: case OpKind.AnimationListener: @@ -1345,6 +1388,7 @@ export function transformExpressionsInOp( case OpKind.ConditionalBranchCreate: case OpKind.Control: case OpKind.ControlCreate: + case OpKind.BoundaryCreate: // These operations contain no expressions. break; default: 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 3e9bf39c525..9ab9adf5661 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -81,7 +81,8 @@ export type CreateOp = | AnimationStringOp | AnimationOp | SourceLocationOp - | ControlCreateOp; + | ControlCreateOp + | BoundaryCreateOp; /** * An operation representing the creation of an element or container. @@ -94,7 +95,8 @@ export type ElementOrContainerOps = | TemplateOp | RepeaterCreateOp | ConditionalCreateOp - | ConditionalBranchCreateOp; + | ConditionalBranchCreateOp + | BoundaryCreateOp; /** * The set of OpKinds that represent the creation of an element or container @@ -108,6 +110,7 @@ const elementContainerOpKinds = new Set([ OpKind.RepeaterCreate, OpKind.ConditionalCreate, OpKind.ConditionalBranchCreate, + OpKind.BoundaryCreate, ]); /** @@ -186,7 +189,8 @@ export interface ElementOpBase extends ElementOrContainerOpBase { | OpKind.Template | OpKind.RepeaterCreate | OpKind.ConditionalCreate - | OpKind.ConditionalBranchCreate; + | OpKind.ConditionalBranchCreate + | OpKind.BoundaryCreate; /** * The HTML tag name for this element. @@ -531,6 +535,68 @@ export function createConditionalBranchCreateOp( }; } +/** + * An op that creates a boundary block. + */ +export interface BoundaryCreateOp extends ElementOpBase { + kind: OpKind.BoundaryCreate; + + templateKind: TemplateKind; + + /** + * The number of declaration slots used by this template, or `null` if slots have not yet been + * assigned. + */ + decls: number | null; + + /** + * The number of binding variable slots used by this template, or `null` if binding variables have + * not yet been counted. + */ + vars: number | null; + + /** + * Suffix to add to the name of the generated template function. + */ + functionNameSuffix: string; + + /** + * The i18n placeholder data associated with this template. + */ + i18nPlaceholder?: i18n.TagPlaceholder | i18n.BlockPlaceholder; +} + +export function createBoundaryCreateOp( + xref: XrefId, + templateKind: TemplateKind, + tag: string | null, + functionNameSuffix: string, + namespace: Namespace, + i18nPlaceholder: i18n.TagPlaceholder | i18n.BlockPlaceholder | undefined, + startSourceSpan: ParseSourceSpan, + wholeSourceSpan: ParseSourceSpan, +): BoundaryCreateOp { + return { + kind: OpKind.BoundaryCreate, + xref, + templateKind, + attributes: null, + tag, + handle: new SlotHandle(), + functionNameSuffix, + decls: null, + vars: null, + localRefs: [], + nonBindable: false, + namespace, + i18nPlaceholder, + startSourceSpan, + wholeSourceSpan, + ...TRAIT_CONSUMES_SLOT, + ...NEW_OP, + }; +} + /** * An op that creates a repeater (e.g. a for loop). */ diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/update.ts b/packages/compiler/src/template/pipeline/ir/src/ops/update.ts index 5b220765c72..0451f0b0b64 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/update.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/update.ts @@ -57,7 +57,8 @@ export type UpdateOp = | DeferWhenOp | AnimationBindingOp | StoreLetOp - | ControlOp; + | ControlOp + | BoundaryOp; /** * A logical operation to perform string interpolation on a text node. @@ -719,6 +720,65 @@ export function createConditionalOp( }; } +/** + * A logical operation representing a boundary expression in the update IR. + */ +export interface BoundaryOp extends Op, DependsOnSlotContextOpTrait, ConsumesVarsTrait { + kind: OpKind.Boundary; + + /** + * The insertion point, which is the BoundaryCreate op belonging to this condition. + */ + target: XrefId; + + /** + * The slot handle of the boundary block. + */ + targetSlot: SlotHandle; + + /** + * The Xref of the primary branch view. + */ + primaryTarget: XrefId; + + /** + * Each possible error fallback view that could be displayed. + */ + conditions: Array; + + /** + * After processing, this will be a single collapsed expression evaluating the error + * against fallback triggers to determine the branch index to render. + */ + processed: o.Expression | null; + + sourceSpan: ParseSourceSpan; +} + +/** + * Create a Boundary updater op. + */ +export function createBoundaryOp( + target: XrefId, + targetSlot: SlotHandle, + primaryTarget: XrefId, + conditions: Array, + sourceSpan: ParseSourceSpan, +): BoundaryOp { + return { + kind: OpKind.Boundary, + target, + targetSlot, + primaryTarget, + conditions, + processed: null, + sourceSpan, + ...NEW_OP, + ...TRAIT_DEPENDS_ON_SLOT_CONTEXT, + ...TRAIT_CONSUMES_VARS, + }; +} + export interface RepeaterOp extends Op, DependsOnSlotContextOpTrait { kind: OpKind.Repeater; diff --git a/packages/compiler/src/template/pipeline/ir/src/variable.ts b/packages/compiler/src/template/pipeline/ir/src/variable.ts index acea1ebeca8..87b24e4103f 100644 --- a/packages/compiler/src/template/pipeline/ir/src/variable.ts +++ b/packages/compiler/src/template/pipeline/ir/src/variable.ts @@ -14,10 +14,7 @@ import type {XrefId} from './operations'; * Union type for the different kinds of variables. */ export type SemanticVariable = - | ContextVariable - | IdentifierVariable - | SavedViewVariable - | AliasVariable; + ContextVariable | IdentifierVariable | SavedViewVariable | AliasVariable | BoundaryStateVariable; export interface SemanticVariableBase { kind: SemanticVariableKind; @@ -84,3 +81,11 @@ export interface AliasVariable extends SemanticVariableBase { identifier: string; expression: o.Expression; } + +/** + * A variable that holds the state of a boundary. + */ +export interface BoundaryStateVariable extends SemanticVariableBase { + kind: SemanticVariableKind.BoundaryState; + boundaryXref: XrefId; +} diff --git a/packages/compiler/src/template/pipeline/src/emit.ts b/packages/compiler/src/template/pipeline/src/emit.ts index 9c66263f51a..375f03fa1c8 100644 --- a/packages/compiler/src/template/pipeline/src/emit.ts +++ b/packages/compiler/src/template/pipeline/src/emit.ts @@ -29,6 +29,7 @@ import {specializeBindings} from './phases/binding_specialization'; import {chain} from './phases/chaining'; import {collapseSingletonInterpolations} from './phases/collapse_singleton_interpolations'; import {generateConditionalExpressions} from './phases/conditionals'; +import {generateBoundaryConditions} from './phases/boundary_conditions'; import {collectElementConsts} from './phases/const_collection'; import {specializeControlProperties} from './phases/control_directives'; import {convertAnimations} from './phases/convert_animations'; @@ -70,6 +71,7 @@ import {removeEmptyBindings} from './phases/remove_empty_bindings'; import {removeI18nContexts} from './phases/remove_i18n_contexts'; import {removeIllegalLetReferences} from './phases/remove_illegal_let_references'; import {removeUnusedI18nAttributesOps} from './phases/remove_unused_i18n_attrs'; +import {resolveBoundaries} from './phases/resolve_boundaries'; import {resolveContexts} from './phases/resolve_contexts'; import {resolveDeferDepsFns} from './phases/resolve_defer_deps_fns'; import {resolveDollarEvent} from './phases/resolve_dollar_event'; @@ -127,6 +129,7 @@ const phases: Phase[] = [ {kind: Kind.Both, fn: collapseSingletonInterpolations}, {kind: Kind.Both, fn: orderOps}, {kind: Kind.Tmpl, fn: generateConditionalExpressions}, + {kind: Kind.Tmpl, fn: generateBoundaryConditions}, {kind: Kind.Tmpl, fn: createPipes}, {kind: Kind.Tmpl, fn: configureDeferInstructions}, {kind: Kind.Tmpl, fn: insertIncrementalHydrationRuntime}, @@ -136,6 +139,7 @@ const phases: Phase[] = [ {kind: Kind.Tmpl, fn: generateProjectionDefs}, {kind: Kind.Tmpl, fn: generateLocalLetReferences}, {kind: Kind.Tmpl, fn: generateVariables}, + {kind: Kind.Tmpl, fn: resolveBoundaries}, {kind: Kind.Tmpl, fn: saveAndRestoreView}, {kind: Kind.Both, fn: deleteAnyCasts}, {kind: Kind.Both, fn: removeSafeNavigationMigration}, diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 35793c130e3..2f897663247 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -306,6 +306,8 @@ function ingestNodes(unit: ViewCompilationUnit, template: t.Node[]): void { ingestForBlock(unit, node); } else if (node instanceof t.LetDeclaration) { ingestLetDeclaration(unit, node); + } else if (node instanceof t.BoundaryBlock) { + ingestBoundaryBlock(unit, node); } else if (node instanceof t.Component) { // TODO(crisbeto): account for selectorless nodes. } else { @@ -670,6 +672,102 @@ function ingestIfBlock(unit: ViewCompilationUnit, ifBlock: t.IfBlock): void { unit.update.push(ir.createConditionalOp(firstXref!, null, conditions, ifBlock.sourceSpan)); } +/** + * Ingest an `@boundary` block into the given `ViewCompilation`. + */ +function ingestBoundaryBlock(unit: ViewCompilationUnit, boundaryBlock: t.BoundaryBlock): void { + // 1. Process primary block first to get its view xref for BoundaryCreateOp + const primaryView = unit.job.allocateView(unit.xref); + const primaryTagName = ingestControlFlowInsertionPoint(unit, primaryView.xref, boundaryBlock); + + // Create BoundaryCreateOp for the container itself, using the primary view xref! + const createOp = ir.createBoundaryCreateOp( + unit.job.allocateXrefId(), + ir.TemplateKind.Block, + primaryTagName, + 'Boundary', + ir.Namespace.HTML, + undefined, + boundaryBlock.startSourceSpan, + boundaryBlock.sourceSpan, + ); + unit.create.push(createOp); + const primaryCreateOp = ir.createConditionalBranchCreateOp( + primaryView.xref, + ir.TemplateKind.Block, + primaryTagName, + 'Primary', + ir.Namespace.HTML, + undefined, + boundaryBlock.startSourceSpan, + boundaryBlock.sourceSpan, + ); + unit.create.push(primaryCreateOp); + + let conditions: Array = []; + + // 2. Process @error blocks (fallbacks) + for (const errorBlock of boundaryBlock.errorBlocks) { + const errorView = unit.job.allocateView(unit.xref); + + // Create branch creation operation + const branchCreateOp = ir.createConditionalBranchCreateOp( + errorView.xref, + ir.TemplateKind.Block, + tagName, + 'Error', + ir.Namespace.HTML, + undefined, + errorBlock.startSourceSpan, + errorBlock.sourceSpan, + ); + unit.create.push(branchCreateOp); + + // Expression case + const caseExpr = errorBlock.expression + ? convertAst(errorBlock.expression, unit.job, null) + : null; + + const conditionalCaseExpr = new ir.ConditionalCaseExpr( + caseExpr, + branchCreateOp.xref, + branchCreateOp.handle, + null, + ); + conditions.push(conditionalCaseExpr); + + for (const variable of errorBlock.contextVariables) { + errorView.aliases.add({ + kind: ir.SemanticVariableKind.Alias, + name: null, + identifier: variable.name, + expression: new o.ReadPropExpr(new ir.ContextExpr(errorView.xref), variable.value), + }); + } + ingestNodes(errorView, errorBlock.children); + } + + const primaryCaseExpr = new ir.ConditionalCaseExpr( + null, + primaryCreateOp.xref, + primaryCreateOp.handle, + null, + ); + conditions.push(primaryCaseExpr); + ingestNodes(primaryView, boundaryBlock.children); + + unit.update.push( + ir.createBoundaryOp( + createOp.xref, + createOp.handle, + primaryCreateOp.xref, + primaryCaseExpr, + conditions, + boundaryBlock.sourceSpan, + ), + ); +} + /** * Ingest an `@switch` block into the given `ViewCompilation`. */ @@ -1982,7 +2080,13 @@ function convertSourceSpan( function ingestControlFlowInsertionPoint( unit: ViewCompilationUnit, xref: ir.XrefId, - node: t.IfBlockBranch | t.SwitchBlockCaseGroup | t.ForLoopBlock | t.ForLoopBlockEmpty, + node: + | t.IfBlockBranch + | t.SwitchBlockCaseGroup + | t.ForLoopBlock + | t.ForLoopBlockEmpty + | t.BoundaryBlock + | t.BoundaryErrorBlock, ): string | null { let root: t.Element | t.Template | null = null; diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 03568aeef6f..c8efd53d12b 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -511,6 +511,23 @@ export function conditionalBranchCreate( return call(Identifiers.conditionalBranchCreate, args, sourceSpan); } +export function boundaryCreate(slot: number, sourceSpan: ParseSourceSpan): ir.CreateOp { + return call(Identifiers.boundaryCreate, [o.literal(slot)], sourceSpan); +} + +export function getBoundary(slot: o.Expression): o.Expression { + return o.importExpr(Identifiers.getBoundary).callFn([slot]); +} + +export function boundary( + slot: o.Expression, + processedExpr: o.Expression, + primarySlot: o.Expression, + sourceSpan: ParseSourceSpan | null, +): ir.UpdateOp { + const args = [slot, processedExpr, primarySlot]; + return call(Identifiers.boundaryUpdate, args, sourceSpan); +} export function repeaterCreate( slot: number, viewFnName: string, diff --git a/packages/compiler/src/template/pipeline/src/phases/boundary_conditions.ts b/packages/compiler/src/template/pipeline/src/phases/boundary_conditions.ts new file mode 100644 index 00000000000..0c0d279597c --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/boundary_conditions.ts @@ -0,0 +1,86 @@ +/** + * @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 * as o from '../../../../output/output_ast'; +import * as ir from '../../ir'; +import type {ComponentCompilationJob} from '../compilation'; + +/** + * Resolves conditions for boundary operations, generating an expression that + * determines which branch to render based on the boundary state. + */ +export function generateBoundaryConditions(job: ComponentCompilationJob): void { + for (const unit of job.units) { + for (const op of unit.update) { + if (op.kind === ir.OpKind.Boundary) { + const boundaryXref = op.target; + + // We generate an expression like: `bnd_0.error === null ? primarySlot : errorSlot` + + const boundaryStateExpr = new ir.BoundaryStateExpr(boundaryXref); + const errorProp = new o.ReadPropExpr(boundaryStateExpr, 'error'); + const condition = new o.BinaryOperatorExpr( + o.BinaryOperator.Identical, + errorProp, + o.NULL_EXPR, + ); + + const errorBranches = op.conditions; + const fallbackBranch = errorBranches.find((c) => c.expr === null); + let errorResultExpr: o.Expression = fallbackBranch + ? new ir.SlotLiteralExpr(fallbackBranch.targetSlot) + : o.literal(-1); + + // Iterate in reverse order over error branches WITH conditions + const conditionalBranches = errorBranches.filter((c) => c.expr !== null); + for (let i = conditionalBranches.length - 1; i >= 0; i--) { + const branch = conditionalBranches[i]; + + let conditionExpr = branch.expr!; + + // Find the corresponding BoundaryErrorCreateOp to check for context variables + const errorCreateOp = Array.from(unit.create).find( + (o): o is ir.BoundaryErrorCreateOp => + o.kind === ir.OpKind.BoundaryErrorCreate && o.xref === branch.target, + ); + + if (errorCreateOp !== undefined) { + const errorVarNames = errorCreateOp.contextVariables + .filter((v: any) => v.value === '$error') + .map((v: any) => v.name); + + if (errorVarNames.length > 0) { + conditionExpr = ir.transformExpressionsInExpression( + conditionExpr, + (expr) => { + if (expr instanceof ir.LexicalReadExpr && errorVarNames.includes(expr.name)) { + return errorProp; + } + return expr; + }, + ir.VisitorContextFlag.None, + ); + } + } + + errorResultExpr = new o.ConditionalExpr( + conditionExpr, + new ir.SlotLiteralExpr(branch.targetSlot), + errorResultExpr, + ); + } + + op.processed = new o.ConditionalExpr( + condition, + new ir.SlotLiteralExpr(op.guarded.targetSlot), + errorResultExpr, + ); + } + } + } +} diff --git a/packages/compiler/src/template/pipeline/src/phases/conditionals.ts b/packages/compiler/src/template/pipeline/src/phases/conditionals.ts index 528db44ad18..cd801fc2eab 100644 --- a/packages/compiler/src/template/pipeline/src/phases/conditionals.ts +++ b/packages/compiler/src/template/pipeline/src/phases/conditionals.ts @@ -16,7 +16,7 @@ import {ComponentCompilationJob} from '../compilation'; export function generateConditionalExpressions(job: ComponentCompilationJob): void { for (const unit of job.units) { for (const op of unit.ops()) { - if (op.kind !== ir.OpKind.Conditional) { + if (op.kind !== ir.OpKind.Conditional && op.kind !== ir.OpKind.Boundary) { continue; } @@ -33,7 +33,7 @@ export function generateConditionalExpressions(job: ComponentCompilationJob): vo } // Switch expressions assign their main test to a temporary, to avoid re-executing it. - let tmp = op.test == null ? null : new ir.AssignTemporaryExpr(op.test, job.allocateXrefId()); + let tmp = op.test != null ? new ir.AssignTemporaryExpr(op.test, job.allocateXrefId()) : null; let caseExpressionTemporaryXref: ir.XrefId | null = null; // For each remaining condition, test whether the temporary satifies the check. (If no temp is diff --git a/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts b/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts index d2c36e8883b..733541450df 100644 --- a/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts +++ b/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts @@ -8,6 +8,7 @@ import * as o from '../../../../output/output_ast'; import * as ir from '../../ir'; +import * as ng from '../instruction'; import type {ComponentCompilationJob, ViewCompilationUnit} from '../compilation'; @@ -98,6 +99,8 @@ interface Scope { aliases: Set; + boundaryVariables: Map; + /** * Local references collected from elements within the view. */ @@ -173,6 +176,7 @@ function getScopeForView(view: ViewCompilationUnit, parent: Scope | null): Scope aliases: view.aliases, references: [], letDeclarations: [], + boundaryVariables: new Map(), parent, }; @@ -212,6 +216,17 @@ function getScopeForView(view: ViewCompilationUnit, parent: Scope | null): Scope } break; + case ir.OpKind.BoundaryCreate: + scope.boundaryVariables.set(op.xref, { + variable: { + kind: ir.SemanticVariableKind.BoundaryState, + name: null, + boundaryXref: op.xref, + }, + handle: op.handle, + }); + break; + case ir.OpKind.DeclareLet: scope.letDeclarations.push({ targetId: op.xref, @@ -292,6 +307,18 @@ function generateVariablesInScopeForView>( ); } + // Add variables for all boundaries declared in this scope. + for (const [xref, {variable, handle}] of scope.boundaryVariables) { + newOps.push( + ir.createVariableOp( + view.job.allocateXrefId(), + variable, + ng.getBoundary(new ir.SlotLiteralExpr(handle)), + ir.VariableFlags.None, + ), + ); + } + // Add variables for all local references declared for elements in this scope. for (const ref of scope.references) { newOps.push( diff --git a/packages/compiler/src/template/pipeline/src/phases/naming.ts b/packages/compiler/src/template/pipeline/src/phases/naming.ts index 2bc4894a83b..2a2a6b74499 100644 --- a/packages/compiler/src/template/pipeline/src/phases/naming.ts +++ b/packages/compiler/src/template/pipeline/src/phases/naming.ts @@ -191,6 +191,9 @@ function getVariableName(variable: ir.SemanticVariable, state: {index: number}): const compatPrefix = variable.identifier === CONTEXT_NAME ? 'i' : ''; variable.name = `${variable.identifier}_${compatPrefix}r${++state.index}`; break; + case ir.SemanticVariableKind.BoundaryState: + variable.name = `bnd_r${++state.index}`; + break; default: // TODO: Prefix increment for compatibility only. variable.name = `_r${++state.index}`; diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 2aca00a98ea..f997695bbab 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -7,9 +7,9 @@ */ import * as o from '../../../../output/output_ast'; -import {CONTEXT_NAME} from '../../../../render3/view/util'; -import {isUnsafeObjectKey} from '../../../../render3/util'; import {Identifiers} from '../../../../render3/r3_identifiers'; +import {isUnsafeObjectKey} from '../../../../render3/util'; +import {CONTEXT_NAME} from '../../../../render3/view/util'; import * as ir from '../../ir'; import { TemplateCompilationMode, @@ -557,6 +557,17 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList 0) { + throw new Error( + `AssertionError: local refs array should have been extracted into a constant`, + ); + } + ir.OpList.replace(op, ng.boundaryCreate(op.handle.slot!, op.startSourceSpan)); + break; case ir.OpKind.RepeaterCreate: if (op.handle.slot === null) { throw new Error('No slot was assigned for repeater instruction'); @@ -737,6 +748,28 @@ function reifyUpdateOperations(unit: CompilationUnit, ops: ir.OpList(); + + // First pass: find all VariableOps that hold boundary state and build the map. + for (const op of unit.ops()) { + if ( + op.kind === ir.OpKind.Variable && + op.variable.kind === ir.SemanticVariableKind.BoundaryState + ) { + const variable = op.variable as ir.BoundaryStateVariable; + boundaryToVariableMap.set(variable.boundaryXref, op.xref); + } + } + + // Second pass: resolve BoundaryStateExpr to ReadVariableExpr. + for (const op of unit.ops()) { + ir.transformExpressionsInOp( + op, + (expr) => { + if (expr instanceof ir.BoundaryStateExpr) { + const variableXref = boundaryToVariableMap.get(expr.xref); + if (variableXref === undefined) { + throw new Error(`Could not find variable for boundary ${expr.xref}`); + } + return new ir.ReadVariableExpr(variableXref); + } + return expr; + }, + ir.VisitorContextFlag.None, + ); + } +} diff --git a/packages/compiler/src/template/pipeline/src/phases/var_counting.ts b/packages/compiler/src/template/pipeline/src/phases/var_counting.ts index 769eac0b761..4b4d20aa82b 100644 --- a/packages/compiler/src/template/pipeline/src/phases/var_counting.ts +++ b/packages/compiler/src/template/pipeline/src/phases/var_counting.ts @@ -160,6 +160,7 @@ function varsUsedByOp(op: (ir.CreateOp | ir.UpdateOp) & ir.ConsumesVarsTrait): n return op.interpolation.expressions.length; case ir.OpKind.I18nExpression: case ir.OpKind.Conditional: + case ir.OpKind.Boundary: case ir.OpKind.DeferWhen: case ir.OpKind.StoreLet: return 1; diff --git a/packages/compiler/src/typecheck/ops/scope.ts b/packages/compiler/src/typecheck/ops/scope.ts index 1eb610a6fd9..154b8068352 100644 --- a/packages/compiler/src/typecheck/ops/scope.ts +++ b/packages/compiler/src/typecheck/ops/scope.ts @@ -13,6 +13,7 @@ import { BoundText, Component, Content, + BoundaryErrorBlock, DeferredBlock, DeferredBlockTriggers, Directive, @@ -225,6 +226,22 @@ export class Scope { ), ); } + } else if (scopedNode instanceof BoundaryErrorBlock) { + for (const variable of scopedNode.contextVariables) { + let typeExpr: TcbExpr; + if (variable.value === '$error') { + typeExpr = new TcbExpr(`(err as Error)`); + } else if (variable.value === '$retry') { + typeExpr = new TcbExpr(`(() => {})`); + } else { + throw new Error(`Unrecognized context variable ${variable.value}`); + } + Scope.registerVariable( + scope, + variable, + new TcbBlockVariableOp(tcb, scope, typeExpr, variable), + ); + } } else if (scopedNode instanceof ForLoopBlock) { // Register the variable for the loop so it can be resolved by // children. It'll be declared once the loop is created. diff --git a/packages/compiler/test/render3/r3_ast_spans_spec.ts b/packages/compiler/test/render3/r3_ast_spans_spec.ts index d11e3e2e3d5..5e21fd8563f 100644 --- a/packages/compiler/test/render3/r3_ast_spans_spec.ts +++ b/packages/compiler/test/render3/r3_ast_spans_spec.ts @@ -219,6 +219,25 @@ class R3AstSourceSpans implements t.Visitor { this.visitAll([block.children]); } + visitBoundaryBlock(block: t.BoundaryBlock): void { + this.result.push([ + 'BoundaryBlock', + humanizeSpan(block.sourceSpan), + humanizeSpan(block.startSourceSpan), + humanizeSpan(block.endSourceSpan), + ]); + this.visitAll([block.children, block.errorBlocks]); + } + + visitBoundaryErrorBlock(block: t.BoundaryErrorBlock): void { + this.result.push([ + 'BoundaryErrorBlock', + humanizeSpan(block.sourceSpan), + humanizeSpan(block.startSourceSpan), + ]); + this.visitAll([block.children]); + } + visitDeferredTrigger(trigger: t.DeferredTrigger): void { let name: string; diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index 59ac9b364e9..77280d2dbcc 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -153,6 +153,16 @@ class R3AstHumanizer implements t.Visitor { this.visitAll(toVisit); } + visitBoundaryBlock(block: t.BoundaryBlock): void { + this.result.push(['BoundaryBlock']); + this.visitAll([block.children, block.errorBlocks]); + } + + visitBoundaryErrorBlock(block: t.BoundaryErrorBlock): void { + this.result.push(['BoundaryErrorBlock', block.expression ? unparse(block.expression) : null]); + this.visitAll([block.contextVariables, block.children]); + } + visitDeferredTrigger(trigger: t.DeferredTrigger): void { if (trigger instanceof t.BoundDeferredTrigger) { this.result.push(['BoundDeferredTrigger', unparse(trigger.value)]); @@ -267,6 +277,35 @@ describe('R3 template transform', () => { }); }); + describe('Boundary blocks', () => { + it('should parse @boundary blocks with multiple @error blocks', () => { + expectFromHtml(` + @boundary { +
Content
+ } @error (when err instanceof SpecificError) { +
Specific Error
+ } @error (let err) { +
General Error {{ err }}
+ } + `).toEqual([ + ['BoundaryBlock'], + ['Element', 'div'], + ['Text', 'Content'], + ['BoundaryErrorBlock', 'err instanceof SpecificError'], + ['Variable', '$error', '$error'], + ['Variable', '$reset', '$reset'], + ['Element', 'div'], + ['Text', 'Specific Error'], + ['BoundaryErrorBlock', null], + ['Variable', '$error', '$error'], + ['Variable', '$reset', '$reset'], + ['Variable', 'err', '$error'], + ['Element', 'div'], + ['BoundText', 'General Error {{ err }}'], + ]); + }); + }); + describe('Nodes without binding', () => { it('should parse incomplete tags terminated by EOF', () => { expectFromHtml(' { /@loading block can only be used after an @defer block/, ); expect(() => parse('@error {error}')).toThrowError( - /@error block can only be used after an @defer block/, + /@error block can only be used after an @defer or @boundary block/, ); }); diff --git a/packages/compiler/test/render3/util/expression.ts b/packages/compiler/test/render3/util/expression.ts index a49567b49b0..cbab9ecde76 100644 --- a/packages/compiler/test/render3/util/expression.ts +++ b/packages/compiler/test/render3/util/expression.ts @@ -243,6 +243,16 @@ class ExpressionSourceHumanizer extends e.RecursiveAstVisitor implements t.Visit t.visitAll(this, block.children); } + visitBoundaryBlock(block: t.BoundaryBlock) { + t.visitAll(this, block.children); + t.visitAll(this, block.errorBlocks); + } + + visitBoundaryErrorBlock(block: t.BoundaryErrorBlock) { + block.expression?.visit(this); + t.visitAll(this, block.children); + } + visitLetDeclaration(decl: t.LetDeclaration) { decl.value.visit(this); } diff --git a/packages/core/schematics/utils/template_ast_visitor.ts b/packages/core/schematics/utils/template_ast_visitor.ts index ee250783a4e..32582328ba6 100644 --- a/packages/core/schematics/utils/template_ast_visitor.ts +++ b/packages/core/schematics/utils/template_ast_visitor.ts @@ -38,6 +38,8 @@ import type { TmplAstUnknownBlock, TmplAstVariable, TmplAstContentBlock, + TmplAstBoundaryBlock, + TmplAstBoundaryErrorBlock, } from '@angular/compiler'; /** @@ -91,6 +93,8 @@ export class TemplateAstVisitor implements TmplAstRecursiveVisitor { visitDirective(directive: TmplAstDirective): void {} visitSwitchExhaustiveCheck(block: TmplAstSwitchExhaustiveCheck): void {} visitContentBlock(block: TmplAstContentBlock): void {} + visitBoundaryBlock(block: TmplAstBoundaryBlock): void {} + visitBoundaryErrorBlock(block: TmplAstBoundaryErrorBlock): void {} /** * Visits all the provided nodes in order using this Visitor's visit methods. diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index f9d11a71af8..953c4296218 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -67,7 +67,7 @@ export { export {IdleService, provideIdleServiceWith} from './defer/idle_service'; export * from './di'; export {DOCUMENT} from './document'; -export {ErrorHandler, provideBrowserGlobalErrorListeners} from './error_handler'; +export {ErrorDetails, ErrorHandler, provideBrowserGlobalErrorListeners} from './error_handler'; export {EventEmitter} from './event_emitter'; export { DEFAULT_CURRENCY_CODE, diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 441c0d446e1..094a40e623c 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -11,12 +11,12 @@ // // no code actually imports these symbols from the @angular/core entry point export { - type DebugSignalGraph as ɵDebugSignalGraph, - type DebugSignalGraphEdge as ɵDebugSignalGraphEdge, - type DebugSignalGraphNode as ɵDebugSignalGraphNode, Framework as ɵFramework, Profiler as ɵProfiler, ProfilerEvent as ɵProfilerEvent, + type DebugSignalGraph as ɵDebugSignalGraph, + type DebugSignalGraphEdge as ɵDebugSignalGraphEdge, + type DebugSignalGraphNode as ɵDebugSignalGraphNode, } from '../primitives/devtools'; export {compileNgModuleFactory as ɵcompileNgModuleFactory} from './application/application_ngmodule_factory_compiler'; export {injectChangeDetectorRef as ɵinjectChangeDetectorRef} from './change_detection/change_detector_ref'; @@ -26,6 +26,7 @@ export { NG_INJ_DEF as ɵNG_INJ_DEF, NG_PROV_DEF as ɵNG_PROV_DEF, } from './di/interface/defs'; +export {ɵɵenableIncrementalHydrationRuntime} from './hydration/incremental_runtime'; export { setAllowDuplicateNgModuleIdsForTest as ɵsetAllowDuplicateNgModuleIdsForTest, registerNgModuleType as ɵɵregisterNgModuleType, @@ -36,10 +37,9 @@ export { } from './metadata/ng_module_def'; export {AfterRenderManager as ɵAfterRenderManager} from './render3/after_render/manager'; export {inferTagNameFromDefinition as ɵinferTagNameFromDefinition} from './render3/component_ref'; -export {ɵɵenableIncrementalHydrationRuntime} from './hydration/incremental_runtime'; export {getLContext as ɵgetLContext} from './render3/context_discovery'; -export {depsTracker as ɵdepsTracker} from './render3/deps_tracker/deps_tracker'; export {getComponentInstanceDeepLinkId as ɵgetComponentInstanceDeepLinkId} from './render3/debug/chrome_dev_tools_performance'; +export {depsTracker as ɵdepsTracker} from './render3/deps_tracker/deps_tracker'; export { NG_COMP_DEF as ɵNG_COMP_DEF, NG_DIR_DEF as ɵNG_DIR_DEF, @@ -95,6 +95,8 @@ export { ɵɵarrowFunction, ɵɵattachSourceLocations, ɵɵattribute, + ɵɵboundaryCreate, + ɵɵboundaryUpdate, ɵɵclassMap, ɵɵclassProp, ɵɵComponentDeclaration, @@ -154,12 +156,13 @@ export { ɵɵelementContainerStart, ɵɵelementEnd, ɵɵelementStart, - ɵɵforeignComponent, - ɵɵforeignContent, - ɵɵforeignContentFn, ɵɵenableBindings, ɵɵExternalStylesFeature, ɵɵFactoryDeclaration, + ɵɵforeignComponent, + ɵɵforeignContent, + ɵɵforeignContentFn, + ɵɵgetBoundary, ɵɵgetComponentDepsFactory, ɵɵgetCurrentView, ɵɵgetInheritedFactory, @@ -280,10 +283,10 @@ export { ɵɵngDeclareDirective, ɵɵngDeclareFactory, ɵɵngDeclareInjectable, - ɵɵngDeclareService, ɵɵngDeclareInjector, ɵɵngDeclareNgModule, ɵɵngDeclarePipe, + ɵɵngDeclareService, } from './render3/jit/partial'; export {compilePipe as ɵcompilePipe} from './render3/jit/pipe'; export {isNgModule as ɵisNgModule} from './render3/jit/util'; diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 73082ece833..2b92ca3e856 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -120,6 +120,9 @@ export { ɵɵconditional, ɵɵconditionalCreate, ɵɵconditionalBranchCreate, + ɵɵboundaryCreate, + ɵɵboundaryUpdate, + ɵɵgetBoundary, ɵɵdefer, ɵɵdeferWhen, ɵɵdeferOnIdle, diff --git a/packages/core/src/render3/instructions/all.ts b/packages/core/src/render3/instructions/all.ts index a6bf0c361aa..bf3faaedc4b 100644 --- a/packages/core/src/render3/instructions/all.ts +++ b/packages/core/src/render3/instructions/all.ts @@ -34,6 +34,7 @@ export * from './change_detection'; export * from './component_instance'; export * from './control'; export * from './control_flow'; +export * from './boundary'; export * from './di'; export * from './di_attr'; export * from './element'; diff --git a/packages/core/src/render3/instructions/boundary.ts b/packages/core/src/render3/instructions/boundary.ts index 2390c8a7f4b..38e2fbf8d2e 100644 --- a/packages/core/src/render3/instructions/boundary.ts +++ b/packages/core/src/render3/instructions/boundary.ts @@ -9,7 +9,7 @@ import {setActiveConsumer} from '../../../primitives/signals'; import {ErrorDetails, ErrorHandler, encapsulateBoundaryError} from '../../error_handler'; import {findAndReconcileMatchingDehydratedViews} from '../../hydration/views'; -import {Type} from '../../interface/type'; +import {Type, Writable} from '../../interface/type'; import {performanceMarkFeature} from '../../util/performance'; import {bindingUpdated} from '../bindings'; import { @@ -21,15 +21,19 @@ import { ON_ERROR, TVIEW, } from '../interfaces/view'; +import {ComponentTemplate} from '../interfaces/definition'; +import {LocalRefExtractor} from '../interfaces/node'; import {getLView, nextBindingIndex} from '../state'; import {NO_CHANGE} from '../tokens'; import {markViewForRefresh} from '../util/view_utils'; import {addLViewToLContainer, removeLViewFromLContainer} from '../view/container'; import {createAndRenderEmbeddedLView, shouldAddViewToDom} from '../view_manipulation'; -import {getExistingTNode, getLContainer} from './control_flow'; +import {createControlFlowBranch, getExistingTNode, getLContainer} from './control_flow'; /** * Error thrown when an error falls through an @boundary without matching any @error block. + * + * @publicApi 22.2 */ export class BoundaryError extends Error { constructor(message: string, options?: {cause?: unknown}) { @@ -116,13 +120,13 @@ export function ɵɵboundaryUpdate( const context = matchingTemplateIndex === primaryTemplateIndex ? undefined - : {$error: boundary.error, $retry: () => boundary.reset()}; + : {$error: boundary.error, $reset: () => boundary.reset()}; embeddedLView = createAndRenderEmbeddedLView(hostLView, templateTNode, context, { dehydratedView, }); if (matchingTemplateIndex === primaryTemplateIndex) { - embeddedLView[ON_ERROR] = (error: Error, details: any) => { + embeddedLView[ON_ERROR] = (error: Error, details: ErrorDetails) => { const boundary = hostLView[HEADER_OFFSET + slotIndex] as LBoundary; boundary.error = error; @@ -130,7 +134,7 @@ export function ɵɵboundaryUpdate( if (errorHandler) { const boundaryComponentView = hostLView[DECLARATION_COMPONENT_VIEW][CONTEXT] as any; const boundaryType: Type = boundaryComponentView.constructor; - details.boundary = { + (details as Writable).boundary = { type: boundaryType, reset: () => boundary.reset(), }; @@ -193,11 +197,9 @@ export function ɵɵboundaryUpdate( ); } else { const boundary = hostLView[HEADER_OFFSET + slotIndex] as LBoundary; - if (boundary.error !== null) { - throw new BoundaryError('Unhandled error in @boundary fell through.', { - cause: boundary.error, - }); - } + throw new BoundaryError('Unhandled error in @boundary fell through.', { + cause: boundary.error, + }); } } finally { setActiveConsumer(prevConsumer); diff --git a/packages/core/src/render3/instructions/change_detection.ts b/packages/core/src/render3/instructions/change_detection.ts index a55a776ed15..950d308b7ec 100644 --- a/packages/core/src/render3/instructions/change_detection.ts +++ b/packages/core/src/render3/instructions/change_detection.ts @@ -16,6 +16,7 @@ import { setActiveConsumer, } from '../../../primitives/signals'; +import {ProfilerEvent} from '../../../primitives/devtools'; import {encapsulateBoundaryError, ErrorDetails} from '../../error_handler'; import {RuntimeError, RuntimeErrorCode} from '../../errors'; import {Type} from '../../interface/type'; @@ -29,7 +30,7 @@ import { MOVED_VIEWS, } from '../interfaces/container'; import {ComponentTemplate, HostBindingsFunction, RenderFlags} from '../interfaces/definition'; -import {isLContainer} from '../interfaces/type_checks'; +import {isDestroyed, isLContainer} from '../interfaces/type_checks'; import { CONTEXT, DECLARATION_COMPONENT_VIEW, @@ -45,6 +46,8 @@ import { TVIEW, TView, } from '../interfaces/view'; +import {profiler} from '../profiler'; +import {executeViewQueryFn, refreshContentQueries} from '../queries/query_execution'; import { getOrBorrowReactiveLViewConsumer, getOrCreateTemporaryConsumer, @@ -52,6 +55,7 @@ import { ReactiveLViewConsumer, viewShouldHaveReactiveConsumer, } from '../reactive_lview_consumer'; +import {runEffectsInView} from '../reactivity/view_effect_runner'; import { CheckNoChangesMode, enterView, @@ -76,11 +80,6 @@ import { viewAttachedToChangeDetector, } from '../util/view_utils'; -import {ProfilerEvent} from '../../../primitives/devtools'; -import {isDestroyed} from '../interfaces/type_checks'; -import {profiler} from '../profiler'; -import {executeViewQueryFn, refreshContentQueries} from '../queries/query_execution'; -import {runEffectsInView} from '../reactivity/view_effect_runner'; import {executeTemplate} from './shared'; /** @@ -391,7 +390,6 @@ export function refreshView( // If the error handler itself throws, capture the new error and // continue propagating it up the tree to the next error boundary. errorToHandle = boundaryError; - handled = false; } } currentLView = currentLView[PARENT]; diff --git a/packages/core/src/render3/instructions/control_flow.ts b/packages/core/src/render3/instructions/control_flow.ts index 77339586aa1..cc11cc5904d 100644 --- a/packages/core/src/render3/instructions/control_flow.ts +++ b/packages/core/src/render3/instructions/control_flow.ts @@ -42,6 +42,8 @@ import {getConstant, getTNode} from '../util/view_utils'; import {createAndRenderEmbeddedLView, shouldAddViewToDom} from '../view_manipulation'; import {AnimationLViewData} from '../../animation/interfaces'; +import {allLeavingAnimations} from '../../animation/longest_animation'; +import {removeFromAnimationQueue} from '../../animation/queue'; import {removeDehydratedViews} from '../../hydration/cleanup'; import { addLViewToLContainer, @@ -50,8 +52,6 @@ import { removeLViewFromLContainer, } from '../view/container'; import {declareNoDirectiveHostTemplate} from './template'; -import {removeFromAnimationQueue} from '../../animation/queue'; -import {allLeavingAnimations} from '../../animation/longest_animation'; /** * Creates an LContainer for an ng-template representing a root node @@ -130,6 +130,32 @@ export function ɵɵconditionalBranchCreate( localRefExtractor?: LocalRefExtractor, ): typeof ɵɵconditionalBranchCreate { performanceMarkFeature('NgControlFlow'); + createControlFlowBranch( + index, + templateFn, + decls, + vars, + tagName, + attrsIndex, + localRefsIndex, + localRefExtractor, + ); + return ɵɵconditionalBranchCreate; +} + +/** + * Shared internal function to create a control flow branch (e.g. for @case, @else, @error). + */ +export function createControlFlowBranch( + index: number, + templateFn: ComponentTemplate | null, + decls: number, + vars: number, + tagName?: string | null, + attrsIndex?: number | null, + localRefsIndex?: number | null, + localRefExtractor?: LocalRefExtractor, +) { const lView = getLView(); const tView = getTView(); const attrs = getConstant(tView.consts, attrsIndex); @@ -147,7 +173,6 @@ export function ɵɵconditionalBranchCreate( localRefsIndex, localRefExtractor, ); - return ɵɵconditionalBranchCreate; } /** @@ -568,7 +593,7 @@ export function ɵɵrepeater(collection: Iterable | undefined | null): } } -function getLContainer(lView: LView, index: number): LContainer { +export function getLContainer(lView: LView, index: number): LContainer { const lContainer = lView[index]; ngDevMode && assertLContainer(lContainer); @@ -625,7 +650,7 @@ function getExistingLViewFromLContainer(lContainer: LContainer, index: number return existingLView!; } -function getExistingTNode(tView: TView, index: number): TNode { +export function getExistingTNode(tView: TView, index: number): TNode { const tNode = getTNode(tView, index); ngDevMode && assertTNode(tNode); diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 4441f25823c..5eed2d7c2ae 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -114,6 +114,9 @@ export const angularCoreEnv: {[name: string]: unknown} = (() => ({ 'ɵɵconditional': r3.ɵɵconditional, 'ɵɵconditionalCreate': r3.ɵɵconditionalCreate, 'ɵɵconditionalBranchCreate': r3.ɵɵconditionalBranchCreate, + 'ɵɵboundaryCreate': r3.ɵɵboundaryCreate, + 'ɵɵboundaryUpdate': r3.ɵɵboundaryUpdate, + 'ɵɵgetBoundary': r3.ɵɵgetBoundary, 'ɵɵdefer': r3.ɵɵdefer, 'ɵɵdeferWhen': r3.ɵɵdeferWhen, 'ɵɵdeferOnIdle': r3.ɵɵdeferOnIdle, diff --git a/packages/core/src/resource/resource.ts b/packages/core/src/resource/resource.ts index 68f3caaf376..2223eb5c69b 100644 --- a/packages/core/src/resource/resource.ts +++ b/packages/core/src/resource/resource.ts @@ -29,6 +29,7 @@ import { import {assertInInjectionContext} from '../di/contextual'; import {Injector} from '../di/injector'; import {inject} from '../di/injector_compatibility'; +import {isErrorLike} from '../error_handler'; import {RuntimeError, RuntimeErrorCode} from '../errors'; import {CACHE_ACTIVE} from '../hydration/cache'; import {DestroyRef} from '../linker/destroy_ref'; @@ -606,15 +607,6 @@ export function encapsulateResourceError(error: unknown): Error { return new ResourceWrappedError(error); } -export function isErrorLike(error: unknown): error is Error { - return ( - error instanceof Error || - (typeof error === 'object' && - typeof (error as Error).name === 'string' && - typeof (error as Error).message === 'string') - ); -} - export class ResourceValueError extends Error { constructor(error: Error) { super( diff --git a/packages/core/test/render3/error_boundary_spec.ts b/packages/core/test/render3/error_boundary_spec.ts index 6536260521d..b5ae77d9862 100644 --- a/packages/core/test/render3/error_boundary_spec.ts +++ b/packages/core/test/render3/error_boundary_spec.ts @@ -1,13 +1,24 @@ +/** + * @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 { Component, EnvironmentInjector, - signal, + ErrorHandler, + Input, TemplateRef, ViewChild, ViewContainerRef, + effect, + signal, } from '@angular/core'; -import {DeferBlockBehavior, TestBed} from '@angular/core/testing'; -import {ErrorBoundaryWrappedError, ErrorDetails, ErrorHandler} from '../../src/error_handler'; +import {DeferBlockBehavior, DeferBlockState, TestBed} from '@angular/core/testing'; +import {ErrorBoundaryWrappedError, ErrorDetails} from '../../src/error_handler'; describe('Error Boundary Runtime Interception', () => { it('should intercept errors using createComponent onError', () => { @@ -15,7 +26,6 @@ describe('Error Boundary Runtime Interception', () => { @Component({ template: '{{ throwError() }}', - standalone: true, }) class ThrowingComponent { throwError() { @@ -25,13 +35,12 @@ describe('Error Boundary Runtime Interception', () => { @Component({ template: '', - standalone: true, }) - class HostComponent { + class Host { @ViewChild('vc', {read: ViewContainerRef, static: true}) vc!: ViewContainerRef; } - const fixture = TestBed.createComponent(HostComponent); + const fixture = TestBed.createComponent(Host); fixture.detectChanges(); const envInjector = TestBed.inject(EnvironmentInjector); @@ -43,11 +52,9 @@ describe('Error Boundary Runtime Interception', () => { }, }); - // The inner component is created and attached, but it hasn't run CD yet? - // Actually `createComponent` doesn't run CD by default, we need to call `detectChanges` on the HostComponent. + // `createComponent` doesn't run CD by default, we need to call `detectChanges` on the Host. expect(() => fixture.detectChanges()).not.toThrow(); - expect(interceptedError).toBeDefined(); expect(interceptedError).toBeInstanceOf(Error); expect(interceptedError!.message).toBe('Component Error'); }); @@ -60,9 +67,8 @@ describe('Error Boundary Runtime Interception', () => { {{ throwError() }} `, - standalone: true, }) - class HostComponent { + class Host { @ViewChild('tpl', {static: true}) tpl!: TemplateRef; @ViewChild('vc', {read: ViewContainerRef, static: true}) vc!: ViewContainerRef; @@ -71,7 +77,7 @@ describe('Error Boundary Runtime Interception', () => { } } - const fixture = TestBed.createComponent(HostComponent); + const fixture = TestBed.createComponent(Host); fixture.detectChanges(); fixture.componentInstance.vc.createEmbeddedView( @@ -95,7 +101,6 @@ describe('Error Boundary Runtime Interception', () => { @Component({ template: '...', - standalone: true, }) class ThrowingInitComponent { ngOnInit() { @@ -105,13 +110,12 @@ describe('Error Boundary Runtime Interception', () => { @Component({ template: '', - standalone: true, }) - class HostComponent { + class Host { @ViewChild('vc', {read: ViewContainerRef, static: true}) vc!: ViewContainerRef; } - const fixture = TestBed.createComponent(HostComponent); + const fixture = TestBed.createComponent(Host); fixture.detectChanges(); const envInjector = TestBed.inject(EnvironmentInjector); @@ -130,12 +134,11 @@ describe('Error Boundary Runtime Interception', () => { expect(interceptedError!.message).toBe('Init Error'); }); - it('should intercept errors thrown during component constructor via createComponent', () => { + it('should NOT intercept errors thrown during component constructor via createComponent', () => { let interceptedError: Error | null = null; @Component({ template: '...', - standalone: true, }) class ThrowingConstructorComponent { constructor() { @@ -145,13 +148,12 @@ describe('Error Boundary Runtime Interception', () => { @Component({ template: '', - standalone: true, }) - class HostComponent { + class Host { @ViewChild('vc', {read: ViewContainerRef, static: true}) vc!: ViewContainerRef; } - const fixture = TestBed.createComponent(HostComponent); + const fixture = TestBed.createComponent(Host); fixture.detectChanges(); const envInjector = TestBed.inject(EnvironmentInjector); @@ -173,7 +175,6 @@ describe('Error Boundary Runtime Interception', () => { @Component({ template: '', - standalone: true, }) class MiddleComponent { @ViewChild('vc', {read: ViewContainerRef, static: true}) vc!: ViewContainerRef; @@ -181,7 +182,6 @@ describe('Error Boundary Runtime Interception', () => { @Component({ template: '...', - standalone: true, }) class ThrowChild { ngOnInit() { @@ -191,13 +191,12 @@ describe('Error Boundary Runtime Interception', () => { @Component({ template: '', - standalone: true, }) - class HostComponent { + class Host { @ViewChild('vc', {read: ViewContainerRef, static: true}) vc!: ViewContainerRef; } - const fixture = TestBed.createComponent(HostComponent); + const fixture = TestBed.createComponent(Host); fixture.detectChanges(); const middleRef = fixture.componentInstance.vc.createComponent(MiddleComponent, { @@ -1002,7 +1001,7 @@ describe('@boundary runtime instructions (JIT)', () => { @Component({ template: ` @boundary { - + Main Content } @error (let err) { Error: {{err.message}} @@ -1064,6 +1063,6 @@ describe('@boundary runtime instructions (JIT)', () => { expect(capturedDetails.declarationType).toBe(Throwing); expect(capturedDetails.boundary).toBeDefined(); expect(capturedDetails.boundary!.type).toBe(Host); - expect(typeof capturedDetails.boundary!.reset).toBe('function'); + expect(typeof capturedDetails.boundary!.retry).toBe('function'); }); });