diff --git a/goldens/public-api/core/errors.md b/goldens/public-api/core/errors.md
index 4aeefda8d79..9d36522e527 100644
--- a/goldens/public-api/core/errors.md
+++ b/goldens/public-api/core/errors.md
@@ -29,6 +29,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
CYCLIC_DI_DEPENDENCY = -200,
// (undocumented)
+ DEFER_LOADING_FAILED = 750,
+ // (undocumented)
DUPLICATE_DIRECTITVE = 309,
// (undocumented)
EXPORT_NOT_FOUND = -301,
diff --git a/packages/core/src/defer/instructions.ts b/packages/core/src/defer/instructions.ts
index f5156d246a5..41bb54135ab 100644
--- a/packages/core/src/defer/instructions.ts
+++ b/packages/core/src/defer/instructions.ts
@@ -7,12 +7,15 @@
*/
import {InjectionToken, Injector} from '../di';
+import {RuntimeError, RuntimeErrorCode} from '../errors';
import {findMatchingDehydratedView} from '../hydration/views';
import {populateDehydratedViewsInLContainer} from '../linker/view_container_ref';
import {assertLContainer, assertTNodeForLView} from '../render3/assert';
import {bindingUpdated} from '../render3/bindings';
import {getComponentDef, getDirectiveDef, getPipeDef} from '../render3/definition';
+import {getTemplateLocationDetails} from '../render3/instructions/element_validation';
import {markViewDirty} from '../render3/instructions/mark_view_dirty';
+import {handleError} from '../render3/instructions/shared';
import {ɵɵtemplate} from '../render3/instructions/template';
import {LContainer} from '../render3/interfaces/container';
import {DirectiveDefList, PipeDefList} from '../render3/interfaces/definition';
@@ -465,7 +468,11 @@ export function renderDeferBlockState(
const applyStateFn =
needsScheduling ? applyDeferBlockStateWithSchedulingImpl! : applyDeferBlockState;
- applyStateFn(newState, lDetails, lContainer, tNode, hostLView);
+ try {
+ applyStateFn(newState, lDetails, lContainer, tNode, hostLView);
+ } catch (error: unknown) {
+ handleError(hostLView, error);
+ }
}
}
@@ -667,6 +674,17 @@ export function triggerResourceLoading(tDetails: TDeferBlockDetails, lView: LVie
if (failed) {
tDetails.loadingState = DeferDependenciesLoadingState.FAILED;
+
+ if (tDetails.errorTmplIndex === null) {
+ const templateLocation = getTemplateLocationDetails(lView);
+ const error = new RuntimeError(
+ RuntimeErrorCode.DEFER_LOADING_FAILED,
+ ngDevMode &&
+ 'Loading dependencies for `@defer` block failed, ' +
+ `but no \`@error\` block was configured${templateLocation}. ` +
+ 'Consider using the `@error` block to render an error state.');
+ handleError(lView, error);
+ }
} else {
tDetails.loadingState = DeferDependenciesLoadingState.COMPLETE;
diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts
index 331f765de69..d220794f37d 100644
--- a/packages/core/src/errors.ts
+++ b/packages/core/src/errors.ts
@@ -93,6 +93,9 @@ export const enum RuntimeErrorCode {
INVALID_I18N_STRUCTURE = 700,
MISSING_LOCALE_DATA = 701,
+ // Defer errors (750-799 range)
+ DEFER_LOADING_FAILED = 750,
+
// standalone errors
IMPORT_PROVIDERS_FROM_STANDALONE = 800,
diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts
index ae1c69dde4b..94a10299979 100644
--- a/packages/core/test/acceptance/defer_spec.ts
+++ b/packages/core/test/acceptance/defer_spec.ts
@@ -7,7 +7,7 @@
*/
import {ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID} from '@angular/common';
-import {Attribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, inject, Input, NgZone, Pipe, PipeTransform, PLATFORM_ID, QueryList, Type, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core';
+import {Attribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, ErrorHandler, inject, Input, NgZone, Pipe, PipeTransform, PLATFORM_ID, QueryList, Type, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core';
import {getComponentDef} from '@angular/core/src/render3/definition';
import {ComponentFixture, DeferBlockBehavior, fakeAsync, flush, TestBed, tick} from '@angular/core/testing';
@@ -863,6 +863,156 @@ describe('@defer', () => {
expect(fixture.componentInstance.cmps.length).toBe(1);
expect(fixture.componentInstance.cmps.get(0)?.block).toBe('error');
});
+
+ it('should report an error to the ErrorHandler if no `@error` block is defined', async () => {
+ @Component({
+ selector: 'nested-cmp',
+ standalone: true,
+ template: 'NestedCmp',
+ })
+ class NestedCmp {
+ }
+
+ @Component({
+ standalone: true,
+ selector: 'simple-app',
+ imports: [NestedCmp],
+ template: `
+ @defer (when isVisible) {
+
+ } @loading {
+ Loading...
+ } @placeholder {
+ Placeholder
+ }
+ `
+ })
+ class MyCmp {
+ isVisible = false;
+ }
+
+ const deferDepsInterceptor = {
+ intercept() {
+ return () => [failedDynamicImport()];
+ }
+ };
+
+ const reportedErrors: Error[] = [];
+ TestBed.configureTestingModule({
+ providers: [
+ {
+ provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
+ useValue: deferDepsInterceptor,
+ },
+ {
+ provide: ErrorHandler, useClass: class extends ErrorHandler{
+ override handleError(error: Error) {
+ reportedErrors.push(error);
+ }
+ },
+ },
+ ],
+ deferBlockBehavior: DeferBlockBehavior.Playthrough,
+ });
+
+ const fixture = TestBed.createComponent(MyCmp);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.outerHTML).toContain('Placeholder');
+
+ fixture.componentInstance.isVisible = true;
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.outerHTML).toContain('Loading');
+
+ // Wait for dependencies to load.
+ await allPendingDynamicImports();
+ fixture.detectChanges();
+
+ // Verify that there was an error reported to the `ErrorHandler`.
+ expect(reportedErrors.length).toBe(1);
+ expect(reportedErrors[0].message).toContain('NG0750');
+ expect(reportedErrors[0].message).toContain(`(used in the 'MyCmp' component template)`);
+ });
+
+ it('should not render `@error` block if loaded component has errors', async () => {
+ @Component({
+ selector: 'cmp-with-error',
+ standalone: true,
+ template: 'CmpWithError',
+ })
+ class CmpWithError {
+ constructor() {
+ throw new Error('CmpWithError produced an error');
+ }
+ }
+
+ @Component({
+ standalone: true,
+ selector: 'simple-app',
+ imports: [CmpWithError],
+ template: `
+ @defer (when isVisible) {
+
+ } @loading {
+ Loading...
+ } @error {
+ Error
+ } @placeholder {
+ Placeholder
+ }
+ `
+ })
+ class MyCmp {
+ isVisible = false;
+ }
+
+ const deferDepsInterceptor = {
+ intercept() {
+ return () => [dynamicImportOf(CmpWithError)];
+ }
+ };
+
+ const reportedErrors: Error[] = [];
+ TestBed.configureTestingModule({
+ providers: [
+ {
+ provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
+ useValue: deferDepsInterceptor,
+ },
+ {
+ provide: ErrorHandler, useClass: class extends ErrorHandler{
+ override handleError(error: Error) {
+ reportedErrors.push(error);
+ }
+ },
+ },
+ ],
+ deferBlockBehavior: DeferBlockBehavior.Playthrough,
+ });
+
+ const fixture = TestBed.createComponent(MyCmp);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.outerHTML).toContain('Placeholder');
+
+ fixture.componentInstance.isVisible = true;
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.outerHTML).toContain('Loading');
+
+ // Wait for dependencies to load.
+ await allPendingDynamicImports();
+ fixture.detectChanges();
+
+ // Expect an error to be reported to the `ErrorHandler`.
+ expect(reportedErrors.length).toBe(1);
+ expect(reportedErrors[0].message).toBe('CmpWithError produced an error');
+
+ // Expect that the `@loading` UI is removed, but the `@error` is *not* rendered,
+ // because it was a component initialization error, not resource loading issue.
+ expect(fixture.nativeElement.textContent).toBe('');
+ });
});
describe('queries', () => {
diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json
index f6aff46ce98..d77d4bedd9e 100644
--- a/packages/core/test/bundling/defer/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json
@@ -962,6 +962,9 @@
{
"name": "getTView"
},
+ {
+ "name": "handleError"
+ },
{
"name": "hasInSkipHydrationBlockFlag"
},
@@ -2363,6 +2366,9 @@
{
"name": "switchMap"
},
+ {
+ "name": "throwError"
+ },
{
"name": "throwProviderNotFoundError"
},