@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 +74 -2
- 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 +20 -50
- package/types/api.d.ts +10 -4
- package/types/behaviors/async.d.ts +19 -5
- package/types/behaviors/optimistic.d.ts +9 -2
- package/types/events.d.ts +149 -0
- package/types/index.d.ts +1 -0
- package/types/migration/rtk.d.ts +0 -2
- package/types/store.d.ts +78 -6
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
|
|
@@ -1632,7 +1659,52 @@ interface SalesEntity extends ChartEntity {
|
|
|
1632
1659
|
// types: { Router, Sales: Chart }
|
|
1633
1660
|
```
|
|
1634
1661
|
|
|
1635
|
-
**5.
|
|
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.
|
|
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.
|
|
81
|
+
"@inglorious/utils": "^5.2.0"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@inglorious/eslint-config": "1.2.0",
|
|
85
|
-
"@inglorious/utils": "5.
|
|
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": ">=
|
|
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)
|