preact-sigma 2.1.2 → 2.2.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 CHANGED
@@ -157,13 +157,17 @@ cleanup();
157
157
 
158
158
  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
159
 
160
+ Cleanup resources can be returned as functions, `AbortController`, objects with `dispose()`, or objects with `Symbol.dispose`.
161
+
162
+ 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.
163
+
160
164
  ## Best Practices
161
165
 
162
166
  - Let `new SigmaType<TState, TEvents>()` and the builder inputs drive inference. Avoid forcing extra type arguments onto builder methods.
163
167
  - 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
168
  - 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.
169
+ - 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. Setup can use `this.act(function () { ... })` to run one synchronous anonymous action for initialization work or setup-owned callbacks without adding a public action method.
166
170
  - 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 `ref(value)` when a value should stay by reference in sigma's `Draft` and `Immutable` types. It only changes typing and does not change Immer's runtime drafting or freezing behavior.
171
+ - 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
172
  - 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
173
  - Use `setup(...)` for owned side effects, and always return cleanup resources for anything the instance starts.
package/dist/index.d.mts CHANGED
@@ -47,10 +47,13 @@ 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
  }
53
- /** A type brand added by `ref(...)`. */
56
+ /** A type brand that keeps a value by reference in sigma's `Draft` and `Immutable` helpers. */
54
57
  type SigmaRef<T = unknown> = T & SigmaRefBrand;
55
58
  /** The event map shape used by sigma types. */
56
59
  type AnyEvents = Record<string, object | void>;
@@ -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;
@@ -150,11 +158,6 @@ declare function replaceState<T extends AnySigmaState>(publicInstance: T, nextSt
150
158
  //#region src/framework.d.ts
151
159
  /** Checks whether a value is a sigma-state instance. */
152
160
  declare function isSigmaState(value: unknown): value is AnySigmaState;
153
- /**
154
- * Returns `value` unchanged and marks its type so sigma's Draft and Immutable
155
- * helpers keep it by reference instead of recursively immerizing it.
156
- */
157
- declare function ref<T extends object>(value: T): SigmaRef<T>;
158
161
  /** Creates a standalone tracked query function with the same signature as `fn`. */
159
162
  declare function query<TArgs extends any[], TResult>(fn: (this: void, ...args: TArgs) => TResult): typeof fn;
160
163
  /**
@@ -238,4 +241,4 @@ declare function useSigma<T extends AnySigmaState>(create: () => T, setupArgs?:
238
241
  /** Attaches an event listener in a component and cleans it up when dependencies change. */
239
242
  declare function useListener<TTarget extends EventTarget | AnySigmaState, TEvent extends InferEventType<TTarget>>(target: TTarget | null, name: TEvent, listener: InferListener<TTarget, TEvent>): void;
240
243
  //#endregion
241
- export { type AnyDefaultState, type AnyEvents, type AnyResource, type AnySigmaState, type AnySigmaStateWithEvents, type AnyState, InferEventType, InferListener, type InferSetupArgs, type SigmaObserveChange, type SigmaObserveOptions, type SigmaRef, type SigmaState, SigmaType, action, batch, computed, effect, freeze, immerable, isSigmaState, listen, query, ref, replaceState, setAutoFreeze, snapshot, untracked, useListener, useSigma };
244
+ export { type AnyDefaultState, type AnyEvents, type AnyResource, type AnySigmaState, type AnySigmaStateWithEvents, type AnyState, InferEventType, InferListener, type InferSetupArgs, type SigmaObserveChange, type SigmaObserveOptions, type SigmaRef, type SigmaState, SigmaType, action, batch, computed, effect, freeze, immerable, isSigmaState, listen, query, replaceState, setAutoFreeze, snapshot, untracked, useListener, useSigma };
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) {
@@ -520,13 +541,6 @@ Object.defineProperty(Sigma.prototype, sigmaStateBrand, { value: true });
520
541
  function isSigmaState(value) {
521
542
  return Boolean(value && typeof value === "object" && value[sigmaStateBrand]);
522
543
  }
523
- /**
524
- * Returns `value` unchanged and marks its type so sigma's Draft and Immutable
525
- * helpers keep it by reference instead of recursively immerizing it.
526
- */
527
- function ref(value) {
528
- return value;
529
- }
530
544
  /** Creates a standalone tracked query function with the same signature as `fn`. */
531
545
  function query(fn) {
532
546
  return ((...args) => computed$1(() => fn(...args)).value);
@@ -633,4 +647,4 @@ function useListener(target, name, listener) {
633
647
  }, [target, name]);
634
648
  }
635
649
  //#endregion
