diff --git a/modules/component/spec/core/tick-scheduler.spec.ts b/modules/component/spec/core/tick-scheduler.spec.ts index f48428804b..9cb853c5f0 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, NoopTickScheduler, TickScheduler, + ZonelessTickScheduler, } from '../../src/core/tick-scheduler'; import { ngZoneMock, noopNgZoneMock } from '../fixtures/fixtures'; describe('TickScheduler', () => { - function setup(ngZone: unknown) { + function setup(ngZone: unknown, isSsrMode = false) { TestBed.configureTestingModule({ - providers: [{ provide: NgZone, useValue: ngZone }], + providers: [ + { provide: NgZone, useValue: ngZone }, + { + provide: PLATFORM_ID, + useValue: isSsrMode ? '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 browser mode', () => { // `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 requestAnimationFrame', 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 mode', () => { + it('should initialize ZonelessTickScheduler', () => { + const { tickScheduler } = setup(noopNgZoneMock, true); + expect(tickScheduler instanceof ZonelessTickScheduler).toBe(true); + }); + + it('should schedule tick using setTimeout', 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..5bc2159372 100644 --- a/modules/component/src/core/tick-scheduler.ts +++ b/modules/component/src/core/tick-scheduler.ts @@ -1,4 +1,11 @@ -import { ApplicationRef, inject, Injectable, NgZone } from '@angular/core'; +import { + ApplicationRef, + inject, + Injectable, + NgZone, + PLATFORM_ID, +} from '@angular/core'; +import { isPlatformServer } from '@angular/common'; import { isNgZone } from './zone-helpers'; @Injectable({ @@ -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 readonly appRef = inject(ApplicationRef); + private readonly platformId = inject(PLATFORM_ID); + private readonly isServer = isPlatformServer(this.platformId); + private readonly scheduleFn = this.isServer + ? setTimeout + : requestAnimationFrame; private isScheduled = false; - constructor(private readonly appRef: ApplicationRef) { - super(); - } - schedule(): void { if (!this.isScheduled) { this.isScheduled = true; - requestAnimationFrame(() => { + this.scheduleFn(() => { this.appRef.tick(); this.isScheduled = false; });