@inglorious/store 12.1.0 → 12.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 +28 -1
- package/package.json +4 -4
- package/src/behaviors/async.js +29 -10
- package/src/behaviors/optimistic.js +84 -41
- package/src/behaviors/state-machine/fsm.js +0 -6
- package/src/behaviors/state-machine/scenes.js +0 -6
- package/src/client/devtools.js +10 -34
- package/src/client/multiplayer-middleware.js +1 -14
- package/src/event-map.js +4 -2
- package/src/migration/rtk.js +0 -24
- package/src/store.js +4 -50
- package/types/api.d.ts +1 -1
- package/types/behaviors/async.d.ts +19 -5
- package/types/behaviors/optimistic.d.ts +9 -2
- package/types/migration/rtk.d.ts +0 -2
package/README.md
CHANGED
|
@@ -522,7 +522,7 @@ const types = {
|
|
|
522
522
|
const { name } = api.getEntity("user")
|
|
523
523
|
const response = await fetch(`/api/todos/${name}`)
|
|
524
524
|
const data = await response.json()
|
|
525
|
-
api.notify("todosLoaded",
|
|
525
|
+
api.notify("todosLoaded", data)
|
|
526
526
|
} catch (error) {
|
|
527
527
|
api.notify("loadFailed", error.message)
|
|
528
528
|
}
|
|
@@ -553,6 +553,11 @@ Notice: you don't need pending/fulfilled/rejected actions. You stay in control o
|
|
|
553
553
|
|
|
554
554
|
All events triggered via `api.notify()` enter the queue and process together, maintaining predictability and testability.
|
|
555
555
|
|
|
556
|
+
Two things to keep in mind:
|
|
557
|
+
|
|
558
|
+
- **`entity` ends at the first `await`.** `entity`, and every object or array read from it, is a draft of the current update, revoked when the update ends, and the update does not wait for your `await`. After it, writing to `entity` or reading anything taken from it throws. Write before the first `await`, then notify an event that does the rest, as `todosLoaded` does above. The same goes for payloads: pass a copy (`{ ...entity.values }`), not the draft, to a handler that reads it after an `await`.
|
|
559
|
+
- **`api.getEntity()` reads the state from before the update.** A handler cannot see what an earlier handler in the same chain of events changed: see [State swapping](#state-swapping).
|
|
560
|
+
|
|
556
561
|
### `handleAsync`
|
|
557
562
|
|
|
558
563
|
The `handleAsync` helper generates a set of event handlers representing the lifecycle of an async operation.
|
|
@@ -611,6 +616,8 @@ fetchTodosFinally
|
|
|
611
616
|
|
|
612
617
|
Each step is an **event handler**, not an implicit callback.
|
|
613
618
|
|
|
619
|
+
Each call gets a `callId`, and every event of that call carries it: `Start` and `Run` get `{ payload, callId }`, `Success` gets `{ result, callId }`, `Error` gets `{ error, callId }` and `Finally` gets `{ callId }`. The `start`, `run`, `success`, `error` and `finally` handlers still receive the payload, result or error alone; the `callId` matters to code that listens to the events directly, such as `optimistic`.
|
|
620
|
+
|
|
614
621
|
---
|
|
615
622
|
|
|
616
623
|
### Optional `start` handler
|
|
@@ -685,6 +692,26 @@ const saveSettings = optimistic(
|
|
|
685
692
|
|
|
686
693
|
The wrapper snapshots the keys in the patch, applies them in `Start`, restores the previous values in `Error`, and leaves `Success`/`Finally` handlers in control of cleanup.
|
|
687
694
|
|
|
695
|
+
Each call keeps its own snapshot, so saves on the same entity can overlap. A failed call restores the keys it patched, except those a later pending call patched too: those go back to what they were before both once that call fails as well, or stay as that call left them.
|
|
696
|
+
|
|
697
|
+
A patch built on the current value, such as a list with a temporary item added, cannot be undone key by key when two calls change the same list. Give `rollback` to undo the call's own change in place of restoring the snapshot:
|
|
698
|
+
|
|
699
|
+
```ts
|
|
700
|
+
const addTodo = optimistic(
|
|
701
|
+
handleAsync("addTodo", {
|
|
702
|
+
async run(todo) {
|
|
703
|
+
return api.add(todo)
|
|
704
|
+
},
|
|
705
|
+
}),
|
|
706
|
+
(entity, todo) => ({ todos: [...entity.todos, { ...todo, pending: true }] }),
|
|
707
|
+
{
|
|
708
|
+
rollback(entity, todo) {
|
|
709
|
+
entity.todos = entity.todos.filter(({ id }) => id !== todo.id)
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
)
|
|
713
|
+
```
|
|
714
|
+
|
|
688
715
|
---
|
|
689
716
|
|
|
690
717
|
### Reading a store from outside a handler
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/store",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.2.0",
|
|
4
4
|
"description": "A state manager for real-time, collaborative apps, inspired by game development patterns and compatible with Redux.",
|
|
5
5
|
"author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -75,14 +75,14 @@
|
|
|
75
75
|
},
|
|
76
76
|
"sideEffects": false,
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@inglorious/utils": "5.1.0",
|
|
79
78
|
"mutative": "^1.3.0"
|
|
80
79
|
},
|
|
81
80
|
"peerDependencies": {
|
|
82
|
-
"@inglorious/utils": "5.1.0"
|
|
81
|
+
"@inglorious/utils": "^5.1.0"
|
|
83
82
|
},
|
|
84
83
|
"devDependencies": {
|
|
85
84
|
"@inglorious/eslint-config": "1.2.0",
|
|
85
|
+
"@inglorious/utils": "5.1.0",
|
|
86
86
|
"@reduxjs/toolkit": "^2.12.0",
|
|
87
87
|
"prettier": "^3.9.6",
|
|
88
88
|
"typescript": "~7.0.2",
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"vitest": "^5.0.0"
|
|
91
91
|
},
|
|
92
92
|
"engines": {
|
|
93
|
-
"node": ">=
|
|
93
|
+
"node": ">= 26"
|
|
94
94
|
},
|
|
95
95
|
"scripts": {
|
|
96
96
|
"format": "prettier --write .",
|
package/src/behaviors/async.js
CHANGED
|
@@ -36,6 +36,11 @@
|
|
|
36
36
|
* - "type": notify `typeName:event` (broadcasts to all entities of this type)
|
|
37
37
|
* - "global": notify `event` (global broadcast to any listener)
|
|
38
38
|
*
|
|
39
|
+
* Each call gets a `callId`, and every event of that call carries it: `Start`
|
|
40
|
+
* and `Run` get `{ payload, callId }`, `Success` gets `{ result, callId }`,
|
|
41
|
+
* `Error` gets `{ error, callId }` and `Finally` gets `{ callId }`. The
|
|
42
|
+
* handlers above still receive the payload, result or error alone.
|
|
43
|
+
*
|
|
39
44
|
* @returns {Object} An object containing the generated event handlers that can be spread into a type:
|
|
40
45
|
* - `[type]`: Main trigger - dispatches Start (if defined) and Run events
|
|
41
46
|
* - `[typeStart]`: (if start handler provided) Executes the start handler
|
|
@@ -148,38 +153,39 @@ export function handleAsync(type, handlers, options = {}) {
|
|
|
148
153
|
|
|
149
154
|
const asyncHandlers = {
|
|
150
155
|
[type](entity, payload, api) {
|
|
156
|
+
const callId = nextCallId()
|
|
157
|
+
|
|
151
158
|
if (handlers.start) {
|
|
152
|
-
notify(api, entity, `${type}Start`, payload)
|
|
159
|
+
notify(api, entity, `${type}Start`, { payload, callId })
|
|
153
160
|
}
|
|
154
161
|
|
|
155
|
-
notify(api, entity, `${type}Run`, payload)
|
|
162
|
+
notify(api, entity, `${type}Run`, { payload, callId })
|
|
156
163
|
},
|
|
157
164
|
|
|
158
165
|
...(handlers.start && {
|
|
159
|
-
[`${type}Start`](entity, payload, api) {
|
|
166
|
+
[`${type}Start`](entity, { payload }, api) {
|
|
160
167
|
handlers.start(entity, payload, api)
|
|
161
168
|
},
|
|
162
169
|
}),
|
|
163
170
|
|
|
164
|
-
async [`${type}Run`](entity, payload, api) {
|
|
165
|
-
// Snapshot routing keys before await — mutative revokes draft proxies after the patch.
|
|
171
|
+
async [`${type}Run`](entity, { payload, callId }, api) {
|
|
166
172
|
const scopedEntity = { id: entity.id, type: entity.type }
|
|
167
173
|
|
|
168
174
|
try {
|
|
169
175
|
const result = await handlers.run(payload, api)
|
|
170
|
-
notify(api, scopedEntity, `${type}Success`, result)
|
|
176
|
+
notify(api, scopedEntity, `${type}Success`, { result, callId })
|
|
171
177
|
} catch (error) {
|
|
172
|
-
notify(api, scopedEntity, `${type}Error`, error)
|
|
178
|
+
notify(api, scopedEntity, `${type}Error`, { error, callId })
|
|
173
179
|
} finally {
|
|
174
|
-
notify(api, scopedEntity, `${type}Finally
|
|
180
|
+
notify(api, scopedEntity, `${type}Finally`, { callId })
|
|
175
181
|
}
|
|
176
182
|
},
|
|
177
183
|
|
|
178
|
-
[`${type}Success`](entity, result, api) {
|
|
184
|
+
[`${type}Success`](entity, { result }, api) {
|
|
179
185
|
handlers.success?.(entity, result, api)
|
|
180
186
|
},
|
|
181
187
|
|
|
182
|
-
[`${type}Error`](entity, error, api) {
|
|
188
|
+
[`${type}Error`](entity, { error }, api) {
|
|
183
189
|
handlers.error?.(entity, error, api)
|
|
184
190
|
},
|
|
185
191
|
|
|
@@ -196,4 +202,17 @@ export function handleAsync(type, handlers, options = {}) {
|
|
|
196
202
|
return asyncHandlers
|
|
197
203
|
}
|
|
198
204
|
|
|
205
|
+
const NEXT = 1
|
|
206
|
+
|
|
207
|
+
let lastCallId = 0
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A new id for one call of an async behavior, which its events carry.
|
|
211
|
+
* @returns {number}
|
|
212
|
+
*/
|
|
213
|
+
export function nextCallId() {
|
|
214
|
+
lastCallId += NEXT
|
|
215
|
+
return lastCallId
|
|
216
|
+
}
|
|
217
|
+
|
|
199
218
|
export const ASYNC_META = Symbol.for("@inglorious/store/asyncMeta")
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { ASYNC_META } from "./async.js"
|
|
1
|
+
import { ASYNC_META, nextCallId } from "./async.js"
|
|
2
2
|
|
|
3
3
|
const START = 0
|
|
4
|
+
const NOT_FOUND = -1
|
|
5
|
+
const ONE_CALL = 1
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Decorates an async behavior with optimistic update boilerplate.
|
|
@@ -8,6 +10,16 @@ const START = 0
|
|
|
8
10
|
* Pass either a static shallow patch object or a factory that returns one.
|
|
9
11
|
* The wrapper applies the patch during `Start` and rolls it back during `Error`.
|
|
10
12
|
*
|
|
13
|
+
* Each call keeps its own snapshot, so calls on the same entity can overlap. A
|
|
14
|
+
* failed call restores the keys it patched, except those a later pending call
|
|
15
|
+
* patched too: that call's own rollback then restores them to what they were
|
|
16
|
+
* before both.
|
|
17
|
+
*
|
|
18
|
+
* A patch built on the current value, such as a list with a temporary item
|
|
19
|
+
* added, cannot be undone key by key when two calls change the same list. Give
|
|
20
|
+
* `rollback` instead: it undoes the call's own change in place of restoring
|
|
21
|
+
* the snapshot.
|
|
22
|
+
*
|
|
11
23
|
* @template {import('../store').BaseEntity} TEntity
|
|
12
24
|
* @template TPayload
|
|
13
25
|
* @template TResult
|
|
@@ -16,6 +28,9 @@ const START = 0
|
|
|
16
28
|
* @param {import('./async').AsyncEvents<TType, TEntity, TPayload, TResult>} behavior
|
|
17
29
|
* @param {Record<string, any> | ((entity: TEntity, payload: TPayload, api: import('../store').Api) => Record<string, any> | null | undefined)} optimistic
|
|
18
30
|
* Shallow patch applied optimistically on start, or a factory that returns it.
|
|
31
|
+
* @param {object} [options]
|
|
32
|
+
* @param {(entity: TEntity, payload: TPayload, api: import('../store').Api) => void} [options.rollback]
|
|
33
|
+
* Undoes a failed call's change, in place of restoring the snapshot.
|
|
19
34
|
*
|
|
20
35
|
* @returns {import('./async').AsyncEvents<TType, TEntity, TPayload, TResult>}
|
|
21
36
|
*
|
|
@@ -46,8 +61,23 @@ const START = 0
|
|
|
46
61
|
* data: { ...entity.data, ...payload },
|
|
47
62
|
* }),
|
|
48
63
|
* )
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const addTodo = optimistic(
|
|
67
|
+
* handleAsync("addTodo", {
|
|
68
|
+
* async run(todo) {
|
|
69
|
+
* return api.add(todo)
|
|
70
|
+
* },
|
|
71
|
+
* }),
|
|
72
|
+
* (entity, todo) => ({ todos: [...entity.todos, todo] }),
|
|
73
|
+
* {
|
|
74
|
+
* rollback(entity, todo) {
|
|
75
|
+
* entity.todos = entity.todos.filter(({ id }) => id !== todo.id)
|
|
76
|
+
* },
|
|
77
|
+
* },
|
|
78
|
+
* )
|
|
49
79
|
*/
|
|
50
|
-
export function optimistic(behavior, optimistic) {
|
|
80
|
+
export function optimistic(behavior, optimistic, { rollback } = {}) {
|
|
51
81
|
const { type, scope } = resolveAsyncMeta(behavior)
|
|
52
82
|
|
|
53
83
|
const startEvent = `${type}Start`
|
|
@@ -55,7 +85,8 @@ export function optimistic(behavior, optimistic) {
|
|
|
55
85
|
const errorEvent = `${type}Error`
|
|
56
86
|
const finallyEvent = `${type}Finally`
|
|
57
87
|
|
|
58
|
-
|
|
88
|
+
/** The calls still pending, by entity id, in the order they started. */
|
|
89
|
+
const pending = new Map()
|
|
59
90
|
|
|
60
91
|
function notify(api, entity, event, payload) {
|
|
61
92
|
switch (scope) {
|
|
@@ -75,19 +106,16 @@ export function optimistic(behavior, optimistic) {
|
|
|
75
106
|
...behavior,
|
|
76
107
|
|
|
77
108
|
[type](entity, payload, api) {
|
|
78
|
-
|
|
79
|
-
notify(api, entity,
|
|
109
|
+
const callId = nextCallId()
|
|
110
|
+
notify(api, entity, startEvent, { payload, callId })
|
|
111
|
+
notify(api, entity, runEvent, { payload, callId })
|
|
80
112
|
},
|
|
81
113
|
|
|
82
|
-
[startEvent](entity,
|
|
114
|
+
[startEvent](entity, event, api) {
|
|
115
|
+
const { payload, callId } = event
|
|
83
116
|
const patch = toPatch(optimistic, entity, payload, api)
|
|
84
117
|
|
|
85
|
-
if (
|
|
86
|
-
behavior[startEvent]?.(entity, payload, api)
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (typeof patch !== "object") {
|
|
118
|
+
if (patch && typeof patch !== "object") {
|
|
91
119
|
throw new TypeError(
|
|
92
120
|
"optimistic expects the patch to be an object or a factory that returns an object",
|
|
93
121
|
)
|
|
@@ -95,25 +123,46 @@ export function optimistic(behavior, optimistic) {
|
|
|
95
123
|
|
|
96
124
|
const key = getEntityKey(entity)
|
|
97
125
|
const currentEntity = api.getEntity?.(key) ?? entity
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
126
|
+
const calls = pending.get(key) ?? []
|
|
127
|
+
calls.push({
|
|
128
|
+
callId,
|
|
129
|
+
payload,
|
|
130
|
+
snapshot: snapshotPatch(currentEntity, patch ?? {}),
|
|
131
|
+
})
|
|
132
|
+
pending.set(key, calls)
|
|
133
|
+
if (patch) Object.assign(entity, patch)
|
|
134
|
+
|
|
135
|
+
behavior[startEvent]?.(entity, event, api)
|
|
102
136
|
},
|
|
103
137
|
|
|
104
138
|
[runEvent]: behavior[runEvent],
|
|
105
139
|
|
|
106
|
-
[errorEvent](entity,
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
140
|
+
[errorEvent](entity, event, api) {
|
|
141
|
+
const calls = pending.get(getEntityKey(entity)) ?? []
|
|
142
|
+
const index = calls.findIndex(({ callId }) => callId === event.callId)
|
|
143
|
+
|
|
144
|
+
if (index !== NOT_FOUND) {
|
|
145
|
+
const [call] = calls.splice(index, ONE_CALL)
|
|
146
|
+
if (rollback) {
|
|
147
|
+
rollback(entity, call.payload, api)
|
|
148
|
+
} else {
|
|
149
|
+
restorePatch(entity, call.snapshot, calls.slice(index))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
behavior[errorEvent]?.(entity, event, api)
|
|
110
154
|
},
|
|
111
155
|
|
|
112
|
-
[finallyEvent](entity,
|
|
156
|
+
[finallyEvent](entity, event, api) {
|
|
113
157
|
try {
|
|
114
|
-
behavior[finallyEvent]?.(entity,
|
|
158
|
+
behavior[finallyEvent]?.(entity, event, api)
|
|
115
159
|
} finally {
|
|
116
|
-
|
|
160
|
+
const key = getEntityKey(entity)
|
|
161
|
+
const calls = (pending.get(key) ?? []).filter(
|
|
162
|
+
({ callId }) => callId !== event?.callId,
|
|
163
|
+
)
|
|
164
|
+
if (calls.length) pending.set(key, calls)
|
|
165
|
+
else pending.delete(key)
|
|
117
166
|
}
|
|
118
167
|
},
|
|
119
168
|
}
|
|
@@ -168,27 +217,21 @@ function toPatch(optimistic, entity, payload, api) {
|
|
|
168
217
|
}
|
|
169
218
|
|
|
170
219
|
function snapshotPatch(entity, patch) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
for (const key of Object.keys(patch)) {
|
|
174
|
-
entries.push([
|
|
220
|
+
return new Map(
|
|
221
|
+
Object.keys(patch).map((key) => [
|
|
175
222
|
key,
|
|
176
|
-
Object.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return entries
|
|
223
|
+
{ existed: Object.hasOwn(entity, key), value: entity[key] },
|
|
224
|
+
]),
|
|
225
|
+
)
|
|
182
226
|
}
|
|
183
227
|
|
|
184
|
-
function restorePatch(entity,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
entity[key] = value
|
|
228
|
+
function restorePatch(entity, snapshot, laterCalls) {
|
|
229
|
+
for (const [key, before] of snapshot) {
|
|
230
|
+
const later = laterCalls.find((call) => call.snapshot.has(key))
|
|
231
|
+
if (later) {
|
|
232
|
+
later.snapshot.set(key, before)
|
|
233
|
+
} else if (before.existed) {
|
|
234
|
+
entity[key] = before.value
|
|
192
235
|
} else {
|
|
193
236
|
delete entity[key]
|
|
194
237
|
}
|
|
@@ -20,12 +20,6 @@ export function fsm(states) {
|
|
|
20
20
|
[eventName](entity, event, api) {
|
|
21
21
|
const from = entity.state
|
|
22
22
|
|
|
23
|
-
// A state answers for itself, and what it has to say replaces what the type
|
|
24
|
-
// would have said. The type-stage handler is the default -- the thing every
|
|
25
|
-
// state would otherwise do -- and a state takes it over by naming the same
|
|
26
|
-
// event, which is how a state can take the way out for itself rather than
|
|
27
|
-
// being quit by, without whatever answers by default having to know that
|
|
28
|
-
// state exists.
|
|
29
23
|
const handler = states[entity.state]?.[eventName] ?? type[eventName]
|
|
30
24
|
|
|
31
25
|
handler?.(entity, event, api)
|
|
@@ -59,10 +59,6 @@ export function scenes(scenesByState) {
|
|
|
59
59
|
continue
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
// The same thing stands on both sides of the move, so it is neither added nor
|
|
63
|
-
// removed -- but it may be wanted to be a different thing now, and then only what
|
|
64
|
-
// has actually changed is merged over it. That is what leaves a shared thing where
|
|
65
|
-
// the world had put it when the next state says nothing different about it.
|
|
66
62
|
const previous = leaving.find(({ id }) => id === added.id)
|
|
67
63
|
|
|
68
64
|
if (!isDeepEqual(previous, added)) {
|
|
@@ -78,8 +74,6 @@ export function scenes(scenesByState) {
|
|
|
78
74
|
build(entity, entity.state, api)
|
|
79
75
|
},
|
|
80
76
|
|
|
81
|
-
// The announcement is broadcast and carries the id, so that anything else listening
|
|
82
|
-
// for it can tell which machine moved. This one is only interested in its own.
|
|
83
77
|
stateChange(entity, { entityId, to }, api) {
|
|
84
78
|
if (entityId !== entity.id) return
|
|
85
79
|
|
package/src/client/devtools.js
CHANGED
|
@@ -2,21 +2,10 @@ import { deserialize } from "@inglorious/utils/object.js"
|
|
|
2
2
|
|
|
3
3
|
const LAST_STATE = 1
|
|
4
4
|
|
|
5
|
-
// HMR-safe container to persist a single DevTools connection across Vite reloads.
|
|
6
|
-
// Uses import.meta.hot.data when available (preferred), falls back to module scope.
|
|
7
5
|
const globalContainer = import.meta.hot
|
|
8
6
|
? ((import.meta.hot.data ??= {}).__INGLORIOUS_DEVTOOLS__ ??= {})
|
|
9
7
|
: {}
|
|
10
8
|
|
|
11
|
-
// connection shape: {
|
|
12
|
-
// devToolsInstance,
|
|
13
|
-
// unsubscribe,
|
|
14
|
-
// store,
|
|
15
|
-
// updateMode,
|
|
16
|
-
// restoreSetState: () => void,
|
|
17
|
-
// }
|
|
18
|
-
|
|
19
|
-
// Unified API for cleaner integration across apps and engine
|
|
20
9
|
export function createDevtools(config = {}) {
|
|
21
10
|
return {
|
|
22
11
|
connect(store) {
|
|
@@ -51,12 +40,10 @@ export function createDevtools(config = {}) {
|
|
|
51
40
|
function connectDevTools(store, config = {}) {
|
|
52
41
|
const updateMode = config.updateMode ?? "auto"
|
|
53
42
|
|
|
54
|
-
// Prevent duplicate connections; rewire if store or update mode changed
|
|
55
43
|
const existing = getConnection()
|
|
56
44
|
if (existing) {
|
|
57
45
|
const sameMode = existing.updateMode === updateMode
|
|
58
46
|
if (sameMode) {
|
|
59
|
-
// Hot-swap store without resetting DevTools history
|
|
60
47
|
existing.restoreSetState?.()
|
|
61
48
|
const baseSetState = store.setState
|
|
62
49
|
const restoreSetState =
|
|
@@ -79,7 +66,6 @@ function connectDevTools(store, config = {}) {
|
|
|
79
66
|
existing.restoreSetState = restoreSetState
|
|
80
67
|
}
|
|
81
68
|
|
|
82
|
-
// Different update mode: fully disconnect previous and proceed
|
|
83
69
|
try {
|
|
84
70
|
existing.unsubscribe?.()
|
|
85
71
|
} finally {
|
|
@@ -104,7 +90,6 @@ function connectDevTools(store, config = {}) {
|
|
|
104
90
|
: () => {
|
|
105
91
|
// noop; we didn't wrap setState
|
|
106
92
|
}
|
|
107
|
-
// Only add setState side-effects in auto update mode; manual update mode logs explicitly from engine.
|
|
108
93
|
if (updateMode === "auto") {
|
|
109
94
|
store.setState = (newState) => {
|
|
110
95
|
baseSetState(newState)
|
|
@@ -114,19 +99,17 @@ function connectDevTools(store, config = {}) {
|
|
|
114
99
|
|
|
115
100
|
const devToolsInstance = window.__REDUX_DEVTOOLS_EXTENSION__.connect({
|
|
116
101
|
name,
|
|
117
|
-
// predicate: // doesn't work
|
|
118
|
-
// @see https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#features
|
|
119
102
|
features: {
|
|
120
|
-
pause: true,
|
|
121
|
-
lock: true,
|
|
122
|
-
persist: true,
|
|
123
|
-
export: true,
|
|
124
|
-
import: "custom",
|
|
125
|
-
jump: false,
|
|
126
|
-
skip: false,
|
|
127
|
-
reorder: false,
|
|
128
|
-
dispatch: true,
|
|
129
|
-
test: false,
|
|
103
|
+
pause: true,
|
|
104
|
+
lock: true,
|
|
105
|
+
persist: true,
|
|
106
|
+
export: true,
|
|
107
|
+
import: "custom",
|
|
108
|
+
jump: false,
|
|
109
|
+
skip: false,
|
|
110
|
+
reorder: false,
|
|
111
|
+
dispatch: true,
|
|
112
|
+
test: false,
|
|
130
113
|
},
|
|
131
114
|
})
|
|
132
115
|
|
|
@@ -154,9 +137,6 @@ function connectDevTools(store, config = {}) {
|
|
|
154
137
|
}
|
|
155
138
|
|
|
156
139
|
function disconnectDevTools() {
|
|
157
|
-
// The `disconnect` method on the devToolsInstance is not available in all
|
|
158
|
-
// environments or versions of the extension.
|
|
159
|
-
// Safest is to unsubscribe and release our reference, and restore setState.
|
|
160
140
|
const existing = getConnection()
|
|
161
141
|
if (!existing) return
|
|
162
142
|
try {
|
|
@@ -171,7 +151,6 @@ function handleDispatch(message) {
|
|
|
171
151
|
const { store } = getConnection() ?? {}
|
|
172
152
|
if (!store) return
|
|
173
153
|
switch (message.payload.type) {
|
|
174
|
-
// reset button
|
|
175
154
|
case "RESET": {
|
|
176
155
|
store.reset()
|
|
177
156
|
const existing = getConnection()
|
|
@@ -179,21 +158,18 @@ function handleDispatch(message) {
|
|
|
179
158
|
break
|
|
180
159
|
}
|
|
181
160
|
|
|
182
|
-
// revert button
|
|
183
161
|
case "ROLLBACK": {
|
|
184
162
|
const newState = deserialize(message.state)
|
|
185
163
|
store.setState(newState)
|
|
186
164
|
break
|
|
187
165
|
}
|
|
188
166
|
|
|
189
|
-
// commit button
|
|
190
167
|
case "COMMIT": {
|
|
191
168
|
const existing = getConnection()
|
|
192
169
|
existing?.devToolsInstance?.init(store.getState())
|
|
193
170
|
break
|
|
194
171
|
}
|
|
195
172
|
|
|
196
|
-
// import from file button
|
|
197
173
|
case "IMPORT_STATE": {
|
|
198
174
|
const { computedStates, actionsById } = message.payload.nextLiftedState
|
|
199
175
|
|
|
@@ -18,7 +18,6 @@ export function multiplayerMiddleware(config = {}) {
|
|
|
18
18
|
let ws = null
|
|
19
19
|
const localQueue = []
|
|
20
20
|
|
|
21
|
-
// The middleware function that will be applied to the store.
|
|
22
21
|
return (store) => {
|
|
23
22
|
const next = store.dispatch
|
|
24
23
|
store.dispatch = (event) => send(store, event, next)
|
|
@@ -38,7 +37,6 @@ export function multiplayerMiddleware(config = {}) {
|
|
|
38
37
|
return next(event)
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
// Establish the connection on the first event.
|
|
42
40
|
if (!ws) {
|
|
43
41
|
establishConnection(store)
|
|
44
42
|
}
|
|
@@ -51,8 +49,6 @@ export function multiplayerMiddleware(config = {}) {
|
|
|
51
49
|
}
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
// Pass the event to the next middleware in the chain,
|
|
55
|
-
// which is eventually the store's original dispatch function.
|
|
56
52
|
return next(event)
|
|
57
53
|
}
|
|
58
54
|
|
|
@@ -60,18 +56,12 @@ export function multiplayerMiddleware(config = {}) {
|
|
|
60
56
|
* Attempts to establish a WebSocket connection to the server.
|
|
61
57
|
*/
|
|
62
58
|
function establishConnection(store) {
|
|
63
|
-
// If a connection already exists, close it first.
|
|
64
59
|
if (ws) {
|
|
65
60
|
ws.close()
|
|
66
61
|
}
|
|
67
62
|
ws = new WebSocket(serverUrl)
|
|
68
63
|
|
|
69
|
-
// =====================================================================
|
|
70
|
-
// WebSocket Event Handlers
|
|
71
|
-
// =====================================================================
|
|
72
|
-
|
|
73
64
|
ws.onopen = () => {
|
|
74
|
-
// Send any queued events to the server.
|
|
75
65
|
while (localQueue.length) {
|
|
76
66
|
ws.send(serialize(localQueue.shift()))
|
|
77
67
|
}
|
|
@@ -81,22 +71,19 @@ export function multiplayerMiddleware(config = {}) {
|
|
|
81
71
|
const serverEvent = deserialize(event.data)
|
|
82
72
|
|
|
83
73
|
if (serverEvent.type === "stateInit") {
|
|
84
|
-
// Merge the server's initial state with the client's local state.
|
|
85
74
|
const nextState = extend(store.getState(), serverEvent.payload)
|
|
86
75
|
store.setState(nextState)
|
|
87
76
|
} else {
|
|
88
|
-
// Dispatch the event to the local store to update the client's state.
|
|
89
77
|
store.dispatch({ ...serverEvent, fromServer: true })
|
|
90
78
|
}
|
|
91
79
|
}
|
|
92
80
|
|
|
93
81
|
ws.onclose = () => {
|
|
94
|
-
// Attempt to reconnect after a short delay.
|
|
95
82
|
setTimeout(() => establishConnection(store), reconnectionDelay)
|
|
96
83
|
}
|
|
97
84
|
|
|
98
85
|
ws.onerror = () => {
|
|
99
|
-
ws.close()
|
|
86
|
+
ws.close()
|
|
100
87
|
}
|
|
101
88
|
}
|
|
102
89
|
}
|
package/src/event-map.js
CHANGED
|
@@ -8,6 +8,10 @@ const SPLIT_LIMIT = 2
|
|
|
8
8
|
/**
|
|
9
9
|
* A class to manage the mapping of event names to the entity IDs that handle them.
|
|
10
10
|
* This is used for optimized event handling with support for scoped events.
|
|
11
|
+
*
|
|
12
|
+
* Do not replace it with a scan of all entities: every event would then visit
|
|
13
|
+
* every entity, and a game dispatching dozens of events a frame over thousands
|
|
14
|
+
* of entities cannot afford that (see the comparison in event-map.test.js).
|
|
11
15
|
*/
|
|
12
16
|
export class EventMap {
|
|
13
17
|
/**
|
|
@@ -118,13 +122,11 @@ export class EventMap {
|
|
|
118
122
|
return entitySet && entitySet.has(targetEntityId) ? [targetEntityId] : []
|
|
119
123
|
}
|
|
120
124
|
|
|
121
|
-
// form:submit - all entities of this type
|
|
122
125
|
if (targetType) {
|
|
123
126
|
const entitySet = typeMap.get(targetType)
|
|
124
127
|
return entitySet ? Array.from(entitySet) : []
|
|
125
128
|
}
|
|
126
129
|
|
|
127
|
-
// submit - all entities with this handler (broadcast)
|
|
128
130
|
const allEntities = []
|
|
129
131
|
for (const entitySet of typeMap.values()) {
|
|
130
132
|
allEntities.push(...entitySet)
|
package/src/migration/rtk.js
CHANGED
|
@@ -76,8 +76,6 @@ export function convertAsyncThunk(name, payloadCreator, options = {}) {
|
|
|
76
76
|
: undefined,
|
|
77
77
|
|
|
78
78
|
run: async (payload, api) => {
|
|
79
|
-
// RTK payloadCreator signature: (arg, thunkAPI)
|
|
80
|
-
// Use dispatch for action compatibility, with notify fallback in tests/minimal APIs.
|
|
81
79
|
const dispatch = api.dispatch
|
|
82
80
|
? api.dispatch.bind(api)
|
|
83
81
|
: (action) => {
|
|
@@ -170,15 +168,12 @@ export function convertSlice(slice, options = {}) {
|
|
|
170
168
|
const { asyncThunks = {}, extraHandlers = {}, extraActions = [] } = options
|
|
171
169
|
|
|
172
170
|
const type = {
|
|
173
|
-
// Convert initialState to create handler.
|
|
174
|
-
// Preserve explicitly preloaded fields from entities (common in tests/SSR hydration).
|
|
175
171
|
create(entity) {
|
|
176
172
|
const initialState = slice.getInitialState()
|
|
177
173
|
Object.assign(entity, extend(initialState, entity))
|
|
178
174
|
},
|
|
179
175
|
}
|
|
180
176
|
|
|
181
|
-
// Convert each reducer to an event handler
|
|
182
177
|
for (const [actionName, reducer] of Object.entries(slice.caseReducers)) {
|
|
183
178
|
const thunkConfig = asyncThunks[actionName]
|
|
184
179
|
const isAsyncThunkReducer =
|
|
@@ -249,22 +244,15 @@ export function convertSlice(slice, options = {}) {
|
|
|
249
244
|
}
|
|
250
245
|
|
|
251
246
|
const handler = (entity, payload) => {
|
|
252
|
-
// Create a mock action object for the RTK reducer
|
|
253
247
|
const action = { type: `${slice.name}/${actionName}`, payload }
|
|
254
248
|
|
|
255
|
-
// RTK reducers expect to mutate state directly (via Immer)
|
|
256
|
-
// Inglorious handlers do the same (via Mutative)
|
|
257
|
-
// So we can call the reducer directly on the entity
|
|
258
249
|
reducer(entity, action)
|
|
259
250
|
}
|
|
260
251
|
|
|
261
|
-
// Expose both Inglorious-style event names and RTK action-type aliases.
|
|
262
|
-
// This enables direct dispatch of RTK slice actions without extra bridge wiring.
|
|
263
252
|
type[actionName] = handler
|
|
264
253
|
type[`${slice.name}/${actionName}`] = handler
|
|
265
254
|
}
|
|
266
255
|
|
|
267
|
-
// Convert explicitly configured async thunks that are not embedded in slice reducers.
|
|
268
256
|
for (const [thunkName, thunkHandlers] of Object.entries(asyncThunks)) {
|
|
269
257
|
if (thunkHandlers?.payloadCreator && !type[thunkName]) {
|
|
270
258
|
const asyncHandlers = convertAsyncThunk(
|
|
@@ -283,9 +271,6 @@ export function convertSlice(slice, options = {}) {
|
|
|
283
271
|
}
|
|
284
272
|
}
|
|
285
273
|
|
|
286
|
-
// Register additional RTK action types (e.g. createAction + extraReducers).
|
|
287
|
-
// RTK does not expose extraReducers through slice.caseReducers, so callers can
|
|
288
|
-
// provide the action creators/types explicitly and we route them through slice.reducer.
|
|
289
274
|
for (const action of extraActions) {
|
|
290
275
|
const actionType = typeof action === "string" ? action : action?.type
|
|
291
276
|
if (typeof actionType !== "string" || type[actionType]) continue
|
|
@@ -295,7 +280,6 @@ export function convertSlice(slice, options = {}) {
|
|
|
295
280
|
}
|
|
296
281
|
}
|
|
297
282
|
|
|
298
|
-
// Add any extra handlers
|
|
299
283
|
Object.assign(type, extraHandlers)
|
|
300
284
|
|
|
301
285
|
return type
|
|
@@ -330,7 +314,6 @@ export function createMigrationGuide(slice) {
|
|
|
330
314
|
lines.push("=".repeat(SEP_LENGTH))
|
|
331
315
|
lines.push("")
|
|
332
316
|
|
|
333
|
-
// Show state structure
|
|
334
317
|
lines.push("STATE STRUCTURE:")
|
|
335
318
|
lines.push("RTK:")
|
|
336
319
|
lines.push(
|
|
@@ -350,7 +333,6 @@ export function createMigrationGuide(slice) {
|
|
|
350
333
|
lines.push("=".repeat(SEP_LENGTH))
|
|
351
334
|
lines.push("")
|
|
352
335
|
|
|
353
|
-
// Show each reducer conversion
|
|
354
336
|
lines.push("ACTIONS / EVENTS:")
|
|
355
337
|
for (const actionName of Object.keys(slice.caseReducers)) {
|
|
356
338
|
lines.push("")
|
|
@@ -365,7 +347,6 @@ export function createMigrationGuide(slice) {
|
|
|
365
347
|
lines.push("=".repeat(SEP_LENGTH))
|
|
366
348
|
lines.push("")
|
|
367
349
|
|
|
368
|
-
// Show selector conversion
|
|
369
350
|
lines.push("SELECTORS:")
|
|
370
351
|
lines.push("RTK:")
|
|
371
352
|
lines.push(
|
|
@@ -403,22 +384,17 @@ export function createMigrationGuide(slice) {
|
|
|
403
384
|
export function createRTKCompatDispatch(api, entityId) {
|
|
404
385
|
return (action) => {
|
|
405
386
|
if (typeof action === "function") {
|
|
406
|
-
// Thunk - not supported in compat mode
|
|
407
387
|
console.warn("Thunks are not supported in RTK compat mode")
|
|
408
388
|
return
|
|
409
389
|
}
|
|
410
390
|
|
|
411
|
-
// Extract action type and payload
|
|
412
391
|
const { type, payload } = action
|
|
413
392
|
|
|
414
|
-
// Convert RTK action type to Inglorious event
|
|
415
|
-
// RTK: 'todos/addTodo' -> Inglorious: '#todos:addTodo'
|
|
416
393
|
const parts = type.split("/")
|
|
417
394
|
if (parts.length === SLICE_PLUS_ACTION) {
|
|
418
395
|
const [, actionName] = parts
|
|
419
396
|
api.notify(`#${entityId}:${actionName}`, payload)
|
|
420
397
|
} else {
|
|
421
|
-
// Fallback for non-standard action types
|
|
422
398
|
api.notify(`#${entityId}:${type}`, payload)
|
|
423
399
|
}
|
|
424
400
|
}
|
package/src/store.js
CHANGED
|
@@ -72,7 +72,7 @@ export function createStore({
|
|
|
72
72
|
subscribe,
|
|
73
73
|
update,
|
|
74
74
|
notify,
|
|
75
|
-
dispatch,
|
|
75
|
+
dispatch,
|
|
76
76
|
getTypes,
|
|
77
77
|
getType,
|
|
78
78
|
setType,
|
|
@@ -119,10 +119,6 @@ export function createStore({
|
|
|
119
119
|
return []
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// Nothing queued means nothing to apply and nothing to publish: skipping the draft
|
|
123
|
-
// entirely, rather than copying the world just to arrive back where it started.
|
|
124
|
-
// This is what keeps a loop that pumps every animation frame cheap on the frames
|
|
125
|
-
// where neither the game nor the player had anything to say.
|
|
126
122
|
if (!incomingEvents.length) {
|
|
127
123
|
return []
|
|
128
124
|
}
|
|
@@ -172,9 +168,6 @@ export function createStore({
|
|
|
172
168
|
const event = incomingEvents.shift()
|
|
173
169
|
processedEvents.push(event)
|
|
174
170
|
|
|
175
|
-
// Pausing and resuming are the store's own, and they keep dispatching like any
|
|
176
|
-
// other event, so the game behaviour still sees them and anything else that
|
|
177
|
-
// wants to react gets the same notification.
|
|
178
171
|
if (event.type === "pause") {
|
|
179
172
|
isHalted = true
|
|
180
173
|
}
|
|
@@ -183,24 +176,6 @@ export function createStore({
|
|
|
183
176
|
isHalted = false
|
|
184
177
|
}
|
|
185
178
|
|
|
186
|
-
// Handle special system events
|
|
187
|
-
// `add` and `remove` change the world and are then dispatched like any
|
|
188
|
-
// other event, so that whatever is watching can hear about it. They are
|
|
189
|
-
// broadcast: the entity named by the payload is not the only one that wants
|
|
190
|
-
// to know, and something counting down what it put up needs to hear about
|
|
191
|
-
// each of them going.
|
|
192
|
-
//
|
|
193
|
-
// They are not the counterpart to `create` and `destroy`, which reach only
|
|
194
|
-
// the entity they are about so that those read as a constructor and a
|
|
195
|
-
// destructor without every handler beginning by checking whether it is the
|
|
196
|
-
// one being talked about.
|
|
197
|
-
//
|
|
198
|
-
// The two carry different payloads, and that is on purpose: a payload
|
|
199
|
-
// carries the least that can be had. A removal needs only the id, because
|
|
200
|
-
// everything else about the entity can still be looked up by it. An
|
|
201
|
-
// addition carries the entity itself, because there is nothing yet to look
|
|
202
|
-
// it up by -- the same asymmetry as removing a user by id but updating one
|
|
203
|
-
// by the patched user.
|
|
204
179
|
if (event.type === "add") {
|
|
205
180
|
addEntity(draft, event.payload)
|
|
206
181
|
}
|
|
@@ -209,21 +184,6 @@ export function createStore({
|
|
|
209
184
|
removeEntity(draft, event.payload)
|
|
210
185
|
}
|
|
211
186
|
|
|
212
|
-
// A thing that stands through a change of state can still be told a different
|
|
213
|
-
// configuration, in either of the two ways the world offers.
|
|
214
|
-
//
|
|
215
|
-
// `patch` changes some of it and leaves the rest alone, which is how a screen
|
|
216
|
-
// takes over one field of something it shares with the screen before it. A field
|
|
217
|
-
// named as `undefined` is set to nothing rather than being left alone, which is
|
|
218
|
-
// how a key is taken off a mapping.
|
|
219
|
-
//
|
|
220
|
-
// `replace` installs what it is given and drops everything it does not mention,
|
|
221
|
-
// which is how the whole of something is said to be different.
|
|
222
|
-
//
|
|
223
|
-
// Neither takes the thing out of the world, so neither runs `create` or `destroy`:
|
|
224
|
-
// it never stopped standing, and a thing that was never removed cannot have been
|
|
225
|
-
// destroyed. This is the same pair the world has to hand for anything else --
|
|
226
|
-
// `add` and `remove` beside them, and post/delete/put/patch before that.
|
|
227
187
|
if (event.type === "patch") {
|
|
228
188
|
patchEntity(draft, event.payload)
|
|
229
189
|
}
|
|
@@ -232,10 +192,8 @@ export function createStore({
|
|
|
232
192
|
replaceEntity(draft, event.payload)
|
|
233
193
|
}
|
|
234
194
|
|
|
235
|
-
// Parse the event to get handler name
|
|
236
195
|
const { event: handlerName } = parseEvent(event.type)
|
|
237
196
|
|
|
238
|
-
// Get entities that should handle this event (filtered by EventMap)
|
|
239
197
|
const entityIds = eventMap.getEntitiesForEvent(event.type)
|
|
240
198
|
|
|
241
199
|
// A halted world stops handing out updates, because anything that moves by
|
|
@@ -248,8 +206,6 @@ export function createStore({
|
|
|
248
206
|
const halted = isHalted && handlerName === "update"
|
|
249
207
|
|
|
250
208
|
for (const id of entityIds) {
|
|
251
|
-
// Looked at before it is taken, because an entity the pause holds back is
|
|
252
|
-
// not going to be written to, and taking it would copy it for nothing.
|
|
253
209
|
const entity = draft.entities[id]
|
|
254
210
|
const type = types[entity.type]
|
|
255
211
|
|
|
@@ -273,7 +229,6 @@ export function createStore({
|
|
|
273
229
|
}
|
|
274
230
|
}
|
|
275
231
|
|
|
276
|
-
// Systems process events by handler name (not scoped)
|
|
277
232
|
systems.forEach((system) => {
|
|
278
233
|
const handle = system[handlerName]
|
|
279
234
|
handle?.(draft.forSystems, event.payload, api)
|
|
@@ -338,7 +293,8 @@ export function createStore({
|
|
|
338
293
|
* Sets an augmented type configuration given its name. Replacing a type, as
|
|
339
294
|
* hot module reloading does, notifies subscribers, since what its entities
|
|
340
295
|
* render may have changed. Adding a type does not: the store is unchanged,
|
|
341
|
-
* and a type can be added while rendering.
|
|
296
|
+
* and a type can be added while rendering. Entities already of a type being
|
|
297
|
+
* added get their `create` now: when they were added, it reached no handler.
|
|
342
298
|
* @param {string} typeName - The name of the type to set.
|
|
343
299
|
* @param {Type} type - The type configuration.
|
|
344
300
|
* @returns {void}
|
|
@@ -354,6 +310,7 @@ export function createStore({
|
|
|
354
310
|
if (entity.type === typeName) {
|
|
355
311
|
eventMap.removeEntity(id, oldType, typeName)
|
|
356
312
|
eventMap.addEntity(id, newType, typeName)
|
|
313
|
+
if (!oldType) notify(`#${id}:create`)
|
|
357
314
|
}
|
|
358
315
|
}
|
|
359
316
|
|
|
@@ -516,9 +473,6 @@ export function createStore({
|
|
|
516
473
|
// does not mention, and what it keeps must not be shared with the previous state.
|
|
517
474
|
const existing = draft.take(id)
|
|
518
475
|
|
|
519
|
-
// Configuring something that is not there is not a thing that can be done. The world
|
|
520
|
-
// is built in one pass, so a scene naming a thing another scene is yet to put up is
|
|
521
|
-
// ordinary rather than a mistake, and is not worth failing over.
|
|
522
476
|
if (!existing) return
|
|
523
477
|
|
|
524
478
|
draft.put(id, augmentEntity(id, { ...existing, ...config }))
|
package/types/api.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface Api<
|
|
|
36
36
|
select: <TResult>(selector: (state: TState) => TResult) => TResult
|
|
37
37
|
dispatch: (event: Event) => void
|
|
38
38
|
notify: (type: string, payload?: any) => void
|
|
39
|
-
[key: string]: any
|
|
39
|
+
[key: string]: any
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
@@ -73,6 +73,9 @@ export interface AsyncHandlers<
|
|
|
73
73
|
* ```
|
|
74
74
|
*
|
|
75
75
|
* The `scope` option changes where the events are sent, not their names.
|
|
76
|
+
*
|
|
77
|
+
* Each call gets a `callId` that all its events carry, so that a wrapper such
|
|
78
|
+
* as `optimistic` can tell overlapping calls apart.
|
|
76
79
|
*/
|
|
77
80
|
export type AsyncEvents<
|
|
78
81
|
TType extends string,
|
|
@@ -85,27 +88,38 @@ export type AsyncEvents<
|
|
|
85
88
|
} & {
|
|
86
89
|
[K in `${TType}Start`]?: (
|
|
87
90
|
entity: TEntity,
|
|
88
|
-
payload: TPayload
|
|
91
|
+
event: AsyncCall<{ payload: TPayload }>,
|
|
89
92
|
api: TApi,
|
|
90
93
|
) => void
|
|
91
94
|
} & {
|
|
92
95
|
[K in `${TType}Run`]: (
|
|
93
96
|
entity: TEntity,
|
|
94
|
-
payload: TPayload
|
|
97
|
+
event: AsyncCall<{ payload: TPayload }>,
|
|
95
98
|
api: TApi,
|
|
96
99
|
) => Promise<void>
|
|
97
100
|
} & {
|
|
98
101
|
[K in `${TType}Success`]: (
|
|
99
102
|
entity: TEntity,
|
|
100
|
-
result: TResult
|
|
103
|
+
event: AsyncCall<{ result: TResult }>,
|
|
101
104
|
api: TApi,
|
|
102
105
|
) => void
|
|
103
106
|
} & {
|
|
104
|
-
[K in `${TType}Error`]: (
|
|
107
|
+
[K in `${TType}Error`]: (
|
|
108
|
+
entity: TEntity,
|
|
109
|
+
event: AsyncCall<{ error: any }>,
|
|
110
|
+
api: TApi,
|
|
111
|
+
) => void
|
|
105
112
|
} & {
|
|
106
|
-
[K in `${TType}Finally`]: (
|
|
113
|
+
[K in `${TType}Finally`]: (
|
|
114
|
+
entity: TEntity,
|
|
115
|
+
event: AsyncCall,
|
|
116
|
+
api: TApi,
|
|
117
|
+
) => void
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
/** What an event of one call of an async behavior carries: its `callId`. */
|
|
121
|
+
export type AsyncCall<T = {}> = T & { callId: number }
|
|
122
|
+
|
|
109
123
|
/**
|
|
110
124
|
* Creates a set of event handlers for managing an asynchronous operation lifecycle.
|
|
111
125
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Api } from "../api"
|
|
2
|
-
import { AsyncEvents } from "./async"
|
|
2
|
+
import { AsyncCall, AsyncEvents } from "./async"
|
|
3
3
|
import { BaseEntity } from "../store"
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -9,6 +9,10 @@ import { BaseEntity } from "../store"
|
|
|
9
9
|
* The wrapper snapshots the current values for those keys, applies the patch
|
|
10
10
|
* on start, and restores the previous values automatically if the operation fails.
|
|
11
11
|
*
|
|
12
|
+
* Each call keeps its own snapshot, so calls can overlap. Give `rollback` to
|
|
13
|
+
* undo a failed call's change yourself, such as removing the temporary item it
|
|
14
|
+
* added to a list, in place of restoring the snapshot.
|
|
15
|
+
*
|
|
12
16
|
* The behavior keeps the events `handleAsync` named and typed, and always has
|
|
13
17
|
* a start event.
|
|
14
18
|
*/
|
|
@@ -27,10 +31,13 @@ export function optimistic<
|
|
|
27
31
|
payload: TPayload,
|
|
28
32
|
api: TApi,
|
|
29
33
|
) => Record<string, any> | null | undefined),
|
|
34
|
+
options?: {
|
|
35
|
+
rollback?: (entity: TEntity, payload: TPayload, api: TApi) => void
|
|
36
|
+
},
|
|
30
37
|
): AsyncEvents<TType, TEntity, TPayload, TResult, TApi> & {
|
|
31
38
|
[K in `${TType}Start`]: (
|
|
32
39
|
entity: TEntity,
|
|
33
|
-
payload: TPayload
|
|
40
|
+
event: AsyncCall<{ payload: TPayload }>,
|
|
34
41
|
api: TApi,
|
|
35
42
|
) => void
|
|
36
43
|
}
|
package/types/migration/rtk.d.ts
CHANGED