release: WRNexusJS 0.3.0

This commit is contained in:
2026-07-22 17:29:08 +05:30
parent 13dfa31d19
commit 07d8fb59d6
145 changed files with 9664 additions and 3881 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.79",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+3 -5
View File
@@ -1,5 +1,3 @@
/**
* @wrnexus/reactive — tiny reactive primitives.
*/
export type { Signal, Subscriber, Unsubscribe } from "./signal.ts";
export { signal } from "./signal.ts";
/** @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";
+129 -24
View File
@@ -1,53 +1,158 @@
/**
* A minimal, type-safe reactive signal with zero dependencies.
*
* This is the seed of the framework's reactivity. Today it powers nothing on
* its own, but it is shaped so client islands (and later the `.wrn` compiler's
* `state` blocks) can build reactive bindings on top of it.
*
* const count = signal(0)
* count.get() // 0
* count.set(1) // notifies subscribers
* const off = count.subscribe(v => console.log(v))
* off() // unsubscribe
* Fine-grained reactive primitives shared by server utilities and client code.
* Updates are synchronous by default and coalesced inside `batch()`.
*/
export type Subscriber<T> = (value: T) => void;
export type Subscriber<T> = (value: T, previous?: T) => void;
export type Unsubscribe = () => void;
export type Cleanup = () => void;
export interface Signal<T> {
/** Read the current value. */
get(): T;
/** Write a new value; subscribers run only when the value actually changes. */
set(next: T): void;
/** Apply a function to the current value. */
update(fn: (current: T) => T): void;
/** Subscribe to changes; returns an unsubscribe function. */
subscribe(fn: Subscriber<T>): Unsubscribe;
}
export interface ReadonlySignal<T> {
get(): T;
subscribe(fn: Subscriber<T>): Unsubscribe;
}
type DependencyCollector = (subscribe: (subscriber: Subscriber<unknown>) => Unsubscribe) => void;
let activeCollector: DependencyCollector | null = null;
let batchDepth = 0;
const pending = new Set<() => void>();
function enqueue(job: () => void): void {
if (batchDepth > 0) pending.add(job);
else job();
}
function flush(): void {
while (pending.size > 0) {
const jobs = [...pending];
pending.clear();
for (const job of jobs) job();
}
}
/** Coalesce every signal notification made by `fn` into one flush. */
export function batch<T>(fn: () => T): T {
batchDepth++;
try {
return fn();
} finally {
batchDepth--;
if (batchDepth === 0) flush();
}
}
/** Read reactive values without recording dependencies. */
export function untrack<T>(fn: () => T): T {
const previous = activeCollector;
activeCollector = null;
try {
return fn();
} finally {
activeCollector = previous;
}
}
export function signal<T>(initial: T): Signal<T> {
let value = initial;
let pendingPrevious: T | undefined;
let queued = false;
const subscribers = new Set<Subscriber<T>>();
return {
const notify = (): void => {
queued = false;
const previous = pendingPrevious;
pendingPrevious = undefined;
for (const fn of [...subscribers]) fn(value, previous);
};
const api: Signal<T> = {
get(): T {
if (activeCollector) {
activeCollector((subscriber) => api.subscribe(subscriber as Subscriber<T>));
}
return value;
},
set(next: T): void {
if (Object.is(next, value)) return; // skip no-op updates
if (Object.is(next, value)) return;
const previous = value;
value = next;
// Iterate a copy so a subscriber may unsubscribe during notification.
for (const fn of [...subscribers]) fn(value);
if (!queued) {
queued = true;
pendingPrevious = previous;
enqueue(notify);
}
},
update(fn: (current: T) => T): void {
this.set(fn(value));
api.set(fn(value));
},
subscribe(fn: Subscriber<T>): Unsubscribe {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
return () => subscribers.delete(fn);
},
};
return api;
}
/**
* Run a dependency-tracked side effect. Dependencies are rebuilt after every
* execution, preventing stale subscriptions when conditional reads change.
*/
export function effect(run: () => void | Cleanup): Cleanup {
let disposed = false;
let cleanup: void | Cleanup;
let subscriptions: Cleanup[] = [];
let scheduled = false;
const execute = (): void => {
scheduled = false;
if (disposed) return;
if (typeof cleanup === "function") cleanup();
for (const unsubscribe of subscriptions) unsubscribe();
subscriptions = [];
const previous = activeCollector;
activeCollector = (subscribe) => {
subscriptions.push(
subscribe(() => {
if (scheduled || disposed) return;
scheduled = true;
enqueue(execute);
}),
);
};
try {
const nextCleanup = run();
cleanup = typeof nextCleanup === "function" ? nextCleanup : undefined;
} finally {
activeCollector = previous;
}
};
execute();
return () => {
if (disposed) return;
disposed = true;
if (typeof cleanup === "function") cleanup();
for (const unsubscribe of subscriptions) unsubscribe();
subscriptions = [];
};
}
/** Create a lazily readable derived signal with automatic dependency tracking. */
export function computed<T>(read: () => T): ReadonlySignal<T> {
const output = signal<T>(undefined as T);
effect(() => output.set(read()));
return {
get: output.get,
subscribe: output.subscribe,
};
}
+44 -14
View File
@@ -1,17 +1,47 @@
import { expect, test } from "bun:test";
import { signal } from "../src/index.ts";
import { describe, expect, test } from "bun:test";
import { batch, computed, effect, signal } from "../src/index.ts";
test("signal skips equal writes and supports safe unsubscribe during notification", () => {
const count = signal(0);
const seen: number[] = [];
let off = () => {};
off = count.subscribe((value) => {
seen.push(value);
off();
describe("reactive primitives", () => {
test("signals skip no-op updates", () => {
const value = signal(1);
const seen: number[] = [];
value.subscribe((next) => seen.push(next));
value.set(1);
value.set(2);
expect(seen).toEqual([2]);
});
test("batch coalesces notifications", () => {
const value = signal(0);
const seen: number[] = [];
value.subscribe((next) => seen.push(next));
batch(() => {
value.set(1);
value.set(2);
value.set(3);
});
expect(seen).toEqual([3]);
});
test("computed values and effects track dependencies", () => {
const count = signal(2);
const doubled = computed(() => count.get() * 2);
const seen: number[] = [];
const dispose = effect(() => {
seen.push(doubled.get());
});
count.set(3);
dispose();
count.set(4);
expect(seen).toEqual([4, 6]);
});
test("effects ignore accidental non-function return values", () => {
const value = signal(0);
const seen: number[] = [];
const dispose = effect(() => seen.push(value.get()) as unknown as void);
value.set(1);
dispose();
expect(seen).toEqual([0, 1]);
});
count.set(0);
count.update((value) => value + 1);
count.set(2);
expect(seen).toEqual([1]);
expect(count.get()).toBe(2);
});