@neovici/cosmoz-form 2.1.1 → 2.2.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/dist/async-rule.d.ts +83 -0
- package/dist/async-rule.d.ts.map +1 -0
- package/dist/async-rule.js +20 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -5
- package/dist/make-debounce-runner.d.ts +4 -0
- package/dist/make-debounce-runner.d.ts.map +1 -0
- package/dist/make-debounce-runner.js +50 -0
- package/dist/make-take-latest-runner.d.ts +4 -0
- package/dist/make-take-latest-runner.d.ts.map +1 -0
- package/dist/make-take-latest-runner.js +27 -0
- package/dist/test/make-debounce-runner.test.d.ts +2 -0
- package/dist/test/make-debounce-runner.test.d.ts.map +1 -0
- package/dist/test/make-debounce-runner.test.js +123 -0
- package/dist/test/make-take-latest-runner.test.d.ts +2 -0
- package/dist/test/make-take-latest-runner.test.d.ts.map +1 -0
- package/dist/test/make-take-latest-runner.test.js +110 -0
- package/dist/test/use-async-form-core.test.d.ts +2 -0
- package/dist/test/use-async-form-core.test.d.ts.map +1 -0
- package/dist/test/use-async-form-core.test.js +238 -0
- package/dist/test/use-async-rules.test.d.ts +2 -0
- package/dist/test/use-async-rules.test.d.ts.map +1 -0
- package/dist/test/use-async-rules.test.js +180 -0
- package/dist/use-async-form-core.d.ts +21 -0
- package/dist/use-async-form-core.d.ts.map +1 -0
- package/dist/use-async-form-core.js +71 -0
- package/dist/use-items/use-async-rules.d.ts +17 -0
- package/dist/use-items/use-async-rules.d.ts.map +1 -0
- package/dist/use-items/use-async-rules.js +78 -0
- package/dist/use-items/use-items.d.ts.map +1 -1
- package/dist/use-items/use-items.js +2 -2
- package/dist/use-validated-form$.d.ts +4 -1
- package/dist/use-validated-form$.d.ts.map +1 -1
- package/dist/use-validated-form$.js +4 -1
- package/package.json +6 -5
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { renderHook } from '@neovici/testing';
|
|
2
|
+
import { assert, waitUntil } from '@open-wc/testing';
|
|
3
|
+
import { spy } from 'sinon';
|
|
4
|
+
import { delay } from '../async-rule';
|
|
5
|
+
import { makeDebounceRunner } from '../make-debounce-runner';
|
|
6
|
+
import { touched } from '../touch';
|
|
7
|
+
import { useAsyncFormCore } from '../use-async-form-core';
|
|
8
|
+
import { useForm } from '../use-form';
|
|
9
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
10
|
+
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
// ── Suite ─────────────────────────────────────────────────────────────────────
|
|
12
|
+
suite('useAsyncFormCore', () => {
|
|
13
|
+
// Basic rule: when name changes, set city = name + '-city'
|
|
14
|
+
const cityFromName = [
|
|
15
|
+
async (current) => ({ city: current.name + '-city' }),
|
|
16
|
+
({ name }) => [name],
|
|
17
|
+
];
|
|
18
|
+
test('rule runs on initialization (prev deps are null → runs)', async () => {
|
|
19
|
+
const { result } = await renderHook(() => {
|
|
20
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
21
|
+
useAsyncFormCore(form, [cityFromName]);
|
|
22
|
+
return form;
|
|
23
|
+
});
|
|
24
|
+
await waitUntil(() => result.current.values.city === 'Alice-city');
|
|
25
|
+
});
|
|
26
|
+
test('rule does NOT run again if deps unchanged (onChange with unrelated field)', async () => {
|
|
27
|
+
const { result } = await renderHook(() => {
|
|
28
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
29
|
+
useAsyncFormCore(form, [cityFromName]);
|
|
30
|
+
return form;
|
|
31
|
+
});
|
|
32
|
+
await waitUntil(() => result.current.values.city === 'Alice-city');
|
|
33
|
+
// change city manually — name (dep) didn't change so rule should NOT re-run
|
|
34
|
+
result.current.onChange({ city: 'manual' });
|
|
35
|
+
await tick(50); // give rule time to NOT re-run
|
|
36
|
+
assert.equal(result.current.values.city, 'manual');
|
|
37
|
+
});
|
|
38
|
+
test('rule runs again when deps change (name changes → city updated)', async () => {
|
|
39
|
+
const { result } = await renderHook(() => {
|
|
40
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
41
|
+
useAsyncFormCore(form, [cityFromName]);
|
|
42
|
+
return form;
|
|
43
|
+
});
|
|
44
|
+
await waitUntil(() => result.current.values.city === 'Alice-city');
|
|
45
|
+
result.current.onChange({ name: 'Bob' }); // dep changed
|
|
46
|
+
await waitUntil(() => result.current.values.city === 'Bob-city');
|
|
47
|
+
});
|
|
48
|
+
test('takeLatest — slow rule is cancelled when deps change again', async () => {
|
|
49
|
+
const slowRule = [
|
|
50
|
+
async (current, { signal }) => {
|
|
51
|
+
await delay(signal, 200);
|
|
52
|
+
return { city: current.name + '-slow' };
|
|
53
|
+
},
|
|
54
|
+
({ name }) => [name],
|
|
55
|
+
];
|
|
56
|
+
const { result } = await renderHook(() => {
|
|
57
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
58
|
+
useAsyncFormCore(form, [slowRule]);
|
|
59
|
+
return form;
|
|
60
|
+
});
|
|
61
|
+
// initial rule for Alice kicked off (slow, 200ms)
|
|
62
|
+
await tick(10); // let Alice rule start
|
|
63
|
+
result.current.onChange({ name: 'Bob' }); // triggers second rule, cancels first
|
|
64
|
+
await waitUntil(() => result.current.values.city === 'Bob-slow', 'Bob-slow should appear', { timeout: 2000 });
|
|
65
|
+
// Alice-slow must never appear
|
|
66
|
+
assert.notEqual(result.current.values.city, 'Alice-slow');
|
|
67
|
+
});
|
|
68
|
+
test('opts.update() fires intermediate patch before rule completes', async () => {
|
|
69
|
+
const loadingRule = [
|
|
70
|
+
async (current, { update, signal }) => {
|
|
71
|
+
update({ city: 'loading...' });
|
|
72
|
+
await delay(signal, 50);
|
|
73
|
+
return { city: current.name + '-done' };
|
|
74
|
+
},
|
|
75
|
+
({ name }) => [name],
|
|
76
|
+
];
|
|
77
|
+
const { result } = await renderHook(() => {
|
|
78
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
79
|
+
useAsyncFormCore(form, [loadingRule]);
|
|
80
|
+
return form;
|
|
81
|
+
});
|
|
82
|
+
// loading patch fires before the delay
|
|
83
|
+
await waitUntil(() => result.current.values.city === 'loading...');
|
|
84
|
+
// then the final patch arrives after the delay
|
|
85
|
+
await waitUntil(() => result.current.values.city === 'Alice-done', 'Alice-done should appear', { timeout: 2000 });
|
|
86
|
+
});
|
|
87
|
+
test('async patches do not set form.touched', async () => {
|
|
88
|
+
const { result } = await renderHook(() => {
|
|
89
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
90
|
+
useAsyncFormCore(form, [cityFromName]);
|
|
91
|
+
return form;
|
|
92
|
+
});
|
|
93
|
+
await waitUntil(() => result.current.values.city === 'Alice-city');
|
|
94
|
+
assert.isFalse(touched(result.current.values));
|
|
95
|
+
});
|
|
96
|
+
test('onError called with (err, rule) when rule throws', async () => {
|
|
97
|
+
const boom = [
|
|
98
|
+
async () => {
|
|
99
|
+
throw new Error('rule boom');
|
|
100
|
+
},
|
|
101
|
+
({ name }) => [name],
|
|
102
|
+
];
|
|
103
|
+
const onError = spy();
|
|
104
|
+
await renderHook(() => {
|
|
105
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
106
|
+
useAsyncFormCore(form, [boom], { onError });
|
|
107
|
+
return form;
|
|
108
|
+
});
|
|
109
|
+
await waitUntil(() => onError.called, 'onError should be called');
|
|
110
|
+
assert.instanceOf(onError.args[0][0], Error);
|
|
111
|
+
assert.equal(onError.args[0][0].message, 'rule boom');
|
|
112
|
+
assert.equal(onError.args[0][1], boom);
|
|
113
|
+
});
|
|
114
|
+
test('uses runner factory from the rule — debounce semantics observed', async () => {
|
|
115
|
+
const debounceRule = [
|
|
116
|
+
async (current) => ({ city: current.name + '-debounced' }),
|
|
117
|
+
({ name }) => [name],
|
|
118
|
+
() => makeDebounceRunner(100),
|
|
119
|
+
];
|
|
120
|
+
const { result } = await renderHook(() => {
|
|
121
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
122
|
+
useAsyncFormCore(form, [debounceRule]);
|
|
123
|
+
return form;
|
|
124
|
+
});
|
|
125
|
+
// Two rapid dep changes within the 100ms debounce window
|
|
126
|
+
result.current.onChange({ name: 'Bob' });
|
|
127
|
+
await tick(30); // within debounce window
|
|
128
|
+
result.current.onChange({ name: 'Carol' });
|
|
129
|
+
// Wait long enough for the debounce to fire and rule to complete
|
|
130
|
+
await waitUntil(() => result.current.values.city !== '', 'city should be set', { timeout: 2000 });
|
|
131
|
+
// Only Carol's rule should have applied — Bob's was debounced away
|
|
132
|
+
assert.equal(result.current.values.city, 'Carol-debounced');
|
|
133
|
+
assert.notEqual(result.current.values.city, 'Bob-debounced');
|
|
134
|
+
});
|
|
135
|
+
test('processing is false initially', async () => {
|
|
136
|
+
const { result } = await renderHook(() => {
|
|
137
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
138
|
+
return useAsyncFormCore(form, [cityFromName]);
|
|
139
|
+
});
|
|
140
|
+
await waitUntil(() => result.current.processing === false);
|
|
141
|
+
assert.isFalse(result.current.processing);
|
|
142
|
+
});
|
|
143
|
+
test('processing is true while a rule is in flight', async () => {
|
|
144
|
+
const slowRule = [
|
|
145
|
+
async (current, { signal }) => {
|
|
146
|
+
await delay(signal, 100);
|
|
147
|
+
return { city: current.name + '-slow' };
|
|
148
|
+
},
|
|
149
|
+
({ name }) => [name],
|
|
150
|
+
];
|
|
151
|
+
const { result } = await renderHook(() => {
|
|
152
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
153
|
+
return useAsyncFormCore(form, [slowRule]);
|
|
154
|
+
});
|
|
155
|
+
await waitUntil(() => result.current.processing === true);
|
|
156
|
+
assert.isTrue(result.current.processing);
|
|
157
|
+
await waitUntil(() => result.current.processing === false, undefined, {
|
|
158
|
+
timeout: 2000,
|
|
159
|
+
});
|
|
160
|
+
assert.isFalse(result.current.processing);
|
|
161
|
+
});
|
|
162
|
+
test('processing stays true while multiple rules are in flight', async () => {
|
|
163
|
+
const slowRule1 = [
|
|
164
|
+
async (current, { signal }) => {
|
|
165
|
+
await delay(signal, 80);
|
|
166
|
+
return { city: current.name + '-1' };
|
|
167
|
+
},
|
|
168
|
+
({ name }) => [name],
|
|
169
|
+
];
|
|
170
|
+
const slowRule2 = [
|
|
171
|
+
async (current, { signal }) => {
|
|
172
|
+
await delay(signal, 160);
|
|
173
|
+
return { city: current.name + '-2' };
|
|
174
|
+
},
|
|
175
|
+
({ name }) => [name],
|
|
176
|
+
];
|
|
177
|
+
const { result } = await renderHook(() => {
|
|
178
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
179
|
+
return useAsyncFormCore(form, [slowRule1, slowRule2]);
|
|
180
|
+
});
|
|
181
|
+
await waitUntil(() => result.current.processing === true);
|
|
182
|
+
// After first settles (~80ms) processing must still be true (second still running)
|
|
183
|
+
await tick(100);
|
|
184
|
+
assert.isTrue(result.current.processing);
|
|
185
|
+
// After both settle — processing false
|
|
186
|
+
await waitUntil(() => result.current.processing === false, undefined, {
|
|
187
|
+
timeout: 2000,
|
|
188
|
+
});
|
|
189
|
+
assert.isFalse(result.current.processing);
|
|
190
|
+
});
|
|
191
|
+
test('processing returns to false when a rule is cancelled (runner returns null)', async () => {
|
|
192
|
+
const slowRule = [
|
|
193
|
+
async (current, { signal }) => {
|
|
194
|
+
await delay(signal, 200);
|
|
195
|
+
return { city: current.name + '-slow' };
|
|
196
|
+
},
|
|
197
|
+
({ name }) => [name],
|
|
198
|
+
];
|
|
199
|
+
const { result } = await renderHook(() => {
|
|
200
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
201
|
+
return useAsyncFormCore(form, [slowRule]);
|
|
202
|
+
});
|
|
203
|
+
await waitUntil(() => result.current.processing === true);
|
|
204
|
+
// Trigger a new dep change — cancels previous rule, starts a new one
|
|
205
|
+
result.current.onChange({ name: 'Bob' });
|
|
206
|
+
await waitUntil(() => result.current.processing === false, undefined, {
|
|
207
|
+
timeout: 2000,
|
|
208
|
+
});
|
|
209
|
+
assert.isFalse(result.current.processing);
|
|
210
|
+
});
|
|
211
|
+
test('cleanup — no state update after unmount', async () => {
|
|
212
|
+
const slowRule = [
|
|
213
|
+
async (current, { signal }) => {
|
|
214
|
+
await delay(signal, 100);
|
|
215
|
+
return { city: current.name + '-after-unmount' };
|
|
216
|
+
},
|
|
217
|
+
({ name }) => [name],
|
|
218
|
+
];
|
|
219
|
+
const onChangeSpy = spy();
|
|
220
|
+
const { unmount } = await renderHook(() => {
|
|
221
|
+
const form = useForm({ name: 'Alice', city: '' });
|
|
222
|
+
const wrappedForm = {
|
|
223
|
+
...form,
|
|
224
|
+
onChange: (...args) => {
|
|
225
|
+
onChangeSpy(...args);
|
|
226
|
+
return form.onChange(...args);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
useAsyncFormCore(wrappedForm, [slowRule]);
|
|
230
|
+
return wrappedForm;
|
|
231
|
+
});
|
|
232
|
+
await tick(10); // mid-delay
|
|
233
|
+
const callsBefore = onChangeSpy.callCount;
|
|
234
|
+
unmount(); // cancel in-flight rule
|
|
235
|
+
await tick(200); // wait for what would have been the delay
|
|
236
|
+
assert.equal(onChangeSpy.callCount, callsBefore, 'no onChange after unmount');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-async-rules.test.d.ts","sourceRoot":"","sources":["../../src/test/use-async-rules.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { renderHook } from '@neovici/testing';
|
|
2
|
+
import { assert, waitUntil } from '@open-wc/testing';
|
|
3
|
+
import { spy } from 'sinon';
|
|
4
|
+
import { delay } from '../async-rule';
|
|
5
|
+
import { makeDebounceRunner } from '../make-debounce-runner';
|
|
6
|
+
import { useItems } from '../use-items';
|
|
7
|
+
import { useAsyncRules } from '../use-items/use-async-rules';
|
|
8
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
9
|
+
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
|
|
10
|
+
// ── Suite ─────────────────────────────────────────────────────────────────────
|
|
11
|
+
suite('useAsyncRules (items)', () => {
|
|
12
|
+
// Basic rule: derived = name + '-' + idx
|
|
13
|
+
const derivedFromName = [
|
|
14
|
+
async (current, { index }) => ({ derived: current.name + '-' + index }),
|
|
15
|
+
({ name }, idx) => [name, idx],
|
|
16
|
+
];
|
|
17
|
+
test('rule runs per-item on initialization', async () => {
|
|
18
|
+
const { result } = await renderHook(() => {
|
|
19
|
+
const core = useItems({
|
|
20
|
+
initial: [{ name: 'A' }, { name: 'B' }],
|
|
21
|
+
});
|
|
22
|
+
useAsyncRules(core.items, [derivedFromName], core.update);
|
|
23
|
+
return core;
|
|
24
|
+
});
|
|
25
|
+
await waitUntil(() => result.current.items[0]?.derived !== undefined &&
|
|
26
|
+
result.current.items[1]?.derived !== undefined);
|
|
27
|
+
assert.equal(result.current.items[0].derived, 'A-0');
|
|
28
|
+
assert.equal(result.current.items[1].derived, 'B-1');
|
|
29
|
+
});
|
|
30
|
+
test('rule result applied to correct item index', async () => {
|
|
31
|
+
const { result } = await renderHook(() => {
|
|
32
|
+
const core = useItems({
|
|
33
|
+
initial: [{ name: 'X' }, { name: 'Y' }],
|
|
34
|
+
});
|
|
35
|
+
useAsyncRules(core.items, [derivedFromName], core.update);
|
|
36
|
+
return core;
|
|
37
|
+
});
|
|
38
|
+
await waitUntil(() => result.current.items[0]?.derived !== undefined);
|
|
39
|
+
assert.equal(result.current.items[0].derived, 'X-0');
|
|
40
|
+
assert.equal(result.current.items[1].derived, 'Y-1');
|
|
41
|
+
});
|
|
42
|
+
test('rule does not run for items whose deps are unchanged', async () => {
|
|
43
|
+
const { result } = await renderHook(() => {
|
|
44
|
+
const core = useItems({ initial: [{ name: 'A' }] });
|
|
45
|
+
useAsyncRules(core.items, [derivedFromName], core.update);
|
|
46
|
+
return core;
|
|
47
|
+
});
|
|
48
|
+
await waitUntil(() => result.current.items[0]?.derived === 'A-0');
|
|
49
|
+
// Manually set derived to 'manual', then trigger a re-render without changing name/idx
|
|
50
|
+
result.current.update(0, { derived: 'manual' });
|
|
51
|
+
await tick(50); // give rule time to NOT re-run
|
|
52
|
+
assert.equal(result.current.items[0].derived, 'manual');
|
|
53
|
+
});
|
|
54
|
+
test('changing item[0] triggers rule for item[0] only, not item[1]', async () => {
|
|
55
|
+
const runCounts = { 0: 0, 1: 0 };
|
|
56
|
+
const countingRule = [
|
|
57
|
+
async (current, { index }) => {
|
|
58
|
+
runCounts[index]++;
|
|
59
|
+
return { derived: current.name + '-' + index };
|
|
60
|
+
},
|
|
61
|
+
({ name }, idx) => [name, idx],
|
|
62
|
+
];
|
|
63
|
+
const { result } = await renderHook(() => {
|
|
64
|
+
const core = useItems({
|
|
65
|
+
initial: [{ name: 'A' }, { name: 'B' }],
|
|
66
|
+
});
|
|
67
|
+
useAsyncRules(core.items, [countingRule], core.update);
|
|
68
|
+
return core;
|
|
69
|
+
});
|
|
70
|
+
await waitUntil(() => result.current.items[0]?.derived !== undefined);
|
|
71
|
+
const runBefore1 = runCounts[1];
|
|
72
|
+
result.current.update(0, { name: 'A2' }); // only item[0] changes
|
|
73
|
+
await waitUntil(() => result.current.items[0].derived === 'A2-0');
|
|
74
|
+
assert.equal(runCounts[1], runBefore1);
|
|
75
|
+
});
|
|
76
|
+
test('per-item takeLatest — item[0] rule cancelled independently of item[1]', async () => {
|
|
77
|
+
const slowRule = [
|
|
78
|
+
async (current, { signal, index }) => {
|
|
79
|
+
await delay(signal, 150);
|
|
80
|
+
return { derived: current.name + '-slow-' + index };
|
|
81
|
+
},
|
|
82
|
+
({ name }, idx) => [name, idx],
|
|
83
|
+
];
|
|
84
|
+
const { result } = await renderHook(() => {
|
|
85
|
+
const core = useItems({
|
|
86
|
+
initial: [{ name: 'A' }, { name: 'B' }],
|
|
87
|
+
});
|
|
88
|
+
useAsyncRules(core.items, [slowRule], core.update);
|
|
89
|
+
return core;
|
|
90
|
+
});
|
|
91
|
+
await tick(10);
|
|
92
|
+
// Change item[0] name mid-flight (cancels item[0] first rule)
|
|
93
|
+
result.current.update(0, { name: 'A2' });
|
|
94
|
+
await waitUntil(() => result.current.items[0]?.derived === 'A2-slow-0', 'A2-slow-0 should appear', { timeout: 2000 });
|
|
95
|
+
assert.equal(result.current.items[1].derived, 'B-slow-1');
|
|
96
|
+
assert.notEqual(result.current.items[0].derived, 'A-slow-0');
|
|
97
|
+
});
|
|
98
|
+
test('intermediate opts.update patch applied to correct item only', async () => {
|
|
99
|
+
const loadingRule = [
|
|
100
|
+
async (current, { update, signal, index }) => {
|
|
101
|
+
update({ derived: 'loading-' + index });
|
|
102
|
+
await delay(signal, 50);
|
|
103
|
+
return { derived: current.name + '-done-' + index };
|
|
104
|
+
},
|
|
105
|
+
({ name }, idx) => [name, idx],
|
|
106
|
+
];
|
|
107
|
+
const { result } = await renderHook(() => {
|
|
108
|
+
const core = useItems({
|
|
109
|
+
initial: [{ name: 'A' }, { name: 'B' }],
|
|
110
|
+
});
|
|
111
|
+
useAsyncRules(core.items, [loadingRule], core.update);
|
|
112
|
+
return core;
|
|
113
|
+
});
|
|
114
|
+
await waitUntil(() => result.current.items[0]?.derived === 'loading-0');
|
|
115
|
+
assert.equal(result.current.items[1].derived, 'loading-1');
|
|
116
|
+
await waitUntil(() => result.current.items[0]?.derived === 'A-done-0', undefined, { timeout: 2000 });
|
|
117
|
+
assert.equal(result.current.items[1].derived, 'B-done-1');
|
|
118
|
+
});
|
|
119
|
+
test('onError called with (err, rule, index)', async () => {
|
|
120
|
+
const boom = [
|
|
121
|
+
async () => {
|
|
122
|
+
throw new Error('item boom');
|
|
123
|
+
},
|
|
124
|
+
({ name }, idx) => [name, idx],
|
|
125
|
+
];
|
|
126
|
+
const onError = spy();
|
|
127
|
+
await renderHook(() => {
|
|
128
|
+
const core = useItems({ initial: [{ name: 'A' }] });
|
|
129
|
+
useAsyncRules(core.items, [boom], core.update, { onError });
|
|
130
|
+
return core;
|
|
131
|
+
});
|
|
132
|
+
await waitUntil(() => onError.called, 'onError should be called');
|
|
133
|
+
assert.instanceOf(onError.args[0][0], Error);
|
|
134
|
+
assert.equal(onError.args[0][1], boom);
|
|
135
|
+
assert.equal(onError.args[0][2], 0); // index
|
|
136
|
+
});
|
|
137
|
+
test('runner map grows when items are appended — new item rule runs', async () => {
|
|
138
|
+
const { result } = await renderHook(() => {
|
|
139
|
+
const core = useItems({ initial: [{ name: 'A' }] });
|
|
140
|
+
useAsyncRules(core.items, [derivedFromName], core.update);
|
|
141
|
+
return core;
|
|
142
|
+
});
|
|
143
|
+
await waitUntil(() => result.current.items[0]?.derived === 'A-0');
|
|
144
|
+
result.current.update(1, { name: 'B' }); // append
|
|
145
|
+
await waitUntil(() => result.current.items[1]?.derived === 'B-1');
|
|
146
|
+
});
|
|
147
|
+
test('uses runner factory from the rule — debounce semantics observed', async () => {
|
|
148
|
+
const debounceRule = [
|
|
149
|
+
async (current, { index }) => ({
|
|
150
|
+
derived: current.name + '-debounced-' + index,
|
|
151
|
+
}),
|
|
152
|
+
({ name }, idx) => [name, idx],
|
|
153
|
+
() => makeDebounceRunner(100),
|
|
154
|
+
];
|
|
155
|
+
const { result } = await renderHook(() => {
|
|
156
|
+
const core = useItems({ initial: [{ name: 'A' }] });
|
|
157
|
+
useAsyncRules(core.items, [debounceRule], core.update);
|
|
158
|
+
return core;
|
|
159
|
+
});
|
|
160
|
+
// Two rapid dep changes within the 100ms debounce window
|
|
161
|
+
result.current.update(0, { name: 'B' });
|
|
162
|
+
await tick(30);
|
|
163
|
+
result.current.update(0, { name: 'C' });
|
|
164
|
+
await waitUntil(() => result.current.items[0]?.derived !== undefined, 'derived should be set', { timeout: 2000 });
|
|
165
|
+
assert.equal(result.current.items[0].derived, 'C-debounced-0');
|
|
166
|
+
assert.notEqual(result.current.items[0].derived, 'B-debounced-0');
|
|
167
|
+
});
|
|
168
|
+
test('no stale runner for removed item — new item at index 0 gets a fresh rule', async () => {
|
|
169
|
+
const { result } = await renderHook(() => {
|
|
170
|
+
const core = useItems({
|
|
171
|
+
initial: [{ name: 'A' }, { name: 'B' }],
|
|
172
|
+
});
|
|
173
|
+
useAsyncRules(core.items, [derivedFromName], core.update);
|
|
174
|
+
return core;
|
|
175
|
+
});
|
|
176
|
+
await waitUntil(() => result.current.items[0]?.derived === 'A-0');
|
|
177
|
+
result.current.remove(0); // remove item[0], 'B' shifts to index 0
|
|
178
|
+
await waitUntil(() => result.current.items[0]?.derived === 'B-0', 'B-0 should appear after removal', { timeout: 2000 });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AsyncItemRule } from './async-rule';
|
|
2
|
+
import type { UseForm } from './use-form-core';
|
|
3
|
+
/**
|
|
4
|
+
* Composes with UseForm<T> to add async rules.
|
|
5
|
+
* Returns UseForm<T> & { processing } where processing is true while any
|
|
6
|
+
* async rule is in flight.
|
|
7
|
+
*
|
|
8
|
+
* Async patches call onChange(patch, false) — they do not mark the form touched.
|
|
9
|
+
* Intermediate patches (from opts.update(...)) go through onChange like any other
|
|
10
|
+
* patch — sync rules cascade on top of them, which is expected.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const form = useValidatedForm({ fields, initial, rules });
|
|
14
|
+
* const { processing } = useAsyncFormCore(form, asyncRules);
|
|
15
|
+
*/
|
|
16
|
+
export declare const useAsyncFormCore: <T extends object>(form: UseForm<T>, asyncRules: readonly AsyncItemRule<T>[] | undefined, opts?: {
|
|
17
|
+
onError?: (err: unknown, rule: AsyncItemRule<T>) => void;
|
|
18
|
+
}) => UseForm<T> & {
|
|
19
|
+
processing: boolean;
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=use-async-form-core.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-async-form-core.d.ts","sourceRoot":"","sources":["../src/use-async-form-core.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,cAAc,CAAC;AAE/D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAU/C;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,SAAS,MAAM,EAChD,MAAM,OAAO,CAAC,CAAC,CAAC,EAChB,YAAY,SAAS,aAAa,CAAC,CAAC,CAAC,EAAE,GAAG,SAAS,EACnD,OAAO;IAAE,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,aAAa,CAAC,CAAC,CAAC,KAAK,IAAI,CAAA;CAAE,KACjE,OAAO,CAAC,CAAC,CAAC,GAAG;IAAE,UAAU,EAAE,OAAO,CAAA;CAkEpC,CAAC"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from '@pionjs/pion';
|
|
2
|
+
import { makeTakeLatestRunner } from './make-take-latest-runner';
|
|
3
|
+
const changed = (a, b) => a.length !== b.length || a.some((v, i) => !Object.is(v, b[i]));
|
|
4
|
+
const DEFAULT_ON_ERROR = (err) => {
|
|
5
|
+
// eslint-disable-next-line no-console
|
|
6
|
+
console.error('[cosmoz-form] async rule error:', err);
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Composes with UseForm<T> to add async rules.
|
|
10
|
+
* Returns UseForm<T> & { processing } where processing is true while any
|
|
11
|
+
* async rule is in flight.
|
|
12
|
+
*
|
|
13
|
+
* Async patches call onChange(patch, false) — they do not mark the form touched.
|
|
14
|
+
* Intermediate patches (from opts.update(...)) go through onChange like any other
|
|
15
|
+
* patch — sync rules cascade on top of them, which is expected.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* const form = useValidatedForm({ fields, initial, rules });
|
|
19
|
+
* const { processing } = useAsyncFormCore(form, asyncRules);
|
|
20
|
+
*/
|
|
21
|
+
export const useAsyncFormCore = (form, asyncRules, opts) => {
|
|
22
|
+
const onError = opts?.onError ?? DEFAULT_ON_ERROR;
|
|
23
|
+
// Refs persist across renders without triggering re-renders
|
|
24
|
+
const runnersRef = useRef(new Map());
|
|
25
|
+
const prevDepsRef = useRef(new Map());
|
|
26
|
+
// pendingCount tracks in-flight rules without causing re-renders itself.
|
|
27
|
+
// processing state is updated only on 0→1 and 1→0 transitions.
|
|
28
|
+
const pendingCount = useRef(0);
|
|
29
|
+
const [processing, setProcessing] = useState(false);
|
|
30
|
+
// Cleanup: cancel all in-flight rules on unmount
|
|
31
|
+
useEffect(() => () => {
|
|
32
|
+
for (const runner of runnersRef.current.values())
|
|
33
|
+
runner.cancel();
|
|
34
|
+
}, []);
|
|
35
|
+
// Dep-check + rule dispatch: runs after every values change
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!asyncRules?.length)
|
|
38
|
+
return;
|
|
39
|
+
for (const rule of asyncRules) {
|
|
40
|
+
const [ruleFn, depsFn, runnerFactory = makeTakeLatestRunner] = rule;
|
|
41
|
+
if (!runnersRef.current.has(rule)) {
|
|
42
|
+
runnersRef.current.set(rule, runnerFactory());
|
|
43
|
+
}
|
|
44
|
+
const deps = depsFn(form.values);
|
|
45
|
+
const prev = prevDepsRef.current.get(rule);
|
|
46
|
+
// Skip if deps unchanged (Object.is per element, same as applyRules)
|
|
47
|
+
if (prev != null && !changed(deps, prev)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
prevDepsRef.current.set(rule, deps);
|
|
51
|
+
const runner = runnersRef.current.get(rule);
|
|
52
|
+
pendingCount.current++;
|
|
53
|
+
if (pendingCount.current === 1)
|
|
54
|
+
setProcessing(true);
|
|
55
|
+
runner
|
|
56
|
+
.run(ruleFn, form.values, (patch) => form.onChange(patch, false))
|
|
57
|
+
.then((result) => {
|
|
58
|
+
if (result !== null) {
|
|
59
|
+
form.onChange(result, false); // final: no touch
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.catch((err) => onError(err, rule))
|
|
63
|
+
.finally(() => {
|
|
64
|
+
pendingCount.current--;
|
|
65
|
+
if (pendingCount.current === 0)
|
|
66
|
+
setProcessing(false);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}, [form.values]);
|
|
70
|
+
return { ...form, processing };
|
|
71
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AsyncItemRule } from '../async-rule';
|
|
2
|
+
import type { UseItemsCore } from './use-items';
|
|
3
|
+
/**
|
|
4
|
+
* Composes with useItemsCore / useItems to add async rules.
|
|
5
|
+
* One runner per (rule, itemIndex) pair — item rules are independent.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const core = useItems({ initial, rules });
|
|
9
|
+
* useAsyncRules(core.items, asyncRules, core.update);
|
|
10
|
+
* return core;
|
|
11
|
+
*/
|
|
12
|
+
export declare const useAsyncRules: <T extends object>(items: T[], asyncRules: readonly AsyncItemRule<T>[] | undefined, update: UseItemsCore<T>["update"], opts?: {
|
|
13
|
+
onError?: (err: unknown, rule: AsyncItemRule<T>, index: number) => void;
|
|
14
|
+
}) => {
|
|
15
|
+
processing: boolean;
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=use-async-rules.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-async-rules.d.ts","sourceRoot":"","sources":["../../src/use-items/use-async-rules.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,eAAe,CAAC;AAEhE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAuBhD;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,GAAI,CAAC,SAAS,MAAM,EAC7C,OAAO,CAAC,EAAE,EACV,YAAY,SAAS,aAAa,CAAC,CAAC,CAAC,EAAE,GAAG,SAAS,EACnD,QAAQ,YAAY,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EACjC,OAAO;IACN,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,aAAa,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACxE,KACC;IAAE,UAAU,EAAE,OAAO,CAAA;CA0EvB,CAAC"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from '@pionjs/pion';
|
|
2
|
+
import { makeTakeLatestRunner } from '../make-take-latest-runner';
|
|
3
|
+
const changed = (a, b) => a.length !== b.length || a.some((v, i) => !Object.is(v, b[i]));
|
|
4
|
+
const DEFAULT_ON_ERROR = (err) => {
|
|
5
|
+
// eslint-disable-next-line no-console
|
|
6
|
+
console.error('[cosmoz-form] async rule error:', err);
|
|
7
|
+
};
|
|
8
|
+
const ensureRuleTracking = (rule, runnersRef, prevDepsRef) => {
|
|
9
|
+
if (runnersRef.current.has(rule))
|
|
10
|
+
return;
|
|
11
|
+
runnersRef.current.set(rule, new Map());
|
|
12
|
+
prevDepsRef.current.set(rule, new Map());
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Composes with useItemsCore / useItems to add async rules.
|
|
16
|
+
* One runner per (rule, itemIndex) pair — item rules are independent.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* const core = useItems({ initial, rules });
|
|
20
|
+
* useAsyncRules(core.items, asyncRules, core.update);
|
|
21
|
+
* return core;
|
|
22
|
+
*/
|
|
23
|
+
export const useAsyncRules = (items, asyncRules, update, opts) => {
|
|
24
|
+
const onError = opts?.onError ?? DEFAULT_ON_ERROR;
|
|
25
|
+
const pendingCount = useRef(0);
|
|
26
|
+
const [processing, setProcessing] = useState(false);
|
|
27
|
+
const runnersRef = useRef(new Map());
|
|
28
|
+
const prevDepsRef = useRef(new Map());
|
|
29
|
+
// Cleanup on unmount
|
|
30
|
+
useEffect(() => () => {
|
|
31
|
+
for (const perItem of runnersRef.current.values()) {
|
|
32
|
+
for (const runner of perItem.values()) {
|
|
33
|
+
runner.cancel();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}, []);
|
|
37
|
+
// Dep-check + rule dispatch: runs after every items change
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!asyncRules?.length) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const rule of asyncRules) {
|
|
43
|
+
const [ruleFn, depsFn, runnerFactory = makeTakeLatestRunner] = rule;
|
|
44
|
+
ensureRuleTracking(rule, runnersRef, prevDepsRef);
|
|
45
|
+
for (const [idx, item] of items.entries()) {
|
|
46
|
+
const runnersForRule = runnersRef.current.get(rule);
|
|
47
|
+
if (!runnersForRule.has(idx)) {
|
|
48
|
+
runnersForRule.set(idx, runnerFactory());
|
|
49
|
+
}
|
|
50
|
+
const deps = depsFn(item, idx);
|
|
51
|
+
const prev = prevDepsRef.current.get(rule).get(idx);
|
|
52
|
+
if (prev != null && !changed(deps, prev)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
prevDepsRef.current.get(rule).set(idx, deps);
|
|
56
|
+
const runner = runnersForRule.get(idx);
|
|
57
|
+
pendingCount.current++;
|
|
58
|
+
if (pendingCount.current === 1)
|
|
59
|
+
setProcessing(true);
|
|
60
|
+
runner
|
|
61
|
+
.run(ruleFn, item, (patch) => update(idx, patch), // intermediate: no touch
|
|
62
|
+
{ index: idx })
|
|
63
|
+
.then((result) => {
|
|
64
|
+
if (result !== null) {
|
|
65
|
+
update(idx, result);
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
.catch((err) => onError(err, rule, idx))
|
|
69
|
+
.finally(() => {
|
|
70
|
+
pendingCount.current--;
|
|
71
|
+
if (pendingCount.current === 0)
|
|
72
|
+
setProcessing(false);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}, [items]);
|
|
77
|
+
return { processing };
|
|
78
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-items.d.ts","sourceRoot":"","sources":["../../src/use-items/use-items.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"use-items.d.ts","sourceRoot":"","sources":["../../src/use-items/use-items.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAkC,MAAM,cAAc,CAAC;AAE5E,OAAO,EAAc,QAAQ,EAAE,MAAM,eAAe,CAAC;AAErD,UAAU,KAAK,CAAC,CAAC,SAAS,MAAM;IAC/B,OAAO,EAAE,CAAC,EAAE,CAAC;IACb,KAAK,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;CACtB;AAYD,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,IAAI;IAC5C,KAAK,EAAE,CAAC,EAAE,CAAC;IACX,QAAQ,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;IAC5B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,CACP,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,EACxD,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,KACd,IAAI,CAAC;IACV,SAAS,EAAE,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;IACrE,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,IAAI,CAAC;IAC7B,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,aAAa,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,CAAC;CAC1D,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS,MAAM,EAAE,sCAK5C,KAAK,CAAC,CAAC,CAAC,GAAG;IACb,KAAK,EAAE,CAAC,EAAE,CAAC;IACX,QAAQ,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;CAC5B,KAAG,YAAY,CAAC,CAAC,CA+FjB,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,CAAC,SAAS,MAAM,EAAE,oBAAoB,KAAK,CAAC,CAAC,CAAC,oBAStE,CAAC"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useCallback, useMemo, useState } from '@pionjs/pion';
|
|
2
1
|
import { invoke } from '@neovici/cosmoz-utils/function';
|
|
3
|
-
import {
|
|
2
|
+
import { useCallback, useMemo, useState } from '@pionjs/pion';
|
|
4
3
|
import { touch, touched } from '../touch';
|
|
4
|
+
import { applyRules } from './apply-rules';
|
|
5
5
|
const changes = (indexOrChanges, update) => {
|
|
6
6
|
if (Array.isArray(indexOrChanges)) {
|
|
7
7
|
return indexOrChanges;
|