release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.7.0",
"version": "0.8.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+216
View File
@@ -0,0 +1,216 @@
import { signal, type Signal } from "./signal.ts";
export interface HistorySignal<T> extends Signal<T> {
undo(): boolean;
redo(): boolean;
canUndo(): boolean;
canRedo(): boolean;
clearHistory(): void;
}
export function historySignal<T>(
initial: T,
options: { limit?: number; equals?: (left: T, right: T) => boolean } = {},
): HistorySignal<T> {
const limit = options.limit ?? 100;
if (!Number.isInteger(limit) || limit < 1) throw new RangeError("history limit must be positive");
const current = signal(initial);
const past: T[] = [];
const future: T[] = [];
const equals = options.equals ?? Object.is;
const set = current.set.bind(current);
current.set = (value) => {
const next = typeof value === "function" ? (value as (previous: T) => T)(current.get()) : value;
const previous = current.get();
if (equals(previous, next)) return;
past.push(previous);
if (past.length > limit) past.shift();
future.length = 0;
set(next);
};
return Object.assign(current, {
undo() {
const value = past.pop();
if (value === undefined) return false;
future.push(current.get());
set(value);
return true;
},
redo() {
const value = future.pop();
if (value === undefined) return false;
past.push(current.get());
set(value);
return true;
},
canUndo: () => past.length > 0,
canRedo: () => future.length > 0,
clearHistory() {
past.length = 0;
future.length = 0;
},
});
}
export interface UrlStateOptions<T> {
url?: URL;
parameter: string;
parse?: (value: string | null) => T;
serialize?: (value: T) => string | null;
replace?: (url: URL) => void;
}
export function urlSignal<T>(initial: T, options: UrlStateOptions<T>): Signal<T> {
const url = options.url ?? new URL(globalThis.location?.href ?? "http://localhost/");
const parsed = options.parse?.(url.searchParams.get(options.parameter));
const state = signal(parsed ?? initial);
const original = state.set.bind(state);
state.set = (value) => {
original(value);
const serialized = options.serialize ? options.serialize(state.get()) : String(state.get());
if (serialized === null) url.searchParams.delete(options.parameter);
else url.searchParams.set(options.parameter, serialized);
if (options.replace) options.replace(new URL(url));
else globalThis.history?.replaceState?.(null, "", url);
};
return state;
}
export interface ReactiveContext<T> {
provide<R>(value: T, run: () => R): R;
use(): T;
}
export function createContextProvider<T>(defaultValue?: T): ReactiveContext<T> {
const stack: T[] = [];
return {
provide(value, run) {
stack.push(value);
try {
return run();
} finally {
stack.pop();
}
},
use() {
if (stack.length) return stack[stack.length - 1]!;
if (defaultValue !== undefined) return defaultValue;
throw new Error("WRN-REACTIVE-CONTEXT: no provider is active.");
},
};
}
export function mountPortal(content: Node | string, target: Element): () => void {
const marker = target.ownerDocument.createComment("wrnexus-portal");
const node = typeof content === "string" ? target.ownerDocument.createTextNode(content) : content;
target.append(marker, node);
return () => {
marker.remove();
node.parentNode?.removeChild(node);
};
}
export async function transition(
update: () => void,
options: { className?: string; target?: Element; durationMs?: number; signal?: AbortSignal } = {},
): Promise<void> {
if (options.signal?.aborted) throw options.signal.reason;
const target = options.target;
const className = options.className ?? "wrn-transition";
target?.classList.add(className);
update();
const duration = options.durationMs ?? 0;
if (duration > 0)
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, duration);
options.signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(options.signal!.reason);
},
{ once: true },
);
});
target?.classList.remove(className);
}
export interface TimelineStep {
durationMs: number;
delayMs?: number;
easing?: (progress: number) => number;
update(progress: number): void;
}
export interface AnimationTimeline {
play(options?: { reverse?: boolean; signal?: AbortSignal }): Promise<void>;
cancel(reason?: unknown): void;
readonly running: boolean;
}
export function createTimeline(
steps: TimelineStep[],
options: { now?: () => number; frame?: (callback: () => void) => unknown } = {},
): AnimationTimeline {
for (const step of steps) {
if (
!Number.isFinite(step.durationMs) ||
step.durationMs < 0 ||
!Number.isFinite(step.delayMs ?? 0) ||
(step.delayMs ?? 0) < 0
)
throw new RangeError("Timeline durations and delays must be non-negative");
}
const now = options.now ?? (() => performance.now());
const frame = options.frame ?? ((callback) => requestAnimationFrame(callback));
let controller: AbortController | null = null;
const timeline: AnimationTimeline = {
get running() {
return controller !== null;
},
cancel(reason = new Error("Timeline cancelled")) {
controller?.abort(reason);
},
async play(playOptions = {}) {
controller?.abort(new Error("Timeline restarted"));
controller = new AbortController();
const local = controller;
playOptions.signal?.addEventListener("abort", () => local.abort(playOptions.signal!.reason), {
once: true,
});
const ordered = playOptions.reverse ? [...steps].reverse() : steps;
try {
for (const step of ordered) {
const delay = step.delayMs ?? 0;
if (delay)
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
local.signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(local.signal.reason);
},
{ once: true },
);
});
const started = now();
await new Promise<void>((resolve, reject) => {
const tick = () => {
if (local.signal.aborted) {
reject(local.signal.reason);
return;
}
const raw =
step.durationMs === 0 ? 1 : Math.min(1, (now() - started) / step.durationMs);
const progress = playOptions.reverse ? 1 - raw : raw;
step.update(step.easing?.(progress) ?? progress);
if (raw >= 1) resolve();
else frame(tick);
};
tick();
});
}
} finally {
if (controller === local) controller = null;
}
},
};
return timeline;
}
+16 -1
View File
@@ -1,4 +1,4 @@
/** @wrnexus/reactive — fine-grained reactive primitives. */
/** @wrnexus/reactive — fine-grained reactive primitives, history, URL state, context and portals. */
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";
@@ -9,3 +9,18 @@ export type {
ResourceStatus,
ReactiveScope,
} from "./resource.ts";
export {
createContextProvider,
historySignal,
mountPortal,
transition,
createTimeline,
urlSignal,
} from "./advanced.ts";
export type {
HistorySignal,
ReactiveContext,
UrlStateOptions,
TimelineStep,
AnimationTimeline,
} from "./advanced.ts";
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import {
createContextProvider,
historySignal,
mountPortal,
transition,
createTimeline,
urlSignal,
} from "../src/index.ts";
describe("advanced reactive primitives", () => {
test("supports bounded undo and redo", () => {
const value = historySignal(0, { limit: 2 });
value.set(1);
value.set(2);
value.set(3);
expect(value.undo()).toBe(true);
expect(value.get()).toBe(2);
expect(value.undo()).toBe(true);
expect(value.get()).toBe(1);
expect(value.undo()).toBe(false);
expect(value.redo()).toBe(true);
expect(value.get()).toBe(2);
});
test("synchronizes URL state through an explicit adapter", () => {
let changed = "";
const value = urlSignal("all", {
url: new URL("https://example.test/?filter=open"),
parameter: "filter",
parse: (input) => input ?? "all",
replace: (url) => {
changed = url.href;
},
});
expect(value.get()).toBe("open");
value.set("closed");
expect(changed).toBe("https://example.test/?filter=closed");
});
test("provides scoped context and cleans up portals and transitions", async () => {
const context = createContextProvider("default");
expect(context.provide("tenant", () => context.use())).toBe("tenant");
expect(context.use()).toBe("default");
const window = new Window();
const target = window.document.createElement("div");
const cleanup = mountPortal("hello", target as unknown as Element);
expect(target.textContent).toBe("hello");
cleanup();
expect(target.textContent).toBe("");
await transition(() => target.setAttribute("ready", ""), {
target: target as unknown as Element,
className: "enter",
});
expect(target.classList.contains("enter")).toBe(false);
});
});
test("animation timelines sequence bounded forward and reverse steps", async () => {
let clock = 0;
const values: number[] = [];
const timeline = createTimeline(
[{ durationMs: 10, update: (progress) => values.push(progress) }],
{
now: () => clock,
frame(callback) {
clock += 5;
queueMicrotask(callback);
},
},
);
await timeline.play();
expect(values.at(-1)).toBe(1);
values.length = 0;
clock = 0;
await timeline.play({ reverse: true });
expect(values[0]).toBe(1);
expect(values.at(-1)).toBe(0);
expect(timeline.running).toBeFalse();
});