what-core 0.12.2 → 0.12.4
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-NCPX66TV.min.js → chunk-M5GDJRVX.min.js} +1 -1
- package/dist/chunk-T2SKNKT5.min.js +11 -0
- package/dist/index.min.js +5 -5
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/index.d.ts +329 -40
- package/package.json +1 -1
- package/src/a11y.js +234 -23
- package/src/agent-context.js +1 -1
- package/src/animation.js +8 -0
- package/src/data.js +730 -105
- package/src/dom.js +52 -0
- package/src/errors.js +12 -1
- package/src/form.js +329 -31
- package/src/hooks.js +20 -3
- package/src/index.js +6 -0
- package/src/render.js +872 -50
- package/src/scheduler.js +17 -0
- package/src/warnings.js +83 -0
- package/dist/chunk-RXISSKLI.min.js +0 -11
package/src/dom.js
CHANGED
|
@@ -405,6 +405,58 @@ export function getComponentStack() {
|
|
|
405
405
|
return componentStack;
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Run a component during SSR under a real component context.
|
|
410
|
+
*
|
|
411
|
+
* renderToString used to call `vnode.tag(props)` directly, with nothing on the
|
|
412
|
+
* component stack. Every hook that needs a context (useState, useSignal,
|
|
413
|
+
* useComputed, useEffect, useMemo, useCallback, useRef, useReducer, onMount,
|
|
414
|
+
* onCleanup, and Context.Provider) resolves it through getCurrentComponent(),
|
|
415
|
+
* so all of them threw on the server. A single useState anywhere in the tree
|
|
416
|
+
* meant the component could not be server-rendered at all: the page failed at
|
|
417
|
+
* render time, not with a hydration warning.
|
|
418
|
+
*
|
|
419
|
+
* The context is the same shape createComponent and the hydration path build,
|
|
420
|
+
* for the same reason: `useContext` walks `_parentCtx`, so a Provider's context
|
|
421
|
+
* has to stay on the stack while its children render. Hence the callback.
|
|
422
|
+
*
|
|
423
|
+
* Nothing here ever mounts, so nothing deferred may run. Every hook that defers
|
|
424
|
+
* work (useEffect in all three of its dep shapes) re-checks `ctx.disposed`
|
|
425
|
+
* inside its microtask, and onMount/onCleanup only collect callbacks that a
|
|
426
|
+
* mount would later invoke. _endComponentSSR marks the context disposed, which
|
|
427
|
+
* is what makes an SSR render leave no live effects behind.
|
|
428
|
+
*
|
|
429
|
+
* Begin/end rather than a wrapper callback because one of the three SSR call
|
|
430
|
+
* sites is a generator: renderToStream has to hold the frame open across yields
|
|
431
|
+
* until the subtree has finished streaming.
|
|
432
|
+
*
|
|
433
|
+
* Always pair these in a try/finally.
|
|
434
|
+
*/
|
|
435
|
+
export function _beginComponentSSR(Component) {
|
|
436
|
+
const ctx = {
|
|
437
|
+
hooks: [],
|
|
438
|
+
hookIndex: 0,
|
|
439
|
+
effects: [],
|
|
440
|
+
cleanups: [],
|
|
441
|
+
mounted: false,
|
|
442
|
+
disposed: false,
|
|
443
|
+
Component,
|
|
444
|
+
_parentCtx: componentStack[componentStack.length - 1] || null,
|
|
445
|
+
_errorBoundary: null,
|
|
446
|
+
};
|
|
447
|
+
componentStack.push(ctx);
|
|
448
|
+
return ctx;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function _endComponentSSR(ctx) {
|
|
452
|
+
const top = componentStack[componentStack.length - 1];
|
|
453
|
+
// Defensive: an async component that interleaved with another render could
|
|
454
|
+
// otherwise pop someone else's frame and silently reparent every context
|
|
455
|
+
// lookup after it.
|
|
456
|
+
if (top === ctx) componentStack.pop();
|
|
457
|
+
ctx.disposed = true;
|
|
458
|
+
}
|
|
459
|
+
|
|
408
460
|
// --- _installLazyChildren(Component, target, lazyChildren) ---
|
|
409
461
|
// Deferred children from compiled JSX arrive as a zero-arg factory instead of
|
|
410
462
|
// built DOM. This defines target.children over that factory and returns a
|
package/src/errors.js
CHANGED
|
@@ -163,11 +163,18 @@ catch (e) { if (e.name === 'RouterRedirect') return Response.redirect(e.to, 302)
|
|
|
163
163
|
// Structured error class with full context for agent consumption.
|
|
164
164
|
|
|
165
165
|
export class WhatError extends Error {
|
|
166
|
-
|
|
166
|
+
// codeExample carries the bad/good pair from the error's ERROR_CODES entry.
|
|
167
|
+
// Every entry above already had one; the class simply dropped it on the
|
|
168
|
+
// floor, so the field the docs promise on the serialized error was never
|
|
169
|
+
// there and `suggestion` had to carry the whole fix in prose. It matters more
|
|
170
|
+
// here than in a framework aimed at humans: the audience reading toJSON() is
|
|
171
|
+
// usually an agent, and a diff-shaped example is the part it can copy.
|
|
172
|
+
constructor({ code, message, suggestion, codeExample, file, line, component, signal, effect }) {
|
|
167
173
|
super(message);
|
|
168
174
|
this.name = 'WhatError';
|
|
169
175
|
this.code = code;
|
|
170
176
|
this.suggestion = suggestion;
|
|
177
|
+
this.codeExample = codeExample;
|
|
171
178
|
this.file = file;
|
|
172
179
|
this.line = line;
|
|
173
180
|
this.component = component;
|
|
@@ -180,6 +187,7 @@ export class WhatError extends Error {
|
|
|
180
187
|
code: this.code,
|
|
181
188
|
message: this.message,
|
|
182
189
|
suggestion: this.suggestion,
|
|
190
|
+
codeExample: this.codeExample,
|
|
183
191
|
file: this.file,
|
|
184
192
|
line: this.line,
|
|
185
193
|
component: this.component,
|
|
@@ -214,6 +222,9 @@ export function createWhatError(errorCode, context = {}) {
|
|
|
214
222
|
code: def.code,
|
|
215
223
|
message,
|
|
216
224
|
suggestion: def.suggestion,
|
|
225
|
+
// Verbatim from the definition: codeExample is a worked bad/good pair, not
|
|
226
|
+
// a template, so there is nothing in it to interpolate.
|
|
227
|
+
codeExample: def.codeExample,
|
|
217
228
|
file: context.file,
|
|
218
229
|
line: context.line,
|
|
219
230
|
component: context.component,
|
package/src/form.js
CHANGED
|
@@ -1,10 +1,72 @@
|
|
|
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
|
+
return function composedHandler(...args) {
|
|
25
|
+
registered.apply(this, args);
|
|
26
|
+
return callerHandler.apply(this, args);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Merge a register() result into caller props WITHOUT dropping the caller's
|
|
31
|
+
// event handlers. The obvious `{ ...props, ...registered }` silently loses every
|
|
32
|
+
// handler the two objects share, and it does not even need matching keys to do
|
|
33
|
+
// it: `onChange` and `onchange` are different object keys that resolve to the
|
|
34
|
+
// SAME DOM event, and setProp keys its listener bookkeeping by event name, so
|
|
35
|
+
// the second one applied replaces the first. That is how <Radio onBlur> stopped
|
|
36
|
+
// firing — the registration's onBlur always exists, so it always won.
|
|
37
|
+
// Non-event props keep plain spread semantics (the registration wins: it is what
|
|
38
|
+
// makes the input controlled).
|
|
39
|
+
function _mergeRegistration(props, registered) {
|
|
40
|
+
const merged = { ...props };
|
|
41
|
+
// Event identity, not key identity: strip the `on` prefix and lowercase, so
|
|
42
|
+
// onChange/onchange collapse together while onBlurCapture (capture phase — a
|
|
43
|
+
// genuinely different listener) stays separate. When the caller wrote two keys
|
|
44
|
+
// for one event, the LAST one wins in the DOM, and it is also the one left in
|
|
45
|
+
// this map, so that is the one the registration composes with.
|
|
46
|
+
const callerKeyByEvent = new Map();
|
|
47
|
+
for (const key in merged) {
|
|
48
|
+
if (_isEventProp(key)) callerKeyByEvent.set(key.slice(2).toLowerCase(), key);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const key in registered) {
|
|
52
|
+
const value = registered[key];
|
|
53
|
+
if (!_isEventProp(key)) {
|
|
54
|
+
merged[key] = value;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const callerKey = callerKeyByEvent.get(key.slice(2).toLowerCase());
|
|
58
|
+
if (callerKey === undefined) {
|
|
59
|
+
merged[key] = value;
|
|
60
|
+
callerKeyByEvent.set(key.slice(2).toLowerCase(), key);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Keep the caller's key so their props still read back the way they wrote
|
|
64
|
+
// them; the composed function underneath carries both handlers.
|
|
65
|
+
merged[callerKey] = _composeHandlers(value, merged[callerKey]);
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
}
|
|
69
|
+
|
|
8
70
|
// --- useForm Hook ---
|
|
9
71
|
// Complete form state management with validation
|
|
10
72
|
|
|
@@ -150,12 +212,77 @@ function createFormController(options = {}) {
|
|
|
150
212
|
// Register a field — only subscribes to THIS field's signal
|
|
151
213
|
function register(name, options = {}) {
|
|
152
214
|
const fieldSig = getFieldSignal(name);
|
|
153
|
-
const
|
|
215
|
+
const isRadio = options.type === 'radio';
|
|
216
|
+
const isCheckbox = options.type === 'checkbox';
|
|
217
|
+
// A radio does NOT store its own checkedness: every radio in a group shares
|
|
218
|
+
// one field, and that field holds the SELECTED option's value. A declared
|
|
219
|
+
// options.value is what this particular radio contributes, so it is both
|
|
220
|
+
// what the change handler writes and what `checked` compares the field
|
|
221
|
+
// against. radioValueOf() below covers the case where it was not declared.
|
|
222
|
+
const hasRadioValue = isRadio && options.value !== undefined;
|
|
223
|
+
|
|
224
|
+
// The value THIS radio contributes to the shared field. A declared
|
|
225
|
+
// options.value wins; otherwise the element's own `value` ATTRIBUTE is the
|
|
226
|
+
// contribution, which is exactly how a plain HTML radio group works.
|
|
227
|
+
// undefined means "this radio has nothing to contribute": the DOM reports
|
|
228
|
+
// the default "on" for EVERY valueless radio, so a group of them could not
|
|
229
|
+
// represent a choice, and a `checked` binding derived from it would light up
|
|
230
|
+
// every radio in the group at once.
|
|
231
|
+
function radioValueOf(el) {
|
|
232
|
+
if (hasRadioValue) return options.value;
|
|
233
|
+
if (!el) return undefined;
|
|
234
|
+
// Only a real element can tell `value=""` apart from "no value attribute";
|
|
235
|
+
// the `value` IDL property reports "on" for the second case and cannot.
|
|
236
|
+
if (typeof el.hasAttribute === 'function') {
|
|
237
|
+
return el.hasAttribute('value') ? el.value : undefined;
|
|
238
|
+
}
|
|
239
|
+
return el.value; // synthetic target (tests, custom widgets)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Warn-once latches: register() runs once per input, so these fire once per
|
|
243
|
+
// offending input rather than once per keystroke or click.
|
|
244
|
+
let warnedValuelessRadio = false;
|
|
245
|
+
let warnedUntypedRadio = false;
|
|
154
246
|
|
|
155
247
|
const handler = (e) => {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
248
|
+
const target = e && e.target;
|
|
249
|
+
// NOT `|| {}`: a missing target used to read as `{}`, whose `.value` is
|
|
250
|
+
// undefined, so calling the handler without an event quietly OVERWROTE the
|
|
251
|
+
// field with undefined. Refuse instead — there is no value to apply.
|
|
252
|
+
if (!target) {
|
|
253
|
+
if (__DEV__) {
|
|
254
|
+
console.warn(
|
|
255
|
+
`[what] register("${name}") change handler was called without an event. ` +
|
|
256
|
+
'Pass the DOM event through (or call form.setValue() directly). Ignoring.'
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// options.type is the declared intent; target.type covers a bare
|
|
262
|
+
// register('x') spread straight onto a checkbox or radio input.
|
|
263
|
+
const type = options.type || target.type;
|
|
264
|
+
let value;
|
|
265
|
+
if (type === 'radio') {
|
|
266
|
+
// NOT target.checked: a radio's change event only ever fires when it
|
|
267
|
+
// becomes checked, so `true` carries no information. The field wants the
|
|
268
|
+
// value of whichever radio in the group was picked.
|
|
269
|
+
value = radioValueOf(target);
|
|
270
|
+
if (value === undefined) {
|
|
271
|
+
// Nothing identifies this radio, so any write would be a guess.
|
|
272
|
+
if (__DEV__ && !warnedValuelessRadio) {
|
|
273
|
+
warnedValuelessRadio = true;
|
|
274
|
+
console.warn(
|
|
275
|
+
`[what] register("${name}", { type: 'radio' }) cannot tell which value this radio ` +
|
|
276
|
+
'contributes: pass { value } or give the <input> a value attribute. Field left unchanged.'
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
} else if (type === 'checkbox') {
|
|
282
|
+
value = target.checked;
|
|
283
|
+
} else {
|
|
284
|
+
value = target.value;
|
|
285
|
+
}
|
|
159
286
|
setValue(name, value);
|
|
160
287
|
|
|
161
288
|
if (mode === 'onChange' || (isSubmitted.peek() && reValidateMode === 'onChange')) {
|
|
@@ -176,8 +303,57 @@ function createFormController(options = {}) {
|
|
|
176
303
|
ref: options.ref,
|
|
177
304
|
};
|
|
178
305
|
|
|
179
|
-
if (
|
|
180
|
-
//
|
|
306
|
+
if (isRadio) {
|
|
307
|
+
// Without a declared value this thunk must report FALSE, never `!!field`:
|
|
308
|
+
// the contribution can only come from the element, which a prop thunk does
|
|
309
|
+
// not have, and `!!field` is true for every radio in the group at once, so
|
|
310
|
+
// picking any option rendered ALL of them checked. The ref below fixes up
|
|
311
|
+
// the real DOM once it does have the element.
|
|
312
|
+
const isChecked = hasRadioValue
|
|
313
|
+
? () => fieldSig() === options.value
|
|
314
|
+
: () => false;
|
|
315
|
+
|
|
316
|
+
if (hasRadioValue) result.value = options.value;
|
|
317
|
+
// A FUNCTION, not the getter the checkbox branch uses: every consumer
|
|
318
|
+
// spreads this object (`<Radio>` does, and so does hand-written
|
|
319
|
+
// `{...register(...)}`), and a spread RESOLVES a getter into a one-shot
|
|
320
|
+
// value that never updates again. A function prop is the framework's
|
|
321
|
+
// reactive-binding protocol — setProp() wraps it in an effect, and the SSR
|
|
322
|
+
// serializer calls it — so one thunk covers both renderers.
|
|
323
|
+
result.checked = isChecked;
|
|
324
|
+
result.onchange = handler;
|
|
325
|
+
result.ref = (el) => {
|
|
326
|
+
_applyRef(options.ref, el);
|
|
327
|
+
if (!el || typeof el !== 'object') return;
|
|
328
|
+
|
|
329
|
+
// The reactive `checked` prop above only stamps the checked CONTENT
|
|
330
|
+
// ATTRIBUTE, and the attribute stops controlling checkedness the moment
|
|
331
|
+
// the DOM sets an input's "dirty checkedness" flag, which the user's
|
|
332
|
+
// first click does permanently. From then on, re-selecting that radio
|
|
333
|
+
// through form state would set the attribute and change nothing on
|
|
334
|
+
// screen. Writing the checked PROPERTY is what a controlled radio needs
|
|
335
|
+
// (React and Solid do the same) and it also runs the DOM's radio-group
|
|
336
|
+
// invariant that unchecks the siblings.
|
|
337
|
+
// Disposal rides on the el._propEffects convention that dom.js and
|
|
338
|
+
// render.js both walk on unmount; the ':property' suffix keeps it from
|
|
339
|
+
// colliding with setProp's own effect for the `checked` attribute.
|
|
340
|
+
if (!el._propEffects) el._propEffects = {};
|
|
341
|
+
const key = 'checked:property';
|
|
342
|
+
if (el._propEffects[key]) {
|
|
343
|
+
try { el._propEffects[key](); } catch (err) { /* already disposed */ }
|
|
344
|
+
}
|
|
345
|
+
// radioValueOf(el) is re-read on every run rather than captured, so a
|
|
346
|
+
// registration without a declared value still binds correctly once the
|
|
347
|
+
// element's own value attribute is in place, and an unidentifiable radio
|
|
348
|
+
// is simply never checked instead of being checked alongside its
|
|
349
|
+
// siblings.
|
|
350
|
+
el._propEffects[key] = effect(() => {
|
|
351
|
+
const own = radioValueOf(el);
|
|
352
|
+
el.checked = own !== undefined && fieldSig() === own;
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
} else if (isCheckbox) {
|
|
356
|
+
// Checkbox: use checked prop + onchange event
|
|
181
357
|
Object.defineProperty(result, 'checked', {
|
|
182
358
|
get() { return !!fieldSig(); },
|
|
183
359
|
enumerable: true,
|
|
@@ -190,6 +366,35 @@ function createFormController(options = {}) {
|
|
|
190
366
|
enumerable: true,
|
|
191
367
|
});
|
|
192
368
|
result.oninput = handler;
|
|
369
|
+
result.ref = (el) => {
|
|
370
|
+
_applyRef(options.ref, el);
|
|
371
|
+
// An UNTYPED registration spread onto a radio cannot be rescued, and it
|
|
372
|
+
// fails silently, so say so at mount. The `value` binding above resolves
|
|
373
|
+
// during the spread and overwrites the input's own value with the FIELD's
|
|
374
|
+
// value — and for a radio that own value IS this option's contribution to
|
|
375
|
+
// the group. It is gone for good: `value` on a radio is in the DOM's
|
|
376
|
+
// "default/on" mode, so writing the property also rewrites the value
|
|
377
|
+
// CONTENT ATTRIBUTE, which takes defaultValue and getAttribute('value')
|
|
378
|
+
// down with it. Nothing is left to recover the real value from, so the
|
|
379
|
+
// registration has to be told it is a radio up front.
|
|
380
|
+
// Checking el.type HERE, from the ref, is also what keeps the warning
|
|
381
|
+
// honest: props apply in key order, so with the spread written FIRST
|
|
382
|
+
// (`{...register('u'), type: 'radio', value: 'two'}`) this ref runs
|
|
383
|
+
// before `type` and `value` land, the literal value survives, the write
|
|
384
|
+
// is correct, and el.type is not yet 'radio' — so nothing is warned
|
|
385
|
+
// about. The warning fires exactly when our binding really did overwrite
|
|
386
|
+
// a radio's value.
|
|
387
|
+
if (__DEV__ && !warnedUntypedRadio && el && el.type === 'radio') {
|
|
388
|
+
warnedUntypedRadio = true;
|
|
389
|
+
console.warn(
|
|
390
|
+
`[what] register("${name}") was spread onto <input type="radio"> without a type. ` +
|
|
391
|
+
'Radios in a group share one field holding the SELECTED option, so the registration ' +
|
|
392
|
+
`must be told this radio's value: register("${name}", { type: 'radio', value: ... }), ` +
|
|
393
|
+
'or use <Radio name value register />. As written, the registration overwrote the ' +
|
|
394
|
+
"input's own value and the group cannot record a choice."
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
193
398
|
}
|
|
194
399
|
|
|
195
400
|
return result;
|
|
@@ -254,15 +459,22 @@ function createFormController(options = {}) {
|
|
|
254
459
|
isSubmitted.set(true);
|
|
255
460
|
submitCount.set(submitCount.peek() + 1);
|
|
256
461
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
462
|
+
// try/finally, because anything that throws between here and the release
|
|
463
|
+
// leaves isSubmitting stuck true forever, which in practice means a submit
|
|
464
|
+
// button disabled for the rest of the page's life. Throwers include a
|
|
465
|
+
// rejected onValid handler and a resolver that rethrows a non-validation
|
|
466
|
+
// failure (see zodResolver/yupResolver: they fail CLOSED by design).
|
|
467
|
+
try {
|
|
468
|
+
const isFormValid = await validate();
|
|
469
|
+
|
|
470
|
+
if (isFormValid) {
|
|
471
|
+
await onValid(getAllValues());
|
|
472
|
+
} else if (onInvalid) {
|
|
473
|
+
onInvalid(getAllErrors(false));
|
|
474
|
+
}
|
|
475
|
+
} finally {
|
|
476
|
+
isSubmitting.set(false);
|
|
263
477
|
}
|
|
264
|
-
|
|
265
|
-
isSubmitting.set(false);
|
|
266
478
|
};
|
|
267
479
|
}
|
|
268
480
|
|
|
@@ -311,34 +523,94 @@ function createFormController(options = {}) {
|
|
|
311
523
|
|
|
312
524
|
// --- Validation Resolvers ---
|
|
313
525
|
|
|
526
|
+
// A resolver that cannot read its library's error shape must never answer "no
|
|
527
|
+
// errors": handleSubmit() reads an empty error map as VALID and submits the
|
|
528
|
+
// form, so a shape mismatch turns validation off silently. Every resolver below
|
|
529
|
+
// therefore fails CLOSED — it reports errors, or it rethrows.
|
|
530
|
+
|
|
531
|
+
// Zod moved the issue list between majors: v3 exposes it as BOTH `.issues` and
|
|
532
|
+
// the legacy `.errors` alias, v4 dropped `.errors` and keeps only `.issues`.
|
|
533
|
+
// Reading `.errors` alone collected NOTHING under Zod 4, so every invalid form
|
|
534
|
+
// submitted. `.issues` is canonical in both majors, so try it first.
|
|
535
|
+
function _zodIssues(err) {
|
|
536
|
+
if (!err || typeof err !== 'object') return null;
|
|
537
|
+
if (Array.isArray(err.issues)) return err.issues;
|
|
538
|
+
if (Array.isArray(err.errors)) return err.errors;
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Zod types issue paths as PropertyKey[], so a record keyed by a symbol yields a
|
|
543
|
+
// symbol segment — and Array#join would throw on it (ToString of a symbol is a
|
|
544
|
+
// TypeError), losing every issue in the list including the ones we could report.
|
|
545
|
+
// Numbers (array indices) stringify the same in both majors.
|
|
546
|
+
function _issuePath(issue) {
|
|
547
|
+
const path = issue?.path;
|
|
548
|
+
if (path == null) return '';
|
|
549
|
+
if (!Array.isArray(path)) return String(path);
|
|
550
|
+
let out = '';
|
|
551
|
+
for (let i = 0; i < path.length; i++) {
|
|
552
|
+
const segment = path[i];
|
|
553
|
+
out += (i ? '.' : '') + (typeof segment === 'symbol' ? segment.toString() : String(segment));
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
|
|
314
558
|
export function zodResolver(schema) {
|
|
315
559
|
return async (values) => {
|
|
316
560
|
try {
|
|
317
561
|
const result = await schema.parseAsync(values);
|
|
318
562
|
return { values: result, errors: {} };
|
|
319
563
|
} catch (e) {
|
|
564
|
+
const issues = _zodIssues(e);
|
|
565
|
+
// Not a ZodError at all: a TypeError from a mis-built schema, a fetch that
|
|
566
|
+
// failed inside an async .refine(), a bug in zod itself. Swallowing it here
|
|
567
|
+
// reported the form as valid; rethrowing surfaces it, which is what
|
|
568
|
+
// @hookform/resolvers does for the same case.
|
|
569
|
+
if (!issues) throw e;
|
|
570
|
+
|
|
320
571
|
const errors = {};
|
|
321
|
-
for (const issue of
|
|
322
|
-
const path = issue
|
|
572
|
+
for (const issue of issues) {
|
|
573
|
+
const path = _issuePath(issue);
|
|
323
574
|
if (!errors[path]) {
|
|
324
|
-
errors[path] = { type: issue.code, message: issue.message };
|
|
575
|
+
errors[path] = { type: issue.code ?? 'validation', message: issue.message };
|
|
325
576
|
}
|
|
326
577
|
}
|
|
578
|
+
// parseAsync threw, so the input is NOT valid. An empty map here (an issue
|
|
579
|
+
// list we could not interpret) would read as valid, so keep the form
|
|
580
|
+
// blocked with a root-level error instead.
|
|
581
|
+
if (Object.keys(errors).length === 0) {
|
|
582
|
+
errors[''] = { type: 'validation', message: e?.message || 'Validation failed' };
|
|
583
|
+
}
|
|
327
584
|
return { values: {}, errors };
|
|
328
585
|
}
|
|
329
586
|
};
|
|
330
587
|
}
|
|
331
588
|
|
|
589
|
+
// yup reports the per-field failures on `inner` when abortEarly is false, but a
|
|
590
|
+
// failure raised at the ROOT (a non-object schema, or a test that reported a
|
|
591
|
+
// bare message) arrives with `inner` empty and the path/message on the error
|
|
592
|
+
// itself. Anything without a yup error shape is not a validation failure.
|
|
593
|
+
function _isYupValidationError(err) {
|
|
594
|
+
if (!err || typeof err !== 'object') return false;
|
|
595
|
+
return err.name === 'ValidationError' || Array.isArray(err.inner);
|
|
596
|
+
}
|
|
597
|
+
|
|
332
598
|
export function yupResolver(schema) {
|
|
333
599
|
return async (values) => {
|
|
334
600
|
try {
|
|
335
601
|
const result = await schema.validate(values, { abortEarly: false });
|
|
336
602
|
return { values: result, errors: {} };
|
|
337
603
|
} catch (e) {
|
|
604
|
+
// Same fail-closed rule as zodResolver: a crash inside a yup test must not
|
|
605
|
+
// be reported to handleSubmit() as "this form is fine".
|
|
606
|
+
if (!_isYupValidationError(e)) throw e;
|
|
607
|
+
|
|
338
608
|
const errors = {};
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
609
|
+
const inner = Array.isArray(e.inner) && e.inner.length > 0 ? e.inner : [e];
|
|
610
|
+
for (const err of inner) {
|
|
611
|
+
const path = err.path ?? '';
|
|
612
|
+
if (!errors[path]) {
|
|
613
|
+
errors[path] = { type: err.type ?? 'validation', message: err.message };
|
|
342
614
|
}
|
|
343
615
|
}
|
|
344
616
|
return { values: {}, errors };
|
|
@@ -531,19 +803,45 @@ export function Checkbox(props) {
|
|
|
531
803
|
|
|
532
804
|
export function Radio(props) {
|
|
533
805
|
const { register, value: radioValue, ...rest } = props;
|
|
534
|
-
const registered = register ? register(props.name) : {};
|
|
535
806
|
|
|
536
|
-
|
|
807
|
+
// A radio with no `value` is a programming error, not a default. The group's
|
|
808
|
+
// field holds the SELECTED option's value, and the DOM reports the default
|
|
809
|
+
// "on" for every valueless radio, so registering one would write "on" into
|
|
810
|
+
// form state and then check every radio in the group that reads it back.
|
|
811
|
+
// Render a plain unregistered input instead: inert, but honest.
|
|
812
|
+
if (radioValue === undefined) {
|
|
813
|
+
if (__DEV__) {
|
|
814
|
+
console.warn(
|
|
815
|
+
`[what] <Radio name="${props.name}"> is missing a \`value\` prop. Radios in a group share ` +
|
|
816
|
+
"one field holding the selected option's value, so a valueless radio has nothing to " +
|
|
817
|
+
'contribute and cannot be checked. Rendering it unregistered.'
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
return h('input', { type: 'radio', ...rest });
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// The registration has to carry THIS radio's value: radios in a group share
|
|
824
|
+
// one field, so the value is what the change handler writes into form state
|
|
825
|
+
// and what `checked` compares the field against. register() returns the
|
|
826
|
+
// handler under its real key (`onchange`) and `checked` as a reactive thunk —
|
|
827
|
+
// the previous version hand rolled an onChange that called
|
|
828
|
+
// `registered.onInput`, a key register() has never defined, and compared a
|
|
829
|
+
// one-shot read of the field for `checked`.
|
|
830
|
+
// rest.ref is forwarded so register() can compose it with its own ref.
|
|
831
|
+
const registered = register
|
|
832
|
+
? register(props.name, { type: 'radio', value: radioValue, ref: rest.ref })
|
|
833
|
+
: {};
|
|
834
|
+
|
|
835
|
+
// Merged, NOT spread: `{ ...rest, ...registered }` silently dropped whatever
|
|
836
|
+
// handler the caller passed for an event the registration also handles, and
|
|
837
|
+
// the registration always carries onBlur/onFocus/onchange. onBlur is the one
|
|
838
|
+
// that hurts — it is what marks the field touched and drives mode:'onBlur'
|
|
839
|
+
// validation — and it disappeared without a trace.
|
|
840
|
+
return h('input', _mergeRegistration({
|
|
537
841
|
type: 'radio',
|
|
538
842
|
value: radioValue,
|
|
539
843
|
...rest,
|
|
540
|
-
|
|
541
|
-
onChange: (e) => {
|
|
542
|
-
if (e.target.checked && registered.onInput) {
|
|
543
|
-
registered.onInput({ target: { value: radioValue } });
|
|
544
|
-
}
|
|
545
|
-
},
|
|
546
|
-
});
|
|
844
|
+
}, registered));
|
|
547
845
|
}
|
|
548
846
|
|
|
549
847
|
// --- Form Error Display ---
|
package/src/hooks.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { signal, computed, effect, batch, untrack, createRoot, __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
|
}
|
|
@@ -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++}`;
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,9 @@ export { signal, computed, effect, memo as signalMemo, batch, untrack, flushSync
|
|
|
6
6
|
|
|
7
7
|
// Fine-grained rendering primitives
|
|
8
8
|
export { template, _template, _$template, svgTemplate, insert, mapArray, spread, setProp, delegateEvents, on, classList, hydrate, isHydrating, _$createComponent } from './render.js';
|
|
9
|
+
// Internal, underscore-prefixed: SSR has no DOM to run a mapArray inserter
|
|
10
|
+
// against, so it renders the rows from the inserter's inputs instead.
|
|
11
|
+
export { _mapArrayToArray } from './render.js';
|
|
9
12
|
|
|
10
13
|
// JSX factory — Fragment and html tagged template are public APIs.
|
|
11
14
|
// h is exported for internal package use only (jsx-runtime, server, router, react-compat).
|
|
@@ -17,6 +20,9 @@ export { mount } from './dom.js';
|
|
|
17
20
|
// Internal, underscore-prefixed: shared so the client, compiled-JSX and SSR
|
|
18
21
|
// attribute paths cannot disagree about ARIA serialization again.
|
|
19
22
|
export { _isAriaAttr } from './dom.js';
|
|
23
|
+
// Internal, underscore-prefixed: the component stack lives here, so SSR has to
|
|
24
|
+
// borrow it rather than build a second one that hooks cannot see.
|
|
25
|
+
export { _beginComponentSSR, _endComponentSSR } from './dom.js';
|
|
20
26
|
|
|
21
27
|
// Hooks (React-compatible API)
|
|
22
28
|
export {
|