@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 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", todos)
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.1.0",
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": ">= 22"
93
+ "node": ">= 26"
94
94
  },
95
95
  "scripts": {
96
96
  "format": "prettier --write .",
@@ -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
- const snapshots = new Map()
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
- notify(api, entity, startEvent, payload)
79
- notify(api, entity, runEvent, payload)
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, payload, api) {
114
+ [startEvent](entity, event, api) {
115
+ const { payload, callId } = event
83
116
  const patch = toPatch(optimistic, entity, payload, api)
84
117
 
85
- if (!patch) {
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
- snapshots.set(key, snapshotPatch(currentEntity, patch))
99
- Object.assign(entity, patch)
100
-
101
- behavior[startEvent]?.(entity, payload, api)
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, error, api) {
107
- const key = getEntityKey(entity)
108
- restorePatch(entity, snapshots.get(key))
109
- behavior[errorEvent]?.(entity, error, api)
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, payload, api) {
156
+ [finallyEvent](entity, event, api) {
113
157
  try {
114
- behavior[finallyEvent]?.(entity, payload, api)
158
+ behavior[finallyEvent]?.(entity, event, api)
115
159
  } finally {
116
- snapshots.delete(getEntityKey(entity))
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
- const entries = []
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.prototype.hasOwnProperty.call(entity, key),
177
- entity[key],
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, entries) {
185
- if (!entries) {
186
- return
187
- }
188
-
189
- for (const [key, existed, value] of entries) {
190
- if (existed) {
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
 
@@ -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, // start/pause recording of dispatched actions
121
- lock: true, // lock/unlock dispatching actions and side effects
122
- persist: true, // persist states on page reloading
123
- export: true, // export history of actions in a file
124
- import: "custom", // import history of actions from a file
125
- jump: false, // jump back and forth (time travelling)
126
- skip: false, // skip (cancel) actions
127
- reorder: false, // drag and drop actions in the history list
128
- dispatch: true, // dispatch custom actions or action creators
129
- test: false, // generate tests for the selected actions
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() // The 'onclose' handler will trigger the reconnect.
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)
@@ -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, // needed for compatibility with Redux
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 // For middleware extras
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`]: (entity: TEntity, error: any, api: TApi) => void
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`]: (entity: TEntity, payload: void, api: TApi) => void
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
  }
@@ -6,8 +6,6 @@ import { Api, BaseEntity } from "../store"
6
6
  */
7
7
  export interface ThunkAPI {
8
8
  dispatch: Api["notify"]
9
- // Add other thunk API properties as needed
10
- // getState, extra, requestId, signal, rejectWithValue, fulfillWithValue
11
9
  }
12
10
 
13
11
  /**