release: WRNexusJS 0.4.0
This commit is contained in:
@@ -1,3 +1,11 @@
|
||||
/** @wrnexus/reactive — fine-grained reactive primitives. */
|
||||
export type { Cleanup, ReadonlySignal, Signal, Subscriber, Unsubscribe } from "./signal.ts";
|
||||
export { batch, computed, effect, signal, untrack } from "./signal.ts";
|
||||
export { watch, resource, createScope } from "./resource.ts";
|
||||
export type {
|
||||
WatchOptions,
|
||||
Resource,
|
||||
ResourceOptions,
|
||||
ResourceStatus,
|
||||
ReactiveScope,
|
||||
} from "./resource.ts";
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user