@purityjs/signals 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ // ---------------------------------------------------------------------------
2
+ // debounced — derived signal that mirrors `source` but waits `ms` of quiet
3
+ // before propagating updates. Common pairing: feed a search input through
4
+ // debounced() into a resource() source.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ import { type ComputedAccessor, state, watch } from './signals.ts';
8
+
9
+ declare function setTimeout(handler: () => void, timeout?: number): unknown;
10
+ declare function clearTimeout(id: unknown): void;
11
+ declare const console: { error(...args: unknown[]): void };
12
+
13
+ /**
14
+ * Debounced read-only accessor with an explicit dispose. Inside a component
15
+ * the underlying watcher auto-disposes on unmount; `dispose()` is the escape
16
+ * hatch for module-scope or test usage.
17
+ */
18
+ export interface DebouncedAccessor<T> extends ComputedAccessor<T> {
19
+ dispose(): void;
20
+ }
21
+
22
+ /**
23
+ * Debounced read-only accessor. Mirrors `source` after `ms` of no further
24
+ * changes. The initial value is observed synchronously; subsequent updates
25
+ * are delayed.
26
+ */
27
+ export function debounced<T>(source: () => T, ms: number): DebouncedAccessor<T> {
28
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
29
+ throw new RangeError(
30
+ `[Purity] debounced(): ms must be a finite, non-negative number (got ${String(ms)})`,
31
+ );
32
+ }
33
+
34
+ const out = state<T>(source());
35
+ let timer: unknown = null;
36
+ let firstRun = true;
37
+ let disposed = false;
38
+
39
+ const cleanup = () => {
40
+ if (timer !== null) {
41
+ clearTimeout(timer);
42
+ timer = null;
43
+ }
44
+ };
45
+
46
+ const stop = watch(() => {
47
+ let v: T;
48
+ try {
49
+ v = source();
50
+ } catch (e) {
51
+ console.error('[Purity] debounced() source threw:', e);
52
+ return cleanup;
53
+ }
54
+ if (firstRun) {
55
+ firstRun = false;
56
+ return cleanup;
57
+ }
58
+ cleanup();
59
+ timer = setTimeout(() => {
60
+ timer = null;
61
+ if (disposed) return;
62
+ out(v);
63
+ }, ms);
64
+ return cleanup;
65
+ });
66
+
67
+ const accessor = out.get as DebouncedAccessor<T>;
68
+ accessor.get = out.get;
69
+ accessor.peek = out.peek;
70
+ accessor.dispose = () => {
71
+ if (disposed) return;
72
+ disposed = true;
73
+ cleanup();
74
+ stop();
75
+ };
76
+ return accessor;
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ export {
2
+ type ComputedAccessor,
3
+ type Dispose,
4
+ type StateAccessor,
5
+ type WatchSource,
6
+ batch,
7
+ compute,
8
+ disposeScope,
9
+ state,
10
+ untracked,
11
+ watch,
12
+ watchEffectInternal,
13
+ } from './signals.ts';
14
+ export { type DebouncedAccessor, debounced } from './debounced.ts';
15
+ export {
16
+ attachToOwner,
17
+ ComponentContext,
18
+ getCurrentComponent,
19
+ getCurrentContext,
20
+ popContext,
21
+ pushContext,
22
+ type Scope,
23
+ withoutScope,
24
+ } from './scope.ts';
package/src/scope.ts ADDED
@@ -0,0 +1,136 @@
1
+ // ---------------------------------------------------------------------------
2
+ // ComponentContext & Scope — platform-agnostic ownership tree
3
+ // ---------------------------------------------------------------------------
4
+
5
+ declare const console: {
6
+ error(...args: unknown[]): void;
7
+ warn(...args: unknown[]): void;
8
+ };
9
+
10
+ export interface Scope {
11
+ disposers?: (() => void)[] | null;
12
+ /** Lean scopes that run after their component rendered (an effect re-running
13
+ * in a later flush) record the component they belong to. */
14
+ component?: ComponentContext | null;
15
+ }
16
+
17
+ let contextStack: Scope[] = [];
18
+
19
+ export class ComponentContext implements Scope {
20
+ declare mounted?: (() => void)[] | null;
21
+ declare destroyed?: (() => void)[] | null;
22
+ declare errorHandlers?: ((err: unknown) => void)[] | null;
23
+ declare disposers?: (() => void)[] | null;
24
+ declare nodes?: any[] | null;
25
+ /** Root container owned by mount/hydrate, without a DOM dependency here. */
26
+ declare _rootContainer?: unknown;
27
+ declare parent?: ComponentContext | null;
28
+ declare children?: ComponentContext[] | null;
29
+ declare _isMounted?: boolean;
30
+ declare _isDestroyed?: boolean;
31
+ // Only failed factory teardown sets this flag; lean-owned descendants inherit it.
32
+ declare _rollback?: boolean;
33
+ declare _slotContent?: unknown;
34
+ // Platform-specific extensions (_internals, _stateRefs, _form*, _shadowRoot, _ssr*)
35
+ // are attached lazily by @purityjs/core and @purityjs/ssr with zero base allocation.
36
+ declare _internals?: any;
37
+ declare _stateRefs?: Map<string, number> | null;
38
+ declare _shadowRoot?: unknown;
39
+ declare _ssrSlots?: unknown;
40
+ declare _ssrStyles?: string[];
41
+ declare _formAssociated?: ((form: any) => void)[] | null;
42
+ declare _formDisabled?: ((disabled: boolean) => void)[] | null;
43
+ declare _formReset?: (() => void)[] | null;
44
+ declare _formStateRestore?: ((state: any, mode: 'restore' | 'autocomplete') => void)[] | null;
45
+
46
+ _handleError(err: unknown): void {
47
+ // Scoped onError removal can leave an empty array; treat it as no handler.
48
+ const handlers = this.errorHandlers;
49
+ if (handlers?.length) {
50
+ // Recovery work belongs to the component whose handlers receive the
51
+ // error, even when a descendant or a later scheduled effect failed.
52
+ // Teardown errors can still use handlers until their list is released,
53
+ // but must not restore an already destroyed component as a live owner.
54
+ const scoped = !this._isDestroyed;
55
+ if (scoped) pushContext(this);
56
+ try {
57
+ // A handler can unmount this component and release the callback list.
58
+ for (let i = 0; i < handlers.length && this.errorHandlers === handlers; i++) {
59
+ try {
60
+ handlers[i](err);
61
+ } catch (e) {
62
+ console.error('[Purity] Error in onError handler:', e);
63
+ }
64
+ }
65
+ } finally {
66
+ if (scoped) popContext();
67
+ }
68
+ } else if (this.parent) {
69
+ this.parent._handleError(err);
70
+ } else {
71
+ throw err;
72
+ }
73
+ }
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Context stack
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** Run shared work without capturing a caller's disposal or component owner. @internal */
81
+ export function withoutScope<T>(fn: () => T): T {
82
+ const previous = contextStack;
83
+ contextStack = [];
84
+ try {
85
+ return fn();
86
+ } finally {
87
+ contextStack = previous;
88
+ }
89
+ }
90
+
91
+ export function getCurrentContext(): Scope | null {
92
+ return contextStack[contextStack.length - 1] || null;
93
+ }
94
+
95
+ /**
96
+ * Nearest enclosing component render context. Lean scopes pushed above it
97
+ * (each() rows, match() branches, running effects) own disposal but must not
98
+ * hide the component from component-only APIs (lifecycle hooks, css(),
99
+ * internals, custom states). @internal
100
+ */
101
+ export function getCurrentComponent(): ComponentContext | null {
102
+ for (let i = contextStack.length - 1; i >= 0; i--) {
103
+ const ctx = contextStack[i];
104
+ const component = ctx instanceof ComponentContext ? ctx : ctx.component;
105
+ if (component) return component;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Attach a new child context to the current owner. A component owner tracks it
112
+ * as a child (and is its error parent). A lean scope (row, branch, effect)
113
+ * disposes it, and the nearest component becomes its error parent.
114
+ * @internal
115
+ */
116
+ export function attachToOwner(
117
+ ctx: ComponentContext,
118
+ unmount: (ctx: ComponentContext) => void,
119
+ ): void {
120
+ const owner = getCurrentContext();
121
+ if (owner instanceof ComponentContext) {
122
+ ctx.parent = owner;
123
+ (owner.children ??= []).push(ctx);
124
+ } else if (owner) {
125
+ ctx.parent = getCurrentComponent();
126
+ (owner.disposers ??= []).push(() => unmount(ctx));
127
+ }
128
+ }
129
+
130
+ export function pushContext(ctx: Scope): void {
131
+ contextStack.push(ctx);
132
+ }
133
+
134
+ export function popContext(): Scope | undefined {
135
+ return contextStack.pop();
136
+ }