blob: 592775ca519d53419befe08bd3db5b4090a9c788 [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.
/**
* AsyncMemo: Declarative async data fetching for synchronous render cycles.
*
* ## Why it exists
*
* UI components (Mithril views, canvas tracks) render synchronously but need
* async data e.g. from SQL queries. Manually tracking state changes and
* handling stale queries is difficult and error prone.
*
* AsyncMemo bridges this gap:
* - Call `use()` every render cycle with the current parameters
* - Get back whatever data is available (cached, stale, or undefined)
* - New tasks are scheduled automatically when parameters change
* - Serial execution prevents race conditions with shared resources (temp
* tables)
*
* ## Example usage:
*
* ```ts
* class MyPanel implements m.ClassComponent<Attrs> {
* private readonly memo = new AsyncMemo<MyData>();
*
* view({attrs}: m.CVnode<Attrs>) {
* const result = this.memo.use({
* key: {filters: attrs.filters, pagination: this.pagination},
* compute: () => fetchData(attrs.filters, this.pagination),
* retainOn: ['pagination'], // Show stale data during pagination changes
* });
*
* return m('div', result.data ? renderData(result.data) : 'Loading...');
* }
*
* onremove() {
* // Cancel pending tasks and cleanup resources
* this.memo.dispose();
* }
* }
* ```
*
* ## Key concepts
*
* - **key**: Object identifying the memo. Changes trigger re-fetch.
* - **retainOn**: Key fields that allow showing previous data while fetching.
* E.g., `retainOn: ['pagination']` shows old data during scroll for
* smoothness, but `filters` changing would show loading state.
* - **enabled**: Truthy value required before task runs. Use for dependencies
* like `enabled: tableResult.data` to wait for a temp table to be created.
*
* ## Behavior
*
* - Tasks within a queue run serially (no interleaving)
* - Only the latest pending task per memo is kept (intermediates dropped)
* - Each memo has a single-entry cache (most recent result)
* - If compute() returns an AsyncDisposable, it is automatically disposed
* before running the next task and when the memo is disposed. Disposal runs
* through the queue to stay synchronized with in-flight tasks.
*/
import m from 'mithril';
import {isAsyncDisposable} from './disposable';
import {type JSONCompatible, stringifyJsonWithBigints} from './json_utils';
/**
* Signal passed to compute() to check if the task has been cancelled.
* Check this periodically during long-running operations to bail out early.
*/
export interface CancellationSignal {
readonly isCancelled: boolean;
}
/**
* Special return value from compute() indicating the task was cancelled.
* When returned, the result is not cached.
*/
export const TASK_CANCELLED = Symbol('TASK_CANCELLED');
// Simple alias for a function that returns a Promise of type T.
type AsyncFunc<T> = () => Promise<T>;
/**
* Runs async functions one at a time with cancellation support.
*
* Compelling use case - to avoid interlaving queries in async functions that
* run more than one query.
*
* Tasks are keyed by an object reference. If a new task is scheduled with the
* same key, it replaces any pending task for that key ("latest wins"). Tasks
* that have already started running cannot be cancelled.
*/
export class AtomicTaskQueue {
private pending = new Map<object, AsyncFunc<void>>();
private running = false;
/**
* Schedule a task. If a task with this key is already pending,
* it gets replaced.
*/
schedule(key: object, task: AsyncFunc<void>): void {
this.pending.set(key, task);
this.runNext();
}
/**
* Cancel any pending task for this key.
* Has no effect if the task is already running.
*/
cancel(key: object): void {
this.pending.delete(key);
}
private async runNext(): Promise<void> {
if (this.running) return;
const first = this.pending.entries().next();
if (first.done) return;
const [key, task] = first.value;
this.pending.delete(key);
this.running = true;
try {
await task();
} catch (e) {
console.error('Task failed:', e);
} finally {
this.running = false;
this.runNext();
}
}
}
export interface AsyncMemoOptions<T, K extends JSONCompatible<K>> {
readonly key: K;
// Function to call when the key changes to fetch the memo data.
readonly compute: (
signal: CancellationSignal,
) => Promise<T | typeof TASK_CANCELLED>;
// If provided, compute() only runs when this is truthy.
readonly enabled?: boolean;
// Keep returning the previous memo while the new one loads as long as only
// keys in this set have changed.
readonly retainOn?: (keyof K)[];
}
export type AsyncMemoResult<T> =
| {
readonly isPending: true;
readonly data?: T; // Stale data may be available.
}
| {
readonly isPending: false;
readonly data: T;
};
interface Cache<T> {
readonly key: object;
readonly keyStr: string;
readonly data: T;
}
/**
* A single async memo with a single-entry cache.
*
* Created once per piece of data you want to memoize on a component. Multiple
* memos should share a SerialTaskQueue for atomic compute task execution.
*
* If T is an AsyncDisposable, the memo will automatically dispose of previous
* results before running a new compute and when the memo is disposed.
*/
export class AsyncMemo<T> {
private cache?: Cache<T>;
private pendingKey?: object;
private disposed = false;
private currentSignal?: {cancelled: boolean};
// Stores error keyed by keyStr - thrown on next use() with same key
private error?: {keyStr: string; error: Error};
constructor(
private readonly queue: AtomicTaskQueue = new AtomicTaskQueue(),
) {}
/**
* Call every render cycle to get the current memoized result.
*
* @throws Error if called after dispose()
*/
use<K extends JSONCompatible<K>>(
options: AsyncMemoOptions<T, K>,
): AsyncMemoResult<T> {
if (this.disposed) {
throw new Error('AsyncMemo.use() called after dispose()');
}
const {key, compute, enabled, retainOn = []} = options;
const keyStr = stringifyJsonWithBigints(key);
// If we have a stored error for this key, throw it
if (this.error?.keyStr === keyStr) {
throw this.error.error;
}
// Check if we need to schedule a new task
const pendingKeyStr = this.pendingKey
? stringifyJsonWithBigints(this.pendingKey)
: undefined;
const cachedKeyStr = this.cache?.keyStr;
const isKeyDifferentFromPending = pendingKeyStr !== keyStr;
const isKeyDifferentFromCache = cachedKeyStr !== keyStr;
// enabled: undefined means no dependencies (satisfied)
// enabled: <value> means satisfied only if truthy
const canRun = enabled === undefined || enabled;
if (isKeyDifferentFromPending && isKeyDifferentFromCache && canRun) {
// Cancel any in-flight task
if (this.currentSignal) {
this.currentSignal.cancelled = true;
}
// Create new signal for this task
const signal = {cancelled: false};
this.currentSignal = signal;
this.pendingKey = key;
this.queue.schedule(this, async () => {
try {
// Dispose of previous result before running new task
await this.disposeCache();
const result = await compute({
get isCancelled() {
return signal.cancelled;
},
});
this.finaliseTask(key, result);
} catch (e) {
// Support both throwing and returning TASK_CANCELLED
if (e === TASK_CANCELLED) {
this.finaliseTask(key, TASK_CANCELLED);
} else {
this.finaliseError(key, e);
}
} finally {
m.redraw();
}
});
}
// Determine what to return
const isPending = this.pendingKey !== undefined;
if (!this.cache) {
// Without cached data, the result remains pending. This also covers a
// memo blocked by `enabled`, whose dependency has not resolved yet.
return {data: undefined, isPending: true};
}
// Check if we can use cached data
if (this.cache.keyStr === keyStr) {
return isPending
? {data: this.cache.data, isPending: true}
: {data: this.cache.data, isPending: false};
}
// Key differs - can we use stale data?
const canUseStale = canUseStaleData(
this.cache.key,
key,
retainOn as string[],
);
if (canUseStale) {
return isPending
? {data: this.cache.data, isPending: true}
: {data: this.cache.data, isPending: false};
}
// Can't use stale data
return {data: undefined, isPending: true};
}
/**
* Called when a task completes. Clears the pending state and optionally
* caches the result (if not cancelled).
*/
private finaliseTask(key: object, result: T | typeof TASK_CANCELLED): void {
const keyStr = stringifyJsonWithBigints(key);
// Clear pending if it matches
if (
this.pendingKey &&
stringifyJsonWithBigints(this.pendingKey) === keyStr
) {
this.pendingKey = undefined;
}
// Cache the result (unless cancelled)
if (result !== TASK_CANCELLED) {
this.cache = {key, keyStr, data: result};
}
}
/**
* Called when a task fails with an error. Stores the error keyed by the key -
* next use() with the same key will throw this error.
*/
private finaliseError(key: object, e: unknown): void {
const keyStr = stringifyJsonWithBigints(key);
this.error = {
keyStr,
error: e instanceof Error ? e : new Error(String(e)),
};
// Clear pending if it matches (don't clear if a different task was
// scheduled)
if (
this.pendingKey &&
stringifyJsonWithBigints(this.pendingKey) === keyStr
) {
this.pendingKey = undefined;
}
}
private async disposeCache(): Promise<void> {
if (this.cache && isAsyncDisposable(this.cache.data)) {
await this.cache.data[Symbol.asyncDispose]();
// Only clear cache for AsyncDisposable - the resource is now invalid
// For non-AsyncDisposable data, keep the cache until setCache replaces it
this.cache = undefined;
}
}
/**
* Clear the cached result and any stored error, forcing the next use() with
* any key to re-run its compute function. Unlike dispose(), the memo remains
* usable. Cancels any in-flight task and disposes cached AsyncDisposable
* data through the queue to stay synchronized with pending work.
*/
invalidate(): void {
if (this.disposed) return;
// Cancel any in-flight task and clear pending state so the next use()
// reschedules from scratch.
if (this.currentSignal) {
this.currentSignal.cancelled = true;
this.currentSignal = undefined;
}
this.pendingKey = undefined;
this.error = undefined;
// Schedule cache disposal/clearing through the queue so it runs after any
// in-flight task settles.
this.queue.schedule(this, async () => {
await this.disposeCache();
this.cache = undefined;
});
}
/**
* Dispose this memo. Cancels pending work and disposes any cached
* AsyncDisposable through the queue to maintain synchronization.
* Call this in component's onremove() to prevent orphaned tasks.
*/
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.queue.cancel(this);
this.pendingKey = undefined;
// Schedule cache disposal through the queue. This runs after any
// in-flight task completes, ensuring we dispose whatever ends up
// in the cache (either existing data or newly fetched data).
this.queue.schedule(this, async () => {
await this.disposeCache();
});
}
}
/**
* Check if stale data can be used by comparing cached and current keys.
*
* Returns true if only fields listed in retainOn differ.
* Returns false if any non-retainOn field differs.
*/
function canUseStaleData(
cachedKey: object,
currentKey: object,
retainOn: string[],
): boolean {
const cached = cachedKey as Record<string, unknown>;
const current = currentKey as Record<string, unknown>;
// Check all fields in current key
for (const field of Object.keys(current)) {
const cachedStr = stringifyJsonWithBigints(cached[field]);
const currentStr = stringifyJsonWithBigints(current[field]);
if (cachedStr !== currentStr) {
// Field differs - is it in retainOn?
if (!retainOn.includes(field)) {
return false; // Non-retainOn field changed → need fresh data
}
}
}
// Also check for fields in cached that aren't in current (removed fields)
for (const field of Object.keys(cached)) {
if (!(field in current)) {
if (!retainOn.includes(field)) {
return false;
}
}
}
return true; // Only retainOn fields changed → stale OK
}