totally-tubular 0.1.2 → 0.3.1
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 +76 -95
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/react.cjs +42 -0
- package/dist/cjs/react.d.ts +30 -0
- package/dist/cjs/totally-tubular.cjs +80 -0
- package/dist/{totally-tubular.d.ts → cjs/totally-tubular.d.ts} +5 -5
- package/dist/cjs/types.cjs +4 -0
- package/dist/cjs/types.d.ts +53 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/react.d.ts +30 -0
- package/dist/esm/react.mjs +58 -0
- package/dist/esm/totally-tubular.d.ts +30 -0
- package/dist/{totally-tubular.js → esm/totally-tubular.mjs} +25 -31
- package/dist/esm/types.d.ts +53 -0
- package/dist/esm/types.mjs +11 -0
- package/package.json +52 -11
- package/dist/types.d.ts +0 -10
- package/dist/types.js +0 -1
package/README.md
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
1
|
# totally-tubular
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A framework-agnostic JavaScript / TypeScript state management library with zero dependencies, a tiny file size, and solid performance.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Why another state management library?
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
State management has a tendency to get complicated fast. This library is an experiment in keeping things simple:
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
and easy-to-oopsie performance faux pas.
|
|
13
|
-
|
|
14
|
-
Additionally, our shipped JavaScript bundles should be getting **smaller**, not **bigger**,
|
|
15
|
-
but it's all-too-easy, especially when crafting enterprise software, to keep shoving more libraries
|
|
16
|
-
into your bundle with questionable benefits, when there often exists a much simpler, closer-to-vanilla
|
|
17
|
-
way to do what you need to do.
|
|
18
|
-
|
|
19
|
-
As a final point, your state management system should be fully-portable, so if you swap out
|
|
20
|
-
your rendering layer, be it React, Svelte, Angular, or something else, the meat-and-potatoes of
|
|
21
|
-
your state management shouldn't need to be fully rewritten.
|
|
9
|
+
- **Portable.** Your store is plain TypeScript. If you swap React for Svelte, or add a Node.js background worker, the store itself doesn't change.
|
|
10
|
+
- **Small.** No framework coupling, no middleware pipeline, no magic — just a class that holds state, lets you read it, update it, and watch it for changes.
|
|
11
|
+
- **Honest.** It mutates your state in place for performance. If you want immutability, return new objects from your updater functions.
|
|
22
12
|
|
|
23
13
|
## Install
|
|
24
14
|
|
|
@@ -26,12 +16,25 @@ your state management shouldn't need to be fully rewritten.
|
|
|
26
16
|
npm i totally-tubular --save
|
|
27
17
|
```
|
|
28
18
|
|
|
19
|
+
## Core concepts
|
|
20
|
+
|
|
21
|
+
A `Tubular` instance wraps an object and gives you four things:
|
|
22
|
+
|
|
23
|
+
| Method | What it does |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `read(path)` | Returns the current value at a dot-separated path |
|
|
26
|
+
| `update(path, fn)` | Calls `fn` with the current value, stores the return value, then notifies all observers |
|
|
27
|
+
| `observe(path, fn)` | Registers a callback that fires whenever `path` is updated |
|
|
28
|
+
| `unobserve(path, fn)` | Removes a previously registered callback |
|
|
29
|
+
|
|
30
|
+
Paths are fully type-checked. TypeScript knows every valid dot-path into your state shape, so you get autocomplete and a compile error if you mistype a key.
|
|
31
|
+
|
|
29
32
|
## Quick start
|
|
30
33
|
|
|
31
34
|
```typescript
|
|
32
35
|
import { Tubular } from "totally-tubular";
|
|
33
36
|
|
|
34
|
-
interface
|
|
37
|
+
interface AppState {
|
|
35
38
|
isActive: boolean;
|
|
36
39
|
count: number;
|
|
37
40
|
meta: {
|
|
@@ -40,107 +43,89 @@ interface MyStateShape {
|
|
|
40
43
|
name: string;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
const
|
|
46
|
+
const store = new Tubular<AppState>({
|
|
44
47
|
isActive: false,
|
|
45
48
|
count: 123,
|
|
46
|
-
meta: {
|
|
47
|
-
title: "Director",
|
|
48
|
-
},
|
|
49
|
+
meta: { title: "Director" },
|
|
49
50
|
name: "Test User",
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const t = new Tubular(initialState);
|
|
51
|
+
});
|
|
53
52
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
console.info(propPath, "was updated from", oldVal, "to", newVal),
|
|
53
|
+
// Watch a value for changes
|
|
54
|
+
store.observe("meta.title", (newVal, oldVal) =>
|
|
55
|
+
console.info("title changed from", oldVal, "to", newVal),
|
|
58
56
|
);
|
|
59
57
|
|
|
60
|
-
console.info(
|
|
58
|
+
console.info(store.read("meta.title")); // "Director"
|
|
59
|
+
|
|
60
|
+
// Update a value — all observers are notified immediately after
|
|
61
|
+
store.update("meta.title", (prev) => `Supreme ${prev}`);
|
|
61
62
|
|
|
62
|
-
//
|
|
63
|
-
// all observers will be notified after the value has been set
|
|
64
|
-
t.update("meta.title", (prev) => `Supreme ${prev}`);
|
|
65
|
-
console.info(t.read("meta.title")); // will print the updated 'Supreme Director' value
|
|
63
|
+
console.info(store.read("meta.title")); // "Supreme Director"
|
|
66
64
|
```
|
|
67
65
|
|
|
68
|
-
|
|
66
|
+
## React
|
|
69
67
|
|
|
70
|
-
|
|
68
|
+
Import `useTubular` from `totally-tubular/react`. It works exactly like React's
|
|
69
|
+
built-in `useState` — you get back a `[value, setter]` tuple — except the value
|
|
70
|
+
comes from your store and every component watching the same path re-renders when
|
|
71
|
+
it changes.
|
|
71
72
|
|
|
72
73
|
```tsx
|
|
73
74
|
import { Tubular } from "totally-tubular";
|
|
74
|
-
import {
|
|
75
|
+
import { useTubular } from "totally-tubular/react";
|
|
75
76
|
|
|
76
|
-
interface
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
};
|
|
80
|
-
time: number;
|
|
81
|
-
username: string;
|
|
77
|
+
interface AppState {
|
|
78
|
+
user: { name: string };
|
|
79
|
+
count: number;
|
|
82
80
|
}
|
|
83
81
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const t = new Tubular<MyState>(initialState);
|
|
82
|
+
// Create the store once, outside your components
|
|
83
|
+
const store = new Tubular<AppState>({
|
|
84
|
+
user: { name: "Alice" },
|
|
85
|
+
count: 0,
|
|
86
|
+
});
|
|
91
87
|
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
d.setTime(timestamp);
|
|
95
|
-
return d.toLocaleTimeString();
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
export default function App() {
|
|
99
|
-
/** state */
|
|
100
|
-
const [username, setUsername] = useState(initialState.username);
|
|
101
|
-
const [time, setTime] = useState(initialState.time);
|
|
102
|
-
|
|
103
|
-
/** effects */
|
|
104
|
-
useEffect(() => {
|
|
105
|
-
t.observe("username", (v) => setUsername(v));
|
|
106
|
-
t.observe("time", (t) => setTime(t));
|
|
107
|
-
}, []);
|
|
108
|
-
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
const interval = setInterval(() => t.update("time", (oldVal) => oldVal + 1000), 1000);
|
|
111
|
-
|
|
112
|
-
return () => {
|
|
113
|
-
clearInterval(interval);
|
|
114
|
-
};
|
|
115
|
-
}, []);
|
|
88
|
+
function Counter() {
|
|
89
|
+
const [count, setCount] = useTubular(store, "count");
|
|
116
90
|
|
|
117
91
|
return (
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
</div>
|
|
92
|
+
<button onClick={() => setCount((n) => (n ?? 0) + 1)}>
|
|
93
|
+
Clicked {count} times
|
|
94
|
+
</button>
|
|
122
95
|
);
|
|
123
96
|
}
|
|
97
|
+
|
|
98
|
+
function Greeting() {
|
|
99
|
+
// Read-only — just destructure the first element
|
|
100
|
+
const [name] = useTubular(store, "user.name");
|
|
101
|
+
return <p>Hello, {name}!</p>;
|
|
102
|
+
}
|
|
124
103
|
```
|
|
125
104
|
|
|
126
|
-
|
|
105
|
+
The setter accepts either a plain value or an updater function:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// Plain value
|
|
109
|
+
setCount(10);
|
|
110
|
+
|
|
111
|
+
// Updater function (receives the current value)
|
|
112
|
+
setCount((prev) => (prev ?? 0) + 1);
|
|
113
|
+
```
|
|
127
114
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
If you want to be notified of changes to an array or you want to update some item in an array, you should use the path to the array in your state object, then perform reads or transformations on that.
|
|
131
|
-
- This library **mutates your originally-provided `initialState`**.
|
|
132
|
-
This is for performance reasons, as mutating objects doesn't require cloning and iterating on all object properties, saving on computation time.
|
|
133
|
-
If you want to ensure immutability, you should make your calls to `.update()` return new objects, array, etc.
|
|
115
|
+
`useTubular` automatically unsubscribes when the component unmounts, so you
|
|
116
|
+
don't need to manage cleanup yourself.
|
|
134
117
|
|
|
135
|
-
##
|
|
118
|
+
## Things you should know
|
|
136
119
|
|
|
137
|
-
|
|
120
|
+
- **Arrays and nested objects are observed at their path, not their contents.**
|
|
121
|
+
`observe("items", fn)` fires when `items` itself is replaced. To add an item
|
|
122
|
+
to an array, use `update("items", prev => [...prev, newItem])`.
|
|
123
|
+
- **Arbitrary array indices are not observable.** You cannot do `observe("items.0", fn)`. If you need per-item reactivity, store each item in its own `Tubular` instance.
|
|
124
|
+
- **State is mutated in place.** `totally-tubular` does not clone your state on every update. This keeps things fast, but means the object you pass to `new Tubular(initialState)` will be modified directly. If that matters to you, pass a deep clone as the initial state.
|
|
138
125
|
|
|
139
|
-
|
|
126
|
+
## Performance benchmarks
|
|
140
127
|
|
|
141
|
-
|
|
142
|
-
RAM: 36GB
|
|
143
|
-
OS: MacOS Sequoia 15.5
|
|
128
|
+
### Apple M3 Max · 36 GB RAM · macOS Sequoia 15.5
|
|
144
129
|
|
|
145
130
|
```
|
|
146
131
|
-----shallow state object-----
|
|
@@ -189,11 +174,7 @@ OS: MacOS Sequoia 15.5
|
|
|
189
174
|
1,000,000 updates to "zdeepSettings.level1.level2.level3.level4.level5.level6.currentValue" string val with 1000 observers: 1.1015419579999999s
|
|
190
175
|
```
|
|
191
176
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
CPU: Intel i9-13900HX
|
|
195
|
-
RAM: 32GB
|
|
196
|
-
OS: Windows 11
|
|
177
|
+
### Intel i9-13900HX · 32 GB RAM · Windows 11
|
|
197
178
|
|
|
198
179
|
```
|
|
199
180
|
-----shallow state object-----
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "commonjs" }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
Object.defineProperty(exports, "useTubular", {
|
|
6
|
+
enumerable: true,
|
|
7
|
+
get: function() {
|
|
8
|
+
return useTubular;
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
const _react = require("./react.cjs");
|
|
12
|
+
function useTubular(tubular, key) {
|
|
13
|
+
/** state */ const [val, setVal] = (0, _react.useState)(()=>tubular.read(key));
|
|
14
|
+
/** callbacks */ const handleUpdateVal = (0, _react.useCallback)((newValOrCallback)=>{
|
|
15
|
+
if (typeof newValOrCallback === 'function') {
|
|
16
|
+
tubular.update(key, newValOrCallback);
|
|
17
|
+
} else {
|
|
18
|
+
tubular.update(key, ()=>newValOrCallback);
|
|
19
|
+
}
|
|
20
|
+
}, [
|
|
21
|
+
tubular,
|
|
22
|
+
key
|
|
23
|
+
]);
|
|
24
|
+
/** effects */ (0, _react.useEffect)(()=>{
|
|
25
|
+
// Sync in case the value changed between render and effect registration
|
|
26
|
+
setVal(tubular.read(key));
|
|
27
|
+
const observeCb = (newVal)=>{
|
|
28
|
+
setVal(newVal);
|
|
29
|
+
};
|
|
30
|
+
tubular.observe(key, observeCb);
|
|
31
|
+
return ()=>{
|
|
32
|
+
tubular.unobserve(key, observeCb);
|
|
33
|
+
};
|
|
34
|
+
}, [
|
|
35
|
+
tubular,
|
|
36
|
+
key
|
|
37
|
+
]);
|
|
38
|
+
return [
|
|
39
|
+
val,
|
|
40
|
+
handleUpdateVal
|
|
41
|
+
];
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Tubular } from './totally-tubular.js';
|
|
2
|
+
import type { AllObjectKeys, PropType } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A React hook that binds to a {@link Tubular} instance and a specific key path
|
|
5
|
+
* within its state.
|
|
6
|
+
*
|
|
7
|
+
* Returns a `[value, setter]` tuple that works exactly like React's built-in
|
|
8
|
+
* `useState`, except reads and writes go through the Tubular store — so every
|
|
9
|
+
* component (and non-React observer) watching the same path is notified on
|
|
10
|
+
* every update.
|
|
11
|
+
*
|
|
12
|
+
* The setter accepts either a new value directly or an updater function that
|
|
13
|
+
* receives the current value and returns the next one.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const store = new Tubular({ user: { name: 'Alice' }, count: 0 });
|
|
18
|
+
*
|
|
19
|
+
* function Counter() {
|
|
20
|
+
* const [count, setCount] = useTubular(store, 'count');
|
|
21
|
+
* return <button onClick={() => setCount(n => (n ?? 0) + 1)}>{count}</button>;
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* function Greeting() {
|
|
25
|
+
* const [name] = useTubular(store, 'user.name');
|
|
26
|
+
* return <p>Hello, {name}!</p>;
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function useTubular<T extends object, K extends string>(tubular: Tubular<T>, key: K & AllObjectKeys<T>): readonly [PropType<T, K> | null, (newValOrCallback: PropType<T, K> | ((prevVal: PropType<T, K> | null) => PropType<T, K>)) => void];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
Object.defineProperty(exports, "Tubular", {
|
|
6
|
+
enumerable: true,
|
|
7
|
+
get: function() {
|
|
8
|
+
return Tubular;
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
class Tubular {
|
|
12
|
+
observationCallbacks = new Map();
|
|
13
|
+
state;
|
|
14
|
+
constructor(initialState){
|
|
15
|
+
this.state = initialState;
|
|
16
|
+
}
|
|
17
|
+
doSomethingToPath(readPath, doSomething) {
|
|
18
|
+
let thing = this.state;
|
|
19
|
+
if (typeof readPath !== 'string') {
|
|
20
|
+
return this.getPortionOfStateByPath(thing[readPath]);
|
|
21
|
+
}
|
|
22
|
+
const splitPath = readPath.split('.');
|
|
23
|
+
const len = splitPath.length;
|
|
24
|
+
for(let i = 0; i < len; i++){
|
|
25
|
+
const p = splitPath[i] || '';
|
|
26
|
+
if (i === len - 1) {
|
|
27
|
+
if (thing) thing[p] = doSomething(thing[p]);
|
|
28
|
+
} else thing = thing?.[p];
|
|
29
|
+
}
|
|
30
|
+
for (const p of splitPath){
|
|
31
|
+
thing = thing?.[p];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
getPortionOfStateByPath(readPath) {
|
|
35
|
+
let thing = this.state;
|
|
36
|
+
if (typeof readPath !== 'string') {
|
|
37
|
+
return this.getPortionOfStateByPath(thing[readPath]);
|
|
38
|
+
}
|
|
39
|
+
const splitPath = readPath.split('.');
|
|
40
|
+
for (const p of splitPath){
|
|
41
|
+
thing = thing?.[p];
|
|
42
|
+
}
|
|
43
|
+
return thing ?? null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* observe changes to some part of state that was updated
|
|
47
|
+
*/ observe(propPath, observeCallback) {
|
|
48
|
+
const prevForPath = this.observationCallbacks.get(propPath) ?? [];
|
|
49
|
+
this.observationCallbacks.set(propPath, [
|
|
50
|
+
...prevForPath,
|
|
51
|
+
observeCallback
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* reads a portion of your state
|
|
56
|
+
*/ read(readPath) {
|
|
57
|
+
return this.getPortionOfStateByPath(readPath);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* disconnects an observer callback from totally tubular
|
|
61
|
+
*/ unobserve(propPath, observeCallback) {
|
|
62
|
+
const observers = this.observationCallbacks.get(propPath);
|
|
63
|
+
if (!observers?.length) return;
|
|
64
|
+
this.observationCallbacks.set(propPath, observers.filter((ob)=>ob !== observeCallback));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* updates a portion of your state
|
|
68
|
+
*/ update(updatePath, valUpdater) {
|
|
69
|
+
const prevVal = this.read(updatePath);
|
|
70
|
+
let newVal = null;
|
|
71
|
+
this.doSomethingToPath(updatePath, (oldVal)=>{
|
|
72
|
+
const n = valUpdater(oldVal);
|
|
73
|
+
newVal = n;
|
|
74
|
+
return n;
|
|
75
|
+
});
|
|
76
|
+
for (const cb of this.observationCallbacks.get(updatePath) ?? []){
|
|
77
|
+
cb(newVal, prevVal, updatePath);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AllObjectKeys, PropType } from './types.js';
|
|
1
|
+
import type { AllObjectKeys, ObservationCallback, PropType } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* A totally tubular instance that tracks a top-level
|
|
4
4
|
* set of keys and values.
|
|
@@ -14,17 +14,17 @@ export declare class Tubular<T extends object> {
|
|
|
14
14
|
/**
|
|
15
15
|
* observe changes to some part of state that was updated
|
|
16
16
|
*/
|
|
17
|
-
observe<K extends
|
|
17
|
+
observe<K extends string>(propPath: K & AllObjectKeys<T>, observeCallback: ObservationCallback<T>): void;
|
|
18
18
|
/**
|
|
19
19
|
* reads a portion of your state
|
|
20
20
|
*/
|
|
21
|
-
read<K extends
|
|
21
|
+
read<K extends string>(readPath: K & AllObjectKeys<T>): PropType<T, K> | null;
|
|
22
22
|
/**
|
|
23
23
|
* disconnects an observer callback from totally tubular
|
|
24
24
|
*/
|
|
25
|
-
unobserve<K extends
|
|
25
|
+
unobserve<K extends string>(propPath: K & AllObjectKeys<T>, observeCallback: ObservationCallback<T>): void;
|
|
26
26
|
/**
|
|
27
27
|
* updates a portion of your state
|
|
28
28
|
*/
|
|
29
|
-
update<K extends
|
|
29
|
+
update<K extends string>(updatePath: K & AllObjectKeys<T>, valUpdater: (oldVal: PropType<T, K>) => PropType<T, K>): void;
|
|
30
30
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type TSPrimitve = string | number | bigint | boolean | symbol | undefined | null;
|
|
2
|
+
/**
|
|
3
|
+
* Single-check collection guard.
|
|
4
|
+
* `readonly any[]` covers both `any[]` and `ReadonlyArray<any>` since `Array<T>`
|
|
5
|
+
* extends `ReadonlyArray<T>`. One distributive check instead of four sequential ones.
|
|
6
|
+
*/
|
|
7
|
+
type IsCollection<T> = T extends readonly any[] | Map<any, any> | Set<any> ? true : false;
|
|
8
|
+
/**
|
|
9
|
+
* Produces every dot-separated key path into T as a string union.
|
|
10
|
+
*
|
|
11
|
+
* - The `Depth` tuple acts as a recursion counter: when its length reaches 5 the
|
|
12
|
+
* type resolves to `never`, which caps the union size and prevents the language
|
|
13
|
+
* server from hanging on deeply-nested or self-referential shapes.
|
|
14
|
+
* - Top-level keys are included as bare strings (`K`), so this type supersedes
|
|
15
|
+
* `keyof T & string` — there is no need for a separate union with `keyof T`.
|
|
16
|
+
* - Collections (arrays, Map, Set) are terminated at their key rather than
|
|
17
|
+
* recursed into.
|
|
18
|
+
* - Returning `never` (not `''`) from every terminal branch avoids the need for
|
|
19
|
+
* a secondary `Extract<D, string>` pass over the full union.
|
|
20
|
+
*/
|
|
21
|
+
export type DotNestedKeys<T, Depth extends 1[] = []> = Depth['length'] extends 5 ? never : T extends object ? IsCollection<T> extends true ? never : {
|
|
22
|
+
[K in keyof T & string]: IsCollection<T[K]> extends true ? K : T[K] extends object ? K | `${K}.${DotNestedKeys<T[K], [...Depth, 1]>}` : K;
|
|
23
|
+
}[keyof T & string] : never;
|
|
24
|
+
/**
|
|
25
|
+
* All valid key paths into T (top-level and dot-nested).
|
|
26
|
+
*
|
|
27
|
+
* `DotNestedKeys<T>` already emits top-level string keys alongside nested paths,
|
|
28
|
+
* so a `keyof T` union is redundant and was previously responsible for duplicate
|
|
29
|
+
* entries that the checker had to deduplicate on every instantiation.
|
|
30
|
+
*/
|
|
31
|
+
export type AllObjectKeys<T> = DotNestedKeys<T>;
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the value type at a dot-separated path P within T.
|
|
34
|
+
*
|
|
35
|
+
* The intermediate `Rest extends AllObjectKeys<T[Head]>` guard that previously
|
|
36
|
+
* existed here forced a full re-evaluation of `AllObjectKeys` at every recursive
|
|
37
|
+
* level. It has been removed: if `Rest` is not a valid path the recursion
|
|
38
|
+
* naturally returns `never`, which is the correct result.
|
|
39
|
+
*/
|
|
40
|
+
export type PropType<T, P extends string> = P extends keyof T ? T[P] : P extends `${infer Head}.${infer Rest}` ? Head extends keyof T ? PropType<T[Head], Rest> : never : never;
|
|
41
|
+
/**
|
|
42
|
+
* Callback invoked by Tubular whenever an observed path changes.
|
|
43
|
+
*
|
|
44
|
+
* The previous definition used defaulted type parameters
|
|
45
|
+
* (`K = AllObjectKeys<T>`, `V = PropType<T, K & string>`). Default type
|
|
46
|
+
* parameters are eagerly evaluated by the checker at *every* reference to
|
|
47
|
+
* `ObservationCallback<T>`, causing a full `AllObjectKeys<T>` + distributed
|
|
48
|
+
* `PropType` expansion each time. Using `unknown` for the value parameters
|
|
49
|
+
* defers any narrowing to call sites that actually need it, which is a far
|
|
50
|
+
* smaller surface area.
|
|
51
|
+
*/
|
|
52
|
+
export type ObservationCallback<T> = (newVal: unknown, oldVal: unknown, propPath: AllObjectKeys<T>) => void;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "module" }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Tubular } from './totally-tubular.js';
|
|
2
|
+
import type { AllObjectKeys, PropType } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A React hook that binds to a {@link Tubular} instance and a specific key path
|
|
5
|
+
* within its state.
|
|
6
|
+
*
|
|
7
|
+
* Returns a `[value, setter]` tuple that works exactly like React's built-in
|
|
8
|
+
* `useState`, except reads and writes go through the Tubular store — so every
|
|
9
|
+
* component (and non-React observer) watching the same path is notified on
|
|
10
|
+
* every update.
|
|
11
|
+
*
|
|
12
|
+
* The setter accepts either a new value directly or an updater function that
|
|
13
|
+
* receives the current value and returns the next one.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const store = new Tubular({ user: { name: 'Alice' }, count: 0 });
|
|
18
|
+
*
|
|
19
|
+
* function Counter() {
|
|
20
|
+
* const [count, setCount] = useTubular(store, 'count');
|
|
21
|
+
* return <button onClick={() => setCount(n => (n ?? 0) + 1)}>{count}</button>;
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* function Greeting() {
|
|
25
|
+
* const [name] = useTubular(store, 'user.name');
|
|
26
|
+
* return <p>Hello, {name}!</p>;
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function useTubular<T extends object, K extends string>(tubular: Tubular<T>, key: K & AllObjectKeys<T>): readonly [PropType<T, K> | null, (newValOrCallback: PropType<T, K> | ((prevVal: PropType<T, K> | null) => PropType<T, K>)) => void];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from './react.mjs';
|
|
2
|
+
/**
|
|
3
|
+
* A React hook that binds to a {@link Tubular} instance and a specific key path
|
|
4
|
+
* within its state.
|
|
5
|
+
*
|
|
6
|
+
* Returns a `[value, setter]` tuple that works exactly like React's built-in
|
|
7
|
+
* `useState`, except reads and writes go through the Tubular store — so every
|
|
8
|
+
* component (and non-React observer) watching the same path is notified on
|
|
9
|
+
* every update.
|
|
10
|
+
*
|
|
11
|
+
* The setter accepts either a new value directly or an updater function that
|
|
12
|
+
* receives the current value and returns the next one.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const store = new Tubular({ user: { name: 'Alice' }, count: 0 });
|
|
17
|
+
*
|
|
18
|
+
* function Counter() {
|
|
19
|
+
* const [count, setCount] = useTubular(store, 'count');
|
|
20
|
+
* return <button onClick={() => setCount(n => (n ?? 0) + 1)}>{count}</button>;
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* function Greeting() {
|
|
24
|
+
* const [name] = useTubular(store, 'user.name');
|
|
25
|
+
* return <p>Hello, {name}!</p>;
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/ export function useTubular(tubular, key) {
|
|
29
|
+
/** state */ const [val, setVal] = useState(()=>tubular.read(key));
|
|
30
|
+
/** callbacks */ const handleUpdateVal = useCallback((newValOrCallback)=>{
|
|
31
|
+
if (typeof newValOrCallback === 'function') {
|
|
32
|
+
tubular.update(key, newValOrCallback);
|
|
33
|
+
} else {
|
|
34
|
+
tubular.update(key, ()=>newValOrCallback);
|
|
35
|
+
}
|
|
36
|
+
}, [
|
|
37
|
+
tubular,
|
|
38
|
+
key
|
|
39
|
+
]);
|
|
40
|
+
/** effects */ useEffect(()=>{
|
|
41
|
+
// Sync in case the value changed between render and effect registration
|
|
42
|
+
setVal(tubular.read(key));
|
|
43
|
+
const observeCb = (newVal)=>{
|
|
44
|
+
setVal(newVal);
|
|
45
|
+
};
|
|
46
|
+
tubular.observe(key, observeCb);
|
|
47
|
+
return ()=>{
|
|
48
|
+
tubular.unobserve(key, observeCb);
|
|
49
|
+
};
|
|
50
|
+
}, [
|
|
51
|
+
tubular,
|
|
52
|
+
key
|
|
53
|
+
]);
|
|
54
|
+
return [
|
|
55
|
+
val,
|
|
56
|
+
handleUpdateVal
|
|
57
|
+
];
|
|
58
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AllObjectKeys, ObservationCallback, PropType } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A totally tubular instance that tracks a top-level
|
|
4
|
+
* set of keys and values.
|
|
5
|
+
* if you want to support nested or "deep" state updates and observations,
|
|
6
|
+
* you should construct your state to be a series of Tubular instances
|
|
7
|
+
*/
|
|
8
|
+
export declare class Tubular<T extends object> {
|
|
9
|
+
private observationCallbacks;
|
|
10
|
+
private state;
|
|
11
|
+
constructor(initialState: T);
|
|
12
|
+
private doSomethingToPath;
|
|
13
|
+
private getPortionOfStateByPath;
|
|
14
|
+
/**
|
|
15
|
+
* observe changes to some part of state that was updated
|
|
16
|
+
*/
|
|
17
|
+
observe<K extends string>(propPath: K & AllObjectKeys<T>, observeCallback: ObservationCallback<T>): void;
|
|
18
|
+
/**
|
|
19
|
+
* reads a portion of your state
|
|
20
|
+
*/
|
|
21
|
+
read<K extends string>(readPath: K & AllObjectKeys<T>): PropType<T, K> | null;
|
|
22
|
+
/**
|
|
23
|
+
* disconnects an observer callback from totally tubular
|
|
24
|
+
*/
|
|
25
|
+
unobserve<K extends string>(propPath: K & AllObjectKeys<T>, observeCallback: ObservationCallback<T>): void;
|
|
26
|
+
/**
|
|
27
|
+
* updates a portion of your state
|
|
28
|
+
*/
|
|
29
|
+
update<K extends string>(updatePath: K & AllObjectKeys<T>, valUpdater: (oldVal: PropType<T, K>) => PropType<T, K>): void;
|
|
30
|
+
}
|
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
* set of keys and values.
|
|
4
4
|
* if you want to support nested or "deep" state updates and observations,
|
|
5
5
|
* you should construct your state to be a series of Tubular instances
|
|
6
|
-
*/
|
|
7
|
-
export class Tubular {
|
|
6
|
+
*/ export class Tubular {
|
|
8
7
|
observationCallbacks = new Map();
|
|
9
8
|
state;
|
|
10
|
-
constructor(initialState)
|
|
9
|
+
constructor(initialState){
|
|
11
10
|
this.state = initialState;
|
|
12
11
|
}
|
|
13
12
|
doSomethingToPath(readPath, doSomething) {
|
|
@@ -17,16 +16,13 @@ export class Tubular {
|
|
|
17
16
|
}
|
|
18
17
|
const splitPath = readPath.split('.');
|
|
19
18
|
const len = splitPath.length;
|
|
20
|
-
for
|
|
19
|
+
for(let i = 0; i < len; i++){
|
|
21
20
|
const p = splitPath[i] || '';
|
|
22
21
|
if (i === len - 1) {
|
|
23
|
-
if (thing)
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
else
|
|
27
|
-
thing = thing?.[p];
|
|
22
|
+
if (thing) thing[p] = doSomething(thing[p]);
|
|
23
|
+
} else thing = thing?.[p];
|
|
28
24
|
}
|
|
29
|
-
for (const p of splitPath)
|
|
25
|
+
for (const p of splitPath){
|
|
30
26
|
thing = thing?.[p];
|
|
31
27
|
}
|
|
32
28
|
}
|
|
@@ -36,46 +32,44 @@ export class Tubular {
|
|
|
36
32
|
return this.getPortionOfStateByPath(thing[readPath]);
|
|
37
33
|
}
|
|
38
34
|
const splitPath = readPath.split('.');
|
|
39
|
-
for (const p of splitPath)
|
|
35
|
+
for (const p of splitPath){
|
|
40
36
|
thing = thing?.[p];
|
|
41
37
|
}
|
|
42
38
|
return thing ?? null;
|
|
43
39
|
}
|
|
44
40
|
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
observe(propPath, observeCallback) {
|
|
41
|
+
* observe changes to some part of state that was updated
|
|
42
|
+
*/ observe(propPath, observeCallback) {
|
|
48
43
|
const prevForPath = this.observationCallbacks.get(propPath) ?? [];
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
this.observationCallbacks.set(propPath, [
|
|
45
|
+
...prevForPath,
|
|
46
|
+
observeCallback
|
|
47
|
+
]);
|
|
51
48
|
}
|
|
52
49
|
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
read(readPath) {
|
|
50
|
+
* reads a portion of your state
|
|
51
|
+
*/ read(readPath) {
|
|
56
52
|
return this.getPortionOfStateByPath(readPath);
|
|
57
53
|
}
|
|
58
54
|
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
unobserve(propPath, observeCallback) {
|
|
55
|
+
* disconnects an observer callback from totally tubular
|
|
56
|
+
*/ unobserve(propPath, observeCallback) {
|
|
62
57
|
const observers = this.observationCallbacks.get(propPath);
|
|
63
|
-
if (!observers?.length)
|
|
64
|
-
|
|
65
|
-
this.observationCallbacks.set(propPath, observers.filter(ob => ob !== observeCallback));
|
|
58
|
+
if (!observers?.length) return;
|
|
59
|
+
this.observationCallbacks.set(propPath, observers.filter((ob)=>ob !== observeCallback));
|
|
66
60
|
}
|
|
67
61
|
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
update(updatePath, valUpdater) {
|
|
62
|
+
* updates a portion of your state
|
|
63
|
+
*/ update(updatePath, valUpdater) {
|
|
71
64
|
const prevVal = this.read(updatePath);
|
|
72
65
|
let newVal = null;
|
|
73
|
-
this.doSomethingToPath(updatePath, oldVal
|
|
66
|
+
this.doSomethingToPath(updatePath, (oldVal)=>{
|
|
74
67
|
const n = valUpdater(oldVal);
|
|
75
68
|
newVal = n;
|
|
76
69
|
return n;
|
|
77
70
|
});
|
|
78
|
-
|
|
79
|
-
|
|
71
|
+
for (const cb of this.observationCallbacks.get(updatePath) ?? []){
|
|
72
|
+
cb(newVal, prevVal, updatePath);
|
|
73
|
+
}
|
|
80
74
|
}
|
|
81
75
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type TSPrimitve = string | number | bigint | boolean | symbol | undefined | null;
|
|
2
|
+
/**
|
|
3
|
+
* Single-check collection guard.
|
|
4
|
+
* `readonly any[]` covers both `any[]` and `ReadonlyArray<any>` since `Array<T>`
|
|
5
|
+
* extends `ReadonlyArray<T>`. One distributive check instead of four sequential ones.
|
|
6
|
+
*/
|
|
7
|
+
type IsCollection<T> = T extends readonly any[] | Map<any, any> | Set<any> ? true : false;
|
|
8
|
+
/**
|
|
9
|
+
* Produces every dot-separated key path into T as a string union.
|
|
10
|
+
*
|
|
11
|
+
* - The `Depth` tuple acts as a recursion counter: when its length reaches 5 the
|
|
12
|
+
* type resolves to `never`, which caps the union size and prevents the language
|
|
13
|
+
* server from hanging on deeply-nested or self-referential shapes.
|
|
14
|
+
* - Top-level keys are included as bare strings (`K`), so this type supersedes
|
|
15
|
+
* `keyof T & string` — there is no need for a separate union with `keyof T`.
|
|
16
|
+
* - Collections (arrays, Map, Set) are terminated at their key rather than
|
|
17
|
+
* recursed into.
|
|
18
|
+
* - Returning `never` (not `''`) from every terminal branch avoids the need for
|
|
19
|
+
* a secondary `Extract<D, string>` pass over the full union.
|
|
20
|
+
*/
|
|
21
|
+
export type DotNestedKeys<T, Depth extends 1[] = []> = Depth['length'] extends 5 ? never : T extends object ? IsCollection<T> extends true ? never : {
|
|
22
|
+
[K in keyof T & string]: IsCollection<T[K]> extends true ? K : T[K] extends object ? K | `${K}.${DotNestedKeys<T[K], [...Depth, 1]>}` : K;
|
|
23
|
+
}[keyof T & string] : never;
|
|
24
|
+
/**
|
|
25
|
+
* All valid key paths into T (top-level and dot-nested).
|
|
26
|
+
*
|
|
27
|
+
* `DotNestedKeys<T>` already emits top-level string keys alongside nested paths,
|
|
28
|
+
* so a `keyof T` union is redundant and was previously responsible for duplicate
|
|
29
|
+
* entries that the checker had to deduplicate on every instantiation.
|
|
30
|
+
*/
|
|
31
|
+
export type AllObjectKeys<T> = DotNestedKeys<T>;
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the value type at a dot-separated path P within T.
|
|
34
|
+
*
|
|
35
|
+
* The intermediate `Rest extends AllObjectKeys<T[Head]>` guard that previously
|
|
36
|
+
* existed here forced a full re-evaluation of `AllObjectKeys` at every recursive
|
|
37
|
+
* level. It has been removed: if `Rest` is not a valid path the recursion
|
|
38
|
+
* naturally returns `never`, which is the correct result.
|
|
39
|
+
*/
|
|
40
|
+
export type PropType<T, P extends string> = P extends keyof T ? T[P] : P extends `${infer Head}.${infer Rest}` ? Head extends keyof T ? PropType<T[Head], Rest> : never : never;
|
|
41
|
+
/**
|
|
42
|
+
* Callback invoked by Tubular whenever an observed path changes.
|
|
43
|
+
*
|
|
44
|
+
* The previous definition used defaulted type parameters
|
|
45
|
+
* (`K = AllObjectKeys<T>`, `V = PropType<T, K & string>`). Default type
|
|
46
|
+
* parameters are eagerly evaluated by the checker at *every* reference to
|
|
47
|
+
* `ObservationCallback<T>`, causing a full `AllObjectKeys<T>` + distributed
|
|
48
|
+
* `PropType` expansion each time. Using `unknown` for the value parameters
|
|
49
|
+
* defers any narrowing to call sites that actually need it, which is a far
|
|
50
|
+
* smaller surface area.
|
|
51
|
+
*/
|
|
52
|
+
export type ObservationCallback<T> = (newVal: unknown, oldVal: unknown, propPath: AllObjectKeys<T>) => void;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callback invoked by Tubular whenever an observed path changes.
|
|
3
|
+
*
|
|
4
|
+
* The previous definition used defaulted type parameters
|
|
5
|
+
* (`K = AllObjectKeys<T>`, `V = PropType<T, K & string>`). Default type
|
|
6
|
+
* parameters are eagerly evaluated by the checker at *every* reference to
|
|
7
|
+
* `ObservationCallback<T>`, causing a full `AllObjectKeys<T>` + distributed
|
|
8
|
+
* `PropType` expansion each time. Using `unknown` for the value parameters
|
|
9
|
+
* defers any narrowing to call sites that actually need it, which is a far
|
|
10
|
+
* smaller surface area.
|
|
11
|
+
*/ export { };
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"main": "./dist/totally-tubular.js",
|
|
7
7
|
"types": "./dist/totally-tubular.d.ts",
|
|
8
8
|
"typings": "./dist/totally-tubular.d.ts",
|
|
9
|
-
"version": "0.1
|
|
9
|
+
"version": "0.3.1",
|
|
10
10
|
"keywords": [
|
|
11
11
|
"state",
|
|
12
12
|
"management",
|
|
@@ -30,19 +30,60 @@
|
|
|
30
30
|
"dist/"
|
|
31
31
|
],
|
|
32
32
|
"scripts": {
|
|
33
|
-
"build": "
|
|
33
|
+
"build": "ts-duality --clean",
|
|
34
34
|
"clean": "rm -rf dist/",
|
|
35
|
-
"test": "
|
|
36
|
-
"test:watch": "
|
|
35
|
+
"test": "bun test",
|
|
36
|
+
"test:watch": "bun test --watch"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@
|
|
40
|
-
"@
|
|
39
|
+
"@better-builds/lets-version": "^1.6.0",
|
|
40
|
+
"@better-builds/ts-duality": "^1.3.2",
|
|
41
|
+
"@biomejs/biome": "^2.4.6",
|
|
42
|
+
"@commitlint/cli": "^20.4.3",
|
|
43
|
+
"@commitlint/config-conventional": "^20.4.3",
|
|
44
|
+
"@happy-dom/global-registrator": "^20.8.3",
|
|
45
|
+
"@testing-library/react": "^16.3.2",
|
|
46
|
+
"@testing-library/user-event": "^14.6.1",
|
|
41
47
|
"@types/bun": "latest",
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
48
|
+
"@types/react": ">=18",
|
|
49
|
+
"happy-dom": "^20.8.3",
|
|
50
|
+
"react": ">=18",
|
|
51
|
+
"typescript": "^5"
|
|
46
52
|
},
|
|
47
|
-
"
|
|
53
|
+
"optionalDependencies": {
|
|
54
|
+
"react": ">=18"
|
|
55
|
+
},
|
|
56
|
+
"exports": {
|
|
57
|
+
"./react": {
|
|
58
|
+
"require": {
|
|
59
|
+
"types": "./dist/cjs/react.d.ts",
|
|
60
|
+
"default": "./dist/cjs/react.cjs"
|
|
61
|
+
},
|
|
62
|
+
"import": {
|
|
63
|
+
"types": "./dist/esm/react.d.ts",
|
|
64
|
+
"default": "./dist/esm/react.mjs"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"./totally-tubular": {
|
|
68
|
+
"require": {
|
|
69
|
+
"types": "./dist/cjs/totally-tubular.d.ts",
|
|
70
|
+
"default": "./dist/cjs/totally-tubular.cjs"
|
|
71
|
+
},
|
|
72
|
+
"import": {
|
|
73
|
+
"types": "./dist/esm/totally-tubular.d.ts",
|
|
74
|
+
"default": "./dist/esm/totally-tubular.mjs"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"./types": {
|
|
78
|
+
"require": {
|
|
79
|
+
"types": "./dist/cjs/types.d.ts",
|
|
80
|
+
"default": "./dist/cjs/types.cjs"
|
|
81
|
+
},
|
|
82
|
+
"import": {
|
|
83
|
+
"types": "./dist/esm/types.d.ts",
|
|
84
|
+
"default": "./dist/esm/types.mjs"
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"./package.json": "./package.json"
|
|
88
|
+
}
|
|
48
89
|
}
|
package/dist/types.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
export type TSPrimitve = string | number | bigint | boolean | symbol | undefined | null;
|
|
2
|
-
type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`;
|
|
3
|
-
type IsCollection<T> = T extends any[] ? true : T extends ReadonlyArray<any> ? true : T extends Map<any, any> ? true : T extends Set<any> ? true : false;
|
|
4
|
-
type DotNestedKeys<T> = (T extends object ? IsCollection<T> extends true ? '' : {
|
|
5
|
-
[K in Exclude<keyof T, symbol>]: `${K}${DotPrefix<DotNestedKeys<T[K]>>}`;
|
|
6
|
-
}[Exclude<keyof T, symbol>] : '') extends infer D ? Extract<D, string> : never;
|
|
7
|
-
export type AllObjectKeys<T> = keyof T | DotNestedKeys<T>;
|
|
8
|
-
export type PropType<T, P extends string> = P extends keyof T ? T[P] : P extends `${infer Head}.${infer Rest}` ? Head extends keyof T ? Rest extends AllObjectKeys<T[Head]> ? PropType<T[Head], Rest> : never : never : never;
|
|
9
|
-
export type ObservationCallback<T, K = AllObjectKeys<T>, V = PropType<T, K & string>> = (newVal: V, oldVal: V, propPath: K) => void;
|
|
10
|
-
export {};
|
package/dist/types.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|