@hoosk/minisignals-react 1.0.3 → 1.0.5
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 +51 -0
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/package.json +1 -1
- package/tests/adapter.test.tsx +140 -20
- package/src/index.ts +0 -2
- package/src/useSignal.ts +0 -20
- package/src/useSignalValue.ts +0 -36
- package/tsconfig.json +0 -7
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @hoosk/minisignals-react
|
|
2
|
+
|
|
3
|
+
The React 18 adapter for `@hoosk/minisignals`.
|
|
4
|
+
|
|
5
|
+
This package provides custom hooks to seamlessly integrate the agnostic reactive signals with React's component lifecycle and rendering engine. It utilizes React 18's `useSyncExternalStore` for tear-free concurrent rendering.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @hoosk/minisignals @hoosk/minisignals-react
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Basic Usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { useSignal, useSignalValue } from '@hoosk/minisignals-react';
|
|
17
|
+
|
|
18
|
+
function Counter() {
|
|
19
|
+
// Create a local signal that survives re-renders
|
|
20
|
+
const count = useSignal(0);
|
|
21
|
+
|
|
22
|
+
// Connect the signal to trigger React renders on mutation
|
|
23
|
+
const value = useSignalValue(count);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div>
|
|
27
|
+
<p>Count: {value}</p>
|
|
28
|
+
<button onClick={() => count.value++}>Increment</button>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### `useSignalValue` accepts any `ReadonlySignal`
|
|
35
|
+
|
|
36
|
+
`useSignalValue` accepts both `Signal<T>` and `Computed<T>` via the shared `ReadonlySignal<T>` interface, so you can pass either directly:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { signal, computed } from '@hoosk/minisignals';
|
|
40
|
+
import { useSignalValue } from '@hoosk/minisignals-react';
|
|
41
|
+
|
|
42
|
+
const price = signal(100);
|
|
43
|
+
const doubled = computed(() => price.value * 2);
|
|
44
|
+
|
|
45
|
+
function Price() {
|
|
46
|
+
const value = useSignalValue(doubled); // works with Computed too
|
|
47
|
+
return <p>{value}</p>;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For full documentation, please refer to the [main repository README](../../README.md).
|
package/dist/index.d.cts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ReadonlySignal, Signal } from '@hoosk/minisignals';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A hook that subscribes to a signal or computed value and triggers
|
|
5
5
|
* a React re-render whenever the value changes.
|
|
6
6
|
* Uses React 18's useSyncExternalStore for tear-free concurrent rendering.
|
|
7
7
|
*
|
|
8
|
-
* @param sig
|
|
8
|
+
* @param sig Any ReadonlySignal (Signal or Computed) to observe.
|
|
9
9
|
* @returns The current value of the signal.
|
|
10
10
|
*/
|
|
11
|
-
declare function useSignalValue<T>(sig:
|
|
11
|
+
declare function useSignalValue<T>(sig: ReadonlySignal<T>): T;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Creates a signal that is tied to the component's lifecycle.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ReadonlySignal, Signal } from '@hoosk/minisignals';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A hook that subscribes to a signal or computed value and triggers
|
|
5
5
|
* a React re-render whenever the value changes.
|
|
6
6
|
* Uses React 18's useSyncExternalStore for tear-free concurrent rendering.
|
|
7
7
|
*
|
|
8
|
-
* @param sig
|
|
8
|
+
* @param sig Any ReadonlySignal (Signal or Computed) to observe.
|
|
9
9
|
* @returns The current value of the signal.
|
|
10
10
|
*/
|
|
11
|
-
declare function useSignalValue<T>(sig:
|
|
11
|
+
declare function useSignalValue<T>(sig: ReadonlySignal<T>): T;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Creates a signal that is tied to the component's lifecycle.
|
package/package.json
CHANGED
package/tests/adapter.test.tsx
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
2
|
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
-
import { render, screen, act } from '@testing-library/react';
|
|
4
|
-
import { signal, computed } from '@hoosk/minisignals';
|
|
3
|
+
import { render, screen, act, cleanup } from '@testing-library/react';
|
|
4
|
+
import { signal, computed, batch } from '@hoosk/minisignals';
|
|
5
5
|
import { useSignalValue, useSignal } from '../src/index.ts';
|
|
6
6
|
|
|
7
7
|
describe('@hoosk/minisignals-react adapter', () => {
|
|
8
8
|
describe('useSignalValue()', () => {
|
|
9
9
|
it('should render the initial value of a signal', () => {
|
|
10
10
|
const count = signal(5);
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
const TestComponent = () => {
|
|
13
13
|
const val = useSignalValue(count);
|
|
14
14
|
return <div>Value: {val}</div>;
|
|
@@ -20,7 +20,7 @@ describe('@hoosk/minisignals-react adapter', () => {
|
|
|
20
20
|
|
|
21
21
|
it('should trigger a re-render when the signal mutates', () => {
|
|
22
22
|
const count = signal(10);
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
const TestComponent = () => {
|
|
25
25
|
const val = useSignalValue(count);
|
|
26
26
|
return <div data-testid="display">{val}</div>;
|
|
@@ -30,9 +30,7 @@ describe('@hoosk/minisignals-react adapter', () => {
|
|
|
30
30
|
const display = screen.getByTestId('display');
|
|
31
31
|
expect(display.textContent).toBe('10');
|
|
32
32
|
|
|
33
|
-
act(() => {
|
|
34
|
-
count.value = 20;
|
|
35
|
-
});
|
|
33
|
+
act(() => { count.value = 20; });
|
|
36
34
|
|
|
37
35
|
expect(display.textContent).toBe('20');
|
|
38
36
|
});
|
|
@@ -40,7 +38,7 @@ describe('@hoosk/minisignals-react adapter', () => {
|
|
|
40
38
|
it('should work seamlessly with computed signals', () => {
|
|
41
39
|
const name = signal('A');
|
|
42
40
|
const doubledName = computed(() => name.value + name.value);
|
|
43
|
-
|
|
41
|
+
|
|
44
42
|
const TestComponent = () => {
|
|
45
43
|
const val = useSignalValue(doubledName);
|
|
46
44
|
return <div data-testid="computed-display">{val}</div>;
|
|
@@ -50,25 +48,115 @@ describe('@hoosk/minisignals-react adapter', () => {
|
|
|
50
48
|
const display = screen.getByTestId('computed-display');
|
|
51
49
|
expect(display.textContent).toBe('AA');
|
|
52
50
|
|
|
51
|
+
act(() => { name.value = 'B'; });
|
|
52
|
+
|
|
53
|
+
expect(display.textContent).toBe('BB');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('NEGATIVE CASE: should unsubscribe when the component unmounts (no memory leak)', () => {
|
|
57
|
+
const count = signal(0);
|
|
58
|
+
const renderSpy = vi.fn();
|
|
59
|
+
|
|
60
|
+
const TestComponent = () => {
|
|
61
|
+
renderSpy();
|
|
62
|
+
const val = useSignalValue(count);
|
|
63
|
+
return <div>{val}</div>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const { unmount } = render(<TestComponent />);
|
|
67
|
+
expect(renderSpy).toHaveBeenCalledTimes(1);
|
|
68
|
+
|
|
69
|
+
unmount();
|
|
70
|
+
|
|
71
|
+
// Mutating after unmount must NOT trigger any render
|
|
72
|
+
act(() => { count.value = 99; });
|
|
73
|
+
expect(renderSpy).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should re-subscribe when the observed signal instance changes', () => {
|
|
77
|
+
const sigA = signal('A');
|
|
78
|
+
const sigB = signal('B');
|
|
79
|
+
|
|
80
|
+
const TestComponent = ({ which }: { which: 'a' | 'b' }) => {
|
|
81
|
+
const val = useSignalValue(which === 'a' ? sigA : sigB);
|
|
82
|
+
return <div data-testid="out">{val}</div>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const { rerender } = render(<TestComponent which="a" />);
|
|
86
|
+
expect(screen.getByTestId('out').textContent).toBe('A');
|
|
87
|
+
|
|
88
|
+
rerender(<TestComponent which="b" />);
|
|
89
|
+
expect(screen.getByTestId('out').textContent).toBe('B');
|
|
90
|
+
|
|
91
|
+
// Mutating the old signal must not re-render
|
|
92
|
+
act(() => { sigA.value = 'A2'; });
|
|
93
|
+
expect(screen.getByTestId('out').textContent).toBe('B');
|
|
94
|
+
|
|
95
|
+
// Mutating the new signal must re-render
|
|
96
|
+
act(() => { sigB.value = 'B2'; });
|
|
97
|
+
expect(screen.getByTestId('out').textContent).toBe('B2');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should update multiple sibling components sharing the same signal independently', () => {
|
|
101
|
+
const shared = signal(0);
|
|
102
|
+
const renderCounts = { a: 0, b: 0 };
|
|
103
|
+
|
|
104
|
+
const CompA = () => {
|
|
105
|
+
renderCounts.a++;
|
|
106
|
+
return <span data-testid="a">{useSignalValue(shared)}</span>;
|
|
107
|
+
};
|
|
108
|
+
const CompB = () => {
|
|
109
|
+
renderCounts.b++;
|
|
110
|
+
return <span data-testid="b">{useSignalValue(shared)}</span>;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
render(<><CompA /><CompB /></>);
|
|
114
|
+
expect(renderCounts).toEqual({ a: 1, b: 1 });
|
|
115
|
+
|
|
116
|
+
act(() => { shared.value = 42; });
|
|
117
|
+
|
|
118
|
+
expect(screen.getByTestId('a').textContent).toBe('42');
|
|
119
|
+
expect(screen.getByTestId('b').textContent).toBe('42');
|
|
120
|
+
expect(renderCounts).toEqual({ a: 2, b: 2 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should only trigger one re-render when signal changes inside a batch', () => {
|
|
124
|
+
const name = signal('John');
|
|
125
|
+
const surname = signal('Doe');
|
|
126
|
+
const renderSpy = vi.fn();
|
|
127
|
+
|
|
128
|
+
const TestComponent = () => {
|
|
129
|
+
renderSpy();
|
|
130
|
+
const n = useSignalValue(name);
|
|
131
|
+
const s = useSignalValue(surname);
|
|
132
|
+
return <div data-testid="full">{n} {s}</div>;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
render(<TestComponent />);
|
|
136
|
+
expect(renderSpy).toHaveBeenCalledTimes(1);
|
|
137
|
+
|
|
53
138
|
act(() => {
|
|
54
|
-
|
|
139
|
+
batch(() => {
|
|
140
|
+
name.value = 'Jane';
|
|
141
|
+
surname.value = 'Smith';
|
|
142
|
+
});
|
|
55
143
|
});
|
|
56
144
|
|
|
57
|
-
expect(
|
|
145
|
+
expect(screen.getByTestId('full').textContent).toBe('Jane Smith');
|
|
146
|
+
// 2 total: 1 initial + 1 from batch (not 3)
|
|
147
|
+
expect(renderSpy).toHaveBeenCalledTimes(2);
|
|
58
148
|
});
|
|
59
149
|
});
|
|
60
150
|
|
|
61
151
|
describe('useSignal()', () => {
|
|
62
152
|
it('should maintain state across re-renders', () => {
|
|
63
153
|
let renderCount = 0;
|
|
64
|
-
|
|
154
|
+
|
|
65
155
|
const TestComponent = () => {
|
|
66
156
|
renderCount++;
|
|
67
|
-
// Create local signal
|
|
68
157
|
const localCount = useSignal(100);
|
|
69
|
-
// Subscribe to it to trigger re-renders
|
|
70
158
|
const val = useSignalValue(localCount);
|
|
71
|
-
|
|
159
|
+
|
|
72
160
|
return (
|
|
73
161
|
<div>
|
|
74
162
|
<span data-testid="val">{val}</span>
|
|
@@ -78,20 +166,52 @@ describe('@hoosk/minisignals-react adapter', () => {
|
|
|
78
166
|
};
|
|
79
167
|
|
|
80
168
|
render(<TestComponent />);
|
|
81
|
-
|
|
169
|
+
|
|
82
170
|
const valSpan = screen.getByTestId('val');
|
|
83
171
|
const btn = screen.getByText('Add');
|
|
84
172
|
|
|
85
173
|
expect(valSpan.textContent).toBe('100');
|
|
86
174
|
expect(renderCount).toBe(1);
|
|
87
175
|
|
|
88
|
-
act(() => {
|
|
89
|
-
btn.click();
|
|
90
|
-
});
|
|
176
|
+
act(() => { btn.click(); });
|
|
91
177
|
|
|
92
|
-
// The hook maintained the signal instance, and useSignalValue triggered the re-render
|
|
93
178
|
expect(valSpan.textContent).toBe('101');
|
|
94
179
|
expect(renderCount).toBe(2);
|
|
95
180
|
});
|
|
181
|
+
|
|
182
|
+
it('should return the same signal instance across re-renders', () => {
|
|
183
|
+
let capturedSignal: ReturnType<typeof useSignal<number>> | null = null;
|
|
184
|
+
let prevSignal: typeof capturedSignal = null;
|
|
185
|
+
let instanceChanged = false;
|
|
186
|
+
|
|
187
|
+
const TestComponent = () => {
|
|
188
|
+
const sig = useSignal(0);
|
|
189
|
+
if (capturedSignal !== null && capturedSignal !== sig) instanceChanged = true;
|
|
190
|
+
capturedSignal = sig;
|
|
191
|
+
const val = useSignalValue(sig);
|
|
192
|
+
return <div data-testid="v">{val}</div>;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const { rerender } = render(<TestComponent />);
|
|
196
|
+
rerender(<TestComponent />);
|
|
197
|
+
rerender(<TestComponent />);
|
|
198
|
+
|
|
199
|
+
expect(instanceChanged).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should ignore the initialValue on subsequent renders', () => {
|
|
203
|
+
const TestComponent = ({ init }: { init: number }) => {
|
|
204
|
+
const sig = useSignal(init);
|
|
205
|
+
const val = useSignalValue(sig);
|
|
206
|
+
return <div data-testid="v">{val}</div>;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const { rerender } = render(<TestComponent init={1} />);
|
|
210
|
+
expect(screen.getByTestId('v').textContent).toBe('1');
|
|
211
|
+
|
|
212
|
+
// Passing a different initial value on re-render must have no effect
|
|
213
|
+
rerender(<TestComponent init={999} />);
|
|
214
|
+
expect(screen.getByTestId('v').textContent).toBe('1');
|
|
215
|
+
});
|
|
96
216
|
});
|
|
97
217
|
});
|
package/src/index.ts
DELETED
package/src/useSignal.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { useRef } from 'react';
|
|
2
|
-
import { signal, Signal } from '@hoosk/minisignals';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Creates a signal that is tied to the component's lifecycle.
|
|
6
|
-
* The signal instance is preserved across re-renders.
|
|
7
|
-
* NOTE: To trigger a re-render on value change, you must use `useSignalValue(sig)`.
|
|
8
|
-
*
|
|
9
|
-
* @param initialValue The starting value of the signal.
|
|
10
|
-
* @returns A stable Signal instance.
|
|
11
|
-
*/
|
|
12
|
-
export function useSignal<T>(initialValue: T): Signal<T> {
|
|
13
|
-
const signalRef = useRef<Signal<T> | null>(null);
|
|
14
|
-
|
|
15
|
-
if (signalRef.current === null) {
|
|
16
|
-
signalRef.current = signal(initialValue);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return signalRef.current;
|
|
20
|
-
}
|
package/src/useSignalValue.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { useSyncExternalStore, useCallback } from 'react';
|
|
2
|
-
import { effect, Signal, Computed } from '@hoosk/minisignals';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* A hook that subscribes to a signal or computed value and triggers
|
|
6
|
-
* a React re-render whenever the value changes.
|
|
7
|
-
* Uses React 18's useSyncExternalStore for tear-free concurrent rendering.
|
|
8
|
-
*
|
|
9
|
-
* @param sig The Signal or Computed to observe.
|
|
10
|
-
* @returns The current value of the signal.
|
|
11
|
-
*/
|
|
12
|
-
export function useSignalValue<T>(sig: Signal<T> | Computed<T>): T {
|
|
13
|
-
const subscribe = useCallback(
|
|
14
|
-
(onStoreChange: () => void) => {
|
|
15
|
-
let isInitialRun = true;
|
|
16
|
-
|
|
17
|
-
const unsubscribe = effect(() => {
|
|
18
|
-
const _val = sig.value;
|
|
19
|
-
|
|
20
|
-
if (!isInitialRun) {
|
|
21
|
-
onStoreChange();
|
|
22
|
-
}
|
|
23
|
-
isInitialRun = false;
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
return unsubscribe;
|
|
27
|
-
},
|
|
28
|
-
[sig]
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
return useSyncExternalStore(
|
|
32
|
-
subscribe,
|
|
33
|
-
() => sig.value, // getSnapshot
|
|
34
|
-
() => sig.value // getServerSnapshot (SSR support)
|
|
35
|
-
);
|
|
36
|
-
}
|