@sigrea/react 0.1.0 → 0.2.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 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 so they can participate in React components. It wires scope-aware lifecycles to `useEffect`, keeps signal subscriptions aligned with React rendering, and surfaces ergonomic hooks for both shallow and deep reactivity.
4
-
5
- ## Installation
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
- pnpm add @sigrea/react @sigrea/core react react-dom
29
+ npm install @sigrea/react @sigrea/core react react-dom
9
30
  ```
10
31
 
11
- React 18+ and Node.js 20+ are required. Equivalent npm or yarn commands work the same way.
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 signal
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
- const value = useSignal(count);
33
- return <span>{value}</span>;
45
+ const value = useSignal(count);
46
+ return <span>{value}</span>;
34
47
  }
35
48
  ```
36
49
 
37
- ### Bridge framework-agnostic logic
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
- const count = signal(props.initialCount);
57
+ const count = signal(props.initialCount);
45
58
 
46
- const increment = () => {
47
- count.value += 1;
48
- };
59
+ const increment = () => {
60
+ count.value += 1;
61
+ };
49
62
 
50
- const reset = () => {
51
- count.value = props.initialCount;
52
- };
63
+ const reset = () => {
64
+ count.value = props.initialCount;
65
+ };
53
66
 
54
- return { count, increment, reset };
67
+ return { count, increment, reset };
55
68
  });
56
69
 
