@inglorious/store 11.3.0 → 12.1.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 +35 -15
- package/package.json +4 -4
- package/src/client/devtools.js +5 -1
- package/src/client/multiplayer-middleware.js +23 -13
- package/src/middlewares.js +13 -34
- package/src/store.js +124 -36
- package/types/store.d.ts +8 -6
package/README.md
CHANGED
|
@@ -1239,7 +1239,7 @@ const store = createStore({
|
|
|
1239
1239
|
systems, // Array (optional): global state handlers
|
|
1240
1240
|
autoCreateEntities, // Boolean (optional): false (default) or true
|
|
1241
1241
|
updateMode, // String (optional): 'auto' (default) or 'manual'
|
|
1242
|
-
updateStrategy, // String (optional): 'structural-sharing' (default) or '
|
|
1242
|
+
updateStrategy, // String (optional): 'structural-sharing' (default) or 'copy-on-dispatch'
|
|
1243
1243
|
})
|
|
1244
1244
|
```
|
|
1245
1245
|
|
|
@@ -1258,7 +1258,7 @@ const store = createStore({
|
|
|
1258
1258
|
- `'manual'` - Manual control via `api.update()`
|
|
1259
1259
|
- **`updateStrategy`** (optional) - Controls how the state is copied before queued events are applied:
|
|
1260
1260
|
- `'structural-sharing'` (default) - Only the entities that actually changed are copied
|
|
1261
|
-
- `'
|
|
1261
|
+
- `'copy-on-dispatch'` - Each entity is deep-copied as it is handed out, and updated without a proxy
|
|
1262
1262
|
|
|
1263
1263
|
#### Update Strategy
|
|
1264
1264
|
|
|
@@ -1271,10 +1271,26 @@ get copied. Untouched entities keep their previous object reference, which makes
|
|
|
1271
1271
|
reference-equality change detection nearly free. This is what you want for UI stores, where a
|
|
1272
1272
|
handful of entities change per interaction.
|
|
1273
1273
|
|
|
1274
|
-
**`'
|
|
1275
|
-
the copy, with no proxy involved.
|
|
1276
|
-
|
|
1277
|
-
|
|
1274
|
+
**`'copy-on-dispatch'`** deep-copies an entity the first time it is handed to one of its handlers, or read
|
|
1275
|
+
by a system, and applies events directly to the copy, with no proxy involved. Entities that nothing
|
|
1276
|
+
touches during an update are not copied and keep their previous reference. Writing to a copy costs
|
|
1277
|
+
nothing however often it happens, so it scales much better when thousands of entities change every
|
|
1278
|
+
frame — the typical case for a game simulation.
|
|
1279
|
+
|
|
1280
|
+
Systems receive every entity at once, so each entity a system reads through its first argument is
|
|
1281
|
+
copied in case it is written to. A system that only needs to know how things stood should read
|
|
1282
|
+
them from `api.getEntities()` and go through its first argument only for what it writes:
|
|
1283
|
+
|
|
1284
|
+
```javascript
|
|
1285
|
+
const readouts = {
|
|
1286
|
+
update(entities, dt, api) {
|
|
1287
|
+
const current = api.getEntities() // read without copying
|
|
1288
|
+
const count = Object.keys(current).length
|
|
1289
|
+
|
|
1290
|
+
entities.readout.value = `${count} entities` // copies only the readout
|
|
1291
|
+
},
|
|
1292
|
+
}
|
|
1293
|
+
```
|
|
1278
1294
|
|
|
1279
1295
|
```javascript
|
|
1280
1296
|
// A UI store: few entities change, reference equality matters
|
|
@@ -1285,7 +1301,11 @@ const store = createStore({
|
|
|
1285
1301
|
})
|
|
1286
1302
|
|
|
1287
1303
|
// A game simulation: thousands of entities change every frame
|
|
1288
|
-
const store = createStore({
|
|
1304
|
+
const store = createStore({
|
|
1305
|
+
types,
|
|
1306
|
+
entities,
|
|
1307
|
+
updateStrategy: "copy-on-dispatch",
|
|
1308
|
+
})
|
|
1289
1309
|
```
|
|
1290
1310
|
|
|
1291
1311
|
#### State swapping
|
|
@@ -1311,23 +1331,23 @@ const types = {
|
|
|
1311
1331
|
}
|
|
1312
1332
|
```
|
|
1313
1333
|
|
|
1314
|
-
**Caveats for `'
|
|
1334
|
+
**Caveats for `'copy-on-dispatch'`:**
|
|
1315
1335
|
|
|
1316
|
-
-
|
|
1317
|
-
|
|
1318
|
-
`DataCloneError`. Keep such values in module scope or in a closure inside a behavior factory
|
|
1336
|
+
- Plain objects, arrays, vectors and primitives are copied directly. Anything else must be
|
|
1337
|
+
[structured-cloneable](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm):
|
|
1338
|
+
DOM nodes and class instances with non-cloneable internals throw a `DataCloneError`. Keep such values in module scope or in a closure inside a behavior factory
|
|
1319
1339
|
(as the `images()` and `audio()` engine behaviors do) rather than in the state.
|
|
1320
1340
|
- The state is not frozen, even in `devMode`.
|
|
1321
1341
|
- Writing to an entity obtained from `api.getEntity()` during an update mutates the _previous_
|
|
1322
|
-
state
|
|
1323
|
-
|
|
1324
|
-
|
|
1342
|
+
state. Whether the write also survives depends on whether that entity has been copied yet, under
|
|
1343
|
+
either strategy. Always mutate the entity passed to your handler rather than one fetched through
|
|
1344
|
+
the api.
|
|
1325
1345
|
|
|
1326
1346
|
An unsupported strategy throws a `TypeError` at store creation:
|
|
1327
1347
|
|
|
1328
1348
|
```javascript
|
|
1329
1349
|
createStore({ types, entities, updateStrategy: "immutable" })
|
|
1330
|
-
// TypeError: Unsupported update strategy: immutable. Expected one of: structural-sharing,
|
|
1350
|
+
// TypeError: Unsupported update strategy: immutable. Expected one of: structural-sharing, copy-on-dispatch.
|
|
1331
1351
|
```
|
|
1332
1352
|
|
|
1333
1353
|
#### Auto-Create Entities
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/store",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.1.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.
|
|
78
|
+
"@inglorious/utils": "5.1.0",
|
|
79
79
|
"mutative": "^1.3.0"
|
|
80
80
|
},
|
|
81
81
|
"peerDependencies": {
|
|
82
|
-
"@inglorious/utils": "5.
|
|
82
|
+
"@inglorious/utils": "5.1.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
|
-
"@inglorious/eslint-config": "1.
|
|
85
|
+
"@inglorious/eslint-config": "1.2.0",
|
|
86
86
|
"@reduxjs/toolkit": "^2.12.0",
|
|
87
87
|
"prettier": "^3.9.6",
|
|
88
88
|
"typescript": "~7.0.2",
|
package/src/client/devtools.js
CHANGED
|
@@ -33,13 +33,17 @@ export function createDevtools(config = {}) {
|
|
|
33
33
|
|
|
34
34
|
middleware(store) {
|
|
35
35
|
connectDevTools(store, config)
|
|
36
|
-
|
|
36
|
+
|
|
37
|
+
const next = store.dispatch
|
|
38
|
+
store.dispatch = (event) => {
|
|
37
39
|
const result = next(event)
|
|
38
40
|
if (shouldLogEvent(event, config)) {
|
|
39
41
|
sendAction(event, store.getState())
|
|
40
42
|
}
|
|
41
43
|
return result
|
|
42
44
|
}
|
|
45
|
+
|
|
46
|
+
return store
|
|
43
47
|
},
|
|
44
48
|
}
|
|
45
49
|
}
|
|
@@ -2,18 +2,6 @@ import { deserialize, extend, serialize } from "@inglorious/utils/object.js"
|
|
|
2
2
|
|
|
3
3
|
const DEFAULT_RECONNECTION_DELAY = 1000
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Where the server is, when the caller has not said.
|
|
7
|
-
*
|
|
8
|
-
* Read when it is asked for rather than when this module is imported, so that importing it
|
|
9
|
-
* does not require a browser -- which is what lets the store be imported anywhere.
|
|
10
|
-
*
|
|
11
|
-
* @returns {string} The server's WebSocket URL.
|
|
12
|
-
*/
|
|
13
|
-
function defaultServerUrl() {
|
|
14
|
-
return `ws://${window.location.hostname}:3000`
|
|
15
|
-
}
|
|
16
|
-
|
|
17
5
|
/**
|
|
18
6
|
* Creates and returns the multiplayer middleware.
|
|
19
7
|
* @returns {Function} The middleware function.
|
|
@@ -31,7 +19,17 @@ export function multiplayerMiddleware(config = {}) {
|
|
|
31
19
|
const localQueue = []
|
|
32
20
|
|
|
33
21
|
// The middleware function that will be applied to the store.
|
|
34
|
-
return (store) =>
|
|
22
|
+
return (store) => {
|
|
23
|
+
const next = store.dispatch
|
|
24
|
+
store.dispatch = (event) => send(store, event, next)
|
|
25
|
+
return store
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sends an event to the server unless it is filtered out or came from there, then
|
|
30
|
+
* passes it on to the next middleware, and eventually to the store.
|
|
31
|
+
*/
|
|
32
|
+
function send(store, event, next) {
|
|
35
33
|
const shouldBeSent =
|
|
36
34
|
(!blacklist.length || !blacklist.includes(event.type)) &&
|
|
37
35
|
(!whitelist.length || whitelist.includes(event.type)) &&
|
|
@@ -102,3 +100,15 @@ export function multiplayerMiddleware(config = {}) {
|
|
|
102
100
|
}
|
|
103
101
|
}
|
|
104
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Where the server is, when the caller has not said.
|
|
106
|
+
*
|
|
107
|
+
* Read when it is asked for rather than when this module is imported, so that importing it
|
|
108
|
+
* does not require a browser -- which is what lets the store be imported anywhere.
|
|
109
|
+
*
|
|
110
|
+
* @returns {string} The server's WebSocket URL.
|
|
111
|
+
*/
|
|
112
|
+
function defaultServerUrl() {
|
|
113
|
+
return `ws://${window.location.hostname}:3000`
|
|
114
|
+
}
|
package/src/middlewares.js
CHANGED
|
@@ -1,37 +1,16 @@
|
|
|
1
|
-
import { compose } from "@inglorious/utils/functions"
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Wraps the store with each middleware. A middleware takes the store and returns it,
|
|
3
|
+
* changed or wrapped: one that only looks at events replaces `dispatch` with a function
|
|
4
|
+
* that calls the previous one, and returns its result. The first middleware wraps all
|
|
5
|
+
* the others, so events pass through them in the order they are listed.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} store - The store to wrap.
|
|
8
|
+
* @param {Function[]} middlewares - The middlewares, each taking the store and returning it.
|
|
9
|
+
* @returns {Object} The wrapped store.
|
|
7
10
|
*/
|
|
8
|
-
export function applyMiddlewares(
|
|
9
|
-
return (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// Enhanced store interface that middlewares receive
|
|
17
|
-
const middlewareStore = {
|
|
18
|
-
...store,
|
|
19
|
-
dispatch: (...args) => dispatch(...args),
|
|
20
|
-
notify: (type, payload) => dispatch({ type, payload }),
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Create middleware chain - each middleware gets the store and can access baseApi
|
|
24
|
-
const chain = middlewares.map((middleware) => middleware(middlewareStore))
|
|
25
|
-
|
|
26
|
-
// Compose the middleware chain to create enhanced dispatch
|
|
27
|
-
dispatch = compose(...chain)(store.dispatch)
|
|
28
|
-
|
|
29
|
-
// Return enhanced API that external code will use
|
|
30
|
-
return {
|
|
31
|
-
...middlewareStore,
|
|
32
|
-
// Override dispatch/notify with the middleware-enhanced versions
|
|
33
|
-
dispatch,
|
|
34
|
-
notify: (type, payload) => dispatch({ type, payload }),
|
|
35
|
-
}
|
|
36
|
-
}
|
|
11
|
+
export function applyMiddlewares(store, middlewares) {
|
|
12
|
+
return middlewares.reduceRight(
|
|
13
|
+
(wrapped, middleware) => middleware(wrapped),
|
|
14
|
+
store,
|
|
15
|
+
)
|
|
37
16
|
}
|
package/src/store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clone } from "@inglorious/utils/data-structures/object.js"
|
|
1
2
|
import { toCamelCase } from "@inglorious/utils/data-structures/string.js"
|
|
2
3
|
import { create } from "mutative"
|
|
3
4
|
|
|
@@ -10,10 +11,10 @@ import { augmentType, augmentTypes } from "./types.js"
|
|
|
10
11
|
/** Copies only the entities that change, using a draft proxy. */
|
|
11
12
|
const STRUCTURAL_SHARING = "structural-sharing"
|
|
12
13
|
|
|
13
|
-
/** Deep-
|
|
14
|
-
const
|
|
14
|
+
/** Deep-copies each entity as it is handed out, and applies events to the copy, without a proxy. */
|
|
15
|
+
const COPY_ON_DISPATCH = "copy-on-dispatch"
|
|
15
16
|
|
|
16
|
-
const UPDATE_STRATEGIES = [STRUCTURAL_SHARING,
|
|
17
|
+
const UPDATE_STRATEGIES = [STRUCTURAL_SHARING, COPY_ON_DISPATCH]
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Creates a store to manage state and events.
|
|
@@ -25,10 +26,10 @@ const UPDATE_STRATEGIES = [STRUCTURAL_SHARING, FULL_CLONE]
|
|
|
25
26
|
* that actually changed are copied. Unchanged entities keep their previous reference, which
|
|
26
27
|
* makes change detection cheap for consumers that rely on reference equality. This is the
|
|
27
28
|
* right choice for UI stores, where a small number of entities change at a time.
|
|
28
|
-
* - `"
|
|
29
|
-
* to the copy, without a proxy.
|
|
30
|
-
*
|
|
31
|
-
* entities change every frame, as in a game simulation.
|
|
29
|
+
* - `"copy-on-dispatch"` - Deep-copies each entity the first time it is handed to a handler or read by
|
|
30
|
+
* a system, and applies events directly to the copy, without a proxy. Entities nothing touches
|
|
31
|
+
* are not copied, and there is no per-entity proxy overhead, so it scales better when
|
|
32
|
+
* thousands of entities change every frame, as in a game simulation.
|
|
32
33
|
*
|
|
33
34
|
* Under both strategies the current state is kept intact while the queued events are applied to a
|
|
34
35
|
* separate draft, and the two are swapped only once every event has been processed. Handlers and
|
|
@@ -41,7 +42,8 @@ const UPDATE_STRATEGIES = [STRUCTURAL_SHARING, FULL_CLONE]
|
|
|
41
42
|
* @param {Types} [config.types] - The initial types configuration.
|
|
42
43
|
* @param {Entities} [config.entities] - The initial entities configuration.
|
|
43
44
|
* @param {Object[]} [config.systems] - The initial systems configuration.
|
|
44
|
-
* @param {Function[]} [config.middlewares] -
|
|
45
|
+
* @param {Function[]} [config.middlewares] - Functions that each take the store and return it,
|
|
46
|
+
* changed or wrapped. The first wraps all the others, so events pass through them in order.
|
|
45
47
|
* @param {boolean} [config.autoCreateEntities=false] - Creates entities if not defined in `config.entities`.
|
|
46
48
|
* @param {"auto" | "manual"} [config.updateMode="auto"] - Whether each event triggers an update, or updates are batched until `update()` is called.
|
|
47
49
|
* @param {UpdateStrategy} [config.updateStrategy="structural-sharing"] - How the state is copied before events are applied.
|
|
@@ -80,9 +82,7 @@ export function createStore({
|
|
|
80
82
|
reset,
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
const store = middlewares
|
|
84
|
-
? applyMiddlewares(...middlewares)(baseStore)
|
|
85
|
-
: baseStore
|
|
85
|
+
const store = applyMiddlewares(baseStore, middlewares)
|
|
86
86
|
const api = createApi(store, store.extras)
|
|
87
87
|
store._api = api
|
|
88
88
|
|
|
@@ -132,14 +132,14 @@ export function createStore({
|
|
|
132
132
|
let nextState
|
|
133
133
|
|
|
134
134
|
if (updateStrategy === STRUCTURAL_SHARING) {
|
|
135
|
-
nextState = create(state, patch, {
|
|
135
|
+
nextState = create(state, (draft) => patch(writeThrough(draft)), {
|
|
136
136
|
enableAutoFreeze: state.game?.devMode,
|
|
137
137
|
})
|
|
138
138
|
} else {
|
|
139
|
-
const draft =
|
|
139
|
+
const draft = copyOnDispatch(state)
|
|
140
140
|
|
|
141
141
|
patch(draft)
|
|
142
|
-
nextState = draft
|
|
142
|
+
nextState = draft.entities
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
state = nextState
|
|
@@ -152,9 +152,10 @@ export function createStore({
|
|
|
152
152
|
/**
|
|
153
153
|
* Applies every queued event to the given draft.
|
|
154
154
|
*
|
|
155
|
-
* The draft
|
|
156
|
-
*
|
|
157
|
-
* modified, and only becomes the new state once
|
|
155
|
+
* The draft wraps either the proxy returned by `create` (structural sharing) or a
|
|
156
|
+
* copy of the entity map whose entities are copied as they are handed out (copy on
|
|
157
|
+
* dispatch). The current state is not modified, and only becomes the new state once
|
|
158
|
+
* this function returns.
|
|
158
159
|
*
|
|
159
160
|
* A change made by a handler is not visible to the next handler in the same pass:
|
|
160
161
|
* `getState` still returns the state as it was before the pass began. This is on
|
|
@@ -163,7 +164,7 @@ export function createStore({
|
|
|
163
164
|
* left is asking about the past. Anything reacting to a change should be told the
|
|
164
165
|
* change rather than go looking for it.
|
|
165
166
|
*
|
|
166
|
-
* @param {
|
|
167
|
+
* @param {Draft} draft - The draft to apply the queued events to.
|
|
167
168
|
* @returns {void}
|
|
168
169
|
*/
|
|
169
170
|
function patch(draft) {
|
|
@@ -247,24 +248,26 @@ export function createStore({
|
|
|
247
248
|
const halted = isHalted && handlerName === "update"
|
|
248
249
|
|
|
249
250
|
for (const id of entityIds) {
|
|
250
|
-
|
|
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
|
+
const entity = draft.entities[id]
|
|
251
254
|
const type = types[entity.type]
|
|
252
255
|
|
|
253
256
|
if (halted && !entity.updatesWhilePaused) continue
|
|
254
257
|
|
|
255
258
|
const handle = type[handlerName]
|
|
256
259
|
|
|
257
|
-
handle?.(
|
|
260
|
+
handle?.(draft.take(id), event.payload, api)
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
if (event.type.endsWith(":destroy")) {
|
|
261
264
|
const [target] = event.type.split(":")
|
|
262
265
|
const [, id] = target.split("#")
|
|
263
|
-
const entity = draft[id]
|
|
266
|
+
const entity = draft.entities[id]
|
|
264
267
|
if (entity) {
|
|
265
268
|
const type = types[entity.type]
|
|
266
269
|
const typeName = entity.type
|
|
267
|
-
delete draft[id]
|
|
270
|
+
delete draft.entities[id]
|
|
268
271
|
|
|
269
272
|
eventMap.removeEntity(id, type, typeName)
|
|
270
273
|
}
|
|
@@ -273,7 +276,7 @@ export function createStore({
|
|
|
273
276
|
// Systems process events by handler name (not scoped)
|
|
274
277
|
systems.forEach((system) => {
|
|
275
278
|
const handle = system[handlerName]
|
|
276
|
-
handle?.(draft, event.payload, api)
|
|
279
|
+
handle?.(draft.forSystems, event.payload, api)
|
|
277
280
|
})
|
|
278
281
|
}
|
|
279
282
|
}
|
|
@@ -423,7 +426,7 @@ export function createStore({
|
|
|
423
426
|
|
|
424
427
|
entitiesToCreate.forEach((id) => {
|
|
425
428
|
const entity = newEntities[id]
|
|
426
|
-
addEntity(state, { id, ...entity })
|
|
429
|
+
addEntity(writeThrough(state), { id, ...entity })
|
|
427
430
|
})
|
|
428
431
|
|
|
429
432
|
entitiesToUpdate.forEach((id) => {
|
|
@@ -445,7 +448,7 @@ export function createStore({
|
|
|
445
448
|
)
|
|
446
449
|
|
|
447
450
|
if (!hasEntity) {
|
|
448
|
-
addEntity(state, { id: entityId, type: typeName })
|
|
451
|
+
addEntity(writeThrough(state), { id: entityId, type: typeName })
|
|
449
452
|
}
|
|
450
453
|
}
|
|
451
454
|
}
|
|
@@ -462,13 +465,13 @@ export function createStore({
|
|
|
462
465
|
|
|
463
466
|
/**
|
|
464
467
|
* Adds an entity to the state and registers its event handlers.
|
|
465
|
-
* @param {
|
|
468
|
+
* @param {Draft} draft - The draft to put it in.
|
|
466
469
|
* @param {Entity} payload - The entity to add, including its id.
|
|
467
470
|
* @returns {void}
|
|
468
471
|
*/
|
|
469
472
|
function addEntity(draft, payload) {
|
|
470
473
|
const { id, ...entity } = payload
|
|
471
|
-
draft
|
|
474
|
+
draft.put(id, augmentEntity(id, entity))
|
|
472
475
|
const type = types[entity.type]
|
|
473
476
|
|
|
474
477
|
eventMap.addEntity(id, type, entity.type)
|
|
@@ -482,7 +485,7 @@ export function createStore({
|
|
|
482
485
|
* as being half a sort, so naming a different one means taking the thing out of the old
|
|
483
486
|
* one's list of entities before putting it in the new one.
|
|
484
487
|
*
|
|
485
|
-
* @param {
|
|
488
|
+
* @param {Draft} draft - The draft it stands in.
|
|
486
489
|
* @param {string} id - The thing being moved.
|
|
487
490
|
* @param {string} previousType - The type it was under.
|
|
488
491
|
* @param {string|undefined} type - The type it is under now, if it has named one.
|
|
@@ -503,20 +506,22 @@ export function createStore({
|
|
|
503
506
|
* new configuration says otherwise. A field named as `undefined` is set to nothing,
|
|
504
507
|
* which is what taking a key off a mapping means.
|
|
505
508
|
*
|
|
506
|
-
* @param {
|
|
509
|
+
* @param {Draft} draft - The draft to change it in.
|
|
507
510
|
* @param {Entity} payload - The thing and the fields to merge into it.
|
|
508
511
|
* @returns {void}
|
|
509
512
|
*/
|
|
510
513
|
function patchEntity(draft, payload) {
|
|
511
514
|
const { id, ...config } = payload
|
|
512
|
-
|
|
515
|
+
// Taken rather than looked at, because the merge keeps whatever the configuration
|
|
516
|
+
// does not mention, and what it keeps must not be shared with the previous state.
|
|
517
|
+
const existing = draft.take(id)
|
|
513
518
|
|
|
514
519
|
// Configuring something that is not there is not a thing that can be done. The world
|
|
515
520
|
// is built in one pass, so a scene naming a thing another scene is yet to put up is
|
|
516
521
|
// ordinary rather than a mistake, and is not worth failing over.
|
|
517
522
|
if (!existing) return
|
|
518
523
|
|
|
519
|
-
draft
|
|
524
|
+
draft.put(id, augmentEntity(id, { ...existing, ...config }))
|
|
520
525
|
|
|
521
526
|
moveToType(draft, id, existing.type, config.type)
|
|
522
527
|
}
|
|
@@ -529,24 +534,25 @@ export function createStore({
|
|
|
529
534
|
* nothing here as much as it is in a patch -- the difference between the two is what
|
|
530
535
|
* happens to the fields neither mentions.
|
|
531
536
|
*
|
|
532
|
-
* @param {
|
|
537
|
+
* @param {Draft} draft - The draft to change it in.
|
|
533
538
|
* @param {Entity} payload - The thing and the fields it is to be.
|
|
534
539
|
* @returns {void}
|
|
535
540
|
*/
|
|
536
541
|
function replaceEntity(draft, payload) {
|
|
537
542
|
const { id, ...config } = payload
|
|
538
|
-
const existing = draft[id]
|
|
543
|
+
const existing = draft.entities[id]
|
|
539
544
|
|
|
540
545
|
if (!existing) return
|
|
541
546
|
|
|
542
|
-
draft
|
|
547
|
+
draft.put(id, augmentEntity(id, config))
|
|
543
548
|
|
|
544
549
|
moveToType(draft, id, existing.type, config.type)
|
|
545
550
|
}
|
|
546
551
|
|
|
547
552
|
/**
|
|
548
553
|
* Queues the destruction of an entity. The entity is removed by the `#id:destroy` event.
|
|
549
|
-
* @param {Entities} draft -
|
|
554
|
+
* @param {Draft|Entities} draft - Where it stands. The removal itself is queued, so
|
|
555
|
+
* nothing is written to it here.
|
|
550
556
|
* @param {string} id - The id of the entity to remove.
|
|
551
557
|
* @returns {void}
|
|
552
558
|
*/
|
|
@@ -555,6 +561,83 @@ export function createStore({
|
|
|
555
561
|
}
|
|
556
562
|
}
|
|
557
563
|
|
|
564
|
+
/**
|
|
565
|
+
* A draft that writes straight into the entities it is given.
|
|
566
|
+
*
|
|
567
|
+
* This is the whole of structural sharing's draft, because Mutative's proxy already copies
|
|
568
|
+
* an entity the moment it is written to, and of the state being built by `setState`,
|
|
569
|
+
* which has no previous state to protect.
|
|
570
|
+
*
|
|
571
|
+
* @param {Entities} entities - The entities to write into.
|
|
572
|
+
* @returns {Draft} The draft.
|
|
573
|
+
*/
|
|
574
|
+
function writeThrough(entities) {
|
|
575
|
+
return {
|
|
576
|
+
entities,
|
|
577
|
+
forSystems: entities,
|
|
578
|
+
take: (id) => entities[id],
|
|
579
|
+
put(id, entity) {
|
|
580
|
+
entities[id] = entity
|
|
581
|
+
},
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* A draft that copies an entity the first time it is handed to anything that could
|
|
587
|
+
* write to it.
|
|
588
|
+
*
|
|
589
|
+
* This is what `copy-on-dispatch` is named after: an entity is copied when an event is
|
|
590
|
+
* dispatched to it, and the entities that nothing touches are never paid for. The map is
|
|
591
|
+
* copied straight away, which is cheap -- a reference for every entity -- and an entity
|
|
592
|
+
* is deep-copied only when it is taken: handed to one of its handlers, merged by a
|
|
593
|
+
* `patch`, or read by a system. A frame on which only the pointer moves copies the
|
|
594
|
+
* pointer, and a frame on which nothing at all happens copies nothing but the map.
|
|
595
|
+
*
|
|
596
|
+
* There is no proxy on the entities themselves, which is what still sets this apart
|
|
597
|
+
* from structural sharing: a handler is given a plain object, and pays nothing for
|
|
598
|
+
* writing to it however many times it does. The one proxy is over the map handed to
|
|
599
|
+
* systems, which have no single entity to be given and so have to be given all of them.
|
|
600
|
+
* Each entity a system reads through it is taken, which means a system that walks every
|
|
601
|
+
* entity copies every entity: one that only needs to know how things stood can ask
|
|
602
|
+
* `api.getEntities()` instead, and take only what it writes to.
|
|
603
|
+
*
|
|
604
|
+
* @param {Entities} state - The state to leave as it is.
|
|
605
|
+
* @returns {Draft} The draft.
|
|
606
|
+
*/
|
|
607
|
+
function copyOnDispatch(state) {
|
|
608
|
+
const entities = { ...state }
|
|
609
|
+
const taken = new Set()
|
|
610
|
+
|
|
611
|
+
function take(id) {
|
|
612
|
+
if (!taken.has(id) && Object.hasOwn(entities, id)) {
|
|
613
|
+
entities[id] = clone(entities[id])
|
|
614
|
+
taken.add(id)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return entities[id]
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function put(id, entity) {
|
|
621
|
+
entities[id] = entity
|
|
622
|
+
taken.add(id)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const forSystems = new Proxy(entities, {
|
|
626
|
+
get(target, key, receiver) {
|
|
627
|
+
return typeof key === "string" && Object.hasOwn(target, key)
|
|
628
|
+
? take(key)
|
|
629
|
+
: Reflect.get(target, key, receiver)
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
set(target, key, value) {
|
|
633
|
+
put(key, value)
|
|
634
|
+
return true
|
|
635
|
+
},
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
return { entities, forSystems, take, put }
|
|
639
|
+
}
|
|
640
|
+
|
|
558
641
|
/**
|
|
559
642
|
* Validates the given update strategy.
|
|
560
643
|
* @param {UpdateStrategy} updateStrategy - The update strategy to validate.
|
|
@@ -572,12 +655,17 @@ function assertUpdateStrategy(updateStrategy) {
|
|
|
572
655
|
}
|
|
573
656
|
|
|
574
657
|
/**
|
|
575
|
-
* @typedef {"structural-sharing" | "
|
|
658
|
+
* @typedef {"structural-sharing" | "copy-on-dispatch"} UpdateStrategy - How the state is copied before events are applied.
|
|
576
659
|
* @typedef {Object} StoreConfig - Configuration options accepted by {@link createStore}.
|
|
577
660
|
* @typedef {Object.<string, any>} Type - An augmented entity type.
|
|
578
661
|
* @typedef {Object.<string, Type>} Types - A collection of augmented types.
|
|
579
662
|
* @typedef {Object.<string, any>} Entity - An object representing an entity.
|
|
580
663
|
* @typedef {Object.<string, Entity>} Entities - A collection of entities indexed by id.
|
|
664
|
+
* @typedef {Object} Draft - The entities an update writes to, and how it gets at them.
|
|
665
|
+
* @property {Entities} Draft.entities - The entities being built, read without copying.
|
|
666
|
+
* @property {Entities} Draft.forSystems - The entities as handed to systems.
|
|
667
|
+
* @property {(id: string) => Entity|undefined} Draft.take - The entity to write to.
|
|
668
|
+
* @property {(id: string, entity: Entity) => void} Draft.put - Sets an entity outright.
|
|
581
669
|
* @typedef {(listener: () => void) => () => void} Listener - Subscribes to state updates and returns an unsubscribe function.
|
|
582
670
|
* @typedef {Object} Store - The store returned by {@link createStore}.
|
|
583
671
|
*/
|
package/types/store.d.ts
CHANGED
|
@@ -154,17 +154,17 @@ export type UpdateMode = "auto" | "manual"
|
|
|
154
154
|
* - `"structural-sharing"` (default) applies events to a draft proxy, so only the entities that
|
|
155
155
|
* actually changed are copied. Unchanged entities keep their previous reference, which keeps
|
|
156
156
|
* reference-equality change detection cheap. Best for UI stores.
|
|
157
|
-
* - `"
|
|
158
|
-
*
|
|
159
|
-
* no per-entity proxy overhead, so it scales better when thousands of
|
|
160
|
-
* frame, as in a game simulation.
|
|
157
|
+
* - `"copy-on-dispatch"` deep-copies each entity the first time it is handed to a handler or read by a
|
|
158
|
+
* system, and applies events to the copy without a proxy. Entities nothing touches are not
|
|
159
|
+
* copied, and there is no per-entity proxy overhead, so it scales better when thousands of
|
|
160
|
+
* entities change every frame, as in a game simulation.
|
|
161
161
|
*
|
|
162
162
|
* Under both strategies the current state is left intact while the queued events are applied to
|
|
163
163
|
* a separate draft, and the two are swapped only once every event has been processed. This means
|
|
164
164
|
* that during an update `api.getEntity()` and `api.getEntities()` read the previous state rather
|
|
165
165
|
* than the in-flight changes.
|
|
166
166
|
*/
|
|
167
|
-
export type UpdateStrategy = "structural-sharing" | "
|
|
167
|
+
export type UpdateStrategy = "structural-sharing" | "copy-on-dispatch"
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
170
|
* Store configuration
|
|
@@ -223,7 +223,9 @@ export interface Store<
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
/**
|
|
226
|
-
* Middleware function type
|
|
226
|
+
* Middleware function type: takes the store and returns it, changed or wrapped. One that
|
|
227
|
+
* only looks at events replaces `dispatch` with a function that calls the previous one.
|
|
228
|
+
* The first middleware wraps all the others, so events pass through them in order.
|
|
227
229
|
*/
|
|
228
230
|
export type Middleware<
|
|
229
231
|
TEntity extends BaseEntity = BaseEntity,
|