636
- export { SigmaType, action, batch, computed, effect, freeze, immerable, isSigmaState, listen, query, ref, replaceState, setAutoFreeze, snapshot, untracked, useListener, useSigma };
650
+ export { SigmaType, action, batch, computed, effect, freeze, immerable, isSigmaState, listen, query, replaceState, setAutoFreeze, snapshot, untracked, useListener, useSigma };
package/llms.txt CHANGED
@@ -7,18 +7,18 @@
7
7
  - `state property`: A top-level property from `TState`, such as `draft` in `{ draft: string }`.
8
8
  - `computed`: A tracked getter declared with `.computed({ ... })`.
9
9
  - `query`: A tracked method declared with `.queries({ ... })` or created with `query(fn)`.
10
- - `ref`: A helper that keeps a value by reference in sigma's `Draft` and `Immutable` types without changing runtime behavior.
11
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.
12
12
  - `draft boundary`: Any point where sigma cannot keep reusing the current draft.
13
13
  - `setup handler`: A function declared with `.setup(fn)` that returns an array of cleanup resources.
14
- - `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]()`.
15
15
  - `signal access`: Reading the underlying `ReadonlySignal` for a state property or computed through `instance.get(key)`.
16
16
 
17
17
  ## Navigation
18
18
 
19
19
  - For state shape, inference, and instance shape, read `Start Here`, `Inference`, `SigmaType`, and `Public Instance Shape`.
20
20
  - For mutation semantics, read `Critical Rules`, `actions`, `immerable`, and `setAutoFreeze`.
21
- - For type-level by-reference values, read `ref`.
21
+ - For type-level by-reference values, see `SigmaRef<T>`.
22
22
  - For side effects and events, read `setup`, `Events`, `listen`, `useListener`, and `useSigma`.
23
23
  - For committed-state utilities, read `observe`, `snapshot`, and `replaceState`.
24
24
 
@@ -62,7 +62,7 @@ import {
62
62
  isSigmaState,
63
63
  listen,
64
64
  query,
65
- ref,
65
+ type SigmaRef,
66
66
  replaceState,
67
67
  setAutoFreeze,
68
68
  snapshot,
@@ -76,7 +76,7 @@ import {
76
76
 
77
77
  - `AnyDefaultState`: Describes the object accepted by `.defaultState(...)`.
78
78
  - `AnyEvents`: Describes an event map from event names to payload objects or `void`.
79
- - `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`.
80
80
  - `AnySigmaState`: Describes the public shape shared by all sigma-state instances.
81
81
  - `AnySigmaStateWithEvents`: Describes a sigma-state instance with a typed event map.
82
82
  - `AnyState`: Describes the top-level state object for a sigma type.
@@ -170,7 +170,7 @@ Behavior:
170
170
  - Constructor input shallowly overrides `defaultState`.
171
171
  - If every required state property is covered by `defaultState`, constructor input is optional.
172
172
  - Duplicate names across state properties, computeds, queries, and actions are rejected at runtime.
173
- - Reserved public names are `get`, `setup`, `on`, and `emit`.
173
+ - Reserved public names are `act`, `get`, `setup`, `on`, and `emit`.
174
174
 
175
175
  ## Public Instance Shape
176
176
 
@@ -290,13 +290,18 @@ Behavior:
290
290
  - calling `.setup(...)` again cleans up the previous setup first
291
291
  - one `.setup(...)` call runs every registered setup handler in definition order
292
292
  - the public `.setup(...)` method always returns one cleanup function
293
- - `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
294
298
  - each setup handler returns an array of cleanup resources
295
299
  - setup typing only exposes computeds, queries, and actions that were already present when that `.setup(...)` call happened
296
300
 
297
301
  Supported cleanup resources:
298
302
 
299
303
  - cleanup functions
304
+ - objects with `dispose()`
300
305
  - objects with `[Symbol.dispose]()`
301
306
  - `AbortController`
302
307
 
@@ -328,13 +333,13 @@ Behavior:
328
333
  - custom class instances without a true `[immerable]` property stay outside that freeze path
329
334
  - plain objects, arrays, `Map`, and `Set` already participate in normal Immer drafting without extra markers
330
335
 
331
- ## `ref(value)`
336
+ ## `SigmaRef<T>`
332
337
 
333
- `ref(value)` returns `value` unchanged and marks its type so sigma's `Draft` and `Immutable` helpers keep that value by reference.
338
+ `SigmaRef<T>` marks a value's type so sigma's `Draft` and `Immutable` helpers keep that value by reference.
334
339
 
335
340
  Behavior:
336
341
 
337
- - it has no runtime effect
342
+ - assigning to a `SigmaRef<T>`-typed value has no runtime effect
338
343
  - it only changes sigma's local `Draft` and `Immutable` typing
339
344
  - it prevents type-level recursive immerization for that value
340
345
  - it does not change whether Immer drafts or freezes the value at runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "preact-sigma",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "keywords": [],
5
5
  "license": "MIT",
6
6
  "author": "Alec Larson",