cursedbelt 5.0.0 → 5.0.1

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.
Files changed (63) hide show
  1. package/dist/react/emoji/EmojiPicker.d.ts.map +1 -1
  2. package/dist/react/emoji/EmojiPicker.js +49 -8
  3. package/dist/react/emoji/EmojiPicker.js.map +1 -1
  4. package/dist/react/lib/form.d.ts +2 -2
  5. package/dist/react/lib/form.d.ts.map +1 -1
  6. package/dist/react/lib/form.js +4 -41
  7. package/dist/react/lib/form.js.map +1 -1
  8. package/dist/react/lib/zodResolver.d.ts +9 -0
  9. package/dist/react/lib/zodResolver.d.ts.map +1 -0
  10. package/dist/react/lib/zodResolver.js +105 -0
  11. package/dist/react/lib/zodResolver.js.map +1 -0
  12. package/dist/styles-areas/analytics.css +1 -1
  13. package/dist/styles-areas/auth.css +1 -1
  14. package/dist/styles-areas/charts.css +1 -1
  15. package/dist/styles-areas/chat.css +1 -1
  16. package/dist/styles-areas/core.css +1 -1
  17. package/dist/styles-areas/dashboard-grid.css +1 -1
  18. package/dist/styles-areas/data-table.css +1 -1
  19. package/dist/styles-areas/deep-link.css +1 -1
  20. package/dist/styles-areas/emoji.css +1 -1
  21. package/dist/styles-areas/fields.css +1 -1
  22. package/dist/styles-areas/file-tree.css +1 -1
  23. package/dist/styles-areas/keep-awake.css +1 -1
  24. package/dist/styles-areas/layout-engine.css +1 -1
  25. package/dist/styles-areas/markdown.css +1 -1
  26. package/dist/styles-areas/media-gallery.css +1 -1
  27. package/dist/styles-areas/media.css +1 -1
  28. package/dist/styles-areas/nav.css +1 -1
  29. package/dist/styles-areas/overlays.css +1 -1
  30. package/dist/styles-areas/palette.css +1 -1
  31. package/dist/styles-areas/stats.css +1 -1
  32. package/dist/styles-areas/virtual.css +1 -1
  33. package/dist/styles-areas/wizard.css +1 -1
  34. package/dist/styles-areas/workbench.css +1 -1
  35. package/package.json +16 -5
  36. package/src/react/emoji/EmojiPicker.tsx +70 -19
  37. package/src/react/lib/form.ts +5 -48
  38. package/src/react/lib/zodResolver.spec.ts +82 -0
  39. package/src/react/lib/zodResolver.ts +136 -0
  40. package/src/styles-areas/analytics.css +1 -1
  41. package/src/styles-areas/auth.css +1 -1
  42. package/src/styles-areas/charts.css +1 -1
  43. package/src/styles-areas/chat.css +1 -1
  44. package/src/styles-areas/core.css +1 -1
  45. package/src/styles-areas/dashboard-grid.css +1 -1
  46. package/src/styles-areas/data-table.css +1 -1
  47. package/src/styles-areas/deep-link.css +1 -1
  48. package/src/styles-areas/emoji.css +1 -1
  49. package/src/styles-areas/fields.css +1 -1
  50. package/src/styles-areas/file-tree.css +1 -1
  51. package/src/styles-areas/keep-awake.css +1 -1
  52. package/src/styles-areas/layout-engine.css +1 -1
  53. package/src/styles-areas/markdown.css +1 -1
  54. package/src/styles-areas/media-gallery.css +1 -1
  55. package/src/styles-areas/media.css +1 -1
  56. package/src/styles-areas/nav.css +1 -1
  57. package/src/styles-areas/overlays.css +1 -1
  58. package/src/styles-areas/palette.css +1 -1
  59. package/src/styles-areas/stats.css +1 -1
  60. package/src/styles-areas/virtual.css +1 -1
  61. package/src/styles-areas/wizard.css +1 -1
  62. package/src/styles-areas/workbench.css +1 -1
  63. package/src/unpeeredStaysOffTheBarrel.spec.ts +99 -0
