ranui
Version:
A framework-agnostic Web Components UI library built on native custom elements, with TypeScript types, light/dark theming, SSR and PWA support.
117 lines (116 loc) • 5.57 kB
TypeScript
/**
* Reactive primitives — signal / computed / createEffect / batch, plus an
* ownership layer (createRoot / onCleanup / getOwner / runWithOwner) and untrack.
*
* Design reference: SwiftUI @Observable + Solid.js fine-grained reactivity.
*
* View = f(State)
* signal — reactive source (@State)
* computed — derived, LAZY + memoized (SwiftUI computed property / Solid memo)
* createEffect— side effect that re-runs when its reads change (≈ body)
* ownership — effects/memos form an owner tree; disposing a scope disposes
* everything created under it (critical for MPA: tear down a page
* and every effect it spawned is cleaned up in one call).
*
* Model (push-pull scheduling + lazy memos):
* - Reading a source inside a computation links them both ways.
* - Writing a signal marks direct observers DIRTY and their transitive memo
* observers CHECK ("maybe dirty"), scheduling any dependent effects. Effects
* flush once (deduped — diamond dependencies run an effect a single time).
* - Memos recompute lazily on read, and only re-notify their observers when
* their derived value actually changes (via `equals`). A CHECK computation
* first pulls its sources up to date; if none changed value, it is skipped —
* so a memo whose output is stable never wakes the effects behind it.
* - Every computation is owned; re-running or disposing it first disposes its
* owned children and runs its cleanups (no leaked nested effects).
*/
export type Getter<T> = () => T;
export type Setter<T> = (value: T | ((prev: T) => T)) => void;
export interface SignalOptions<T> {
/** Custom equality. Defaults to Object.is. Return true to skip the update. */
equals?: (prev: T, next: T) => boolean;
}
/** Anything a computation can subscribe to (a signal, or a memo). */
interface SourceLike {
observers: Set<Computation>;
}
/** Staleness of a computation. CLEAN → up to date; CHECK → a memo source may
* have changed (verify before recomputing); DIRTY → definitely recompute. */
declare const CLEAN = 0;
declare const CHECK = 1;
declare const DIRTY = 2;
type State = typeof CLEAN | typeof CHECK | typeof DIRTY;
/** An effect or a memo — a node that runs a function and tracks its reads. */
interface Computation extends Owner {
fn: () => unknown;
/** Sources (signals / memos) read during the last run. */
sources: Set<SourceLike>;
isEffect: boolean;
running: boolean;
disposed: boolean;
state: State;
observers: Set<Computation>;
value: unknown;
equals: (a: unknown, b: unknown) => boolean;
}
/** A node that can own child computations + cleanups (effect, memo, or root). */
export interface Owner {
owned: Computation[];
cleanups: Array<() => void>;
owner: Owner | null;
}
/**
* Create a reactive value. Returns a `[getter, setter]` tuple.
*
* const [count, setCount] = signal(0);
* count(); // read — auto-tracked inside computed / createEffect
* setCount(1); // write — notifies dependents; skipped if unchanged
* setCount((n) => n + 1) // updater form
*/
export declare function signal<T>(initial: T, options?: SignalOptions<T>): [Getter<T>, Setter<T>];
/**
* Derived, read-only, LAZY + memoized value. Recomputes only when read after a
* dependency changed; unread memos never recompute. When a recompute produces an
* equal value (default `Object.is`, override via `options.equals`) the memo does
* NOT notify its observers — so effects behind a value-stable memo stay asleep.
* Owned by the current scope, so it is disposed when that scope is.
*
* const fullName = computed(() => `${first()} ${last()}`);
* fullName(); // computes on first read, caches until a dep's value changes
*/
export declare function computed<T>(fn: () => T, options?: SignalOptions<T>): Getter<T>;
/**
* Run `fn` immediately, then re-run it whenever a signal/memo it read changes.
* Owned by the current scope. `fn` may return a cleanup, called before each
* re-run and on dispose. Returns a dispose function.
*
* const dispose = createEffect(() => {
* el.textContent = `Count: ${count()}`;
* return () => {}; // optional cleanup
* });
*/
export declare function createEffect(fn: () => void | (() => void)): () => void;
/**
* Coalesce multiple writes into one flush. Effects run once after `fn` returns,
* deduplicated. Nested batches are absorbed by the outermost one.
*/
export declare function batch(fn: () => void): void;
/** Read signals without subscribing the current computation to them. */
export declare function untrack<T>(fn: () => T): T;
/**
* Create a disposable reactive scope. Effects/memos created inside are owned by
* it; calling the passed `dispose` tears them all down. Use one per page/route
* in an MPA/SPA so navigating away cleans up every effect that page spawned.
*
* const dispose = createRoot((dispose) => { renderPage(); return dispose; });
* // ...later, on navigation:
* dispose();
*/
export declare function createRoot<T>(fn: (dispose: () => void) => T): T;
/** Register a cleanup on the current scope, run when it re-runs or is disposed. */
export declare function onCleanup(fn: () => void): void;
/** The current owner scope (for advanced integrations, e.g. a router). */
export declare function getOwner(): Owner | null;
/** Run `fn` under a specific owner scope (pairs with getOwner). */
export declare function runWithOwner<T>(owner: Owner | null, fn: () => T): T;
export {};