preact-sigma 2.1.3 → 2.2.1

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 CHANGED
@@ -12,10 +12,33 @@ To add `preact-sigma` to your project:
12
12
  npm install preact-sigma
13
13
  ```
14
14
 
15
- If you use AI coding agents, this repo also includes agent-oriented guidance:
15
+ ## Smallest Useful Example
16
16
 
17
- - [llms.txt](./llms.txt) provides a compact overview of the API and recommended patterns.
18
- - Companion skills are available via `npx skills add alloc/preact-sigma`.
17
+ ```ts
18
+ import { SigmaType } from "preact-sigma";
19
+
20
+ const Counter = new SigmaType<{ count: number }>("Counter")
21
+ .defaultState({
22
+ count: 0,
23
+ })
24
+ .computed({
25
+ doubled() {
26
+ return this.count * 2;
27
+ },
28
+ })
29
+ .actions({
30
+ increment() {
31
+ this.count += 1;
32
+ },
33
+ });
34
+
35
+ const counter = new Counter();
36
+
37
+ counter.increment();
38
+
39
+ console.log(counter.count); // 1
40
+ console.log(counter.doubled); // 2
41
+ ```
19
42
 
20
43
  ## What It Is
21
44
 
@@ -157,13 +180,17 @@ cleanup();
157
180
 
158
181
  In Preact, the same constructor can be used with `useSigma(() => new TodoList(), ["todos-demo"])` so the component owns one instance and `setup(...)` cleanup runs automatically. Use `useListener(...)` when you want component-scoped event subscriptions with automatic teardown.
159
182
 
160
- ## Best Practices
183
+ Cleanup resources can be returned as functions, `AbortController`, objects with `dispose()`, or objects with `Symbol.dispose`.
161
184
 
162
- - Let `new SigmaType<TState, TEvents>()` and the builder inputs drive inference. Avoid forcing extra type arguments onto builder methods.
163
- - Keep top-level state properties meaningful. Each top-level property gets its own signal, so shape state around the reads you want to track.
164
- - Use `computed(...)` for argument-free derived state, and use queries for reactive reads that need parameters.
165
- - Put writes in actions. A draft boundary is any point where sigma cannot keep reusing the current draft. `emit()`, `await`, and any action call other than a same-instance sync nested action call are draft boundaries, so call `this.commit()` before those boundaries when pending writes should become public.
166
- - Use `snapshot(instance)` and `replaceState(instance, snapshot)` for committed-state replay. They work on top-level state keys and stay outside action semantics.
167
- - Use `SigmaRef<T>` when a value should stay by reference in sigma's `Draft` and `Immutable` types. A normal assignment to a `SigmaRef<T>`-typed value only changes typing and does not change Immer's runtime drafting or freezing behavior.
168
- - Use `immerable` on custom classes only when they should participate in Immer drafting. `setAutoFreeze(false)` disables sigma's runtime deep-freezing when you need published state to stay unfrozen.
169
- - Use `setup(...)` for owned side effects, and always return cleanup resources for anything the instance starts.
185
+ Inside setup, `this` exposes the public instance plus `emit(...)` and `act(fn)`. Use `this.act(function () { ... })` when setup needs one synchronous anonymous action with normal draft, `commit()`, and `emit(...)` semantics, whether that work happens immediately in the setup body or later from a setup-owned callback, but should not become a public action method.
186
+
187
+ ## Constructor and Defaults
188
+
189
+ - `defaultState` values may be plain values or zero-argument initializer functions. Use initializer functions when each instance needs a fresh object, array, or class instance.
190
+ - Constructor input shallowly overrides `defaultState`, so `new TodoList({ draft: "ready" })` replaces only the top-level keys you pass.
191
+
192
+ ## More Docs
193
+
194
+ - [llms.txt](./llms.txt) provides the exhaustive machine-oriented API guide and terminology.
195
+ - Companion skills are available via `npx skills add alloc/preact-sigma`.
196
+ - The `preact-sigma` skill packages the procedural guidance and agent-oriented workflow for this library.
package/dist/index.d.mts CHANGED
@@ -47,6 +47,9 @@ type DefaultStateValue<TValue> = TValue | DefaultStateInitializer<TValue>;
47
47
  type Disposable = {
48
48
  [Symbol.dispose](): void;
49
49
  };
50
+ type DisposableLike = {
51
+ dispose(): void;
52
+ };
50
53
  interface SigmaRefBrand {
51
54
  [sigmaRefBrand]?: true;
52
55
  }
@@ -58,8 +61,8 @@ type AnyEvents = Record<string, object | void>;
58
61
  type AnyState = Record<string, unknown>;
59
62
  /** The object accepted by `.defaultState(...)`. */
60
63
  type AnyDefaultState<TState extends AnyState> = { [K in keyof TState]?: DefaultStateValue<TState[K]> };
61
- /** A cleanup resource supported by `.setup(...)`. */
62
- type AnyResource = Cleanup | Disposable | AbortController;
64
+ /** A cleanup resource supported by `.setup(...)`, including function, `dispose()`, and `Symbol.dispose` cleanup. */
65
+ type AnyResource = Cleanup | Disposable | DisposableLike | AbortController;
63
66
  type ComputedValues<TComputeds extends object | undefined> = [undefined] extends [TComputeds] ? never : { readonly [K in keyof TComputeds]: TComputeds[K] extends AnyFunction ? Immutable<ReturnType<TComputeds[K]>> : never };
64
67
  type ComputedContext<TState extends AnyState, TComputeds extends object> = Immutable<TState> & ComputedValues<TComputeds>;
65
68
  type QueryMethods<TQueries extends object | undefined> = [undefined] extends [TQueries] ? never : { [K in keyof TQueries]: TQueries[K] extends AnyFunction ? (...args: Parameters<TQueries[K]>) => ReturnType<TQueries[K]> : never };
@@ -77,6 +80,10 @@ type ActionContext<TState extends AnyState, TEvents extends AnyEvents, TComputed
77
80
  /** Publishes the current action draft immediately so later boundaries use committed state. */commit(): void;
78
81
  emit: Emit<TEvents>;
79
82
  };
83
+ type DefinitionEvents<T extends SigmaDefinition> = T["events"] extends AnyEvents ? T["events"] : {};
84
+ type DefinitionComputeds<T extends SigmaDefinition> = T["computeds"] extends object ? T["computeds"] : {};
85
+ type DefinitionQueries<T extends SigmaDefinition> = T["queries"] extends object ? T["queries"] : {};
86
+ type DefinitionActions<T extends SigmaDefinition> = T["actions"] extends object ? T["actions"] : {};
80
87
  /** The public shape shared by all sigma-state instances. */
81
88
  interface AnySigmaState extends EventTarget {
82
89
  readonly [sigmaStateBrand]: true;
@@ -114,6 +121,7 @@ type MapSigmaDefinition<T extends SigmaDefinition> = keyof T extends infer K ? K
114
121
  /** The public instance shape produced by a configured sigma type. */
115
122
  type SigmaState<T extends SigmaDefinition = SigmaDefinition> = AnySigmaState & Simplify<UnionToIntersection<MapSigmaDefinition<T>>>;
116
123
  type SetupContext<T extends SigmaDefinition> = SigmaState<T> & {
124
+ act<TResult>(fn: (this: ActionContext<T["state"], DefinitionEvents<T>, DefinitionComputeds<T>, DefinitionQueries<T>, DefinitionActions<T>>) => TResult): TResult;
117
125
  emit: T["events"] extends object ? Emit<T["events"]> : never;
118
126
  };
119
127
  type MergeObjects<TLeft extends object, TRight> = [TRight] extends [object] ? Extract<Simplify<Omit<TLeft, keyof TRight> & TRight>, TLeft> : TLeft;
package/dist/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { freeze, immerable } from "immer";
4
4
  import { useEffect, useRef, useState } from "preact/hooks";
5
5
  //#region src/internal/context.ts
6
6
  const disabledContextOptions = {
7
+ allowAct: false,
7
8
  allowActions: false,
8
9
  allowCommit: false,
9
10
  allowEmit: false,
@@ -30,6 +31,7 @@ const publicContextOptions = {
30
31
  },
31
32
  setup: {
32
33
  ...disabledContextOptions,
34
+ allowAct: true,
33
35
  allowActions: true,
34
36
  allowEmit: true,
35
37
  allowQueries: true
@@ -87,8 +89,12 @@ function registerContextOwner(context, owner) {
87
89
  function createContext(instance, options, owner) {
88
90
  const publicPrototype = Object.getPrototypeOf(instance.publicInstance);
89
91
  return new Proxy(publicPrototype, {
90
- get(_target, key) {
92
+ get(_target, key, receiver) {
91
93
  if (typeof key !== "string") return Reflect.get(publicPrototype, key, owner?.actionContext ?? instance.publicInstance);
94
+ if (key === "act") return options.allowAct ? (actionFn) => {
95
+ if (typeof actionFn !== "function") throw new Error("[preact-sigma] act() requires a function");
96
+ return runAdHocAction(receiver, actionFn);
97
+ } : void 0;
92
98
  if (key === "commit") return options.allowCommit && owner ? () => commitActionOwner(owner) : void 0;
93
99
  if (key === "emit") return options.allowEmit && owner ? (name, detail) => {
94
100
  handleActionBoundary(owner, "emit");
@@ -168,6 +174,7 @@ function getPublicContext(instance, kind) {
168
174
  //#region src/internal/symbols.ts
169
175
  const sigmaStateBrand = Symbol("sigma.v2.state");
170
176
  const reservedKeys = new Set([
177
+ "act",
171
178
  "get",
172
179
  "emit",
173
180
  "commit",
@@ -237,38 +244,13 @@ function buildQueryMethod(queryFunction) {
237
244
  };
238
245
  }
239
246
  function buildActionMethod(actionName, actionFn) {
240
- const actionIsAsync = isAsyncFunction(actionFn);
241
247
  return function(...args) {
242
- const instance = getSigmaInternals(this);
243
- if (instance.disposed) throw new Error("[preact-sigma] Cannot run an action on a disposed sigma state");
244
- return untracked$1(() => {
245
- let owner;
246
- const callerOwner = getContextOwner(this);
247
- if (callerOwner && callerOwner.instance === instance && !actionIsAsync) owner = callerOwner;
248
- else {
249
- handleActionBoundary(callerOwner, "action", actionName);
250
- owner = createActionOwner(instance, actionName, actionFn, args);
251
- }
252
- let result;
253
- try {
254
- result = actionFn.apply(owner.actionContext, [...args]);
255
- } catch (error) {
256
- clearCurrentDraft(owner);
257
- throw error;
258
- }
259
- if (!actionIsAsync && isPromiseLike(result)) {
260
- clearCurrentDraft(owner);
261
- Promise.resolve(result).catch(() => {});
262
- throw new Error(`[preact-sigma] Action "${actionName}" must use native async-await syntax to return a promise.`);
263
- }
264
- if (owner === callerOwner) return result;
265
- const finalized = finalizeOwnerDraft(owner);
266
- if (finalized?.changed) publishState(instance, finalized);
267
- if (isPromiseLike(result)) return resolveAsyncActionResult(owner, result);
268
- return result;
269
- });
248
+ return runActionInvocation(this, actionName, actionFn, args);
270
249
  };
271
250
  }
251
+ function runAdHocAction(context, actionFn) {
252
+ return runActionInvocation(context, "act()", actionFn, []);
253
+ }
272
254
  function readActionStateValue(owner, key, options) {
273
255
  if (owner.currentDraft) return owner.currentDraft[key];
274
256
  const signal = getSignal(owner.instance, key);
@@ -323,6 +305,44 @@ function createActionOwner(instance, actionName, actionFn, args) {
323
305
  registerContextOwner(owner.actionContext, owner);
324
306
  return owner;
325
307
  }
308
+ function runActionInvocation(context, actionName, actionFn, args) {
309
+ const instance = getSigmaInternals(context);
310
+ if (instance.disposed) throw new Error("[preact-sigma] Cannot run an action on a disposed sigma state");
311
+ const isAdHocAction = actionName === "act()";
312
+ const actionIsAsync = isAsyncFunction(actionFn);
313
+ if (actionIsAsync && isAdHocAction) throw new Error("[preact-sigma] act() callbacks must stay synchronous");
314
+ return untracked$1(() => {
315
+ let owner;
316
+ const callerOwner = getContextOwner(context);
317
+ if (callerOwner && callerOwner.instance === instance && !actionIsAsync) owner = callerOwner;
318
+ else {
319
+ handleActionBoundary(callerOwner, "action", actionName);
320
+ owner = createActionOwner(instance, actionName, actionFn, args);
321
+ }
322
+ let result;
323
+ try {
324
+ result = actionFn.apply(owner.actionContext, args);
325
+ } catch (error) {
326
+ clearCurrentDraft(owner);
327
+ throw error;
328
+ }
329
+ if (isAdHocAction && isPromiseLike(result)) {
330
+ clearCurrentDraft(owner);
331
+ Promise.resolve(result).catch(() => {});
332
+ throw new Error("[preact-sigma] act() callbacks must stay synchronous");
333
+ }
334
+ if (!actionIsAsync && isPromiseLike(result)) {
335
+ clearCurrentDraft(owner);
336
+ Promise.resolve(result).catch(() => {});
337
+ throw new Error(`[preact-sigma] Action "${actionName}" must use native async-await syntax to return a promise.`);
338
+ }
339
+ if (owner === callerOwner) return result;
340
+ const finalized = finalizeOwnerDraft(owner);
341
+ if (finalized?.changed) publishState(instance, finalized);
342
+ if (isPromiseLike(result)) return resolveAsyncActionResult(owner, result);
343
+ return result;
344
+ });
345
+ }
326
346
  function createCommitError(owner) {
327
347
  return /* @__PURE__ */ new Error(`[preact-sigma] Async action "${owner.actionName}" finished with unpublished changes. Call this.commit() before await or return.`);
328
348
  }
@@ -339,6 +359,7 @@ function createDraftMetadata(owner) {
339
359
  function disposeCleanupResource(resource) {
340
360
  if (typeof resource === "function") resource();
341
361
  else if (resource instanceof AbortController) resource.abort();
362
+ else if ("dispose" in resource) resource.dispose();
342
363
  else resource[Symbol.dispose]();
343
364
  }
344
365
  function assertExactStateKeys(instance, nextState) {
package/llms.txt CHANGED
@@ -8,9 +8,10 @@
8
8
  - `computed`: A tracked getter declared with `.computed({ ... })`.
9
9
  - `query`: A tracked method declared with `.queries({ ... })` or created with `query(fn)`.
10
10
  - `action`: A method declared with `.actions({ ... })` that reads and writes through one Immer draft for one synchronous call.
11
+ - `anonymous action`: A synchronous function passed to `this.act(...)` inside setup to run one anonymous action.
11
12
  - `draft boundary`: Any point where sigma cannot keep reusing the current draft.
12
13
  - `setup handler`: A function declared with `.setup(fn)` that returns an array of cleanup resources.
13
- - `cleanup resource`: A cleanup function, an `AbortController`, or an object with `[Symbol.dispose]()`.
14
+ - `cleanup resource`: A cleanup function, an `AbortController`, an object with `dispose()`, or an object with `[Symbol.dispose]()`.
14
15
  - `signal access`: Reading the underlying `ReadonlySignal` for a state property or computed through `instance.get(key)`.
15
16
 
16
17
  ## Navigation
@@ -75,7 +76,7 @@ import {
75
76
 
76
77
  - `AnyDefaultState`: Describes the object accepted by `.defaultState(...)`.
77
78
  - `AnyEvents`: Describes an event map from event names to payload objects or `void`.
78
- - `AnyResource`: Describes a supported setup cleanup resource.
79
+ - `AnyResource`: Describes a supported setup cleanup resource, including cleanup functions, `AbortController`, objects with `dispose()`, and objects with `Symbol.dispose`.
79
80
  - `AnySigmaState`: Describes the public shape shared by all sigma-state instances.
80
81
  - `AnySigmaStateWithEvents`: Describes a sigma-state instance with a typed event map.
81
82
  - `AnyState`: Describes the top-level state object for a sigma type.
@@ -169,7 +170,7 @@ Behavior:
169
170
  - Constructor input shallowly overrides `defaultState`.
170
171
  - If every required state property is covered by `defaultState`, constructor input is optional.
171
172
  - Duplicate names across state properties, computeds, queries, and actions are rejected at runtime.
172
- - Reserved public names are `get`, `setup`, `on`, and `emit`.
173
+ - Reserved public names are `act`, `get`, `setup`, `on`, and `emit`.
173
174
 
174
175
  ## Public Instance Shape
175
176
 
@@ -289,13 +290,18 @@ Behavior:
289
290
  - calling `.setup(...)` again cleans up the previous setup first
290
291
  - one `.setup(...)` call runs every registered setup handler in definition order
291
292
  - the public `.setup(...)` method always returns one cleanup function
292
- - `this` inside a setup handler exposes the public instance plus `emit(...)`
293
+ - `this` inside a setup handler exposes the public instance plus `emit(...)` and `act(fn)`
294
+ - `this.act(fn)` inside setup runs `fn` with normal action semantics without adding a public action method
295
+ - use `this.act(fn)` for setup-time initialization work or setup-owned callbacks that need action semantics
296
+ - pass a normal `function () {}` to `this.act(...)` so callback `this` receives the action context
297
+ - `this.act(fn)` functions must stay synchronous
293
298
  - each setup handler returns an array of cleanup resources
294
299
  - setup typing only exposes computeds, queries, and actions that were already present when that `.setup(...)` call happened
295
300
 
296
301
  Supported cleanup resources:
297
302
 
298
303
  - cleanup functions
304
+ - objects with `dispose()`
299
305
  - objects with `[Symbol.dispose]()`
300
306
  - `AbortController`
301
307
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "preact-sigma",
3
- "version": "2.1.3",
3
+ "version": "2.2.1",
4
4
  "keywords": [],
5
5
  "license": "MIT",
6
6
  "author": "Alec Larson",