what-core 0.12.3 → 0.13.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/chunk-ENARGHSD.min.js +11 -0
- package/dist/chunk-OKM3GKVP.min.js +1 -0
- package/dist/index.min.js +82 -5
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/index.d.ts +369 -43
- package/package.json +1 -1
- package/render.d.ts +7 -0
- package/src/a11y.js +237 -26
- package/src/agent-context.js +1 -1
- package/src/animation.js +13 -6
- package/src/components.js +1 -1
- package/src/data.js +643 -93
- package/src/dom.js +29 -19
- package/src/errors.js +333 -1
- package/src/form.js +330 -31
- package/src/head.js +2 -1
- package/src/hooks.js +30 -20
- package/src/index.js +1 -0
- package/src/reactive.js +31 -14
- package/src/render.js +502 -36
- package/src/scheduler.js +24 -7
- package/src/skeleton.js +16 -1
- package/src/store.js +0 -1
- package/src/testing.js +101 -50
- package/src/warnings.js +83 -0
- package/testing.d.ts +17 -1
- package/dist/chunk-JVEPLFIB.min.js +0 -11
- package/dist/chunk-VTPLA4AS.min.js +0 -1
package/src/form.js
CHANGED
|
@@ -1,10 +1,73 @@
|
|
|
1
1
|
// What Framework - Form Utilities
|
|
2
2
|
// Controlled inputs, validation, and form state management
|
|
3
3
|
|
|
4
|
-
import { signal, computed, batch } from './reactive.js';
|
|
5
|
-
import { getCurrentComponent } from './dom.js';
|
|
4
|
+
import { signal, computed, batch, effect, __DEV__ } from './reactive.js';
|
|
5
|
+
import { getCurrentComponent, _isEventProp } from './dom.js';
|
|
6
6
|
import { h } from './h.js';
|
|
7
7
|
|
|
8
|
+
// --- Registration plumbing ---
|
|
9
|
+
|
|
10
|
+
// A ref can be a callback or a { current } box; both spellings are public API,
|
|
11
|
+
// so anything that wants to install its OWN ref has to forward the caller's.
|
|
12
|
+
function _applyRef(ref, el) {
|
|
13
|
+
if (typeof ref === 'function') ref(el);
|
|
14
|
+
else if (ref && typeof ref === 'object') ref.current = el;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Run BOTH handlers when a registration and its caller want the same DOM event.
|
|
18
|
+
// Registration first, caller second, so the caller's handler observes form state
|
|
19
|
+
// that is already up to date (React Hook Form orders its `register(name, {
|
|
20
|
+
// onChange })` option the same way).
|
|
21
|
+
function _composeHandlers(registered, callerHandler) {
|
|
22
|
+
if (typeof registered !== 'function') return callerHandler;
|
|
23
|
+
if (typeof callerHandler !== 'function') return registered;
|
|
24
|
+
/** @this {any} */
|
|
25
|
+
return function composedHandler(...args) {
|
|
26
|
+
registered.apply(this, args);
|
|
27
|
+
return callerHandler.apply(this, args);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Merge a register() result into caller props WITHOUT dropping the caller's
|
|
32
|
+
// event handlers. The obvious `{ ...props, ...registered }` silently loses every
|
|
33
|
+
// handler the two objects share, and it does not even need matching keys to do
|
|
34
|
+
// it: `onChange` and `onchange` are different object keys that resolve to the
|
|
35
|
+
// SAME DOM event, and setProp keys its listener bookkeeping by event name, so
|
|
36
|
+
// the second one applied replaces the first. That is how <Radio onBlur> stopped
|
|
37
|
+
// firing — the registration's onBlur always exists, so it always won.
|
|
38
|
+
// Non-event props keep plain spread semantics (the registration wins: it is what
|
|
39
|
+
// makes the input controlled).
|
|
40
|
+
function _mergeRegistration(props, registered) {
|
|
41
|
+
const merged = { ...props };
|
|
42
|
+
// Event identity, not key identity: strip the `on` prefix and lowercase, so
|
|
43
|
+
// onChange/onchange collapse together while onBlurCapture (capture phase — a
|
|
44
|
+
// genuinely different listener) stays separate. When the caller wrote two keys
|
|
45
|
+
// for one event, the LAST one wins in the DOM, and it is also the one left in
|
|
46
|
+
// this map, so that is the one the registration composes with.
|
|
47
|
+
const callerKeyByEvent = new Map();
|
|
48
|
+
for (const key in merged) {
|
|
49
|
+
if (_isEventProp(key)) callerKeyByEvent.set(key.slice(2).toLowerCase(), key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const key in registered) {
|
|
53
|
+
const value = registered[key];
|
|
54
|
+
if (!_isEventProp(key)) {
|
|
55
|
+
merged[key] = value;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const callerKey = callerKeyByEvent.get(key.slice(2).toLowerCase());
|
|
59
|
+
if (callerKey === undefined) {
|
|
60
|
+
merged[key] = value;
|
|
61
|
+
callerKeyByEvent.set(key.slice(2).toLowerCase(), key);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// Keep the caller's key so their props still read back the way they wrote
|
|
65
|
+
// them; the composed function underneath carries both handlers.
|
|
66
|
+
merged[callerKey] = _composeHandlers(value, merged[callerKey]);
|
|
67
|
+
}
|
|
68
|
+
return merged;
|
|
69
|
+
}
|
|
70
|
+
|
|
8
71
|
// --- useForm Hook ---
|
|
9
72
|
// Complete form state management with validation
|
|
10
73
|
|
|
@@ -150,12 +213,77 @@ function createFormController(options = {}) {
|
|
|
150
213
|
// Register a field — only subscribes to THIS field's signal
|
|
151
214
|
function register(name, options = {}) {
|
|
152
215
|
const fieldSig = getFieldSignal(name);
|
|
153
|
-
const
|
|
216
|
+
const isRadio = options.type === 'radio';
|
|
217
|
+
const isCheckbox = options.type === 'checkbox';
|
|
218
|
+
// A radio does NOT store its own checkedness: every radio in a group shares
|
|
219
|
+
// one field, and that field holds the SELECTED option's value. A declared
|
|
220
|
+
// options.value is what this particular radio contributes, so it is both
|
|
221
|
+
// what the change handler writes and what `checked` compares the field
|
|
222
|
+
// against. radioValueOf() below covers the case where it was not declared.
|
|
223
|
+
const hasRadioValue = isRadio && options.value !== undefined;
|
|
224
|
+
|
|
225
|
+
// The value THIS radio contributes to the shared field. A declared
|
|
226
|
+
// options.value wins; otherwise the element's own `value` ATTRIBUTE is the
|
|
227
|
+
// contribution, which is exactly how a plain HTML radio group works.
|
|
228
|
+
// undefined means "this radio has nothing to contribute": the DOM reports
|
|
229
|
+
// the default "on" for EVERY valueless radio, so a group of them could not
|
|
230
|
+
// represent a choice, and a `checked` binding derived from it would light up
|
|
231
|
+
// every radio in the group at once.
|
|
232
|
+
function radioValueOf(el) {
|
|
233
|
+
if (hasRadioValue) return options.value;
|
|
234
|
+
if (!el) return undefined;
|
|
235
|
+
// Only a real element can tell `value=""` apart from "no value attribute";
|
|
236
|
+
// the `value` IDL property reports "on" for the second case and cannot.
|
|
237
|
+
if (typeof el.hasAttribute === 'function') {
|
|
238
|
+
return el.hasAttribute('value') ? el.value : undefined;
|
|
239
|
+
}
|
|
240
|
+
return el.value; // synthetic target (tests, custom widgets)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Warn-once latches: register() runs once per input, so these fire once per
|
|
244
|
+
// offending input rather than once per keystroke or click.
|
|
245
|
+
let warnedValuelessRadio = false;
|
|
246
|
+
let warnedUntypedRadio = false;
|
|
154
247
|
|
|
155
248
|
const handler = (e) => {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
249
|
+
const target = e && e.target;
|
|
250
|
+
// NOT `|| {}`: a missing target used to read as `{}`, whose `.value` is
|
|
251
|
+
// undefined, so calling the handler without an event quietly OVERWROTE the
|
|
252
|
+
// field with undefined. Refuse instead — there is no value to apply.
|
|
253
|
+
if (!target) {
|
|
254
|
+
if (__DEV__) {
|
|
255
|
+
console.warn(
|
|
256
|
+
`[what] register("${name}") change handler was called without an event. ` +
|
|
257
|
+
'Pass the DOM event through (or call form.setValue() directly). Ignoring.'
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// options.type is the declared intent; target.type covers a bare
|
|
263
|
+
// register('x') spread straight onto a checkbox or radio input.
|
|
264
|
+
const type = options.type || target.type;
|
|
265
|
+
let value;
|
|
266
|
+
if (type === 'radio') {
|
|
267
|
+
// NOT target.checked: a radio's change event only ever fires when it
|
|
268
|
+
// becomes checked, so `true` carries no information. The field wants the
|
|
269
|
+
// value of whichever radio in the group was picked.
|
|
270
|
+
value = radioValueOf(target);
|
|
271
|
+
if (value === undefined) {
|
|
272
|
+
// Nothing identifies this radio, so any write would be a guess.
|
|
273
|
+
if (__DEV__ && !warnedValuelessRadio) {
|
|
274
|
+
warnedValuelessRadio = true;
|
|
275
|
+
console.warn(
|
|
276
|
+
`[what] register("${name}", { type: 'radio' }) cannot tell which value this radio ` +
|
|
277
|
+
'contributes: pass { value } or give the <input> a value attribute. Field left unchanged.'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
} else if (type === 'checkbox') {
|
|
283
|
+
value = target.checked;
|
|
284
|
+
} else {
|
|
285
|
+
value = target.value;
|
|
286
|
+
}
|
|
159
287
|
setValue(name, value);
|
|
160
288
|
|
|
161
289
|
if (mode === 'onChange' || (isSubmitted.peek() && reValidateMode === 'onChange')) {
|
|
@@ -176,8 +304,57 @@ function createFormController(options = {}) {
|
|
|
176
304
|
ref: options.ref,
|
|
177
305
|
};
|
|
178
306
|
|
|
179
|
-
if (
|
|
180
|
-
//
|
|
307
|
+
if (isRadio) {
|
|
308
|
+
// Without a declared value this thunk must report FALSE, never `!!field`:
|
|
309
|
+
// the contribution can only come from the element, which a prop thunk does
|
|
310
|
+
// not have, and `!!field` is true for every radio in the group at once, so
|
|
311
|
+
// picking any option rendered ALL of them checked. The ref below fixes up
|
|
312
|
+
// the real DOM once it does have the element.
|
|
313
|
+
const isChecked = hasRadioValue
|
|
314
|
+
? () => fieldSig() === options.value
|
|
315
|
+
: () => false;
|
|
316
|
+
|
|
317
|
+
if (hasRadioValue) result.value = options.value;
|
|
318
|
+
// A FUNCTION, not the getter the checkbox branch uses: every consumer
|
|
319
|
+
// spreads this object (`<Radio>` does, and so does hand-written
|
|
320
|
+
// `{...register(...)}`), and a spread RESOLVES a getter into a one-shot
|
|
321
|
+
// value that never updates again. A function prop is the framework's
|
|
322
|
+
// reactive-binding protocol — setProp() wraps it in an effect, and the SSR
|
|
323
|
+
// serializer calls it — so one thunk covers both renderers.
|
|
324
|
+
result.checked = isChecked;
|
|
325
|
+
result.onchange = handler;
|
|
326
|
+
result.ref = (el) => {
|
|
327
|
+
_applyRef(options.ref, el);
|
|
328
|
+
if (!el || typeof el !== 'object') return;
|
|
329
|
+
|
|
330
|
+
// The reactive `checked` prop above only stamps the checked CONTENT
|
|
331
|
+
// ATTRIBUTE, and the attribute stops controlling checkedness the moment
|
|
332
|
+
// the DOM sets an input's "dirty checkedness" flag, which the user's
|
|
333
|
+
// first click does permanently. From then on, re-selecting that radio
|
|
334
|
+
// through form state would set the attribute and change nothing on
|
|
335
|
+
// screen. Writing the checked PROPERTY is what a controlled radio needs
|
|
336
|
+
// (React and Solid do the same) and it also runs the DOM's radio-group
|
|
337
|
+
// invariant that unchecks the siblings.
|
|
338
|
+
// Disposal rides on the el._propEffects convention that dom.js and
|
|
339
|
+
// render.js both walk on unmount; the ':property' suffix keeps it from
|
|
340
|
+
// colliding with setProp's own effect for the `checked` attribute.
|
|
341
|
+
if (!el._propEffects) el._propEffects = {};
|
|
342
|
+
const key = 'checked:property';
|
|
343
|
+
if (el._propEffects[key]) {
|
|
344
|
+
try { el._propEffects[key](); } catch { /* already disposed */ }
|
|
345
|
+
}
|
|
346
|
+
// radioValueOf(el) is re-read on every run rather than captured, so a
|
|
347
|
+
// registration without a declared value still binds correctly once the
|
|
348
|
+
// element's own value attribute is in place, and an unidentifiable radio
|
|
349
|
+
// is simply never checked instead of being checked alongside its
|
|
350
|
+
// siblings.
|
|
351
|
+
el._propEffects[key] = effect(() => {
|
|
352
|
+
const own = radioValueOf(el);
|
|
353
|
+
el.checked = own !== undefined && fieldSig() === own;
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
} else if (isCheckbox) {
|
|
357
|
+
// Checkbox: use checked prop + onchange event
|
|
181
358
|
Object.defineProperty(result, 'checked', {
|
|
182
359
|
get() { return !!fieldSig(); },
|
|
183
360
|
enumerable: true,
|
|
@@ -190,6 +367,35 @@ function createFormController(options = {}) {
|
|
|
190
367
|
enumerable: true,
|
|
191
368
|
});
|
|
192
369
|
result.oninput = handler;
|
|
370
|
+
result.ref = (el) => {
|
|
371
|
+
_applyRef(options.ref, el);
|
|
372
|
+
// An UNTYPED registration spread onto a radio cannot be rescued, and it
|
|
373
|
+
// fails silently, so say so at mount. The `value` binding above resolves
|
|
374
|
+
// during the spread and overwrites the input's own value with the FIELD's
|
|
375
|
+
// value — and for a radio that own value IS this option's contribution to
|
|
376
|
+
// the group. It is gone for good: `value` on a radio is in the DOM's
|
|
377
|
+
// "default/on" mode, so writing the property also rewrites the value
|
|
378
|
+
// CONTENT ATTRIBUTE, which takes defaultValue and getAttribute('value')
|
|
379
|
+
// down with it. Nothing is left to recover the real value from, so the
|
|
380
|
+
// registration has to be told it is a radio up front.
|
|
381
|
+
// Checking el.type HERE, from the ref, is also what keeps the warning
|
|
382
|
+
// honest: props apply in key order, so with the spread written FIRST
|
|
383
|
+
// (`{...register('u'), type: 'radio', value: 'two'}`) this ref runs
|
|
384
|
+
// before `type` and `value` land, the literal value survives, the write
|
|
385
|
+
// is correct, and el.type is not yet 'radio' — so nothing is warned
|
|
386
|
+
// about. The warning fires exactly when our binding really did overwrite
|
|
387
|
+
// a radio's value.
|
|
388
|
+
if (__DEV__ && !warnedUntypedRadio && el && el.type === 'radio') {
|
|
389
|
+
warnedUntypedRadio = true;
|
|
390
|
+
console.warn(
|
|
391
|
+
`[what] register("${name}") was spread onto <input type="radio"> without a type. ` +
|
|
392
|
+
'Radios in a group share one field holding the SELECTED option, so the registration ' +
|
|
393
|
+
`must be told this radio's value: register("${name}", { type: 'radio', value: ... }), ` +
|
|
394
|
+
'or use <Radio name value register />. As written, the registration overwrote the ' +
|
|
395
|
+
"input's own value and the group cannot record a choice."
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
193
399
|
}
|
|
194
400
|
|
|
195
401
|
return result;
|
|
@@ -254,15 +460,22 @@ function createFormController(options = {}) {
|
|
|
254
460
|
isSubmitted.set(true);
|
|
255
461
|
submitCount.set(submitCount.peek() + 1);
|
|
256
462
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
463
|
+
// try/finally, because anything that throws between here and the release
|
|
464
|
+
// leaves isSubmitting stuck true forever, which in practice means a submit
|
|
465
|
+
// button disabled for the rest of the page's life. Throwers include a
|
|
466
|
+
// rejected onValid handler and a resolver that rethrows a non-validation
|
|
467
|
+
// failure (see zodResolver/yupResolver: they fail CLOSED by design).
|
|
468
|
+
try {
|
|
469
|
+
const isFormValid = await validate();
|
|
470
|
+
|
|
471
|
+
if (isFormValid) {
|
|
472
|
+
await onValid(getAllValues());
|
|
473
|
+
} else if (onInvalid) {
|
|
474
|
+
onInvalid(getAllErrors(false));
|
|
475
|
+
}
|
|
476
|
+
} finally {
|
|
477
|
+
isSubmitting.set(false);
|
|
263
478
|
}
|
|
264
|
-
|
|
265
|
-
isSubmitting.set(false);
|
|
266
479
|
};
|
|
267
480
|
}
|
|
268
481
|
|
|
@@ -311,34 +524,94 @@ function createFormController(options = {}) {
|
|
|
311
524
|
|
|
312
525
|
// --- Validation Resolvers ---
|
|
313
526
|
|
|
527
|
+
// A resolver that cannot read its library's error shape must never answer "no
|
|
528
|
+
// errors": handleSubmit() reads an empty error map as VALID and submits the
|
|
529
|
+
// form, so a shape mismatch turns validation off silently. Every resolver below
|
|
530
|
+
// therefore fails CLOSED — it reports errors, or it rethrows.
|
|
531
|
+
|
|
532
|
+
// Zod moved the issue list between majors: v3 exposes it as BOTH `.issues` and
|
|
533
|
+
// the legacy `.errors` alias, v4 dropped `.errors` and keeps only `.issues`.
|
|
534
|
+
// Reading `.errors` alone collected NOTHING under Zod 4, so every invalid form
|
|
535
|
+
// submitted. `.issues` is canonical in both majors, so try it first.
|
|
536
|
+
function _zodIssues(err) {
|
|
537
|
+
if (!err || typeof err !== 'object') return null;
|
|
538
|
+
if (Array.isArray(err.issues)) return err.issues;
|
|
539
|
+
if (Array.isArray(err.errors)) return err.errors;
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Zod types issue paths as PropertyKey[], so a record keyed by a symbol yields a
|
|
544
|
+
// symbol segment — and Array#join would throw on it (ToString of a symbol is a
|
|
545
|
+
// TypeError), losing every issue in the list including the ones we could report.
|
|
546
|
+
// Numbers (array indices) stringify the same in both majors.
|
|
547
|
+
function _issuePath(issue) {
|
|
548
|
+
const path = issue?.path;
|
|
549
|
+
if (path == null) return '';
|
|
550
|
+
if (!Array.isArray(path)) return String(path);
|
|
551
|
+
let out = '';
|
|
552
|
+
for (let i = 0; i < path.length; i++) {
|
|
553
|
+
const segment = path[i];
|
|
554
|
+
out += (i ? '.' : '') + (typeof segment === 'symbol' ? segment.toString() : String(segment));
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
|
|
314
559
|
export function zodResolver(schema) {
|
|
315
560
|
return async (values) => {
|
|
316
561
|
try {
|
|
317
562
|
const result = await schema.parseAsync(values);
|
|
318
563
|
return { values: result, errors: {} };
|
|
319
564
|
} catch (e) {
|
|
565
|
+
const issues = _zodIssues(e);
|
|
566
|
+
// Not a ZodError at all: a TypeError from a mis-built schema, a fetch that
|
|
567
|
+
// failed inside an async .refine(), a bug in zod itself. Swallowing it here
|
|
568
|
+
// reported the form as valid; rethrowing surfaces it, which is what
|
|
569
|
+
// @hookform/resolvers does for the same case.
|
|
570
|
+
if (!issues) throw e;
|
|
571
|
+
|
|
320
572
|
const errors = {};
|
|
321
|
-
for (const issue of
|
|
322
|
-
const path = issue
|
|
573
|
+
for (const issue of issues) {
|
|
574
|
+
const path = _issuePath(issue);
|
|
323
575
|
if (!errors[path]) {
|
|
324
|
-
errors[path] = { type: issue.code, message: issue.message };
|
|
576
|
+
errors[path] = { type: issue.code ?? 'validation', message: issue.message };
|
|
325
577
|
}
|
|
326
578
|
}
|
|
579
|
+
// parseAsync threw, so the input is NOT valid. An empty map here (an issue
|
|
580
|
+
// list we could not interpret) would read as valid, so keep the form
|
|
581
|
+
// blocked with a root-level error instead.
|
|
582
|
+
if (Object.keys(errors).length === 0) {
|
|
583
|
+
errors[''] = { type: 'validation', message: e?.message || 'Validation failed' };
|
|
584
|
+
}
|
|
327
585
|
return { values: {}, errors };
|
|
328
586
|
}
|
|
329
587
|
};
|
|
330
588
|
}
|
|
331
589
|
|
|
590
|
+
// yup reports the per-field failures on `inner` when abortEarly is false, but a
|
|
591
|
+
// failure raised at the ROOT (a non-object schema, or a test that reported a
|
|
592
|
+
// bare message) arrives with `inner` empty and the path/message on the error
|
|
593
|
+
// itself. Anything without a yup error shape is not a validation failure.
|
|
594
|
+
function _isYupValidationError(err) {
|
|
595
|
+
if (!err || typeof err !== 'object') return false;
|
|
596
|
+
return err.name === 'ValidationError' || Array.isArray(err.inner);
|
|
597
|
+
}
|
|
598
|
+
|
|
332
599
|
export function yupResolver(schema) {
|
|
333
600
|
return async (values) => {
|
|
334
601
|
try {
|
|
335
602
|
const result = await schema.validate(values, { abortEarly: false });
|
|
336
603
|
return { values: result, errors: {} };
|
|
337
604
|
} catch (e) {
|
|
605
|
+
// Same fail-closed rule as zodResolver: a crash inside a yup test must not
|
|
606
|
+
// be reported to handleSubmit() as "this form is fine".
|
|
607
|
+
if (!_isYupValidationError(e)) throw e;
|
|
608
|
+
|
|
338
609
|
const errors = {};
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
610
|
+
const inner = Array.isArray(e.inner) && e.inner.length > 0 ? e.inner : [e];
|
|
611
|
+
for (const err of inner) {
|
|
612
|
+
const path = err.path ?? '';
|
|
613
|
+
if (!errors[path]) {
|
|
614
|
+
errors[path] = { type: err.type ?? 'validation', message: err.message };
|
|
342
615
|
}
|
|
343
616
|
}
|
|
344
617
|
return { values: {}, errors };
|
|
@@ -531,19 +804,45 @@ export function Checkbox(props) {
|
|
|
531
804
|
|
|
532
805
|
export function Radio(props) {
|
|
533
806
|
const { register, value: radioValue, ...rest } = props;
|
|
534
|
-
const registered = register ? register(props.name) : {};
|
|
535
807
|
|
|
536
|
-
|
|
808
|
+
// A radio with no `value` is a programming error, not a default. The group's
|
|
809
|
+
// field holds the SELECTED option's value, and the DOM reports the default
|
|
810
|
+
// "on" for every valueless radio, so registering one would write "on" into
|
|
811
|
+
// form state and then check every radio in the group that reads it back.
|
|
812
|
+
// Render a plain unregistered input instead: inert, but honest.
|
|
813
|
+
if (radioValue === undefined) {
|
|
814
|
+
if (__DEV__) {
|
|
815
|
+
console.warn(
|
|
816
|
+
`[what] <Radio name="${props.name}"> is missing a \`value\` prop. Radios in a group share ` +
|
|
817
|
+
"one field holding the selected option's value, so a valueless radio has nothing to " +
|
|
818
|
+
'contribute and cannot be checked. Rendering it unregistered.'
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
return h('input', { type: 'radio', ...rest });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// The registration has to carry THIS radio's value: radios in a group share
|
|
825
|
+
// one field, so the value is what the change handler writes into form state
|
|
826
|
+
// and what `checked` compares the field against. register() returns the
|
|
827
|
+
// handler under its real key (`onchange`) and `checked` as a reactive thunk —
|
|
828
|
+
// the previous version hand rolled an onChange that called
|
|
829
|
+
// `registered.onInput`, a key register() has never defined, and compared a
|
|
830
|
+
// one-shot read of the field for `checked`.
|
|
831
|
+
// rest.ref is forwarded so register() can compose it with its own ref.
|
|
832
|
+
const registered = register
|
|
833
|
+
? register(props.name, { type: 'radio', value: radioValue, ref: rest.ref })
|
|
834
|
+
: {};
|
|
835
|
+
|
|
836
|
+
// Merged, NOT spread: `{ ...rest, ...registered }` silently dropped whatever
|
|
837
|
+
// handler the caller passed for an event the registration also handles, and
|
|
838
|
+
// the registration always carries onBlur/onFocus/onchange. onBlur is the one
|
|
839
|
+
// that hurts — it is what marks the field touched and drives mode:'onBlur'
|
|
840
|
+
// validation — and it disappeared without a trace.
|
|
841
|
+
return h('input', _mergeRegistration({
|
|
537
842
|
type: 'radio',
|
|
538
843
|
value: radioValue,
|
|
539
844
|
...rest,
|
|
540
|
-
|
|
541
|
-
onChange: (e) => {
|
|
542
|
-
if (e.target.checked && registered.onInput) {
|
|
543
|
-
registered.onInput({ target: { value: radioValue } });
|
|
544
|
-
}
|
|
545
|
-
},
|
|
546
|
-
});
|
|
845
|
+
}, registered));
|
|
547
846
|
}
|
|
548
847
|
|
|
549
848
|
// --- Form Error Display ---
|
package/src/head.js
CHANGED
|
@@ -130,7 +130,8 @@ function escapeHtml(str) {
|
|
|
130
130
|
function escapeSelectorValue(key) {
|
|
131
131
|
const s = String(key);
|
|
132
132
|
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(s);
|
|
133
|
-
|
|
133
|
+
// `c` is a single matched code unit, so codePointAt(0) is always defined.
|
|
134
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${/** @type {number} */ (c.codePointAt(0)).toString(16)} `);
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
function setHeadTag(tag, key, attrs) {
|
package/src/hooks.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// Components run ONCE. Hooks return signal accessors (functions) so the
|
|
4
4
|
// fine-grained runtime handles reactive updates automatically via effects.
|
|
5
5
|
|
|
6
|
-
import { signal, computed, effect, batch, untrack,
|
|
6
|
+
import { signal, computed, effect, batch, untrack, __DEV__ } from './reactive.js';
|
|
7
7
|
import { getCurrentComponent } from './dom.js';
|
|
8
|
-
import { getServerContext } from './server-context.js';
|
|
8
|
+
import { getServerContext, isServerRender } from './server-context.js';
|
|
9
9
|
import { getLoaderData as _getLoaderData, getResource as _getResource } from './hydration-data.js';
|
|
10
10
|
|
|
11
11
|
// --- useLoaderData ---
|
|
@@ -14,7 +14,18 @@ import { getLoaderData as _getLoaderData, getResource as _getResource } from './
|
|
|
14
14
|
// consolidated #__what_data payload). Intentionally NOT a component-scoped hook
|
|
15
15
|
// (no hook slot) so it is safe to call anywhere — components, effects, helpers.
|
|
16
16
|
export function useLoaderData() {
|
|
17
|
-
|
|
17
|
+
// The branch is on "is a server render in progress", NOT on "does a document
|
|
18
|
+
// exist". Those are different questions, and `typeof document === 'undefined'`
|
|
19
|
+
// answers the wrong one: jsdom and happy-dom are loaded process-wide by a huge
|
|
20
|
+
// number of test setups, and some SSR runtimes ship a DOM shim outright, so a
|
|
21
|
+
// REAL server render routinely sees a `document`. It then took the client path
|
|
22
|
+
// and returned the #__what_data payload left over from a PREVIOUS render
|
|
23
|
+
// instead of this request's loader result — the page silently served another
|
|
24
|
+
// request's data, and under concurrency, another user's.
|
|
25
|
+
//
|
|
26
|
+
// isServerRender() asks the render scope (see server-context.js), which is the
|
|
27
|
+
// same correction 0.12.0 applied to renderToString.
|
|
28
|
+
if (isServerRender()) {
|
|
18
29
|
const ctx = getServerContext();
|
|
19
30
|
return ctx ? ctx.loaderData : undefined;
|
|
20
31
|
}
|
|
@@ -127,7 +138,7 @@ export function useEffect(fn, deps) {
|
|
|
127
138
|
if (ctx.disposed) return;
|
|
128
139
|
hook.dispose = effect(() => {
|
|
129
140
|
if (hook.cleanup) {
|
|
130
|
-
try { hook.cleanup(); } catch
|
|
141
|
+
try { hook.cleanup(); } catch { /* cleanup error */ }
|
|
131
142
|
hook.cleanup = null;
|
|
132
143
|
}
|
|
133
144
|
const result = fn();
|
|
@@ -163,7 +174,7 @@ export function useEffect(fn, deps) {
|
|
|
163
174
|
|
|
164
175
|
// Run cleanup from previous execution
|
|
165
176
|
if (hook.cleanup) {
|
|
166
|
-
try { hook.cleanup(); } catch
|
|
177
|
+
try { hook.cleanup(); } catch { /* cleanup error */ }
|
|
167
178
|
hook.cleanup = null;
|
|
168
179
|
}
|
|
169
180
|
|
|
@@ -184,7 +195,7 @@ export function useEffect(fn, deps) {
|
|
|
184
195
|
// computed() auto-tracks signal dependencies.
|
|
185
196
|
// Returns a computed signal function (call it to read the value).
|
|
186
197
|
|
|
187
|
-
export function useMemo(fn,
|
|
198
|
+
export function useMemo(fn, _deps) {
|
|
188
199
|
const ctx = getCtx('useMemo');
|
|
189
200
|
const { index, exists } = getHook(ctx);
|
|
190
201
|
|
|
@@ -200,7 +211,7 @@ export function useMemo(fn, deps) {
|
|
|
200
211
|
// executes once, so the callback reference is inherently stable.
|
|
201
212
|
// Simply store and return the function on first call.
|
|
202
213
|
|
|
203
|
-
export function useCallback(fn,
|
|
214
|
+
export function useCallback(fn, _deps) {
|
|
204
215
|
const ctx = getCtx('useCallback');
|
|
205
216
|
const { index, exists } = getHook(ctx);
|
|
206
217
|
|
|
@@ -284,7 +295,7 @@ export function createContext(defaultValue) {
|
|
|
284
295
|
};
|
|
285
296
|
// The context value is only published once the provider body runs, so
|
|
286
297
|
// compiled children must not be built during this call. See createComponent.
|
|
287
|
-
context.Provider._deferChildren = true;
|
|
298
|
+
/** @type {any} */ (context.Provider)._deferChildren = true;
|
|
288
299
|
return context;
|
|
289
300
|
}
|
|
290
301
|
|
|
@@ -337,7 +348,13 @@ export function createResource(fetcher, options = {}) {
|
|
|
337
348
|
// --- Server branch: run the fetcher, cache by key on the render context, and
|
|
338
349
|
// suspend (throw the promise) so the nearest Suspense shows its fallback until
|
|
339
350
|
// the data resolves. On re-render the cached value is returned synchronously.
|
|
340
|
-
|
|
351
|
+
//
|
|
352
|
+
// Gated on the render scope for the same reason as useLoaderData above: under
|
|
353
|
+
// a DOM shim a server render used to fall into the CLIENT branch, which starts
|
|
354
|
+
// a browser-style fetch, returns null immediately and never suspends. The
|
|
355
|
+
// server then emitted the empty state as final HTML and renderToStringAsync
|
|
356
|
+
// had nothing pending to await, so the resolve loop exited after one pass.
|
|
357
|
+
if (isServerRender()) {
|
|
341
358
|
const ctx = getServerContext();
|
|
342
359
|
if (ctx) {
|
|
343
360
|
const key = options.key != null ? options.key : `__r${ctx.resourceCounter++}`;
|
|
@@ -435,14 +452,7 @@ export function createResource(fetcher, options = {}) {
|
|
|
435
452
|
return [data, { loading, error, refetch, mutate }];
|
|
436
453
|
}
|
|
437
454
|
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (!oldDeps || !newDeps) return true;
|
|
443
|
-
if (oldDeps.length !== newDeps.length) return true;
|
|
444
|
-
for (let i = 0; i < oldDeps.length; i++) {
|
|
445
|
-
if (!Object.is(oldDeps[i], newDeps[i])) return true;
|
|
446
|
-
}
|
|
447
|
-
return false;
|
|
448
|
-
}
|
|
455
|
+
// A local `depsChanged` lived here, commented "kept for potential external
|
|
456
|
+
// use". It was never exported, so there was no external use to keep it for,
|
|
457
|
+
// and nothing in this package called it. react-compat has its own copy, which
|
|
458
|
+
// is the one that is actually used.
|