153 lines
3.9 KiB
TypeScript
153 lines
3.9 KiB
TypeScript
import { effect, signal, type Cleanup, type ReadonlySignal } from "./signal.ts";
|
|
|
|
export interface WatchOptions<T> {
|
|
immediate?: boolean;
|
|
equals?: (left: T, right: T) => boolean;
|
|
}
|
|
|
|
export function watch<T>(
|
|
read: () => T,
|
|
listener: (value: T, previous: T | undefined) => void | Cleanup,
|
|
options: WatchOptions<T> = {},
|
|
): Cleanup {
|
|
const equals = options.equals ?? Object.is;
|
|
let initialized = false;
|
|
let previous: T | undefined;
|
|
let listenerCleanup: void | Cleanup;
|
|
const dispose = effect(() => {
|
|
const value = read();
|
|
const old = previous;
|
|
if (!initialized) {
|
|
initialized = true;
|
|
if (!options.immediate) {
|
|
previous = value;
|
|
return;
|
|
}
|
|
} else if (equals(value, previous as T)) {
|
|
return;
|
|
}
|
|
if (typeof listenerCleanup === "function") listenerCleanup();
|
|
previous = value;
|
|
listenerCleanup = listener(value, old);
|
|
});
|
|
return () => {
|
|
dispose();
|
|
if (typeof listenerCleanup === "function") listenerCleanup();
|
|
};
|
|
}
|
|
|
|
export type ResourceStatus = "idle" | "pending" | "success" | "error";
|
|
export interface Resource<T> {
|
|
data: ReadonlySignal<T | undefined>;
|
|
error: ReadonlySignal<unknown>;
|
|
status: ReadonlySignal<ResourceStatus>;
|
|
loading: ReadonlySignal<boolean>;
|
|
run(): Promise<T | undefined>;
|
|
abort(reason?: unknown): void;
|
|
reset(): void;
|
|
}
|
|
|
|
export interface ResourceOptions<T> {
|
|
initial?: T;
|
|
immediate?: boolean;
|
|
keepPrevious?: boolean;
|
|
}
|
|
|
|
export function resource<T>(
|
|
loader: (signal: AbortSignal) => Promise<T>,
|
|
options: ResourceOptions<T> = {},
|
|
): Resource<T> {
|
|
const data = signal<T | undefined>(options.initial);
|
|
const error = signal<unknown>(undefined);
|
|
const status = signal<ResourceStatus>("idle");
|
|
let controller: AbortController | null = null;
|
|
let generation = 0;
|
|
|
|
const api: Resource<T> = {
|
|
data,
|
|
error,
|
|
status,
|
|
loading: {
|
|
get: () => status.get() === "pending",
|
|
subscribe(fn) {
|
|
return status.subscribe((value, previous) =>
|
|
fn(value === "pending", previous === "pending"),
|
|
);
|
|
},
|
|
},
|
|
async run() {
|
|
controller?.abort("superseded");
|
|
const requestController = new AbortController();
|
|
controller = requestController;
|
|
const current = ++generation;
|
|
if (!options.keepPrevious) data.set(options.initial);
|
|
error.set(undefined);
|
|
status.set("pending");
|
|
try {
|
|
const value = await loader(requestController.signal);
|
|
if (current !== generation || requestController.signal.aborted) {
|
|
return undefined;
|
|
}
|
|
controller = null;
|
|
data.set(value);
|
|
status.set("success");
|
|
return value;
|
|
} catch (cause) {
|
|
if (current !== generation || requestController.signal.aborted) {
|
|
return undefined;
|
|
}
|
|
controller = null;
|
|
error.set(cause);
|
|
status.set("error");
|
|
return undefined;
|
|
}
|
|
},
|
|
abort(reason) {
|
|
generation++;
|
|
controller?.abort(reason);
|
|
controller = null;
|
|
if (status.get() === "pending") status.set("idle");
|
|
},
|
|
reset() {
|
|
api.abort("reset");
|
|
data.set(options.initial);
|
|
error.set(undefined);
|
|
status.set("idle");
|
|
},
|
|
};
|
|
if (options.immediate) void api.run();
|
|
return api;
|
|
}
|
|
|
|
export interface ReactiveScope {
|
|
add(cleanup: Cleanup): Cleanup;
|
|
dispose(): void;
|
|
readonly disposed: boolean;
|
|
}
|
|
|
|
export function createScope(): ReactiveScope {
|
|
const cleanups = new Set<Cleanup>();
|
|
let disposed = false;
|
|
return {
|
|
add(cleanup) {
|
|
if (disposed) {
|
|
cleanup();
|
|
return cleanup;
|
|
}
|
|
cleanups.add(cleanup);
|
|
return () => {
|
|
if (cleanups.delete(cleanup)) cleanup();
|
|
};
|
|
},
|
|
dispose() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
for (const cleanup of [...cleanups].reverse()) cleanup();
|
|
cleanups.clear();
|
|
},
|
|
get disposed() {
|
|
return disposed;
|
|
},
|
|
};
|
|
}
|