@sigrea/react 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -55
- package/dist/index.cjs +26 -22
- package/dist/index.mjs +26 -22
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
1
|
# @sigrea/react
|
|
2
2
|
|
|
3
|
-
`@sigrea/react` adapts [@sigrea/core](https://www.npmjs.com/package/@sigrea/core) logic modules and signals
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
`@sigrea/react` adapts [@sigrea/core](https://www.npmjs.com/package/@sigrea/core) logic modules and signals for use in React components. It binds scope-aware lifecycles to `useEffect`, synchronizes signal subscriptions with React rendering, and provides hooks for both shallow and deep reactivity.
|
|
4
|
+
|
|
5
|
+
- **Signal subscriptions.** `useSignal` subscribes to signals and computed values, triggering re-renders when they change.
|
|
6
|
+
- **Computed subscriptions.** `useComputed` subscribes to computed values and memoizes them per component instance.
|
|
7
|
+
- **Deep signal subscriptions.** `useDeepSignal` subscribes to deep signal objects and exposes them for direct mutation.
|
|
8
|
+
- **Logic lifecycles.** `useLogic` mounts logic factories and binds their lifecycles to React components.
|
|
9
|
+
|
|
10
|
+
## Table of Contents
|
|
11
|
+
|
|
12
|
+
- [Install](#install)
|
|
13
|
+
- [Quick Start](#quick-start)
|
|
14
|
+
- [Consume a Signal](#consume-a-signal)
|
|
15
|
+
- [Bridge Framework-Agnostic Logic](#bridge-framework-agnostic-logic)
|
|
16
|
+
- [Work with Deep Signals](#work-with-deep-signals)
|
|
17
|
+
- [API Reference](#api-reference)
|
|
18
|
+
- [useSignal](#usesignal)
|
|
19
|
+
- [useComputed](#usecomputed)
|
|
20
|
+
- [useDeepSignal](#usedeepsignal)
|
|
21
|
+
- [useLogic](#uselogic)
|
|
22
|
+
- [Testing](#testing)
|
|
23
|
+
- [Development](#development)
|
|
24
|
+
- [License](#license)
|
|
25
|
+
|
|
26
|
+
## Install
|
|
6
27
|
|
|
7
28
|
```bash
|
|
8
|
-
|
|
29
|
+
npm install @sigrea/react @sigrea/core react react-dom
|
|
9
30
|
```
|
|
10
31
|
|
|
11
|
-
React 18+ and Node.js 20
|
|
12
|
-
|
|
13
|
-
## What This Adapter Provides
|
|
14
|
-
|
|
15
|
-
- **Signal readers** – `useSignal` streams signals and computed values into React components.
|
|
16
|
-
- **Deep signal access** – `useDeepSignal` exposes mutable deep signal objects with automatic teardown.
|
|
17
|
-
- **Derived state** – `useComputed` keeps derived values memoized per component instance.
|
|
18
|
-
- **Logic lifecycles** – `useLogic` mounts `defineLogic` factories and binds `onMount` / `onUnmount` to React’s lifecycle.
|
|
19
|
-
- **Snapshots** – `useSnapshot` provides low-level control when you need direct access to signal handlers.
|
|
32
|
+
Requires React 18+ and Node.js 20 or later.
|
|
20
33
|
|
|
21
34
|
## Quick Start
|
|
22
35
|
|
|
23
|
-
### Consume a
|
|
36
|
+
### Consume a Signal
|
|
24
37
|
|
|
25
38
|
```tsx
|
|
26
39
|
import { signal } from "@sigrea/core";
|
|
@@ -29,46 +42,46 @@ import { useSignal } from "@sigrea/react";
|
|
|
29
42
|
const count = signal(0);
|
|
30
43
|
|
|
31
44
|
export function CounterLabel() {
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
const value = useSignal(count);
|
|
46
|
+
return <span>{value}</span>;
|
|
34
47
|
}
|
|
35
48
|
```
|
|
36
49
|
|
|
37
|
-
### Bridge
|
|
50
|
+
### Bridge Framework-Agnostic Logic
|
|
38
51
|
|
|
39
52
|
```tsx
|
|
40
53
|
import { defineLogic, signal } from "@sigrea/core";
|
|
41
|
-
import { useLogic } from "@sigrea/react";
|
|
54
|
+
import { useLogic, useSignal } from "@sigrea/react";
|
|
42
55
|
|
|
43
56
|
const CounterLogic = defineLogic<{ initialCount: number }>()((props) => {
|
|
44
|
-
|
|
57
|
+
const count = signal(props.initialCount);
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
const increment = () => {
|
|
60
|
+
count.value += 1;
|
|
61
|
+
};
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
const reset = () => {
|
|
64
|
+
count.value = props.initialCount;
|
|
65
|
+
};
|
|
53
66
|
|
|
54
|
-
|
|
67
|
+
return { count, increment, reset };
|
|
55
68
|
});
|
|
56
69
|
|
|
57
70
|
export function Counter(props: { initialCount: number }) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
const counter = useLogic(CounterLogic, props);
|
|
72
|
+
const value = useSignal(counter.count);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div>
|
|
76
|
+
<span>{value}</span>
|
|
77
|
+
<button onClick={counter.increment}>Increment</button>
|
|
78
|
+
<button onClick={counter.reset}>Reset</button>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
68
81
|
}
|
|
69
82
|
```
|
|
70
83
|
|
|
71
|
-
### Work with
|
|
84
|
+
### Work with Deep Signals
|
|
72
85
|
|
|
73
86
|
```tsx
|
|
74
87
|
import { deepSignal } from "@sigrea/core";
|
|
@@ -77,29 +90,88 @@ import { useDeepSignal } from "@sigrea/react";
|
|
|
77
90
|
const form = deepSignal({ name: "Sigrea" });
|
|
78
91
|
|
|
79
92
|
export function ProfileForm() {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
const state = useDeepSignal(form);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<label>
|
|
97
|
+
Name
|
|
98
|
+
<input
|
|
99
|
+
value={state.name}
|
|
100
|
+
onChange={(event) => {
|
|
101
|
+
state.name = event.target.value;
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
</label>
|
|
105
|
+
);
|
|
93
106
|
}
|
|
94
107
|
```
|
|
95
108
|
|
|
109
|
+
## API Reference
|
|
110
|
+
|
|
111
|
+
### useSignal
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
function useSignal<T>(signal: Signal<T> | ReadonlySignal<T>): T
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Subscribes to a signal or computed value and returns its current value. The component re-renders when the signal changes.
|
|
118
|
+
|
|
119
|
+
### useComputed
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
function useComputed<T>(source: Computed<T>): T
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Subscribes to a computed value and returns its current value. The component re-renders when the computed value changes, and the subscription is cleaned up when the component unmounts.
|
|
126
|
+
|
|
127
|
+
### useDeepSignal
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
function useDeepSignal<T extends object>(signal: DeepSignal<T>): T
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Exposes a deep signal object for direct mutation within the component. Updates to nested properties trigger re-renders, and the subscription is cleaned up when the component unmounts.
|
|
134
|
+
|
|
135
|
+
### useLogic
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
function useLogic<TProps, TReturn>(
|
|
139
|
+
logic: LogicFunction<TProps, TReturn>,
|
|
140
|
+
props?: TProps
|
|
141
|
+
): TReturn
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Mounts a logic factory and returns its public API. The logic's scope is bound to the component lifecycle: `onMount` callbacks run after the component mounts, and `onUnmount` callbacks run before it unmounts.
|
|
145
|
+
|
|
96
146
|
## Testing
|
|
97
147
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
148
|
+
```tsx
|
|
149
|
+
// tests/Counter.test.tsx
|
|
150
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
151
|
+
import { cleanupLogics } from "@sigrea/core";
|
|
152
|
+
import { Counter } from "../components/Counter";
|
|
153
|
+
|
|
154
|
+
afterEach(() => cleanupLogics());
|
|
155
|
+
|
|
156
|
+
it("increments and displays the updated count", () => {
|
|
157
|
+
render(<Counter initialCount={10} />);
|
|
158
|
+
|
|
159
|
+
const incrementButton = screen.getByText("Increment");
|
|
160
|
+
fireEvent.click(incrementButton);
|
|
161
|
+
|
|
162
|
+
expect(screen.getByText("11")).toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Development
|
|
167
|
+
|
|
168
|
+
Development scripts prefer pnpm. npm or yarn work too, but pnpm keeps dependency resolution identical to CI.
|
|
169
|
+
|
|
170
|
+
- `pnpm install` — install dependencies.
|
|
171
|
+
- `pnpm test` — run the Vitest suite once (no watch).
|
|
172
|
+
- `pnpm build` — compile via unbuild to produce dual CJS/ESM bundles.
|
|
173
|
+
- `pnpm dev` — launch the playground counter demo.
|
|
102
174
|
|
|
103
175
|
## License
|
|
104
176
|
|
|
105
|
-
MIT — see
|
|
177
|
+
MIT — see [LICENSE](./LICENSE).
|
package/dist/index.cjs
CHANGED
|
@@ -3,13 +3,6 @@
|
|
|
3
3
|
const react = require('react');
|
|
4
4
|
const core = require('@sigrea/core');
|
|
5
5
|
|
|
6
|
-
const scheduleMicrotask = (callback) => {
|
|
7
|
-
if (typeof globalThis.queueMicrotask === "function") {
|
|
8
|
-
globalThis.queueMicrotask(callback);
|
|
9
|
-
return;
|
|
10
|
-
}
|
|
11
|
-
Promise.resolve().then(callback);
|
|
12
|
-
};
|
|
13
6
|
function useLogic(logic, ...args) {
|
|
14
7
|
const props = args.length === 0 ? void 0 : args[0];
|
|
15
8
|
const stateRef = react.useRef(void 0);
|
|
@@ -17,6 +10,7 @@ function useLogic(logic, ...args) {
|
|
|
17
10
|
const shouldRemount = currentState === void 0 || currentState.logic !== logic || !Object.is(currentState.props, props);
|
|
18
11
|
if (shouldRemount) {
|
|
19
12
|
if (currentState !== void 0) {
|
|
13
|
+
currentState.pendingDisposeToken = null;
|
|
20
14
|
core.cleanupLogic(currentState.instance);
|
|
21
15
|
stateRef.current = void 0;
|
|
22
16
|
}
|
|
@@ -25,8 +19,9 @@ function useLogic(logic, ...args) {
|
|
|
25
19
|
instance: core.mountLogic(logic, ...logicArgs),
|
|
26
20
|
logic,
|
|
27
21
|
props,
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
subscribers: 0,
|
|
23
|
+
disposed: false,
|
|
24
|
+
pendingDisposeToken: null
|
|
30
25
|
};
|
|
31
26
|
}
|
|
32
27
|
const state = stateRef.current;
|
|
@@ -40,25 +35,34 @@ function useLogic(logic, ...args) {
|
|
|
40
35
|
return () => {
|
|
41
36
|
};
|
|
42
37
|
}
|
|
43
|
-
state2.
|
|
38
|
+
if (state2.pendingDisposeToken !== null) {
|
|
39
|
+
state2.pendingDisposeToken = null;
|
|
40
|
+
}
|
|
41
|
+
state2.subscribers += 1;
|
|
44
42
|
return () => {
|
|
45
43
|
const latest = stateRef.current;
|
|
46
44
|
if (latest === void 0 || latest.instance !== instance) {
|
|
47
45
|
core.cleanupLogic(instance);
|
|
48
46
|
return;
|
|
49
47
|
}
|
|
50
|
-
latest.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
48
|
+
latest.subscribers -= 1;
|
|
49
|
+
if (latest.subscribers < 0) {
|
|
50
|
+
latest.subscribers = 0;
|
|
51
|
+
}
|
|
52
|
+
if (!latest.disposed && latest.subscribers === 0) {
|
|
53
|
+
const token = Symbol("pending-dispose");
|
|
54
|
+
latest.pendingDisposeToken = token;
|
|
55
|
+
queueMicrotask(() => {
|
|
56
|
+
const current = stateRef.current;
|
|
57
|
+
if (current === void 0 || current.instance !== instance || current.subscribers > 0 || current.disposed || current.pendingDisposeToken !== token) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
current.disposed = true;
|
|
61
|
+
current.pendingDisposeToken = null;
|
|
62
|
+
stateRef.current = void 0;
|
|
63
|
+
core.cleanupLogic(instance);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
62
66
|
};
|
|
63
67
|
}, [instance]);
|
|
64
68
|
return instance;
|
package/dist/index.mjs
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { useRef, useEffect, useCallback, useSyncExternalStore, useMemo } from 'react';
|
|
2
2
|
import { cleanupLogic, mountLogic, createSignalHandler, createComputedHandler, createDeepSignalHandler } from '@sigrea/core';
|
|
3
3
|
|
|
4
|
-
const scheduleMicrotask = (callback) => {
|
|
5
|
-
if (typeof globalThis.queueMicrotask === "function") {
|
|
6
|
-
globalThis.queueMicrotask(callback);
|
|
7
|
-
return;
|
|
8
|
-
}
|
|
9
|
-
Promise.resolve().then(callback);
|
|
10
|
-
};
|
|
11
4
|
function useLogic(logic, ...args) {
|
|
12
5
|
const props = args.length === 0 ? void 0 : args[0];
|
|
13
6
|
const stateRef = useRef(void 0);
|
|
@@ -15,6 +8,7 @@ function useLogic(logic, ...args) {
|
|
|
15
8
|
const shouldRemount = currentState === void 0 || currentState.logic !== logic || !Object.is(currentState.props, props);
|
|
16
9
|
if (shouldRemount) {
|
|
17
10
|
if (currentState !== void 0) {
|
|
11
|
+
currentState.pendingDisposeToken = null;
|
|
18
12
|
cleanupLogic(currentState.instance);
|
|
19
13
|
stateRef.current = void 0;
|
|
20
14
|
}
|
|
@@ -23,8 +17,9 @@ function useLogic(logic, ...args) {
|
|
|
23
17
|
instance: mountLogic(logic, ...logicArgs),
|
|
24
18
|
logic,
|
|
25
19
|
props,
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
subscribers: 0,
|
|
21
|
+
disposed: false,
|
|
22
|
+
pendingDisposeToken: null
|
|
28
23
|
};
|
|
29
24
|
}
|
|
30
25
|
const state = stateRef.current;
|
|
@@ -38,25 +33,34 @@ function useLogic(logic, ...args) {
|
|
|
38
33
|
return () => {
|
|
39
34
|
};
|
|
40
35
|
}
|
|
41
|
-
state2.
|
|
36
|
+
if (state2.pendingDisposeToken !== null) {
|
|
37
|
+
state2.pendingDisposeToken = null;
|
|
38
|
+
}
|
|
39
|
+
state2.subscribers += 1;
|
|
42
40
|
return () => {
|
|
43
41
|
const latest = stateRef.current;
|
|
44
42
|
if (latest === void 0 || latest.instance !== instance) {
|
|
45
43
|
cleanupLogic(instance);
|
|
46
44
|
return;
|
|
47
45
|
}
|
|
48
|
-
latest.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
46
|
+
latest.subscribers -= 1;
|
|
47
|
+
if (latest.subscribers < 0) {
|
|
48
|
+
latest.subscribers = 0;
|
|
49
|
+
}
|
|
50
|
+
if (!latest.disposed && latest.subscribers === 0) {
|
|
51
|
+
const token = Symbol("pending-dispose");
|
|
52
|
+
latest.pendingDisposeToken = token;
|
|
53
|
+
queueMicrotask(() => {
|
|
54
|
+
const current = stateRef.current;
|
|
55
|
+
if (current === void 0 || current.instance !== instance || current.subscribers > 0 || current.disposed || current.pendingDisposeToken !== token) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
current.disposed = true;
|
|
59
|
+
current.pendingDisposeToken = null;
|
|
60
|
+
stateRef.current = void 0;
|
|
61
|
+
cleanupLogic(instance);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
60
64
|
};
|
|
61
65
|
}, [instance]);
|
|
62
66
|
return instance;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigrea/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "React adapter bindings for Sigrea logic modules.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,16 +39,16 @@
|
|
|
39
39
|
"typescript"
|
|
40
40
|
],
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@sigrea/core": "^0.1
|
|
42
|
+
"@sigrea/core": "^0.3.1",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0",
|
|
44
44
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@biomejs/biome": "1.9.4",
|
|
48
48
|
"@vitejs/plugin-react": "^4.3.3",
|
|
49
|
-
"@changesets/cli": "^2.29.6",
|
|
50
49
|
"@types/react": "^19.0.0",
|
|
51
50
|
"@types/react-dom": "^19.0.0",
|
|
51
|
+
"changelogen": "^0.6.2",
|
|
52
52
|
"@vitest/coverage-v8": "^3.2.4",
|
|
53
53
|
"lefthook": "1.13.6",
|
|
54
54
|
"react": "^19.0.0",
|
|
@@ -63,6 +63,8 @@
|
|
|
63
63
|
"scripts": {
|
|
64
64
|
"dev": "vite --config playground/vite.config.ts",
|
|
65
65
|
"build": "unbuild",
|
|
66
|
+
"changelog": "changelogen",
|
|
67
|
+
"release": "pnpm test && pnpm build && changelogen --release",
|
|
66
68
|
"test": "vitest run",
|
|
67
69
|
"test:coverage": "vitest --coverage",
|
|
68
70
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|