57
70
  export function Counter(props: { initialCount: number }) {
58
- const counter = useLogic(CounterLogic, props);
59
- const value = useSignal(counter.count);
60
-
61
- return (
62
- <div>
63
- <span>{value}</span>
64
- <button onClick={counter.increment}>Increment</button>
65
- <button onClick={counter.reset}>Reset</button>
66
- </div>
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 deep signals
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
- const state = useDeepSignal(form);
81
-
82
- return (
83
- <label>
84
- Name
85
- <input
86
- value={state.name}
87
- onChange={(event) => {
88
- state.name = event.target.value;
89
- }}
90
- />
91
- </label>
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
- - `pnpm install` – install dependencies
99
- - `pnpm test` – run the Vitest suite
100
- - `pnpm build` emit distributable artifacts
101
- - `pnpm dev` launch the playground counter demo
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 `LICENSE`.
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
- cleanupScheduled: false,
29
- cleanupToken: 0
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.cleanupScheduled = false;
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.cleanupScheduled = true;
51
- const token = latest.cleanupToken + 1;
52
- latest.cleanupToken = token;
53
- scheduleMicrotask(() => {
54
- const updated = stateRef.current;
55
- if (updated === void 0 || updated.instance !== instance || !updated.cleanupScheduled || updated.cleanupToken !== token) {
56
- return;
57
- }
58
- updated.cleanupScheduled = false;
59
- stateRef.current = void 0;
60
- core.cleanupLogic(instance);
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
- cleanupScheduled: false,
27
- cleanupToken: 0
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.cleanupScheduled = false;
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.cleanupScheduled = true;
49
- const token = latest.cleanupToken + 1;
50
- latest.cleanupToken = token;
51
- scheduleMicrotask(() => {
52
- const updated = stateRef.current;
53
- if (updated === void 0 || updated.instance !== instance || !updated.cleanupScheduled || updated.cleanupToken !== token) {
54
- return;
55
- }
56
- updated.cleanupScheduled = false;
57
- stateRef.current = void 0;
58
- cleanupLogic(instance);
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,72 +1,82 @@
1
1
  {
2
- "name": "@sigrea/react",
3
- "version": "0.1.0",
4
- "description": "React adapter bindings for Sigrea logic modules.",
5
- "license": "MIT",
6
- "type": "module",
7
- "publishConfig": {
8
- "access": "public"
9
- },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/sigrea/react.git"
13
- },
14
- "homepage": "https://github.com/sigrea/react#readme",
15
- "bugs": {
16
- "url": "https://github.com/sigrea/react/issues"
17
- },
18
- "engines": {
19
- "node": ">=20"
20
- },
21
- "sideEffects": false,
22
- "exports": {
23
- ".": {
24
- "types": "./dist/index.d.ts",
25
- "import": "./dist/index.mjs",
26
- "require": "./dist/index.cjs"
27
- }
28
- },
29
- "main": "./dist/index.cjs",
30
- "types": "./dist/index.d.ts",
31
- "files": [
32
- "dist"
33
- ],
34
- "keywords": [
35
- "signals",
36
- "reactivity",
37
- "react",
38
- "logic",
39
- "typescript"
40
- ],
41
- "peerDependencies": {
42
- "@sigrea/core": "^0.1.0",
43
- "react": "^18.0.0 || ^19.0.0",
44
- "react-dom": "^18.0.0 || ^19.0.0"
45
- },
46
- "devDependencies": {
47
- "@biomejs/biome": "1.9.4",
48
- "@vitejs/plugin-react": "^4.3.3",
49
- "@changesets/cli": "^2.29.6",
50
- "@types/react": "^19.0.0",
51
- "@types/react-dom": "^19.0.0",
52
- "@vitest/coverage-v8": "^3.2.4",
53
- "lefthook": "1.13.6",
54
- "react": "^19.0.0",
55
- "react-dom": "^19.0.0",
56
- "tsx": "^4.20.5",
57
- "typescript": "5.9.3",
58
- "unbuild": "3.6.1",
59
- "vite": "^5.4.6",
60
- "vitest": "^3.2.4",
61
- "jsdom": "^24.1.3"
62
- },
63
- "scripts": {
64
- "dev": "vite --config playground/vite.config.ts",
65
- "build": "unbuild",
66
- "test": "vitest run",
67
- "test:coverage": "vitest --coverage",
68
- "typecheck": "tsc -p tsconfig.json --noEmit",
69
- "format": "biome check .",
70
- "format:fix": "biome check --write ."
71
- }
72
- }
2
+ "name": "@sigrea/react",
3
+ "version": "0.2.1",
4
+ "description": "React adapter bindings for Sigrea logic modules.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "packageManager": "pnpm@10.0.0",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/sigrea/react.git"
14
+ },
15
+ "homepage": "https://github.com/sigrea/react#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/sigrea/react/issues"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "sideEffects": false,
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.mjs",
27
+ "require": "./dist/index.cjs"
28
+ }
29
+ },
30
+ "main": "./dist/index.cjs",
31
+ "types": "./dist/index.d.ts",
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "keywords": [
36
+ "signals",
37
+ "reactivity",
38
+ "react",
39
+ "logic",
40
+ "typescript"
41
+ ],
42
+ "scripts": {
43
+ "dev": "vite --config playground/vite.config.ts",
44
+ "build": "unbuild",
45
+ "prepack": "unbuild",
46
+ "changelog": "changelogen",
47
+ "release": "pnpm test && pnpm build && changelogen --release",
48
+ "test": "vitest run",
49
+ "test:coverage": "vitest --coverage",
50
+ "typecheck": "tsc -p tsconfig.json --noEmit",
51
+ "format": "biome check .",
52
+ "format:fix": "biome check --write ."
53
+ },
54
+ "peerDependencies": {
55
+ "@sigrea/core": "^0.3.1",
56
+ "react": "^18.0.0 || ^19.0.0",
57
+ "react-dom": "^18.0.0 || ^19.0.0"
58
+ },
59
+ "devDependencies": {
60
+ "@biomejs/biome": "1.9.4",
61
+ "@vitejs/plugin-react": "^4.3.3",
62
+ "@types/react": "^19.0.0",
63
+ "@types/react-dom": "^19.0.0",
64
+ "changelogen": "^0.6.2",
65
+ "@vitest/coverage-v8": "^3.2.4",
66
+ "lefthook": "1.13.6",
67
+ "react": "^19.0.0",
68
+ "react-dom": "^19.0.0",
69
+ "tsx": "^4.20.5",
70
+ "typescript": "5.9.3",
71
+ "unbuild": "3.6.1",
72
+ "vite": "^5.4.6",
73
+ "vitest": "^3.2.4",
74
+ "jsdom": "^24.1.3"
75
+ },
76
+ "pnpm": {
77
+ "onlyBuiltDependencies": [
78
+ "lefthook",
79
+ "@biomejs/biome"
80
+ ]
81
+ }
82
+ }