@streetui/state 1.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,352 @@
1
+ // src/signal.ts
2
+ var _activeConsumer = null;
3
+ function withConsumer(consumer, fn) {
4
+ const prev = _activeConsumer;
5
+ _activeConsumer = consumer;
6
+ try {
7
+ return fn();
8
+ } finally {
9
+ _activeConsumer = prev;
10
+ }
11
+ }
12
+ var _batchDepth = 0;
13
+ var _pendingFlushes = /* @__PURE__ */ new Map();
14
+ function _enqueueBatchFlush(sig, value) {
15
+ _pendingFlushes.set(sig, { signal: sig, value });
16
+ }
17
+ function _drainBatch() {
18
+ const flushes = [..._pendingFlushes.values()];
19
+ _pendingFlushes.clear();
20
+ for (const { signal: sig, value } of flushes) {
21
+ sig._flushBatch(value);
22
+ }
23
+ }
24
+ var Signal = class {
25
+ _value;
26
+ _subscribers = /* @__PURE__ */ new Set();
27
+ _consumers = /* @__PURE__ */ new Set();
28
+ constructor(initial) {
29
+ this._value = initial;
30
+ }
31
+ get() {
32
+ if (_activeConsumer !== null) {
33
+ this._consumers.add(_activeConsumer);
34
+ _activeConsumer._addSource(this);
35
+ }
36
+ return this._value;
37
+ }
38
+ peek() {
39
+ return this._value;
40
+ }
41
+ set(value) {
42
+ if (Object.is(this._value, value)) return;
43
+ this._value = value;
44
+ if (_batchDepth > 0) {
45
+ _enqueueBatchFlush(this, value);
46
+ } else {
47
+ this._flush(value);
48
+ }
49
+ }
50
+ update(fn) {
51
+ this.set(fn(this._value));
52
+ }
53
+ subscribe(fn) {
54
+ this._subscribers.add(fn);
55
+ return () => {
56
+ this._subscribers.delete(fn);
57
+ };
58
+ }
59
+ _removeConsumer(consumer) {
60
+ this._consumers.delete(consumer);
61
+ }
62
+ /**
63
+ * Called by the batch machinery after the batch has completed.
64
+ * Notifies subscribers with the final coalesced value.
65
+ */
66
+ _flushBatch(value) {
67
+ this._flush(value);
68
+ }
69
+ _flush(value) {
70
+ for (const sub of [...this._subscribers]) sub(value);
71
+ for (const consumer of [...this._consumers]) consumer._invalidate();
72
+ }
73
+ /**
74
+ * @internal DevTools inspection only. The number of live observers
75
+ * (direct subscribers plus derived/effect consumers). Read-only; never
76
+ * mutates reactive state.
77
+ */
78
+ _observerCount() {
79
+ return this._subscribers.size + this._consumers.size;
80
+ }
81
+ };
82
+ var DerivedSignal = class {
83
+ _value = void 0;
84
+ _dirty = true;
85
+ _disposed = false;
86
+ _fn;
87
+ _subscribers = /* @__PURE__ */ new Set();
88
+ /** All upstream sources this derived currently reads from. */
89
+ _sources = /* @__PURE__ */ new Set();
90
+ /** Downstream consumers that depend on this derived. */
91
+ _consumers = /* @__PURE__ */ new Set();
92
+ constructor(fn) {
93
+ this._fn = fn;
94
+ }
95
+ get() {
96
+ if (_activeConsumer !== null) {
97
+ this._consumers.add(_activeConsumer);
98
+ _activeConsumer._addSource(this);
99
+ }
100
+ if (this._dirty) this._recompute();
101
+ return this._value;
102
+ }
103
+ peek() {
104
+ if (this._dirty) this._recompute();
105
+ return this._value;
106
+ }
107
+ subscribe(fn) {
108
+ if (this._dirty) this._recompute();
109
+ this._subscribers.add(fn);
110
+ return () => {
111
+ this._subscribers.delete(fn);
112
+ };
113
+ }
114
+ _addSource(src) {
115
+ this._sources.add(src);
116
+ }
117
+ _removeConsumer(consumer) {
118
+ this._consumers.delete(consumer);
119
+ }
120
+ _invalidate() {
121
+ if (this._disposed) return;
122
+ this._dirty = true;
123
+ const newVal = this.peek();
124
+ for (const sub of [...this._subscribers]) sub(newVal);
125
+ for (const consumer of [...this._consumers]) consumer._invalidate();
126
+ }
127
+ _recompute() {
128
+ for (const src of this._sources) src._removeConsumer(this);
129
+ this._sources.clear();
130
+ this._value = withConsumer(this, this._fn);
131
+ this._dirty = false;
132
+ }
133
+ dispose() {
134
+ this._disposed = true;
135
+ for (const src of this._sources) src._removeConsumer(this);
136
+ this._sources.clear();
137
+ this._subscribers.clear();
138
+ this._consumers.clear();
139
+ }
140
+ /**
141
+ * @internal DevTools inspection only. Live observers (subscribers plus
142
+ * downstream consumers). Read-only.
143
+ */
144
+ _observerCount() {
145
+ return this._subscribers.size + this._consumers.size;
146
+ }
147
+ };
148
+ var Effect = class {
149
+ _fn;
150
+ _cleanup = void 0;
151
+ _disposed = false;
152
+ _sources = /* @__PURE__ */ new Set();
153
+ constructor(fn) {
154
+ this._fn = fn;
155
+ this._run();
156
+ }
157
+ _addSource(src) {
158
+ this._sources.add(src);
159
+ }
160
+ _invalidate() {
161
+ if (this._disposed) return;
162
+ this._run();
163
+ }
164
+ _run() {
165
+ for (const src of this._sources) src._removeConsumer(this);
166
+ this._sources.clear();
167
+ if (typeof this._cleanup === "function") this._cleanup();
168
+ const result = withConsumer(this, this._fn);
169
+ this._cleanup = typeof result === "function" ? result : void 0;
170
+ }
171
+ dispose() {
172
+ this._disposed = true;
173
+ for (const src of this._sources) src._removeConsumer(this);
174
+ this._sources.clear();
175
+ if (typeof this._cleanup === "function") this._cleanup();
176
+ this._cleanup = void 0;
177
+ }
178
+ };
179
+ function signal(initial) {
180
+ return new Signal(initial);
181
+ }
182
+ function derived(fn) {
183
+ return new DerivedSignal(fn);
184
+ }
185
+ function effect(fn) {
186
+ const e = new Effect(fn);
187
+ return () => e.dispose();
188
+ }
189
+ function batch(fn) {
190
+ _batchDepth++;
191
+ try {
192
+ fn();
193
+ } finally {
194
+ _batchDepth--;
195
+ if (_batchDepth === 0) {
196
+ _drainBatch();
197
+ }
198
+ }
199
+ }
200
+ function isBatching() {
201
+ return _batchDepth > 0;
202
+ }
203
+ function signalKind(source) {
204
+ return source instanceof DerivedSignal ? "derived" : "writable";
205
+ }
206
+ function observerCount(source) {
207
+ const maybe = source;
208
+ return typeof maybe._observerCount === "function" ? maybe._observerCount() : void 0;
209
+ }
210
+
211
+ // src/store.ts
212
+ var Store = class {
213
+ _signals;
214
+ constructor(initial) {
215
+ const signals = {};
216
+ for (const key in initial) {
217
+ if (Object.prototype.hasOwnProperty.call(initial, key)) {
218
+ signals[key] = signal(initial[key]);
219
+ }
220
+ }
221
+ this._signals = signals;
222
+ }
223
+ get(key) {
224
+ const s = this._signals[key];
225
+ if (s === void 0) {
226
+ throw new Error(`Store: unknown key "${String(key)}"`);
227
+ }
228
+ return s.get();
229
+ }
230
+ set(key, value) {
231
+ const s = this._signals[key];
232
+ if (s === void 0) {
233
+ throw new Error(`Store: unknown key "${String(key)}"`);
234
+ }
235
+ s.set(value);
236
+ }
237
+ signal(key) {
238
+ const s = this._signals[key];
239
+ if (s === void 0) {
240
+ throw new Error(`Store: unknown key "${String(key)}"`);
241
+ }
242
+ return s;
243
+ }
244
+ subscribe(key, fn) {
245
+ return this.signal(key).subscribe(fn);
246
+ }
247
+ getSnapshot() {
248
+ const snap = {};
249
+ for (const key in this._signals) {
250
+ if (Object.prototype.hasOwnProperty.call(this._signals, key)) {
251
+ snap[key] = this._signals[key]?.peek();
252
+ }
253
+ }
254
+ return snap;
255
+ }
256
+ };
257
+ function createStore(initial) {
258
+ return new Store(initial);
259
+ }
260
+
261
+ // src/resource.ts
262
+ function isAbortError(err) {
263
+ return err instanceof Error && err.name === "AbortError" || typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "AbortError";
264
+ }
265
+ function resource(loader, options = {}) {
266
+ const hasInitial = options.initialData !== void 0 || options.initialError !== void 0 || options.initialStatus !== void 0;
267
+ const seededStatus = options.initialStatus ?? (options.initialData !== void 0 ? "success" : options.initialError !== void 0 ? "error" : "idle");
268
+ const status = signal(seededStatus);
269
+ const data = signal(options.initialData);
270
+ const error = signal(options.initialError);
271
+ const loading = derived(() => status.get() === "loading");
272
+ const isRefetching = derived(() => status.get() === "loading" && data.get() !== void 0);
273
+ let disposed = false;
274
+ let runId = 0;
275
+ let controller = null;
276
+ const load = async () => {
277
+ if (disposed) return;
278
+ controller?.abort();
279
+ const myRun = ++runId;
280
+ const myController = new AbortController();
281
+ controller = myController;
282
+ batch(() => {
283
+ error.set(void 0);
284
+ status.set("loading");
285
+ });
286
+ try {
287
+ const result = await loader({ signal: myController.signal });
288
+ if (disposed || myRun !== runId) return;
289
+ batch(() => {
290
+ data.set(result);
291
+ error.set(void 0);
292
+ status.set("success");
293
+ });
294
+ } catch (err) {
295
+ if (disposed || myRun !== runId) return;
296
+ if (isAbortError(err)) return;
297
+ batch(() => {
298
+ error.set(err);
299
+ status.set("error");
300
+ });
301
+ }
302
+ };
303
+ const refetch = () => load();
304
+ const watchUnsubs = [];
305
+ if (options.watch !== void 0) {
306
+ for (const dep of options.watch) {
307
+ watchUnsubs.push(
308
+ dep.subscribe(() => {
309
+ if (!disposed) void load();
310
+ })
311
+ );
312
+ }
313
+ }
314
+ const dispose = () => {
315
+ if (disposed) return;
316
+ disposed = true;
317
+ controller?.abort();
318
+ controller = null;
319
+ for (const unsub of watchUnsubs) unsub();
320
+ watchUnsubs.length = 0;
321
+ };
322
+ if (options.onCleanup !== void 0) {
323
+ options.onCleanup(dispose);
324
+ }
325
+ if (options.immediate === true || options.immediate !== false && !hasInitial) {
326
+ void load();
327
+ }
328
+ return {
329
+ status,
330
+ data,
331
+ error,
332
+ loading,
333
+ isRefetching,
334
+ refetch,
335
+ dispose
336
+ };
337
+ }
338
+ export {
339
+ DerivedSignal,
340
+ Signal,
341
+ Store,
342
+ batch,
343
+ createStore,
344
+ derived,
345
+ effect,
346
+ isBatching,
347
+ observerCount,
348
+ resource,
349
+ signal,
350
+ signalKind
351
+ };
352
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/signal.ts","../src/store.ts","../src/resource.ts"],"sourcesContent":["/**\n * StreetUI reactive signals — framework-owned reactivity, no external libraries.\n *\n * Architecture:\n * Signal<T> — writable, holds a value, notifies on change\n * DerivedSignal<T> — read-only, lazily computed from other signals\n * effect() — side-effect that re-runs when dependencies change\n * batch() — run multiple updates before notifying\n */\n\nexport type Subscriber<T> = (value: T) => void;\nexport type Unsubscribe = () => void;\n\n// ── Tracking ──────────────────────────────────────────────────────────────────\n\n/**\n * Any reactive source that can have downstream consumers attached.\n * Both Signal and DerivedSignal implement this.\n */\nexport interface ReactiveSource<T> {\n get(): T;\n peek(): T;\n subscribe(fn: Subscriber<T>): Unsubscribe;\n /** Internal: remove a downstream consumer. */\n _removeConsumer(consumer: ReactiveConsumer): void;\n}\n\n/**\n * A downstream consumer (DerivedSignal or Effect) that can be invalidated\n * and can register itself as depending on a source.\n */\nexport interface ReactiveConsumer {\n _invalidate(): void;\n /** Internal: called by a source to register a dependency. */\n _addSource(src: ReactiveSource<unknown>): void;\n}\n\n/** The active consumer being tracked during a computation. */\nlet _activeConsumer: ReactiveConsumer | null = null;\n\nfunction withConsumer<T>(consumer: ReactiveConsumer, fn: () => T): T {\n const prev = _activeConsumer;\n _activeConsumer = consumer;\n try {\n return fn();\n } finally {\n _activeConsumer = prev;\n }\n}\n\n// ── Batch state (must be before Signal class so .set() can reference it) ──────\n\n/** Depth counter — batches nest; only the outermost flush propagates. */\nlet _batchDepth = 0;\n\ninterface PendingFlush {\n signal: Signal<unknown>;\n value: unknown;\n}\n\n/**\n * Keyed by signal identity — last-write-wins for each signal within a batch.\n * An ordered Map preserves first-seen insertion order for stable flush sequence.\n */\nconst _pendingFlushes = new Map<Signal<unknown>, PendingFlush>();\n\nfunction _enqueueBatchFlush(sig: Signal<unknown>, value: unknown): void {\n _pendingFlushes.set(sig, { signal: sig, value });\n}\n\nfunction _drainBatch(): void {\n const flushes = [..._pendingFlushes.values()];\n _pendingFlushes.clear();\n for (const { signal: sig, value } of flushes) {\n sig._flushBatch(value);\n }\n}\n\n// ── Signal ────────────────────────────────────────────────────────────────────\n\nexport interface ReadonlySignal<T> {\n get(): T;\n peek(): T;\n subscribe(fn: Subscriber<T>): Unsubscribe;\n}\n\nexport class Signal<T> implements ReactiveSource<T>, ReadonlySignal<T> {\n protected _value: T;\n private readonly _subscribers: Set<Subscriber<T>> = new Set();\n private readonly _consumers: Set<ReactiveConsumer> = new Set();\n\n constructor(initial: T) {\n this._value = initial;\n }\n\n get(): T {\n if (_activeConsumer !== null) {\n this._consumers.add(_activeConsumer);\n _activeConsumer._addSource(this as ReactiveSource<unknown>);\n }\n return this._value;\n }\n\n peek(): T {\n return this._value;\n }\n\n set(value: T): void {\n if (Object.is(this._value, value)) return;\n this._value = value;\n if (_batchDepth > 0) {\n // Defer notification until the batch flushes.\n // The stored value is always the latest (last-write-wins for same signal).\n _enqueueBatchFlush(this as unknown as Signal<unknown>, value);\n } else {\n this._flush(value);\n }\n }\n\n update(fn: (current: T) => T): void {\n this.set(fn(this._value));\n }\n\n subscribe(fn: Subscriber<T>): Unsubscribe {\n this._subscribers.add(fn);\n return () => { this._subscribers.delete(fn); };\n }\n\n _removeConsumer(consumer: ReactiveConsumer): void {\n this._consumers.delete(consumer);\n }\n\n /**\n * Called by the batch machinery after the batch has completed.\n * Notifies subscribers with the final coalesced value.\n */\n _flushBatch(value: unknown): void {\n this._flush(value as T);\n }\n\n private _flush(value: T): void {\n for (const sub of [...this._subscribers]) sub(value);\n for (const consumer of [...this._consumers]) consumer._invalidate();\n }\n\n /**\n * @internal DevTools inspection only. The number of live observers\n * (direct subscribers plus derived/effect consumers). Read-only; never\n * mutates reactive state.\n */\n _observerCount(): number {\n return this._subscribers.size + this._consumers.size;\n }\n}\n\n// ── DerivedSignal ─────────────────────────────────────────────────────────────\n\nexport class DerivedSignal<T> implements ReactiveSource<T>, ReactiveConsumer, ReadonlySignal<T> {\n private _value: T | undefined = undefined;\n private _dirty = true;\n private _disposed = false;\n private readonly _fn: () => T;\n private readonly _subscribers: Set<Subscriber<T>> = new Set();\n /** All upstream sources this derived currently reads from. */\n private readonly _sources: Set<ReactiveSource<unknown>> = new Set();\n /** Downstream consumers that depend on this derived. */\n private readonly _consumers: Set<ReactiveConsumer> = new Set();\n\n constructor(fn: () => T) {\n this._fn = fn;\n }\n\n get(): T {\n // Register this derived with the outer consumer if any\n if (_activeConsumer !== null) {\n this._consumers.add(_activeConsumer);\n _activeConsumer._addSource(this as ReactiveSource<unknown>);\n }\n if (this._dirty) this._recompute();\n return this._value as T;\n }\n\n peek(): T {\n if (this._dirty) this._recompute();\n return this._value as T;\n }\n\n subscribe(fn: Subscriber<T>): Unsubscribe {\n // Eagerly compute so sources track this derived as a consumer\n if (this._dirty) this._recompute();\n this._subscribers.add(fn);\n return () => { this._subscribers.delete(fn); };\n }\n\n _addSource(src: ReactiveSource<unknown>): void {\n this._sources.add(src);\n }\n\n _removeConsumer(consumer: ReactiveConsumer): void {\n this._consumers.delete(consumer);\n }\n\n _invalidate(): void {\n if (this._disposed) return;\n this._dirty = true;\n const newVal = this.peek(); // recompute\n for (const sub of [...this._subscribers]) sub(newVal);\n for (const consumer of [...this._consumers]) consumer._invalidate();\n }\n\n private _recompute(): void {\n // Detach from all current sources before re-tracking\n for (const src of this._sources) src._removeConsumer(this);\n this._sources.clear();\n\n this._value = withConsumer(this, this._fn);\n this._dirty = false;\n }\n\n dispose(): void {\n this._disposed = true;\n for (const src of this._sources) src._removeConsumer(this);\n this._sources.clear();\n this._subscribers.clear();\n this._consumers.clear();\n }\n\n /**\n * @internal DevTools inspection only. Live observers (subscribers plus\n * downstream consumers). Read-only.\n */\n _observerCount(): number {\n return this._subscribers.size + this._consumers.size;\n }\n}\n\n// ── effect ────────────────────────────────────────────────────────────────────\n\nclass Effect implements ReactiveConsumer {\n private readonly _fn: () => void | (() => void);\n private _cleanup: (() => void) | undefined = undefined;\n private _disposed = false;\n private readonly _sources: Set<ReactiveSource<unknown>> = new Set();\n\n constructor(fn: () => void | (() => void)) {\n this._fn = fn;\n this._run();\n }\n\n _addSource(src: ReactiveSource<unknown>): void {\n this._sources.add(src);\n }\n\n _invalidate(): void {\n if (this._disposed) return;\n this._run();\n }\n\n private _run(): void {\n // Detach from all current sources before re-tracking\n for (const src of this._sources) src._removeConsumer(this);\n this._sources.clear();\n\n if (typeof this._cleanup === 'function') this._cleanup();\n const result = withConsumer(this, this._fn);\n this._cleanup = typeof result === 'function' ? result : undefined;\n }\n\n dispose(): void {\n this._disposed = true;\n for (const src of this._sources) src._removeConsumer(this);\n this._sources.clear();\n if (typeof this._cleanup === 'function') this._cleanup();\n this._cleanup = undefined;\n }\n}\n\n// ── Public API ─────────────────────────────────────────────────────────────────\n\nexport function signal<T>(initial: T): Signal<T> {\n return new Signal(initial);\n}\n\nexport function derived<T>(fn: () => T): DerivedSignal<T> {\n return new DerivedSignal(fn);\n}\n\nexport function effect(fn: () => void | (() => void)): Unsubscribe {\n const e = new Effect(fn);\n return () => e.dispose();\n}\n\n/**\n * Run multiple signal updates as an atomic batch.\n *\n * Within the callback, calls to signal.set() are deferred — each signal\n * accumulates its latest value. When the outermost batch() returns,\n * each modified signal fires its subscribers exactly once with the final\n * value. Nested batch() calls are supported; the flush only runs when the\n * outermost batch exits.\n *\n * Example:\n * batch(() => {\n * count.set(1);\n * count.set(2);\n * count.set(3);\n * });\n * // subscribers see count = 3 exactly once\n */\nexport function batch(fn: () => void): void {\n _batchDepth++;\n try {\n fn();\n } finally {\n _batchDepth--;\n if (_batchDepth === 0) {\n _drainBatch();\n }\n }\n}\n\n/** True when inside a batch() call. Useful for advanced scheduling integration. */\nexport function isBatching(): boolean {\n return _batchDepth > 0;\n}\n\n// ── Inspection (read-only, DevTools-facing) ─────────────────────────────────\n\n/** Whether a signal is writable (`signal()`) or computed (`derived()`). */\nexport type SignalKind = 'writable' | 'derived';\n\n/** Classify a reactive value as writable or derived. */\nexport function signalKind(source: ReadonlySignal<unknown>): SignalKind {\n return source instanceof DerivedSignal ? 'derived' : 'writable';\n}\n\n/**\n * The number of live observers on a signal — direct subscribers plus derived\n * or effect consumers — or `undefined` if the source does not expose the count.\n * Read-only; safe for DevTools. Never mutates reactive state.\n */\nexport function observerCount(source: ReadonlySignal<unknown>): number | undefined {\n const maybe = source as { _observerCount?: () => number };\n return typeof maybe._observerCount === 'function' ? maybe._observerCount() : undefined;\n}\n","/**\n * A simple reactive store built on top of signals.\n * Useful for structured state with multiple fields.\n */\n\nimport { Signal, signal, type Subscriber, type Unsubscribe } from './signal.js';\n\nexport type StoreState = Record<string, unknown>;\n\nexport class Store<T extends StoreState> {\n private readonly _signals: Record<string, Signal<unknown>>;\n\n constructor(initial: T) {\n const signals: Record<string, Signal<unknown>> = {};\n for (const key in initial) {\n if (Object.prototype.hasOwnProperty.call(initial, key)) {\n signals[key] = signal(initial[key] as unknown);\n }\n }\n this._signals = signals;\n }\n\n get<K extends keyof T>(key: K): T[K] {\n const s = this._signals[key as string];\n if (s === undefined) {\n throw new Error(`Store: unknown key \"${String(key)}\"`);\n }\n return s.get() as T[K];\n }\n\n set<K extends keyof T>(key: K, value: T[K]): void {\n const s = this._signals[key as string];\n if (s === undefined) {\n throw new Error(`Store: unknown key \"${String(key)}\"`);\n }\n s.set(value as unknown);\n }\n\n signal<K extends keyof T>(key: K): Signal<T[K]> {\n const s = this._signals[key as string];\n if (s === undefined) {\n throw new Error(`Store: unknown key \"${String(key)}\"`);\n }\n return s as unknown as Signal<T[K]>;\n }\n\n subscribe<K extends keyof T>(key: K, fn: Subscriber<T[K]>): Unsubscribe {\n return this.signal(key).subscribe(fn);\n }\n\n getSnapshot(): T {\n const snap = {} as T;\n for (const key in this._signals) {\n if (Object.prototype.hasOwnProperty.call(this._signals, key)) {\n (snap as Record<string, unknown>)[key] = this._signals[key]?.peek();\n }\n }\n return snap;\n }\n}\n\nexport function createStore<T extends StoreState>(initial: T): Store<T> {\n return new Store(initial);\n}\n","/**\n * StreetUI async resources — framework-native asynchronous data.\n *\n * A `resource` wraps a Promise-returning loader and exposes its lifecycle as\n * ordinary StreetUI signals (status / data / error), so it composes with\n * `derived`, `effect`, `when()`, `listOf` and the renderer with no second\n * reactive system.\n *\n * State machine:\n *\n * idle ──(load)──▶ loading ──(resolve)──▶ success\n * │\n * └────(reject)──────▶ error\n *\n * Refetch keeps the previously-loaded `data` visible while `status` is\n * `'loading'` again (see `isRefetching`) — there is no separate `'refetching'`\n * status; it is expressed through `status === 'loading'` with `data` still set.\n *\n * The resource is transport-agnostic: the loader is any function returning a\n * value or a Promise. When it accepts the provided `AbortSignal`, in-flight\n * work is cancelled on `dispose()` or when a newer request supersedes it.\n */\n\nimport { signal, derived, batch, type ReadonlySignal, type Unsubscribe } from './signal.js';\n\nexport type ResourceStatus = 'idle' | 'loading' | 'success' | 'error';\n\n/** Context handed to the loader; carries an AbortSignal for cancellation. */\nexport interface ResourceLoaderContext {\n readonly signal: AbortSignal;\n}\n\n/** Any value-or-Promise producing function. Receives an abort-aware context. */\nexport type ResourceLoader<T> = (ctx: ResourceLoaderContext) => Promise<T> | T;\n\nexport interface ResourceOptions<T = unknown> {\n /** Load immediately on creation. Defaults to `true`. When `false`, stays `idle` until `refetch()`. */\n readonly immediate?: boolean;\n /**\n * Explicit reactive dependencies. When any listed signal changes, the\n * resource refetches. Dependencies are explicit (not auto-tracked from the\n * loader body) so there is no risk of an accidental infinite refetch loop.\n */\n readonly watch?: ReadonlyArray<ReadonlySignal<unknown>>;\n /**\n * Optional teardown registrar (e.g. a route's `ctx.onCleanup`). When given,\n * the resource registers its own `dispose` so it is cleaned up automatically\n * when its owner is removed.\n */\n readonly onCleanup?: (fn: () => void) => void;\n /**\n * Server-provided initial value for hydration. When present the resource\n * starts in `'success'` with this data already visible, and the initial\n * auto-load is skipped (so the client does not refetch data the server\n * already resolved). This is the client half of SSR resource transfer; the\n * server side awaits `refetch()` before serializing. Set `immediate: true`\n * explicitly to force a client refetch anyway.\n */\n readonly initialData?: T;\n /** Server-provided initial error for hydration (mirrors `initialData`). */\n readonly initialError?: unknown;\n /**\n * Explicit initial status override. Rarely needed — inferred as `'success'`\n * from `initialData` or `'error'` from `initialError`.\n */\n readonly initialStatus?: ResourceStatus;\n}\n\nexport interface Resource<T> {\n /** Reactive lifecycle status. */\n readonly status: ReadonlySignal<ResourceStatus>;\n /** The last successfully-loaded value, or `undefined` before first success. */\n readonly data: ReadonlySignal<T | undefined>;\n /** The most recent error, or `undefined` when there is none. Typed `unknown` — never `any`. */\n readonly error: ReadonlySignal<unknown>;\n /** Convenience: `status === 'loading'`. */\n readonly loading: ReadonlySignal<boolean>;\n /** Convenience: loading while previously-loaded data is still present (a refetch). */\n readonly isRefetching: ReadonlySignal<boolean>;\n /** Trigger a new request. Resolves when the request settles (or is superseded). */\n refetch(): Promise<void>;\n /** Cancel in-flight work, drop watchers, and ignore any late results. Idempotent. */\n dispose(): void;\n}\n\n/** True for an abort we initiated (so it is not surfaced as a real error). */\nfunction isAbortError(err: unknown): boolean {\n return (\n err instanceof Error && err.name === 'AbortError'\n ) || (\n typeof DOMException !== 'undefined' && err instanceof DOMException && err.name === 'AbortError'\n );\n}\n\nexport function resource<T>(loader: ResourceLoader<T>, options: ResourceOptions<T> = {}): Resource<T> {\n // Seed hydrated initial state (SSR transfer). `initialStatus` wins if given,\n // otherwise infer success/error from the provided value.\n const hasInitial =\n options.initialData !== undefined ||\n options.initialError !== undefined ||\n options.initialStatus !== undefined;\n\n const seededStatus: ResourceStatus =\n options.initialStatus ??\n (options.initialData !== undefined\n ? 'success'\n : options.initialError !== undefined\n ? 'error'\n : 'idle');\n\n const status = signal<ResourceStatus>(seededStatus);\n const data = signal<T | undefined>(options.initialData);\n const error = signal<unknown>(options.initialError);\n\n const loading = derived<boolean>(() => status.get() === 'loading');\n const isRefetching = derived<boolean>(() => status.get() === 'loading' && data.get() !== undefined);\n\n let disposed = false;\n /** Monotonic request id — only the newest request may write state (race guard). */\n let runId = 0;\n let controller: AbortController | null = null;\n\n const load = async (): Promise<void> => {\n if (disposed) return;\n\n // Supersede any in-flight request: abort it and bump the run id so its\n // eventual resolution is ignored.\n controller?.abort();\n const myRun = ++runId;\n const myController = new AbortController();\n controller = myController;\n\n // Enter loading. Preserve `data` (so a refetch keeps showing the old value)\n // and clear any previous error.\n batch(() => {\n error.set(undefined);\n status.set('loading');\n });\n\n try {\n const result = await loader({ signal: myController.signal });\n // Ignore stale or cancelled resolutions.\n if (disposed || myRun !== runId) return;\n batch(() => {\n data.set(result);\n error.set(undefined);\n status.set('success');\n });\n } catch (err) {\n if (disposed || myRun !== runId) return;\n // A self-initiated abort is not a user-visible error.\n if (isAbortError(err)) return;\n batch(() => {\n error.set(err);\n status.set('error');\n });\n }\n };\n\n const refetch = (): Promise<void> => load();\n\n // Explicit reactive dependencies: refetch when any watched signal changes.\n const watchUnsubs: Unsubscribe[] = [];\n if (options.watch !== undefined) {\n for (const dep of options.watch) {\n watchUnsubs.push(\n dep.subscribe(() => {\n if (!disposed) void load();\n }),\n );\n }\n }\n\n const dispose = (): void => {\n if (disposed) return;\n disposed = true;\n controller?.abort();\n controller = null;\n for (const unsub of watchUnsubs) unsub();\n watchUnsubs.length = 0;\n };\n\n if (options.onCleanup !== undefined) {\n options.onCleanup(dispose);\n }\n\n // Auto-load on creation unless disabled. When hydrated initial state is\n // present we skip the initial load by default (the server already resolved\n // it); an explicit `immediate: true` still forces a refetch.\n if (options.immediate === true || (options.immediate !== false && !hasInitial)) {\n void load();\n }\n\n return {\n status,\n data,\n error,\n loading,\n isRefetching,\n refetch,\n dispose,\n };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@streetui/state",
3
+ "version": "1.0.0",
4
+ "description": "StreetUI reactive state — signals, subscriptions, derived state",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "tsup",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run",
25
+ "clean": "rm -rf dist"
26
+ },
27
+ "devDependencies": {
28
+ "typescript": "*",
29
+ "tsup": "*",
30
+ "vitest": "*"
31
+ },
32
+ "license": "MIT",
33
+ "sideEffects": false,
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "README.md",
40
+ "LICENSE"
41
+ ]
42
+ }