@@ -1,15 +1,68 @@
1
- import { lazy, type ReactNode, Suspense, useMemo } from 'react';
1
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { IconButton } from '../components/IconButton.js';
3
3
  import { Popover } from '../components/Popover.js';
4
4
  import { Skeleton } from '../components/Skeleton.js';
5
5
  import { resolveEmojiTheme } from './emojiSearch.js';
6
6
 
7
- // The emoji-mart React picker + its data are BOTH lazy: the component code-splits
8
- // (only loaded when the panel first mounts) and the ~300 KB data is fetched via the
9
- // async `data` loader (never statically imported — that would tax every page).
10
- const Picker = lazy(() => import('@emoji-mart/react'));
7
+ // The emoji-mart engine and its ~300 KB data are BOTH lazy: nothing loads until the panel
8
+ // first mounts, and the data is fetched through emoji-mart's async `data` loader.
11
9
  const loadEmojiData = () => import('@emoji-mart/data').then((m) => m.default);
12
10
 
11
+ /**
12
+ * 🔴 emoji-mart's `Picker` custom element, mounted directly — NOT through `@emoji-mart/react`
13
+ * (cursedbelt 5.0.1). That wrapper is twenty lines (`new Picker({ ...props, ref })` into a div),
14
+ * and depending on it cost every consumer something either way: as an optional peer it made
15
+ * `npm install cursedbelt` fail ERESOLVE (it peers `react <=18`; task 2087), and as `unpeered`
16
+ * (5.0.0) a literal `import('@emoji-mart/react')` broke every clean-install `vite build` that
17
+ * imports `cursedbelt/react`, because Vite stubs a missing import only for a DECLARED optional
18
+ * peer. `emoji-mart` itself IS a declared optional peer, so a consumer that never renders this
19
+ * builds green with it absent, and one that does gets a named error rather than a blank panel.
20
+ */
21
+ function EmojiMartPanel(props: Record<string, unknown>) {
22
+ const host = useRef<HTMLDivElement>(null);
23
+ const picker = useRef<{ update?: (props: object) => void } | null>(null);
24
+ const latest = useRef(props);
25
+ latest.current = props;
26
+ const [ready, setReady] = useState(false);
27
+ const [failed, setFailed] = useState<Error | null>(null);
28
+
29
+ useEffect(() => {
30
+ let live = true;
31
+ import('emoji-mart').then(
32
+ ({ Picker }) => {
33
+ if (!live || !host.current) return;
34
+ picker.current = new Picker({ ...latest.current, ref: host }) as { update?: (props: object) => void };
35
+ setReady(true);
36
+ },
37
+ (cause: unknown) => {
38
+ if (live) {
39
+ setFailed(
40
+ new Error("cursedbelt: EmojiPicker needs the optional peer 'emoji-mart' (and '@emoji-mart/data') — run `bun add emoji-mart @emoji-mart/data` in the app that renders it.", { cause }),
41
+ );
42
+ }
43
+ },
44
+ );
45
+ return () => {
46
+ live = false;
47
+ picker.current = null;
48
+ host.current?.replaceChildren();
49
+ };
50
+ }, []);
51
+
52
+ // The wrapper's contract: every render pushes the current props into the live element.
53
+ useEffect(() => {
54
+ picker.current?.update?.(props);
55
+ });
56
+
57
+ if (failed) throw failed;
58
+ return (
59
+ <>
60
+ {!ready && <Skeleton className='h-[420px] w-[352px]' />}
61
+ <div ref={host} />
62
+ </>
63
+ );
64
+ }
65
+
13
66
  interface EmojiMartSelectEvent {
14
67
  native: string;
15
68
  id: string;
@@ -37,7 +90,7 @@ export interface EmojiPickerProps {
37
90
  onOpenChange?: (open: boolean) => void;
38
91
  }
39
92
 
40
- /** The emoji-mart grid itself, themed + lazy, behind a Suspense skeleton. */
93
+ /** The emoji-mart grid itself, themed + lazy, behind a skeleton until the engine has loaded. */
41
94
  function EmojiPanel({
42
95
  onSelect,
43
96
  theme = 'auto',
@@ -47,19 +100,17 @@ function EmojiPanel({
47
100
  }: Pick<EmojiPickerProps, 'onSelect' | 'theme' | 'locale' | 'previewPosition' | 'set'>) {
48
101
  const resolvedTheme = useMemo(() => resolveEmojiTheme(theme), [theme]);
49
102
  return (
50
- <Suspense fallback={<Skeleton className='h-[420px] w-[352px]' />}>
51
- <Picker
52
- data={loadEmojiData}
53
- onEmojiSelect={(e: EmojiMartSelectEvent) => onSelect(e.native)}
54
- theme={resolvedTheme}
55
- locale={locale}
56
- previewPosition={previewPosition}
57
- set={set}
58
- navPosition='top'
59
- skinTonePosition='search'
60
- dynamicWidth={false}
61
- />
62
- </Suspense>
103
+ <EmojiMartPanel
104
+ data={loadEmojiData}
105
+ onEmojiSelect={(e: EmojiMartSelectEvent) => onSelect(e.native)}
106
+ theme={resolvedTheme}
107
+ locale={locale}
108
+ previewPosition={previewPosition}
109
+ set={set}
110
+ navPosition='top'
111
+ skinTonePosition='search'
112
+ dynamicWidth={false}
113
+ />
63
114
  );
64
115
  }
65
116
 
@@ -1,36 +1,6 @@
1
- import type { DefaultValues, FieldValues, Resolver, UseFormProps } from 'react-hook-form';
1
+ import type { DefaultValues, FieldValues, UseFormProps } from 'react-hook-form';
2
2
  import type { z } from 'zod';
3
-
4
- /**
5
- * 🔴 `@hookform/resolvers` is an OPTIONAL peer, and this dynamic import is what makes it
6
- * one (task 149). `cursedbelt/react` is the barrel all eight apps import; a static
7
- * `import { zodResolver } from '@hookform/resolvers/zod'` at the top of this file put the
8
- * package — and its own dependency tree — into every one of those installs, to serve the
9
- * one function below. `src/barrelsReachNoOptionalPeer.spec.ts` is the check that keeps it
10
- * out, and a `await import(...)` inside the function that needs it is the shape that spec
11
- * blesses.
12
- *
13
- * Resolved once per process and memoized: RHF calls the resolver on every validation
14
- * pass, and `zodResolver(schema)` is cheap but the module load is not.
15
- */
16
- let zodResolverModule: Promise<typeof import('@hookform/resolvers/zod')> | undefined;
17
-
18
- const loadZodResolver = async (): Promise<typeof import('@hookform/resolvers/zod')> => {
19
- zodResolverModule ??= import('@hookform/resolvers/zod');
20
- try {
21
- return await zodResolverModule;
22
- } catch (cause) {
23
- // 🔴 Loud and actionable, not an unhandled rejection three frames into RHF. A missing
24
- // optional peer surfaces here as "the form never validates", which is the worst
25
- // possible thing to debug without the package name in the message.
26
- zodResolverModule = undefined;
27
- throw new Error(
28
- "cursedbelt: makeFormConfig() needs the optional peer '@hookform/resolvers' — " +
29
- 'run `bun add @hookform/resolvers` in the app that renders this form.',
30
- { cause },
31
- );
32
- }
33
- };
3
+ import { zodResolver } from './zodResolver.js';
34
4
 
35
5
  /**
36
6
  * The canonical react-hook-form config for every cursedbelt form (Wizard,
@@ -53,27 +23,14 @@ const loadZodResolver = async (): Promise<typeof import('@hookform/resolvers/zod
53
23
  * const form = useForm<Values>(makeFormConfig(schema, { name: '', tags: [] }));
54
24
  * ```
55
25
  *
56
- * 🔴 Needs the optional peer `@hookform/resolvers` installed in the app — RHF resolvers
57
- * are already async, so the load happens on the first validation rather than at import.
26
+ * The resolver is this package's own (`./zodResolver.ts`, 5.0.1) — there is nothing for the
27
+ * app to install, and nothing for a bundler to fail to resolve.
58
28
  */
59
29
  export function makeFormConfig<TFieldValues extends FieldValues>(
60
30
  schema: z.ZodType,
61
31
  defaults: TFieldValues,
62
32
  ): UseFormProps<TFieldValues> {
63
- // Built once per form, then reused across every validation pass.
64
- let resolved: Resolver<TFieldValues> | undefined;
65
- const resolver: Resolver<TFieldValues> = async (values, context, options) => {
66
- if (!resolved) {
67
- const { zodResolver } = await loadZodResolver();
68
- // zodResolver's generics don't line up with the caller's value type (the schema
69
- // input/output may differ from TFieldValues); the runtime resolver is correct, so
70
- // cast through the overload's param/return types rather than over-genericize.
71
- resolved = zodResolver(
72
- schema as Parameters<typeof zodResolver>[0],
73
- ) as unknown as Resolver<TFieldValues>;
74
- }
75
- return resolved(values, context, options);
76
- };
33
+ const resolver = zodResolver<TFieldValues>(schema);
77
34
 
78
35
  return {
79
36
  resolver,
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 🔴 `./zodResolver.ts` must answer EXACTLY what `@hookform/resolvers/zod` answers — it replaced
3
+ * that package in 5.0.1 so that no consumer has to install it (see the module header for why
4
+ * every way of depending on it broke somebody). `@hookform/resolvers` stays a devDependency for
5
+ * one reason: to be the oracle here. Each case runs both resolvers over one schema and one input
6
+ * and compares the whole result, `ref`s and all.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { zodResolver as upstream } from '@hookform/resolvers/zod';
10
+ import type { FieldValues, ResolverOptions } from 'react-hook-form';
11
+ import { z } from 'zod';
12
+ import { zodResolver } from './zodResolver.js';
13
+
14
+ const options = (over: Partial<ResolverOptions<FieldValues>> = {}): ResolverOptions<FieldValues> =>
15
+ ({ fields: {}, shouldUseNativeValidation: false, ...over }) as ResolverOptions<FieldValues>;
16
+
17
+ async function both(schema: z.ZodType, values: FieldValues, opts = options()) {
18
+ const ours = await zodResolver(schema)(values, undefined, opts);
19
+ const theirs = await (upstream as unknown as (s: z.ZodType) => typeof zodResolver extends (s: z.ZodType) => infer R ? R : never)(schema)(values, undefined, opts);
20
+ return { ours, theirs };
21
+ }
22
+
23
+ const account = z.object({
24
+ name: z.string().min(2, 'too short'),
25
+ email: z.email('not an email'),
26
+ age: z.number().int().min(18, 'adults only'),
27
+ address: z.object({ city: z.string().min(1, 'city required'), zip: z.string().regex(/^\d{5}$/, 'five digits') }),
28
+ tags: z.array(z.string().min(1, 'empty tag')).min(1, 'at least one tag'),
29
+ contact: z.union([z.object({ kind: z.literal('phone'), phone: z.string().min(7, 'phone too short') }), z.object({ kind: z.literal('mail'), mail: z.email('bad mail') })]),
30
+ });
31
+
32
+ describe('zodResolver matches @hookform/resolvers/zod', () => {
33
+ test('valid input: parsed output as values, no errors', async () => {
34
+ const schema = z.object({ n: z.coerce.number(), s: z.string().trim() });
35
+ const { ours, theirs } = await both(schema, { n: '42', s: ' x ' });
36
+ expect(ours).toEqual(theirs);
37
+ expect(ours.values).toEqual({ n: 42, s: 'x' });
38
+ });
39
+
40
+ test('flat errors', async () => {
41
+ const { ours, theirs } = await both(account, { name: 'a', email: 'nope', age: 12, address: { city: '', zip: '1' }, tags: [], contact: { kind: 'phone', phone: '1' } });
42
+ expect(ours).toEqual(theirs);
43
+ expect(Object.keys(ours.errors).sort()).toEqual(['address', 'age', 'contact', 'email', 'name', 'tags']);
44
+ });
45
+
46
+ test('nested paths and array items nest, not dot-join', async () => {
47
+ const { ours, theirs } = await both(account, { name: 'ok', email: 'a@b.co', age: 30, address: { city: 'x', zip: 'abc' }, tags: ['a', ''], contact: { kind: 'mail', mail: 'x' } });
48
+ expect(ours).toEqual(theirs);
49
+ expect((ours.errors as Record<string, Record<string, unknown>>).address?.zip).toBeDefined();
50
+ });
51
+
52
+ test('criteriaMode "all" collects every failing rule per field', async () => {
53
+ const schema = z.object({ pw: z.string().min(8, 'eight').regex(/\d/, 'a digit').regex(/[A-Z]/, 'a capital') });
54
+ const { ours, theirs } = await both(schema, { pw: 'abc' }, options({ criteriaMode: 'all' }));
55
+ expect(ours).toEqual(theirs);
56
+ expect(Object.keys((ours.errors as Record<string, { types: object }>).pw?.types ?? {}).length).toBeGreaterThan(0);
57
+ });
58
+
59
+ test('a field-array ROOT error lands on `.root` when its items are registered', async () => {
60
+ const schema = z.object({ items: z.array(z.object({ v: z.string() })).min(2, 'two please') });
61
+ const opts = options({ names: ['items.0.v'] });
62
+ const { ours, theirs } = await both(schema, { items: [{ v: 'a' }] }, opts);
63
+ expect(ours).toEqual(theirs);
64
+ expect((ours.errors as Record<string, { root?: unknown }>).items?.root).toBeDefined();
65
+ });
66
+
67
+ test('a field ref is carried onto its error, as react-hook-form focuses it', async () => {
68
+ const ref = { name: 'name' };
69
+ const opts = options({ fields: { name: { ref, name: 'name' } } as unknown as ResolverOptions<FieldValues>['fields'] });
70
+ const { ours, theirs } = await both(account.pick({ name: true }), { name: '' }, opts);
71
+ expect(ours).toEqual(theirs);
72
+ expect((ours.errors as Record<string, { ref?: unknown }>).name?.ref).toBe(ref);
73
+ });
74
+
75
+ test('a non-zod throw from the schema is rethrown, by both', async () => {
76
+ const schema = z.string().transform(() => {
77
+ throw new TypeError('boom');
78
+ });
79
+ await expect(zodResolver(schema)('x' as unknown as FieldValues, undefined, options())).rejects.toThrow('boom');
80
+ await expect((upstream as unknown as (s: z.ZodType) => ReturnType<typeof zodResolver>)(schema)('x' as unknown as FieldValues, undefined, options())).rejects.toThrow('boom');
81
+ });
82
+ });
@@ -0,0 +1,136 @@
1
+ import { appendErrors, type FieldError, type FieldErrors, type FieldValues, get, type Resolver, type ResolverOptions, set } from 'react-hook-form';
2
+ import type { z } from 'zod';
3
+
4
+ /**
5
+ * 🔴 react-hook-form's zod resolver, owned here instead of imported from `@hookform/resolvers`
6
+ * (cursedbelt 5.0.1). What that package cost, and why neither way of depending on it worked:
7
+ *
8
+ * · As an optional PEER, `npm install cursedbelt` failed ERESOLVE (task 2087): npm checks an
9
+ * optional peer's own peer ranges, and `@hookform/resolvers@5` declares ~25 of them.
10
+ * · As `unpeered` (5.0.0), every consumer's clean-install `vite build` failed:
11
+ * `Rollup failed to resolve import "@hookform/resolvers/zod"`. Vite stubs a missing import
12
+ * only when it is a DECLARED optional peer, and a `import()` with a literal specifier is
13
+ * still resolved at build time. Primary checkouts hid it because their `node_modules` still
14
+ * held the 4.x copy.
15
+ * · A variable specifier (`import(/* @vite-ignore *\/ spec)`) builds, and then fails in the
16
+ * BROWSER for every app that does install the package — a bare specifier has nothing to
17
+ * resolve it at run time.
18
+ *
19
+ * So this is the zod-4 half of `@hookform/resolvers/zod` 5.9.1 and its `toNestErrors` /
20
+ * `validateFieldsNatively` (MIT, © 2019-present Beier (Bill) Luo), reduced to the one schema
21
+ * library this package peers on (`zod ^4.4.0`) and the one call shape {@link makeFormConfig}
22
+ * makes. `react-hook-form` is already a dependency, and supplies `get`, `set` and
23
+ * `appendErrors`. `zodResolver.spec.ts` pins the behaviours that matter: nested paths, the
24
+ * closest union branch, `criteriaMode: 'all'`, a field-array root error, and parsed output.
25
+ */
26
+
27
+ type Issue = z.core.$ZodIssue;
28
+
29
+ /** zod 4 marks its errors with a trait, not a prototype chain a bundler could duplicate. */
30
+ function isZodError(error: unknown): error is { issues: Issue[] } {
31
+ return !!(error as { _zod?: { traits?: Set<string> } } | null)?._zod?.traits?.has('$ZodError');
32
+ }
33
+
34
+ function parseIssues(input: Issue[], validateAllFieldCriteria: boolean): Record<string, FieldError> {
35
+ const issues = [...input];
36
+ // A null prototype, so a field named `toString` or `constructor` is not already "set".
37
+ const errors: Record<string, FieldError> = Object.create(null);
38
+ while (issues.length > 0) {
39
+ const issue = issues[0] as Issue;
40
+ const { code, message, path } = issue;
41
+ const key = path.join('.');
42
+ if (!errors[key]) {
43
+ if (issue.code === 'invalid_union' && issue.errors.length > 0) {
44
+ // The branch that came CLOSEST to matching speaks for the union, not the first one.
45
+ const closest = issue.errors.reduce((best, branch) => (branch.length < best.length ? branch : best));
46
+ const first = closest[0];
47
+ errors[key] = { message: first?.message ?? message, type: first?.code ?? code };
48
+ } else {
49
+ errors[key] = { message, type: code };
50
+ }
51
+ }
52
+ if (issue.code === 'invalid_union') {
53
+ for (const branch of issue.errors) for (const nested of branch) issues.push({ ...nested, path: [...issue.path, ...nested.path] } as Issue);
54
+ }
55
+ if (validateAllFieldCriteria) {
56
+ const types = (errors[key] as FieldError).types;
57
+ const messages = types?.[issue.code];
58
+ errors[key] = appendErrors(
59
+ key,
60
+ validateAllFieldCriteria,
61
+ errors as Parameters<typeof appendErrors>[2],
62
+ code,
63
+ messages ? ([] as string[]).concat(messages as string[], issue.message) : issue.message,
64
+ ) as FieldError;
65
+ }
66
+ issues.shift();
67
+ }
68
+ return errors;
69
+ }
70
+
71
+ type Fields = ResolverOptions<FieldValues>['fields'];
72
+ type NativeRef = { reportValidity?: () => void; setCustomValidity?: (m: string) => void };
73
+
74
+ function reportNative(ref: unknown, name: string, errors: FieldErrors): void {
75
+ const el = ref as NativeRef | undefined;
76
+ if (el && typeof el.reportValidity === 'function' && typeof el.setCustomValidity === 'function') {
77
+ const error = get(errors, name) as FieldError | undefined;
78
+ el.setCustomValidity(error?.message || '');
79
+ el.reportValidity();
80
+ }
81
+ }
82
+
83
+ function validateFieldsNatively(errors: FieldErrors, fields: Fields): void {
84
+ for (const name in fields) {
85
+ const field = fields[name] as { ref?: unknown; refs?: unknown[] } | undefined;
86
+ if (field?.ref && typeof (field.ref as NativeRef).reportValidity === 'function') reportNative(field.ref, name, errors);
87
+ else for (const ref of field?.refs ?? []) reportNative(ref, name, errors);
88
+ }
89
+ }
90
+
91
+ /** `a[0].b` and `a.0.b` are one path. */
92
+ const dotted = (name: string): string => name.replace(/\[(\d+)]/g, '.$1').replace(/[[\]]/g, '');
93
+
94
+ /** Is `name` the ROOT of a field array — some registered name is `name.<index>…`? */
95
+ function isFieldArrayRoot(names: readonly string[], name: string): boolean {
96
+ const escaped = dotted(name).replace(/[.*+?^${}()|\\]/g, '\\$&');
97
+ return names.some((n) => new RegExp(`^${escaped}\\.\\d+`).test(dotted(n)));
98
+ }
99
+
100
+ function toNestErrors<T extends FieldValues>(flat: Record<string, FieldError>, options: ResolverOptions<T>): FieldErrors<T> {
101
+ if (options.shouldUseNativeValidation) validateFieldsNatively(flat as FieldErrors, options.fields);
102
+ const nested: Record<string, unknown> = {};
103
+ for (const path in flat) {
104
+ const field = get(options.fields, path) as { ref?: unknown; refs?: unknown[] } | undefined;
105
+ const error = Object.assign(flat[path] || {}, { ref: field?.refs ? field.refs[0] : field?.ref });
106
+ if (isFieldArrayRoot((options.names as string[] | undefined) ?? Object.keys(flat), path)) {
107
+ const existing = Object.assign({}, get(nested, path));
108
+ set(existing, 'root', error);
109
+ set(nested, path, existing);
110
+ } else {
111
+ set(nested, path, error);
112
+ }
113
+ }
114
+ return nested as FieldErrors<T>;
115
+ }
116
+
117
+ /**
118
+ * A react-hook-form {@link Resolver} for a zod 4 schema: `parseAsync`, parsed output as the
119
+ * form's values on success, react-hook-form's nested error shape on a `ZodError`. Anything
120
+ * else a schema throws is not a validation result and is rethrown.
121
+ */
122
+ export function zodResolver<TFieldValues extends FieldValues>(schema: z.ZodType): Resolver<TFieldValues> {
123
+ return async (values, _context, options) => {
124
+ try {
125
+ const data = await schema.parseAsync(values);
126
+ if (options.shouldUseNativeValidation) validateFieldsNatively({}, options.fields);
127
+ return { errors: {}, values: data as TFieldValues };
128
+ } catch (error) {
129
+ if (!isZodError(error)) throw error;
130
+ return {
131
+ values: {},
132
+ errors: toNestErrors(parseIssues(error.issues, !options.shouldUseNativeValidation && options.criteriaMode === 'all'), options),
133
+ };
134
+ }
135
+ };
136
+ }
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 1312 candidate(s) scanned.
18
+ * when this file is stale. 1317 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 303 candidate(s) scanned.
18
+ * when this file is stale. 302 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 462 candidate(s) scanned.
18
+ * when this file is stale. 464 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 388 candidate(s) scanned.
18
+ * when this file is stale. 390 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
15
15
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
16
- * when this file is stale. 5197 candidate(s) scanned.
16
+ * when this file is stale. 5214 candidate(s) scanned.
17
17
  *
18
18
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
19
19
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 208 candidate(s) scanned.
18
+ * when this file is stale. 209 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 1350 candidate(s) scanned.
18
+ * when this file is stale. 1355 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 188 candidate(s) scanned.
18
+ * when this file is stale. 190 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 100 candidate(s) scanned.
18
+ * when this file is stale. 103 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 616 candidate(s) scanned.
18
+ * when this file is stale. 619 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 574 candidate(s) scanned.
18
+ * when this file is stale. 573 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 154 candidate(s) scanned.
18
+ * when this file is stale. 155 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 243 candidate(s) scanned.
18
+ * when this file is stale. 245 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 110 candidate(s) scanned.
18
+ * when this file is stale. 112 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 2159 candidate(s) scanned.
18
+ * when this file is stale. 2157 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 2777 candidate(s) scanned.
18
+ * when this file is stale. 2775 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 799 candidate(s) scanned.
18
+ * when this file is stale. 801 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 117 candidate(s) scanned.
18
+ * when this file is stale. 119 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 212 candidate(s) scanned.
18
+ * when this file is stale. 213 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 360 candidate(s) scanned.
18
+ * when this file is stale. 361 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 161 candidate(s) scanned.
18
+ * when this file is stale. 160 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 392 candidate(s) scanned.
18
+ * when this file is stale. 395 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 770 candidate(s) scanned.
18
+ * when this file is stale. 776 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are