redux-sparse-kit 0.0.0-stage → 0.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/LICENSE.md +21 -0
- package/README.md +331 -2
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +336 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +332 -0
- package/dist/index.mjs.map +1 -0
- package/dist/observable.d.mts +17 -0
- package/dist/observable.d.ts +17 -0
- package/dist/observable.js +24 -0
- package/dist/observable.js.map +1 -0
- package/dist/observable.mjs +23 -0
- package/dist/observable.mjs.map +1 -0
- package/dist/react.d.mts +83 -0
- package/dist/react.d.ts +83 -0
- package/dist/react.js +189 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +185 -0
- package/dist/react.mjs.map +1 -0
- package/dist/segment-DFAgPcwA.mjs +33 -0
- package/dist/segment-DFAgPcwA.mjs.map +1 -0
- package/dist/segment-Dqd6-ABS.js +44 -0
- package/dist/segment-Dqd6-ABS.js.map +1 -0
- package/dist/sparseEnhancer-CIcdhh-P.d.mts +212 -0
- package/dist/sparseEnhancer-CIcdhh-P.d.ts +212 -0
- package/package.json +112 -4
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aleksandr Khizhnyi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,332 @@
|
|
|
1
|
-
#
|
|
1
|
+
# redux-sparse-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/redux-sparse-kit)
|
|
4
|
+
[](LICENSE.md)
|
|
5
|
+
|
|
6
|
+
Dynamic reducers and epics for large Redux stores: only mounted features run on dispatch.
|
|
7
|
+
|
|
8
|
+
Every action a Redux store dispatches goes through every reducer, and with
|
|
9
|
+
redux-observable every epic sees it too. In a large application most of that
|
|
10
|
+
work is spent on features that are not on screen. `redux-sparse-kit` keeps the
|
|
11
|
+
store _sparse_: a feature's reducer and epics are injected when the feature
|
|
12
|
+
mounts and evicted some time after it is gone, so the cost of a dispatch
|
|
13
|
+
follows what the user is looking at, not the size of the codebase.
|
|
14
|
+
|
|
15
|
+
- **Segments** - a reducer, its epics and the segments it depends on, injected
|
|
16
|
+
and evicted as one unit. Each part is optional.
|
|
17
|
+
- **Reference counting** - a segment stays while anything holds it; shared
|
|
18
|
+
segments and dependencies are never evicted from under a feature.
|
|
19
|
+
- **Idle eviction** - a released segment waits before it is evicted, so moving
|
|
20
|
+
back and forth between pages does not tear features down and set them up
|
|
21
|
+
again. Idle segments can keep running, pause their epics or freeze.
|
|
22
|
+
- **Ordered, atomic changes** - dependencies are injected before their
|
|
23
|
+
dependents and evicted after them; every batch of changes reaches the store
|
|
24
|
+
as a single action.
|
|
25
|
+
- **Typed scopes** - an epic that reads a segment its segment does not depend
|
|
26
|
+
on is a type error.
|
|
27
|
+
- **Scope checks** - in development, reads of a segment's state outside the
|
|
28
|
+
part of the tree that holds it are reported.
|
|
29
|
+
- **Works with what you have** - a plain store enhancer, usable with
|
|
30
|
+
`createStore` or Redux Toolkit's `configureStore`, with any root reducer.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install redux-sparse-kit
|
|
36
|
+
# or
|
|
37
|
+
yarn add redux-sparse-kit
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`redux` 5 is required. React bindings need `react` 18 or later and
|
|
41
|
+
`react-redux`; epics need `rxjs` 7 and an epic middleware such as
|
|
42
|
+
`redux-observable`. Types need TypeScript 5.4 or later.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
### 1. Create the store
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import {
|
|
50
|
+
applyMiddleware,
|
|
51
|
+
combineReducers,
|
|
52
|
+
compose,
|
|
53
|
+
legacy_createStore as createStore,
|
|
54
|
+
} from 'redux';
|
|
55
|
+
import type { StoreEnhancer } from 'redux';
|
|
56
|
+
import { createEpicMiddleware } from 'redux-observable';
|
|
57
|
+
import type { SparseStoreExt } from 'redux-sparse-kit';
|
|
58
|
+
import { sparseEnhancer } from 'redux-sparse-kit';
|
|
59
|
+
import { createEpicRunner } from 'redux-sparse-kit/observable';
|
|
60
|
+
|
|
61
|
+
const epicMiddleware = createEpicMiddleware();
|
|
62
|
+
|
|
63
|
+
export const store = createStore(
|
|
64
|
+
// The static state: always present, owned by the root reducer as usual.
|
|
65
|
+
combineReducers({ session: sessionReducer, router: routerReducer }),
|
|
66
|
+
// `compose` does not keep the types enhancers add to the store.
|
|
67
|
+
compose(
|
|
68
|
+
applyMiddleware(epicMiddleware),
|
|
69
|
+
sparseEnhancer({ epicRunner: createEpicRunner(epicMiddleware) }),
|
|
70
|
+
) as StoreEnhancer<SparseStoreExt>,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
epicMiddleware.run(rootEpic);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
With Redux Toolkit, which keeps the type of `store.sparse` on its own:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const store = configureStore({
|
|
80
|
+
reducer: { session: sessionReducer, router: routerReducer },
|
|
81
|
+
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(epicMiddleware),
|
|
82
|
+
enhancers: (getDefaultEnhancers) =>
|
|
83
|
+
getDefaultEnhancers().concat(sparseEnhancer({ epicRunner: createEpicRunner(epicMiddleware) })),
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 2. Define segments
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { defineSegment } from 'redux-sparse-kit';
|
|
91
|
+
|
|
92
|
+
export const accountSegment = defineSegment({
|
|
93
|
+
key: 'account',
|
|
94
|
+
reducer: accountReducer,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export const ordersSegment = defineSegment({
|
|
98
|
+
key: 'orders',
|
|
99
|
+
reducer: ordersReducer,
|
|
100
|
+
deps: [accountSegment],
|
|
101
|
+
epics: [loadOrdersEpic],
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A segment owns the top-level state key it names. Its dependencies are present
|
|
106
|
+
for as long as it is.
|
|
107
|
+
|
|
108
|
+
### 3. Hold them where they are used
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
import { SparseScope } from 'redux-sparse-kit/react';
|
|
112
|
+
|
|
113
|
+
export function OrdersPage() {
|
|
114
|
+
return (
|
|
115
|
+
<SparseScope segments={[ordersSegment]} fallback={<Spinner />}>
|
|
116
|
+
<OrdersTable />
|
|
117
|
+
</SparseScope>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`OrdersTable` renders once `orders` and `account` are in the store, and reads
|
|
123
|
+
them with `useSelector` as usual. When `OrdersPage` unmounts, the segments go
|
|
124
|
+
idle and are evicted a minute later unless something holds them again.
|
|
125
|
+
|
|
126
|
+
## Segments
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
defineSegment({
|
|
130
|
+
key: 'orders', // the top-level state key the reducer owns
|
|
131
|
+
reducer: ordersReducer,
|
|
132
|
+
deps: [accountSegment], // present for as long as this segment is
|
|
133
|
+
epics: [loadOrdersEpic], // started after injection, stopped before eviction
|
|
134
|
+
idleTimeout: 5 * 60_000, // overrides the store's idle timeout
|
|
135
|
+
whileIdle: 'pause-epics', // overrides the store's idle behaviour
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Every part but the key is optional. A segment without a reducer owns no slice
|
|
140
|
+
of the state: it can run epics only, or only bundle the segments it depends on,
|
|
141
|
+
so that one scope holds a whole page.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const pricePolling = defineSegment({ key: 'pricePolling', epics: [pollPricesEpic] });
|
|
145
|
+
|
|
146
|
+
const ordersPage = defineSegment({
|
|
147
|
+
key: 'ordersPage',
|
|
148
|
+
deps: [ordersSegment, pricesSegment, pricePolling],
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
A dependency has to be defined before the segments that list it, so the
|
|
153
|
+
dependency graph cannot have cycles. Two different segments cannot use the same
|
|
154
|
+
key in one store at the same time.
|
|
155
|
+
|
|
156
|
+
### Typing epics
|
|
157
|
+
|
|
158
|
+
The state a segment's epics may read is the static state, the segment's own
|
|
159
|
+
slice and the slices of its direct dependencies. An epic that declares a state
|
|
160
|
+
outside of that is rejected:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import type { Epic } from 'redux-observable';
|
|
164
|
+
import type { StateOfSegments } from 'redux-sparse-kit';
|
|
165
|
+
|
|
166
|
+
type OrdersState = StateOfSegments<typeof accountSegment> & { orders: Orders };
|
|
167
|
+
|
|
168
|
+
const loadOrdersEpic: Epic<Action, Action, OrdersState> = (action$, state$) => /* ... */;
|
|
169
|
+
|
|
170
|
+
defineSegment({ key: 'orders', reducer: ordersReducer, deps: [accountSegment], epics: [loadOrdersEpic] }); // ok
|
|
171
|
+
defineSegment({ key: 'orders', reducer: ordersReducer, epics: [loadOrdersEpic] }); // error: reads `account`
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Epics typed with a `void`, `unknown` or `any` state do not read it and are
|
|
175
|
+
always accepted. Register the static state once to let epics read it too:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
declare module 'redux-sparse-kit' {
|
|
179
|
+
interface StaticState {
|
|
180
|
+
session: SessionState;
|
|
181
|
+
router: RouterState;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`ScopeStateOf<typeof segment>` is the full state a segment may read.
|
|
187
|
+
|
|
188
|
+
## Lifecycle
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
acquire release (last holder)
|
|
192
|
+
absent ─────────────▶ active ◀──────────────────────────▶ idle
|
|
193
|
+
▲ ▲ acquire (revive) │
|
|
194
|
+
│ └──────────────────────────────────┤
|
|
195
|
+
│ │ idle timeout,
|
|
196
|
+
└───────────────────────── evict ───────────────────────┘ maxIdle
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- **Injecting** dispatches `@@sparse/INJECT` with the keys of the slices the
|
|
200
|
+
call brings in, dependencies first, then starts the epics of the injected
|
|
201
|
+
segments in that order.
|
|
202
|
+
- **Releasing** the last hold makes a segment idle. Its dependencies stay held
|
|
203
|
+
by it.
|
|
204
|
+
- **Reviving** an idle segment costs nothing: its slice and epics are still
|
|
205
|
+
there.
|
|
206
|
+
- **Evicting** stops the epics of the evicted segments, dependents first, then
|
|
207
|
+
dispatches `@@sparse/EVICT`, which removes their slices.
|
|
208
|
+
- Segments without a reducer add and remove no slice: when a change involves
|
|
209
|
+
only such segments, no action is dispatched. Dependencies that
|
|
210
|
+
only the evicted segments held are evicted in the same action. Segments that
|
|
211
|
+
expire together are swept together, when the browser is idle.
|
|
212
|
+
|
|
213
|
+
Reducers outside the segments can handle `SparseActionTypes.EVICT` to drop
|
|
214
|
+
data that refers to an evicted segment. A preloaded slice for a segment that is
|
|
215
|
+
not present yet is kept, and the segment starts from it when injected.
|
|
216
|
+
|
|
217
|
+
## Store options
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
sparseEnhancer({
|
|
221
|
+
epicRunner, // required once a held segment has epics
|
|
222
|
+
idleTimeout: 60_000, // ms a released segment waits; 0 = evict now, Infinity = never
|
|
223
|
+
whileIdle: 'keep-running', // what an idle segment does
|
|
224
|
+
maxIdle: Infinity, // idle segments kept at most; the least recently used go first
|
|
225
|
+
pauseWhenHidden: true, // stop the idle clock while the page is hidden
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
| `whileIdle` | Reducer | Epics | Use for |
|
|
230
|
+
| -------------- | ------- | ------- | ----------------------------------------------------- |
|
|
231
|
+
| `keep-running` | runs | run | data kept current by a stream, e.g. live quotes |
|
|
232
|
+
| `pause-epics` | runs | stopped | epics that hold subscriptions or poll; restart on use |
|
|
233
|
+
| `freeze` | skipped | stopped | state that is cheap to keep but costly to update |
|
|
234
|
+
|
|
235
|
+
A frozen segment resumes from the state it had when it went idle, so actions
|
|
236
|
+
dispatched in the meantime are lost to it.
|
|
237
|
+
|
|
238
|
+
## The store API
|
|
239
|
+
|
|
240
|
+
`sparseEnhancer` adds `store.sparse`:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
const release = store.sparse.acquire(ordersSegment, pricesSegment);
|
|
244
|
+
release(); // idempotent
|
|
245
|
+
|
|
246
|
+
store.sparse.has(ordersSegment); // or has('orders')
|
|
247
|
+
store.sparse.stats(); // { active: ['orders', 'account'], idle: ['prices'] }
|
|
248
|
+
|
|
249
|
+
store.sparse.batch(() => {
|
|
250
|
+
// injections and evictions caused in here reach the store as one action each
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const unsubscribe = store.sparse.subscribe((event) => {
|
|
254
|
+
// { type: 'inject' | 'evict', keys } | { type: 'idle' | 'revive', key }
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`acquire` is all or nothing: if any of the segments, or their dependencies,
|
|
259
|
+
conflicts with a different segment already using its key, it throws and holds
|
|
260
|
+
nothing.
|
|
261
|
+
|
|
262
|
+
## React
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { SparseScope, useSparseSegments, createSparseReact } from 'redux-sparse-kit/react';
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
- `<SparseScope segments fallback>` holds the segments while mounted and
|
|
269
|
+
renders its children once they are present. Segments still present from an
|
|
270
|
+
earlier visit render straight away. Holding happens in a layout effect, so
|
|
271
|
+
the fallback never reaches the screen when the segments can be injected
|
|
272
|
+
right away.
|
|
273
|
+
- `useSparseSegments(segments)` holds the segments while the calling component
|
|
274
|
+
is mounted and returns whether they are present yet.
|
|
275
|
+
- A new array of the same segments on every render does not acquire them
|
|
276
|
+
again.
|
|
277
|
+
|
|
278
|
+
The bindings find the store in react-redux's context, so they work with any
|
|
279
|
+
react-redux 8.1 or later. Pass `createSparseReact({ context })` when the
|
|
280
|
+
`<Provider>` was given a custom context.
|
|
281
|
+
|
|
282
|
+
### Scope checks
|
|
283
|
+
|
|
284
|
+
In development, `SparseScope` reports reads of a segment's slice made outside
|
|
285
|
+
every scope that holds it: such a read works only while some other part of the
|
|
286
|
+
tree happens to hold the segment, and breaks once it is evicted.
|
|
287
|
+
|
|
288
|
+
```tsx
|
|
289
|
+
<Provider store={store}>
|
|
290
|
+
{/* reports reads below that no scope holds */}
|
|
291
|
+
<SparseScope segments={[]}>
|
|
292
|
+
<App />
|
|
293
|
+
</SparseScope>
|
|
294
|
+
</Provider>
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
A scope makes its segments and their dependencies available to everything
|
|
298
|
+
below it, including nested scopes. Reads of static state are never reported.
|
|
299
|
+
|
|
300
|
+
By default each key is logged once per selector with `console.error`. Pass a
|
|
301
|
+
handler to collect them instead, for example to fail end-to-end tests:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
const { SparseScope } = createSparseReact({
|
|
305
|
+
onViolation: ({ key, selector, scope }) => violations.push({ key, selector, scope }),
|
|
306
|
+
checks: true, // default: on outside production builds
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
How reads are observed depends on react-redux. When its context value carries
|
|
311
|
+
`stateAccessTracking: true`, react-redux reports the first read of each
|
|
312
|
+
top-level key by each selector through an `onStateAccess(key, selector)`
|
|
313
|
+
callback on the context value; scopes replace that callback for their subtree
|
|
314
|
+
and report the selector that made the read. Otherwise scopes pass down a
|
|
315
|
+
wrapper of the store whose state is a proxy that sees each top-level read; the
|
|
316
|
+
selector is then not known. Checks are compiled out of production builds that
|
|
317
|
+
replace `process.env.NODE_ENV`.
|
|
318
|
+
|
|
319
|
+
## Caveats
|
|
320
|
+
|
|
321
|
+
- **Server rendering** - segments are injected on the client. On the server
|
|
322
|
+
`SparseScope` renders its fallback.
|
|
323
|
+
- **`connect`** - with `onStateAccess`, reads made by `connect` are not
|
|
324
|
+
reported: react-redux reports only the reads of `useSelector`. With the store
|
|
325
|
+
wrapper they are.
|
|
326
|
+
- **Reading the whole state** - with the store wrapper, a selector that spreads
|
|
327
|
+
or iterates the whole state reads every key and is reported for each
|
|
328
|
+
segment slice.
|
|
329
|
+
|
|
330
|
+
## License
|
|
331
|
+
|
|
332
|
+
[MIT](LICENSE.md)
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { _ as StaticState, a as SparseEvent, b as isSegmentKey, c as SparseStoreExt, d as ScopeStateOf, f as Segment, g as StateOfSegments, h as SegmentOptions, i as SparseController, l as sparseEnhancer, m as SegmentEpicStateError, n as SparseAction, o as SparseOptions, p as SegmentEpic, r as SparseActionTypes, s as SparseStats, t as EpicRunner, u as AnySegment, v as WhileIdle, y as defineSegment } from "./sparseEnhancer-CIcdhh-P.mjs";
|
|
2
|
+
export { type AnySegment, type EpicRunner, type ScopeStateOf, type Segment, type SegmentEpic, type SegmentEpicStateError, type SegmentOptions, type SparseAction, SparseActionTypes, type SparseController, type SparseEvent, type SparseOptions, type SparseStats, type SparseStoreExt, type StateOfSegments, type StaticState, type WhileIdle, defineSegment, isSegmentKey, sparseEnhancer };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { _ as StaticState, a as SparseEvent, b as isSegmentKey, c as SparseStoreExt, d as ScopeStateOf, f as Segment, g as StateOfSegments, h as SegmentOptions, i as SparseController, l as sparseEnhancer, m as SegmentEpicStateError, n as SparseAction, o as SparseOptions, p as SegmentEpic, r as SparseActionTypes, s as SparseStats, t as EpicRunner, u as AnySegment, v as WhileIdle, y as defineSegment } from "./sparseEnhancer-CIcdhh-P.js";
|
|
2
|
+
export { type AnySegment, type EpicRunner, type ScopeStateOf, type Segment, type SegmentEpic, type SegmentEpicStateError, type SegmentOptions, type SparseAction, SparseActionTypes, type SparseController, type SparseEvent, type SparseOptions, type SparseStats, type SparseStoreExt, type StateOfSegments, type StaticState, type WhileIdle, defineSegment, isSegmentKey, sparseEnhancer };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_segment = require("./segment-Dqd6-ABS.js");
|
|
3
|
+
//#region src/sparseEnhancer.ts
|
|
4
|
+
/**
|
|
5
|
+
* Actions the sparse store dispatches. Reducers outside the segments can
|
|
6
|
+
* handle {@linkcode SparseActionTypes.EVICT} to drop data that refers to an
|
|
7
|
+
* evicted segment.
|
|
8
|
+
*/
|
|
9
|
+
const SparseActionTypes = {
|
|
10
|
+
/** Segment slices with the keys in `payload.keys` were added to the state. */
|
|
11
|
+
INJECT: "@@sparse/INJECT",
|
|
12
|
+
/** Segment slices with the keys in `payload.keys` are removed by this action. */
|
|
13
|
+
EVICT: "@@sparse/EVICT"
|
|
14
|
+
};
|
|
15
|
+
const emptyKeys = /* @__PURE__ */ new Set();
|
|
16
|
+
const defaultIdleTimeout = 6e4;
|
|
17
|
+
const whileIdleValues = [
|
|
18
|
+
"keep-running",
|
|
19
|
+
"pause-epics",
|
|
20
|
+
"freeze"
|
|
21
|
+
];
|
|
22
|
+
const maxTimerDelay = 2 ** 31 - 1;
|
|
23
|
+
const scheduleIdle = (callback) => {
|
|
24
|
+
if (typeof requestIdleCallback === "function") requestIdleCallback(callback, { timeout: 1e3 });
|
|
25
|
+
else setTimeout(callback, 0);
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A store enhancer that lets segments be injected into and evicted from a
|
|
29
|
+
* running store. The root reducer passed to `createStore` keeps owning the
|
|
30
|
+
* static state; segment slices live next to it under their keys.
|
|
31
|
+
*/
|
|
32
|
+
function sparseEnhancer(options = {}) {
|
|
33
|
+
const { epicRunner, idleTimeout = defaultIdleTimeout, whileIdle = "keep-running", maxIdle = Infinity, pauseWhenHidden = true } = options;
|
|
34
|
+
const isHidden = () => pauseWhenHidden && typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
35
|
+
if (!(idleTimeout >= 0)) throw new RangeError("The idle timeout must be a non-negative number");
|
|
36
|
+
if (!(maxIdle >= 0)) throw new RangeError("maxIdle must be a non-negative number");
|
|
37
|
+
if (!whileIdleValues.includes(whileIdle)) throw new RangeError(`whileIdle must be one of ${whileIdleValues.join(", ")}`);
|
|
38
|
+
const enhancer = (createStore) => (rootReducer, preloadedState) => {
|
|
39
|
+
const entries = /* @__PURE__ */ new Map();
|
|
40
|
+
const idleEntries = /* @__PURE__ */ new Set();
|
|
41
|
+
let evictingKeys = emptyKeys;
|
|
42
|
+
const slices = /* @__PURE__ */ new Map();
|
|
43
|
+
const isDynamicKey = (key) => slices.has(key) || evictingKeys.has(key) || require_segment.isSegmentKey(key);
|
|
44
|
+
const wrapReducer = (root) => (state, action) => {
|
|
45
|
+
var _staticState;
|
|
46
|
+
const current = state !== null && state !== void 0 ? state : {};
|
|
47
|
+
let staticState;
|
|
48
|
+
let hasDynamicKeys = false;
|
|
49
|
+
for (const key in current) if (isDynamicKey(key)) {
|
|
50
|
+
hasDynamicKeys = true;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (hasDynamicKeys) {
|
|
54
|
+
staticState = {};
|
|
55
|
+
for (const key in current) if (!isDynamicKey(key)) staticState[key] = current[key];
|
|
56
|
+
}
|
|
57
|
+
const input = state === void 0 ? void 0 : (_staticState = staticState) !== null && _staticState !== void 0 ? _staticState : current;
|
|
58
|
+
const nextStatic = root(input, action);
|
|
59
|
+
let changed = nextStatic !== input || evictingKeys.size > 0;
|
|
60
|
+
const values = [];
|
|
61
|
+
for (const [key, entry] of slices) {
|
|
62
|
+
const previous = current[key];
|
|
63
|
+
const value = entry.frozen && key in current ? previous : entry.segment.reducer(previous, action);
|
|
64
|
+
values.push(value);
|
|
65
|
+
if (value !== previous || !(key in current)) changed = true;
|
|
66
|
+
}
|
|
67
|
+
if (!changed) return current;
|
|
68
|
+
const next = { ...nextStatic };
|
|
69
|
+
for (const key in current) if (!slices.has(key) && !evictingKeys.has(key) && require_segment.isSegmentKey(key)) next[key] = current[key];
|
|
70
|
+
let index = 0;
|
|
71
|
+
for (const key of slices.keys()) next[key] = values[index++];
|
|
72
|
+
return next;
|
|
73
|
+
};
|
|
74
|
+
const store = createStore(wrapReducer(rootReducer), preloadedState);
|
|
75
|
+
let batchDepth = 0;
|
|
76
|
+
const pendingInject = [];
|
|
77
|
+
const pendingEvict = [];
|
|
78
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
79
|
+
const emit = (event) => {
|
|
80
|
+
for (const listener of listeners) listener(event);
|
|
81
|
+
};
|
|
82
|
+
const dispatchSparse = (type, keys) => {
|
|
83
|
+
const action = {
|
|
84
|
+
type,
|
|
85
|
+
payload: { keys }
|
|
86
|
+
};
|
|
87
|
+
store.dispatch(action);
|
|
88
|
+
};
|
|
89
|
+
const flush = () => {
|
|
90
|
+
if (batchDepth > 0) return;
|
|
91
|
+
if (pendingInject.length > 0) {
|
|
92
|
+
const injected = pendingInject.splice(0);
|
|
93
|
+
const keys = injected.map((entry) => entry.segment.key);
|
|
94
|
+
const sliceKeys = [];
|
|
95
|
+
for (const entry of injected) if (entry.segment.reducer) {
|
|
96
|
+
slices.set(entry.segment.key, entry);
|
|
97
|
+
sliceKeys.push(entry.segment.key);
|
|
98
|
+
}
|
|
99
|
+
if (sliceKeys.length > 0) dispatchSparse(SparseActionTypes.INJECT, sliceKeys);
|
|
100
|
+
for (const entry of injected) if (!entry.paused) startEpics(entry);
|
|
101
|
+
emit({
|
|
102
|
+
type: "inject",
|
|
103
|
+
keys
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (pendingEvict.length > 0) {
|
|
107
|
+
const evicted = pendingEvict.splice(0);
|
|
108
|
+
const keys = evicted.map((entry) => entry.segment.key);
|
|
109
|
+
for (const entry of evicted) stopEpics(entry);
|
|
110
|
+
const sliceKeys = keys.filter((key) => slices.delete(key));
|
|
111
|
+
if (sliceKeys.length > 0) {
|
|
112
|
+
evictingKeys = new Set(sliceKeys);
|
|
113
|
+
try {
|
|
114
|
+
dispatchSparse(SparseActionTypes.EVICT, sliceKeys);
|
|
115
|
+
} finally {
|
|
116
|
+
evictingKeys = emptyKeys;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
emit({
|
|
120
|
+
type: "evict",
|
|
121
|
+
keys
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const startEpics = (entry) => {
|
|
126
|
+
for (const epic of entry.segment.epics) entry.stops.push(epicRunner.run(epic, entry.segment));
|
|
127
|
+
};
|
|
128
|
+
const stopEpics = (entry) => {
|
|
129
|
+
for (const stop of entry.stops.splice(0)) stop();
|
|
130
|
+
};
|
|
131
|
+
const batch = (fn) => {
|
|
132
|
+
batchDepth++;
|
|
133
|
+
try {
|
|
134
|
+
return fn();
|
|
135
|
+
} finally {
|
|
136
|
+
batchDepth--;
|
|
137
|
+
flush();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const assertCompatible = (segment, seen) => {
|
|
141
|
+
var _seen$get, _entries$get;
|
|
142
|
+
const other = (_seen$get = seen.get(segment.key)) !== null && _seen$get !== void 0 ? _seen$get : (_entries$get = entries.get(segment.key)) === null || _entries$get === void 0 ? void 0 : _entries$get.segment;
|
|
143
|
+
if (other === segment) return;
|
|
144
|
+
if (other !== void 0) throw new Error(`Two different segments use the key "${segment.key}"`);
|
|
145
|
+
if (segment.epics.length > 0 && epicRunner === void 0) throw new Error(`Segment "${segment.key}" has epics, but sparseEnhancer was created without an epicRunner`);
|
|
146
|
+
seen.set(segment.key, segment);
|
|
147
|
+
for (const dep of segment.deps) assertCompatible(dep, seen);
|
|
148
|
+
};
|
|
149
|
+
const hold = (segment) => {
|
|
150
|
+
const existing = entries.get(segment.key);
|
|
151
|
+
if (existing) {
|
|
152
|
+
if (existing.holders === 0) revive(existing);
|
|
153
|
+
existing.holders++;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const evictIndex = pendingEvict.findIndex((entry) => entry.segment === segment);
|
|
157
|
+
if (evictIndex !== -1) {
|
|
158
|
+
const [entry] = pendingEvict.splice(evictIndex, 1);
|
|
159
|
+
entry.holders = 1;
|
|
160
|
+
entries.set(segment.key, entry);
|
|
161
|
+
for (const dep of segment.deps) hold(dep);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const entry = {
|
|
165
|
+
segment,
|
|
166
|
+
holders: 1,
|
|
167
|
+
stops: [],
|
|
168
|
+
timer: void 0,
|
|
169
|
+
remaining: Infinity,
|
|
170
|
+
startedAt: 0,
|
|
171
|
+
expired: false,
|
|
172
|
+
paused: false,
|
|
173
|
+
frozen: false
|
|
174
|
+
};
|
|
175
|
+
entries.set(segment.key, entry);
|
|
176
|
+
for (const dep of segment.deps) hold(dep);
|
|
177
|
+
pendingInject.push(entry);
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Drops a hold. A segment left without holders goes idle, unless it was
|
|
181
|
+
* only held by a segment being evicted: then it is evicted with it.
|
|
182
|
+
*/
|
|
183
|
+
const unhold = (segment, cascade = false) => {
|
|
184
|
+
var _segment$idleTimeout;
|
|
185
|
+
const entry = entries.get(segment.key);
|
|
186
|
+
entry.holders--;
|
|
187
|
+
if (entry.holders > 0) return;
|
|
188
|
+
const timeout = (_segment$idleTimeout = segment.idleTimeout) !== null && _segment$idleTimeout !== void 0 ? _segment$idleTimeout : idleTimeout;
|
|
189
|
+
if (cascade || timeout === 0) evict(entry);
|
|
190
|
+
else becomeIdle(entry, timeout);
|
|
191
|
+
};
|
|
192
|
+
const becomeIdle = (entry, timeout) => {
|
|
193
|
+
var _entry$segment$whileI;
|
|
194
|
+
const policy = (_entry$segment$whileI = entry.segment.whileIdle) !== null && _entry$segment$whileI !== void 0 ? _entry$segment$whileI : whileIdle;
|
|
195
|
+
if (policy !== "keep-running") {
|
|
196
|
+
entry.paused = true;
|
|
197
|
+
entry.frozen = policy === "freeze";
|
|
198
|
+
stopEpics(entry);
|
|
199
|
+
}
|
|
200
|
+
entry.remaining = timeout > maxTimerDelay ? Infinity : timeout;
|
|
201
|
+
if (!isHidden()) startTimer(entry);
|
|
202
|
+
idleEntries.add(entry);
|
|
203
|
+
watchVisibility();
|
|
204
|
+
emit({
|
|
205
|
+
type: "idle",
|
|
206
|
+
key: entry.segment.key
|
|
207
|
+
});
|
|
208
|
+
while (idleEntries.size > maxIdle) evict(idleEntries.values().next().value);
|
|
209
|
+
};
|
|
210
|
+
const startTimer = (entry) => {
|
|
211
|
+
if (entry.remaining !== Infinity) {
|
|
212
|
+
entry.startedAt = Date.now();
|
|
213
|
+
entry.timer = setTimeout(() => {
|
|
214
|
+
entry.timer = void 0;
|
|
215
|
+
entry.expired = true;
|
|
216
|
+
scheduleSweep();
|
|
217
|
+
}, entry.remaining);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const pauseTimers = () => {
|
|
221
|
+
for (const entry of idleEntries) if (entry.timer !== void 0) {
|
|
222
|
+
clearTimeout(entry.timer);
|
|
223
|
+
entry.timer = void 0;
|
|
224
|
+
entry.remaining = Math.max(0, entry.remaining - (Date.now() - entry.startedAt));
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const resumeTimers = () => {
|
|
228
|
+
for (const entry of idleEntries) if (entry.timer === void 0 && !entry.expired) startTimer(entry);
|
|
229
|
+
};
|
|
230
|
+
const onVisibilityChange = () => {
|
|
231
|
+
if (isHidden()) pauseTimers();
|
|
232
|
+
else resumeTimers();
|
|
233
|
+
};
|
|
234
|
+
let watchingVisibility = false;
|
|
235
|
+
/** Listens to the page visibility only while there are idle segments. */
|
|
236
|
+
const watchVisibility = () => {
|
|
237
|
+
const watch = idleEntries.size > 0;
|
|
238
|
+
if (watch !== watchingVisibility && pauseWhenHidden && typeof document !== "undefined") {
|
|
239
|
+
watchingVisibility = watch;
|
|
240
|
+
if (watch) document.addEventListener("visibilitychange", onVisibilityChange);
|
|
241
|
+
else document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const revive = (entry) => {
|
|
245
|
+
idleEntries.delete(entry);
|
|
246
|
+
watchVisibility();
|
|
247
|
+
clearTimeout(entry.timer);
|
|
248
|
+
entry.timer = void 0;
|
|
249
|
+
entry.expired = false;
|
|
250
|
+
entry.frozen = false;
|
|
251
|
+
if (entry.paused) {
|
|
252
|
+
entry.paused = false;
|
|
253
|
+
if (!pendingInject.includes(entry)) startEpics(entry);
|
|
254
|
+
}
|
|
255
|
+
emit({
|
|
256
|
+
type: "revive",
|
|
257
|
+
key: entry.segment.key
|
|
258
|
+
});
|
|
259
|
+
};
|
|
260
|
+
const evict = (entry) => {
|
|
261
|
+
idleEntries.delete(entry);
|
|
262
|
+
watchVisibility();
|
|
263
|
+
clearTimeout(entry.timer);
|
|
264
|
+
entries.delete(entry.segment.key);
|
|
265
|
+
const pendingIndex = pendingInject.indexOf(entry);
|
|
266
|
+
if (pendingIndex !== -1) pendingInject.splice(pendingIndex, 1);
|
|
267
|
+
else pendingEvict.push(entry);
|
|
268
|
+
for (const dep of entry.segment.deps) unhold(dep, true);
|
|
269
|
+
};
|
|
270
|
+
let sweepScheduled = false;
|
|
271
|
+
/** Evicts every expired segment with a single action. */
|
|
272
|
+
const sweep = () => {
|
|
273
|
+
sweepScheduled = false;
|
|
274
|
+
batch(() => {
|
|
275
|
+
for (const entry of entries.values()) if (entry.expired) evict(entry);
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
const scheduleSweep = () => {
|
|
279
|
+
if (!sweepScheduled) {
|
|
280
|
+
sweepScheduled = true;
|
|
281
|
+
scheduleIdle(sweep);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const sparse = {
|
|
285
|
+
acquire(...segments) {
|
|
286
|
+
const seen = /* @__PURE__ */ new Map();
|
|
287
|
+
for (const segment of segments) assertCompatible(segment, seen);
|
|
288
|
+
batch(() => {
|
|
289
|
+
for (const segment of segments) hold(segment);
|
|
290
|
+
});
|
|
291
|
+
let released = false;
|
|
292
|
+
return () => {
|
|
293
|
+
if (released) return;
|
|
294
|
+
released = true;
|
|
295
|
+
batch(() => {
|
|
296
|
+
for (const segment of segments) unhold(segment);
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
has(segmentOrKey) {
|
|
301
|
+
const key = typeof segmentOrKey === "string" ? segmentOrKey : segmentOrKey.key;
|
|
302
|
+
return entries.has(key) && !pendingInject.includes(entries.get(key));
|
|
303
|
+
},
|
|
304
|
+
stats() {
|
|
305
|
+
const stats = {
|
|
306
|
+
active: [],
|
|
307
|
+
idle: []
|
|
308
|
+
};
|
|
309
|
+
for (const [key, entry] of entries) if (!pendingInject.includes(entry)) (entry.holders > 0 ? stats.active : stats.idle).push(key);
|
|
310
|
+
return stats;
|
|
311
|
+
},
|
|
312
|
+
batch,
|
|
313
|
+
subscribe(listener) {
|
|
314
|
+
listeners.add(listener);
|
|
315
|
+
return () => {
|
|
316
|
+
listeners.delete(listener);
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
return {
|
|
321
|
+
...store,
|
|
322
|
+
replaceReducer(nextReducer) {
|
|
323
|
+
store.replaceReducer(wrapReducer(nextReducer));
|
|
324
|
+
},
|
|
325
|
+
sparse
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
return enhancer;
|
|
329
|
+
}
|
|
330
|
+
//#endregion
|
|
331
|
+
exports.SparseActionTypes = SparseActionTypes;
|
|
332
|
+
exports.defineSegment = require_segment.defineSegment;
|
|
333
|
+
exports.isSegmentKey = require_segment.isSegmentKey;
|
|
334
|
+
exports.sparseEnhancer = sparseEnhancer;
|
|
335
|
+
|
|
336
|
+
//# sourceMappingURL=index.js.map
|