@hoosk/minisignals-react 1.0.8 → 1.1.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 CHANGED
@@ -14,7 +14,6 @@ npm install @hoosk/minisignals @hoosk/minisignals-react
14
14
 
15
15
  ```tsx
16
16
  import { useSignal, useSignalValue } from '@hoosk/minisignals-react';
17
-
18
17
  function Counter() {
19
18
  // Create a local signal that survives re-renders
20
19
  const count = useSignal(0);
@@ -31,6 +30,40 @@ function Counter() {
31
30
  }
32
31
  ```
33
32
 
33
+ ### `useComputed(fn, deps?)`
34
+
35
+ Creates a `Computed<T>` that survives re-renders and is disposed automatically on unmount or when `deps` change:
36
+
37
+ ```tsx
38
+ import { signal } from '@hoosk/minisignals';
39
+ import { useComputed, useSignalValue } from '@hoosk/minisignals-react';
40
+
41
+ const price = signal(100);
42
+
43
+ function Total({ quantity }: { quantity: number }) {
44
+ const total = useComputed(() => price.value * quantity, [quantity]);
45
+ return <p>{useSignalValue(total)}</p>;
46
+ }
47
+ ```
48
+
49
+ ### `useSignalEffect(fn, deps?)`
50
+
51
+ Runs a core `effect` bound to the component lifecycle. The callback may return a cleanup function, which runs before each re-execution and on unmount:
52
+
53
+ ```tsx
54
+ import { signal } from '@hoosk/minisignals';
55
+ import { useSignalEffect } from '@hoosk/minisignals-react';
56
+
57
+ const count = signal(0);
58
+
59
+ function Title() {
60
+ useSignalEffect(() => {
61
+ document.title = `Count: ${count.value}`;
62
+ });
63
+ return null;
64
+ }
65
+ ```
66
+
34
67
  ### `useSignalValue` accepts any `ReadonlySignal`
35
68
 
36
69
  `useSignalValue` accepts both `Signal<T>` and `Computed<T>` via the shared `ReadonlySignal<T>` interface, so you can pass either directly:
package/dist/index.cjs CHANGED
@@ -20,7 +20,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ useComputed: () => useComputed,
23
24
  useSignal: () => useSignal,
25
+ useSignalEffect: () => useSignalEffect,
24
26
  useSignalValue: () => useSignalValue
25
27
  });
26
28
  module.exports = __toCommonJS(index_exports);
@@ -45,9 +47,9 @@ function useSignalValue(sig) {
45
47
  );
46
48
  return (0, import_react.useSyncExternalStore)(
47
49
  subscribe,
48
- () => sig.value,
50
+ () => sig.peek(),
49
51
  // getSnapshot
50
- () => sig.value
52
+ () => sig.peek()
51
53
  // getServerSnapshot (SSR support)
52
54
  );
53
55
  }
@@ -62,8 +64,35 @@ function useSignal(initialValue) {
62
64
  }
63
65
  return signalRef.current;
64
66
  }
67
+
68
+ // src/useComputed.ts
69
+ var import_react3 = require("react");
70
+ var import_minisignals3 = require("@hoosk/minisignals");
71
+ function useComputed(fn, deps = []) {
72
+ const fnRef = (0, import_react3.useRef)(fn);
73
+ fnRef.current = fn;
74
+ const instance = (0, import_react3.useMemo)(
75
+ () => (0, import_minisignals3.computed)(() => fnRef.current()),
76
+ deps
77
+ );
78
+ (0, import_react3.useEffect)(() => () => instance.dispose(), [instance]);
79
+ return instance;
80
+ }
81
+
82
+ // src/useSignalEffect.ts
83
+ var import_react4 = require("react");
84
+ var import_minisignals4 = require("@hoosk/minisignals");
85
+ function useSignalEffect(fn, deps = []) {
86
+ const fnRef = (0, import_react4.useRef)(fn);
87
+ fnRef.current = fn;
88
+ (0, import_react4.useEffect)(() => {
89
+ return (0, import_minisignals4.effect)(() => fnRef.current());
90
+ }, deps);
91
+ }
65
92
  // Annotate the CommonJS export names for ESM import in node:
66
93
  0 && (module.exports = {
94
+ useComputed,
67
95
  useSignal,
96
+ useSignalEffect,
68
97
  useSignalValue
69
98
  });
