angular / angular

Deliver web apps with confidence 🚀
https://angular.dev
MIT License
95.69k stars 25.24k forks source link

In unit test, view children are populated after afterNextRender callback is called rather than before #57313

Open jnizet opened 1 month ago

jnizet commented 1 month ago

Which @angular/* package(s) are the source of the bug?

core

Is this a regression?

No

Description

Given the following component:

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [],
  template: `
    <button (click)="click()">Show inputs and focus the firt one</button>
    @if (inputsShown) {
      <input #foo />
    }
  `,
})
export class AppComponent {
  title = 'repro';

  inputs = viewChildren<ElementRef<HTMLInputElement>>('foo');

  inputsShown = false;
  injector = inject(Injector);

  click() {
    this.inputsShown = true;
    afterNextRender(
      () => {
        console.log('inputs().length = ' + this.inputs().length);
        console.log('input = ' + this.inputs()[0]);
        this.inputs()[0]?.nativeElement.focus();
      },
      {
        injector: this.injector,
      }
    );
  }
}

I expect that clicking the button will make the input appear and give it the focus. And it indeed does that, but not in a unit test.

When running the following unit test, all the expectations pass except the last one, and the console logs show that the length of the view children inside the afterNextRender callback is 0.

describe('AppComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [AppComponent],
    }).compileComponents();
  });

  it('should give the focus to the input', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;

    fixture.detectChanges();

    const button = fixture.debugElement.query(By.css('button')).nativeElement;

    button.click();
    fixture.detectChanges();

    // check that the input has been displayed (passes)
    const input = fixture.debugElement.query(By.css('input')).nativeElement;
    expect(input).toBeTruthy();

    // check that the viewChildren is populated (passes)
    expect(app.inputs().length).toBe(1);
    expect(app.inputs()[0].nativeElement).toBe(input);

    // check that it has the focus (fails, and console shows the input from the viewChildren is undefined)
    expect(document.activeElement).toBe(input);
  });
});

The same problem occurs with @ViewChildrenor viewChild is being used.

Please provide a link to a minimal reproduction of the bug

https://stackblitz.com/edit/stackblitz-starters-kkjfwf?file=repro%2Fsrc%2Fapp%2Fapp.component.spec.ts,repro%2Fsrc%2Fapp%2Fapp.component.ts

Please provide the exception or error you saw

LOG: 'inputs().length = 0'
LOG: 'input = undefined'
[...]
Expected <body>...</body> to be <input>

Please provide the environment you discovered this bug in (run ng version)

Angular CLI: 18.1.4
Node: 18.20.3
Package Manager: npm 10.2.3
OS: linux x64

Angular: 18.1.4
... animations, cli, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1801.4
@angular-devkit/build-angular   18.1.4
@angular-devkit/core            18.1.4
@angular-devkit/schematics      18.1.4
@schematics/angular             18.1.4
rxjs                            7.8.1
typescript                      5.5.4
zone.js                         0.14.10

Anything else?

To run the repro, here are the commands to run in the terminal:

cd repro
npm install
ng serve # to test that everything works fine in real life)
ng test
atscott commented 1 month ago

afterNextRender runs after the application synchronization happens. With ZoneJS, this happens synchronously after the click because the handler is called inside the angular zone. There are two ways to resolve this for your test:

jnizet commented 1 month ago

Thank you @atscott.

MurhafSousli commented 2 days ago

The same applies to inputs signals