@j13b/react-state 0.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.
Files changed (91) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +16 -0
  3. package/README.md +60 -0
  4. package/dist/cjs/hooks/use_runner_error.d.ts +25 -0
  5. package/dist/cjs/hooks/use_runner_error.js +30 -0
  6. package/dist/cjs/hooks/use_runner_error_effect.d.ts +28 -0
  7. package/dist/cjs/hooks/use_runner_error_effect.js +35 -0
  8. package/dist/cjs/hooks/use_runner_feedback.d.ts +26 -0
  9. package/dist/cjs/hooks/use_runner_feedback.js +31 -0
  10. package/dist/cjs/hooks/use_runner_feedback_effect.d.ts +30 -0
  11. package/dist/cjs/hooks/use_runner_feedback_effect.js +37 -0
  12. package/dist/cjs/hooks/use_runner_progress.d.ts +21 -0
  13. package/dist/cjs/hooks/use_runner_progress.js +7 -0
  14. package/dist/cjs/hooks/use_runner_progress_effect.d.ts +31 -0
  15. package/dist/cjs/hooks/use_runner_progress_effect.js +38 -0
  16. package/dist/cjs/hooks/use_runner_status.d.ts +23 -0
  17. package/dist/cjs/hooks/use_runner_status.js +28 -0
  18. package/dist/cjs/hooks/use_runner_status_effect.d.ts +24 -0
  19. package/dist/cjs/hooks/use_runner_status_effect.js +31 -0
  20. package/dist/cjs/hooks/use_signal_value.d.ts +38 -0
  21. package/dist/cjs/hooks/use_signal_value.js +56 -0
  22. package/dist/cjs/hooks/use_signal_value_effect.d.ts +34 -0
  23. package/dist/cjs/hooks/use_signal_value_effect.js +60 -0
  24. package/dist/cjs/hooks/use_update.d.ts +14 -0
  25. package/dist/cjs/hooks/use_update.js +23 -0
  26. package/dist/cjs/index.d.ts +21 -0
  27. package/dist/cjs/index.js +37 -0
  28. package/dist/cjs/package.json +3 -0
  29. package/dist/hooks/use_runner_error.d.ts +26 -0
  30. package/dist/hooks/use_runner_error.d.ts.map +1 -0
  31. package/dist/hooks/use_runner_error.js +32 -0
  32. package/dist/hooks/use_runner_error.js.map +1 -0
  33. package/dist/hooks/use_runner_error_effect.d.ts +29 -0
  34. package/dist/hooks/use_runner_error_effect.d.ts.map +1 -0
  35. package/dist/hooks/use_runner_error_effect.js +37 -0
  36. package/dist/hooks/use_runner_error_effect.js.map +1 -0
  37. package/dist/hooks/use_runner_feedback.d.ts +21 -0
  38. package/dist/hooks/use_runner_feedback.d.ts.map +1 -0
  39. package/dist/hooks/use_runner_feedback.js +27 -0
  40. package/dist/hooks/use_runner_feedback.js.map +1 -0
  41. package/dist/hooks/use_runner_feedback_effect.d.ts +25 -0
  42. package/dist/hooks/use_runner_feedback_effect.d.ts.map +1 -0
  43. package/dist/hooks/use_runner_feedback_effect.js +33 -0
  44. package/dist/hooks/use_runner_feedback_effect.js.map +1 -0
  45. package/dist/hooks/use_runner_progress.d.ts +3 -0
  46. package/dist/hooks/use_runner_progress.d.ts.map +1 -0
  47. package/dist/hooks/use_runner_progress.js +9 -0
  48. package/dist/hooks/use_runner_progress.js.map +1 -0
  49. package/dist/hooks/use_runner_progress_effect.d.ts +26 -0
  50. package/dist/hooks/use_runner_progress_effect.d.ts.map +1 -0
  51. package/dist/hooks/use_runner_progress_effect.js +34 -0
  52. package/dist/hooks/use_runner_progress_effect.js.map +1 -0
  53. package/dist/hooks/use_runner_status.d.ts +24 -0
  54. package/dist/hooks/use_runner_status.d.ts.map +1 -0
  55. package/dist/hooks/use_runner_status.js +30 -0
  56. package/dist/hooks/use_runner_status.js.map +1 -0
  57. package/dist/hooks/use_runner_status_effect.d.ts +25 -0
  58. package/dist/hooks/use_runner_status_effect.d.ts.map +1 -0
  59. package/dist/hooks/use_runner_status_effect.js +33 -0
  60. package/dist/hooks/use_runner_status_effect.js.map +1 -0
  61. package/dist/hooks/use_signal_value.d.ts +39 -0
  62. package/dist/hooks/use_signal_value.d.ts.map +1 -0
  63. package/dist/hooks/use_signal_value.js +51 -0
  64. package/dist/hooks/use_signal_value.js.map +1 -0
  65. package/dist/hooks/use_signal_value_effect.d.ts +35 -0
  66. package/dist/hooks/use_signal_value_effect.d.ts.map +1 -0
  67. package/dist/hooks/use_signal_value_effect.js +54 -0
  68. package/dist/hooks/use_signal_value_effect.js.map +1 -0
  69. package/dist/hooks/use_update.d.ts +15 -0
  70. package/dist/hooks/use_update.d.ts.map +1 -0
  71. package/dist/hooks/use_update.js +24 -0
  72. package/dist/hooks/use_update.js.map +1 -0
  73. package/dist/index.d.ts +22 -0
  74. package/dist/index.d.ts.map +1 -0
  75. package/dist/index.js +12 -0
  76. package/package.json +79 -0
  77. package/src/__tests__/exports.test.ts +32 -0
  78. package/src/__tests__/hooks.test.tsx +242 -0
  79. package/src/hooks/use_runner_error.ts +29 -0
  80. package/src/hooks/use_runner_error_effect.ts +37 -0
  81. package/src/hooks/use_runner_feedback.ts +31 -0
  82. package/src/hooks/use_runner_feedback_effect.ts +40 -0
  83. package/src/hooks/use_runner_progress.ts +25 -0
  84. package/src/hooks/use_runner_progress_effect.ts +41 -0
  85. package/src/hooks/use_runner_status.ts +27 -0
  86. package/src/hooks/use_runner_status_effect.ts +33 -0
  87. package/src/hooks/use_signal_value.ts +61 -0
  88. package/src/hooks/use_signal_value_effect.ts +68 -0
  89. package/src/hooks/use_update.ts +21 -0
  90. package/src/index.ts +21 -0
  91. package/tsconfig.json +23 -0
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ useSignalValue,
4
+ useSignalValueEffect,
5
+ useUpdate,
6
+ useRunnerStatus,
7
+ useRunnerStatusEffect,
8
+ useRunnerError,
9
+ useRunnerErrorEffect,
10
+ useRunnerProgress,
11
+ useRunnerProgressEffect,
12
+ useRunnerFeedback,
13
+ useRunnerFeedbackEffect,
14
+ } from '../index.js';
15
+
16
+ describe('@j13b/react-state exports', () => {
17
+ it.each([
18
+ ['useSignalValue', useSignalValue],
19
+ ['useSignalValueEffect', useSignalValueEffect],
20
+ ['useUpdate', useUpdate],
21
+ ['useRunnerStatus', useRunnerStatus],
22
+ ['useRunnerStatusEffect', useRunnerStatusEffect],
23
+ ['useRunnerError', useRunnerError],
24
+ ['useRunnerErrorEffect', useRunnerErrorEffect],
25
+ ['useRunnerProgress', useRunnerProgress],
26
+ ['useRunnerProgressEffect', useRunnerProgressEffect],
27
+ ['useRunnerFeedback', useRunnerFeedback],
28
+ ['useRunnerFeedbackEffect', useRunnerFeedbackEffect],
29
+ ])('exports %s as a function', (_name, hook) => {
30
+ expect(typeof hook).toBe('function');
31
+ });
32
+ });
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { act, render, screen } from '@testing-library/react';
3
+ import { IBroadcast, Runner, Signal, Status } from '@j13b/state';
4
+ import {
5
+ useRunnerStatus,
6
+ useSignalValue,
7
+ useSignalValueEffect,
8
+ } from '../index.js';
9
+
10
+ describe('useSignalValue', () => {
11
+ function makeProbe() {
12
+ const renders = { count: 0 };
13
+
14
+ function Probe({ broadcast }: { broadcast: IBroadcast<string> }) {
15
+ renders.count++;
16
+ const value = useSignalValue(broadcast);
17
+ return <div data-testid="value">{value}</div>;
18
+ }
19
+
20
+ return { Probe, renders };
21
+ }
22
+
23
+ it('re-renders with the new value on set and unsubscribes on unmount', () => {
24
+ const signal = new Signal('a');
25
+ const { Probe, renders } = makeProbe();
26
+
27
+ const { unmount } = render(<Probe broadcast={signal.broadcast} />);
28
+ expect(screen.getByTestId('value').textContent).toBe('a');
29
+
30
+ act(() => {
31
+ signal.set('b');
32
+ });
33
+ expect(screen.getByTestId('value').textContent).toBe('b');
34
+
35
+ unmount();
36
+ expect(signal.subscriptions.length).toBe(0);
37
+
38
+ const rendersAfterUnmount = renders.count;
39
+ expect(() => {
40
+ act(() => {
41
+ signal.set('c');
42
+ });
43
+ }).not.toThrow();
44
+ expect(renders.count).toBe(rendersAfterUnmount);
45
+ });
46
+
47
+ it('re-renders even when the same value is set again (version snapshot contract)', () => {
48
+ const signal = new Signal('same');
49
+ const { Probe, renders } = makeProbe();
50
+
51
+ render(<Probe broadcast={signal.broadcast} />);
52
+ const rendersBefore = renders.count;
53
+
54
+ act(() => {
55
+ signal.set('same');
56
+ });
57
+
58
+ expect(renders.count).toBeGreaterThan(rendersBefore);
59
+ expect(screen.getByTestId('value').textContent).toBe('same');
60
+ });
61
+
62
+ it('coalesces a synchronous burst of emissions into a single render of the latest value', () => {
63
+ const signal = new Signal(0);
64
+ const renders = { count: 0 };
65
+
66
+ function NumberProbe() {
67
+ renders.count++;
68
+ const value = useSignalValue(signal.broadcast);
69
+ return <div data-testid="number">{value}</div>;
70
+ }
71
+
72
+ render(<NumberProbe />);
73
+ const rendersBefore = renders.count;
74
+
75
+ act(() => {
76
+ signal.set(1);
77
+ signal.set(2);
78
+ signal.set(3);
79
+ });
80
+
81
+ expect(screen.getByTestId('number').textContent).toBe('3');
82
+ expect(renders.count).toBe(rendersBefore + 1);
83
+ });
84
+
85
+ it('subscribes to a swapped broadcast and ignores the old one', () => {
86
+ const first = new Signal('first');
87
+ const second = new Signal('second');
88
+ const { Probe, renders } = makeProbe();
89
+
90
+ const { rerender } = render(<Probe broadcast={first.broadcast} />);
91
+ expect(screen.getByTestId('value').textContent).toBe('first');
92
+
93
+ rerender(<Probe broadcast={second.broadcast} />);
94
+ expect(screen.getByTestId('value').textContent).toBe('second');
95
+ expect(first.subscriptions.length).toBe(0);
96
+
97
+ const rendersAfterSwap = renders.count;
98
+ act(() => {
99
+ first.set('stale');
100
+ });
101
+ expect(renders.count).toBe(rendersAfterSwap);
102
+ expect(screen.getByTestId('value').textContent).toBe('second');
103
+
104
+ act(() => {
105
+ second.set('updated');
106
+ });
107
+ expect(screen.getByTestId('value').textContent).toBe('updated');
108
+ });
109
+
110
+ it('keeps multiple components reading one signal consistent', () => {
111
+ const signal = new Signal(0);
112
+
113
+ function Reader({ testId }: { testId: string }) {
114
+ const value = useSignalValue(signal.broadcast);
115
+ return <div data-testid={testId}>{value}</div>;
116
+ }
117
+
118
+ render(
119
+ <div>
120
+ <Reader testId="one" />
121
+ <Reader testId="two" />
122
+ </div>
123
+ );
124
+
125
+ act(() => {
126
+ signal.set(42);
127
+ });
128
+
129
+ expect(screen.getByTestId('one').textContent).toBe('42');
130
+ expect(screen.getByTestId('two').textContent).toBe('42');
131
+ });
132
+ });
133
+
134
+ describe('useSignalValueEffect', () => {
135
+ function Probe({
136
+ broadcast,
137
+ onValue,
138
+ }: {
139
+ broadcast: IBroadcast<string>;
140
+ onValue: (value: string) => void;
141
+ }) {
142
+ useSignalValueEffect(onValue, broadcast);
143
+ return null;
144
+ }
145
+
146
+ it('fires once on mount with the current value and once per set', () => {
147
+ const signal = new Signal('initial');
148
+ const onValue = vi.fn();
149
+
150
+ render(<Probe broadcast={signal.broadcast} onValue={onValue} />);
151
+ expect(onValue.mock.calls).toEqual([['initial']]);
152
+
153
+ act(() => {
154
+ signal.set('next');
155
+ });
156
+ expect(onValue.mock.calls).toEqual([['initial'], ['next']]);
157
+
158
+ act(() => {
159
+ signal.set('next');
160
+ });
161
+ expect(onValue.mock.calls).toEqual([['initial'], ['next'], ['next']]);
162
+ });
163
+
164
+ it('fires exactly once per emission across a broadcast swap (no double-fire)', () => {
165
+ const first = new Signal('first');
166
+ const second = new Signal('second');
167
+ const onValue = vi.fn();
168
+
169
+ const { rerender } = render(
170
+ <Probe broadcast={first.broadcast} onValue={onValue} />
171
+ );
172
+ expect(onValue.mock.calls).toEqual([['first']]);
173
+
174
+ rerender(<Probe broadcast={second.broadcast} onValue={onValue} />);
175
+ expect(onValue.mock.calls).toEqual([['first'], ['second']]);
176
+
177
+ act(() => {
178
+ second.set('second-updated');
179
+ });
180
+ expect(onValue.mock.calls).toEqual([
181
+ ['first'],
182
+ ['second'],
183
+ ['second-updated'],
184
+ ]);
185
+
186
+ act(() => {
187
+ first.set('stale');
188
+ });
189
+ expect(onValue).toHaveBeenCalledTimes(3);
190
+ });
191
+
192
+ it('uses the latest callback prop without re-firing on callback change', () => {
193
+ const signal = new Signal('value');
194
+ const firstCallback = vi.fn();
195
+ const secondCallback = vi.fn();
196
+
197
+ const { rerender } = render(
198
+ <Probe broadcast={signal.broadcast} onValue={firstCallback} />
199
+ );
200
+ expect(firstCallback).toHaveBeenCalledTimes(1);
201
+
202
+ rerender(<Probe broadcast={signal.broadcast} onValue={secondCallback} />);
203
+ expect(secondCallback).not.toHaveBeenCalled();
204
+
205
+ act(() => {
206
+ signal.set('changed');
207
+ });
208
+ expect(firstCallback).toHaveBeenCalledTimes(1);
209
+ expect(secondCallback.mock.calls).toEqual([['changed']]);
210
+ });
211
+ });
212
+
213
+ describe('useRunnerStatus', () => {
214
+ it('transitions INITIAL -> PENDING -> SUCCESS through an execute()', async () => {
215
+ const runner = new Runner<string>('initial');
216
+
217
+ function Probe() {
218
+ const status = useRunnerStatus(runner.broadcast);
219
+ return <div data-testid="status">{status}</div>;
220
+ }
221
+
222
+ render(<Probe />);
223
+ expect(screen.getByTestId('status').textContent).toBe(Status.INITIAL);
224
+
225
+ let resolveAction!: (value: string) => void;
226
+ const actionResult = new Promise<string>(resolve => {
227
+ resolveAction = resolve;
228
+ });
229
+
230
+ let execution!: Promise<string>;
231
+ act(() => {
232
+ execution = runner.execute(() => actionResult);
233
+ });
234
+ expect(screen.getByTestId('status').textContent).toBe(Status.PENDING);
235
+
236
+ await act(async () => {
237
+ resolveAction('done');
238
+ await execution;
239
+ });
240
+ expect(screen.getByTestId('status').textContent).toBe(Status.SUCCESS);
241
+ });
242
+ });
@@ -0,0 +1,29 @@
1
+ import { IRunnerBroadcast } from '@j13b/state';
2
+ import { useSignalValue } from './use_signal_value.js';
3
+
4
+ /**
5
+ * A React hook that subscribes to the error state of a runner.
6
+ *
7
+ * @template T - The type of value that the runner manages
8
+ * @param {IRunnerBroadcast<T>} task - The runner broadcast interface to subscribe to
9
+ * @returns {Error | null} The current error state of the runner, or null if there is no error
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * const error = useRunnerError(saveRunner.broadcast);
14
+ * if (error) {
15
+ * return <ErrorMessage error={error} />;
16
+ * }
17
+ * ```
18
+ *
19
+ * @remarks
20
+ * This hook is useful for:
21
+ * - Displaying error messages in the UI
22
+ * - Conditionally rendering error states
23
+ * - Triggering error handling logic
24
+ *
25
+ * The hook will automatically unsubscribe when the component unmounts.
26
+ */
27
+ export function useRunnerError(task: IRunnerBroadcast<any>) {
28
+ return useSignalValue(task.stateBroadcast).error;
29
+ }
@@ -0,0 +1,37 @@
1
+ import { IRunnerBroadcast } from '@j13b/state';
2
+ import { useSignalValueEffect } from './use_signal_value_effect.js';
3
+
4
+ /**
5
+ * A React hook that executes a callback whenever the error state of a Runner changes.
6
+ * This hook is useful for handling side effects that need to respond to error states
7
+ * in async operations managed by a Runner.
8
+ *
9
+ * @template T - The type of value managed by the Runner
10
+ * @param callback - A function that will be called with the current error state
11
+ * @param task - The Runner instance to monitor for error changes
12
+ * @returns void
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * useRunnerErrorEffect((error) => {
17
+ * if (error) {
18
+ * // Handle the error, e.g., show a notification
19
+ * showErrorNotification(error.message);
20
+ * }
21
+ * }, myRunner);
22
+ * ```
23
+ *
24
+ * @remarks
25
+ * - The callback will be called immediately with the current error state
26
+ * - The callback will be called whenever the error state changes
27
+ * - The hook automatically handles cleanup on component unmount
28
+ * - This hook is built on top of useSignalValueEffect for efficient state tracking
29
+ */
30
+ export function useRunnerErrorEffect(
31
+ callback: (error: Error | null) => void,
32
+ task: IRunnerBroadcast<any>
33
+ ) {
34
+ return useSignalValueEffect(state => {
35
+ callback(state.error);
36
+ }, task.stateBroadcast);
37
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @module hooks/use_runner_feedback
3
+ *
4
+ * This module provides a hook for accessing the feedback message from a Runner's state.
5
+ * It is useful for displaying status messages to users during async operations.
6
+ */
7
+
8
+ import { IRunnerBroadcast } from '@j13b/state';
9
+ import { useSignalValue } from './use_signal_value.js';
10
+
11
+ /**
12
+ * Hook that provides access to the feedback message from a Runner's state.
13
+ *
14
+ * @template T - The type of the Runner's value
15
+ * @param {IRunnerBroadcast<T>} task - The Runner broadcast interface to subscribe to
16
+ * @returns {string | undefined} The current feedback message, or undefined if no feedback is set
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * const feedback = useRunnerFeedback(saveRunner.broadcast);
21
+ * return <div>{feedback}</div>;
22
+ * ```
23
+ *
24
+ * @remarks
25
+ * This hook is useful for displaying status messages to users during async operations.
26
+ * The feedback message is typically set using the Runner's setFeedback method.
27
+ * The component will automatically re-render when the feedback message changes.
28
+ */
29
+ export function useRunnerFeedback(task: IRunnerBroadcast<any>) {
30
+ return useSignalValue(task.stateBroadcast).feedback;
31
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @module hooks/use_runner_feedback_effect
3
+ *
4
+ * This module provides a React hook for handling feedback messages from a Runner.
5
+ * It allows components to react to changes in the feedback state of async operations.
6
+ */
7
+
8
+ import { IRunnerBroadcast } from '@j13b/state';
9
+ import { useSignalValueEffect } from './use_signal_value_effect.js';
10
+
11
+ /**
12
+ * A React hook that executes a callback whenever the feedback message of a Runner changes.
13
+ *
14
+ * This hook is useful for displaying operation feedback to users, such as loading messages,
15
+ * progress updates, or error notifications. It automatically handles cleanup when the
16
+ * component unmounts.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * useRunnerFeedbackEffect(
21
+ * (feedback) => {
22
+ * // Update UI with feedback message
23
+ * setMessage(feedback);
24
+ * },
25
+ * myRunner.broadcast
26
+ * );
27
+ * ```
28
+ *
29
+ * @param callback - A function that will be called with the new feedback message whenever it changes
30
+ * @param task - The Runner's broadcast interface to subscribe to
31
+ * @returns void
32
+ */
33
+ export function useRunnerFeedbackEffect(
34
+ callback: (feedback: string) => void,
35
+ task: IRunnerBroadcast<any>
36
+ ) {
37
+ return useSignalValueEffect(state => {
38
+ callback(state.feedback);
39
+ }, task.stateBroadcast);
40
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A React hook that subscribes to the progress state of a Runner.
3
+ *
4
+ * @template T - The type of the Runner's value
5
+ * @param {IRunnerBroadcast<T>} task - The Runner broadcast interface to subscribe to
6
+ * @returns {number} A number between 0 and 1 representing the current progress of the task
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * const progress = useRunnerProgress(saveRunner.broadcast);
11
+ * // progress will be a number between 0 and 1
12
+ * // 0 = not started, 1 = complete
13
+ * ```
14
+ *
15
+ * @remarks
16
+ * This hook is useful for tracking the progress of long-running operations
17
+ * such as file uploads, data processing, or any task that can report progress.
18
+ * The component will automatically re-render when the progress value changes.
19
+ */
20
+ import { IRunnerBroadcast } from '@j13b/state';
21
+ import { useSignalValue } from './use_signal_value.js';
22
+
23
+ export function useRunnerProgress(task: IRunnerBroadcast<any>) {
24
+ return useSignalValue(task.stateBroadcast).progress;
25
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @module hooks/use_runner_progress_effect
3
+ *
4
+ * This module provides a React hook for tracking the progress of a Runner operation.
5
+ * It allows components to react to progress changes in long-running operations.
6
+ */
7
+
8
+ import { IRunnerBroadcast } from '@j13b/state';
9
+ import { useSignalValueEffect } from './use_signal_value_effect.js';
10
+
11
+ /**
12
+ * Hook that executes a callback whenever the progress of a Runner operation changes.
13
+ *
14
+ * @template T - The type of the Runner's value
15
+ * @param callback - Function to be called when progress changes. Receives the current progress value (0-1)
16
+ * @param task - The Runner broadcast interface to track progress from
17
+ * @returns void
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * useRunnerProgressEffect(
22
+ * (progress) => {
23
+ * console.log(`Operation is ${progress * 100}% complete`);
24
+ * },
25
+ * myRunner.broadcast
26
+ * );
27
+ * ```
28
+ *
29
+ * @remarks
30
+ * - The callback will be called with a number between 0 and 1 representing progress
31
+ * - The effect is automatically cleaned up when the component unmounts
32
+ * - This hook is built on top of useSignalValueEffect for efficient state tracking
33
+ */
34
+ export function useRunnerProgressEffect(
35
+ callback: (progress: number) => void,
36
+ task: IRunnerBroadcast<any>
37
+ ) {
38
+ return useSignalValueEffect(state => {
39
+ callback(state.progress);
40
+ }, task.stateBroadcast);
41
+ }
@@ -0,0 +1,27 @@
1
+ import { IRunnerBroadcast } from '@j13b/state';
2
+ import { useSignalValue } from './use_signal_value.js';
3
+
4
+ /**
5
+ * A hook that subscribes to a runner's status changes and returns the current status.
6
+ * The component will re-render whenever the runner's status changes.
7
+ *
8
+ * @template T - The type of the runner's value
9
+ * @param broadcast - The broadcast interface of the runner to subscribe to
10
+ * @returns The current status of the runner (INITIAL, PENDING, SUCCESS, ERROR)
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * const status = useRunnerStatus(runner.broadcast);
15
+ *
16
+ * return (
17
+ * <div>
18
+ * {status === 'PENDING' && <Spinner />}
19
+ * {status === 'ERROR' && <ErrorMessage />}
20
+ * {status === 'SUCCESS' && <SuccessMessage />}
21
+ * </div>
22
+ * );
23
+ * ```
24
+ */
25
+ export function useRunnerStatus(broadcast: IRunnerBroadcast<any>) {
26
+ return useSignalValue(broadcast.stateBroadcast).status;
27
+ }
@@ -0,0 +1,33 @@
1
+ import { IRunnerBroadcast, Status } from '@j13b/state';
2
+ import { useSignalValueEffect } from './use_signal_value_effect.js';
3
+
4
+ /**
5
+ * A hook that runs an effect whenever a runner's status changes.
6
+ * This is useful for side effects that need to respond to runner status changes
7
+ * without causing a re-render.
8
+ *
9
+ * @template T - The type of the runner's value
10
+ * @param callback - The effect to run when the runner's status changes
11
+ * @param task - The broadcast interface of the runner to subscribe to
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * useRunnerStatusEffect(
16
+ * status => {
17
+ * if (status === 'ERROR') {
18
+ * // Handle error state
19
+ * showErrorToast();
20
+ * }
21
+ * },
22
+ * runner.broadcast
23
+ * );
24
+ * ```
25
+ */
26
+ export function useRunnerStatusEffect(
27
+ callback: (value: Status) => void,
28
+ task: IRunnerBroadcast<any>
29
+ ) {
30
+ return useSignalValueEffect(state => {
31
+ callback(state.status);
32
+ }, task.stateBroadcast);
33
+ }
@@ -0,0 +1,61 @@
1
+ import { useCallback, useSyncExternalStore } from 'react';
2
+ import { IBroadcast } from '@j13b/state';
3
+
4
+ /**
5
+ * A hook that subscribes to a signal's value changes and returns the current value.
6
+ * The component re-renders when the signal broadcasts. Notifications are batched
7
+ * by React: a burst of synchronous emissions (e.g. several `set()` calls in one
8
+ * event tick) renders once with the latest value — intermediate states are never
9
+ * painted.
10
+ *
11
+ * Built on React 18's `useSyncExternalStore`, which makes the hook:
12
+ * - Tear-free under concurrent rendering (React re-checks the snapshot after render).
13
+ * - SSR-safe: on the server the current value is rendered via the server snapshot
14
+ * without subscribing (no `useLayoutEffect` warnings).
15
+ *
16
+ * The snapshot is the broadcast's monotonically increasing `version` rather than
17
+ * the value itself. This guarantees that no emission is silently ignored:
18
+ * `Signal.transform()` mutates the value in place and returns the SAME reference,
19
+ * so value/reference equality (`Object.is`) cannot detect the change — the version
20
+ * counter is what makes in-place transforms render. A consequence is that a
21
+ * `set()` of an identical value also triggers one render, since without a deep
22
+ * comparison it is indistinguishable from an in-place mutation.
23
+ *
24
+ * The subscription object returned by `broadcast.subscribe()` is strongly held by
25
+ * the unsubscribe closure for the lifetime of the subscription (the signal itself
26
+ * only holds it via WeakRef), and is unsubscribed when the component unmounts or
27
+ * the broadcast changes.
28
+ *
29
+ * @template TValue - The type of the signal's value
30
+ * @param broadcast - The broadcast interface of the signal to subscribe to
31
+ * @returns The current value of the signal
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * const value = useSignalValue(signal.broadcast);
36
+ *
37
+ * return <div>Current value: {value}</div>;
38
+ * ```
39
+ */
40
+ export function useSignalValue<TValue>(broadcast: IBroadcast<TValue>) {
41
+ const subscribe = useCallback(
42
+ (onStoreChange: () => void) => {
43
+ // `subscription` must be strongly referenced: the signal only keeps a
44
+ // WeakRef to it, so this closure is what keeps the callback alive.
45
+ const subscription = broadcast.subscribe(onStoreChange);
46
+ return () => subscription.unsubscribe();
47
+ },
48
+ [broadcast]
49
+ );
50
+
51
+ // Version snapshot (deliberate): transform() mutates in place and returns the
52
+ // same reference, so Object.is on the value would miss changes. The version
53
+ // increments on every set(), so no emission is ignored (React coalesces a
54
+ // synchronous burst into a single render of the latest value). Do not
55
+ // snapshot the value.
56
+ const getSnapshot = useCallback(() => broadcast.version, [broadcast]);
57
+
58
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
59
+
60
+ return broadcast.get();
61
+ }
@@ -0,0 +1,68 @@
1
+ import { useEffect, useLayoutEffect, useRef } from 'react';
2
+ import type { IBroadcast } from '@j13b/state';
3
+
4
+ // useLayoutEffect warns during server-side rendering; fall back to useEffect
5
+ // there. Effects never run on the server either way, so behavior is identical.
6
+ const useIsomorphicLayoutEffect =
7
+ typeof window !== 'undefined' ? useLayoutEffect : useEffect;
8
+
9
+ /**
10
+ * A hook that runs an effect whenever a signal's value changes.
11
+ * This is useful for side effects that need to respond to signal value changes
12
+ * without causing a re-render.
13
+ *
14
+ * Contract:
15
+ * - The callback fires exactly once with the current value on mount, and again
16
+ * once whenever the `broadcast` identity changes (with the new broadcast's
17
+ * current value).
18
+ * - After that, it fires exactly once per emission — including emissions of an
19
+ * identical value (every `set()` notifies subscribers). No double-fires.
20
+ * - The latest `callback` prop is always used; changing the callback does not
21
+ * re-subscribe or re-fire.
22
+ * - The subscription object is strongly held for the component's lifetime (the
23
+ * signal itself only holds it via WeakRef) and is cleaned up on unmount or
24
+ * when the broadcast changes.
25
+ *
26
+ * @template T - The type of the signal's value
27
+ * @param callback - The effect to run when the signal's value changes
28
+ * @param broadcast - The broadcast interface of the signal to subscribe to
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * useSignalValueEffect(
33
+ * value => {
34
+ * // Do something with the new value
35
+ * console.log('Value changed:', value);
36
+ * },
37
+ * signal.broadcast
38
+ * );
39
+ * ```
40
+ */
41
+ export function useSignalValueEffect<T>(
42
+ callback: (value: T) => void,
43
+ broadcast: IBroadcast<T>
44
+ ) {
45
+ const callbackRef = useRef(callback);
46
+
47
+ // Declared before the subscription effect so the ref is up to date when the
48
+ // subscription effect (re-)runs in the same commit.
49
+ useIsomorphicLayoutEffect(() => {
50
+ callbackRef.current = callback;
51
+ }, [callback]);
52
+
53
+ useIsomorphicLayoutEffect(() => {
54
+ // `subscription` is strongly referenced by this effect's closure: the
55
+ // signal only keeps a WeakRef to it, so this is what keeps it alive.
56
+ const subscription = broadcast.subscribe(value => {
57
+ callbackRef.current(value);
58
+ });
59
+
60
+ // Fire exactly once with the current value on mount / broadcast change.
61
+ // Subsequent emissions arrive through the subscription above.
62
+ callbackRef.current(broadcast.get());
63
+
64
+ return () => {
65
+ subscription.unsubscribe();
66
+ };
67
+ }, [broadcast]);
68
+ }