package/dist/index.d.cts CHANGED
@@ -1,4 +1,5 @@
1
- import { ReadonlySignal, Signal } from '@hoosk/minisignals';
1
+ import { ReadonlySignal, Signal, Computed } from '@hoosk/minisignals';
2
+ import { DependencyList } from 'react';
2
3
 
3
4
  /**
4
5
  * A hook that subscribes to a signal or computed value and triggers
@@ -20,4 +21,29 @@ declare function useSignalValue<T>(sig: ReadonlySignal<T>): T;
20
21
  */
21
22
  declare function useSignal<T>(initialValue: T): Signal<T>;
22
23
 
23
- export { useSignal, useSignalValue };
24
+ /**
25
+ * Creates a computed signal tied to the component's lifecycle.
26
+ *
27
+ * The instance is stable across re-renders unless `deps` changes. It is
28
+ * automatically disposed when the component unmounts, preventing the source
29
+ * signals from retaining subscriptions to unmounted components.
30
+ *
31
+ * @param fn The derivation function. The latest closure is always used.
32
+ * @param deps Optional dependency list that recreates the computed instance.
33
+ * @returns A stable Computed instance.
34
+ */
35
+ declare function useComputed<T>(fn: () => T, deps?: DependencyList): Computed<T>;
36
+
37
+ /**
38
+ * Runs a reactive effect tied to the component's lifecycle.
39
+ *
40
+ * The effect is created on mount, re-created when `deps` change, and stopped
41
+ * on unmount. The callback may return a cleanup function, which runs before
42
+ * each re-execution and when the effect is stopped.
43
+ *
44
+ * @param fn The effect callback. The latest closure is always used.
45
+ * @param deps Optional dependency list that recreates the effect.
46
+ */
47
+ declare function useSignalEffect(fn: () => void | (() => void), deps?: DependencyList): void;
48
+
49
+ export { useComputed, useSignal, useSignalEffect, useSignalValue };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { ReadonlySignal, Signal } from '@hoosk/minisignals';
1
+ import { ReadonlySignal, Signal, Computed } from '@hoosk/minisignals';
2
+ import { DependencyList } from 'react';
2
3
 
3
4
  /**
4
5
  * A hook that subscribes to a signal or computed value and triggers
@@ -20,4 +21,29 @@ declare function useSignalValue<T>(sig: ReadonlySignal<T>): T;
20
21
  */
21
22
  declare function useSignal<T>(initialValue: T): Signal<T>;
22
23
 
23
- export { useSignal, useSignalValue };
24
+ /**
25
+ * Creates a computed signal tied to the component's lifecycle.
26
+ *
27
+ * The instance is stable across re-renders unless `deps` changes. It is
28
+ * automatically disposed when the component unmounts, preventing the source
29
+ * signals from retaining subscriptions to unmounted components.
30
+ *
31
+ * @param fn The derivation function. The latest closure is always used.
32
+ * @param deps Optional dependency list that recreates the computed instance.
33
+ * @returns A stable Computed instance.
34
+ */
35
+ declare function useComputed<T>(fn: () => T, deps?: DependencyList): Computed<T>;
36
+
37
+ /**
38
+ * Runs a reactive effect tied to the component's lifecycle.
39
+ *
40
+ * The effect is created on mount, re-created when `deps` change, and stopped
41
+ * on unmount. The callback may return a cleanup function, which runs before
42
+ * each re-execution and when the effect is stopped.
43
+ *
44
+ * @param fn The effect callback. The latest closure is always used.
45
+ * @param deps Optional dependency list that recreates the effect.
46
+ */
47
+ declare function useSignalEffect(fn: () => void | (() => void), deps?: DependencyList): void;
48
+
49
+ export { useComputed, useSignal, useSignalEffect, useSignalValue };
package/dist/index.js CHANGED
@@ -18,9 +18,9 @@ function useSignalValue(sig) {
18
18
  );
19
19
  return useSyncExternalStore(
20
20
  subscribe,
21
- () => sig.value,
21
+ () => sig.peek(),
22
22
  // getSnapshot
23
- () => sig.value
23
+ () => sig.peek()
24
24
  // getServerSnapshot (SSR support)
25
25
  );
