@everystate/react 1.0.0 → 1.0.2
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 +28 -10
- package/eventStateReact.js +127 -0
- package/index.d.ts +66 -0
- package/index.js +10 -3
- package/package.json +4 -3
- package/self-test.js +166 -0
- package/tests/react.test.js +179 -0
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
**React adapter for EveryState with hooks**
|
|
4
4
|
|
|
5
|
-
Use EveryState in React with `usePath`, `useIntent`, and `useAsync` hooks.
|
|
5
|
+
Use EveryState in React with `usePath`, `useIntent`, `useWildcard`, and `useAsync` hooks.
|
|
6
|
+
Built on React 18's `useSyncExternalStore` for concurrent-mode safety.
|
|
6
7
|
|
|
7
8
|
## Installation
|
|
8
9
|
|
|
@@ -13,20 +14,19 @@ npm install @everystate/react @everystate/core react
|
|
|
13
14
|
## Quick Start
|
|
14
15
|
|
|
15
16
|
```jsx
|
|
16
|
-
import {
|
|
17
|
-
import { EventStateProvider, usePath } from '@everystate/react';
|
|
17
|
+
import { createEveryState } from '@everystate/core';
|
|
18
|
+
import { EventStateProvider, usePath, useIntent } from '@everystate/react';
|
|
18
19
|
|
|
19
|
-
const store =
|
|
20
|
+
const store = createEveryState({ count: 0 });
|
|
20
21
|
|
|
21
22
|
function Counter() {
|
|
22
23
|
const count = usePath('count');
|
|
24
|
+
const setCount = useIntent('count');
|
|
23
25
|
|
|
24
26
|
return (
|
|
25
27
|
<div>
|
|
26
28
|
<p>Count: {count}</p>
|
|
27
|
-
<button onClick={() =>
|
|
28
|
-
Increment
|
|
29
|
-
</button>
|
|
29
|
+
<button onClick={() => setCount(count + 1)}>Increment</button>
|
|
30
30
|
</div>
|
|
31
31
|
);
|
|
32
32
|
}
|
|
@@ -42,9 +42,27 @@ function App() {
|
|
|
42
42
|
|
|
43
43
|
## Hooks
|
|
44
44
|
|
|
45
|
-
- **`usePath(path)`** Subscribe to a
|
|
46
|
-
- **`useIntent(
|
|
47
|
-
- **`
|
|
45
|
+
- **`usePath(path)`** - Subscribe to a dot-path. Re-renders only when that path changes.
|
|
46
|
+
- **`useIntent(path)`** - Returns a stable setter function for a path. Memoized to prevent unnecessary re-renders.
|
|
47
|
+
- **`useWildcard(path)`** - Subscribe to a wildcard path (e.g. `'user.*'`). Returns the parent object.
|
|
48
|
+
- **`useAsync(path)`** - Returns `{ data, status, error, execute, cancel }` for async operations.
|
|
49
|
+
- **`useStore()`** - Returns the raw store from context.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
## Ecosystem
|
|
53
|
+
|
|
54
|
+
| Package | Description | License |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| [@everystate/aliases](https://www.npmjs.com/package/@everystate/aliases) | Ergonomic single-character and short-name DOM aliases for vanilla JS | MIT |
|
|
57
|
+
| [@everystate/core](https://www.npmjs.com/package/@everystate/core) | Path-based state management with wildcard subscriptions and async support. Core state engine (you are here). | MIT |
|
|
58
|
+
| [@everystate/css](https://www.npmjs.com/package/@everystate/css) | Reactive CSSOM engine: design tokens, typed validation, WCAG enforcement, all via path-based state | MIT |
|
|
59
|
+
| [@everystate/examples](https://www.npmjs.com/package/@everystate/examples) | Example applications and patterns | MIT |
|
|
60
|
+
| [@everystate/perf](https://www.npmjs.com/package/@everystate/perf) | Performance monitoring overlay | MIT |
|
|
61
|
+
| [@everystate/react](https://www.npmjs.com/package/@everystate/react) | React hooks adapter: `usePath`, `useIntent`, `useAsync` hooks and `EveryStateProvider` | MIT |
|
|
62
|
+
| [@everystate/renderer](https://www.npmjs.com/package/@everystate/renderer) | Direct-binding reactive renderer: `bind-*`, `set`, `each` attributes. Zero build step | Proprietary |
|
|
63
|
+
| [@everystate/router](https://www.npmjs.com/package/@everystate/router) | SPA routing as state | MIT |
|
|
64
|
+
| [@everystate/test](https://www.npmjs.com/package/@everystate/test) | Event-sequence testing for EveryState stores. Zero dependency. | Proprietary |
|
|
65
|
+
| [@everystate/view](https://www.npmjs.com/package/@everystate/view) | State-driven view: DOMless resolve + surgical DOM projector. View tree as first-class state | MIT |
|
|
48
66
|
|
|
49
67
|
## License
|
|
50
68
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
// ---- Context ----
|
|
4
|
+
const EventStateContext = createContext(null);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Provider: makes a store available to all child components via hooks.
|
|
8
|
+
* The store is created *outside* React. The provider is pure dependency injection.
|
|
9
|
+
*
|
|
10
|
+
* @param {{ store: object, children: React.ReactNode }} props
|
|
11
|
+
*/
|
|
12
|
+
export function EventStateProvider({ store, children }) {
|
|
13
|
+
return (
|
|
14
|
+
<EventStateContext.Provider value={store}>
|
|
15
|
+
{children}
|
|
16
|
+
</EventStateContext.Provider>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* useStore: returns the EventState store from context.
|
|
22
|
+
* Throws if called outside an EventStateProvider.
|
|
23
|
+
*
|
|
24
|
+
* @returns {object} The EventState store
|
|
25
|
+
*/
|
|
26
|
+
export function useStore() {
|
|
27
|
+
const store = useContext(EventStateContext);
|
|
28
|
+
if (!store) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'useStore: no store found. Wrap your component tree in <EventStateProvider store={store}>.'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return store;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* usePath: subscribe to a dot-path in the store.
|
|
38
|
+
* Re-renders the component only when the value at that path changes.
|
|
39
|
+
* Uses React 18's useSyncExternalStore for concurrent-mode safety.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} path: dot-separated state path (e.g. 'state.tasks')
|
|
42
|
+
* @returns {any} The current value at the path
|
|
43
|
+
*/
|
|
44
|
+
export function usePath(path) {
|
|
45
|
+
const store = useStore();
|
|
46
|
+
|
|
47
|
+
const subscribe = useMemo(
|
|
48
|
+
() => (onStoreChange) => store.subscribe(path, () => onStoreChange()),
|
|
49
|
+
[store, path]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const getSnapshot = useMemo(
|
|
53
|
+
() => () => store.get(path),
|
|
54
|
+
[store, path]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* useIntent: returns a stable function that publishes a value to a path.
|
|
62
|
+
* Memoized so it won't cause unnecessary re-renders when passed as a prop.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} path: dot-separated intent path (e.g. 'intent.addTask')
|
|
65
|
+
* @returns {(value: any) => any} A setter function
|
|
66
|
+
*/
|
|
67
|
+
export function useIntent(path) {
|
|
68
|
+
const store = useStore();
|
|
69
|
+
return useMemo(
|
|
70
|
+
() => (value) => store.set(path, value),
|
|
71
|
+
[store, path]
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* useWildcard: subscribe to a wildcard path (e.g. 'state.*').
|
|
77
|
+
* Re-renders whenever any child of that path changes.
|
|
78
|
+
* The returned value is the parent object at the path prefix.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} wildcardPath: e.g. 'state.tasks.*' or 'state.*'
|
|
81
|
+
* @returns {any} The current value at the parent path
|
|
82
|
+
*/
|
|
83
|
+
export function useWildcard(wildcardPath) {
|
|
84
|
+
const store = useStore();
|
|
85
|
+
const parentPath = wildcardPath.endsWith('.*')
|
|
86
|
+
? wildcardPath.slice(0, -2)
|
|
87
|
+
: wildcardPath;
|
|
88
|
+
|
|
89
|
+
const subscribe = useMemo(
|
|
90
|
+
() => (onStoreChange) => store.subscribe(wildcardPath, () => onStoreChange()),
|
|
91
|
+
[store, wildcardPath]
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const getSnapshot = useMemo(
|
|
95
|
+
() => () => store.get(parentPath),
|
|
96
|
+
[store, parentPath]
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* useAsync: trigger an async operation and subscribe to its status.
|
|
104
|
+
* Returns { data, status, error, execute, cancel }.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} path: base path for the async operation
|
|
107
|
+
* @returns {{ data: any, status: string, error: any, execute: Function, cancel: Function }}
|
|
108
|
+
*/
|
|
109
|
+
export function useAsync(path) {
|
|
110
|
+
const store = useStore();
|
|
111
|
+
|
|
112
|
+
const data = usePath(`${path}.data`);
|
|
113
|
+
const status = usePath(`${path}.status`);
|
|
114
|
+
const error = usePath(`${path}.error`);
|
|
115
|
+
|
|
116
|
+
const execute = useMemo(
|
|
117
|
+
() => (fetcher) => store.setAsync(path, fetcher),
|
|
118
|
+
[store, path]
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const cancel = useMemo(
|
|
122
|
+
() => () => store.cancel(path),
|
|
123
|
+
[store, path]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return { data, status, error, execute, cancel };
|
|
127
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystate/react
|
|
3
|
+
*
|
|
4
|
+
* React adapter for EveryState with hooks.
|
|
5
|
+
* Built on React 18's useSyncExternalStore for concurrent-mode safety.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EveryStateStore } from '@everystate/core';
|
|
9
|
+
import type { ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Provider: makes a store available to all child components via hooks.
|
|
13
|
+
* The store is created *outside* React. The provider is pure dependency injection.
|
|
14
|
+
*/
|
|
15
|
+
export function EventStateProvider(props: {
|
|
16
|
+
store: EveryStateStore;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}): JSX.Element;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns the EveryState store from context.
|
|
22
|
+
* Throws if called outside an EventStateProvider.
|
|
23
|
+
*/
|
|
24
|
+
export function useStore(): EveryStateStore;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Subscribe to a dot-path in the store.
|
|
28
|
+
* Re-renders the component only when the value at that path changes.
|
|
29
|
+
* Uses React 18's useSyncExternalStore for concurrent-mode safety.
|
|
30
|
+
*
|
|
31
|
+
* @param path - Dot-separated state path (e.g. 'user.name')
|
|
32
|
+
* @returns The current value at the path
|
|
33
|
+
*/
|
|
34
|
+
export function usePath(path: string): any;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns a stable function that publishes a value to a path.
|
|
38
|
+
* Memoized so it won't cause unnecessary re-renders when passed as a prop.
|
|
39
|
+
*
|
|
40
|
+
* @param path - Dot-separated intent path (e.g. 'intent.addTask')
|
|
41
|
+
* @returns A setter function
|
|
42
|
+
*/
|
|
43
|
+
export function useIntent(path: string): (value: any) => any;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Subscribe to a wildcard path (e.g. 'state.*').
|
|
47
|
+
* Re-renders whenever any child of that path changes.
|
|
48
|
+
* The returned value is the parent object at the path prefix.
|
|
49
|
+
*
|
|
50
|
+
* @param wildcardPath - e.g. 'state.tasks.*' or 'state.*'
|
|
51
|
+
* @returns The current value at the parent path
|
|
52
|
+
*/
|
|
53
|
+
export function useWildcard(wildcardPath: string): any;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Trigger an async operation and subscribe to its status.
|
|
57
|
+
*
|
|
58
|
+
* @param path - Base path for the async operation
|
|
59
|
+
*/
|
|
60
|
+
export function useAsync(path: string): {
|
|
61
|
+
data: any;
|
|
62
|
+
status: string | undefined;
|
|
63
|
+
error: any;
|
|
64
|
+
execute: (fetcher: (signal: AbortSignal) => Promise<any>) => Promise<any>;
|
|
65
|
+
cancel: () => void;
|
|
66
|
+
};
|
package/index.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @everystate/react
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* React adapter for EveryState with hooks:
|
|
5
|
+
* EventStateProvider, useStore, usePath, useIntent, useWildcard, useAsync
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
export
|
|
8
|
+
export {
|
|
9
|
+
EventStateProvider,
|
|
10
|
+
useStore,
|
|
11
|
+
usePath,
|
|
12
|
+
useIntent,
|
|
13
|
+
useWildcard,
|
|
14
|
+
useAsync,
|
|
15
|
+
} from './eventStateReact.js';
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystate/react",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "EveryState React: React adapter with usePath, useIntent, useAsync hooks and EventStateProvider",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
7
8
|
"keywords": [
|
|
8
9
|
"everystate",
|
|
9
10
|
"react",
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"type": "git",
|
|
25
26
|
"url": "https://github.com/ImsirovicAjdin/everystate-react"
|
|
26
27
|
},
|
|
27
|
-
"
|
|
28
|
-
"
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"react": ">=18.0.0"
|
|
29
30
|
}
|
|
30
31
|
}
|
package/self-test.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystate/react: zero-dependency self-test
|
|
3
|
+
*
|
|
4
|
+
* Since the React adapter uses JSX and React hooks (which require a React
|
|
5
|
+
* runtime and JSX transform), this self-test verifies:
|
|
6
|
+
* 1. The module exports exist and are functions
|
|
7
|
+
* 2. The store-side patterns that the hooks consume work correctly
|
|
8
|
+
*
|
|
9
|
+
* The hooks themselves (usePath, useIntent, useWildcard, useAsync) are
|
|
10
|
+
* thin wrappers around store.subscribe + useSyncExternalStore.
|
|
11
|
+
* Testing the store patterns proves the hooks will work.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createEveryState } from '@everystate/core';
|
|
15
|
+
|
|
16
|
+
let passed = 0;
|
|
17
|
+
let failed = 0;
|
|
18
|
+
|
|
19
|
+
function assert(label, condition) {
|
|
20
|
+
if (condition) {
|
|
21
|
+
console.log(` ✓ ${label}`);
|
|
22
|
+
passed++;
|
|
23
|
+
} else {
|
|
24
|
+
console.error(` ✗ ${label}`);
|
|
25
|
+
failed++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function section(title) {
|
|
30
|
+
console.log(`\n${title}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// -- 1. usePath pattern: subscribe + get -----------------------------
|
|
34
|
+
|
|
35
|
+
section('1. usePath pattern: subscribe + get');
|
|
36
|
+
|
|
37
|
+
const s1 = createEveryState({ user: { name: 'Alice' } });
|
|
38
|
+
let s1snap = s1.get('user.name');
|
|
39
|
+
const unsub1 = s1.subscribe('user.name', () => { s1snap = s1.get('user.name'); });
|
|
40
|
+
assert('initial snapshot', s1snap === 'Alice');
|
|
41
|
+
|
|
42
|
+
s1.set('user.name', 'Bob');
|
|
43
|
+
assert('snapshot updates on set', s1snap === 'Bob');
|
|
44
|
+
|
|
45
|
+
unsub1();
|
|
46
|
+
s1.set('user.name', 'Charlie');
|
|
47
|
+
assert('unsubscribe stops updates', s1snap === 'Bob');
|
|
48
|
+
s1.destroy();
|
|
49
|
+
|
|
50
|
+
// -- 2. useIntent pattern: stable setter -----------------------------
|
|
51
|
+
|
|
52
|
+
section('2. useIntent pattern: stable setter');
|
|
53
|
+
|
|
54
|
+
const s2 = createEveryState({ intent: { addTask: null } });
|
|
55
|
+
const setter = (value) => s2.set('intent.addTask', value);
|
|
56
|
+
setter({ text: 'Buy milk' });
|
|
57
|
+
assert('setter writes to path', s2.get('intent.addTask').text === 'Buy milk');
|
|
58
|
+
|
|
59
|
+
setter(null);
|
|
60
|
+
assert('setter clears value', s2.get('intent.addTask') === null);
|
|
61
|
+
s2.destroy();
|
|
62
|
+
|
|
63
|
+
// -- 3. useWildcard pattern: subscribe wildcard + get parent ---------
|
|
64
|
+
|
|
65
|
+
section('3. useWildcard pattern: wildcard subscribe');
|
|
66
|
+
|
|
67
|
+
const s3 = createEveryState({ state: { tasks: { t1: 'A', t2: 'B' } } });
|
|
68
|
+
let wildcardFires = 0;
|
|
69
|
+
const unsub3 = s3.subscribe('state.tasks.*', () => {
|
|
70
|
+
wildcardFires++;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
s3.set('state.tasks.t1', 'A updated');
|
|
74
|
+
assert('wildcard fires on child change', wildcardFires === 1);
|
|
75
|
+
|
|
76
|
+
s3.set('state.tasks.t3', 'C');
|
|
77
|
+
assert('wildcard fires on new child', wildcardFires === 2);
|
|
78
|
+
|
|
79
|
+
const parent = s3.get('state.tasks');
|
|
80
|
+
assert('get parent returns object', typeof parent === 'object');
|
|
81
|
+
assert('parent has t1', parent.t1 === 'A updated');
|
|
82
|
+
assert('parent has t3', parent.t3 === 'C');
|
|
83
|
+
|
|
84
|
+
unsub3();
|
|
85
|
+
s3.destroy();
|
|
86
|
+
|
|
87
|
+
// -- 4. useAsync pattern: setAsync lifecycle -------------------------
|
|
88
|
+
|
|
89
|
+
section('4. useAsync pattern: setAsync lifecycle');
|
|
90
|
+
|
|
91
|
+
const s4 = createEveryState({});
|
|
92
|
+
const promise = s4.setAsync('users', async () => [{ id: 1, name: 'Alice' }]);
|
|
93
|
+
|
|
94
|
+
// During loading
|
|
95
|
+
assert('loading: status = loading', s4.get('users.status') === 'loading');
|
|
96
|
+
assert('loading: error = null', s4.get('users.error') === null);
|
|
97
|
+
|
|
98
|
+
await promise;
|
|
99
|
+
|
|
100
|
+
// After success
|
|
101
|
+
assert('success: status = success', s4.get('users.status') === 'success');
|
|
102
|
+
assert('success: data is array', Array.isArray(s4.get('users.data')));
|
|
103
|
+
assert('success: data[0].name = Alice', s4.get('users.data')[0].name === 'Alice');
|
|
104
|
+
s4.destroy();
|
|
105
|
+
|
|
106
|
+
// -- 5. useAsync error pattern ---------------------------------------
|
|
107
|
+
|
|
108
|
+
section('5. useAsync error pattern');
|
|
109
|
+
|
|
110
|
+
const s5 = createEveryState({});
|
|
111
|
+
try {
|
|
112
|
+
await s5.setAsync('data', async () => { throw new Error('Network error'); });
|
|
113
|
+
} catch {}
|
|
114
|
+
assert('error: status = error', s5.get('data.status') === 'error');
|
|
115
|
+
assert('error: error message exists', typeof s5.get('data.error') === 'string' || s5.get('data.error') instanceof Error);
|
|
116
|
+
s5.destroy();
|
|
117
|
+
|
|
118
|
+
// -- 6. Provider pattern: store as context ---------------------------
|
|
119
|
+
|
|
120
|
+
section('6. Provider pattern: store as external dependency');
|
|
121
|
+
|
|
122
|
+
const s6 = createEveryState({ count: 0 });
|
|
123
|
+
// The provider just passes the store via React context.
|
|
124
|
+
// We verify the store is usable as an external store.
|
|
125
|
+
const subscribe = (onStoreChange) => s6.subscribe('count', () => onStoreChange());
|
|
126
|
+
const getSnapshot = () => s6.get('count');
|
|
127
|
+
|
|
128
|
+
let latestSnapshot = getSnapshot();
|
|
129
|
+
const unsub6 = subscribe(() => { latestSnapshot = getSnapshot(); });
|
|
130
|
+
|
|
131
|
+
s6.set('count', 10);
|
|
132
|
+
assert('external store subscribe works', latestSnapshot === 10);
|
|
133
|
+
|
|
134
|
+
s6.set('count', 20);
|
|
135
|
+
assert('external store re-fires', latestSnapshot === 20);
|
|
136
|
+
|
|
137
|
+
unsub6();
|
|
138
|
+
s6.destroy();
|
|
139
|
+
|
|
140
|
+
// -- 7. Batch pattern (React 18 automatic batching) ------------------
|
|
141
|
+
|
|
142
|
+
section('7. batch pattern (React 18 compat)');
|
|
143
|
+
|
|
144
|
+
const s7 = createEveryState({ a: 0, b: 0 });
|
|
145
|
+
let renderCount = 0;
|
|
146
|
+
s7.subscribe('a', () => { renderCount++; });
|
|
147
|
+
s7.subscribe('b', () => { renderCount++; });
|
|
148
|
+
|
|
149
|
+
s7.batch(() => {
|
|
150
|
+
s7.set('a', 1);
|
|
151
|
+
s7.set('b', 2);
|
|
152
|
+
});
|
|
153
|
+
assert('batch: 2 subscribers fire once each', renderCount === 2);
|
|
154
|
+
assert('batch: a = 1', s7.get('a') === 1);
|
|
155
|
+
assert('batch: b = 2', s7.get('b') === 2);
|
|
156
|
+
s7.destroy();
|
|
157
|
+
|
|
158
|
+
// -- Summary ---------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
console.log(`\n@everystate/react v1.0.0 self-test`);
|
|
161
|
+
if (failed > 0) {
|
|
162
|
+
console.error(`✗ ${failed} assertion(s) failed, ${passed} passed`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
} else {
|
|
165
|
+
console.log(`✓ ${passed} assertions passed`);
|
|
166
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystate/react: integration tests via @everystate/test
|
|
3
|
+
*
|
|
4
|
+
* Tests the store-side patterns that React hooks consume.
|
|
5
|
+
* Since EveryState is the IR, testing the IR proves the hooks work.
|
|
6
|
+
* The hooks are thin wrappers: usePath = subscribe + get,
|
|
7
|
+
* useIntent = set, useWildcard = subscribe wildcard + get parent.
|
|
8
|
+
*
|
|
9
|
+
* JSX/React-specific behavior (re-renders, concurrent mode) requires
|
|
10
|
+
* a React test environment and is outside the scope of these tests.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createEventTest, runTests } from '@everystate/test';
|
|
14
|
+
import { createEveryState } from '@everystate/core';
|
|
15
|
+
|
|
16
|
+
const results = runTests({
|
|
17
|
+
|
|
18
|
+
// -- usePath patterns ----------------------------------------------
|
|
19
|
+
|
|
20
|
+
'usePath: subscribe to exact path': () => {
|
|
21
|
+
const t = createEventTest({ user: { name: 'Alice', age: 30 } });
|
|
22
|
+
t.trigger('user.name', 'Bob');
|
|
23
|
+
t.assertPath('user.name', 'Bob');
|
|
24
|
+
t.assertType('user.name', 'string');
|
|
25
|
+
t.assertEventFired('user.name', 1);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
'usePath: nested path subscription': () => {
|
|
29
|
+
const t = createEventTest({ app: { settings: { theme: 'dark' } } });
|
|
30
|
+
t.trigger('app.settings.theme', 'light');
|
|
31
|
+
t.assertPath('app.settings.theme', 'light');
|
|
32
|
+
t.assertEventFired('app.settings.theme', 1);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
'usePath: unsubscribe stops notifications': () => {
|
|
36
|
+
const store = createEventState({ count: 0 });
|
|
37
|
+
let fires = 0;
|
|
38
|
+
const unsub = store.subscribe('count', () => { fires++; });
|
|
39
|
+
store.set('count', 1);
|
|
40
|
+
unsub();
|
|
41
|
+
store.set('count', 2);
|
|
42
|
+
if (fires !== 1) throw new Error(`Expected 1 fire after unsub, got ${fires}`);
|
|
43
|
+
store.destroy();
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// -- useIntent patterns --------------------------------------------
|
|
47
|
+
|
|
48
|
+
'useIntent: set value at path': () => {
|
|
49
|
+
const t = createEventTest({ intent: { addTask: null } });
|
|
50
|
+
t.trigger('intent.addTask', { text: 'Buy milk', done: false });
|
|
51
|
+
t.assertPath('intent.addTask', { text: 'Buy milk', done: false });
|
|
52
|
+
t.assertType('intent.addTask', 'object');
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
'useIntent: reset intent after processing': () => {
|
|
56
|
+
const t = createEventTest({ intent: { submit: null } });
|
|
57
|
+
t.trigger('intent.submit', { form: 'login' });
|
|
58
|
+
t.trigger('intent.submit', null);
|
|
59
|
+
t.assertPath('intent.submit', null);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// -- useWildcard patterns ------------------------------------------
|
|
63
|
+
|
|
64
|
+
'useWildcard: fires on any child change': () => {
|
|
65
|
+
const store = createEventState({ state: { tasks: {} } });
|
|
66
|
+
let fires = 0;
|
|
67
|
+
store.subscribe('state.tasks.*', () => { fires++; });
|
|
68
|
+
store.set('state.tasks.t1', { text: 'A', done: false });
|
|
69
|
+
store.set('state.tasks.t2', { text: 'B', done: false });
|
|
70
|
+
if (fires !== 2) throw new Error(`Expected 2 fires, got ${fires}`);
|
|
71
|
+
store.destroy();
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
'useWildcard: get parent returns full object': () => {
|
|
75
|
+
const t = createEventTest({ state: { tasks: { t1: 'A', t2: 'B' } } });
|
|
76
|
+
t.trigger('state.tasks.t3', 'C');
|
|
77
|
+
t.assertPath('state.tasks', { t1: 'A', t2: 'B', t3: 'C' });
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// -- useAsync patterns ---------------------------------------------
|
|
81
|
+
|
|
82
|
+
'useAsync: loading → success lifecycle': async () => {
|
|
83
|
+
const store = createEventState({});
|
|
84
|
+
await store.setAsync('users', async () => [{ id: 1, name: 'Alice' }]);
|
|
85
|
+
if (store.get('users.status') !== 'success') throw new Error('Expected success');
|
|
86
|
+
if (!Array.isArray(store.get('users.data'))) throw new Error('Expected array');
|
|
87
|
+
store.destroy();
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
'useAsync: loading → error lifecycle': async () => {
|
|
91
|
+
const store = createEventState({});
|
|
92
|
+
try {
|
|
93
|
+
await store.setAsync('data', async () => { throw new Error('fail'); });
|
|
94
|
+
} catch {}
|
|
95
|
+
if (store.get('data.status') !== 'error') throw new Error('Expected error');
|
|
96
|
+
store.destroy();
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
'useAsync: status/data/error paths are typed': () => {
|
|
100
|
+
const t = createEventTest({});
|
|
101
|
+
t.store.setMany({
|
|
102
|
+
'users.status': 'success',
|
|
103
|
+
'users.data': [{ id: 1, name: 'Alice' }],
|
|
104
|
+
'users.error': null,
|
|
105
|
+
});
|
|
106
|
+
t.assertType('users.status', 'string');
|
|
107
|
+
t.assertArrayOf('users.data', { id: 'number', name: 'string' });
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// -- Provider pattern ----------------------------------------------
|
|
111
|
+
|
|
112
|
+
'provider: store as external store (useSyncExternalStore compat)': () => {
|
|
113
|
+
const store = createEventState({ count: 0 });
|
|
114
|
+
// Simulate useSyncExternalStore contract
|
|
115
|
+
let snapshot = store.get('count');
|
|
116
|
+
const subscribe = (cb) => store.subscribe('count', () => {
|
|
117
|
+
snapshot = store.get('count');
|
|
118
|
+
cb();
|
|
119
|
+
});
|
|
120
|
+
let renderCount = 0;
|
|
121
|
+
const unsub = subscribe(() => { renderCount++; });
|
|
122
|
+
|
|
123
|
+
store.set('count', 1);
|
|
124
|
+
if (snapshot !== 1) throw new Error('Snapshot should be 1');
|
|
125
|
+
if (renderCount !== 1) throw new Error('Should have rendered once');
|
|
126
|
+
|
|
127
|
+
store.set('count', 2);
|
|
128
|
+
if (snapshot !== 2) throw new Error('Snapshot should be 2');
|
|
129
|
+
if (renderCount !== 2) throw new Error('Should have rendered twice');
|
|
130
|
+
|
|
131
|
+
unsub();
|
|
132
|
+
store.destroy();
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// -- batch (React 18 automatic batching compat) --------------------
|
|
136
|
+
|
|
137
|
+
'batch: atomic updates (React 18 compat)': () => {
|
|
138
|
+
const t = createEventTest({ form: { name: '', email: '' } });
|
|
139
|
+
t.store.batch(() => {
|
|
140
|
+
t.trigger('form.name', 'Alice');
|
|
141
|
+
t.trigger('form.email', 'alice@example.com');
|
|
142
|
+
});
|
|
143
|
+
t.assertPath('form.name', 'Alice');
|
|
144
|
+
t.assertPath('form.email', 'alice@example.com');
|
|
145
|
+
// Each path fires once after batch
|
|
146
|
+
t.assertEventFired('form.name', 1);
|
|
147
|
+
t.assertEventFired('form.email', 1);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
'batch: setMany for atomic route updates': () => {
|
|
151
|
+
const t = createEventTest({});
|
|
152
|
+
t.store.setMany({
|
|
153
|
+
'ui.route.view': 'dashboard',
|
|
154
|
+
'ui.route.path': '/dashboard',
|
|
155
|
+
'ui.route.params': {},
|
|
156
|
+
});
|
|
157
|
+
t.assertPath('ui.route.view', 'dashboard');
|
|
158
|
+
t.assertPath('ui.route.path', '/dashboard');
|
|
159
|
+
t.assertType('ui.route.view', 'string');
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
// -- type generation from React patterns ---------------------------
|
|
163
|
+
|
|
164
|
+
'types: React app state shape': () => {
|
|
165
|
+
const t = createEventTest({
|
|
166
|
+
user: { name: 'Alice', email: 'alice@example.com', role: 'admin' },
|
|
167
|
+
tasks: [{ id: 1, text: 'Buy milk', done: false }],
|
|
168
|
+
ui: { theme: 'dark', sidebarOpen: true },
|
|
169
|
+
});
|
|
170
|
+
t.assertShape('user', { name: 'string', email: 'string', role: 'string' });
|
|
171
|
+
t.assertArrayOf('tasks', { id: 'number', text: 'string', done: 'boolean' });
|
|
172
|
+
t.assertShape('ui', { theme: 'string', sidebarOpen: 'boolean' });
|
|
173
|
+
|
|
174
|
+
const types = t.getTypeAssertions();
|
|
175
|
+
if (types.length !== 3) throw new Error(`Expected 3 type assertions, got ${types.length}`);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (results.failed > 0) process.exit(1);
|