preact-sigma 4.0.0 → 6.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 +26 -21
- package/dist/index.d.mts +41 -325
- package/dist/index.mjs +52 -712
- package/dist/persist.d.mts +126 -0
- package/dist/persist.mjs +240 -0
- package/dist/sigma-CJibGQ6g.mjs +383 -0
- package/dist/sigma-DD7HfTvw.d.mts +162 -0
- package/docs/context.md +81 -55
- package/docs/migrations/v5-to-v6.md +273 -0
- package/docs/persist.md +67 -0
- 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 +25 -17
- package/examples/persist-search-draft.ts +70 -0
- package/examples/setup-act.ts +17 -9
- package/examples/sigma-target.ts +17 -8
- package/package.json +5 -1
- package/examples/signal-access.ts +0 -28
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,86 +19,112 @@
|
|
|
19
19
|
|
|
20
20
|
# Core Abstractions
|
|
21
21
|
|
|
22
|
-
- Sigma
|
|
23
|
-
- Sigma
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
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
|
+
- Computed: an argument-free derived getter on the class prototype that reads committed state.
|
|
26
|
+
- Query: a reactive read method that accepts arguments, is marked with the `query` decorator, and reads committed state.
|
|
27
|
+
- Action: a prototype method that is not marked as a query. Actions read and write state properties through sigma's draft and commit semantics.
|
|
28
|
+
- Setup handler: an optional `onSetup(...)` method that owns side effects and returns cleanup resources.
|
|
29
|
+
- Event: a typed notification emitted with `this.emit(...)` inside an action and observed through `listen(...)` or `useListener(...)`.
|
|
30
|
+
- Protected view: the readonly consumer view returned by `castProtected(...)` and `useSigma(...)`.
|
|
31
31
|
|
|
32
32
|
# Data Flow / Lifecycle
|
|
33
33
|
|
|
34
|
-
1. Define a
|
|
35
|
-
2.
|
|
36
|
-
3. Add
|
|
37
|
-
4. Instantiate the
|
|
34
|
+
1. Define a class that extends `Sigma<TState>` or `SigmaTarget<TEvents, TState>`.
|
|
35
|
+
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.
|
|
36
|
+
3. Add getters for computed values, `@query` methods for argument-based reactive reads, and ordinary methods for actions.
|
|
37
|
+
4. Instantiate the class. Constructor input can be merged with defaults before `super(...)` when instances need partial overrides.
|
|
38
38
|
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
|
|
39
|
+
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.
|
|
40
|
+
7. Run `setup(...)` explicitly when the instance should start owning side effects. `useSigma(...)` does this automatically for component-owned instances that define `onSetup(...)`.
|
|
41
41
|
8. Dispose the cleanup returned from `setup(...)` when the owned resources should stop.
|
|
42
42
|
|
|
43
43
|
# Common Tasks -> Recommended APIs
|
|
44
44
|
|
|
45
|
-
- Define reusable model state: `
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
45
|
+
- Define reusable model state: `class Model extends Sigma<TState>`.
|
|
46
|
+
- Define reusable model state with events: `class Model extends SigmaTarget<TEvents, TState>`.
|
|
47
|
+
- Merge partial constructor input with defaults: `mergeDefaults(initial, defaults)`.
|
|
48
|
+
- Derive an argument-free value: a class getter.
|
|
49
|
+
- Derive a reactive read with arguments: an `@query` class method.
|
|
50
|
+
- Mutate state and emit typed notifications: ordinary class methods plus `this.emit(...)`.
|
|
51
|
+
- Publish unpublished changes before `await`, `emit(...)`, promise resolution, or another instance's action: `this.commit()`.
|
|
52
|
+
- React to committed state changes: `sigma.subscribe(instance, handler)` or `sigma.subscribe(instance, key, handler)`.
|
|
53
|
+
- Read one top-level state property as a `ReadonlySignal`: `sigma.getSignal(instance, key)`.
|
|
54
|
+
- Own timers, listeners, subscriptions, or nested setup: `onSetup(...)` plus `setup(...)`.
|
|
55
|
+
- Use a sigma instance inside a component: `useSigma(...)`.
|
|
56
|
+
- Cast an instance to its readonly consumer view outside a component: `castProtected(instance)`.
|
|
57
|
+
- Subscribe to sigma or DOM events in a component: `useListener(...)`.
|
|
58
|
+
- Subscribe outside components: `listen(instance, ...)`.
|
|
59
|
+
- Read or restore committed top-level state: `sigma.captureState(...)` and `sigma.replaceState(...)`.
|
|
60
|
+
|
|
61
|
+
# Recommended Patterns
|
|
62
|
+
|
|
63
|
+
- 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.
|
|
62
64
|
- Keep frequently read values as separate top-level state properties. Each top-level key gets its own signal.
|
|
63
|
-
- Use
|
|
64
|
-
- Use
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
- Put owned side effects in
|
|
65
|
+
- Use getters for argument-free derived reads.
|
|
66
|
+
- Use `@query` for tracked reads with arguments.
|
|
67
|
+
- Derive directly from state properties inside an action when the calculation needs unpublished draft values.
|
|
68
|
+
- 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.
|
|
69
|
+
- Emit directly from actions that have no unpublished draft changes. After mutating state, publish first with `this.commit(); this.emit(...)`.
|
|
70
|
+
- Prefer `listen(...)` for external event subscriptions. It works with sigma targets and DOM targets.
|
|
71
|
+
- Put owned side effects in `onSetup(...)`.
|
|
72
|
+
- 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.
|
|
73
|
+
```ts
|
|
74
|
+
onSetup() {
|
|
75
|
+
return [
|
|
76
|
+
sigma.subscribe(this, (nextState, baseState) => {
|
|
77
|
+
console.log(baseState, nextState);
|
|
78
|
+
}),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
```
|
|
70
82
|
- Use `this.act(function () { ... })` for setup-owned callbacks that need action semantics.
|
|
71
|
-
|
|
83
|
+
|
|
84
|
+
# Patterns to Avoid
|
|
85
|
+
|
|
86
|
+
- Reaching for `sigma.getSignal(instance, key)` when direct property reads already cover the use case.
|
|
87
|
+
- Crossing `emit(...)`, `await`, promise resolution, or another instance's action with unpublished changes. Publish them first with `this.commit()`.
|
|
88
|
+
- Starting side effects during construction instead of through explicit `setup(...)`.
|
|
89
|
+
- Encoding storage, hydration, or migration policy directly into model classes.
|
|
90
|
+
- Relying on computeds or queries to observe unpublished draft changes inside actions.
|
|
91
|
+
- Treating query calls as memoized across invocations.
|
|
92
|
+
- Relying on patch payloads without enabling Immer patches first.
|
|
72
93
|
|
|
73
94
|
# Invariants and Constraints
|
|
74
95
|
|
|
75
|
-
- Sigma
|
|
76
|
-
-
|
|
77
|
-
-
|
|
96
|
+
- Sigma tracks top-level state properties. Each top-level key gets its own signal.
|
|
97
|
+
- Protected consumer views expose immutable state and callable actions.
|
|
98
|
+
- Published draftable public state is deep-frozen by default. `setAutoFreeze(false)` disables that behavior globally.
|
|
99
|
+
- Computeds and queries read committed state, including when called inside actions.
|
|
78
100
|
- Query calls are reactive at the call site but do not memoize across invocations.
|
|
79
101
|
- Setup handlers return arrays of cleanup resources, and cleanup runs in reverse order.
|
|
80
|
-
- `
|
|
81
|
-
-
|
|
102
|
+
- Call Immer's `enablePatches()` before relying on `sigma.subscribe(instance, handler, { patches: true })`.
|
|
103
|
+
- `sigma.replaceState(...)` works on committed top-level state and requires a plain object snapshot.
|
|
104
|
+
- `SigmaTarget.emit(...)` runs from an action and requires no active unpublished draft. It does not need a `commit(...)` callback.
|
|
82
105
|
|
|
83
106
|
# Error Model
|
|
84
107
|
|
|
85
108
|
- Crossing an action boundary with unpublished changes throws until `this.commit()` publishes them. Async actions also reject when they finish with unpublished changes.
|
|
86
|
-
-
|
|
87
|
-
- Calling `
|
|
88
|
-
-
|
|
89
|
-
-
|
|
109
|
+
- Calling `commit(...)` outside an action throws.
|
|
110
|
+
- Calling `act(...)` outside an `onSetup(...)` setup context throws.
|
|
111
|
+
- Calling `emit(...)` outside an action or before committing the active draft throws.
|
|
112
|
+
- Calling an action from a computed or query throws.
|
|
113
|
+
- Returning an active draft from an action throws.
|
|
114
|
+
- `sigma.replaceState(...)` throws when the replacement value is not a plain object or when an action still owns unpublished changes.
|
|
115
|
+
- Starting an action on another sigma instance while the current instance has an active action context throws.
|
|
90
116
|
|
|
91
117
|
# Terminology
|
|
92
118
|
|
|
93
119
|
- Draft boundary: a point where sigma cannot keep reusing the current unpublished draft.
|
|
94
120
|
- Committed state: the published top-level public state visible outside the current action draft.
|
|
95
|
-
- Signal access: reading the underlying `ReadonlySignal` for a top-level state key
|
|
96
|
-
- Cleanup resource: a cleanup function,
|
|
121
|
+
- Signal access: reading the underlying `ReadonlySignal` for a top-level state key through `sigma.getSignal(instance, key)`.
|
|
122
|
+
- Cleanup resource: a cleanup function, object with `dispose()`, or object with `[Symbol.dispose]()`.
|
|
97
123
|
- 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.
|
|
98
124
|
|
|
99
125
|
# Non-Goals
|
|
100
126
|
|
|
101
|
-
- Replacing every plain-signal use case with a
|
|
127
|
+
- Replacing every plain-signal use case with a class abstraction.
|
|
102
128
|
- Hiding lifecycle behind implicit setup or constructor side effects.
|
|
103
129
|
- Memoizing every query call or turning queries into a global cache.
|
|
104
130
|
- Acting as a large tutorial framework or hand-maintained API reference. Exact signatures come from declaration output, and factual behavior lives beside source.
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# Migrating from v5 to v6
|
|
2
|
+
|
|
3
|
+
v6 replaces the `SigmaType` builder with class-based models. The runtime contract is still centered on top-level reactive state, derived reads, explicit actions, setup-owned side effects, typed events, and committed snapshots.
|
|
4
|
+
|
|
5
|
+
## Model Definitions
|
|
6
|
+
|
|
7
|
+
Define a class that extends `Sigma<TState>` instead of building a configured `SigmaType`.
|
|
8
|
+
|
|
9
|
+
Before:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { SigmaType } from "preact-sigma";
|
|
13
|
+
|
|
14
|
+
const Counter = new SigmaType<{ count: number }>("Counter")
|
|
15
|
+
.defaultState({
|
|
16
|
+
count: 0,
|
|
17
|
+
})
|
|
18
|
+
.computed({
|
|
19
|
+
doubled() {
|
|
20
|
+
return this.count * 2;
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
.actions({
|
|
24
|
+
increment() {
|
|
25
|
+
this.count += 1;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
After:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { Sigma } from "preact-sigma";
|
|
34
|
+
|
|
35
|
+
type CounterState = {
|
|
36
|
+
count: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
class Counter extends Sigma<CounterState> {
|
|
40
|
+
constructor() {
|
|
41
|
+
super({
|
|
42
|
+
count: 0,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get doubled() {
|
|
47
|
+
return this.count * 2;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
increment() {
|
|
51
|
+
this.count += 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Counter extends CounterState {}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`TState` drives helper typing for subscriptions, signals, and replacement snapshots. The same-named merged interface gives direct state property reads their instance types.
|
|
59
|
+
|
|
60
|
+
## Constructor Defaults
|
|
61
|
+
|
|
62
|
+
Constructor input is ordinary TypeScript now. Use `mergeDefaults(...)` when an instance accepts partial initial state.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { mergeDefaults, Sigma } from "preact-sigma";
|
|
66
|
+
|
|
67
|
+
type SearchState = {
|
|
68
|
+
draft: string;
|
|
69
|
+
page: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
class Search extends Sigma<SearchState> {
|
|
73
|
+
static defaultState: SearchState = {
|
|
74
|
+
draft: "",
|
|
75
|
+
page: 1,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
constructor(initialState?: Partial<SearchState>) {
|
|
79
|
+
super(mergeDefaults(initialState, Search.defaultState));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface Search extends SearchState {}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Computeds, Queries, and Actions
|
|
87
|
+
|
|
88
|
+
Use class getters for argument-free computed reads. Use ordinary prototype methods for actions. Computeds and queries read committed state, including when called inside actions.
|
|
89
|
+
|
|
90
|
+
Argument-based reactive reads are class methods marked with `@query`.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { query, Sigma } from "preact-sigma";
|
|
94
|
+
|
|
95
|
+
type TodoListState = {
|
|
96
|
+
draft: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
class TodoList extends Sigma<TodoListState> {
|
|
100
|
+
constructor() {
|
|
101
|
+
super({ draft: "" });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@query
|
|
105
|
+
canAdd(minLength: number) {
|
|
106
|
+
return this.draft.trim().length >= minLength;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setDraft(draft: string) {
|
|
110
|
+
this.draft = draft;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface TodoList extends TodoListState {}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Events
|
|
118
|
+
|
|
119
|
+
`SigmaTarget` now takes event types first. Use `SigmaTarget<TEvents>` for event-only targets and `SigmaTarget<TEvents, TState>` for targets that also own state.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { listen, SigmaTarget } from "preact-sigma";
|
|
123
|
+
|
|
124
|
+
type NotificationEvents = {
|
|
125
|
+
saved: {
|
|
126
|
+
id: string;
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
class Notifications extends SigmaTarget<NotificationEvents> {
|
|
131
|
+
saved(id: string) {
|
|
132
|
+
this.emit("saved", { id });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const notifications = new Notifications();
|
|
137
|
+
|
|
138
|
+
const stop = listen(notifications, "saved", ({ id }) => {
|
|
139
|
+
console.log(id);
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`emit(...)` runs inside actions. If an action mutates state before emitting, publish first with `this.commit()`.
|
|
144
|
+
|
|
145
|
+
## Commit Boundaries
|
|
146
|
+
|
|
147
|
+
Synchronous actions publish automatically when they return. Call `this.commit()` only when unpublished changes cross a boundary:
|
|
148
|
+
|
|
149
|
+
- before `await`
|
|
150
|
+
- before an async action promise resolves
|
|
151
|
+
- before `emit(...)`
|
|
152
|
+
- before invoking another instance's action
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
type SaveIndicatorState = {
|
|
156
|
+
savedCount: number;
|
|
157
|
+
saving: boolean;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
type SaveIndicatorEvents = {
|
|
161
|
+
saved: {
|
|
162
|
+
count: number;
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
class SaveIndicator extends SigmaTarget<SaveIndicatorEvents, SaveIndicatorState> {
|
|
167
|
+
constructor() {
|
|
168
|
+
super({
|
|
169
|
+
savedCount: 0,
|
|
170
|
+
saving: false,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async save() {
|
|
175
|
+
this.saving = true;
|
|
176
|
+
this.commit();
|
|
177
|
+
|
|
178
|
+
await Promise.resolve();
|
|
179
|
+
|
|
180
|
+
this.savedCount += 1;
|
|
181
|
+
this.saving = false;
|
|
182
|
+
this.commit();
|
|
183
|
+
|
|
184
|
+
this.emit("saved", { count: this.savedCount });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface SaveIndicator extends SaveIndicatorState {}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Setup
|
|
192
|
+
|
|
193
|
+
Replace builder setup with an `onSetup(...)` method. Call `setup(...)` manually outside Preact, or use `useSigma(...)` for component-owned instances.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { listen, Sigma } from "preact-sigma";
|
|
197
|
+
|
|
198
|
+
type ClickTrackerState = {
|
|
199
|
+
clicks: number;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
class ClickTracker extends Sigma<ClickTrackerState> {
|
|
203
|
+
constructor() {
|
|
204
|
+
super({ clicks: 0 });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
onSetup(target: EventTarget) {
|
|
208
|
+
return [
|
|
209
|
+
listen(target, "click", () => {
|
|
210
|
+
this.act(function () {
|
|
211
|
+
this.clicks += 1;
|
|
212
|
+
});
|
|
213
|
+
}),
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface ClickTracker extends ClickTrackerState {}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Protected Views
|
|
222
|
+
|
|
223
|
+
The instance method `protect()` is gone. Use `castProtected(instance)` outside components, and use `useSigma(...)` inside components.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { castProtected } from "preact-sigma";
|
|
227
|
+
|
|
228
|
+
const publicCounter = castProtected(new Counter());
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Protected sigma targets keep their event metadata, so `useListener(...)` works directly with the value returned by `useSigma(...)`.
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
const palette = useSigma(() => new CommandPalette());
|
|
235
|
+
|
|
236
|
+
useListener(palette, "ran", (command) => {
|
|
237
|
+
console.log(command.title);
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Committed State Helpers
|
|
242
|
+
|
|
243
|
+
Replace `sigma.getState(...)` with `sigma.captureState(...)`.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
const saved = sigma.captureState(todoList);
|
|
247
|
+
|
|
248
|
+
todoList.add("Ship release");
|
|
249
|
+
sigma.replaceState(todoList, saved);
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Use `sigma.subscribe(instance, listener)` for committed state publishes and `sigma.subscribe(instance, key, listener)` for one top-level state key.
|
|
253
|
+
|
|
254
|
+
## Persistence
|
|
255
|
+
|
|
256
|
+
The `preact-sigma/persist` helpers are named around restore, persist, and hydrate flows:
|
|
257
|
+
|
|
258
|
+
- `restore(instance, options)`
|
|
259
|
+
- `restoreSync(instance, options)`
|
|
260
|
+
- `persist(instance, options)`
|
|
261
|
+
- `hydrate(instance, options)`
|
|
262
|
+
- `hydrateSync(instance, options)`
|
|
263
|
+
|
|
264
|
+
Use `pick: ["key"]` options for selected top-level state keys instead of a separate pick codec helper.
|
|
265
|
+
|
|
266
|
+
See [`../persist.md`](../persist.md) for persistence-specific guidance.
|
|
267
|
+
|
|
268
|
+
## More References
|
|
269
|
+
|
|
270
|
+
- [`../context.md`](../context.md): concepts, lifecycle, invariants, and API selection
|
|
271
|
+
- [`../../examples/basic-counter.ts`](../../examples/basic-counter.ts): minimal class model
|
|
272
|
+
- [`../../examples/command-palette.tsx`](../../examples/command-palette.tsx): component usage, setup, events, nested state, and custom helper objects
|
|
273
|
+
- `dist/index.d.mts` and `dist/persist.d.mts` after `pnpm build`: exact exported signatures
|
package/docs/persist.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Persist
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`preact-sigma/persist` persists and restores committed top-level sigma state without moving storage, scheduling, or migration policy into `Sigma` classes.
|
|
6
|
+
|
|
7
|
+
The module builds on the core committed-state helpers:
|
|
8
|
+
|
|
9
|
+
- `sigma.captureState(instance)` reads the current committed snapshot.
|
|
10
|
+
- `sigma.replaceState(instance, nextState)` restores a committed snapshot.
|
|
11
|
+
- `sigma.subscribe(instance, handler)` observes future committed publishes.
|
|
12
|
+
|
|
13
|
+
Use the persist module when those primitives are the right boundary, but you do not want each application to reimplement store adapters, partial persistence, restore sequencing, or write scheduling. Exact signatures live in [`dist/persist.d.mts`](../dist/persist.d.mts).
|
|
14
|
+
|
|
15
|
+
## When to Use
|
|
16
|
+
|
|
17
|
+
- State should survive reloads, navigation, or app restarts.
|
|
18
|
+
- Persistence needs to stay instance-specific instead of becoming part of the model class.
|
|
19
|
+
- Storage may be synchronous or asynchronous.
|
|
20
|
+
- Stored payloads need versioning, migration, or partial persistence.
|
|
21
|
+
- Restore and future persistence should share one small lifecycle helper.
|
|
22
|
+
|
|
23
|
+
## When Not to Use
|
|
24
|
+
|
|
25
|
+
- A one-off snapshot or replay flow is enough. Use `sigma.captureState(...)` and `sigma.replaceState(...)` directly.
|
|
26
|
+
- The data is really a remote cache, normalization layer, or conflict-resolution problem.
|
|
27
|
+
- You need unpublished drafts, computeds, queries, setup resources, or emitted events persisted.
|
|
28
|
+
- The model should start side effects before async restore completes. Sequence that explicitly outside `useSigma(...)`.
|
|
29
|
+
|
|
30
|
+
## Core Pieces
|
|
31
|
+
|
|
32
|
+
- Store: owns `get`, `set`, and `delete` for persisted records. These names match [Keyv](https://github.com/jaredwray/keyv) and `Map`.
|
|
33
|
+
- Codec: owns payload shape, versioning, and migration logic between stored data and a full committed snapshot.
|
|
34
|
+
- Pick options: persist selected top-level keys without writing a custom codec.
|
|
35
|
+
- Helper: owns restore sequencing, subscription lifecycle, and write scheduling for one sigma instance.
|
|
36
|
+
|
|
37
|
+
## Common Tasks -> Recommended APIs
|
|
38
|
+
|
|
39
|
+
- Restore once through an async store: `restore(instance, options)`
|
|
40
|
+
- Restore once through a sync store: `restoreSync(instance, options)`
|
|
41
|
+
- Persist future committed changes only: `persist(instance, options)`
|
|
42
|
+
- Restore first, then persist future changes: `hydrate(instance, options)` or `hydrateSync(instance, options)`
|
|
43
|
+
- Persist only selected top-level keys while restoring the full state shape: pass `pick: ["key"]`
|
|
44
|
+
|
|
45
|
+
## Scheduling and Lifecycle
|
|
46
|
+
|
|
47
|
+
- Persistence helpers only read and write committed snapshots. Unpublished drafts never reach storage.
|
|
48
|
+
- `persist(...)` defaults to `"microtask"` scheduling so multiple same-turn publishes can coalesce into one write.
|
|
49
|
+
- `writeInitial` defaults to `false`, which prevents a new binding from overwriting an older record before restore runs.
|
|
50
|
+
- `flush()` waits for scheduled or active writes to finish.
|
|
51
|
+
- `clear()` removes the stored record and keeps the binding usable for later writes.
|
|
52
|
+
- `stop()` unsubscribes the binding, cancels unwritten scheduled state, and waits for any active write to settle.
|
|
53
|
+
- `hydrate(...)` starts future persistence only after restore resolves successfully.
|
|
54
|
+
|
|
55
|
+
## Constraints
|
|
56
|
+
|
|
57
|
+
- `sigma.replaceState(...)` requires a plain object replacement snapshot. In supported TypeScript usage, pass the class's full `TState` shape.
|
|
58
|
+
- Custom partial persistence codecs should reconstruct a full replacement snapshot before restore finishes.
|
|
59
|
+
- Nested sigma-state values are stored only if the chosen codec and payload format support them explicitly.
|
|
60
|
+
- Async restore failures reject through `restore(...)` or the `restored` promise from `hydrate(...)`.
|
|
61
|
+
- Background write failures route through `onWriteError(...)` without automatically stopping persistence.
|
|
62
|
+
|
|
63
|
+
## Example Routes
|
|
64
|
+
|
|
65
|
+
- [`examples/persist-search-draft.ts`](../examples/persist-search-draft.ts): sync restore-first persistence with `hydrateSync(...)` and `pick`
|
|
66
|
+
- [`examples/observe-and-restore.ts`](../examples/observe-and-restore.ts): direct snapshot and restore without the persist subpath
|
|
67
|
+
- [`dist/persist.d.mts`](../dist/persist.d.mts): exact exported signatures for the persist module
|
package/examples/async-commit.ts
CHANGED
|
@@ -1,38 +1,43 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import { listen, SigmaTarget } from "preact-sigma";
|
|
2
|
+
|
|
3
|
+
type SaveIndicatorState = {
|
|
4
|
+
savedCount: number;
|
|
5
|
+
saving: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type SaveIndicatorEvents = {
|
|
9
|
+
saved: {
|
|
10
|
+
count: number;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
class SaveIndicator extends SigmaTarget<SaveIndicatorEvents, SaveIndicatorState> {
|
|
15
|
+
constructor() {
|
|
16
|
+
super({
|
|
17
|
+
savedCount: 0,
|
|
18
|
+
saving: false,
|
|
19
|
+
});
|
|
12
20
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
this.emit("saved", { count: this.savedCount });
|
|
30
|
-
},
|
|
31
|
-
});
|
|
21
|
+
|
|
22
|
+
async save() {
|
|
23
|
+
this.saving = true;
|
|
24
|
+
this.commit(); // Publish before the async boundary.
|
|
25
|
+
|
|
26
|
+
await Promise.resolve();
|
|
27
|
+
|
|
28
|
+
this.savedCount += 1;
|
|
29
|
+
this.saving = false;
|
|
30
|
+
this.commit(); // Publish before emitting the event boundary.
|
|
31
|
+
|
|
32
|
+
this.emit("saved", { count: this.savedCount });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SaveIndicator extends SaveIndicatorState {}
|
|
32
37
|
|
|
33
38
|
const indicator = new SaveIndicator();
|
|
34
39
|
|
|
35
|
-
indicator
|
|
40
|
+
const stop = listen(indicator, "saved", ({ count }) => {
|
|
36
41
|
console.log(`Saved ${count} times`);
|
|
37
42
|
});
|
|
38
43
|
|
|
@@ -40,3 +45,5 @@ await indicator.save();
|
|
|
40
45
|
|
|
41
46
|
console.log(indicator.saving); // false
|
|
42
47
|
console.log(indicator.savedCount); // 1
|
|
48
|
+
|
|
49
|
+
stop();
|
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
1
|
+
import { Sigma } from "preact-sigma";
|
|
2
|
+
|
|
3
|
+
type CounterState = { count: number };
|
|
4
|
+
|
|
5
|
+
class Counter extends Sigma<CounterState> {
|
|
6
|
+
constructor() {
|
|
7
|
+
super({
|
|
8
|
+
count: 0,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get doubled() {
|
|
13
|
+
return this.count * 2;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
increment() {
|
|
17
|
+
this.count += 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Counter extends CounterState {}
|
|
17
22
|
|
|
18
23
|
const counter = new Counter();
|
|
19
24
|
|