preact-sigma 0.0.0 → 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/README.md ADDED
@@ -0,0 +1,509 @@
1
+ # `preact-sigma`
2
+
3
+ Managed UI state for Preact Signals, with Immer-powered updates and a small public API.
4
+
5
+ For naming and API design conventions, see [best-practices.md](./best-practices.md).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add preact-sigma
11
+ ```
12
+
13
+ ```sh
14
+ npm install preact-sigma
15
+ ```
16
+
17
+ ## Big Picture
18
+
19
+ Define state once, expose a few methods, and return reactive immutable data from the public instance.
20
+
21
+ ```ts
22
+ import { computed, defineManagedState, type StateHandle } from "preact-sigma";
23
+
24
+ type CounterEvents = {
25
+ thresholdReached: [{ count: number }];
26
+ };
27
+
28
+ type CounterState = number;
29
+
30
+ const Counter = defineManagedState(
31
+ (counter: StateHandle<CounterState, CounterEvents>, step: number) => {
32
+ const doubled = computed(() => counter.get() * 2);
33
+
34
+ return {
35
+ count: counter,
36
+ doubled,
37
+ increment() {
38
+ counter.set((value) => value + step);
39
+
40
+ if (counter.get() >= 10) {
41
+ counter.emit("thresholdReached", { count: counter.get() });
42
+ }
43
+ },
44
+ reset() {
45
+ counter.set(0);
46
+ },
47
+ };
48
+ },
49
+ 0,
50
+ );
51
+
52
+ const counter = new Counter(2);
53
+ const stopThreshold = counter.on("thresholdReached", (event) => {
54
+ console.log(event.count);
55
+ });
56
+
57
+ counter.count;
58
+ counter.doubled;
59
+ counter.increment();
60
+ stopThreshold();
61
+ ```
62
+
63
+ - `count: counter` exposes the base state as a reactive immutable property.
64
+ - `doubled` is a memoized reactive value exposed through `computed()`.
65
+ - `increment()` is action-wrapped automatically, so the state update is batched and untracked.
66
+ - `counter.on(...)` returns `stopThreshold`, which unsubscribes the event listener.
67
+
68
+ ## Define Reusable State
69
+
70
+ Use `defineManagedState()` when you want a reusable managed-state class.
71
+
72
+ ```ts
73
+ import { defineManagedState, type StateHandle } from "preact-sigma";
74
+
75
+ type CounterState = number;
76
+
77
+ const Counter = defineManagedState(
78
+ (counter: StateHandle<CounterState>) => ({
79
+ count: counter,
80
+ increment() {
81
+ counter.set((value) => value + 1);
82
+ },
83
+ }),
84
+ 0,
85
+ );
86
+ ```
87
+
88
+ ## Expose Base State
89
+
90
+ Return the constructor handle when you want the base state to appear as a reactive immutable property.
91
+
92
+ ```ts
93
+ import { defineManagedState, type StateHandle } from "preact-sigma";
94
+
95
+ type CounterState = number;
96
+
97
+ const Counter = defineManagedState(
98
+ (count: StateHandle<CounterState>) => ({
99
+ count,
100
+ }),
101
+ 0,
102
+ );
103
+
104
+ new Counter().count;
105
+ ```
106
+
107
+ ## Memoize A Reactive Derivation
108
+
109
+ Use `computed()` when you want a memoized reactive value on the public instance.
110
+
111
+ ```ts
112
+ import { computed, defineManagedState, type StateHandle } from "preact-sigma";
113
+
114
+ type CounterState = number;
115
+
116
+ const Counter = defineManagedState(
117
+ (counter: StateHandle<CounterState>) => ({
118
+ doubled: computed(() => counter.get() * 2),
119
+ }),
120
+ 0,
121
+ );
122
+
123
+ new Counter().doubled;
124
+ ```
125
+
126
+ ## Create A Tracked Query Method
127
+
128
+ Use `query()` when you want a public method whose reads stay tracked.
129
+ Query functions read from closed-over handles or signals and do not use
130
+ instance `this`.
131
+
132
+ ```ts
133
+ import { defineManagedState, query, type StateHandle } from "preact-sigma";
134
+
135
+ type CounterState = number;
136
+
137
+ const Counter = defineManagedState(
138
+ (counter: StateHandle<CounterState>) => ({
139
+ isPositive: query(() => counter.get() > 0),
140
+ }),
141
+ 0,
142
+ );
143
+
144
+ new Counter().isPositive();
145
+ ```
146
+
147
+ ## Read Base State Without Tracking
148
+
149
+ Use `handle.peek()` when you need the current base-state snapshot without creating a reactive dependency.
150
+
151
+ ```ts
152
+ import { defineManagedState, type StateHandle } from "preact-sigma";
153
+
154
+ type CounterState = number;
155
+
156
+ const Counter = defineManagedState(
157
+ (counter: StateHandle<CounterState>) => ({
158
+ logNow() {
159
+ console.log(counter.peek());
160
+ },
161
+ }),
162
+ 0,
163
+ );
164
+ ```
165
+
166
+ ## Use Top-Level Lenses
167
+
168
+ When the base state is object-shaped, the constructor handle exposes a shallow lens for each top-level property, and you can return that lens directly.
169
+
170
+ ```ts
171
+ import { computed, defineManagedState, type StateHandle } from "preact-sigma";
172
+
173
+ type SearchState = {
174
+ query: string;
175
+ };
176
+
177
+ const Search = defineManagedState(
178
+ (search: StateHandle<SearchState>) => ({
179
+ query: search.query,
180
+ trimmedQuery: computed(() => search.query.get().trim()),
181
+ setQuery(query: string) {
182
+ search.query.set(query);
183
+ },
184
+ }),
185
+ { query: "" },
186
+ );
187
+
188
+ new Search().query;
189
+ ```
190
+
191
+ ## Spread A Handle To Expose Top-Level Properties
192
+
193
+ When the base state is object-shaped, spread the handle into the returned object to expose its current top-level lenses at once.
194
+
195
+ ```ts
196
+ import { defineManagedState, type StateHandle } from "preact-sigma";
197
+
198
+ type SearchState = {
199
+ page: number;
200
+ query: string;
201
+ };
202
+
203
+ const Search = defineManagedState(
204
+ (search: StateHandle<SearchState>) => ({
205
+ ...search,
206
+ nextPage() {
207
+ search.page.set((page) => page + 1);
208
+ },
209
+ }),
210
+ { page: 1, query: "" },
211
+ );
212
+
213
+ const search = new Search();
214
+
215
+ search.query;
216
+ search.page;
217
+ ```
218
+
219
+ ## Compose Managed States
220
+
221
+ Return another managed-state instance when you want to expose it unchanged as a property.
222
+ Composed managed states are available through direct property access and whole-state snapshots.
223
+
224
+ ```ts
225
+ import { defineManagedState, type StateHandle } from "preact-sigma";
226
+
227
+ type CounterState = number;
228
+
229
+ const Counter = defineManagedState(
230
+ (count: StateHandle<CounterState>) => ({
231
+ count,
232
+ increment() {
233
+ count.set((value) => value + 1);
234
+ },
235
+ }),
236
+ 0,
237
+ );
238
+
239
+ type DashboardState = {
240
+ ready: boolean;
241
+ };
242
+
243
+ const Dashboard = defineManagedState(
244
+ (dashboard: StateHandle<DashboardState>) => ({
245
+ dashboard,
246
+ counter: new Counter(),
247
+ }),
248
+ { ready: false },
249
+ );
250
+
251
+ new Dashboard().counter.increment();
252
+ ```
253
+
254
+ ## Own Resources And Dispose The Instance
255
+
256
+ Use `handle.own()` to register cleanup functions or disposables, and call `.dispose()` when the managed state should release them.
257
+
258
+ ```ts
259
+ import { defineManagedState, type StateHandle } from "preact-sigma";
260
+
261
+ type PollerState = {
262
+ ticks: number;
263
+ };
264
+
265
+ const Poller = defineManagedState(
266
+ (poller: StateHandle<PollerState>) => ({
267
+ ticks: poller.ticks,
268
+ start() {
269
+ const interval = window.setInterval(() => {
270
+ poller.ticks.set((ticks) => ticks + 1);
271
+ }, 1000);
272
+
273
+ poller.own([() => window.clearInterval(interval)]);
274
+ },
275
+ }),
276
+ { ticks: 0 },
277
+ );
278
+
279
+ const poller = new Poller();
280
+
281
+ poller.start();
282
+ poller.dispose();
283
+ ```
284
+
285
+ ## Update State
286
+
287
+ Pass an Immer producer to `.set()` when your base state is object-shaped.
288
+
289
+ ```ts
290
+ import { defineManagedState, type StateHandle } from "preact-sigma";
291
+
292
+ type SearchState = {
293
+ query: string;
294
+ };
295
+
296
+ const Search = defineManagedState(
297
+ (search: StateHandle<SearchState>) => ({
298
+ setQuery(query: string) {
299
+ search.set((draft) => {
300
+ draft.query = query;
301
+ });
302
+ },
303
+ }),
304
+ { query: "" },
305
+ );
306
+ ```
307
+
308
+ ## Emit Events
309
+
310
+ Use `.emit()` to publish a custom event with zero or one argument.
311
+
312
+ ```ts
313
+ import { defineManagedState, type StateHandle } from "preact-sigma";
314
+
315
+ type TodoEvents = {
316
+ saved: [];
317
+ selected: [{ id: string }];
318
+ };
319
+
320
+ type TodoState = {};
321
+
322
+ const Todo = defineManagedState(
323
+ (todo: StateHandle<TodoState, TodoEvents>) => ({
324
+ save() {
325
+ todo.emit("saved");
326
+ },
327
+ select(id: string) {
328
+ todo.emit("selected", { id });
329
+ },
330
+ }),
331
+ {},
332
+ );
333
+ ```
334
+
335
+ ## Listen For Events
336
+
337
+ Use `.on()` to subscribe to custom events from a managed state instance.
338
+
339
+ ```ts
340
+ const todo = new Todo();
341
+
342
+ const stopSaved = todo.on("saved", () => {
343
+ console.log("saved");
344
+ });
345
+
346
+ const stopSelected = todo.on("selected", (event) => {
347
+ console.log(event.id);
348
+ });
349
+
350
+ stopSaved();
351
+ stopSelected();
352
+ ```
353
+
354
+ ## Read Signals From A Managed State
355
+
356
+ Use `.get(key)` for one exposed signal-backed property or `.get()` for the whole public state signal.
357
+ Keyed reads do not target composed managed-state properties.
358
+
359
+ ```ts
360
+ const counter = new Counter();
361
+
362
+ const countSignal = counter.get("count");
363
+ const counterSignal = counter.get();
364
+
365
+ countSignal.value;
366
+ counterSignal.value.count;
367
+ ```
368
+
369
+ ## Peek At Public State
370
+
371
+ Use `.peek(key)` for one exposed signal-backed property or `.peek()` for the whole public snapshot.
372
+ Keyed peeks do not target composed managed-state properties.
373
+
374
+ ```ts
375
+ const counter = new Counter();
376
+
377
+ counter.peek("count");
378
+ counter.peek();
379
+ ```
380
+
381
+ ## Subscribe To Public State
382
+
383
+ Use `.subscribe(key, listener)` for one exposed signal-backed property or `.subscribe(listener)` for the whole public state.
384
+ Listeners receive the current value immediately and then future updates.
385
+ Keyed subscriptions do not target composed managed-state properties.
386
+
387
+ ```ts
388
+ const counter = new Counter();
389
+
390
+ const stopCount = counter.subscribe("count", (count) => {
391
+ console.log(count);
392
+ });
393
+
394
+ const stopState = counter.subscribe((value) => {
395
+ console.log(value.count);
396
+ });
397
+
398
+ stopCount();
399
+ stopState();
400
+ ```
401
+
402
+ ## Use It Inside A Component
403
+
404
+ Use `useManagedState()` when you want the same pattern directly inside a component.
405
+
406
+ ```tsx
407
+ import { useManagedState, type StateHandle } from "preact-sigma";
408
+
409
+ type SearchState = {
410
+ query: string;
411
+ };
412
+
413
+ function SearchBox() {
414
+ const search = useManagedState(
415
+ (search: StateHandle<SearchState>) => ({
416
+ query: search.query,
417
+ setQuery(query: string) {
418
+ search.query.set(query);
419
+ },
420
+ }),
421
+ () => ({ query: "" }),
422
+ );
423
+
424
+ return (
425
+ <input value={search.query} onInput={(event) => search.setQuery(event.currentTarget.value)} />
426
+ );
427
+ }
428
+ ```
429
+
430
+ ## Subscribe In `useEffect`
431
+
432
+ Use `useSubscribe()` with any subscribable source, including managed state and Preact signals.
433
+ The listener receives the current value immediately and then future updates.
434
+
435
+ ```tsx
436
+ import { useSubscribe } from "preact-sigma";
437
+
438
+ useSubscribe(counter, (value) => {
439
+ console.log(value.count);
440
+ });
441
+ ```
442
+
443
+ ## Listen To DOM Or Managed-State Events In `useEffect`
444
+
445
+ Use `useEventTarget()` for either DOM events or managed-state events.
446
+
447
+ ```tsx
448
+ import { useEventTarget } from "preact-sigma";
449
+
450
+ useEventTarget(window, "resize", () => {
451
+ console.log(window.innerWidth);
452
+ });
453
+ ```
454
+
455
+ ```tsx
456
+ useEventTarget(counter, "thresholdReached", (event) => {
457
+ console.log(event.count);
458
+ });
459
+ ```
460
+
461
+ ## Reach For Signals Helpers
462
+
463
+ `batch` and `untracked` are re-exported from `@preact/signals`.
464
+
465
+ ```ts
466
+ import { batch, untracked } from "preact-sigma";
467
+
468
+ batch(() => {
469
+ counter.increment();
470
+ counter.reset();
471
+ });
472
+
473
+ untracked(() => {
474
+ console.log(counter.count);
475
+ });
476
+ ```
477
+
478
+ ## Small Feature Model
479
+
480
+ This pattern works well when a component or UI feature needs a small state model with a few public methods and derived values.
481
+
482
+ ```ts
483
+ import { defineManagedState, type StateHandle } from "preact-sigma";
484
+
485
+ type DialogState = boolean;
486
+
487
+ const Dialog = defineManagedState(
488
+ (dialog: StateHandle<DialogState>) => ({
489
+ open: dialog,
490
+ show() {
491
+ dialog.set(true);
492
+ },
493
+ hide() {
494
+ dialog.set(false);
495
+ },
496
+ }),
497
+ false,
498
+ );
499
+ ```
500
+
501
+ Keep using plain `useState()` when the state is trivial.
502
+
503
+ ```tsx
504
+ const [open, setOpen] = useState(false);
505
+ ```
506
+
507
+ ## License
508
+
509
+ MIT. See [LICENSE-MIT](./LICENSE-MIT).
@@ -0,0 +1,215 @@
1
+ import { ReadonlySignal, Signal, batch, computed, untracked } from "@preact/signals";
2
+ import { Immutable, Producer } from "immer";
3
+
4
+ //#region src/internal.d.ts
5
+ type Cleanup = () => void;
6
+ type Disposable = {
7
+ [Symbol.dispose](): void;
8
+ };
9
+ type OwnedResource = Cleanup | Disposable;
10
+ type OwnedResources = OwnedResource | readonly OwnedResource[];
11
+ declare class AnyStateHandle<TState = any, TEvents extends EventsDefinition = any> {
12
+ private readonly state;
13
+ /**
14
+ * Emit a custom event with zero or one argument.
15
+ */
16
+ readonly emit: [TEvents] extends [{}] ? <TEvent extends string & keyof TEvents>(name: TEvent, ...args: TEvents[TEvent]) => void : never;
17
+ /**
18
+ * Attach cleanup functions or disposables to the managed state instance.
19
+ */
20
+ readonly own: (resources: OwnedResources) => void;
21
+ constructor(state: Signal<Immutable<TState>>,
22
+ /**
23
+ * Emit a custom event with zero or one argument.
24
+ */
25
+
26
+ emit: [TEvents] extends [{}] ? <TEvent extends string & keyof TEvents>(name: TEvent, ...args: TEvents[TEvent]) => void : never,
27
+ /**
28
+ * Attach cleanup functions or disposables to the managed state instance.
29
+ */
30
+
31
+ own: (resources: OwnedResources) => void);
32
+ /** Read the current immutable base state. This read is tracked. */
33
+ get(): Immutable<TState>;
34
+ /** Read the current immutable base state snapshot without tracking. */
35
+ peek(): Immutable<TState>;
36
+ /** Replace the base state, or update it with an Immer producer. */
37
+ set(value: TState | Producer<TState>): void;
38
+ }
39
+ /** Check whether a value is a managed-state instance. */
40
+ declare function isManagedState(value: unknown): value is AnyManagedState;
41
+ //#endregion
42
+ //#region src/framework.d.ts
43
+ type EventsDefinition = Record<string, [any?]>;
44
+ type FilterProperties$1<T extends object, U> = {} & { [P in { [K in keyof T]: [T[K]] extends [never] ? never : T[K] extends U ? K : never }[keyof T]]: T[P] };
45
+ type InferActions$1<TProps extends object> = {} & FilterProperties$1<TProps, (...args: any[]) => any>;
46
+ type InferManagedStates$1<TProps extends object> = {} & FilterProperties$1<TProps, AnyManagedState>;
47
+ type InferPublicProps$1<TProps extends object> = InferActions$1<TProps> & InferManagedStates$1<TProps>;
48
+ type OmitManagedStates<TState> = TState extends object ? Omit<TState, keyof InferManagedStates$1<TState>> : TState;
49
+ type InferState$1<TProps extends object> = {} & Immutable<{ [K in keyof FilterProperties$1<TProps, ReadonlySignal | StateHandle<any, any> | Lens>]: TProps[K] extends ReadonlySignal<infer T> | StateHandle<infer T> | Lens<infer T> ? T : never }> & Readonly<InferManagedStates$1<TProps>>;
50
+ type AnyManagedState<TState = any, TEvents extends EventsDefinition = any> = {
51
+ /** Get the underlying signal for an exposed signal-backed public property. */get<K extends keyof OmitManagedStates<TState>>(key: K): ReadonlySignal<OmitManagedStates<TState>[K]>;
52
+ get(): ReadonlySignal<TState>; /** Read the current immutable public state snapshot without tracking. */
53
+ peek<K extends keyof OmitManagedStates<TState>>(key: K): OmitManagedStates<TState>[K];
54
+ peek(): TState;
55
+ /**
56
+ * Subscribe to the current and future immutable values of one signal-backed
57
+ * public property. Returns a function to unsubscribe.
58
+ */
59
+ subscribe<K extends keyof OmitManagedStates<TState>>(key: K, listener: (value: OmitManagedStates<TState>[K]) => void): () => void; /** Subscribe to the current and future immutable public state snapshots. */
60
+ subscribe(listener: (value: TState) => void): () => void;
61
+ /**
62
+ * Subscribe to a custom event emitted by this managed state.
63
+ *
64
+ * Your listener receives the emitted argument directly, or no argument at all.
65
+ */
66
+ on<TEvent extends string & keyof TEvents>(name: TEvent, listener: (...args: TEvents[TEvent]) => void): () => void; /** Dispose this managed state instance and its owned resources. */
67
+ dispose(): void;
68
+ [Symbol.dispose](): void;
69
+ };
70
+ /**
71
+ * Public instance shape produced by `defineManagedState()` and `useManagedState()`.
72
+ *
73
+ * Returned signals and top-level lenses are exposed as tracked getter
74
+ * properties. Returning the `StateHandle` itself exposes the base state
75
+ * directly as a reactive immutable property.
76
+ */
77
+ type ManagedState<TState, TEvents extends EventsDefinition = EventsDefinition, TProps extends object = Record<string, unknown>> = AnyManagedState<TState, TEvents> & Immutable<TState> & TProps;
78
+ /**
79
+ * Mark a constructor-returned method as a tracked query.
80
+ *
81
+ * Query methods wrap their body in `computed()`, so reads inside the method
82
+ * participate in signal tracking even after the method is exposed publicly.
83
+ * Query functions read from closed-over handles or signals and do not use an
84
+ * instance receiver. Tagged query methods also skip the default `action()`
85
+ * wrapping step.
86
+ */
87
+ declare function query<TFunction extends (this: void, ...args: any[]) => any>(fn: TFunction): TFunction;
88
+ /**
89
+ * Constructor-local access to one top-level property of an object-shaped base
90
+ * state.
91
+ *
92
+ * Lenses only exist on `StateHandle`, and `get()` reads are tracked in the
93
+ * same way as `StateHandle.get()`. `set()` accepts either a replacement value
94
+ * or an Immer producer for that property value.
95
+ */
96
+ type Lens<TState = any> = {
97
+ /** Read the current immutable property value. This read is tracked. */get: () => Immutable<TState>;
98
+ /**
99
+ * Replace the property value, or update it with an Immer producer for that
100
+ * property value.
101
+ */
102
+ set: (value: TState | Producer<TState>) => void;
103
+ };
104
+ /**
105
+ * Constructor-local access to the base state.
106
+ *
107
+ * `TState` may be any non-function value, including primitives.
108
+ *
109
+ * Return this handle from the constructor when you want to expose the base
110
+ * state directly as a reactive immutable property on the managed state.
111
+ *
112
+ * When the base state is object-shaped, the handle also exposes a shallow
113
+ * `Lens` for each top-level property key. Spreading an object-shaped handle
114
+ * into the returned constructor object exposes those top-level lenses as
115
+ * tracked public properties.
116
+ *
117
+ * For ordinary derived values, prefer external functions like
118
+ * `getVisibleTodos(state)` so unused helpers can be tree-shaken. Reach for
119
+ * `computed(() => derive(handle.get()))` only when you need memoized reactive
120
+ * reads as a performance optimization.
121
+ */
122
+ type StateHandle<TState, TEvents extends EventsDefinition = never> = AnyStateHandle<TState, TEvents> & (TState extends object ? TState extends ((...args: any[]) => any) | readonly any[] | ReadonlyMap<any, any> | ReadonlySet<any> ? {} : { readonly [K in keyof TState]: Lens<TState[K]> } : {});
123
+ /**
124
+ * Pure constructor function for a managed state definition.
125
+ *
126
+ * The first parameter should be explicitly typed as `StateHandle<...>`. The
127
+ * library infers the internal state and event types from that parameter type.
128
+ *
129
+ * Return only methods, signals, top-level `Lens` values from the provided
130
+ * `StateHandle`, the provided `StateHandle`, or a managed state instance.
131
+ * Returned signals and lenses become tracked getter properties, returning the
132
+ * handle exposes the base state directly as a reactive immutable property, and
133
+ * managed state instances are passed through unchanged.
134
+ */
135
+ type StateConstructor<TState, TEvents extends EventsDefinition, TParams extends any[], TProps extends object = {}> = (handle: StateHandle<TState, TEvents>, ...params: TParams) => TProps & {
136
+ [key: string]: ((...args: any[]) => any) | AnyManagedState | Lens | ReadonlySignal | StateHandle<any, any>;
137
+ };
138
+ /**
139
+ * Define a managed state class with a private mutable implementation and an
140
+ * immutable public surface.
141
+ *
142
+ * `TState` may be any non-function value, including primitives.
143
+ *
144
+ * The constructor function's explicitly typed `StateHandle` parameter is what
145
+ * the library uses to infer the internal state and event types.
146
+ *
147
+ * Methods are automatically wrapped with `action()` from `@preact/signals`, so
148
+ * they are untracked and batched unless you opt into tracked reads with
149
+ * `query()`.
150
+ *
151
+ * The state constructor must return an object with properties that are either:
152
+ * - A function (to expose methods)
153
+ * - A signal (to expose derived state)
154
+ * - A top-level lens from the state handle (to expose one reactive property)
155
+ * - The state handle (to expose the reactive immutable base state directly)
156
+ * - A managed state instance (to compose another managed state as a property)
157
+ *
158
+ * Returned signals and top-level lenses are turned into getter properties, so
159
+ * reads are tracked by the `@preact/signals` runtime. When the base state is
160
+ * object-shaped, spreading the `StateHandle` into the returned object exposes
161
+ * its current top-level lenses at once.
162
+ *
163
+ * Events can carry at most one argument.
164
+ *
165
+ * The state constructor should be side-effect free.
166
+ */
167
+ declare function defineManagedState<TState, TEvents extends EventsDefinition, TParams extends any[], TProps extends object = {}, TInitialState extends TState = TState>(constructor: StateConstructor<TState, TEvents, TParams, TProps>, initialState: TInitialState): new (...params: TParams) => ManagedState<InferState$1<TProps>, TEvents, InferPublicProps$1<TProps>>;
168
+ //#endregion
169
+ //#region src/hooks.d.ts
170
+ type FilterProperties<T extends object, U> = {} & { [P in { [K in keyof T]: [T[K]] extends [never] ? never : T[K] extends U ? K : never }[keyof T]]: T[P] };
171
+ type InferActions<TProps extends object> = {} & FilterProperties<TProps, (...args: any[]) => any>;
172
+ type InferManagedStates<TProps extends object> = {} & FilterProperties<TProps, AnyManagedState>;
173
+ type InferPublicProps<TProps extends object> = InferActions<TProps> & InferManagedStates<TProps>;
174
+ type InferState<TProps extends object> = {} & Immutable<{ [K in keyof FilterProperties<TProps, ReadonlySignal | StateHandle<any, any> | Lens>]: TProps[K] extends ReadonlySignal<infer T> | StateHandle<infer T> | Lens<infer T> ? T : never }> & Readonly<InferManagedStates<TProps>>;
175
+ /**
176
+ * Clean encapsulation of complex UI state.
177
+ *
178
+ * Use this when a component needs the same managed-state API without defining a
179
+ * separate class. The constructor follows the same rules as
180
+ * `defineManagedState()`, including explicit typing of the `StateHandle`
181
+ * parameter for state and event inference.
182
+ */
183
+ declare function useManagedState<TState, TEvents extends EventsDefinition, TProps extends object = {}, TInitialState extends TState = TState>(constructor: StateConstructor<TState, TEvents, [], TProps>, initialState: TInitialState | (() => TInitialState)): ManagedState<InferState<TProps>, TEvents, InferPublicProps<TProps>>;
184
+ /**
185
+ * Any subscribable source, including a managed state or any Preact signal.
186
+ */
187
+ type SubscribeTarget<T> = {
188
+ subscribe: (listener: (value: T) => void) => () => void;
189
+ };
190
+ /**
191
+ * Subscribe to future values from a subscribable source inside `useEffect`.
192
+ *
193
+ * The listener is kept fresh automatically, so a dependency array is not part
194
+ * of this API. The listener receives the current value immediately and then
195
+ * future updates. Pass `null` to disable the subscription temporarily.
196
+ */
197
+ declare function useSubscribe<T>(target: SubscribeTarget<T> | null, listener: (value: T) => void): void;
198
+ type InferEvent<T extends EventTarget | AnyManagedState> = T extends AnyManagedState<any, infer TEvents extends EventsDefinition> ? string & keyof TEvents : T extends {
199
+ addEventListener: (name: infer TEvent) => any;
200
+ } ? string & TEvent : string;
201
+ type InferEventListener<T extends EventTarget | AnyManagedState, TEvent extends string = any> = T extends AnyManagedState<any, infer TEvents extends EventsDefinition> ? TEvent extends string & keyof TEvents ? (...args: TEvents[TEvent]) => void : never : T extends {
202
+ addEventListener: (name: TEvent, listener: infer TListener extends (event: any) => any) => any;
203
+ } ? TListener : (event: Event) => void;
204
+ /**
205
+ * Subscribe to events from an `EventTarget` or managed state inside `useEffect`.
206
+ *
207
+ * The listener is kept fresh automatically, so a dependency array is not part
208
+ * of this API. Pass `null` to disable the subscription temporarily.
209
+ *
210
+ * For managed-state events, your listener receives the emitted argument
211
+ * directly, or no argument at all.
212
+ */
213
+ declare function useEventTarget<T extends EventTarget | AnyManagedState, TEvent extends InferEvent<T>>(target: T | null, name: TEvent, listener: InferEventListener<T, TEvent>): void;
214
+ //#endregion
215
+ export { AnyManagedState, EventsDefinition, Lens, ManagedState, StateConstructor, StateHandle, SubscribeTarget, batch, computed, defineManagedState, isManagedState, query, untracked, useEventTarget, useManagedState, useSubscribe };
package/dist/index.mjs ADDED
@@ -0,0 +1,301 @@
1
+ import { Signal, action, batch, computed, computed as computed$1, signal, untracked } from "@preact/signals";
2
+ import { castImmutable, freeze, produce } from "immer";
3
+ import { useCallback, useEffect, useRef, useState } from "preact/hooks";
4
+ //#region src/internal.ts
5
+ const lensKeys = /* @__PURE__ */ new WeakMap();
6
+ const queryMethods = /* @__PURE__ */ new WeakSet();
7
+ var StateContainer = class extends EventTarget {
8
+ _signals = /* @__PURE__ */ new Map();
9
+ _view = computed$1(() => ({ ...this }));
10
+ constructor(state, handle, props, dispose) {
11
+ super();
12
+ this.dispose = dispose;
13
+ const propDescriptors = Object.getOwnPropertyDescriptors(props);
14
+ for (const key in propDescriptors) {
15
+ const propDescriptor = propDescriptors[key];
16
+ if ("value" in propDescriptor) {
17
+ let { value } = propDescriptor;
18
+ if (typeof value === "function") {
19
+ Object.defineProperty(this, key, { value: queryMethods.has(value) ? value : action(value) });
20
+ continue;
21
+ }
22
+ const signal = getExposedSignal(value, state, handle);
23
+ if (signal) {
24
+ this._signals.set(key, signal);
25
+ Object.defineProperty(this, key, {
26
+ get: () => signal.value,
27
+ enumerable: true
28
+ });
29
+ } else if (isManagedState(value)) Object.defineProperty(this, key, {
30
+ value,
31
+ enumerable: true
32
+ });
33
+ else throw new Error(`Invalid property: ${key}. Must be a function, a signal, a top-level lens, the state handle, or a managed state.`);
34
+ } else throw new Error(`\`get ${key}() {}\` syntax is forbidden`);
35
+ }
36
+ }
37
+ get(key) {
38
+ if (!key) return this._view;
39
+ return this._signals.get(key);
40
+ }
41
+ peek(key) {
42
+ const signal = this.get(key);
43
+ if (!signal) return;
44
+ return signal.peek();
45
+ }
46
+ subscribe(...args) {
47
+ if (args.length > 1) {
48
+ const [key, listener] = args;
49
+ const signal = this.get(key);
50
+ if (!signal) throw new Error(`Property ${key} is not a signal`);
51
+ return signal.subscribe(listener);
52
+ }
53
+ return this._view.subscribe(args[0]);
54
+ }
55
+ on(name, listener) {
56
+ const adapter = (event) => {
57
+ const detail = event.detail;
58
+ if (detail === void 0) listener();
59
+ else listener(detail);
60
+ };
61
+ this.addEventListener(name, adapter);
62
+ return () => {
63
+ this.removeEventListener(name, adapter);
64
+ };
65
+ }
66
+ [Symbol.dispose]() {
67
+ this.dispose();
68
+ }
69
+ };
70
+ function isProducer(value) {
71
+ return typeof value === "function";
72
+ }
73
+ function makeNonEnumerable(object, keys) {
74
+ for (const key of keys) Object.defineProperty(object, key, {
75
+ ...Object.getOwnPropertyDescriptor(object, key),
76
+ enumerable: false
77
+ });
78
+ }
79
+ var AnyStateHandle = class {
80
+ constructor(state, emit, own) {
81
+ this.state = state;
82
+ this.emit = emit;
83
+ this.own = own;
84
+ makeNonEnumerable(this, ["emit", "own"]);
85
+ }
86
+ /** Read the current immutable base state. This read is tracked. */
87
+ get() {
88
+ return this.state.value;
89
+ }
90
+ /** Read the current immutable base state snapshot without tracking. */
91
+ peek() {
92
+ return this.state.peek();
93
+ }
94
+ /** Replace the base state, or update it with an Immer producer. */
95
+ set(value) {
96
+ this.state.value = isProducer(value) ? produce(this.state.value, value) : castImmutable(value);
97
+ }
98
+ };
99
+ function createStateHandle(state, emit, own) {
100
+ const handle = new AnyStateHandle(state, emit, own);
101
+ let lenses;
102
+ const getLensDescriptor = (key) => {
103
+ const currentState = state.value;
104
+ if (!isLensableState(currentState)) return;
105
+ return Reflect.getOwnPropertyDescriptor(currentState, key);
106
+ };
107
+ const getLens = (key) => {
108
+ let lens = (lenses ||= /* @__PURE__ */ new Map()).get(key);
109
+ if (!lens) {
110
+ lens = {
111
+ get: () => handle.get()[key],
112
+ set: (update) => {
113
+ handle.set((draft) => {
114
+ draft[key] = isProducer(update) ? produce(draft[key], update) : update;
115
+ });
116
+ }
117
+ };
118
+ lensKeys.set(lens, key);
119
+ lenses.set(key, lens);
120
+ }
121
+ return lens;
122
+ };
123
+ return new Proxy(handle, {
124
+ get(target, key, receiver) {
125
+ if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
126
+ if (!getLensDescriptor(key)) return;
127
+ return getLens(key);
128
+ },
129
+ ownKeys(_target) {
130
+ const currentState = state.value;
131
+ if (!isLensableState(currentState)) return [];
132
+ return Reflect.ownKeys(currentState);
133
+ },
134
+ getOwnPropertyDescriptor(_target, key) {
135
+ const lensDescriptor = getLensDescriptor(key);
136
+ if (!lensDescriptor) return;
137
+ return {
138
+ configurable: true,
139
+ enumerable: lensDescriptor.enumerable,
140
+ value: getLens(key),
141
+ writable: false
142
+ };
143
+ }
144
+ });
145
+ }
146
+ function isLensableState(value) {
147
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
148
+ const prototype = Object.getPrototypeOf(value);
149
+ return prototype === Object.prototype || prototype === null;
150
+ }
151
+ function getExposedSignal(value, state, handle) {
152
+ if (value === handle) return state;
153
+ if (value instanceof Signal) return value;
154
+ const lensKey = getLensKey(value);
155
+ if (lensKey !== void 0) return computed$1(() => state.value[lensKey]);
156
+ }
157
+ function getLensKey(value) {
158
+ if (!value || typeof value !== "object") return;
159
+ return lensKeys.get(value);
160
+ }
161
+ function disposeOwnedResources(resources) {
162
+ let errors;
163
+ for (let index = resources.length - 1; index >= 0; index -= 1) try {
164
+ const resource = resources[index];
165
+ if (typeof resource === "function") resource();
166
+ else resource[Symbol.dispose]();
167
+ } catch (error) {
168
+ errors ||= [];
169
+ errors.push(error);
170
+ }
171
+ if (errors) throw new AggregateError(errors, "Failed to dispose one or more resources");
172
+ }
173
+ /** Check whether a value is a managed-state instance. */
174
+ function isManagedState(value) {
175
+ return value instanceof StateContainer;
176
+ }
177
+ //#endregion
178
+ //#region src/framework.ts
179
+ /**
180
+ * Mark a constructor-returned method as a tracked query.
181
+ *
182
+ * Query methods wrap their body in `computed()`, so reads inside the method
183
+ * participate in signal tracking even after the method is exposed publicly.
184
+ * Query functions read from closed-over handles or signals and do not use an
185
+ * instance receiver. Tagged query methods also skip the default `action()`
186
+ * wrapping step.
187
+ */
188
+ function query(fn) {
189
+ const wrapped = ((...args) => computed$1(() => fn(...args)).value);
190
+ queryMethods.add(wrapped);
191
+ return wrapped;
192
+ }
193
+ /**
194
+ * Define a managed state class with a private mutable implementation and an
195
+ * immutable public surface.
196
+ *
197
+ * `TState` may be any non-function value, including primitives.
198
+ *
199
+ * The constructor function's explicitly typed `StateHandle` parameter is what
200
+ * the library uses to infer the internal state and event types.
201
+ *
202
+ * Methods are automatically wrapped with `action()` from `@preact/signals`, so
203
+ * they are untracked and batched unless you opt into tracked reads with
204
+ * `query()`.
205
+ *
206
+ * The state constructor must return an object with properties that are either:
207
+ * - A function (to expose methods)
208
+ * - A signal (to expose derived state)
209
+ * - A top-level lens from the state handle (to expose one reactive property)
210
+ * - The state handle (to expose the reactive immutable base state directly)
211
+ * - A managed state instance (to compose another managed state as a property)
212
+ *
213
+ * Returned signals and top-level lenses are turned into getter properties, so
214
+ * reads are tracked by the `@preact/signals` runtime. When the base state is
215
+ * object-shaped, spreading the `StateHandle` into the returned object exposes
216
+ * its current top-level lenses at once.
217
+ *
218
+ * Events can carry at most one argument.
219
+ *
220
+ * The state constructor should be side-effect free.
221
+ */
222
+ function defineManagedState(constructor, initialState) {
223
+ initialState = freeze(initialState);
224
+ return class extends StateContainer {
225
+ constructor(...params) {
226
+ const state = signal(initialState);
227
+ let owned;
228
+ let disposed = false;
229
+ const dispose = () => {
230
+ if (disposed) return;
231
+ disposed = true;
232
+ const current = owned;
233
+ owned = void 0;
234
+ if (!current) return;
235
+ disposeOwnedResources(current);
236
+ };
237
+ const handle = createStateHandle(state, (name, detail) => this.dispatchEvent(new CustomEvent(name, { detail })), (resources) => {
238
+ const nextResources = Array.isArray(resources) ? resources : [resources];
239
+ if (!nextResources.length) return;
240
+ if (disposed) disposeOwnedResources(nextResources);
241
+ else (owned ??= []).push(...nextResources);
242
+ });
243
+ const props = constructor(handle, ...params);
244
+ super(state, handle, props, dispose);
245
+ }
246
+ };
247
+ }
248
+ //#endregion
249
+ //#region src/hooks.ts
250
+ function isFunction(value) {
251
+ return typeof value === "function";
252
+ }
253
+ /**
254
+ * Clean encapsulation of complex UI state.
255
+ *
256
+ * Use this when a component needs the same managed-state API without defining a
257
+ * separate class. The constructor follows the same rules as
258
+ * `defineManagedState()`, including explicit typing of the `StateHandle`
259
+ * parameter for state and event inference.
260
+ */
261
+ function useManagedState(constructor, initialState) {
262
+ const managedState = useState(() => new (defineManagedState(constructor, isFunction(initialState) ? initialState() : initialState))())[0];
263
+ useEffect(() => () => managedState.dispose(), [managedState]);
264
+ return managedState;
265
+ }
266
+ /**
267
+ * Subscribe to future values from a subscribable source inside `useEffect`.
268
+ *
269
+ * The listener is kept fresh automatically, so a dependency array is not part
270
+ * of this API. The listener receives the current value immediately and then
271
+ * future updates. Pass `null` to disable the subscription temporarily.
272
+ */
273
+ function useSubscribe(target, listener) {
274
+ listener = useStableCallback(listener);
275
+ useEffect(() => target?.subscribe(listener), [target]);
276
+ }
277
+ /**
278
+ * Subscribe to events from an `EventTarget` or managed state inside `useEffect`.
279
+ *
280
+ * The listener is kept fresh automatically, so a dependency array is not part
281
+ * of this API. Pass `null` to disable the subscription temporarily.
282
+ *
283
+ * For managed-state events, your listener receives the emitted argument
284
+ * directly, or no argument at all.
285
+ */
286
+ function useEventTarget(target, name, listener) {
287
+ listener = useStableCallback(listener);
288
+ useEffect(() => {
289
+ if (!target) return;
290
+ if (isManagedState(target)) return target.on(name, listener);
291
+ target.addEventListener(name, listener);
292
+ return () => target.removeEventListener(name, listener);
293
+ }, [target, name]);
294
+ }
295
+ function useStableCallback(callback) {
296
+ const ref = useRef(callback);
297
+ ref.current = callback;
298
+ return useCallback((...params) => (0, ref.current)(...params), []);
299
+ }
300
+ //#endregion
301
+ export { batch, computed, defineManagedState, isManagedState, query, untracked, useEventTarget, useManagedState, useSubscribe };
package/llms.txt ADDED
@@ -0,0 +1,302 @@
1
+ # `preact-sigma` for LLMs
2
+
3
+ ## Glossary
4
+
5
+ - `base state`: The private mutable state owned by one managed state definition. It may be any non-function value, including a primitive.
6
+ - `public state`: The immutable data exposed on a managed state instance. It is derived from returned signals, returned top-level lenses, returned handles, and returned nested managed states.
7
+ - `managed state`: An instance created by `defineManagedState()` or returned by `useManagedState()`. It exposes immutable public data, public methods, subscriptions, events, and disposal.
8
+ - `manager class`: The class returned by `defineManagedState()`. Best practice is to name it with a `Manager` suffix.
9
+ - `constructor`: The function passed to `defineManagedState()` or `useManagedState()`. It receives a typed `StateHandle`.
10
+ - `handle`: The first constructor parameter. Its type must be `StateHandle<...>`. It is the private control surface for reading, writing, owning resources, and emitting.
11
+ - `lens`: A constructor-local object for one top-level property of an object-shaped base state. A lens has `get()` and `set()`.
12
+ - `signal`: A `ReadonlySignal` from `@preact/signals`.
13
+ - `signal-backed public property`: A public property backed by a returned signal, returned lens, or returned handle. Keyed `get`, `peek`, and `subscribe` target only these properties.
14
+ - `derived value`: A value computed from state rather than stored directly.
15
+ - `public action`: A returned method intended to change state. Returned methods are action-wrapped by default.
16
+ - `query method`: A returned method wrapped with `query()`. Query methods are tracked reads and are not action-wrapped.
17
+ - `event map`: A type mapping event names to zero-or-one-argument tuples, for example `{ saved: []; selected: [{ id: string }] }`.
18
+ - `cleanup`: A function of type `() => void`.
19
+ - `disposable`: An object with a `[Symbol.dispose]()` method.
20
+ - `owned resource`: A cleanup function or disposable registered through `handle.own()`.
21
+ - `object-shaped state`: A base state that is a plain object. It is not a function, array, `Map`, or `Set`.
22
+ - `top-level property`: A direct property on an object-shaped base state, such as `query` in `{ query: string }`.
23
+ - `composition`: Returning one managed state instance from another managed state constructor so the nested instance is exposed unchanged as a public property.
24
+ - `tracked read`: A read that participates in Signals tracking.
25
+ - `untracked read`: A read that does not participate in Signals tracking.
26
+ - `unsubscribe`: The cleanup function returned by `.on()` and `.subscribe()`.
27
+
28
+ ## Purpose
29
+
30
+ `preact-sigma` is a small state-management layer built on top of `@preact/signals` and `immer`.
31
+
32
+ It is designed to:
33
+
34
+ - keep mutable implementation details private
35
+ - expose immutable public data
36
+ - express state transitions through public methods
37
+ - support derived reactive values
38
+ - support typed custom events
39
+ - support composition of nested managed states
40
+ - support instance-owned resource cleanup
41
+
42
+ ## Runtime Exports
43
+
44
+ - `defineManagedState`
45
+ - `useManagedState`
46
+ - `useSubscribe`
47
+ - `useEventTarget`
48
+ - `isManagedState`
49
+ - `query`
50
+ - `computed`
51
+ - `batch`
52
+ - `untracked`
53
+
54
+ ## Public Type Exports
55
+
56
+ - `ManagedState`
57
+ - `StateConstructor`
58
+ - `StateHandle`
59
+ - `Lens`
60
+ - `SubscribeTarget`
61
+
62
+ ## Core Rules
63
+
64
+ - The first constructor parameter must be explicitly typed as `StateHandle<...>`.
65
+ - The constructor should be side-effect free.
66
+ - The constructor may return only:
67
+ - methods
68
+ - signals
69
+ - top-level lenses from the provided handle
70
+ - the provided handle itself
71
+ - a managed state instance
72
+ - Event payloads may have zero or one argument only.
73
+ - Returned methods are action-wrapped automatically unless wrapped with `query()`.
74
+ - Returned signals and returned top-level lenses become tracked getter properties on the public instance.
75
+ - Returning the handle exposes the full base state as one reactive immutable public property.
76
+ - Returning a managed state instance exposes that nested managed state unchanged.
77
+ - Keyed `get`, `peek`, and `subscribe` apply only to signal-backed public properties, not to composed managed-state properties.
78
+
79
+ ## `defineManagedState()`
80
+
81
+ Use `defineManagedState(constructor, initialState)` to create a reusable managed-state class.
82
+
83
+ Behavior:
84
+
85
+ - `initialState` is the initial base state
86
+ - constructor parameters after the handle become runtime constructor parameters for the class
87
+ - internal state and event types are inferred from the typed `StateHandle` parameter
88
+ - the resulting instance exposes immutable public data plus public methods
89
+ - the resulting instance owns any resources registered through `handle.own()`
90
+
91
+ ## `useManagedState()`
92
+
93
+ Use `useManagedState(constructor, initialState)` to create a managed state directly inside a component.
94
+
95
+ Behavior:
96
+
97
+ - follows the same constructor rules as `defineManagedState()`
98
+ - accepts either a concrete initial state or a lazy initializer function
99
+ - returns one stable managed state instance for the component
100
+
101
+ ## `StateHandle`
102
+
103
+ `StateHandle<TState, TEvents>` is the constructor-local control surface.
104
+
105
+ Methods:
106
+
107
+ - `get()`: tracked read of the current immutable base state
108
+ - `peek()`: untracked read of the current immutable base state
109
+ - `set(next)`: replace the base state or update it with an Immer producer
110
+ - `own(resources)`: attach cleanup functions or disposables to the managed state instance
111
+ - `emit(name, arg?)`: emit a typed custom event with zero or one argument
112
+
113
+ For object-shaped base state only:
114
+
115
+ - each top-level property is available as a `Lens`
116
+ - spreading the handle into the returned constructor object exposes all current top-level lenses as tracked public properties
117
+
118
+ ## `Lens`
119
+
120
+ `Lens<T>` exists only on object-shaped `StateHandle`s.
121
+
122
+ Methods:
123
+
124
+ - `get()`: tracked read of that top-level property
125
+ - `set(next)`: replace that property or update it with an Immer producer for that property
126
+
127
+ Important:
128
+
129
+ - lenses are constructor-local unless returned
130
+ - returning one lens exposes one reactive public property
131
+ - spreading an object-shaped handle exposes all current top-level lenses at once
132
+
133
+ ## `ManagedState`
134
+
135
+ A managed state instance exposes:
136
+
137
+ - immutable public data as normal properties
138
+ - public methods returned by the constructor
139
+ - subscription methods
140
+ - event subscription methods
141
+ - disposal methods
142
+
143
+ Read APIs:
144
+
145
+ - `get(key)`: return the underlying signal for one exposed signal-backed public property
146
+ - `get()`: return the underlying signal for the whole public state
147
+ - `peek(key)`: untracked read of one exposed signal-backed public property
148
+ - `peek()`: untracked read of the whole public state
149
+
150
+ Subscription APIs:
151
+
152
+ - `subscribe(key, listener)`: subscribe to the current and future values of one exposed signal-backed public property
153
+ - `subscribe(listener)`: subscribe to the current and future whole public state
154
+ - both forms return an unsubscribe function
155
+
156
+ Event API:
157
+
158
+ - `on(name, listener)`: subscribe to one custom event
159
+ - listener receives the emitted argument directly, or no argument at all
160
+ - returns an unsubscribe function
161
+
162
+ Disposal API:
163
+
164
+ - `dispose()`: dispose the managed state instance and its owned resources
165
+ - `[Symbol.dispose]()` does the same thing as `dispose()`
166
+
167
+ ## `query()`
168
+
169
+ `query(fn)` marks a returned method as a tracked public read.
170
+
171
+ Behavior:
172
+
173
+ - wraps the method body in `computed()`
174
+ - lets reads inside the method participate in Signals tracking after the method is exposed publicly
175
+ - query functions read from closed-over handles or signals and do not use an instance receiver
176
+ - skips the default `action()` wrapping step
177
+
178
+ Use `query()` for public methods that conceptually answer a question.
179
+
180
+ Do not use `query()` for ordinary mutating actions.
181
+
182
+ ## `computed`
183
+
184
+ `computed` is re-exported from `@preact/signals`.
185
+
186
+ Use it when a public derived value should be memoized and reactive.
187
+
188
+ ## `batch`
189
+
190
+ `batch` is re-exported from `@preact/signals`.
191
+
192
+ Use it when multiple state updates should be grouped into one reactive batch.
193
+
194
+ ## `untracked`
195
+
196
+ `untracked` is re-exported from `@preact/signals`.
197
+
198
+ Use it when code must read reactive values without subscribing to them.
199
+
200
+ ## `useSubscribe()`
201
+
202
+ Use `useSubscribe(target, listener)` inside a component.
203
+
204
+ Behavior:
205
+
206
+ - accepts any subscribable target, including a managed state or a Preact signal
207
+ - keeps the listener fresh automatically
208
+ - calls the listener immediately with the current value, then with future updates
209
+ - does not take a dependency array
210
+ - accepts `null` to disable the subscription temporarily
211
+
212
+ ## `useEventTarget()`
213
+
214
+ Use `useEventTarget(target, name, listener)` inside a component.
215
+
216
+ Behavior:
217
+
218
+ - accepts either a DOM-style `EventTarget` or a managed state
219
+ - keeps the listener fresh automatically
220
+ - does not take a dependency array
221
+ - accepts `null` to disable the subscription temporarily
222
+ - for managed-state events, the listener receives the emitted argument directly, or no argument at all
223
+
224
+ ## `isManagedState()`
225
+
226
+ Use `isManagedState(value)` to check whether a value is a managed-state instance.
227
+
228
+ ## Supported Patterns
229
+
230
+ - primitive base state
231
+ - plain-object base state
232
+ - Immer producer updates
233
+ - lifecycle-owned cleanup via `handle.own()`
234
+ - disposal via `dispose()` or `[Symbol.dispose]()`
235
+ - reactive derived properties via returned signals
236
+ - tracked read methods via `query()`
237
+ - public exposure of one top-level property via a returned lens
238
+ - public exposure of all top-level properties via `...handle`
239
+ - public exposure of the whole base state via the returned handle
240
+ - nested managed-state composition
241
+ - keyed and whole-state signal access
242
+ - keyed and whole-state snapshot access
243
+ - keyed and whole-state subscriptions
244
+ - typed custom events with zero or one argument
245
+ - component-local managed state via `useManagedState`
246
+ - hook-based subscriptions via `useSubscribe`
247
+ - hook-based event subscriptions via `useEventTarget`
248
+
249
+ ## Unsupported Or Disallowed Patterns
250
+
251
+ - function-valued base state
252
+ - returning arbitrary plain objects from the constructor unless each returned property is one of the supported public shapes
253
+ - multi-argument event payloads outside a single object payload
254
+ - relying on untyped constructor parameters for state or event inference
255
+ - using arrays, `Map`, or `Set` as object-shaped state for lens generation
256
+
257
+ ## Best Practices
258
+
259
+ ### Inference And Naming
260
+
261
+ - Explicitly type the first constructor parameter as `StateHandle<...>`.
262
+ - Prefer a `${ModelName}State` alias near the constructor, even for simple state.
263
+ - Prefer a `${ModelName}Events` alias when events exist.
264
+ - Name the class returned by `defineManagedState()` with a `Manager` suffix.
265
+ - Name the handle like an instance of the state model, not a generic word like `state` or `value`.
266
+
267
+ ### State Shape And Exposure
268
+
269
+ - Use top-level lenses for constructor-local reads and writes to top-level object fields.
270
+ - Return one top-level lens when one public field should stay reactive.
271
+ - Spread an object-shaped handle when the public managed state should mirror the base state's top-level shape.
272
+ - Return the full handle only when the whole base state should be one public property.
273
+ - Use `handle.own()` when the managed-state instance should control external cleanup.
274
+
275
+ ### Derivations
276
+
277
+ - Prefer plain external derivation functions for ordinary derived values so unused helpers can be tree-shaken.
278
+ - Use `computed(() => derive(handle.get()))` only when a derived value needs memoized reactive reads for performance.
279
+ - Use `query()` for tracked public read methods.
280
+
281
+ ### Public API Design
282
+
283
+ - Keep public actions domain-specific.
284
+ - Prefer verbs like `save`, `submit`, `open`, `close`, and `rename` over low-level mutation names.
285
+ - Avoid unnecessary binding. Public methods work through closure over the typed handle, not through `this`.
286
+ - Keep nested features as separate managed states when they already have a clean public API.
287
+ - Prefer explicit disposal when a managed state owns external resources.
288
+
289
+ ### Events
290
+
291
+ - Keep events domain-specific.
292
+ - Do not use generic event names like `changed` or `updated` for ordinary state observation.
293
+ - When you need multiple event fields, wrap them in one object payload.
294
+
295
+ ## Example Interpretation Rules For Agents
296
+
297
+ - If a snippet assigns `const stop = thing.on(...)`, `stop` is an unsubscribe function.
298
+ - If a snippet assigns `const stop = thing.subscribe(...)`, `stop` is an unsubscribe function.
299
+ - If a snippet calls `handle.own([...])`, those resources are released by `instance.dispose()` or `instance[Symbol.dispose]()`.
300
+ - If a returned method is wrapped with `query()`, treat it as a tracked read, not a mutating action.
301
+ - If a constructor returns `...handle`, treat each top-level object property as a public tracked property.
302
+ - If a constructor returns `field: handle.someField`, treat `field` as a public tracked property whose value is the lens's current value, not as the lens object itself.
package/package.json CHANGED
@@ -1,13 +1,47 @@
1
1
  {
2
2
  "name": "preact-sigma",
3
- "version": "0.0.0",
4
- "description": "",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
3
+ "version": "1.0.0",
9
4
  "keywords": [],
10
- "author": "",
11
5
  "license": "MIT",
12
- "type": "commonjs"
13
- }
6
+ "author": "Alec Larson",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/alloc/preact-sigma.git"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "llms.txt"
14
+ ],
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.mts",
19
+ "import": "./dist/index.mjs"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "@preact/signals": "^2.8.2",
24
+ "immer": "^11.1.4",
25
+ "preact": "11.0.0-beta.1"
26
+ },
27
+ "devDependencies": {
28
+ "@preact/preset-vite": "^2.10.5",
29
+ "@types/node": "^25.5.0",
30
+ "jsdom": "^29.0.1",
31
+ "lucide-react": "^1.6.0",
32
+ "oxfmt": "^0.42.0",
33
+ "oxlint": "^1.57.0",
34
+ "preact-sigma": "link:",
35
+ "tsdown": "^0.21.4",
36
+ "typescript": "^6.0.2",
37
+ "vite": "^8.0.2",
38
+ "vitest": "^4.1.1"
39
+ },
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "test": "vitest run",
43
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions",
44
+ "fmt": "oxfmt",
45
+ "lint": "oxlint"
46
+ }
47
+ }
package/readme.md DELETED
@@ -1 +0,0 @@
1
- Coming soon...