preact-sigma 5.0.0 → 6.0.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 +24 -18
- package/dist/index.d.mts +50 -9
- package/dist/index.mjs +58 -140
- package/dist/persist.d.mts +69 -44
- package/dist/persist.mjs +40 -48
- package/dist/sigma-D1V3m1xk.d.mts +163 -0
- package/dist/sigma-DTMODzf8.mjs +457 -0
- package/docs/context.md +68 -58
- package/docs/migrations/v5-to-v6.md +273 -0
- package/docs/persist.md +19 -18
- package/examples/async-commit.ts +38 -31
- package/examples/basic-counter.ts +21 -16
- package/examples/command-palette.tsx +114 -104
- package/examples/observe-and-restore.ts +21 -15
- package/examples/persist-search-draft.ts +33 -30
- package/examples/setup-act.ts +17 -9
- package/examples/sigma-target.ts +16 -7
- package/package.json +9 -10
- package/dist/framework-GgPzRfff.d.mts +0 -331
- package/dist/runtime-nX4Aygb8.mjs +0 -595
- package/examples/signal-access.ts +0 -31
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { action, batch, computed, signal } from "@preact/signals";
|
|
2
|
+
import { createDraft as createDraft$1, finishDraft, freeze, isDraft, isDraftable, original, setAutoFreeze as setAutoFreeze$1 } from "immer";
|
|
3
|
+
//#region src/internal/symbols.ts
|
|
4
|
+
const instanceSymbol = Symbol.for("sigma:instance");
|
|
5
|
+
const listenersSymbol = Symbol.for("sigma:listeners");
|
|
6
|
+
const snapshotSymbol = Symbol.for("sigma:snapshot");
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/internal/listener.ts
|
|
9
|
+
/** Listener registry used by sigma targets and sigma states for typed event delivery. */
|
|
10
|
+
var SigmaListenerMap = class extends Map {
|
|
11
|
+
/** Delivers one event payload to the current listeners for `name`. */
|
|
12
|
+
emit(name, detail) {
|
|
13
|
+
const listeners = this.get(name);
|
|
14
|
+
if (!listeners?.size) return;
|
|
15
|
+
for (const listener of [...listeners]) listener(detail);
|
|
16
|
+
}
|
|
17
|
+
/** Adds one listener for `name`, creating the listener set on first use. */
|
|
18
|
+
addListener(name, listener) {
|
|
19
|
+
let listeners = this.get(name);
|
|
20
|
+
if (!listeners) {
|
|
21
|
+
listeners = /* @__PURE__ */ new Set();
|
|
22
|
+
this.set(name, listeners);
|
|
23
|
+
}
|
|
24
|
+
listeners.add(listener);
|
|
25
|
+
}
|
|
26
|
+
/** Removes one listener for `name` and prunes the empty listener set. */
|
|
27
|
+
removeListener(name, listener) {
|
|
28
|
+
const listeners = this.get(name);
|
|
29
|
+
if (!listeners) return;
|
|
30
|
+
listeners.delete(listener);
|
|
31
|
+
if (!listeners.size) this.delete(name);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/internal/utils.ts
|
|
36
|
+
function isPlainObject(value) {
|
|
37
|
+
if (typeof value !== "object" || value === null) return false;
|
|
38
|
+
const prototype = Object.getPrototypeOf(value);
|
|
39
|
+
return prototype === Object.prototype || prototype === null;
|
|
40
|
+
}
|
|
41
|
+
function isPromiseLike(value) {
|
|
42
|
+
return value != null && typeof value.then === "function";
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/sigma.ts
|
|
46
|
+
let autoFreezeEnabled = true;
|
|
47
|
+
/**
|
|
48
|
+
* Configures Immer auto-freezing for values published through sigma state.
|
|
49
|
+
*
|
|
50
|
+
* Auto-freezing is enabled by default, so draftable public values are deeply frozen after publish.
|
|
51
|
+
*/
|
|
52
|
+
function setAutoFreeze(autoFreeze) {
|
|
53
|
+
setAutoFreeze$1(autoFreeze);
|
|
54
|
+
autoFreezeEnabled = autoFreeze;
|
|
55
|
+
}
|
|
56
|
+
const signalSuffix = "$";
|
|
57
|
+
const changeListenersMap = /* @__PURE__ */ new WeakMap();
|
|
58
|
+
const patchListeners = /* @__PURE__ */ new WeakSet();
|
|
59
|
+
const initializedPrototypes = /* @__PURE__ */ new WeakSet();
|
|
60
|
+
const queries = /* @__PURE__ */ new WeakSet();
|
|
61
|
+
const emptySentinel = {};
|
|
62
|
+
let activeActionInstance = null;
|
|
63
|
+
let activeDraftInstance = null;
|
|
64
|
+
let activeDraft;
|
|
65
|
+
let activeDerivedReadDepth = 0;
|
|
66
|
+
let activeSetupInstance = null;
|
|
67
|
+
const pendingAsyncActions = /* @__PURE__ */ new WeakMap();
|
|
68
|
+
function hasPendingAsyncAction(instance) {
|
|
69
|
+
return pendingAsyncActions.has(instance);
|
|
70
|
+
}
|
|
71
|
+
function addPendingAsyncAction(instance) {
|
|
72
|
+
pendingAsyncActions.set(instance, (pendingAsyncActions.get(instance) ?? 0) + 1);
|
|
73
|
+
}
|
|
74
|
+
function removePendingAsyncAction(instance) {
|
|
75
|
+
const count = pendingAsyncActions.get(instance);
|
|
76
|
+
if (!count) return;
|
|
77
|
+
if (count === 1) pendingAsyncActions.delete(instance);
|
|
78
|
+
else pendingAsyncActions.set(instance, count - 1);
|
|
79
|
+
}
|
|
80
|
+
function hasActionContext(instance) {
|
|
81
|
+
if (activeActionInstance) return activeActionInstance === instance;
|
|
82
|
+
return hasPendingAsyncAction(instance);
|
|
83
|
+
}
|
|
84
|
+
function createExternalActionError() {
|
|
85
|
+
const constructorName = (activeDraftInstance ?? activeActionInstance)?.constructor.name;
|
|
86
|
+
const owner = constructorName ? `Draft for ${constructorName}` : "Draft";
|
|
87
|
+
return /* @__PURE__ */ new Error(`[preact-sigma] ${owner} was not committed before an external action was invoked.`);
|
|
88
|
+
}
|
|
89
|
+
function beginActionContext(instance) {
|
|
90
|
+
if (!activeActionInstance) {
|
|
91
|
+
if (activeDraftInstance && activeDraftInstance !== instance) throw createExternalActionError();
|
|
92
|
+
activeActionInstance = instance;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (instance !== activeActionInstance) throw createExternalActionError();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
function endActionContext(instance) {
|
|
99
|
+
if (activeActionInstance === instance) activeActionInstance = null;
|
|
100
|
+
}
|
|
101
|
+
function clearActiveAction() {
|
|
102
|
+
activeActionInstance = null;
|
|
103
|
+
activeDraftInstance = null;
|
|
104
|
+
activeDraft = null;
|
|
105
|
+
}
|
|
106
|
+
function assertActionContext(instance, message) {
|
|
107
|
+
if (activeActionInstance && activeActionInstance !== instance) throw createExternalActionError();
|
|
108
|
+
if (!hasActionContext(instance)) throw new Error(message);
|
|
109
|
+
}
|
|
110
|
+
function isStateKey(instance, key) {
|
|
111
|
+
return Object.hasOwn(instance, key + signalSuffix);
|
|
112
|
+
}
|
|
113
|
+
function getStateSignal(instance, key) {
|
|
114
|
+
return instance[key + signalSuffix];
|
|
115
|
+
}
|
|
116
|
+
function createSnapshot(instance) {
|
|
117
|
+
if (instance[snapshotSymbol]) return instance[snapshotSymbol];
|
|
118
|
+
const state = {};
|
|
119
|
+
for (const key in instance) if (isStateKey(instance, key)) state[key] = getStateSignal(instance, key).value;
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
function createDraft(instance) {
|
|
123
|
+
return createDraft$1(createSnapshot(instance));
|
|
124
|
+
}
|
|
125
|
+
function ensureDraft(instance) {
|
|
126
|
+
if (activeDraftInstance && activeDraftInstance !== instance) throw createExternalActionError();
|
|
127
|
+
activeDraftInstance = instance;
|
|
128
|
+
activeDraft ??= createDraft(instance);
|
|
129
|
+
return activeDraft;
|
|
130
|
+
}
|
|
131
|
+
function readStateProperty(instance, key) {
|
|
132
|
+
const signal = getStateSignal(instance, key);
|
|
133
|
+
if (!activeDerivedReadDepth && hasActionContext(instance)) {
|
|
134
|
+
if (activeDraftInstance === instance) return activeDraft[key];
|
|
135
|
+
if (isDraftable(signal.value)) return ensureDraft(instance)[key];
|
|
136
|
+
}
|
|
137
|
+
return signal.value;
|
|
138
|
+
}
|
|
139
|
+
function writeStateProperty(instance, key, value) {
|
|
140
|
+
assertActionContext(instance, `[preact-sigma] Cannot set state property "${key}" outside an action.`);
|
|
141
|
+
ensureDraft(instance)[key] = value;
|
|
142
|
+
}
|
|
143
|
+
function runDerivedRead(callback) {
|
|
144
|
+
activeDerivedReadDepth += 1;
|
|
145
|
+
try {
|
|
146
|
+
return callback();
|
|
147
|
+
} finally {
|
|
148
|
+
activeDerivedReadDepth -= 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function assertActionResult(key, result) {
|
|
152
|
+
if (isDraft(result)) throw new Error(`[preact-sigma] Action named "${key}" returned an active draft.`);
|
|
153
|
+
}
|
|
154
|
+
function hasPatchListeners(instance) {
|
|
155
|
+
const listeners = changeListenersMap.get(instance);
|
|
156
|
+
if (!listeners) return false;
|
|
157
|
+
for (const sub of listeners) if (patchListeners.has(sub)) return true;
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
function publishState(instance, nextState, baseState, patches, inversePatches) {
|
|
161
|
+
instance[snapshotSymbol] = nextState;
|
|
162
|
+
batch(() => {
|
|
163
|
+
const missingKeys = new Set(Object.keys(baseState));
|
|
164
|
+
for (const key in nextState) {
|
|
165
|
+
const nextValue = nextState[key];
|
|
166
|
+
if (autoFreezeEnabled) freeze(nextValue, true);
|
|
167
|
+
const signal = getStateSignal(instance, key);
|
|
168
|
+
if (signal) signal.value = nextValue;
|
|
169
|
+
else defineSignalProperty(instance, key, nextValue);
|
|
170
|
+
missingKeys.delete(key);
|
|
171
|
+
}
|
|
172
|
+
for (const key of missingKeys) {
|
|
173
|
+
const signal = getStateSignal(instance, key);
|
|
174
|
+
if (signal) signal.value = void 0;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
changeListenersMap.get(instance)?.forEach((listener) => {
|
|
178
|
+
listener(nextState, baseState, patches, inversePatches);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function getActionInstance(context) {
|
|
182
|
+
return context[instanceSymbol];
|
|
183
|
+
}
|
|
184
|
+
function commitDraft(instance) {
|
|
185
|
+
if (activeDraftInstance && instance !== activeDraftInstance) throw createExternalActionError();
|
|
186
|
+
if (!activeDraft) return false;
|
|
187
|
+
const draft = activeDraft;
|
|
188
|
+
activeDraft = null;
|
|
189
|
+
activeDraftInstance = null;
|
|
190
|
+
let patches;
|
|
191
|
+
let inversePatches;
|
|
192
|
+
let patchListener;
|
|
193
|
+
if (hasPatchListeners(instance)) patchListener = (nextPatches, nextInversePatches) => {
|
|
194
|
+
patches = nextPatches;
|
|
195
|
+
inversePatches = nextInversePatches;
|
|
196
|
+
};
|
|
197
|
+
const baseState = original(draft);
|
|
198
|
+
const nextState = finishDraft(draft, patchListener);
|
|
199
|
+
const changed = baseState !== nextState;
|
|
200
|
+
if (changed) publishState(instance, nextState, baseState, patches, inversePatches);
|
|
201
|
+
return changed;
|
|
202
|
+
}
|
|
203
|
+
function hasStateChanges(baseState, nextState) {
|
|
204
|
+
const baseKeys = Object.keys(baseState);
|
|
205
|
+
const nextKeys = Object.keys(nextState);
|
|
206
|
+
if (baseKeys.length !== nextKeys.length) return true;
|
|
207
|
+
for (const key of nextKeys) if (!Object.hasOwn(baseState, key) || !Object.is(baseState[key], nextState[key])) return true;
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
function createReplacementPatches(baseState, nextState) {
|
|
211
|
+
let patches;
|
|
212
|
+
let inversePatches;
|
|
213
|
+
const draft = createDraft$1(baseState);
|
|
214
|
+
const missingKeys = new Set(Object.keys(baseState));
|
|
215
|
+
for (const key in nextState) {
|
|
216
|
+
draft[key] = nextState[key];
|
|
217
|
+
missingKeys.delete(key);
|
|
218
|
+
}
|
|
219
|
+
for (const key of missingKeys) delete draft[key];
|
|
220
|
+
finishDraft(draft, (nextPatches, nextInversePatches) => {
|
|
221
|
+
patches = nextPatches;
|
|
222
|
+
inversePatches = nextInversePatches;
|
|
223
|
+
});
|
|
224
|
+
return {
|
|
225
|
+
inversePatches,
|
|
226
|
+
patches
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function initializePrototype(prototype) {
|
|
230
|
+
const descriptors = Object.getOwnPropertyDescriptors(prototype);
|
|
231
|
+
for (const key in descriptors) {
|
|
232
|
+
if (key === "constructor" || key === "onSetup") continue;
|
|
233
|
+
const { get, value } = descriptors[key];
|
|
234
|
+
if (get) descriptors[key].get = function() {
|
|
235
|
+
const instance = getActionInstance(this);
|
|
236
|
+
return (instance[key + signalSuffix] ??= computed(() => runDerivedRead(() => get.call(instance)))).value;
|
|
237
|
+
};
|
|
238
|
+
else if (typeof value === "function" && !queries.has(value)) {
|
|
239
|
+
const actionFn = action(value);
|
|
240
|
+
descriptors[key].value = function(...args) {
|
|
241
|
+
if (activeDerivedReadDepth) throw new Error("[preact-sigma] Computeds and queries cannot call actions.");
|
|
242
|
+
const instance = getActionInstance(this);
|
|
243
|
+
if (beginActionContext(instance)) {
|
|
244
|
+
const result = value.apply(instance, args);
|
|
245
|
+
assertActionResult(key, result);
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
let result;
|
|
249
|
+
try {
|
|
250
|
+
result = actionFn.apply(instance, args);
|
|
251
|
+
assertActionResult(key, result);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
clearActiveAction();
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
let changed;
|
|
257
|
+
try {
|
|
258
|
+
changed = commitDraft(instance);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
clearActiveAction();
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
if (isPromiseLike(result)) {
|
|
264
|
+
if (changed) {
|
|
265
|
+
endActionContext(instance);
|
|
266
|
+
throw new Error(`[preact-sigma] Action named "${key}" forgot to commit() its draft before returning a promise.`);
|
|
267
|
+
}
|
|
268
|
+
addPendingAsyncAction(instance);
|
|
269
|
+
endActionContext(instance);
|
|
270
|
+
const onResolveAsyncAction = (promiseResult) => {
|
|
271
|
+
try {
|
|
272
|
+
if (activeDraft && instance === activeDraftInstance) {
|
|
273
|
+
if (commitDraft(instance)) throw new Error(`[preact-sigma] Action named "${key}" forgot to commit() its draft before its promise resolved.`);
|
|
274
|
+
}
|
|
275
|
+
return promiseResult;
|
|
276
|
+
} finally {
|
|
277
|
+
removePendingAsyncAction(instance);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
const onRejectAsyncAction = (error) => {
|
|
281
|
+
if (activeDraftInstance === instance) {
|
|
282
|
+
activeDraft = null;
|
|
283
|
+
activeDraftInstance = null;
|
|
284
|
+
}
|
|
285
|
+
removePendingAsyncAction(instance);
|
|
286
|
+
throw error;
|
|
287
|
+
};
|
|
288
|
+
return result.then(onResolveAsyncAction, onRejectAsyncAction);
|
|
289
|
+
}
|
|
290
|
+
endActionContext(instance);
|
|
291
|
+
return result;
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
Object.defineProperties(prototype, descriptors);
|
|
296
|
+
}
|
|
297
|
+
function initializeType(type) {
|
|
298
|
+
for (let prototype = type.prototype; prototype && prototype !== Sigma.prototype && prototype !== SigmaTarget.prototype; prototype = Object.getPrototypeOf(prototype)) {
|
|
299
|
+
if (initializedPrototypes.has(prototype)) break;
|
|
300
|
+
initializePrototype(prototype);
|
|
301
|
+
initializedPrototypes.add(prototype);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function disposeCleanupResource(resource) {
|
|
305
|
+
if (typeof resource === "function") resource();
|
|
306
|
+
else if ("dispose" in resource) resource.dispose();
|
|
307
|
+
else resource[Symbol.dispose]();
|
|
308
|
+
}
|
|
309
|
+
function defineSignalProperty(instance, key, value) {
|
|
310
|
+
Object.defineProperty(instance, key + signalSuffix, { value: signal(value) });
|
|
311
|
+
if (!Object.hasOwn(instance.constructor.prototype, key)) Object.defineProperty(instance.constructor.prototype, key, {
|
|
312
|
+
get() {
|
|
313
|
+
return readStateProperty(this, key);
|
|
314
|
+
},
|
|
315
|
+
set(value) {
|
|
316
|
+
writeStateProperty(this, key, value);
|
|
317
|
+
},
|
|
318
|
+
enumerable: true
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Base class for signal-backed state models.
|
|
323
|
+
*
|
|
324
|
+
* `TState` is the source of typing for top-level state keys, subscriptions, signals, and replacement snapshots.
|
|
325
|
+
* Private class fields stay ordinary instance storage and are not signal-backed, captured, or persisted.
|
|
326
|
+
* Merge a same-named interface with the class when direct property reads should be typed on the instance.
|
|
327
|
+
*/
|
|
328
|
+
var Sigma = class {
|
|
329
|
+
get [instanceSymbol]() {
|
|
330
|
+
return this;
|
|
331
|
+
}
|
|
332
|
+
constructor(initialState) {
|
|
333
|
+
initializeType(this.constructor);
|
|
334
|
+
if (initialState === emptySentinel) return;
|
|
335
|
+
for (const key in initialState) {
|
|
336
|
+
const initialValue = initialState[key];
|
|
337
|
+
if (autoFreezeEnabled) freeze(initialValue, true);
|
|
338
|
+
defineSignalProperty(this, key, initialValue);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** Runs `onSetup(...)` and returns a cleanup that disposes returned resources in reverse order. */
|
|
342
|
+
setup(...args) {
|
|
343
|
+
const instance = getActionInstance(this);
|
|
344
|
+
const previousSetupInstance = activeSetupInstance;
|
|
345
|
+
activeSetupInstance = instance;
|
|
346
|
+
let resources;
|
|
347
|
+
try {
|
|
348
|
+
resources = this.onSetup.apply(instance, args);
|
|
349
|
+
} finally {
|
|
350
|
+
activeSetupInstance = previousSetupInstance;
|
|
351
|
+
}
|
|
352
|
+
return () => {
|
|
353
|
+
for (let i = resources.length - 1; i >= 0; i--) disposeCleanupResource(resources[i]);
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Publishes the current action draft.
|
|
358
|
+
*
|
|
359
|
+
* Use this before unpublished changes cross an async, event, or external-action boundary.
|
|
360
|
+
* A callback runs after publish in an action context.
|
|
361
|
+
*/
|
|
362
|
+
commit(callback) {
|
|
363
|
+
const instance = getActionInstance(this);
|
|
364
|
+
assertActionContext(instance, "Cannot commit() from outside an action.");
|
|
365
|
+
commitDraft(instance);
|
|
366
|
+
if (callback) return callback.call(instance);
|
|
367
|
+
}
|
|
368
|
+
/** Runs a synchronous setup-owned callback with action semantics from an `onSetup(...)` context. */
|
|
369
|
+
act(fn) {
|
|
370
|
+
const instance = getActionInstance(this);
|
|
371
|
+
if (activeSetupInstance !== instance) throw new Error("Cannot act() from outside an onSetup() context.");
|
|
372
|
+
if (activeActionInstance === instance) throw new Error("Cannot act() from inside an action.");
|
|
373
|
+
beginActionContext(instance);
|
|
374
|
+
try {
|
|
375
|
+
if (isPromiseLike(action(fn).call(instance))) throw new Error("[preact-sigma] act() callbacks must be synchronous");
|
|
376
|
+
commitDraft(instance);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
clearActiveAction();
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
endActionContext(instance);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
/** Casts a sigma instance to its readonly public consumer view. */
|
|
385
|
+
function castProtected(instance) {
|
|
386
|
+
return instance;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Sigma state model that can emit typed events.
|
|
390
|
+
*
|
|
391
|
+
* `TEvents` maps event names to payload types, and `TState` types reactive state.
|
|
392
|
+
*/
|
|
393
|
+
var SigmaTarget = class extends Sigma {
|
|
394
|
+
[listenersSymbol] = new SigmaListenerMap();
|
|
395
|
+
constructor(state) {
|
|
396
|
+
super(state ?? emptySentinel);
|
|
397
|
+
}
|
|
398
|
+
/** Emits a typed event from an action after unpublished draft changes are committed. */
|
|
399
|
+
emit(name, ...[detail]) {
|
|
400
|
+
const instance = getActionInstance(this);
|
|
401
|
+
assertActionContext(instance, "Cannot emit() from outside an action.");
|
|
402
|
+
if (instance === activeDraftInstance && activeDraft) throw new Error("Cannot emit() until you commit() your draft.");
|
|
403
|
+
this[listenersSymbol].emit(name, detail);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
/** Helpers for observing, accessing, capturing, and replacing committed sigma state. */
|
|
407
|
+
const sigma = /* @__PURE__ */ Object.freeze({
|
|
408
|
+
subscribe: ((instance, keyOrListener, listenerOrOptions) => {
|
|
409
|
+
if (typeof keyOrListener === "string") {
|
|
410
|
+
const signal = getStateSignal(instance, keyOrListener);
|
|
411
|
+
if (!signal) throw new Error(`[preact-sigma] Property named "${keyOrListener}" is not signal-backed.`);
|
|
412
|
+
return signal.subscribe(listenerOrOptions);
|
|
413
|
+
}
|
|
414
|
+
const listener = keyOrListener;
|
|
415
|
+
if (listenerOrOptions?.patches) patchListeners.add(listener);
|
|
416
|
+
let subscriptions = changeListenersMap.get(instance);
|
|
417
|
+
if (!subscriptions) {
|
|
418
|
+
subscriptions = /* @__PURE__ */ new Set();
|
|
419
|
+
changeListenersMap.set(instance, subscriptions);
|
|
420
|
+
}
|
|
421
|
+
subscriptions.add(listener);
|
|
422
|
+
return () => {
|
|
423
|
+
subscriptions.delete(listener);
|
|
424
|
+
if (!subscriptions.size) changeListenersMap.delete(instance);
|
|
425
|
+
};
|
|
426
|
+
}),
|
|
427
|
+
getSignal(instance, key) {
|
|
428
|
+
return getStateSignal(instance, key);
|
|
429
|
+
},
|
|
430
|
+
captureState(instance) {
|
|
431
|
+
return Object.freeze(createSnapshot(instance));
|
|
432
|
+
},
|
|
433
|
+
replaceState(target, nextState) {
|
|
434
|
+
if (!isPlainObject(nextState)) throw new Error("[preact-sigma] replaceState() requires a plain object snapshot");
|
|
435
|
+
if (activeDraft) throw new Error(`[preact-sigma] replaceState() cannot run while an action has unpublished changes.`);
|
|
436
|
+
const instance = getActionInstance(target);
|
|
437
|
+
const baseState = createSnapshot(instance);
|
|
438
|
+
if (!hasStateChanges(baseState, nextState)) return;
|
|
439
|
+
const { inversePatches, patches } = hasPatchListeners(instance) ? createReplacementPatches(baseState, nextState) : {
|
|
440
|
+
inversePatches: void 0,
|
|
441
|
+
patches: void 0
|
|
442
|
+
};
|
|
443
|
+
publishState(instance, nextState, baseState, patches, inversePatches);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
/** Marks a class method as a committed-state reactive read with arguments instead of an action. */
|
|
447
|
+
function query(method) {
|
|
448
|
+
queries.add(method);
|
|
449
|
+
function queryMethod(...args) {
|
|
450
|
+
const instance = getActionInstance(this);
|
|
451
|
+
return computed(() => runDerivedRead(() => method.apply(instance, args))).value;
|
|
452
|
+
}
|
|
453
|
+
queries.add(queryMethod);
|
|
454
|
+
return queryMethod;
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
457
|
+
export { setAutoFreeze as a, listenersSymbol as c, query as i, SigmaTarget as n, sigma as o, castProtected as r, isPlainObject as s, Sigma as t };
|
package/docs/context.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Overview
|
|
2
2
|
|
|
3
|
-
`preact-sigma` builds reusable state models
|
|
3
|
+
`preact-sigma` builds reusable state models as TypeScript classes. A `Sigma<TState>` subclass owns top-level state, derived reads, writes, and setup. A `SigmaTarget<TEvents, TState>` subclass adds typed events. Each top-level state key is exposed as a reactive public property backed by a Preact signal, while actions use Immer-style mutation semantics to publish committed state.
|
|
4
4
|
|
|
5
5
|
# When to Use
|
|
6
6
|
|
|
7
7
|
- State, derived reads, mutations, and lifecycle need to stay together.
|
|
8
|
-
- You need multiple instances of the same model.
|
|
9
|
-
- Public reads should stay reactive
|
|
8
|
+
- You need multiple instances of the same model class.
|
|
9
|
+
- Public reads should stay reactive while writes stay explicit.
|
|
10
10
|
- A model needs to own timers, subscriptions, listeners, nested setup, or other cleanup-aware resources.
|
|
11
11
|
- Components should consume the same model shape used outside Preact.
|
|
12
12
|
|
|
@@ -19,105 +19,115 @@
|
|
|
19
19
|
|
|
20
20
|
# Core Abstractions
|
|
21
21
|
|
|
22
|
-
- Sigma
|
|
23
|
-
- Sigma
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
- Computed: an argument-free derived getter
|
|
27
|
-
- Query: a reactive read that accepts arguments,
|
|
28
|
-
- Action: a method
|
|
29
|
-
- Setup handler:
|
|
30
|
-
- Event: a typed notification emitted
|
|
22
|
+
- Sigma class: a class that extends `Sigma<TState>` and passes its initial top-level state to `super(...)`. The `TState` argument drives helper typing for subscriptions, signals, and replacement snapshots; a same-named merged interface gives direct property reads their instance types.
|
|
23
|
+
- Sigma target: a class that extends `SigmaTarget<TEvents, TState>` when it also emits typed events. Use `SigmaTarget<TEvents>` for event-only targets.
|
|
24
|
+
- State property: a top-level key from `TState`. Each key becomes a reactive public property and has its own signal.
|
|
25
|
+
- Private field: an ECMAScript `#field` on the model class. Private fields are ordinary instance storage, not signal-backed state.
|
|
26
|
+
- Computed: an argument-free derived getter on the class prototype that reads committed state.
|
|
27
|
+
- Query: a reactive read method that accepts arguments, is marked with the `query` decorator, and reads committed state.
|
|
28
|
+
- Action: a prototype method that is not marked as a query. Actions read and write state properties through sigma's draft and commit semantics.
|
|
29
|
+
- Setup handler: an optional `onSetup(...)` method that owns side effects and returns cleanup resources.
|
|
30
|
+
- Event: a typed notification emitted with `this.emit(...)` inside an action and observed through `listen(...)` or `useListener(...)`.
|
|
31
|
+
- Protected view: the readonly consumer view returned by `castProtected(...)` and `useSigma(...)`.
|
|
31
32
|
|
|
32
33
|
# Data Flow / Lifecycle
|
|
33
34
|
|
|
34
|
-
1. Define a
|
|
35
|
-
2.
|
|
36
|
-
3. Add
|
|
37
|
-
4. Instantiate the
|
|
35
|
+
1. Define a class that extends `Sigma<TState>` or `SigmaTarget<TEvents, TState>`.
|
|
36
|
+
2. Define the state as a named type, pass it to `Sigma<TState>`, then merge `interface Model extends ModelState {}` after the class so direct property reads are typed.
|
|
37
|
+
3. Add getters for computed values, `@query` methods for argument-based reactive reads, and ordinary methods for actions.
|
|
38
|
+
4. Instantiate the class. Constructor input can be merged with defaults before `super(...)` when instances need partial overrides.
|
|
38
39
|
5. Read state, computeds, and queries reactively from the public instance.
|
|
39
|
-
6. Mutate state inside actions.
|
|
40
|
-
7. Run `setup(...)` explicitly when the instance should start owning side effects. `useSigma(...)` does this automatically for component-owned instances that define
|
|
40
|
+
6. Mutate state inside actions. Synchronous actions publish automatically when they return, and sync nested actions on the same instance share one draft. Computeds and queries still read the last committed state while an action has unpublished draft changes. Call `this.commit()` when derived reads or unpublished changes must cross a boundary, such as before an `await`, before the action promise resolves, before `emit(...)`, or before invoking another instance's action.
|
|
41
|
+
7. Run `setup(...)` explicitly when the instance should start owning side effects. `useSigma(...)` does this automatically for component-owned instances that define `onSetup(...)`.
|
|
41
42
|
8. Dispose the cleanup returned from `setup(...)` when the owned resources should stop.
|
|
42
43
|
|
|
43
44
|
# Common Tasks -> Recommended APIs
|
|
44
45
|
|
|
45
|
-
- Define reusable model state: `
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
- Subscribe
|
|
58
|
-
-
|
|
46
|
+
- Define reusable model state: `class Model extends Sigma<TState>`.
|
|
47
|
+
- Define reusable model state with events: `class Model extends SigmaTarget<TEvents, TState>`.
|
|
48
|
+
- Merge partial constructor input with defaults: `mergeDefaults(initial, defaults)`.
|
|
49
|
+
- Derive an argument-free value: a class getter.
|
|
50
|
+
- Derive a reactive read with arguments: an `@query` class method.
|
|
51
|
+
- Mutate state and emit typed notifications: ordinary class methods plus `this.emit(...)`.
|
|
52
|
+
- Publish unpublished changes before `await`, `emit(...)`, promise resolution, or another instance's action: `this.commit()`.
|
|
53
|
+
- React to committed state changes: `sigma.subscribe(instance, handler)` or `sigma.subscribe(instance, key, handler)`.
|
|
54
|
+
- Read one top-level state property as a `ReadonlySignal`: `sigma.getSignal(instance, key)`.
|
|
55
|
+
- Own timers, listeners, subscriptions, or nested setup: `onSetup(...)` plus `setup(...)`.
|
|
56
|
+
- Use a sigma instance inside a component: `useSigma(...)`.
|
|
57
|
+
- Cast an instance to its readonly consumer view outside a component: `castProtected(instance)`.
|
|
58
|
+
- Subscribe to sigma or DOM events in a component: `useListener(...)`.
|
|
59
|
+
- Subscribe outside components: `listen(instance, ...)`.
|
|
60
|
+
- Read or restore committed top-level state: `sigma.captureState(...)` and `sigma.replaceState(...)`.
|
|
59
61
|
|
|
60
62
|
# Recommended Patterns
|
|
61
63
|
|
|
62
|
-
- Put
|
|
64
|
+
- Put the state shape in a named `State` type, pass it to `Sigma<TState>` or `SigmaTarget<TEvents, TState>`, then merge a same-named interface with the class for direct property typing.
|
|
63
65
|
- Keep frequently read values as separate top-level state properties. Each top-level key gets its own signal.
|
|
64
|
-
- Use
|
|
65
|
-
- Use
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
-
|
|
66
|
+
- Use private fields for ephemeral caches, handles, or bookkeeping that should not be captured, restored, persisted, or used as subscription keys.
|
|
67
|
+
- Use getters for argument-free derived reads.
|
|
68
|
+
- Use `@query` for tracked reads with arguments.
|
|
69
|
+
- Derive directly from state properties inside an action when the calculation needs unpublished draft values.
|
|
70
|
+
- Use ordinary actions for routine writes. Reserve `sigma.captureState(...)` and `sigma.replaceState(...)` for replay, reset, restore, or undo-like flows on committed top-level state.
|
|
71
|
+
- Emit directly from actions that have no unpublished draft changes. After mutating state, publish first with `this.commit(); this.emit(...)`.
|
|
72
|
+
- Prefer `listen(...)` for external event subscriptions. It works with sigma targets and DOM targets.
|
|
73
|
+
- Put owned side effects in `onSetup(...)`.
|
|
74
|
+
- Use `sigma.subscribe(this, ...)` inside `onSetup(...)` when a setup-owned side effect should react to future committed publishes. Return that cleanup so the subscription stops with setup.
|
|
71
75
|
```ts
|
|
72
|
-
|
|
76
|
+
onSetup() {
|
|
73
77
|
return [
|
|
74
|
-
sigma.subscribe(this, (
|
|
75
|
-
console.log(
|
|
78
|
+
sigma.subscribe(this, (nextState, baseState) => {
|
|
79
|
+
console.log(baseState, nextState);
|
|
76
80
|
}),
|
|
77
81
|
];
|
|
78
|
-
}
|
|
82
|
+
}
|
|
79
83
|
```
|
|
80
84
|
- Use `this.act(function () { ... })` for setup-owned callbacks that need action semantics.
|
|
81
85
|
|
|
82
86
|
# Patterns to Avoid
|
|
83
87
|
|
|
84
88
|
- Reaching for `sigma.getSignal(instance, key)` when direct property reads already cover the use case.
|
|
85
|
-
- Crossing `emit(...)`, `await`, or another action
|
|
89
|
+
- Crossing `emit(...)`, `await`, promise resolution, or another instance's action with unpublished changes. Publish them first with `this.commit()`.
|
|
86
90
|
- Starting side effects during construction instead of through explicit `setup(...)`.
|
|
87
|
-
- Encoding storage, hydration, or migration policy directly into
|
|
91
|
+
- Encoding storage, hydration, or migration policy directly into model classes.
|
|
92
|
+
- Relying on computeds or queries to observe unpublished draft changes inside actions.
|
|
88
93
|
- Treating query calls as memoized across invocations.
|
|
89
94
|
- Relying on patch payloads without enabling Immer patches first.
|
|
90
95
|
|
|
91
96
|
# Invariants and Constraints
|
|
92
97
|
|
|
93
|
-
- Sigma
|
|
94
|
-
-
|
|
95
|
-
-
|
|
98
|
+
- Sigma tracks top-level state properties. Each top-level key gets its own signal.
|
|
99
|
+
- Private fields are not top-level state properties. They do not create signals, appear in committed snapshots, participate in persistence helpers, or drive subscriptions by themselves.
|
|
100
|
+
- Protected consumer views expose immutable state and callable actions.
|
|
101
|
+
- Published draftable public state is deep-frozen by default. `setAutoFreeze(false)` disables that behavior globally.
|
|
102
|
+
- Computeds and queries read committed state, including when called inside actions.
|
|
96
103
|
- Query calls are reactive at the call site but do not memoize across invocations.
|
|
97
104
|
- Setup handlers return arrays of cleanup resources, and cleanup runs in reverse order.
|
|
98
105
|
- Call Immer's `enablePatches()` before relying on `sigma.subscribe(instance, handler, { patches: true })`.
|
|
99
|
-
- `sigma.replaceState(...)` works on committed top-level state and requires
|
|
100
|
-
-
|
|
106
|
+
- `sigma.replaceState(...)` works on committed top-level state and requires a plain object snapshot.
|
|
107
|
+
- `SigmaTarget.emit(...)` runs from an action and requires no active unpublished draft. It does not need a `commit(...)` callback.
|
|
101
108
|
|
|
102
109
|
# Error Model
|
|
103
110
|
|
|
104
111
|
- Crossing an action boundary with unpublished changes throws until `this.commit()` publishes them. Async actions also reject when they finish with unpublished changes.
|
|
105
|
-
-
|
|
106
|
-
- Calling `
|
|
107
|
-
-
|
|
108
|
-
-
|
|
112
|
+
- Calling `commit(...)` outside an action throws.
|
|
113
|
+
- Calling `act(...)` outside an `onSetup(...)` setup context throws.
|
|
114
|
+
- Calling `emit(...)` outside an action or before committing the active draft throws.
|
|
115
|
+
- Calling an action from a computed or query throws.
|
|
116
|
+
- Returning an active draft from an action throws.
|
|
117
|
+
- `sigma.replaceState(...)` throws when the replacement value is not a plain object or when an action still owns unpublished changes.
|
|
118
|
+
- Starting an action on another sigma instance while the current instance has an active action context throws.
|
|
109
119
|
|
|
110
120
|
# Terminology
|
|
111
121
|
|
|
112
122
|
- Draft boundary: a point where sigma cannot keep reusing the current unpublished draft.
|
|
113
123
|
- Committed state: the published top-level public state visible outside the current action draft.
|
|
114
|
-
- Signal access: reading the underlying `ReadonlySignal` for a top-level state key
|
|
115
|
-
- Cleanup resource: a cleanup function,
|
|
124
|
+
- Signal access: reading the underlying `ReadonlySignal` for a top-level state key through `sigma.getSignal(instance, key)`.
|
|
125
|
+
- Cleanup resource: a cleanup function, object with `dispose()`, or object with `[Symbol.dispose]()`.
|
|
116
126
|
- Nested sigma state: a sigma-state instance stored in top-level state as a value; it stays usable as a value rather than exposing its internals through parent actions.
|
|
117
127
|
|
|
118
128
|
# Non-Goals
|
|
119
129
|
|
|
120
|
-
- Replacing every plain-signal use case with a
|
|
130
|
+
- Replacing every plain-signal use case with a class abstraction.
|
|
121
131
|
- Hiding lifecycle behind implicit setup or constructor side effects.
|
|
122
132
|
- Memoizing every query call or turning queries into a global cache.
|
|
123
133
|
- Acting as a large tutorial framework or hand-maintained API reference. Exact signatures come from declaration output, and factual behavior lives beside source.
|