release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+8
View File
@@ -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";
+152
View File
@@ -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;
},
};
}
+33 -1
View File
@@ -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();
});