26
26
  }
@@ -35,7 +35,34 @@ function useSignal(initialValue) {
35
35
  }
36
36
  return signalRef.current;
37
37
  }
38
+
39
+ // src/useComputed.ts
40
+ import { useEffect, useMemo, useRef as useRef2 } from "react";
41
+ import { computed } from "@hoosk/minisignals";
42
+ function useComputed(fn, deps = []) {
43
+ const fnRef = useRef2(fn);
44
+ fnRef.current = fn;
45
+ const instance = useMemo(
46
+ () => computed(() => fnRef.current()),
47
+ deps
48
+ );
49
+ useEffect(() => () => instance.dispose(), [instance]);
50
+ return instance;
51
+ }
52
+
53
+ // src/useSignalEffect.ts
54
+ import { useEffect as useEffect2, useRef as useRef3 } from "react";
55
+ import { effect as effect2 } from "@hoosk/minisignals";
56
+ function useSignalEffect(fn, deps = []) {
57
+ const fnRef = useRef3(fn);
58
+ fnRef.current = fn;
59
+ useEffect2(() => {
60
+ return effect2(() => fnRef.current());
61
+ }, deps);
62
+ }
38
63
  export {
64
+ useComputed,
39
65
  useSignal,
66
+ useSignalEffect,
40
67
  useSignalValue
41
68
  };
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@hoosk/minisignals-react",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "React adapter for @hoosk/minisignals",
5
5
  "type": "module",
6
+ "sideEffects": false,
6
7
  "main": "dist/index.cjs",
7
8
  "module": "dist/index.js",
8
9
  "types": "dist/index.d.ts",
@@ -18,7 +19,7 @@
18
19
  "react": ">=18.0.0"
19
20
  },
20
21
  "dependencies": {
21
- "@hoosk/minisignals": "^1.1.0"
22
+ "@hoosk/minisignals": "^1.2.0"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@testing-library/react": "^14.2.1",
@@ -35,6 +36,21 @@
35
36
  "prepare": "npm run build",
36
37
  "test": "vitest run"
37
38
  },
39
+ "keywords": [
40
+ "signals",
41
+ "reactive",
42
+ "reactivity",
43
+ "state",
44
+ "react",
45
+ "hooks",
46
+ "signal",
47
+ "useSyncExternalStore"
48
+ ],
38
49
  "author": "Hoosk",
39
- "license": "MIT"
50
+ "license": "MIT",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/Hoosk/minisignals.git",
54
+ "directory": "packages/react"
55
+ }
40
56
  }
@@ -1,8 +1,8 @@
1
1
  import React, { useState } from 'react';
2
2
  import { describe, it, expect, vi } from 'vitest';
3
3
  import { render, screen, act, cleanup } from '@testing-library/react';
4
- import { signal, computed, batch } from '@hoosk/minisignals';
5
- import { useSignalValue, useSignal } from '../src/index.ts';
4
+ import { signal, computed, batch, type Computed } from '@hoosk/minisignals';
5
+ import { useSignalValue, useSignal, useComputed, useSignalEffect } from '../src/index.ts';
6
6
 
