blob: 4d70d6a7d37352bba40763884cb06c0608bf37b0 [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {forwardAbort} from './abort_utils';
// The grid asks for a new window on every scroll tick, so a drag would
// otherwise be one round trip per tick. Wait for the offset to settle and
// send one request for where the user actually stopped.
export const FETCH_DEBOUNCE_MS = 150;
// Decides when a grid data source actually talks to the backend: it coalesces
// a burst of window requests into one, and cancels the request a newer one
// replaces. Both data sources drive their fetches through it, so the two grids
// can't drift apart on behaviour this fiddly.
//
// A request is identified by a caller-supplied key covering everything that
// would change the reply (window, sort, filter, projection, and whatever else
// the caller varies). An identical request already queued or in flight is
// never repeated — which matters because a data source's "we still need the
// first page" condition stays true until a fetch succeeds, and every fetch
// triggers a redraw that re-evaluates it.
export class FetchScheduler {
private pendingTimer?: number;
private pendingKey?: string;
private pendingRun?: (controller: AbortController) => Promise<void>;
private inFlight?: AbortController;
private inFlightKey?: string;
private running = false;
// `ownerSignal` kills everything at once when the owner goes away.
// `onChange` is called whenever `isPending` may have changed.
constructor(
private readonly ownerSignal?: AbortSignal,
private readonly onChange: () => void = () => {},
) {}
// True while a request is in flight or still waiting out the debounce, so
// the grid doesn't read as settled between the last tick and the request.
get isPending(): boolean {
return this.running || this.pendingTimer !== undefined;
}
// Whether the reply a caller is holding belongs to the request still being
// waited for. A reply that raced its own abort must be dropped, or it puts
// back the page the user scrolled away from.
isCurrent(controller: AbortController): boolean {
return this.inFlight === controller;
}
// Ask for `run` under `key`. `immediate` skips the wait — for the first
// window, which has nothing on screen behind it.
schedule(
key: string,
immediate: boolean,
run: (controller: AbortController) => Promise<void>,
): void {
if (this.ownerSignal?.aborted) return;
// Already on its way, or already queued — asking again changes nothing.
if (key === this.pendingKey) return;
if (this.running && key === this.inFlightKey) return;
this.clearTimer();
if (immediate) {
void this.start(key, run);
return;
}
this.pendingKey = key;
this.pendingRun = run;
this.pendingTimer = window.setTimeout(() => {
this.pendingTimer = undefined;
this.pendingKey = undefined;
const pending = this.pendingRun;
this.pendingRun = undefined;
if (pending !== undefined) void this.start(key, pending);
}, FETCH_DEBOUNCE_MS);
this.onChange();
}
// Run `run` now, dropping anything queued and superseding anything in
// flight. For explicit refreshes, where waiting would be wrong.
async runNow(
run: (controller: AbortController) => Promise<void>,
): Promise<void> {
this.clearTimer();
await this.start(undefined, run);
}
// ----- Internals -----
private clearTimer(): void {
if (this.pendingTimer !== undefined) {
window.clearTimeout(this.pendingTimer);
this.pendingTimer = undefined;
}
this.pendingKey = undefined;
this.pendingRun = undefined;
}
private async start(
key: string | undefined,
run: (controller: AbortController) => Promise<void>,
): Promise<void> {
if (this.ownerSignal?.aborted) return;
// Supersede whatever is still running: its page is one the user has
// already moved off, and because the fetch is awaited it would otherwise
// sit in front of the page they are waiting for.
this.inFlight?.abort();
const controller = new AbortController();
this.inFlight = controller;
this.inFlightKey = key;
this.running = true;
const detach =
this.ownerSignal !== undefined
? forwardAbort(this.ownerSignal, controller)
: () => {};
this.onChange();
try {
await run(controller);
} finally {
detach();
// A superseded request must not report the newer one as finished.
if (this.inFlight === controller) {
this.inFlight = undefined;
this.inFlightKey = undefined;
this.running = false;
}
this.onChange();
}
}
}