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/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
- constructor({ code, message, suggestion, file, line, component, signal, effect }) {
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 isCheckbox = options.type === 'checkbox' || options.type === 'radio';
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 value = (e.target.type === 'checkbox' || e.target.type === 'radio')
157
- ? e.target.checked
158
- : e.target.value;
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 (isCheckbox) {
180
- // Checkbox/radio: use checked prop + onchange event
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
- const isFormValid = await validate();
258
-
259
- if (isFormValid) {
260
- await onValid(getAllValues());
261
- } else if (onInvalid) {
262
- onInvalid(getAllErrors(false));
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 e.errors || []) {
322
- const path = issue.path.join('.');
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
- for (const err of e.inner || []) {
340
- if (!errors[err.path]) {
341
- errors[err.path] = { type: err.type, message: err.message };
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
- return h('input', {
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
- checked: registered.value === radioValue,
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
- if (typeof document === 'undefined') {
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
- if (typeof document === 'undefined') {
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 {