7
7
  describe('@hoosk/minisignals-react adapter', () => {
8
8
  describe('useSignalValue()', () => {
@@ -214,4 +214,142 @@ describe('@hoosk/minisignals-react adapter', () => {
214
214
  expect(screen.getByTestId('v').textContent).toBe('1');
215
215
  });
216
216
  });
217
+
218
+ describe('useComputed()', () => {
219
+ it('should derive values and stay in sync with source signals', () => {
220
+ const price = signal(100);
221
+
222
+ const TestComponent = () => {
223
+ const doubled = useComputed(() => price.value * 2);
224
+ const value = useSignalValue(doubled);
225
+ return <div data-testid="computed">{value}</div>;
226
+ };
227
+
228
+ render(<TestComponent />);
229
+ expect(screen.getByTestId('computed').textContent).toBe('200');
230
+
231
+ act(() => { price.value = 150; });
232
+ expect(screen.getByTestId('computed').textContent).toBe('300');
233
+ });
234
+
235
+ it('should dispose the computed instance on unmount', () => {
236
+ const source = signal(1);
237
+ let captured: Computed<number> | null = null;
238
+
239
+ const TestComponent = () => {
240
+ const instance = useComputed(() => source.value * 10);
241
+ captured = instance;
242
+ useSignalValue(instance);
243
+ return <div>{instance.value}</div>;
244
+ };
245
+
246
+ const { unmount } = render(<TestComponent />);
247
+ expect(captured!.value).toBe(10);
248
+
249
+ unmount();
250
+ source.value = 2;
251
+
252
+ // Disposed computeds are frozen at their last value.
253
+ expect(captured!.value).toBe(10);
254
+ });
255
+
256
+ it('should dispose and recreate the computed when deps change', () => {
257
+ const source = signal(1);
258
+ const instances: Computed<number>[] = [];
259
+
260
+ const TestComponent = ({ factor }: { factor: number }) => {
261
+ const instance = useComputed(() => source.value * factor, [factor]);
262
+ instances.push(instance);
263
+ const value = useSignalValue(instance);
264
+ return <div data-testid="v">{value}</div>;
265
+ };
266
+
267
+ const { rerender } = render(<TestComponent factor={2} />);
268
+ expect(screen.getByTestId('v').textContent).toBe('2');
269
+
270
+ rerender(<TestComponent factor={3} />);
271
+ expect(screen.getByTestId('v').textContent).toBe('3');
272
+
273
+ const first = instances[0];
274
+ const last = instances[instances.length - 1];
275
+ expect(first).not.toBe(last);
276
+
277
+ source.value = 2;
278
+
279
+ // The old instance was disposed by the deps change.
280
+ expect(first.value).toBe(2);
281
+ expect(last.value).toBe(6);
282
+ });
283
+ });
284
+
285
+ describe('useSignalEffect()', () => {
286
+ it('should run the effect and react to signal changes', () => {
287
+ const count = signal(0);
288
+ const spy = vi.fn();
289
+
290
+ const TestComponent = () => {
291
+ useSignalEffect(() => {
292
+ spy(count.value);
293
+ });
294
+ return <div />;
295
+ };
296
+
297
+ render(<TestComponent />);
298
+ expect(spy).toHaveBeenCalledTimes(1);
299
+ expect(spy).toHaveBeenLastCalledWith(0);
300
+
301
+ act(() => { count.value = 1; });
302
+ expect(spy).toHaveBeenCalledTimes(2);
303
+ expect(spy).toHaveBeenLastCalledWith(1);
304
+ });
305
+
306
+ it('should run cleanup before a re-run and on unmount', () => {
307
+ const count = signal(0);
308
+ const cleanup = vi.fn();
309
+
310
+ const TestComponent = () => {
311
+ useSignalEffect(() => {
312
+ count.value;
313
+ return cleanup;
314
+ });
315
+ return <div />;
316
+ };
317
+
318
+ const { unmount } = render(<TestComponent />);
319
+ expect(cleanup).toHaveBeenCalledTimes(0);
320
+
321
+ act(() => { count.value = 1; });
322
+ expect(cleanup).toHaveBeenCalledTimes(1);
323
+
324
+ unmount();
325
+ expect(cleanup).toHaveBeenCalledTimes(2);
326
+
327
+ act(() => { count.value = 2; });
328
+ expect(cleanup).toHaveBeenCalledTimes(2);
329
+ });
330
+
331
+ it('should recreate the effect when deps change', () => {
332
+ const count = signal(0);
333
+ const spy = vi.fn();
334
+
335
+ const TestComponent = ({ id }: { id: number }) => {
336
+ useSignalEffect(() => {
337
+ spy(id, count.value);
338
+ }, [id]);
339
+ return <div />;
340
+ };
341
+
342
+ const { rerender } = render(<TestComponent id={1} />);
343
+ expect(spy).toHaveBeenCalledTimes(1);
344
+
345
+ rerender(<TestComponent id={2} />);
346
+ // Cleanup of the old effect + initial run of the new one.
347
+ expect(spy).toHaveBeenCalledTimes(2);
348
+ expect(spy).toHaveBeenLastCalledWith(2, 0);
349
+
350
+ act(() => { count.value = 5; });
351
+ expect(spy).toHaveBeenCalledTimes(3);
352
+ expect(spy).toHaveBeenLastCalledWith(2, 5);
353
+ });
354
+ });
217
355
  });