I have a component in an Angular 19 application with a vertically scroll-able window where the content outside the window is hidden, i.e., the window content extends beyond the top and bottom of the fixed window and the content can be centered with a button press. There is no horizontal scrolling.
Initial centering of the content is done with an effect in the constructor:
element = viewChild.required<ElementRef>('myWindow');
#scrollTo = computed(() => {
const nativeElement = this.element().nativeElement;
const scrollHeight: number = nativeElement.scrollHeight;
const offsetHeight: number = nativeElement.offsetHeight;
return 4 + (scrollHeight - offsetHeight) / 2;
});
constructor() {
effect(() =>
this.element().nativeElement.scrollTo(0, this.#scrollTo())
);
}
Most of the time scrollHeight
is 700px and offsetHeight
is 300px and things work properly; however, about one in ten or fifteen refreshes (in Chrome) the scrollHeight
and offsetHeight
are the same, in this case 38px which cause the centering to fail.
As you might guess, hard-coding the scroll-to value does not fix the problem (and it would not be a viable solution, either).
I'm guessing this is race-condition between the browser's (Chrome in my case) layout calculation and the scrollTo
signal calculation? Any ideas how to fix this calculation and/or behavior, without introducing a delay in the component constructor?
Here's a StackBlitz demonstrating the problem; you can change the window component's constructor to see various working/not-working combinations.
UPDATES
I filed an Angular bug report as it appears to be framework problem; a work-around is necessary until an Angular fix is available.
This issue is only present during development when HMR is enabled; so, the correct solution for production is the renderAfterEffect
without a timeout.
constructor() {
renderAfterEffect(() =>
this.element().nativeElement.scrollTo(0, this.#scrollTo())
);
}
As Matthieu Riegler noted the correct effect to use for DOM interactions is renderAfterEffect
; unfortunately, this also fails at the moment without a timeout or by disabling Hot Module Replacement (HMR):
constructor() {
renderAfterEffect(() =>
setTimeout(() =>
this.element().nativeElement.scrollTo(0, this.#scrollTo()),
200
)
);
}
As Naren Murali noted in the second part of his answer, a timeout delay will work-around the problem:
constructor() {
effect(() =>
setTimeout(() =>
this.element().nativeElement.scrollTo(0, this.#scrollTo()),
200
)
);
}
However, as Matthieu Riegler stated, the correct work-around should use renderAfterEffect
.
This effect
should actually be an afterRenderEffect()
:
constructor() {
afterRenderEffect(() =>
this.element().nativeElement.scrollTo(0, this.#scrollTo())
);
}
effect
is known to run before the sync process, while afterRenderEffect()
runs after the app has been rendered. This API is specifically recommended for this kind of cases where you want to read & alter the DOM.
Edit: As answered in the angular issue, this is an HMR issue. The styles are being loaded after the callback was fired, thus reading an offsetHeight
that was soon to be outdated.
The workaround is to disable HMR with the --no-hmr
flag.