ustor 0.1.0 → 0.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/LICENSE +21 -0
- package/README.md +587 -151
- package/dist/index.cjs +125 -0
- package/dist/index.es.js +115 -28
- package/dist/index.min.js +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/react.cjs +1 -0
- package/dist/react.es.js +1 -0
- package/package.json +63 -19
- package/types/index.d.ts +30 -0
- package/types/react.d.ts +17 -0
- package/dist/index.js +0 -38
package/README.md
CHANGED
|
@@ -1,257 +1,693 @@
|
|
|
1
1
|
# UStor
|
|
2
2
|
|
|
3
|
-
[](https://github.com/ustor/actions/workflows/node.js.yml)
|
|
3
|
+
[](https://github.com/kethan/ustor/actions/workflows/node.js.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/ustor)
|
|
5
|
+
[](https://bundlejs.com/?q=ustor)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
A tiny, dependency-free reactive store for JavaScript objects and arrays. UStor uses your signal implementation instead of bundling a reactivity engine.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
- **Small:** about 1.8 KiB minified and less than 1 KiB gzipped.
|
|
10
|
+
- **Deeply reactive:** nested objects, arrays, getters, and existing signals.
|
|
11
|
+
- **Framework-friendly:** 16 tested signal/store adapters plus optional React hooks.
|
|
12
|
+
- **SSR-ready:** server rendering and hydration are tested.
|
|
13
|
+
- **Typed:** TypeScript declarations for the core and React entry points.
|
|
14
|
+
- **Interoperable:** ESM, CommonJS, IIFE, and UMD builds.
|
|
8
15
|
|
|
9
|
-
##
|
|
16
|
+
## Contents
|
|
10
17
|
|
|
11
18
|
- [Installation](#installation)
|
|
12
|
-
- [
|
|
13
|
-
- [
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- [
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- [Reactivity and Effects](#reactivity-and-effects)
|
|
26
|
-
- [`state.$prop`](#stateprop)
|
|
27
|
-
|
|
28
|
-
- [Integration with Solid.js, Preact Signals or any](#integration-with-solidjs-preact-signals-or-any)
|
|
19
|
+
- [Quickstart](#quickstart)
|
|
20
|
+
- [Common use cases](#common-use-cases)
|
|
21
|
+
- [Examples](#examples)
|
|
22
|
+
- [API reference](#api-reference)
|
|
23
|
+
- [Arrays](#arrays)
|
|
24
|
+
- [Supported adapters](#supported-adapters)
|
|
25
|
+
- [Porting any signal library](#porting-any-signal-library)
|
|
26
|
+
- [React](#react)
|
|
27
|
+
- [SSR and Next.js](#ssr-and-nextjs)
|
|
28
|
+
- [TypeScript](#typescript)
|
|
29
|
+
- [Profiling and memory analysis](#profiling-and-memory-analysis)
|
|
30
|
+
- [Production notes](#production-notes)
|
|
31
|
+
- [Development](#development)
|
|
29
32
|
|
|
30
33
|
## Installation
|
|
31
34
|
|
|
32
|
-
To use this library, you can install it using npm or yarn:
|
|
33
|
-
|
|
34
35
|
```bash
|
|
35
36
|
npm install ustor
|
|
36
|
-
yarn add ustor
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Install the signal library you want to use. The examples below use `@preact/signals-core`:
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
```bash
|
|
42
|
+
npm install @preact/signals-core
|
|
43
|
+
```
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
UStor targets modern JavaScript environments with `Proxy`, `Reflect`, `WeakMap`, and `Object.hasOwn`. The runtime is not transpiled or polyfilled for legacy browsers.
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
import { store, api } from "./src";
|
|
47
|
-
import { createSignal } from "solid-js";
|
|
48
|
-
// import { signal } from '@preact/signals-core'; // 'usignal' 'ulive'
|
|
47
|
+
## Quickstart
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
api.signal = createSignal;
|
|
52
|
-
api.get = (v) => v[0]();
|
|
53
|
-
api.set = (signal, v) => signal[1](v);
|
|
54
|
-
api.is = (v) =>
|
|
55
|
-
(Array.isArray(v) &&
|
|
56
|
-
typeof v[0] === "function" &&
|
|
57
|
-
typeof v[1] === "function") ||
|
|
58
|
-
v[0]?.name?.includes("readSignal");
|
|
49
|
+
Configure the adapter once, before creating stores:
|
|
59
50
|
|
|
60
|
-
|
|
61
|
-
api
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
51
|
+
```javascript
|
|
52
|
+
import { store, api } from "ustor";
|
|
53
|
+
import { signal, computed, effect } from "@preact/signals-core";
|
|
54
|
+
|
|
55
|
+
Object.assign(api, {
|
|
56
|
+
signal,
|
|
57
|
+
computed,
|
|
58
|
+
effect,
|
|
59
|
+
get: (value) => value.value,
|
|
60
|
+
getComputed: (value) => value.value,
|
|
61
|
+
set: (target, value) => { target.value = value; },
|
|
62
|
+
is: (value) => typeof value?.peek === "function",
|
|
63
|
+
});
|
|
65
64
|
```
|
|
66
65
|
|
|
67
|
-
|
|
66
|
+
Create a store and use ordinary property reads and writes:
|
|
68
67
|
|
|
69
|
-
|
|
68
|
+
```javascript
|
|
69
|
+
const state = store({
|
|
70
|
+
count: 1,
|
|
71
|
+
items: [1, 2],
|
|
72
|
+
get double() {
|
|
73
|
+
return this.count * 2;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
const dispose = effect(() => {
|
|
78
|
+
console.log(state.double, state.items.length);
|
|
79
|
+
});
|
|
72
80
|
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
state.count = 3; // effects using count or double update
|
|
82
|
+
state.items.push(3); // array mutations are reactive too
|
|
75
83
|
|
|
76
|
-
|
|
84
|
+
console.log(state.count); // 3
|
|
85
|
+
console.log(state.double); // 6
|
|
86
|
+
console.log(state.$count.value); // 3: the underlying Preact signal
|
|
77
87
|
|
|
78
|
-
|
|
88
|
+
dispose();
|
|
89
|
+
```
|
|
79
90
|
|
|
80
|
-
|
|
91
|
+
**Important:** `api` is shared by stores from the same UStor module instance. Configure one adapter and do not switch implementations while existing stores are still in use.
|
|
81
92
|
|
|
82
|
-
|
|
93
|
+
## Common use cases
|
|
83
94
|
|
|
84
|
-
|
|
95
|
+
### Shopping cart with reactive items and totals
|
|
85
96
|
|
|
86
|
-
|
|
97
|
+
```javascript
|
|
98
|
+
const cart = store({
|
|
99
|
+
items: [],
|
|
100
|
+
get total() {
|
|
101
|
+
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
87
104
|
|
|
88
|
-
|
|
105
|
+
cart.items.push({ id: "book", price: 12, quantity: 1 });
|
|
106
|
+
console.log(cart.total); // 12
|
|
89
107
|
|
|
90
|
-
|
|
108
|
+
cart.items[0].quantity = 2;
|
|
109
|
+
console.log(cart.total); // 24
|
|
91
110
|
|
|
92
|
-
|
|
111
|
+
cart.items.push({ id: "pen", price: 2, quantity: 3 });
|
|
112
|
+
console.log(cart.total); // 30
|
|
113
|
+
```
|
|
93
114
|
|
|
94
|
-
|
|
115
|
+
Objects inserted into arrays become reactive stores. Their existing fields can be updated, while their object schema remains sealed.
|
|
95
116
|
|
|
96
|
-
|
|
117
|
+
### Form state with derived validation
|
|
97
118
|
|
|
98
|
-
|
|
119
|
+
```javascript
|
|
120
|
+
const form = store({
|
|
121
|
+
email: "",
|
|
122
|
+
password: "",
|
|
123
|
+
get valid() {
|
|
124
|
+
return this.email.includes("@") && this.password.length >= 8;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
99
127
|
|
|
100
|
-
|
|
128
|
+
form.email = "person@example.com";
|
|
129
|
+
form.password = "very-secret";
|
|
130
|
+
console.log(form.valid); // true
|
|
131
|
+
```
|
|
101
132
|
|
|
102
|
-
|
|
133
|
+
Use an effect or a UI binding to react to `form.valid`; reading it manually always returns the current value.
|
|
103
134
|
|
|
104
|
-
|
|
135
|
+
### Shared UI state
|
|
105
136
|
|
|
106
|
-
|
|
137
|
+
```javascript
|
|
138
|
+
const ui = store({
|
|
139
|
+
theme: "light",
|
|
140
|
+
sidebar: { open: false },
|
|
141
|
+
notifications: [],
|
|
142
|
+
});
|
|
107
143
|
|
|
108
|
-
|
|
144
|
+
ui.sidebar.open = true;
|
|
145
|
+
ui.notifications.push({ id: 1, message: "Saved" });
|
|
146
|
+
ui.theme = "dark";
|
|
147
|
+
```
|
|
109
148
|
|
|
110
|
-
|
|
149
|
+
Create request- or component-specific stores when the state must not be shared. See [SSR and Next.js](#ssr-and-nextjs).
|
|
111
150
|
|
|
112
|
-
|
|
151
|
+
## Examples
|
|
113
152
|
|
|
114
|
-
-
|
|
115
|
-
- **value**: The new value to set.
|
|
153
|
+
The [source example collection](examples/README.md) contains runnable Node programs for counters, carts, validation, async race handling, undo history, Vue, and RxJS; React and Next.js components; a browser import-map demo; and TypeScript examples. Run `npm run examples` to build, execute the isolated Node examples, and type-check the component/TypeScript sources.
|
|
116
154
|
|
|
117
|
-
|
|
155
|
+
## API reference
|
|
118
156
|
|
|
119
|
-
|
|
157
|
+
### `store(values, proto?)`
|
|
120
158
|
|
|
121
|
-
|
|
159
|
+
Creates and returns a reactive object or array store.
|
|
122
160
|
|
|
123
161
|
```javascript
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
s.a = 5;
|
|
127
|
-
console.log(s.a); // 5
|
|
162
|
+
const state = store({ nested: { value: 1 } });
|
|
163
|
+
state.nested.value = 2;
|
|
128
164
|
```
|
|
129
165
|
|
|
130
|
-
|
|
166
|
+
Behavior:
|
|
131
167
|
|
|
132
|
-
|
|
168
|
+
- Existing object properties become signal-backed accessors.
|
|
169
|
+
- Nested objects and arrays are recursively converted.
|
|
170
|
+
- Existing stores are returned unchanged.
|
|
171
|
+
- Repeated and cyclic references preserve identity.
|
|
172
|
+
- `proto` optionally replaces the new object store's prototype.
|
|
173
|
+
- Primitives, including `null` and `undefined`, are returned unchanged.
|
|
174
|
+
- Class instances, dates, maps, sets, and similar objects are treated as atomic values.
|
|
175
|
+
- Calling `store` for an object or array before configuring the required adapter throws an error.
|
|
176
|
+
|
|
177
|
+
Object stores have a **sealed schema**. Adding or deleting object properties is unsupported; replace the containing object when its shape must change. Arrays remain structurally mutable.
|
|
133
178
|
|
|
134
179
|
```javascript
|
|
135
|
-
const
|
|
136
|
-
a: 2,
|
|
137
|
-
b: 3,
|
|
138
|
-
get sum() {
|
|
139
|
-
return this.a + this.b;
|
|
140
|
-
},
|
|
141
|
-
});
|
|
180
|
+
const state = store({ profile: { name: "Ada" } });
|
|
142
181
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
console.log(s.sum); // 8
|
|
182
|
+
// Instead of adding a new field to profile:
|
|
183
|
+
state.profile = { name: "Ada", active: true };
|
|
146
184
|
```
|
|
147
185
|
|
|
148
|
-
|
|
186
|
+
### `is(value)`
|
|
187
|
+
|
|
188
|
+
Returns `true` for a reactive store and `false` for every other value.
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
is(state); // true
|
|
192
|
+
is({}); // false
|
|
193
|
+
is(null); // false
|
|
194
|
+
```
|
|
149
195
|
|
|
150
|
-
###
|
|
196
|
+
### `state.$property`
|
|
151
197
|
|
|
152
|
-
|
|
198
|
+
Exposes the underlying signal for a data property, or the underlying computed value for a computed getter.
|
|
153
199
|
|
|
154
200
|
```javascript
|
|
155
|
-
const
|
|
156
|
-
const deepValue = s.nested.deep.value * 2;
|
|
201
|
+
const state = store({ count: 0 });
|
|
157
202
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
console.log(s.nested.deep.value) * 2; // 30
|
|
203
|
+
state.$count.value = 2; // Preact signal example
|
|
204
|
+
console.log(state.count); // 2
|
|
161
205
|
```
|
|
162
206
|
|
|
163
|
-
|
|
207
|
+
Aliases are non-enumerable. Getters without `api.computed` do not have a `$` alias. If an input explicitly defines a conflicting key such as `$count`, the user-defined property takes precedence.
|
|
208
|
+
|
|
209
|
+
### Adapter API
|
|
210
|
+
|
|
211
|
+
Required operations:
|
|
212
|
+
|
|
213
|
+
| Operation | Purpose |
|
|
214
|
+
| --- | --- |
|
|
215
|
+
| `api.signal(value)` | Create a signal. |
|
|
216
|
+
| `api.get(signal)` | Read its current value. |
|
|
217
|
+
| `api.set(signal, value)` | Update its value. |
|
|
218
|
+
| `api.is(value)` | Detect an existing signal without misclassifying ordinary functions or objects. |
|
|
219
|
+
|
|
220
|
+
Optional operations:
|
|
164
221
|
|
|
165
|
-
|
|
222
|
+
| Operation | Purpose |
|
|
223
|
+
| --- | --- |
|
|
224
|
+
| `api.computed(fn)` | Create a lazy computed value. |
|
|
225
|
+
| `api.getComputed(computed)` | Read a computed value; defaults to `api.get`. |
|
|
226
|
+
| `api.effect(fn)` | Required by `ustor/react`; must auto-track synchronously and return a cleanup function. |
|
|
227
|
+
|
|
228
|
+
Without `api.computed`, getters execute on each access and still participate in compatible effects. With `api.computed`, getters are lazy and cached while their dependencies remain unchanged.
|
|
229
|
+
|
|
230
|
+
## Arrays
|
|
231
|
+
|
|
232
|
+
Arrays are proxies over their mutable input target. Mutate the returned store, not the original input, to send notifications.
|
|
166
233
|
|
|
167
234
|
```javascript
|
|
168
|
-
const
|
|
169
|
-
const sum = s.list.reduce((acc, item) => acc + item, 0);
|
|
235
|
+
const state = store({ list: [1, 2, 3] });
|
|
170
236
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
237
|
+
state.list[1] = 5;
|
|
238
|
+
state.list.push(4);
|
|
239
|
+
state.list.splice(0, 1);
|
|
240
|
+
state.list.length = 2;
|
|
241
|
+
delete state.list[0];
|
|
174
242
|
```
|
|
175
243
|
|
|
176
|
-
|
|
244
|
+
Tracked operations include:
|
|
245
|
+
|
|
246
|
+
- Index assignment and whole-array replacement.
|
|
247
|
+
- `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse`, `fill`, and `copyWithin`.
|
|
248
|
+
- Length changes and `delete`.
|
|
249
|
+
- Iteration, key enumeration, and the `in` operator.
|
|
250
|
+
- Objects inserted by assignment or mutating methods.
|
|
251
|
+
- Sparse arrays and nullish entries.
|
|
252
|
+
|
|
253
|
+
Each standard mutating method emits at most one notification after it finishes. Deterministic model tests compare randomized mutation sequences against native arrays.
|
|
177
254
|
|
|
178
|
-
|
|
255
|
+
## Supported adapters
|
|
256
|
+
|
|
257
|
+
The configurations in `adapters.spec.js` are executable documentation and include lifecycle scopes where appropriate.
|
|
258
|
+
|
|
259
|
+
| Package | Getter support |
|
|
260
|
+
| --- | --- |
|
|
261
|
+
| `@preact/signals-core` | Automatic computed values |
|
|
262
|
+
| `solid-js` | Automatic computed values |
|
|
263
|
+
| `oby` | Automatic computed values |
|
|
264
|
+
| `sinuous/observable` | Automatic computed values |
|
|
265
|
+
| `ulive` | Automatic computed values |
|
|
266
|
+
| `usignal` | Automatic computed values |
|
|
267
|
+
| `@vue/reactivity` | Automatic computed values using `shallowRef` |
|
|
268
|
+
| `alien-signals` | Automatic computed values |
|
|
269
|
+
| `s-js` | Automatic computed values using the provided branded factory |
|
|
270
|
+
| `mobx` | Automatic computed values using shallow observable boxes |
|
|
271
|
+
| `@tanstack/store` | Automatic computed values using the provided value wrapper |
|
|
272
|
+
| `@webreflection/signal` | Automatic computed values |
|
|
273
|
+
| `@webreflection/signals` | Automatic computed values |
|
|
274
|
+
| `@webreflection/alien-signals` | Automatic computed values |
|
|
275
|
+
| `rxjs` | Basic: `BehaviorSubject` values and direct getters |
|
|
276
|
+
| `nanostores` | Basic: atoms and direct getters |
|
|
277
|
+
|
|
278
|
+
**Basic adapters** support synchronous values, nested objects, arrays, and getters evaluated on access. RxJS and Nanostores do not automatically discover arbitrary getter dependencies, so automatic computed values are not advertised for them.
|
|
279
|
+
|
|
280
|
+
Adapter-specific notes:
|
|
281
|
+
|
|
282
|
+
- Vue and MobX use shallow containers to avoid wrapping UStor's proxies twice.
|
|
283
|
+
- TanStack Store treats bare functions as computed values; its tested adapter wraps writable values.
|
|
284
|
+
- S.js has no built-in signal brand; use the tested factory and create stores with computed values inside an S.js root.
|
|
285
|
+
- Solid.js, Vue, S.js, and similar libraries may require their own owner/scope lifecycle for short-lived computed values.
|
|
286
|
+
|
|
287
|
+
## Porting any signal library
|
|
288
|
+
|
|
289
|
+
You normally **do not need to modify UStor's core**. Port a library by translating its synchronous read/write API into four required functions, then add optional computed/effect functions when supported.
|
|
290
|
+
|
|
291
|
+
### 1. Map the four required operations
|
|
292
|
+
|
|
293
|
+
Use this template and replace the example method names with your library's API:
|
|
179
294
|
|
|
180
295
|
```javascript
|
|
181
|
-
|
|
182
|
-
|
|
296
|
+
import { store, api } from "ustor";
|
|
297
|
+
import * as library from "your-signal-library";
|
|
298
|
+
|
|
299
|
+
Object.assign(api, {
|
|
300
|
+
// Create a container that can hold any value.
|
|
301
|
+
signal: (value) => library.create(value),
|
|
183
302
|
|
|
184
|
-
|
|
185
|
-
|
|
303
|
+
// Return the current value synchronously.
|
|
304
|
+
get: (signal) => library.read(signal),
|
|
305
|
+
|
|
306
|
+
// Store this exact value and notify synchronously.
|
|
307
|
+
set: (signal, value) => library.write(signal, value),
|
|
308
|
+
|
|
309
|
+
// Return true only for signals from this library.
|
|
310
|
+
is: (value) => library.isSignal(value),
|
|
186
311
|
});
|
|
312
|
+
```
|
|
187
313
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
314
|
+
For a `.value`-based library, the mapping usually looks like this:
|
|
315
|
+
|
|
316
|
+
```javascript
|
|
317
|
+
Object.assign(api, {
|
|
318
|
+
signal: (value) => library.create(value),
|
|
319
|
+
get: (signal) => signal.value,
|
|
320
|
+
set: (signal, value) => { signal.value = value; },
|
|
321
|
+
is: (value) => value instanceof library.Signal,
|
|
322
|
+
});
|
|
191
323
|
```
|
|
192
324
|
|
|
193
|
-
|
|
325
|
+
For a callable-signal library:
|
|
194
326
|
|
|
195
|
-
|
|
327
|
+
```javascript
|
|
328
|
+
Object.assign(api, {
|
|
329
|
+
signal: (value) => library.create(value),
|
|
330
|
+
get: (signal) => signal(),
|
|
331
|
+
set: (signal, value) => signal(value),
|
|
332
|
+
is: (value) => typeof value === "function" && library.isSignal(value),
|
|
333
|
+
});
|
|
334
|
+
```
|
|
196
335
|
|
|
197
|
-
|
|
336
|
+
If the setter expects an updater rather than a direct value, adapt it explicitly:
|
|
198
337
|
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
console.log(s.count); // 0
|
|
202
|
-
console.log(s.$count); // The underlying signal object
|
|
338
|
+
```javascript
|
|
339
|
+
api.set = (signal, value) => signal.setState(() => value);
|
|
203
340
|
```
|
|
204
|
-
## Integration with Solid.js, Preact Signals or any
|
|
205
341
|
|
|
206
|
-
|
|
342
|
+
### 2. Preserve values without double-wrapping
|
|
207
343
|
|
|
208
|
-
|
|
344
|
+
`api.signal` must be able to store `null`, `undefined`, functions, arrays, objects, and UStor proxies.
|
|
345
|
+
|
|
346
|
+
- Use a **shallow** signal/ref/box when the library offers one.
|
|
347
|
+
- Do not make the contained object deeply observable a second time.
|
|
348
|
+
- Do not call a function merely because it was stored as a value.
|
|
349
|
+
- If a library interprets a function argument as a computed getter, place the value inside a shallow container or use the tested wrapper pattern from `adapters.spec.js`.
|
|
350
|
+
|
|
351
|
+
For example, the Vue adapter uses `shallowRef`, and the MobX adapter uses `observable.box(value, { deep: false })`.
|
|
352
|
+
|
|
353
|
+
### 3. Make `api.is` a precise brand check
|
|
354
|
+
|
|
355
|
+
A broad check can turn ordinary values into signals accidentally. Prefer `instanceof`, a library-provided brand check, or a documented marker.
|
|
356
|
+
|
|
357
|
+
```javascript
|
|
358
|
+
// Risky for callable-signal libraries: ordinary functions are values too.
|
|
359
|
+
api.is = (value) => typeof value === "function"; // Do not use this by itself.
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
If the library has no brand, create signals through a factory and track them in a `WeakSet`:
|
|
209
363
|
|
|
210
364
|
```javascript
|
|
211
|
-
|
|
365
|
+
const signals = new WeakSet();
|
|
212
366
|
|
|
213
|
-
api.signal =
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
v?.[0]?.name?.includes("readSignal");
|
|
367
|
+
api.signal = (value) => {
|
|
368
|
+
const signal = library.create(value);
|
|
369
|
+
signals.add(signal);
|
|
370
|
+
return signal;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
api.is = (value) => signals.has(value);
|
|
221
374
|
```
|
|
222
375
|
|
|
223
|
-
|
|
376
|
+
Create external signals through `api.signal` when using this approach. The S.js configuration in `adapters.spec.js` demonstrates it.
|
|
377
|
+
|
|
378
|
+
### 4. Add computed values only when dependencies are automatic
|
|
379
|
+
|
|
380
|
+
If the library can compute a value by automatically tracking signals read inside `fn`, add:
|
|
224
381
|
|
|
225
382
|
```javascript
|
|
226
|
-
|
|
383
|
+
Object.assign(api, {
|
|
384
|
+
computed: (fn) => library.computed(fn),
|
|
385
|
+
getComputed: (computed) => library.read(computed),
|
|
386
|
+
});
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The computed value may be lazy or eager, but it must invalidate when any dependency changes, including conditional dependencies.
|
|
390
|
+
|
|
391
|
+
If the library requires an explicit dependency list—as with RxJS or Nanostores—**omit `computed` and `getComputed`**. UStor then uses direct getters evaluated on access. Do not emulate automatic dependency tracking incorrectly.
|
|
227
392
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
393
|
+
### 5. Add effects for React integration only when compatible
|
|
394
|
+
|
|
395
|
+
`ustor/react` requires this additional contract:
|
|
396
|
+
|
|
397
|
+
```javascript
|
|
398
|
+
api.effect = (fn) => {
|
|
399
|
+
const dispose = library.effect(fn);
|
|
400
|
+
return () => dispose(); // Always return a cleanup function.
|
|
401
|
+
};
|
|
232
402
|
```
|
|
233
403
|
|
|
234
|
-
|
|
404
|
+
A compatible effect must:
|
|
405
|
+
|
|
406
|
+
1. Run `fn` and automatically track its synchronous signal reads.
|
|
407
|
+
2. Rerun synchronously after a dependency changes.
|
|
408
|
+
3. Return a cleanup function, or be wrapped in an owner/root that provides one.
|
|
409
|
+
|
|
410
|
+
The core store does not require `api.effect`. Basic adapters without automatic dependency discovery can still provide ordinary state reads and writes.
|
|
411
|
+
|
|
412
|
+
### 6. Configure once and manage lifecycle
|
|
413
|
+
|
|
414
|
+
Configure `api` before creating stores and keep the same implementation for their lifetime. If your library requires an owner, scope, root, or disposer for computed values/effects, create short-lived stores in that lifecycle and dispose them with their owner.
|
|
415
|
+
|
|
416
|
+
### 7. Validate the adapter
|
|
417
|
+
|
|
418
|
+
This Node script checks the required behavior, function preservation, nested updates, arrays, and optional computed values:
|
|
235
419
|
|
|
236
420
|
```javascript
|
|
237
|
-
import
|
|
421
|
+
import assert from "node:assert/strict";
|
|
422
|
+
import { store, api } from "ustor";
|
|
423
|
+
// Configure your adapter before these assertions.
|
|
424
|
+
|
|
425
|
+
const external = api.signal(1);
|
|
426
|
+
const state = store({
|
|
427
|
+
value: external,
|
|
428
|
+
nested: { count: 2 },
|
|
429
|
+
list: [1, 2],
|
|
430
|
+
fn: () => 7,
|
|
431
|
+
get total() {
|
|
432
|
+
return this.nested.count + this.list.reduce((sum, value) => sum + value, 0);
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
assert.equal(state.value, 1);
|
|
437
|
+
assert.equal(state.$value, external);
|
|
438
|
+
assert.equal(state.fn(), 7); // The function remains a value.
|
|
439
|
+
|
|
440
|
+
state.value = 3;
|
|
441
|
+
assert.equal(api.get(external), 3);
|
|
442
|
+
|
|
443
|
+
state.nested.count = 4;
|
|
444
|
+
assert.equal(state.total, 7);
|
|
445
|
+
|
|
446
|
+
state.list.push(4);
|
|
447
|
+
assert.equal(state.total, 11);
|
|
448
|
+
|
|
449
|
+
state.list[0] = 5;
|
|
450
|
+
assert.equal(state.total, 15);
|
|
451
|
+
|
|
452
|
+
if (api.computed) {
|
|
453
|
+
assert.equal((api.getComputed || api.get)(state.$total), 15);
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
A production-quality adapter should also verify:
|
|
458
|
+
|
|
459
|
+
- Whole-array replacement, deletion, and length changes.
|
|
460
|
+
- Conditional computed dependencies and no-op writes.
|
|
461
|
+
- Effect cleanup and behavior under nested/batched updates.
|
|
462
|
+
- React StrictMode and unmount behavior when used with `ustor/react`.
|
|
463
|
+
- Owner/scope disposal and long-running memory behavior.
|
|
464
|
+
- The library's ESM and CommonJS entry points in your target bundler.
|
|
465
|
+
|
|
466
|
+
For working examples, copy the closest configuration from `adapters.spec.js`, then run:
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
npm test
|
|
470
|
+
npm run test:coverage
|
|
471
|
+
npm run profile
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## React
|
|
475
|
+
|
|
476
|
+
Install React and import the optional hooks:
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
npm install react
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
```jsx
|
|
483
|
+
import { useSignal, useStore } from "ustor/react";
|
|
484
|
+
|
|
485
|
+
// Uses the Preact configuration from Quickstart, including api.effect.
|
|
486
|
+
const state = store({ count: 1, items: [1, 2] });
|
|
487
|
+
const selectTotal = () => state.items.reduce((sum, value) => sum + value, 0);
|
|
488
|
+
|
|
489
|
+
export function Counter() {
|
|
490
|
+
const count = useSignal(state.$count);
|
|
491
|
+
const total = useStore(selectTotal);
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<button onClick={() => { state.count++; state.items.push(state.count); }}>
|
|
495
|
+
{count} / {total}
|
|
496
|
+
</button>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
- `useSignal(signal)` subscribes to one `$property` signal.
|
|
502
|
+
- `useStore(selector)` tracks all compatible signal reads in the selector, including array versions.
|
|
503
|
+
- Selectors must return a primitive or stable reference, not a fresh object on every read.
|
|
504
|
+
- Keep selector functions stable with `useCallback` when they are created inside a component.
|
|
505
|
+
- React is an optional peer dependency and is excluded from the core bundle.
|
|
506
|
+
- StrictMode and unmount cleanup are tested.
|
|
507
|
+
|
|
508
|
+
Plain property reads do not schedule React renders by themselves; use the hooks or another React binding.
|
|
509
|
+
|
|
510
|
+
## SSR and Next.js
|
|
511
|
+
|
|
512
|
+
Yes. `useStore` and `useSignal` provide a server snapshot through `useSyncExternalStore`. Server rendering creates no effects; subscriptions start on the client.
|
|
513
|
+
|
|
514
|
+
The test suite verifies:
|
|
515
|
+
|
|
516
|
+
- Server rendering without `api.effect`.
|
|
517
|
+
- Independent per-request stores.
|
|
518
|
+
- Hydration without markup mismatches.
|
|
519
|
+
- Reactive updates after hydration.
|
|
520
|
+
|
|
521
|
+
### Next.js App Router example
|
|
522
|
+
|
|
523
|
+
Use the hooks in a **Client Component**. Create request- or component-owned state instead of exporting a request-specific singleton.
|
|
524
|
+
|
|
525
|
+
```jsx
|
|
526
|
+
"use client";
|
|
527
|
+
|
|
528
|
+
import { useCallback, useState } from "react";
|
|
529
|
+
import { store } from "ustor";
|
|
530
|
+
import { useSignal, useStore } from "ustor/react";
|
|
531
|
+
// Import your shared adapter configuration once:
|
|
532
|
+
// import "@/lib/ustor-setup";
|
|
533
|
+
|
|
534
|
+
export default function Counter({ initialCount }) {
|
|
535
|
+
const [state] = useState(() => store({
|
|
536
|
+
count: initialCount,
|
|
537
|
+
items: [1, 2],
|
|
538
|
+
}));
|
|
539
|
+
const selectTotal = useCallback(
|
|
540
|
+
() => state.items.reduce((sum, value) => sum + value, 0),
|
|
541
|
+
[state],
|
|
542
|
+
);
|
|
543
|
+
const count = useSignal(state.$count);
|
|
544
|
+
const total = useStore(selectTotal);
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<button onClick={() => { state.count++; state.items.push(1); }}>
|
|
548
|
+
{count} / {total}
|
|
549
|
+
</button>
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
```jsx
|
|
555
|
+
// app/page.jsx
|
|
556
|
+
import Counter from "./Counter";
|
|
557
|
+
|
|
558
|
+
export default function Page() {
|
|
559
|
+
return <Counter initialCount={1} />;
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
SSR requirements:
|
|
564
|
+
|
|
565
|
+
1. Use `"use client"` for components that call the hooks.
|
|
566
|
+
2. Pass the same serializable initial props during server rendering and hydration.
|
|
567
|
+
3. Do not mutate the rendered state between its server snapshot and initial hydration.
|
|
568
|
+
4. Do not store request-specific data in a module-level singleton shared by server requests.
|
|
569
|
+
5. UStor does not provide a dehydration format. Serialize and restore initial data with your framework when needed.
|
|
570
|
+
6. Configure `api.effect` for client hydration even though it is not used during server rendering.
|
|
571
|
+
|
|
572
|
+
The repository tests use `react-dom/server` and `hydrateRoot`, the same underlying React SSR primitives used by Next.js Client Components.
|
|
573
|
+
|
|
574
|
+
## TypeScript
|
|
238
575
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
576
|
+
Declarations are included for both entry points; no separate `@types/ustor` package is needed.
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
import { store } from "ustor";
|
|
580
|
+
import { useStore } from "ustor/react";
|
|
243
581
|
|
|
244
582
|
const state = store({
|
|
245
|
-
count:
|
|
583
|
+
count: 1,
|
|
584
|
+
nested: { text: "hello" },
|
|
585
|
+
list: [1, 2],
|
|
586
|
+
get double() { return this.count * 2; },
|
|
246
587
|
});
|
|
588
|
+
|
|
589
|
+
state.count = 2; // valid
|
|
590
|
+
// state.count = "invalid"; // TypeScript error
|
|
591
|
+
|
|
592
|
+
const doubled: number = state.double;
|
|
593
|
+
const text: string = state.nested.text;
|
|
594
|
+
const total = useStore(() => state.list.reduce((sum, value) => sum + value, 0));
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
Exported type utilities:
|
|
598
|
+
|
|
599
|
+
- `SignalApi<Signal, Computed>` for custom adapters.
|
|
600
|
+
- `Store<T>` for reactive store inference.
|
|
601
|
+
- `SignalValue<Signal>` for React signal-value inference.
|
|
602
|
+
|
|
603
|
+
Run the repository's strict compile-time tests with:
|
|
604
|
+
|
|
605
|
+
```bash
|
|
606
|
+
npm run typecheck
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
## Profiling and memory analysis
|
|
610
|
+
|
|
611
|
+
The profiler uses Preact Signals and measures:
|
|
612
|
+
|
|
613
|
+
- High-frequency computed property updates.
|
|
614
|
+
- Batched property updates.
|
|
615
|
+
- Bounded array mutation workloads.
|
|
616
|
+
- Repeated store/effect creation, updates, and disposal with heap/RSS samples.
|
|
617
|
+
|
|
618
|
+
Run a short smoke profile:
|
|
619
|
+
|
|
620
|
+
```bash
|
|
621
|
+
npm run profile:smoke
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
Run the default 30-second memory profile:
|
|
625
|
+
|
|
626
|
+
```bash
|
|
627
|
+
npm run profile
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
Run a longer custom profile:
|
|
631
|
+
|
|
632
|
+
```bash
|
|
633
|
+
npm run profile -- --updates=1000000 --array-updates=100000 --seconds=60 --sample-ms=1000
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
Write machine-readable output:
|
|
637
|
+
|
|
638
|
+
```bash
|
|
639
|
+
npm run --silent profile -- --json --seconds=30 > profile.json
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Profiling guidelines:
|
|
643
|
+
|
|
644
|
+
- Run with the same Node version and workload characteristics as production.
|
|
645
|
+
- Compare several samples after warm-up and forced GC, not one heap reading.
|
|
646
|
+
- A flat retained-heap trend is more useful than a single delta.
|
|
647
|
+
- RSS can grow early and plateau because of allocator behavior; RSS alone does not prove a leak.
|
|
648
|
+
- The CI smoke profile checks that the profiler remains runnable, not that every workload is leak-free.
|
|
649
|
+
- Repeat equivalent profiling with the adapter and application workload you actually deploy.
|
|
650
|
+
|
|
651
|
+
## Production notes
|
|
652
|
+
|
|
653
|
+
- Use one adapter per UStor module instance and keep it for the lifetime of existing stores.
|
|
654
|
+
- Dispose adapter owners/scopes and UI subscriptions for short-lived stores.
|
|
655
|
+
- Test your actual browser, bundler, SSR, and hydration path.
|
|
656
|
+
- Stress-test application-specific update patterns and long-running memory usage.
|
|
657
|
+
- Use basic RxJS/Nanostores adapters only where their direct-getter semantics are sufficient.
|
|
658
|
+
- Keep runtime and development artifacts separate; `dist` and declarations are the only published implementation files.
|
|
659
|
+
|
|
660
|
+
### Release checklist
|
|
661
|
+
|
|
662
|
+
The repository already checks unit and adapter behavior, TypeScript declarations, React, SSR/hydration, package formats, bundle size, coverage, and dependency audit status.
|
|
663
|
+
|
|
664
|
+
Before publishing a production release:
|
|
665
|
+
|
|
666
|
+
1. **Choose a new version and write release notes.** The changes since the original `0.1.0` include observable behavior changes. Do not publish them as the same version; use an appropriate new version and document getter, sealed-object, array-proxy, and packaging behavior.
|
|
667
|
+
2. **Test the packed artifact in a clean consumer application.** Run your real bundler and framework against the tarball rather than only against repository source.
|
|
668
|
+
3. **Exercise your production SSR path.** The generic React SSR primitives are tested, but a full Next.js or other framework application should verify its own rendering, dehydration, and hydration flow.
|
|
669
|
+
4. **Run target-browser and production-adapter checks.** Repeat performance and long-running memory profiling with the adapter and workload you deploy.
|
|
670
|
+
5. **Review the release artifact.** Confirm `npm pack --dry-run`, `npm audit`, the package entry points, declaration files, license, and version before tagging or publishing.
|
|
671
|
+
|
|
672
|
+
## Development
|
|
673
|
+
|
|
674
|
+
```bash
|
|
675
|
+
npm ci
|
|
676
|
+
npm test # build, type tests, unit/adapter/React/SSR tests, package smoke tests
|
|
677
|
+
npm run typecheck # strict TypeScript declaration tests
|
|
678
|
+
npm run test:coverage # enforced 100% runtime coverage
|
|
679
|
+
npm run test:watch
|
|
680
|
+
npm run examples # isolated Node examples and JSX/TypeScript checks
|
|
681
|
+
npm run profile:smoke
|
|
682
|
+
npm run build
|
|
247
683
|
```
|
|
248
684
|
|
|
249
|
-
The
|
|
685
|
+
The core size budget is enforced at 2 KiB minified and 1 KiB gzipped. `dist` is generated during build/prepack; declarations are published from `types/`.
|
|
250
686
|
|
|
251
|
-
|
|
687
|
+
## License
|
|
252
688
|
|
|
253
|
-
|
|
689
|
+
[MIT](./LICENSE)
|
|
254
690
|
|
|
255
|
-
|
|
691
|
+
## Thanks and inspiration
|
|
256
692
|
|
|
257
|
-
-
|
|
693
|
+
- [dy](https://github.com/dy)
|