@assistant-ui/react-hook-form 0.12.27 → 0.12.29

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/README.md CHANGED
@@ -36,4 +36,4 @@ The assistant gets two built-in tools: `set_form_field` to write values into fie
36
36
 
37
37
  - `@assistant-ui/react-lexical` if you also want a rich-text composer with `@`-mention support inside the same chat.
38
38
 
39
- Full API reference at [assistant-ui.com/docs/api-reference/integrations/react-hook-form](https://www.assistant-ui.com/docs/api-reference/integrations/react-hook-form). See [`examples/with-react-hook-form`](https://github.com/assistant-ui/assistant-ui/tree/main/examples/with-react-hook-form) for a complete app.
39
+ A guided walkthrough lives at [assistant-ui.com/examples/form-demo](https://www.assistant-ui.com/examples/form-demo). See [`examples/with-react-hook-form`](https://github.com/assistant-ui/assistant-ui/tree/main/examples/with-react-hook-form) for a complete app.
@@ -1 +1 @@
1
- {"version":3,"file":"useAssistantForm.d.ts","names":[],"sources":["../src/useAssistantForm.ts"],"mappings":";;;;;KAoBY,sBACV,qBAAqB,aACrB,UACA,sBACE,aAAa,cAAc,UAAU;EACvC;IAEM;MAEM;QAEM,SACI,6BACE,EAAE,cACQ,UAAU;;MAOhC;QAEM,SACI,6BACE,EAAE,cAAc,UAAU;;MAMtC;QAEM,SACI,6BACE,EAAE,cAAc,UAAU;;;;;cAYvC,mBACX,qBAAqB,cAAc,aACnC,gBACA,qBAAqB,cAErB,QAAQ,sBAAsB,cAAc,UAAU,wBACrD,cAAc,cAAc,UAAU"}
1
+ {"version":3,"file":"useAssistantForm.d.ts","names":[],"sources":["../src/useAssistantForm.ts"],"mappings":";;;;;KA6BY,sBACV,qBAAqB,aACrB,UACA,sBACE,aAAa,cAAc,UAAU;EACvC;IAEM;MAEM;QAEM,SACI,6BACE,EAAE,cACQ,UAAU;;MAOhC;QAEM,SACI,6BACE,EAAE,cAAc,UAAU;;MAMtC;QAEM,SACI,6BACE,EAAE,cAAc,UAAU;;;;;cAYvC,mBACX,qBAAqB,cAAc,aACnC,gBACA,qBAAqB,cAErB,QAAQ,sBAAsB,cAAc,UAAU,wBACrD,cAAc,cAAc,UAAU"}
@@ -3,12 +3,66 @@ import { formTools } from "./formTools.js";
3
3
  import { tool } from "@assistant-ui/core";
4
4
  import "@assistant-ui/core/react";
5
5
  import { useAui } from "@assistant-ui/store";
6
- import { useEffect } from "react";
6
+ import { useCallback, useEffect, useMemo, useRef } from "react";
7
7
  import { get, useForm } from "react-hook-form";
8
8
  //#region src/useAssistantForm.ts
9
9
  const useAssistantForm = (props) => {
10
10
  const form = useForm(props);
11
- const { control, getValues, setValue, reset, formState: { isSubmitting } } = form;
11
+ const { control, getValues, setValue, reset, handleSubmit: baseHandleSubmit, formState: { isSubmitting } } = form;
12
+ const pendingAssistantSubmitRef = useRef(null);
13
+ const settleAssistantSubmit = useCallback((outcome) => {
14
+ const pending = pendingAssistantSubmitRef.current;
15
+ if (!pending) return;
16
+ pendingAssistantSubmitRef.current = null;
17
+ pending.resolve(outcome);
18
+ }, []);
19
+ const rejectAssistantSubmit = useCallback((error) => {
20
+ const pending = pendingAssistantSubmitRef.current;
21
+ if (!pending) return;
22
+ pendingAssistantSubmitRef.current = null;
23
+ pending.reject(error);
24
+ }, []);
25
+ const handleSubmit = useCallback((onValid, onInvalid) => {
26
+ const submit = baseHandleSubmit((...args) => {
27
+ const pending = pendingAssistantSubmitRef.current;
28
+ const event = args[1]?.nativeEvent ?? args[1];
29
+ if (pending && pending.event === event) pending.outcome = true;
30
+ return onValid(...args);
31
+ }, (...args) => {
32
+ const pending = pendingAssistantSubmitRef.current;
33
+ const event = args[1]?.nativeEvent ?? args[1];
34
+ if (pending && pending.event === event) pending.outcome = false;
35
+ return onInvalid?.(...args);
36
+ });
37
+ return (async (event) => {
38
+ const pending = pendingAssistantSubmitRef.current;
39
+ const nativeEvent = event?.nativeEvent ?? event;
40
+ const assistantSubmit = pending && (pending.event === nativeEvent || pending.dispatching && pending.event === void 0 && nativeEvent !== void 0) ? pending : null;
41
+ if (assistantSubmit) {
42
+ assistantSubmit.event = nativeEvent;
43
+ assistantSubmit.handlerInvoked = true;
44
+ }
45
+ try {
46
+ const result = await submit(event);
47
+ if (assistantSubmit && pendingAssistantSubmitRef.current === assistantSubmit) settleAssistantSubmit(assistantSubmit.outcome ?? true);
48
+ return result;
49
+ } catch (error) {
50
+ if (pendingAssistantSubmitRef.current === assistantSubmit) rejectAssistantSubmit(error);
51
+ throw error;
52
+ }
53
+ });
54
+ }, [
55
+ baseHandleSubmit,
56
+ rejectAssistantSubmit,
57
+ settleAssistantSubmit
58
+ ]);
59
+ const assistantForm = useMemo(() => ({
60
+ ...form,
61
+ handleSubmit,
62
+ get formState() {
63
+ return form.formState;
64
+ }
65
+ }), [form, handleSubmit]);
12
66
  const aui = useAui();
13
67
  useEffect(() => {
14
68
  const value = { tools: {
@@ -23,24 +77,66 @@ const useAssistantForm = (props) => {
23
77
  submit_form: tool({
24
78
  ...formTools.submit_form,
25
79
  execute: async () => {
26
- if (isSubmitting) return {
80
+ if (isSubmitting || pendingAssistantSubmitRef.current) return {
27
81
  success: false,
28
82
  message: "The form is already submitting."
29
83
  };
30
84
  const { _names, _fields } = control;
85
+ let formElement = null;
31
86
  for (const name of _names.mount) {
32
87
  const field = get(_fields, name);
33
88
  if (field?._f) {
34
89
  const fieldReference = Array.isArray(field._f.refs) ? field._f.refs[0] : field._f.ref;
35
90
  if (fieldReference instanceof HTMLElement) {
36
- const form = fieldReference.closest("form");
37
- if (form) {
38
- form.requestSubmit();
39
- return { success: true };
40
- }
91
+ formElement = fieldReference.closest("form");
92
+ if (formElement) break;
41
93
  }
42
94
  }
43
95
  }
96
+ if (formElement) {
97
+ if (!formElement.noValidate && !formElement.reportValidity()) return {
98
+ success: false,
99
+ message: "The form contains invalid fields and was not submitted."
100
+ };
101
+ let resolveSubmission = () => {};
102
+ let rejectSubmission = () => {};
103
+ const submissionResult = new Promise((resolve, reject) => {
104
+ resolveSubmission = resolve;
105
+ rejectSubmission = reject;
106
+ });
107
+ const assistantSubmit = {
108
+ dispatching: true,
109
+ event: void 0,
110
+ handlerInvoked: false,
111
+ outcome: void 0,
112
+ resolve: resolveSubmission,
113
+ reject: rejectSubmission
114
+ };
115
+ pendingAssistantSubmitRef.current = assistantSubmit;
116
+ const onSubmit = (event) => {
117
+ if (pendingAssistantSubmitRef.current === assistantSubmit && assistantSubmit.event === void 0) assistantSubmit.event = event;
118
+ queueMicrotask(() => {
119
+ if (pendingAssistantSubmitRef.current === assistantSubmit && !assistantSubmit.handlerInvoked) settleAssistantSubmit(true);
120
+ });
121
+ };
122
+ formElement.addEventListener("submit", onSubmit, { once: true });
123
+ try {
124
+ formElement.requestSubmit();
125
+ } catch (error) {
126
+ settleAssistantSubmit(false);
127
+ throw error;
128
+ } finally {
129
+ assistantSubmit.dispatching = false;
130
+ formElement.removeEventListener("submit", onSubmit);
131
+ }
132
+ const dispatched = assistantSubmit.event !== void 0;
133
+ if (!dispatched) settleAssistantSubmit(false);
134
+ if (await submissionResult) return { success: true };
135
+ return {
136
+ success: false,
137
+ message: dispatched ? "The form contains invalid fields and was not submitted." : "The form did not accept the submission."
138
+ };
139
+ }
44
140
  return {
45
141
  success: false,
46
142
  message: "Unable retrieve the form element. This is a coding error."
@@ -65,7 +161,9 @@ const useAssistantForm = (props) => {
65
161
  getValues,
66
162
  aui,
67
163
  reset,
68
- isSubmitting
164
+ isSubmitting,
165
+ rejectAssistantSubmit,
166
+ settleAssistantSubmit
69
167
  ]);
70
168
  const renderFormFieldTool = props?.assistant?.tools?.set_form_field?.render;
71
169
  useEffect(() => {
@@ -82,7 +180,7 @@ const useAssistantForm = (props) => {
82
180
  if (!renderResetFormTool) return void 0;
83
181
  return aui.tools.setToolUI("reset_form", renderResetFormTool);
84
182
  }, [aui, renderResetFormTool]);
85
- return form;
183
+ return assistantForm;
86
184
  };
87
185
  //#endregion
88
186
  export { useAssistantForm };
@@ -1 +1 @@
1
- {"version":3,"file":"useAssistantForm.js","names":[],"sources":["../src/useAssistantForm.ts"],"sourcesContent":["\"use client\";\n\nimport { type ModelContext, tool } from \"@assistant-ui/core\";\nimport type {} from \"@assistant-ui/core/store\";\nimport { type ToolCallMessagePartComponent } from \"@assistant-ui/core/react\";\nimport { useAui } from \"@assistant-ui/store\";\nimport { useEffect } from \"react\";\nimport {\n type Field,\n type FieldValues,\n type Path,\n type PathValue,\n type UseFormProps,\n type UseFormReturn,\n get,\n useForm,\n} from \"react-hook-form\";\nimport type { z } from \"zod\";\nimport { formTools } from \"./formTools\";\n\nexport type UseAssistantFormProps<\n TFieldValues extends FieldValues,\n TContext,\n TTransformedValues,\n> = UseFormProps<TFieldValues, TContext, TTransformedValues> & {\n assistant?:\n | {\n tools?:\n | {\n set_form_field?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<\n (typeof formTools.set_form_field)[\"parameters\"]\n >,\n unknown\n >\n | undefined;\n }\n | undefined;\n submit_form?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<(typeof formTools.submit_form)[\"parameters\"]>,\n unknown\n >\n | undefined;\n }\n | undefined;\n reset_form?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<(typeof formTools.reset_form)[\"parameters\"]>,\n unknown\n >\n | undefined;\n }\n | undefined;\n }\n | undefined;\n }\n | undefined;\n};\n\nexport const useAssistantForm = <\n TFieldValues extends FieldValues = FieldValues,\n TContext = any,\n TTransformedValues = TFieldValues,\n>(\n props?: UseAssistantFormProps<TFieldValues, TContext, TTransformedValues>,\n): UseFormReturn<TFieldValues, TContext, TTransformedValues> => {\n const form = useForm<TFieldValues, TContext, TTransformedValues>(props);\n const {\n control,\n getValues,\n setValue,\n reset,\n formState: { isSubmitting },\n } = form;\n\n const aui = useAui();\n useEffect(() => {\n const value: ModelContext = {\n tools: {\n set_form_field: tool({\n ...formTools.set_form_field,\n parameters: formTools.set_form_field.parameters,\n execute: async (args) => {\n setValue(\n args.name as Path<TFieldValues>,\n args.value as PathValue<TFieldValues, Path<TFieldValues>>,\n );\n\n return { success: true };\n },\n }),\n submit_form: tool({\n ...formTools.submit_form,\n execute: async () => {\n if (isSubmitting) {\n return {\n success: false,\n message: \"The form is already submitting.\",\n };\n }\n const { _names, _fields } = control;\n for (const name of _names.mount) {\n const field: Field | undefined = get(_fields, name);\n if (field?._f) {\n const fieldReference = Array.isArray(field._f.refs)\n ? field._f.refs[0]\n : field._f.ref;\n\n if (fieldReference instanceof HTMLElement) {\n const form = fieldReference.closest(\"form\");\n if (form) {\n form.requestSubmit();\n\n return { success: true };\n }\n }\n }\n }\n\n return {\n success: false,\n message:\n \"Unable retrieve the form element. This is a coding error.\",\n };\n },\n }),\n reset_form: tool({\n ...formTools.reset_form,\n execute: async () => {\n reset();\n return { success: true };\n },\n }),\n },\n };\n\n return aui.modelContext.register({\n getModelContext: () => ({\n ...value,\n system: `Form State:\\n${JSON.stringify(getValues())}`,\n }),\n });\n }, [control, setValue, getValues, aui, reset, isSubmitting]);\n\n const renderFormFieldTool = props?.assistant?.tools?.set_form_field?.render;\n useEffect(() => {\n if (!renderFormFieldTool) return undefined;\n return aui.tools.setToolUI(\"set_form_field\", renderFormFieldTool);\n }, [aui, renderFormFieldTool]);\n\n const renderSubmitFormTool = props?.assistant?.tools?.submit_form?.render;\n useEffect(() => {\n if (!renderSubmitFormTool) return undefined;\n return aui.tools.setToolUI(\"submit_form\", renderSubmitFormTool);\n }, [aui, renderSubmitFormTool]);\n\n const renderResetFormTool = props?.assistant?.tools?.reset_form?.render;\n useEffect(() => {\n if (!renderResetFormTool) return undefined;\n return aui.tools.setToolUI(\"reset_form\", renderResetFormTool);\n }, [aui, renderResetFormTool]);\n\n return form;\n};\n"],"mappings":";;;;;;;;AAmEA,MAAa,oBAKX,UAC8D;CAC9D,MAAM,OAAO,QAAoD,KAAK;CACtE,MAAM,EACJ,SACA,WACA,UACA,OACA,WAAW,EAAE,mBACX;CAEJ,MAAM,MAAM,OAAO;CACnB,gBAAgB;EACd,MAAM,QAAsB,EAC1B,OAAO;GACL,gBAAgB,KAAK;IACnB,GAAG,UAAU;IACb,YAAY,UAAU,eAAe;IACrC,SAAS,OAAO,SAAS;KACvB,SACE,KAAK,MACL,KAAK,KACP;KAEA,OAAO,EAAE,SAAS,KAAK;IACzB;GACF,CAAC;GACD,aAAa,KAAK;IAChB,GAAG,UAAU;IACb,SAAS,YAAY;KACnB,IAAI,cACF,OAAO;MACL,SAAS;MACT,SAAS;KACX;KAEF,MAAM,EAAE,QAAQ,YAAY;KAC5B,KAAK,MAAM,QAAQ,OAAO,OAAO;MAC/B,MAAM,QAA2B,IAAI,SAAS,IAAI;MAClD,IAAI,OAAO,IAAI;OACb,MAAM,iBAAiB,MAAM,QAAQ,MAAM,GAAG,IAAI,IAC9C,MAAM,GAAG,KAAK,KACd,MAAM,GAAG;OAEb,IAAI,0BAA0B,aAAa;QACzC,MAAM,OAAO,eAAe,QAAQ,MAAM;QAC1C,IAAI,MAAM;SACR,KAAK,cAAc;SAEnB,OAAO,EAAE,SAAS,KAAK;QACzB;OACF;MACF;KACF;KAEA,OAAO;MACL,SAAS;MACT,SACE;KACJ;IACF;GACF,CAAC;GACD,YAAY,KAAK;IACf,GAAG,UAAU;IACb,SAAS,YAAY;KACnB,MAAM;KACN,OAAO,EAAE,SAAS,KAAK;IACzB;GACF,CAAC;EACH,EACF;EAEA,OAAO,IAAI,aAAa,SAAS,EAC/B,wBAAwB;GACtB,GAAG;GACH,QAAQ,gBAAgB,KAAK,UAAU,UAAU,CAAC;EACpD,GACF,CAAC;CACH,GAAG;EAAC;EAAS;EAAU;EAAW;EAAK;EAAO;CAAY,CAAC;CAE3D,MAAM,sBAAsB,OAAO,WAAW,OAAO,gBAAgB;CACrE,gBAAgB;EACd,IAAI,CAAC,qBAAqB,OAAO,KAAA;EACjC,OAAO,IAAI,MAAM,UAAU,kBAAkB,mBAAmB;CAClE,GAAG,CAAC,KAAK,mBAAmB,CAAC;CAE7B,MAAM,uBAAuB,OAAO,WAAW,OAAO,aAAa;CACnE,gBAAgB;EACd,IAAI,CAAC,sBAAsB,OAAO,KAAA;EAClC,OAAO,IAAI,MAAM,UAAU,eAAe,oBAAoB;CAChE,GAAG,CAAC,KAAK,oBAAoB,CAAC;CAE9B,MAAM,sBAAsB,OAAO,WAAW,OAAO,YAAY;CACjE,gBAAgB;EACd,IAAI,CAAC,qBAAqB,OAAO,KAAA;EACjC,OAAO,IAAI,MAAM,UAAU,cAAc,mBAAmB;CAC9D,GAAG,CAAC,KAAK,mBAAmB,CAAC;CAE7B,OAAO;AACT"}
1
+ {"version":3,"file":"useAssistantForm.js","names":[],"sources":["../src/useAssistantForm.ts"],"sourcesContent":["\"use client\";\n\nimport { type ModelContext, tool } from \"@assistant-ui/core\";\nimport type {} from \"@assistant-ui/core/store\";\nimport { type ToolCallMessagePartComponent } from \"@assistant-ui/core/react\";\nimport { useAui } from \"@assistant-ui/store\";\nimport { useCallback, useEffect, useMemo, useRef } from \"react\";\nimport {\n type Field,\n type FieldValues,\n type Path,\n type PathValue,\n type UseFormProps,\n type UseFormReturn,\n get,\n useForm,\n} from \"react-hook-form\";\nimport type { z } from \"zod\";\nimport { formTools } from \"./formTools\";\n\ntype PendingAssistantSubmit = {\n dispatching: boolean;\n event: unknown;\n handlerInvoked: boolean;\n outcome: boolean | undefined;\n resolve: (outcome: boolean) => void;\n reject: (error: unknown) => void;\n};\n\nexport type UseAssistantFormProps<\n TFieldValues extends FieldValues,\n TContext,\n TTransformedValues,\n> = UseFormProps<TFieldValues, TContext, TTransformedValues> & {\n assistant?:\n | {\n tools?:\n | {\n set_form_field?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<\n (typeof formTools.set_form_field)[\"parameters\"]\n >,\n unknown\n >\n | undefined;\n }\n | undefined;\n submit_form?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<(typeof formTools.submit_form)[\"parameters\"]>,\n unknown\n >\n | undefined;\n }\n | undefined;\n reset_form?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<(typeof formTools.reset_form)[\"parameters\"]>,\n unknown\n >\n | undefined;\n }\n | undefined;\n }\n | undefined;\n }\n | undefined;\n};\n\nexport const useAssistantForm = <\n TFieldValues extends FieldValues = FieldValues,\n TContext = any,\n TTransformedValues = TFieldValues,\n>(\n props?: UseAssistantFormProps<TFieldValues, TContext, TTransformedValues>,\n): UseFormReturn<TFieldValues, TContext, TTransformedValues> => {\n const form = useForm<TFieldValues, TContext, TTransformedValues>(props);\n const {\n control,\n getValues,\n setValue,\n reset,\n handleSubmit: baseHandleSubmit,\n formState: { isSubmitting },\n } = form;\n\n const pendingAssistantSubmitRef = useRef<PendingAssistantSubmit | null>(null);\n const settleAssistantSubmit = useCallback((outcome: boolean) => {\n const pending = pendingAssistantSubmitRef.current;\n if (!pending) return;\n\n pendingAssistantSubmitRef.current = null;\n pending.resolve(outcome);\n }, []);\n const rejectAssistantSubmit = useCallback((error: unknown) => {\n const pending = pendingAssistantSubmitRef.current;\n if (!pending) return;\n\n pendingAssistantSubmitRef.current = null;\n pending.reject(error);\n }, []);\n\n const handleSubmit = useCallback<\n UseFormReturn<TFieldValues, TContext, TTransformedValues>[\"handleSubmit\"]\n >(\n (onValid, onInvalid) => {\n const submit = baseHandleSubmit(\n (...args) => {\n const pending = pendingAssistantSubmitRef.current;\n const event = args[1]?.nativeEvent ?? args[1];\n if (pending && pending.event === event) pending.outcome = true;\n return onValid(...args);\n },\n (...args) => {\n const pending = pendingAssistantSubmitRef.current;\n const event = args[1]?.nativeEvent ?? args[1];\n if (pending && pending.event === event) pending.outcome = false;\n return onInvalid?.(...args);\n },\n );\n\n return (async (event) => {\n const pending = pendingAssistantSubmitRef.current;\n const nativeEvent = event?.nativeEvent ?? event;\n const assistantSubmit =\n pending &&\n (pending.event === nativeEvent ||\n (pending.dispatching &&\n pending.event === undefined &&\n nativeEvent !== undefined))\n ? pending\n : null;\n if (assistantSubmit) {\n assistantSubmit.event = nativeEvent;\n assistantSubmit.handlerInvoked = true;\n }\n\n try {\n const result = await submit(event);\n if (\n assistantSubmit &&\n pendingAssistantSubmitRef.current === assistantSubmit\n ) {\n settleAssistantSubmit(assistantSubmit.outcome ?? true);\n }\n return result;\n } catch (error) {\n if (pendingAssistantSubmitRef.current === assistantSubmit) {\n rejectAssistantSubmit(error);\n }\n throw error;\n }\n }) as typeof submit;\n },\n [baseHandleSubmit, rejectAssistantSubmit, settleAssistantSubmit],\n );\n const assistantForm = useMemo<\n UseFormReturn<TFieldValues, TContext, TTransformedValues>\n >(\n () => ({\n ...form,\n handleSubmit,\n get formState() {\n return form.formState;\n },\n }),\n [form, handleSubmit],\n );\n\n const aui = useAui();\n useEffect(() => {\n const value: ModelContext = {\n tools: {\n set_form_field: tool({\n ...formTools.set_form_field,\n parameters: formTools.set_form_field.parameters,\n execute: async (args) => {\n setValue(\n args.name as Path<TFieldValues>,\n args.value as PathValue<TFieldValues, Path<TFieldValues>>,\n );\n\n return { success: true };\n },\n }),\n submit_form: tool({\n ...formTools.submit_form,\n execute: async () => {\n if (isSubmitting || pendingAssistantSubmitRef.current) {\n return {\n success: false,\n message: \"The form is already submitting.\",\n };\n }\n const { _names, _fields } = control;\n let formElement: HTMLFormElement | null = null;\n for (const name of _names.mount) {\n const field: Field | undefined = get(_fields, name);\n if (field?._f) {\n const fieldReference = Array.isArray(field._f.refs)\n ? field._f.refs[0]\n : field._f.ref;\n\n if (fieldReference instanceof HTMLElement) {\n formElement = fieldReference.closest(\"form\");\n if (formElement) break;\n }\n }\n }\n\n if (formElement) {\n if (!formElement.noValidate && !formElement.reportValidity()) {\n return {\n success: false,\n message:\n \"The form contains invalid fields and was not submitted.\",\n };\n }\n\n let resolveSubmission: (outcome: boolean) => void = () => {};\n let rejectSubmission: (error: unknown) => void = () => {};\n const submissionResult = new Promise<boolean>(\n (resolve, reject) => {\n resolveSubmission = resolve;\n rejectSubmission = reject;\n },\n );\n const assistantSubmit: PendingAssistantSubmit = {\n dispatching: true,\n event: undefined,\n handlerInvoked: false,\n outcome: undefined,\n resolve: resolveSubmission,\n reject: rejectSubmission,\n };\n pendingAssistantSubmitRef.current = assistantSubmit;\n const onSubmit = (event: SubmitEvent) => {\n if (\n pendingAssistantSubmitRef.current === assistantSubmit &&\n assistantSubmit.event === undefined\n ) {\n assistantSubmit.event = event;\n }\n queueMicrotask(() => {\n if (\n pendingAssistantSubmitRef.current === assistantSubmit &&\n !assistantSubmit.handlerInvoked\n ) {\n settleAssistantSubmit(true);\n }\n });\n };\n\n formElement.addEventListener(\"submit\", onSubmit, { once: true });\n try {\n formElement.requestSubmit();\n } catch (error) {\n settleAssistantSubmit(false);\n throw error;\n } finally {\n assistantSubmit.dispatching = false;\n formElement.removeEventListener(\"submit\", onSubmit);\n }\n\n const dispatched = assistantSubmit.event !== undefined;\n if (!dispatched) {\n settleAssistantSubmit(false);\n }\n\n if (await submissionResult) return { success: true };\n return {\n success: false,\n message: dispatched\n ? \"The form contains invalid fields and was not submitted.\"\n : \"The form did not accept the submission.\",\n };\n }\n\n return {\n success: false,\n message:\n \"Unable retrieve the form element. This is a coding error.\",\n };\n },\n }),\n reset_form: tool({\n ...formTools.reset_form,\n execute: async () => {\n reset();\n return { success: true };\n },\n }),\n },\n };\n\n return aui.modelContext.register({\n getModelContext: () => ({\n ...value,\n system: `Form State:\\n${JSON.stringify(getValues())}`,\n }),\n });\n }, [\n control,\n setValue,\n getValues,\n aui,\n reset,\n isSubmitting,\n rejectAssistantSubmit,\n settleAssistantSubmit,\n ]);\n\n const renderFormFieldTool = props?.assistant?.tools?.set_form_field?.render;\n useEffect(() => {\n if (!renderFormFieldTool) return undefined;\n return aui.tools.setToolUI(\"set_form_field\", renderFormFieldTool);\n }, [aui, renderFormFieldTool]);\n\n const renderSubmitFormTool = props?.assistant?.tools?.submit_form?.render;\n useEffect(() => {\n if (!renderSubmitFormTool) return undefined;\n return aui.tools.setToolUI(\"submit_form\", renderSubmitFormTool);\n }, [aui, renderSubmitFormTool]);\n\n const renderResetFormTool = props?.assistant?.tools?.reset_form?.render;\n useEffect(() => {\n if (!renderResetFormTool) return undefined;\n return aui.tools.setToolUI(\"reset_form\", renderResetFormTool);\n }, [aui, renderResetFormTool]);\n\n return assistantForm;\n};\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-hook-form",
3
- "version": "0.12.27",
3
+ "version": "0.12.29",
4
4
  "description": "React Hook Form integration for assistant-ui",
5
5
  "keywords": [
6
6
  "react-hook-form",
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@assistant-ui/core": "^0.3.10",
33
- "@assistant-ui/store": "^0.3.8",
32
+ "@assistant-ui/core": "^0.3.15",
33
+ "@assistant-ui/store": "^0.3.10",
34
34
  "zod": "^4.4.3"
35
35
  },
36
36
  "peerDependencies": {
@@ -49,9 +49,9 @@
49
49
  "@types/react-dom": "^19.2.4",
50
50
  "jsdom": "^30.0.1",
51
51
  "react": "^19.2.8",
52
- "react-hook-form": "^7.84.0",
52
+ "react-hook-form": "^7.85.0",
53
53
  "vitest": "^4.1.10",
54
- "@assistant-ui/x-buildutils": "0.0.22"
54
+ "@assistant-ui/x-buildutils": "0.0.23"
55
55
  },
56
56
  "publishConfig": {
57
57
  "access": "public",
@@ -1,7 +1,14 @@
1
1
  /** @vitest-environment jsdom */
2
- import { act, render, renderHook } from "@testing-library/react";
2
+ import {
3
+ act,
4
+ fireEvent,
5
+ render,
6
+ renderHook,
7
+ waitFor,
8
+ } from "@testing-library/react";
3
9
  import type { ModelContext } from "@assistant-ui/core";
4
10
  import type { FormEvent, ReactNode } from "react";
11
+ import type { Resolver, ResolverResult } from "react-hook-form";
5
12
  import { beforeEach, describe, expect, it, vi } from "vitest";
6
13
 
7
14
  const mocks = vi.hoisted(() => {
@@ -36,6 +43,19 @@ beforeEach(() => {
36
43
  });
37
44
  });
38
45
 
46
+ const executeSubmitForm = () => {
47
+ const submitTool = provider.getModelContext().tools?.submit_form;
48
+ if (!submitTool?.execute) throw new Error("submit_form is not registered");
49
+ return submitTool.execute({}, {} as never);
50
+ };
51
+
52
+ const expectSubmitBlocked = async () => {
53
+ await expect(executeSubmitForm()).resolves.toEqual({
54
+ success: false,
55
+ message: "The form contains invalid fields and was not submitted.",
56
+ });
57
+ };
58
+
39
59
  const expectRegisteredFieldsToSubmit = async (Fields: () => ReactNode) => {
40
60
  const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
41
61
  event.preventDefault();
@@ -47,10 +67,7 @@ const expectRegisteredFieldsToSubmit = async (Fields: () => ReactNode) => {
47
67
  </form>,
48
68
  );
49
69
 
50
- const submitTool = provider.getModelContext().tools?.submit_form;
51
- if (!submitTool?.execute) throw new Error("submit_form is not registered");
52
-
53
- await expect(submitTool.execute({}, {} as never)).resolves.toEqual({
70
+ await expect(executeSubmitForm()).resolves.toEqual({
54
71
  success: true,
55
72
  });
56
73
  expect(onSubmit).toHaveBeenCalledOnce();
@@ -100,4 +117,246 @@ describe("useAssistantForm", () => {
100
117
  );
101
118
  });
102
119
  });
120
+
121
+ it("reports when native form validation blocks submission", async () => {
122
+ const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
123
+ event.preventDefault();
124
+ });
125
+
126
+ render(
127
+ <form onSubmit={onSubmit}>
128
+ <NativeRequiredField />
129
+ </form>,
130
+ );
131
+
132
+ await expectSubmitBlocked();
133
+ expect(onSubmit).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("submits when native form validation is disabled", async () => {
137
+ const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
138
+ event.preventDefault();
139
+ });
140
+
141
+ render(
142
+ <form noValidate onSubmit={onSubmit}>
143
+ <NativeRequiredField />
144
+ </form>,
145
+ );
146
+
147
+ await expect(executeSubmitForm()).resolves.toEqual({
148
+ success: true,
149
+ });
150
+ expect(onSubmit).toHaveBeenCalledOnce();
151
+ });
152
+
153
+ it("submits a form with react-hook-form rules and a plain onSubmit", async () => {
154
+ const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
155
+ event.preventDefault();
156
+ });
157
+
158
+ const Fields = () => {
159
+ const form = useAssistantForm<{ name: string }>();
160
+ return <input {...form.register("name", { required: true })} />;
161
+ };
162
+
163
+ render(
164
+ <form onSubmit={onSubmit}>
165
+ <Fields />
166
+ </form>,
167
+ );
168
+
169
+ await expect(executeSubmitForm()).resolves.toEqual({ success: true });
170
+ expect(onSubmit).toHaveBeenCalledOnce();
171
+ });
172
+
173
+ it("reports when react-hook-form validation blocks submission", async () => {
174
+ const onValid = vi.fn();
175
+ const onInvalid = vi.fn();
176
+
177
+ const { getByTestId } = render(
178
+ <ReactHookFormRequiredForm onInvalid={onInvalid} onValid={onValid} />,
179
+ );
180
+
181
+ await expectSubmitBlocked();
182
+ expect(onValid).not.toHaveBeenCalled();
183
+ expect(onInvalid).toHaveBeenCalledOnce();
184
+ await waitFor(() => {
185
+ expect(getByTestId("submit-count").textContent).toBe("1");
186
+ expect(getByTestId("name-error").textContent).toBe("invalid");
187
+ });
188
+
189
+ fireEvent.change(getByTestId("name-input"), {
190
+ target: { value: "Ada" },
191
+ });
192
+ await waitFor(() => {
193
+ expect(getByTestId("name-error").textContent).toBe("valid");
194
+ });
195
+ });
196
+
197
+ it("reports validation blocked by a capture-phase handler", async () => {
198
+ const onValid = vi.fn();
199
+
200
+ render(<ReactHookFormRequiredForm capture onValid={onValid} />);
201
+
202
+ await expectSubmitBlocked();
203
+ expect(onValid).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it("reports react-hook-form errors when native validation is disabled", async () => {
207
+ const onValid = vi.fn();
208
+
209
+ render(<ReactHookFormRequiredForm noValidate onValid={onValid} />);
210
+
211
+ await expectSubmitBlocked();
212
+ expect(onValid).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it("submits forms that pass react-hook-form validation", async () => {
216
+ const onValid = vi.fn();
217
+
218
+ render(<ReactHookFormRequiredForm defaultName="Ada" onValid={onValid} />);
219
+
220
+ await expect(executeSubmitForm()).resolves.toEqual({ success: true });
221
+ await waitFor(() => expect(onValid).toHaveBeenCalledOnce());
222
+ });
223
+
224
+ it("does not let user submissions settle a pending assistant submission", async () => {
225
+ type FormValues = { name: string };
226
+ let validationCount = 0;
227
+ let resolveAssistantValidation: (
228
+ result: ResolverResult<FormValues>,
229
+ ) => void = () => {};
230
+ const resolver: Resolver<FormValues> = (values) => {
231
+ validationCount += 1;
232
+ if (validationCount === 1) {
233
+ return new Promise((resolve) => {
234
+ resolveAssistantValidation = resolve;
235
+ });
236
+ }
237
+ return Promise.resolve({ values, errors: {} });
238
+ };
239
+ const onValid = vi.fn();
240
+
241
+ const Form = () => {
242
+ const form = useAssistantForm<FormValues>({
243
+ defaultValues: { name: "" },
244
+ resolver,
245
+ });
246
+ return (
247
+ <form
248
+ data-testid="concurrent-form"
249
+ onSubmit={form.handleSubmit(onValid)}
250
+ >
251
+ <input data-testid="concurrent-name" {...form.register("name")} />
252
+ </form>
253
+ );
254
+ };
255
+ const { getByTestId } = render(<Form />);
256
+
257
+ let assistantSubmitSettled = false;
258
+ const assistantSubmit = executeSubmitForm().finally(() => {
259
+ assistantSubmitSettled = true;
260
+ });
261
+ await waitFor(() => expect(validationCount).toBe(1));
262
+
263
+ fireEvent.change(getByTestId("concurrent-name"), {
264
+ target: { value: "Ada" },
265
+ });
266
+ fireEvent.submit(getByTestId("concurrent-form"));
267
+ await waitFor(() => {
268
+ expect(validationCount).toBe(2);
269
+ expect(onValid).toHaveBeenCalledOnce();
270
+ });
271
+ expect(assistantSubmitSettled).toBe(false);
272
+
273
+ resolveAssistantValidation({
274
+ values: {},
275
+ errors: { name: { type: "required", message: "Name is required" } },
276
+ });
277
+ await expect(assistantSubmit).resolves.toEqual({
278
+ success: false,
279
+ message: "The form contains invalid fields and was not submitted.",
280
+ });
281
+ });
282
+
283
+ it("reports when requestSubmit does not dispatch a submit event", async () => {
284
+ const requestSubmit = vi
285
+ .spyOn(HTMLFormElement.prototype, "requestSubmit")
286
+ .mockImplementation(() => {});
287
+ try {
288
+ render(
289
+ <form noValidate>
290
+ <NativeRequiredField />
291
+ </form>,
292
+ );
293
+
294
+ await expect(executeSubmitForm()).resolves.toEqual({
295
+ success: false,
296
+ message: "The form did not accept the submission.",
297
+ });
298
+ } finally {
299
+ requestSubmit.mockRestore();
300
+ }
301
+ });
302
+
303
+ it("propagates errors when requestSubmit throws", async () => {
304
+ const error = new Error("requestSubmit unavailable");
305
+ const requestSubmit = vi
306
+ .spyOn(HTMLFormElement.prototype, "requestSubmit")
307
+ .mockImplementation(() => {
308
+ throw error;
309
+ });
310
+ try {
311
+ render(
312
+ <form noValidate>
313
+ <NativeRequiredField />
314
+ </form>,
315
+ );
316
+
317
+ await expect(executeSubmitForm()).rejects.toBe(error);
318
+ } finally {
319
+ requestSubmit.mockRestore();
320
+ }
321
+ });
103
322
  });
323
+
324
+ const NativeRequiredField = () => {
325
+ const form = useAssistantForm<{ name: string }>();
326
+ return <input required {...form.register("name")} />;
327
+ };
328
+
329
+ const ReactHookFormRequiredForm = ({
330
+ capture = false,
331
+ defaultName = "",
332
+ onInvalid,
333
+ onValid,
334
+ noValidate,
335
+ }: {
336
+ capture?: boolean | undefined;
337
+ defaultName?: string | undefined;
338
+ onInvalid?: (() => void) | undefined;
339
+ onValid: () => void;
340
+ noValidate?: boolean | undefined;
341
+ }) => {
342
+ const form = useAssistantForm<{ name: string }>({
343
+ defaultValues: { name: defaultName },
344
+ });
345
+ const handleSubmit = form.handleSubmit(onValid, onInvalid);
346
+ return (
347
+ <form
348
+ noValidate={noValidate}
349
+ onSubmit={capture ? undefined : handleSubmit}
350
+ onSubmitCapture={capture ? handleSubmit : undefined}
351
+ >
352
+ <input
353
+ data-testid="name-input"
354
+ {...form.register("name", { required: true })}
355
+ />
356
+ <output data-testid="submit-count">{form.formState.submitCount}</output>
357
+ <output data-testid="name-error">
358
+ {form.formState.errors.name ? "invalid" : "valid"}
359
+ </output>
360
+ </form>
361
+ );
362
+ };
@@ -4,7 +4,7 @@ import { type ModelContext, tool } from "@assistant-ui/core";
4
4
  import type {} from "@assistant-ui/core/store";
5
5
  import { type ToolCallMessagePartComponent } from "@assistant-ui/core/react";
6
6
  import { useAui } from "@assistant-ui/store";
7
- import { useEffect } from "react";
7
+ import { useCallback, useEffect, useMemo, useRef } from "react";
8
8
  import {
9
9
  type Field,
10
10
  type FieldValues,
@@ -18,6 +18,15 @@ import {
18
18
  import type { z } from "zod";
19
19
  import { formTools } from "./formTools";
20
20
 
21
+ type PendingAssistantSubmit = {
22
+ dispatching: boolean;
23
+ event: unknown;
24
+ handlerInvoked: boolean;
25
+ outcome: boolean | undefined;
26
+ resolve: (outcome: boolean) => void;
27
+ reject: (error: unknown) => void;
28
+ };
29
+
21
30
  export type UseAssistantFormProps<
22
31
  TFieldValues extends FieldValues,
23
32
  TContext,
@@ -78,9 +87,93 @@ export const useAssistantForm = <
78
87
  getValues,
79
88
  setValue,
80
89
  reset,
90
+ handleSubmit: baseHandleSubmit,
81
91
  formState: { isSubmitting },
82
92
  } = form;
83
93
 
94
+ const pendingAssistantSubmitRef = useRef<PendingAssistantSubmit | null>(null);
95
+ const settleAssistantSubmit = useCallback((outcome: boolean) => {
96
+ const pending = pendingAssistantSubmitRef.current;
97
+ if (!pending) return;
98
+
99
+ pendingAssistantSubmitRef.current = null;
100
+ pending.resolve(outcome);
101
+ }, []);
102
+ const rejectAssistantSubmit = useCallback((error: unknown) => {
103
+ const pending = pendingAssistantSubmitRef.current;
104
+ if (!pending) return;
105
+
106
+ pendingAssistantSubmitRef.current = null;
107
+ pending.reject(error);
108
+ }, []);
109
+
110
+ const handleSubmit = useCallback<
111
+ UseFormReturn<TFieldValues, TContext, TTransformedValues>["handleSubmit"]
112
+ >(
113
+ (onValid, onInvalid) => {
114
+ const submit = baseHandleSubmit(
115
+ (...args) => {
116
+ const pending = pendingAssistantSubmitRef.current;
117
+ const event = args[1]?.nativeEvent ?? args[1];
118
+ if (pending && pending.event === event) pending.outcome = true;
119
+ return onValid(...args);
120
+ },
121
+ (...args) => {
122
+ const pending = pendingAssistantSubmitRef.current;
123
+ const event = args[1]?.nativeEvent ?? args[1];
124
+ if (pending && pending.event === event) pending.outcome = false;
125
+ return onInvalid?.(...args);
126
+ },
127
+ );
128
+
129
+ return (async (event) => {
130
+ const pending = pendingAssistantSubmitRef.current;
131
+ const nativeEvent = event?.nativeEvent ?? event;
132
+ const assistantSubmit =
133
+ pending &&
134
+ (pending.event === nativeEvent ||
135
+ (pending.dispatching &&
136
+ pending.event === undefined &&
137
+ nativeEvent !== undefined))
138
+ ? pending
139
+ : null;
140
+ if (assistantSubmit) {
141
+ assistantSubmit.event = nativeEvent;
142
+ assistantSubmit.handlerInvoked = true;
143
+ }
144
+
145
+ try {
146
+ const result = await submit(event);
147
+ if (
148
+ assistantSubmit &&
149
+ pendingAssistantSubmitRef.current === assistantSubmit
150
+ ) {
151
+ settleAssistantSubmit(assistantSubmit.outcome ?? true);
152
+ }
153
+ return result;
154
+ } catch (error) {
155
+ if (pendingAssistantSubmitRef.current === assistantSubmit) {
156
+ rejectAssistantSubmit(error);
157
+ }
158
+ throw error;
159
+ }
160
+ }) as typeof submit;
161
+ },
162
+ [baseHandleSubmit, rejectAssistantSubmit, settleAssistantSubmit],
163
+ );
164
+ const assistantForm = useMemo<
165
+ UseFormReturn<TFieldValues, TContext, TTransformedValues>
166
+ >(
167
+ () => ({
168
+ ...form,
169
+ handleSubmit,
170
+ get formState() {
171
+ return form.formState;
172
+ },
173
+ }),
174
+ [form, handleSubmit],
175
+ );
176
+
84
177
  const aui = useAui();
85
178
  useEffect(() => {
86
179
  const value: ModelContext = {
@@ -100,13 +193,14 @@ export const useAssistantForm = <
100
193
  submit_form: tool({
101
194
  ...formTools.submit_form,
102
195
  execute: async () => {
103
- if (isSubmitting) {
196
+ if (isSubmitting || pendingAssistantSubmitRef.current) {
104
197
  return {
105
198
  success: false,
106
199
  message: "The form is already submitting.",
107
200
  };
108
201
  }
109
202
  const { _names, _fields } = control;
203
+ let formElement: HTMLFormElement | null = null;
110
204
  for (const name of _names.mount) {
111
205
  const field: Field | undefined = get(_fields, name);
112
206
  if (field?._f) {
@@ -115,14 +209,78 @@ export const useAssistantForm = <
115
209
  : field._f.ref;
116
210
 
117
211
  if (fieldReference instanceof HTMLElement) {
118
- const form = fieldReference.closest("form");
119
- if (form) {
120
- form.requestSubmit();
212
+ formElement = fieldReference.closest("form");
213
+ if (formElement) break;
214
+ }
215
+ }
216
+ }
121
217
 
122
- return { success: true };
123
- }
218
+ if (formElement) {
219
+ if (!formElement.noValidate && !formElement.reportValidity()) {
220
+ return {
221
+ success: false,
222
+ message:
223
+ "The form contains invalid fields and was not submitted.",
224
+ };
225
+ }
226
+
227
+ let resolveSubmission: (outcome: boolean) => void = () => {};
228
+ let rejectSubmission: (error: unknown) => void = () => {};
229
+ const submissionResult = new Promise<boolean>(
230
+ (resolve, reject) => {
231
+ resolveSubmission = resolve;
232
+ rejectSubmission = reject;
233
+ },
234
+ );
235
+ const assistantSubmit: PendingAssistantSubmit = {
236
+ dispatching: true,
237
+ event: undefined,
238
+ handlerInvoked: false,
239
+ outcome: undefined,
240
+ resolve: resolveSubmission,
241
+ reject: rejectSubmission,
242
+ };
243
+ pendingAssistantSubmitRef.current = assistantSubmit;
244
+ const onSubmit = (event: SubmitEvent) => {
245
+ if (
246
+ pendingAssistantSubmitRef.current === assistantSubmit &&
247
+ assistantSubmit.event === undefined
248
+ ) {
249
+ assistantSubmit.event = event;
124
250
  }
251
+ queueMicrotask(() => {
252
+ if (
253
+ pendingAssistantSubmitRef.current === assistantSubmit &&
254
+ !assistantSubmit.handlerInvoked
255
+ ) {
256
+ settleAssistantSubmit(true);
257
+ }
258
+ });
259
+ };
260
+
261
+ formElement.addEventListener("submit", onSubmit, { once: true });
262
+ try {
263
+ formElement.requestSubmit();
264
+ } catch (error) {
265
+ settleAssistantSubmit(false);
266
+ throw error;
267
+ } finally {
268
+ assistantSubmit.dispatching = false;
269
+ formElement.removeEventListener("submit", onSubmit);
125
270
  }
271
+
272
+ const dispatched = assistantSubmit.event !== undefined;
273
+ if (!dispatched) {
274
+ settleAssistantSubmit(false);
275
+ }
276
+
277
+ if (await submissionResult) return { success: true };
278
+ return {
279
+ success: false,
280
+ message: dispatched
281
+ ? "The form contains invalid fields and was not submitted."
282
+ : "The form did not accept the submission.",
283
+ };
126
284
  }
127
285
 
128
286
  return {
@@ -148,7 +306,16 @@ export const useAssistantForm = <
148
306
  system: `Form State:\n${JSON.stringify(getValues())}`,
149
307
  }),
150
308
  });
151
- }, [control, setValue, getValues, aui, reset, isSubmitting]);
309
+ }, [
310
+ control,
311
+ setValue,
312
+ getValues,
313
+ aui,
314
+ reset,
315
+ isSubmitting,
316
+ rejectAssistantSubmit,
317
+ settleAssistantSubmit,
318
+ ]);
152
319
 
153
320
  const renderFormFieldTool = props?.assistant?.tools?.set_form_field?.render;
154
321
  useEffect(() => {
@@ -168,5 +335,5 @@ export const useAssistantForm = <
168
335
  return aui.tools.setToolUI("reset_form", renderResetFormTool);
169
336
  }, [aui, renderResetFormTool]);
170
337
 
171
- return form;
338
+ return assistantForm;
172
339
  };