[go: nahoru, domu]

blob: 104a664fbcda3ca8e584b549e4d90a2c68dea12e [file] [log] [blame]
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* Components don't orchestrate their DOM updates in a wider context
* (i.e. the host frame's document), which leads to interleaved reading
* and writing of layout-centric values, e.g. clientHeight, scrollTop etc.
*
* This helper to ensure that we do reads, writes, and scrolls at the
* correct point in the frame lifecycle. It groups reads to the start of a
* frame, where we can assume layout-centric values are available on the
* basis of the last completed frame, and then it runs all writes
* afterwards. In the event that a read / write / scroll callback contains
* calls for more read / write / scroll calls, such calls will be scheduled
* for the next available frame.
*/
interface CoordinatorCallback {
(): unknown;
}
interface WorkItem {
trigger: () => void;
cancel: (_: Error) => void;
promise: Promise<unknown>;
handler: CoordinatorCallback;
label: string;
}
interface CoordinatorFrame {
readers: WorkItem[];
writers: WorkItem[];
}
interface CoordinatorLogEntry {
time: number;
value: string;
}
const enum ACTION {
READ = 'read',
WRITE = 'write',
}
export class RenderCoordinatorQueueEmptyEvent extends Event {
static readonly eventName = 'renderqueueempty';
constructor() {
super(RenderCoordinatorQueueEmptyEvent.eventName);
}
}
export class RenderCoordinatorNewFrameEvent extends Event {
static readonly eventName = 'newframe';
constructor() {
super(RenderCoordinatorNewFrameEvent.eventName);
}
}
let renderCoordinatorInstance: RenderCoordinator;
const UNNAMED_READ = 'Unnamed read';
const UNNAMED_WRITE = 'Unnamed write';
const UNNAMED_SCROLL = 'Unnamed scroll';
const DEADLOCK_TIMEOUT = 1500;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).__getRenderCoordinatorPendingFrames = function(): number {
return RenderCoordinator.pendingFramesCount();
};
export class RenderCoordinator extends EventTarget {
static instance({forceNew = false} = {}): RenderCoordinator {
if (!renderCoordinatorInstance || forceNew) {
renderCoordinatorInstance = new RenderCoordinator();
}
return renderCoordinatorInstance;
}
static pendingFramesCount(): number {
if (!renderCoordinatorInstance) {
throw new Error('No render coordinator instance found.');
}
return renderCoordinatorInstance.pendingFramesCount();
}
// Toggle on to start tracking. You must call takeRecords() to
// obtain the records. Please note: records are limited by maxRecordSize below.
observe = false;
recordStorageLimit = 100;
// If true, only log activity with an explicit label.
// This does not affect logging frames or queue empty events.
observeOnlyNamed = true;
readonly #logInternal: CoordinatorLogEntry[] = [];
readonly #pendingWorkFrames: CoordinatorFrame[] = [];
#scheduledWorkId = 0;
pendingFramesCount(): number {
return this.#pendingWorkFrames.length;
}
done(options?: {waitForWork: boolean}): Promise<void> {
if (this.#pendingWorkFrames.length === 0 && !options?.waitForWork) {
this.#logIfEnabled('[Queue empty]');
return Promise.resolve();
}
return new Promise(resolve => this.addEventListener('renderqueueempty', () => resolve(), {once: true}));
}
async read<T extends unknown>(callback: CoordinatorCallback): Promise<T>;
async read<T extends unknown>(label: string, callback: CoordinatorCallback): Promise<T>;
async read<T extends unknown>(labelOrCallback: CoordinatorCallback|string, callback?: CoordinatorCallback):
Promise<T> {
if (typeof labelOrCallback === 'string') {
if (!callback) {
throw new Error('Read called with label but no callback');
}
return this.#enqueueHandler<T>(callback, ACTION.READ, labelOrCallback);
}
return this.#enqueueHandler<T>(labelOrCallback, ACTION.READ, UNNAMED_READ);
}
async write<T extends unknown>(callback: CoordinatorCallback): Promise<T>;
async write<T extends unknown>(label: string, callback: CoordinatorCallback): Promise<T>;
async write<T extends unknown>(labelOrCallback: CoordinatorCallback|string, callback?: CoordinatorCallback):
Promise<T> {
if (typeof labelOrCallback === 'string') {
if (!callback) {
throw new Error('Write called with label but no callback');
}
return this.#enqueueHandler<T>(callback, ACTION.WRITE, labelOrCallback);
}
return this.#enqueueHandler<T>(labelOrCallback, ACTION.WRITE, UNNAMED_WRITE);
}
takeRecords(): CoordinatorLogEntry[] {
const logs = [...this.#logInternal];
this.#logInternal.length = 0;
return logs;
}
/**
* We offer a convenience function for scroll-based activity, but often triggering a scroll
* requires a layout pass, thus it is better handled as a read activity, i.e. we wait until
* the layout-triggering work has been completed then it should be possible to scroll without
* first forcing layout.
*/
async scroll<T extends unknown>(callback: CoordinatorCallback): Promise<T>;
async scroll<T extends unknown>(label: string, callback: CoordinatorCallback): Promise<T>;
async scroll<T extends unknown>(labelOrCallback: CoordinatorCallback|string, callback?: CoordinatorCallback):
Promise<T> {
if (typeof labelOrCallback === 'string') {
if (!callback) {
throw new Error('Scroll called with label but no callback');
}
return this.#enqueueHandler<T>(callback, ACTION.READ, labelOrCallback);
}
return this.#enqueueHandler<T>(labelOrCallback, ACTION.READ, UNNAMED_SCROLL);
}
#enqueueHandler<T = unknown>(callback: CoordinatorCallback, action: ACTION, label: string): Promise<T> {
const hasName = ![UNNAMED_READ, UNNAMED_WRITE, UNNAMED_SCROLL].includes(label);
label = `${action === ACTION.READ ? '[Read]' : '[Write]'}: ${label}`;
if (this.#pendingWorkFrames.length === 0) {
this.#pendingWorkFrames.push({
readers: [],
writers: [],
});
}
const frame = this.#pendingWorkFrames[0];
if (!frame) {
throw new Error('No frame available');
}
let workItems = null;
switch (action) {
case ACTION.READ:
workItems = frame.readers;
break;
case ACTION.WRITE:
workItems = frame.writers;
break;
default:
throw new Error(`Unknown action: ${action}`);
}
let workItem = hasName ? workItems.find(w => w.label === label) : null;
if (!workItem) {
const newWorkItem = {label} as WorkItem;
newWorkItem.promise = (new Promise<void>((resolve, reject) => {
newWorkItem.trigger = resolve;
newWorkItem.cancel = reject;
})).then(() => newWorkItem.handler());
workItem = newWorkItem;
workItems.push(workItem);
}
// We are always using the latest handler, so that we don't end up with a
// stale results. We are reusing the promise to avoid blocking the first invocation, when
// it is being "overridden" by another one.
workItem.handler = callback;
this.#scheduleWork();
return workItem.promise as Promise<T>;
}
#scheduleWork(): void {
const hasScheduledWork = this.#scheduledWorkId !== 0;
if (hasScheduledWork) {
return;
}
this.#scheduledWorkId = requestAnimationFrame(async () => {
const hasPendingFrames = this.#pendingWorkFrames.length > 0;
if (!hasPendingFrames) {
// No pending frames means all pending work has completed.
// The events dispatched below are mostly for testing contexts.
// The first is for cases where we have a direct reference to
// the render coordinator. The second is for other test contexts
// where we don't, and instead we listen for an event on the window.
this.dispatchEvent(new RenderCoordinatorQueueEmptyEvent());
window.dispatchEvent(new RenderCoordinatorQueueEmptyEvent());
this.#logIfEnabled('[Queue empty]');
this.#scheduledWorkId = 0;
return;
}
this.dispatchEvent(new RenderCoordinatorNewFrameEvent());
this.#logIfEnabled('[New frame]');
const frame = this.#pendingWorkFrames.shift();
if (!frame) {
return;
}
// Start with all the readers and allow them
// to proceed together.
for (const reader of frame.readers) {
this.#logIfEnabled(reader.label);
reader.trigger();
}
// Wait for them all to be done.
try {
await Promise.race([
Promise.all(frame.readers.map(r => r.promise)),
new Promise((_, reject) => {
window.setTimeout(
() => reject(new Error(`Readers took over ${DEADLOCK_TIMEOUT}ms. Possible deadlock?`)),
DEADLOCK_TIMEOUT);
}),
]);
} catch (err) {
this.#rejectAll(frame.readers, err);
}
// Next do all the writers as a block.
for (const writer of frame.writers) {
this.#logIfEnabled(writer.label);
writer.trigger();
}
// And wait for them to be done, too.
try {
await Promise.race([
Promise.all(frame.writers.map(w => w.promise)),
new Promise((_, reject) => {
window.setTimeout(
() => reject(new Error(`Writers took over ${DEADLOCK_TIMEOUT}ms. Possible deadlock?`)),
DEADLOCK_TIMEOUT);
}),
]);
} catch (err) {
this.#rejectAll(frame.writers, err);
}
// Since there may have been more work requested in
// the callback of a reader / writer, we attempt to schedule
// it at this point.
this.#scheduledWorkId = 0;
this.#scheduleWork();
});
}
#rejectAll(handlers: WorkItem[], error: Error): void {
for (const handler of handlers) {
handler.cancel(error);
}
}
cancelPending(): void {
const error = new Error();
for (const frame of this.#pendingWorkFrames) {
this.#rejectAll(frame.readers, error);
this.#rejectAll(frame.writers, error);
}
}
#logIfEnabled(value: string|undefined): void {
if (!this.observe || !value) {
return;
}
const hasNoName = value.endsWith(UNNAMED_READ) || value.endsWith(UNNAMED_WRITE) || value.endsWith(UNNAMED_SCROLL);
if (hasNoName && this.observeOnlyNamed) {
return;
}
this.#logInternal.push({time: performance.now(), value});
// Keep the log at the log size.
while (this.#logInternal.length > this.recordStorageLimit) {
this.#logInternal.shift();
}
}
}