release: WRNexusJS 0.4.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { batch, computed, effect, signal } from "../src/index.ts";
|
||||
import { batch, computed, effect, resource, signal, watch } from "../src/index.ts";
|
||||
|
||||
describe("reactive primitives", () => {
|
||||
test("signals skip no-op updates", () => {
|
||||
@@ -45,3 +45,35 @@ describe("reactive primitives", () => {
|
||||
expect(seen).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
test("watch reports undefined as the previous value for an immediate run", () => {
|
||||
const value = signal(1);
|
||||
const seen: Array<[number, number | undefined]> = [];
|
||||
const stop = watch(
|
||||
() => value.get(),
|
||||
(next, previous) => void seen.push([next, previous]),
|
||||
{ immediate: true },
|
||||
);
|
||||
value.set(2);
|
||||
stop();
|
||||
|
||||
expect(seen).toEqual([
|
||||
[1, undefined],
|
||||
[2, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test("aborting a pending resource does not dereference a cleared controller", async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
const pending = new Promise<string>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const value = resource(() => pending);
|
||||
const run = value.run();
|
||||
value.abort("cancelled");
|
||||
resolve("late");
|
||||
|
||||
await expect(run).resolves.toBeUndefined();
|
||||
expect(value.status.get()).toBe("idle");
|
||||
expect(value.data.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user