@inglorious/store 12.1.1 → 12.3.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
@@ -1632,7 +1659,52 @@ interface SalesEntity extends ChartEntity {
1632
1659
  // types: { Router, Sales: Chart }
1633
1660
  ```
1634
1661
 
1635
- **5. Test your handlers**
1662
+ **5. Check the events you send**
1663
+
1664
+ The type declarations already list every event and its payload, so the api can check what `notify` sends. Read the events from them with `EventsOf`, and give them to the api as its third type argument:
1665
+
1666
+ ```typescript
1667
+ // types/index.d.ts
1668
+ import type { EventsOf } from "@inglorious/store"
1669
+
1670
+ export type AppEvents = EventsOf<CounterType | LabelType>
1671
+ // { increment: void; add: number; copyLabel: void; ...the label's events }
1672
+
1673
+ export type AppApi = Api<AppEntity, AppState, AppEvents>
1674
+ ```
1675
+
1676
+ Now every handler's `api.notify` checks the event's name, wherever it is sent (`"add"`, `"Counter:add"`, `` `#${entity.id}:add` ``), and its payload:
1677
+
1678
+ ```typescript
1679
+ api.notify(`#${entity.id}:add`, 2) // fine
1680
+ api.notify(`#${entity.id}:ad`, 2) // error: no type answers `ad`
1681
+ api.notify(`#${entity.id}:add`, "2") // error: `add` takes a number
1682
+ api.notify(`#${entity.id}:increment`, 1) // error: `increment` takes no payload
1683
+ ```
1684
+
1685
+ The store's own events (`add`, `remove`, `patch`, `replace`, `pause`, `resume`, `update`) are always there. Only the event's name is checked, not the target before it, because the target is usually built at runtime. Pass the events to `createStore<AppEntity, AppState, AppEvents>` too, so that `store.notify` checks them, and `mount` gives them to the root render. A typed api is still an `Api`: components that take any app's api accept it.
1686
+
1687
+ Without the third type argument, `notify` takes any name and any payload, as before.
1688
+
1689
+ **6. Give sub-renderers their props**
1690
+
1691
+ Every render method takes props first: `render(props, api)`, where a type's props are its entity, and sub-renderers that draw one item, `renderX(props, ownProps, api)`, which get the component's props, then the item's own (`renderLine(props, line, api)`). `EntityType` reads every member as a handler, whose first parameter is the entity, so it rejects sub-renderers whose props are not the entity. `defineType` checks each member by its name instead: `render` takes the entity as its props, a sub-renderer keeps the types it declares, and every other member is a handler, whose entity and api are inferred:
1692
+
1693
+ ```typescript
1694
+ import { defineType } from "@inglorious/store"
1695
+
1696
+ export const Scoreboard = defineType<ScoreboardEntity, AppApi>()({
1697
+ ...template, // renderScoreboard(props, api), renderContender(props, speaker, api), ...
1698
+ render(props, api) {
1699
+ const debate = api.getEntity("debate")
1700
+ return this.renderScoreboard({ ...props, memory: debate?.memory }, api)
1701
+ },
1702
+ })
1703
+ ```
1704
+
1705
+ Inside its methods, `this` is the behavior, so `this.renderScoreboard` is checked too. It takes the entity and the api first, then the behavior, because TypeScript cannot infer one while being told the others. The result is an `EntityType`, so it goes in `types` or in a list of behaviors like any other: `[Chat, handlers, fsm(states), defineType<DebateEntity, AppApi>()({ ...template, render })]`.
1706
+
1707
+ **7. Test your handlers**
1636
1708
 
1637
1709
  ```typescript
1638
1710
  import { trigger } from "@inglorious/store/test"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "12.1.1",
3
+ "version": "12.3.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",
@@ -78,11 +78,11 @@
78
78
  "mutative": "^1.3.0"
79
79
  },
80
80
  "peerDependencies": {
81
- "@inglorious/utils": "^5.1.0"
81
+ "@inglorious/utils": "^5.2.0"
82
82
  },
83
83
  "devDependencies": {
84
84
  "@inglorious/eslint-config": "1.2.0",
85
- "@inglorious/utils": "5.1.0",
85
+ "@inglorious/utils": "5.2.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)