@hoosk/minisignals-react 1.0.9 → 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 +34 -1
- package/dist/index.cjs +31 -2
- package/dist/index.d.cts +28 -2
- package/dist/index.d.ts +28 -2
- package/dist/index.js +29 -2
- package/package.json +19 -4
- package/tests/adapter.test.tsx +140 -2
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.
|
|
50
|
+
() => sig.peek(),
|
|
49
51
|
// getSnapshot
|
|
50
|
-
() => sig.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
21
|
+
() => sig.peek(),
|
|
22
22
|
// getSnapshot
|
|
23
|
-
() => sig.
|
|
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
|
|
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.
|
|
22
|
+
"@hoosk/minisignals": "^1.2.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@testing-library/react": "^14.2.1",
|
|
@@ -35,7 +36,21 @@
|
|
|
35
36
|
"prepare": "npm run build",
|
|
36
37
|
"test": "vitest run"
|
|
37
38
|
},
|
|
38
|
-
"keywords": [
|
|
39
|
+
"keywords": [
|
|
40
|
+
"signals",
|
|
41
|
+
"reactive",
|
|
42
|
+
"reactivity",
|
|
43
|
+
"state",
|
|
44
|
+
"react",
|
|
45
|
+
"hooks",
|
|
46
|
+
"signal",
|
|
47
|
+
"useSyncExternalStore"
|
|
48
|
+
],
|
|
39
49
|
"author": "Hoosk",
|
|
40
|
-
"license": "MIT"
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/Hoosk/minisignals.git",
|
|
54
|
+
"directory": "packages/react"
|
|
55
|
+
}
|
|
41
56
|
}
|
package/tests/adapter.test.tsx
CHANGED
|
@@ -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
|
});
|