@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.
- package/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +60 -0
- package/dist/cjs/hooks/use_runner_error.d.ts +25 -0
- package/dist/cjs/hooks/use_runner_error.js +30 -0
- package/dist/cjs/hooks/use_runner_error_effect.d.ts +28 -0
- package/dist/cjs/hooks/use_runner_error_effect.js +35 -0
- package/dist/cjs/hooks/use_runner_feedback.d.ts +26 -0
- package/dist/cjs/hooks/use_runner_feedback.js +31 -0
- package/dist/cjs/hooks/use_runner_feedback_effect.d.ts +30 -0
- package/dist/cjs/hooks/use_runner_feedback_effect.js +37 -0
- package/dist/cjs/hooks/use_runner_progress.d.ts +21 -0
- package/dist/cjs/hooks/use_runner_progress.js +7 -0
- package/dist/cjs/hooks/use_runner_progress_effect.d.ts +31 -0
- package/dist/cjs/hooks/use_runner_progress_effect.js +38 -0
- package/dist/cjs/hooks/use_runner_status.d.ts +23 -0
- package/dist/cjs/hooks/use_runner_status.js +28 -0
- package/dist/cjs/hooks/use_runner_status_effect.d.ts +24 -0
- package/dist/cjs/hooks/use_runner_status_effect.js +31 -0
- package/dist/cjs/hooks/use_signal_value.d.ts +38 -0
- package/dist/cjs/hooks/use_signal_value.js +56 -0
- package/dist/cjs/hooks/use_signal_value_effect.d.ts +34 -0
- package/dist/cjs/hooks/use_signal_value_effect.js +60 -0
- package/dist/cjs/hooks/use_update.d.ts +14 -0
- package/dist/cjs/hooks/use_update.js +23 -0
- package/dist/cjs/index.d.ts +21 -0
- package/dist/cjs/index.js +37 -0
- package/dist/cjs/package.json +3 -0
- package/dist/hooks/use_runner_error.d.ts +26 -0
- package/dist/hooks/use_runner_error.d.ts.map +1 -0
- package/dist/hooks/use_runner_error.js +32 -0
- package/dist/hooks/use_runner_error.js.map +1 -0
- package/dist/hooks/use_runner_error_effect.d.ts +29 -0
- package/dist/hooks/use_runner_error_effect.d.ts.map +1 -0
- package/dist/hooks/use_runner_error_effect.js +37 -0
- package/dist/hooks/use_runner_error_effect.js.map +1 -0
- package/dist/hooks/use_runner_feedback.d.ts +21 -0
- package/dist/hooks/use_runner_feedback.d.ts.map +1 -0
- package/dist/hooks/use_runner_feedback.js +27 -0
- package/dist/hooks/use_runner_feedback.js.map +1 -0
- package/dist/hooks/use_runner_feedback_effect.d.ts +25 -0
- package/dist/hooks/use_runner_feedback_effect.d.ts.map +1 -0
- package/dist/hooks/use_runner_feedback_effect.js +33 -0
- package/dist/hooks/use_runner_feedback_effect.js.map +1 -0
- package/dist/hooks/use_runner_progress.d.ts +3 -0
- package/dist/hooks/use_runner_progress.d.ts.map +1 -0
- package/dist/hooks/use_runner_progress.js +9 -0
- package/dist/hooks/use_runner_progress.js.map +1 -0
- package/dist/hooks/use_runner_progress_effect.d.ts +26 -0
- package/dist/hooks/use_runner_progress_effect.d.ts.map +1 -0
- package/dist/hooks/use_runner_progress_effect.js +34 -0
- package/dist/hooks/use_runner_progress_effect.js.map +1 -0
- package/dist/hooks/use_runner_status.d.ts +24 -0
- package/dist/hooks/use_runner_status.d.ts.map +1 -0
- package/dist/hooks/use_runner_status.js +30 -0
- package/dist/hooks/use_runner_status.js.map +1 -0
- package/dist/hooks/use_runner_status_effect.d.ts +25 -0
- package/dist/hooks/use_runner_status_effect.d.ts.map +1 -0
- package/dist/hooks/use_runner_status_effect.js +33 -0
- package/dist/hooks/use_runner_status_effect.js.map +1 -0
- package/dist/hooks/use_signal_value.d.ts +39 -0
- package/dist/hooks/use_signal_value.d.ts.map +1 -0
- package/dist/hooks/use_signal_value.js +51 -0
- package/dist/hooks/use_signal_value.js.map +1 -0
- package/dist/hooks/use_signal_value_effect.d.ts +35 -0
- package/dist/hooks/use_signal_value_effect.d.ts.map +1 -0
- package/dist/hooks/use_signal_value_effect.js +54 -0
- package/dist/hooks/use_signal_value_effect.js.map +1 -0
- package/dist/hooks/use_update.d.ts +15 -0
- package/dist/hooks/use_update.d.ts.map +1 -0
- package/dist/hooks/use_update.js +24 -0
- package/dist/hooks/use_update.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/package.json +79 -0
- package/src/__tests__/exports.test.ts +32 -0
- package/src/__tests__/hooks.test.tsx +242 -0
- package/src/hooks/use_runner_error.ts +29 -0
- package/src/hooks/use_runner_error_effect.ts +37 -0
- package/src/hooks/use_runner_feedback.ts +31 -0
- package/src/hooks/use_runner_feedback_effect.ts +40 -0
- package/src/hooks/use_runner_progress.ts +25 -0
- package/src/hooks/use_runner_progress_effect.ts +41 -0
- package/src/hooks/use_runner_status.ts +27 -0
- package/src/hooks/use_runner_status_effect.ts +33 -0
- package/src/hooks/use_signal_value.ts +61 -0
- package/src/hooks/use_signal_value_effect.ts +68 -0
- package/src/hooks/use_update.ts +21 -0
- package/src/index.ts +21 -0
- 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
|
+
}
|