Skip to content

Commit f03d274

Browse files
atscottdylhunn
authored andcommitted
fix(core): ComponentFixture autoDetect feature works like production (#55228)
This commit fully integrates the `autoDetect` feature into `ApplicationRef.tick` without special handling for errors. This commit also shares the method of autoDetect for change detection between the zoneless and zone component fixture implementations. The difference is now limited to: * autoDetect is defaulted to true with zoneless * detectChanges with zoneless is AppRef.tick while it is ChangeDetectorRef.detectChanges with zones. This should likely converge more in the future. Not going through AppRef.tick means that the zone fixture does not get guaranteed `afterRender` executions and does not get the rerunning behavior if the fixture is marked dirty by a render hook. BREAKING CHANGE: The `autoDetect` feature of `ComponentFixture` will now attach the fixture to the `ApplicationRef`. As a result, errors during automatic change detection of the fixture be reported to the `ErrorHandler`. This change may cause custom error handlers to observe new failures that were previously unreported. PR Close #55228
1 parent 3b0dca7 commit f03d274

4 files changed

Lines changed: 76 additions & 54 deletions

File tree

packages/core/src/application/application_ref.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ export class ApplicationRef {
318318
/** @internal */
319319
afterTick = new Subject<void>();
320320
/** @internal */
321-
get allViews() {
321+
get allViews(): Array<InternalViewRef<unknown>> {
322322
return [...this.externalTestViews.keys(), ...this._views];
323323
}
324324

@@ -577,7 +577,7 @@ export class ApplicationRef {
577577
this.detectChangesInAttachedViews(refreshViews);
578578

579579
if (typeof ngDevMode === 'undefined' || ngDevMode) {
580-
for (let view of this._views) {
580+
for (let view of this.allViews) {
581581
view.checkNoChanges();
582582
}
583583
}
@@ -605,7 +605,7 @@ export class ApplicationRef {
605605
// After the we execute render hooks in the first pass, we loop while views are marked dirty and should refresh them.
606606
if (refreshViews || !isFirstPass) {
607607
this.beforeRender.next(isFirstPass);
608-
for (let {_lView, notifyErrorHandler} of this._views) {
608+
for (let {_lView, notifyErrorHandler} of this.allViews) {
609609
detectChangesInViewIfRequired(
610610
_lView,
611611
notifyErrorHandler,

packages/core/src/render3/instructions/shared.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,8 @@ export function createLView<T>(
210210
LViewFlags.CreationMode |
211211
LViewFlags.Attached |
212212
LViewFlags.FirstLViewPass |
213-
LViewFlags.Dirty;
213+
LViewFlags.Dirty |
214+
LViewFlags.RefreshView;
214215
if (
215216
embeddedViewInjector !== null ||
216217
(parentLView && parentLView[FLAGS] & LViewFlags.HasEmbeddedViewInjector)

packages/core/test/component_fixture_spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ErrorHandler,
1414
Injectable,
1515
Input,
16+
NgZone,
1617
createComponent,
1718
provideExperimentalZonelessChangeDetection,
1819
signal,
@@ -460,6 +461,47 @@ describe('ComponentFixture', () => {
460461
expect(() => fixture.detectChanges()).toThrow();
461462
});
462463
});
464+
465+
it('reports errors from autoDetect change detection to error handler', () => {
466+
let throwError = false;
467+
@Component({template: ''})
468+
class TestComponent {
469+
ngDoCheck() {
470+
if (throwError) {
471+
throw new Error();
472+
}
473+
}
474+
}
475+
const fixture = TestBed.createComponent(TestComponent);
476+
fixture.autoDetectChanges();
477+
const errorHandler = TestBed.inject(ErrorHandler);
478+
const spy = spyOn(errorHandler, 'handleError').and.callThrough();
479+
480+
throwError = true;
481+
TestBed.inject(NgZone).run(() => {});
482+
expect(spy).toHaveBeenCalled();
483+
});
484+
485+
it('reports errors from checkNoChanges in autoDetect to error handler', () => {
486+
let throwError = false;
487+
@Component({template: '{{thing}}'})
488+
class TestComponent {
489+
thing = 'initial';
490+
ngAfterViewChecked() {
491+
if (throwError) {
492+
this.thing = 'new';
493+
}
494+
}
495+
}
496+
const fixture = TestBed.createComponent(TestComponent);
497+
fixture.autoDetectChanges();
498+
const errorHandler = TestBed.inject(ErrorHandler);
499+
const spy = spyOn(errorHandler, 'handleError').and.callThrough();
500+
501+
throwError = true;
502+
TestBed.inject(NgZone).run(() => {});
503+
expect(spy).toHaveBeenCalled();
504+
});
463505
});
464506

465507
describe('ComponentFixture with zoneless', () => {

packages/core/testing/src/component_fixture.ts

Lines changed: 29 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
ApplicationRef,
1111
ChangeDetectorRef,
1212
ComponentRef,
13+
ɵChangeDetectionScheduler,
14+
ɵNotificationSource,
1315
DebugElement,
1416
ElementRef,
1517
getDebugNode,
@@ -18,7 +20,6 @@ import {
1820
RendererFactory2,
1921
ViewRef,
2022
ɵDeferBlockDetails as DeferBlockDetails,
21-
ɵdetectChangesInViewIfRequired,
2223
ɵEffectScheduler as EffectScheduler,
2324
ɵgetDeferBlocks as getDeferBlocks,
2425
ɵNoopNgZone as NoopNgZone,
@@ -81,6 +82,9 @@ export abstract class ComponentFixture<T> {
8182
protected readonly _testAppRef = this._appRef as unknown as TestAppRef;
8283
private readonly pendingTasks = inject(PendingTasks);
8384
private readonly appErrorHandler = inject(TestBedApplicationErrorHandler);
85+
/** @internal */
86+
protected abstract _autoDetect: boolean;
87+
private readonly scheduler = inject(ɵChangeDetectionScheduler, {optional: true});
8488

8589
// TODO(atscott): Remove this from public API
8690
ngZone = this._noZoneOptionIsSet ? null : this._ngZone;
@@ -95,6 +99,17 @@ export abstract class ComponentFixture<T> {
9599
this.componentRef = componentRef;
96100
}
97101

102+
/** @internal */
103+
initialize(): void {
104+
if (this._autoDetect) {
105+
this._testAppRef.externalTestViews.add(this.componentRef.hostView);
106+
this.scheduler?.notify(ɵNotificationSource.ViewAttached);
107+
}
108+
this.componentRef.hostView.onDestroy(() => {
109+
this._testAppRef.externalTestViews.delete(this.componentRef.hostView);
110+
});
111+
}
112+
98113
/**
99114
* Trigger a change detection cycle for the component.
100115
*/
@@ -180,6 +195,7 @@ export abstract class ComponentFixture<T> {
180195
* Trigger component destruction.
181196
*/
182197
destroy(): void {
198+
this._testAppRef.externalTestViews.delete(this.componentRef.hostView);
183199
if (!this._isDestroyed) {
184200
this.componentRef.destroy();
185201
this._isDestroyed = true;
@@ -194,9 +210,11 @@ export abstract class ComponentFixture<T> {
194210
* `ApplicationRef.isStable`, and `autoDetectChanges` cannot be disabled.
195211
*/
196212
export class ScheduledComponentFixture<T> extends ComponentFixture<T> {
197-
private _autoDetect = inject(ComponentFixtureAutoDetect, {optional: true}) ?? true;
213+
/** @internal */
214+
protected override _autoDetect = inject(ComponentFixtureAutoDetect, {optional: true}) ?? true;
198215

199-
initialize(): void {
216+
override initialize(): void {
217+
super.initialize();
200218
if (this._autoDetect) {
201219
this._appRef.attachView(this.componentRef.hostView);
202220
}
@@ -230,26 +248,24 @@ export class ScheduledComponentFixture<T> extends ComponentFixture<T> {
230248

231249
interface TestAppRef {
232250
externalTestViews: Set<ViewRef>;
233-
beforeRender: Subject<boolean>;
234-
afterTick: Subject<void>;
235251
}
236252

237253
/**
238254
* ComponentFixture behavior that attempts to act as a "mini application".
239255
*/
240256
export class PseudoApplicationComponentFixture<T> extends ComponentFixture<T> {
241257
private _subscriptions = new Subscription();
242-
private _autoDetect = inject(ComponentFixtureAutoDetect, {optional: true}) ?? false;
243-
private afterTickSubscription: Subscription | undefined = undefined;
244-
private beforeRenderSubscription: Subscription | undefined = undefined;
258+
/** @internal */
259+
override _autoDetect = inject(ComponentFixtureAutoDetect, {optional: true}) ?? false;
245260

246-
initialize(): void {
261+
override initialize(): void {
247262
if (this._autoDetect) {
248-
this.subscribeToAppRefEvents();
263+
this._testAppRef.externalTestViews.add(this.componentRef.hostView);
249264
}
250265
this.componentRef.hostView.onDestroy(() => {
251-
this.unsubscribeFromAppRefEvents();
266+
this._testAppRef.externalTestViews.delete(this.componentRef.hostView);
252267
});
268+
253269
// Create subscriptions outside the NgZone so that the callbacks run outside
254270
// of NgZone.
255271
this._ngZone.runOutsideAngular(() => {
@@ -285,54 +301,17 @@ export class PseudoApplicationComponentFixture<T> extends ComponentFixture<T> {
285301

286302
if (autoDetect !== this._autoDetect) {
287303
if (autoDetect) {
288-
this.subscribeToAppRefEvents();
304+
this._testAppRef.externalTestViews.add(this.componentRef.hostView);
289305
} else {
290-
this.unsubscribeFromAppRefEvents();
306+
this._testAppRef.externalTestViews.delete(this.componentRef.hostView);
291307
}
292308
}
293309

294310
this._autoDetect = autoDetect;
295311
this.detectChanges();
296312
}
297313

298-
private subscribeToAppRefEvents() {
299-
this._ngZone.runOutsideAngular(() => {
300-
this.afterTickSubscription = this._testAppRef.afterTick.subscribe(() => {
301-
this.checkNoChanges();
302-
});
303-
this.beforeRenderSubscription = this._testAppRef.beforeRender.subscribe((isFirstPass) => {
304-
try {
305-
ɵdetectChangesInViewIfRequired(
306-
(this.componentRef.hostView as any)._lView,
307-
(this.componentRef.hostView as any).notifyErrorHandler,
308-
isFirstPass,
309-
false /** zoneless enabled */,
310-
);
311-
} catch (e: unknown) {
312-
// If an error occurred during change detection, remove the test view from the application
313-
// ref tracking. Note that this isn't exactly desirable but done this way because of how
314-
// things used to work with `autoDetect` and uncaught errors. Ideally we would surface
315-
// this error to the error handler instead and continue refreshing the view like
316-
// what would happen in the application.
317-
this.unsubscribeFromAppRefEvents();
318-
319-
throw e;
320-
}
321-
});
322-
this._testAppRef.externalTestViews.add(this.componentRef.hostView);
323-
});
324-
}
325-
326-
private unsubscribeFromAppRefEvents() {
327-
this.afterTickSubscription?.unsubscribe();
328-
this.beforeRenderSubscription?.unsubscribe();
329-
this.afterTickSubscription = undefined;
330-
this.beforeRenderSubscription = undefined;
331-
this._testAppRef.externalTestViews.delete(this.componentRef.hostView);
332-
}
333-
334314
override destroy(): void {
335-
this.unsubscribeFromAppRefEvents();
336315
this._subscriptions.unsubscribe();
337316
super.destroy();
338317
}

0 commit comments

Comments
 (0)