Skip to content

fix(component): use setTimeout instead of requestAnimationFrame for server and zoneless #4912

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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,
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);
Expand All @@ -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();
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', () => {
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);
}));
});
});
25 changes: 17 additions & 8 deletions modules/component/src/core/tick-scheduler.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
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',
useFactory: () => {
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 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;
});
Expand Down