release: WRNexusJS 0.3.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user