@hoosk/minisignals-react 1.0.4 → 1.0.6

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
@@ -31,4 +31,21 @@ function Counter() {
31
31
  }
32
32
  ```
33
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
+
34
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 { Signal, Computed } from '@hoosk/minisignals';
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 The Signal or Computed to observe.
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: Signal<T> | Computed<T>): T;
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 { Signal, Computed } from '@hoosk/minisignals';
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 The Signal or Computed to observe.
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: Signal<T> | Computed<T>): T;
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
@@ -1,11 +1,19 @@
1
1
  {
2
2
  "name": "@hoosk/minisignals-react",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "React adapter for @hoosk/minisignals",
5
5
  "type": "module",
6
- "main": "dist/index.js",
7
- "module": "dist/index.mjs",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
9
17
  "peerDependencies": {
10
18
  "react": ">=18.0.0"
11
19
  },
@@ -24,6 +32,7 @@
24
32
  "scripts": {
25
33
  "build": "tsup src/index.ts --format cjs,esm --dts --clean",
26
34
  "dev": "tsup src/index.ts --format cjs,esm --watch --dts",
35
+ "prepare": "npm run build",
27
36
  "test": "vitest run"
28
37
  },
29
38
  "author": "Hoosk",
@@ -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
- name.value = 'B';
139
+ batch(() => {
140
+ name.value = 'Jane';
141
+ surname.value = 'Smith';
142
+ });
55
143
  });
56
144
 
57
- expect(display.textContent).toBe('BB');
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
  });