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/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 isCheckbox = options.type === 'checkbox' || options.type === 'radio';
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 value = (e.target.type === 'checkbox' || e.target.type === 'radio')
157
- ? e.target.checked
158
- : e.target.value;
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 (isCheckbox) {
180
- // Checkbox/radio: use checked prop + onchange event
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
- const isFormValid = await validate();
258
-
259
- if (isFormValid) {
260
- await onValid(getAllValues());
261
- } else if (onInvalid) {
262
- onInvalid(getAllErrors(false));
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 e.errors || []) {
322
- const path = issue.path.join('.');
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
- for (const err of e.inner || []) {
340
- if (!errors[err.path]) {
341
- errors[err.path] = { type: err.type, message: err.message };
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
- return h('input', {
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
- checked: registered.value === radioValue,
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
- return s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c.codePointAt(0).toString(16)} `);
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, createRoot, __DEV__ } from './reactive.js';
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
- 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
  }
@@ -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 (e) { /* cleanup error */ }
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 (e) { /* cleanup error */ }
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, deps) {
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, deps) {
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
- 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++}`;
@@ -435,14 +452,7 @@ export function createResource(fetcher, options = {}) {
435
452
  return [data, { loading, error, refetch, mutate }];
436
453
  }
437
454
 
438
- // --- Dep comparison (kept for potential external use) ---
439
-
440
- function depsChanged(oldDeps, newDeps) {
441
- if (oldDeps === undefined) return true;
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.
package/src/index.js CHANGED
@@ -178,6 +178,7 @@ export {
178
178
  export {
179
179
  WhatError,
180
180
  ERROR_CODES,
181
+ getErrorDefinition,
181
182
  createWhatError,
182
183
  classifyError,
183
184
  collectError,