Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 66 additions & 8 deletions modules/component/spec/core/tick-scheduler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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);
}));
});
});
25 changes: 17 additions & 8 deletions modules/component/src/core/tick-scheduler.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 {
Expand All @@ -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;
});
Expand Down