From 35326c7151ffdc3d5defd28c4f2acd208f9eb4b6 Mon Sep 17 00:00:00 2001 From: Jelle Bruisten <4212850+JelleBruisten@users.noreply.github.com> Date: Sun, 10 Aug 2025 12:24:18 +0200 Subject: [PATCH 1/2] fix(component): use setTimeout instead of requestAnimationFrame for server and zoneless requestAnimationFrame is not available in server-side contexts and will cause errors. Using setTimeout ensures compatibility across all environments. --- .../spec/core/tick-scheduler.spec.ts | 74 +++++++++++++++++-- modules/component/src/core/tick-scheduler.ts | 25 +++++-- 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/modules/component/spec/core/tick-scheduler.spec.ts b/modules/component/spec/core/tick-scheduler.spec.ts index f48428804b..afcc63ef8f 100644 --- a/modules/component/spec/core/tick-scheduler.spec.ts +++ b/modules/component/spec/core/tick-scheduler.spec.ts @@ -4,18 +4,24 @@ import { TestBed, tick, } from '@angular/core/testing'; -import { ApplicationRef, NgZone } from '@angular/core'; +import { ApplicationRef, NgZone, PLATFORM_ID } from '@angular/core'; import { - AnimationFrameTickScheduler, + ZonelessTickScheduler, NoopTickScheduler, TickScheduler, } from '../../src/core/tick-scheduler'; import { ngZoneMock, noopNgZoneMock } from '../fixtures/fixtures'; describe('TickScheduler', () => { - function setup(ngZone: unknown) { + function setup(ngZone: unknown, server = false) { TestBed.configureTestingModule({ - providers: [{ provide: NgZone, useValue: ngZone }], + providers: [ + { provide: NgZone, useValue: ngZone }, + { + provide: PLATFORM_ID, + useValue: server ? 'server' : 'browser', + }, + ], }); const tickScheduler = TestBed.inject(TickScheduler); const appRef = TestBed.inject(ApplicationRef); @@ -31,16 +37,16 @@ describe('TickScheduler', () => { }); }); - describe('when NgZone is not provided', () => { + describe('when NgZone is not provided and running in server context', () => { // `fakeAsync` uses 16ms as `requestAnimationFrame` delay const animationFrameDelay = 16; - it('should initialize AnimationFrameTickScheduler', () => { + it('should initialize ZonelessTickScheduler', () => { const { tickScheduler } = setup(noopNgZoneMock); - expect(tickScheduler instanceof AnimationFrameTickScheduler).toBe(true); + expect(tickScheduler instanceof ZonelessTickScheduler).toBe(true); }); - it('should schedule tick using the animationFrameScheduler', fakeAsync(() => { + it('should schedule tick using the ZonelessTickScheduler', fakeAsync(() => { const { tickScheduler, appRef } = setup(noopNgZoneMock); tickScheduler.schedule(); @@ -87,4 +93,56 @@ describe('TickScheduler', () => { expect(appRef.tick).toHaveBeenCalledTimes(3); })); }); + + describe('when NgZone is not provided and running in ssr', () => { + it('should initialize ZonelessTickScheduler', () => { + const { tickScheduler } = setup(noopNgZoneMock, true); + expect(tickScheduler instanceof ZonelessTickScheduler).toBe(true); + }); + + it('should schedule tick using the ZonelessTickScheduler', fakeAsync(() => { + const { tickScheduler, appRef } = setup(noopNgZoneMock, true); + + tickScheduler.schedule(); + + expect(appRef.tick).toHaveBeenCalledTimes(0); + tick(); + expect(appRef.tick).toHaveBeenCalledTimes(1); + })); + + it('should coalesce multiple synchronous schedule calls', fakeAsync(() => { + const { tickScheduler, appRef } = setup(noopNgZoneMock, true); + + tickScheduler.schedule(); + tickScheduler.schedule(); + tickScheduler.schedule(); + + tick(); + expect(appRef.tick).toHaveBeenCalledTimes(1); + })); + + it('should coalesce multiple schedule calls that are queued to the microtask queue', fakeAsync(() => { + const { tickScheduler, appRef } = setup(noopNgZoneMock, true); + + queueMicrotask(() => tickScheduler.schedule()); + queueMicrotask(() => tickScheduler.schedule()); + queueMicrotask(() => tickScheduler.schedule()); + + flushMicrotasks(); + expect(appRef.tick).toHaveBeenCalledTimes(0); + tick(); + expect(appRef.tick).toHaveBeenCalledTimes(1); + })); + + it('should schedule multiple ticks for multiple asynchronous schedule calls', fakeAsync(() => { + const { tickScheduler, appRef } = setup(noopNgZoneMock, true); + + setTimeout(() => tickScheduler.schedule(), 100); + setTimeout(() => tickScheduler.schedule(), 200); + setTimeout(() => tickScheduler.schedule(), 300); + + tick(300); + expect(appRef.tick).toHaveBeenCalledTimes(3); + })); + }); }); diff --git a/modules/component/src/core/tick-scheduler.ts b/modules/component/src/core/tick-scheduler.ts index b59fb686f8..b9a3f452fc 100644 --- a/modules/component/src/core/tick-scheduler.ts +++ b/modules/component/src/core/tick-scheduler.ts @@ -1,5 +1,12 @@ -import { ApplicationRef, inject, Injectable, NgZone } from '@angular/core'; +import { + ApplicationRef, + inject, + Injectable, + NgZone, + PLATFORM_ID, +} from '@angular/core'; import { isNgZone } from './zone-helpers'; +import { isPlatformServer } from '@angular/common'; @Injectable({ providedIn: 'root', @@ -7,7 +14,7 @@ import { isNgZone } from './zone-helpers'; const zone = inject(NgZone); return isNgZone(zone) ? new NoopTickScheduler() - : inject(AnimationFrameTickScheduler); + : inject(ZonelessTickScheduler); }, }) export abstract class TickScheduler { @@ -17,17 +24,19 @@ export abstract class TickScheduler { @Injectable({ providedIn: 'root', }) -export class AnimationFrameTickScheduler extends TickScheduler { +export class ZonelessTickScheduler extends TickScheduler { private isScheduled = false; - - constructor(private readonly appRef: ApplicationRef) { - super(); - } + private readonly platformId = inject(PLATFORM_ID); + private readonly isServer = isPlatformServer(this.platformId); + private readonly appRef = inject(ApplicationRef); + private readonly scheduleFn = this.isServer + ? setTimeout + : requestAnimationFrame; schedule(): void { if (!this.isScheduled) { this.isScheduled = true; - requestAnimationFrame(() => { + this.scheduleFn(() => { this.appRef.tick(); this.isScheduled = false; }); From b3029d3637882d40080037e9bb93e8ae6475380d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Stanimirovi=C4=87?= Date: Tue, 12 Aug 2025 23:04:44 +0200 Subject: [PATCH 2/2] refactor --- modules/component/spec/core/tick-scheduler.spec.ts | 14 +++++++------- modules/component/src/core/tick-scheduler.ts | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/modules/component/spec/core/tick-scheduler.spec.ts b/modules/component/spec/core/tick-scheduler.spec.ts index afcc63ef8f..9cb853c5f0 100644 --- a/modules/component/spec/core/tick-scheduler.spec.ts +++ b/modules/component/spec/core/tick-scheduler.spec.ts @@ -6,20 +6,20 @@ import { } from '@angular/core/testing'; import { ApplicationRef, NgZone, PLATFORM_ID } from '@angular/core'; import { - ZonelessTickScheduler, NoopTickScheduler, TickScheduler, + ZonelessTickScheduler, } from '../../src/core/tick-scheduler'; import { ngZoneMock, noopNgZoneMock } from '../fixtures/fixtures'; describe('TickScheduler', () => { - function setup(ngZone: unknown, server = false) { + function setup(ngZone: unknown, isSsrMode = false) { TestBed.configureTestingModule({ providers: [ { provide: NgZone, useValue: ngZone }, { provide: PLATFORM_ID, - useValue: server ? 'server' : 'browser', + useValue: isSsrMode ? 'server' : 'browser', }, ], }); @@ -37,7 +37,7 @@ describe('TickScheduler', () => { }); }); - describe('when NgZone is not provided and running in server context', () => { + describe('when NgZone is not provided and running in browser mode', () => { // `fakeAsync` uses 16ms as `requestAnimationFrame` delay const animationFrameDelay = 16; @@ -46,7 +46,7 @@ describe('TickScheduler', () => { expect(tickScheduler instanceof ZonelessTickScheduler).toBe(true); }); - it('should schedule tick using the ZonelessTickScheduler', fakeAsync(() => { + it('should schedule tick using requestAnimationFrame', fakeAsync(() => { const { tickScheduler, appRef } = setup(noopNgZoneMock); tickScheduler.schedule(); @@ -94,13 +94,13 @@ describe('TickScheduler', () => { })); }); - describe('when NgZone is not provided and running in ssr', () => { + describe('when NgZone is not provided and running in SSR mode', () => { it('should initialize ZonelessTickScheduler', () => { const { tickScheduler } = setup(noopNgZoneMock, true); expect(tickScheduler instanceof ZonelessTickScheduler).toBe(true); }); - it('should schedule tick using the ZonelessTickScheduler', fakeAsync(() => { + it('should schedule tick using setTimeout', fakeAsync(() => { const { tickScheduler, appRef } = setup(noopNgZoneMock, true); tickScheduler.schedule(); diff --git a/modules/component/src/core/tick-scheduler.ts b/modules/component/src/core/tick-scheduler.ts index b9a3f452fc..5bc2159372 100644 --- a/modules/component/src/core/tick-scheduler.ts +++ b/modules/component/src/core/tick-scheduler.ts @@ -5,8 +5,8 @@ import { NgZone, PLATFORM_ID, } from '@angular/core'; -import { isNgZone } from './zone-helpers'; import { isPlatformServer } from '@angular/common'; +import { isNgZone } from './zone-helpers'; @Injectable({ providedIn: 'root', @@ -25,13 +25,13 @@ export abstract class TickScheduler { providedIn: 'root', }) export class ZonelessTickScheduler extends TickScheduler { - private isScheduled = false; + private readonly appRef = inject(ApplicationRef); private readonly platformId = inject(PLATFORM_ID); private readonly isServer = isPlatformServer(this.platformId); - private readonly appRef = inject(ApplicationRef); private readonly scheduleFn = this.isServer ? setTimeout : requestAnimationFrame; + private isScheduled = false; schedule(): void { if (!this.isScheduled) {