From e230647d5a2a42ab126235f20bb3db7671201a70 Mon Sep 17 00:00:00 2001 From: Dzenan Jupic <56133904+DzenanJupic@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:40:03 +0200 Subject: [PATCH 1/4] Losen up `[Multi]ActionForm` signature Instead of only allowing `PostUrl`, we take a generic `InputProtocol` argument, which is restricted to either `PostUrl` or `MultiPartFormData`. Also, we don't require `DeserializeOwned` on `FromFormData`, so that we can implement it for server functions. --- leptos/Cargo.toml | 2 +- leptos/src/form.rs | 39 ++++++++++++++++++++++----------------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/leptos/Cargo.toml b/leptos/Cargo.toml index cdf6bd4325..1e9a881072 100644 --- a/leptos/Cargo.toml +++ b/leptos/Cargo.toml @@ -45,7 +45,7 @@ typed-builder = { workspace = true, default-features = true } typed-builder-macro = { workspace = true, default-features = true } serde = { workspace = true, default-features = true } serde_json = { workspace = true, default-features = true } -server_fn = { workspace = true, features = ["form-redirects", "browser"] } +server_fn = { workspace = true, features = ["form-redirects", "browser", "multipart"] } web-sys = { features = [ "ShadowRoot", "ShadowRootInit", diff --git a/leptos/src/form.rs b/leptos/src/form.rs index 529b3f9025..78f947e489 100644 --- a/leptos/src/form.rs +++ b/leptos/src/form.rs @@ -1,10 +1,8 @@ use crate::{children::Children, component, prelude::*, IntoView}; use leptos_dom::helpers::window; use leptos_server::{ServerAction, ServerMultiAction}; -use serde::de::DeserializeOwned; use server_fn::{ client::Client, - codec::PostUrl, error::{IntoAppError, ServerFnErrorErr}, request::ClientReq, Http, ServerFn, @@ -75,7 +73,7 @@ use web_sys::{ /// ``` #[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip_all))] #[component] -pub fn ActionForm( +pub fn ActionForm( /// The action from which to build the form. action: ServerAction, /// A [`NodeRef`] in which the `
` element should be stored. @@ -85,8 +83,8 @@ pub fn ActionForm( children: Children, ) -> impl IntoView where - ServFn: DeserializeOwned - + ServerFn> + ServFn: FromFormData + + ServerFn> + Clone + Send + Sync @@ -98,6 +96,7 @@ where ServFn::Output: Send + Sync + 'static, ServFn::Error: Send + Sync + 'static, ::Client: Client<::Error>, + InputProtocol: enc_type::EncType, { // if redirect hook has not yet been set (by a router), defaults to a browser redirect _ = server_fn::redirect::set_redirect_hook(|loc: &str| { @@ -152,7 +151,7 @@ where /// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form) /// progressively enhanced to use client-side routing. #[component] -pub fn MultiActionForm( +pub fn MultiActionForm( /// The action from which to build the form. action: ServerMultiAction, /// A [`NodeRef`] in which the `` element should be stored. @@ -165,8 +164,8 @@ where ServFn: Send + Sync + Clone - + DeserializeOwned - + ServerFn> + + FromFormData + + ServerFn> + 'static, ServFn::Output: Send + Sync + 'static, <>::Request as ClientReq< @@ -174,6 +173,7 @@ where >>::FormData: From, ServFn::Error: Send + Sync + 'static, ::Client: Client<::Error>, + InputProtocol: enc_type::EncType, { // if redirect hook has not yet been set (by a router), defaults to a browser redirect _ = server_fn::redirect::set_redirect_hook(|loc: &str| { @@ -244,10 +244,15 @@ pub(crate) fn resolve_redirect_url(loc: &str) -> Option { /// validation during form submission. pub trait FromFormData where - Self: Sized + serde::de::DeserializeOwned, + Self: Sized, { /// Tries to deserialize the data, given only the `submit` event. - fn from_event(ev: &web_sys::Event) -> Result; + fn from_event(ev: &web_sys::Event) -> Result { + let submit_ev = ev.unchecked_ref(); + let form_data = form_data_from_event(submit_ev)?; + Self::from_form_data(&form_data) + .map_err(FromFormDataError::Deserialization) + } /// Tries to deserialize the data, given the actual form data. fn from_form_data( @@ -273,13 +278,6 @@ impl FromFormData for T where T: serde::de::DeserializeOwned, { - fn from_event(ev: &Event) -> Result { - let submit_ev = ev.unchecked_ref(); - let form_data = form_data_from_event(submit_ev)?; - Self::from_form_data(&form_data) - .map_err(FromFormDataError::Deserialization) - } - fn from_form_data( form_data: &web_sys::FormData, ) -> Result { @@ -325,3 +323,10 @@ fn form_data_from_event( } } } + +mod enc_type { + pub trait EncType {} + + impl EncType for server_fn::codec::PostUrl {} + impl EncType for server_fn::codec::MultipartFormData {} +} From 5eed364d7c995efab05ae376bb9649947ba7d543 Mon Sep 17 00:00:00 2001 From: Dzenan Jupic <56133904+DzenanJupic@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:07:29 +0200 Subject: [PATCH 2/4] Implement client side `Clone` for `MultipartData` This currently panics on the server side, which is far from ideal. --- server_fn/src/codec/multipart.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server_fn/src/codec/multipart.rs b/server_fn/src/codec/multipart.rs index 7cce7aa2c9..3e1496e978 100644 --- a/server_fn/src/codec/multipart.rs +++ b/server_fn/src/codec/multipart.rs @@ -31,6 +31,20 @@ pub enum MultipartData { Server(multer::Multipart<'static>), } +impl Clone for MultipartData { + fn clone(&self) -> Self { + match self { + Self::Client(data) => { + let form_data = (*data.0).clone(); + Self::from(form_data) + } + Self::Server(_) => { + panic!("Cannot clone server side multipart data") + } + } + } +} + impl MultipartData { /// Extracts the inner data to handle as a stream. /// From 2a4c4748aa4bc5ef52382fbcf8a9624257c3e0d5 Mon Sep 17 00:00:00 2001 From: Dzenan Jupic <56133904+DzenanJupic@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:08:08 +0200 Subject: [PATCH 3/4] Add naive macro support for `MultipartData` codec All we need to do is derive `Clone` and implement `FromFormData`. The former is trivial. The later however is quite hacky, as it requires us to 1. reference `leptos`, which is not necessarily there when just using `server_fn`, and 2. makes the assumption that the only field is of type `MultipartData`. I think that's a fair assumption though. --- leptos/src/lib.rs | 1 + server_fn_macro/src/lib.rs | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/leptos/src/lib.rs b/leptos/src/lib.rs index ad70ca17a2..81c93b3940 100644 --- a/leptos/src/lib.rs +++ b/leptos/src/lib.rs @@ -345,6 +345,7 @@ pub mod task { pub use serde; #[doc(hidden)] pub use serde_json; +pub use serde_qs; #[cfg(feature = "tracing")] #[doc(hidden)] pub use tracing; diff --git a/server_fn_macro/src/lib.rs b/server_fn_macro/src/lib.rs index b37c414a9b..e8cb778f5b 100644 --- a/server_fn_macro/src/lib.rs +++ b/server_fn_macro/src/lib.rs @@ -341,9 +341,10 @@ impl ServerFnCall { Clone, #server_fn_path::rkyv::Archive, #server_fn_path::rkyv::Serialize, #server_fn_path::rkyv::Deserialize }, ), - Some("MultipartFormData") - | Some("Streaming") - | Some("StreamingText") => (PathInfo::None, quote! {}), + Some("MultipartFormData") => (PathInfo::None, quote! { Clone }), + Some("Streaming") | Some("StreamingText") => { + (PathInfo::None, quote! {}) + } Some("SerdeLite") => ( PathInfo::Serde, quote! { @@ -744,6 +745,31 @@ impl ServerFnCall { } } + fn impl_from_form(&self) -> TokenStream2 { + if self.input_ident().as_deref() != Some("MultipartFormData") { + return quote! {}; + } + + let Some((field_name, _)) = self.single_field() else { + return quote! {}; + }; + + let server_fn_path = self.server_fn_path(); + let struct_name = self.struct_name(); + + quote! { + impl ::leptos::form::FromFormData for #struct_name { + fn from_form_data( + form_data: &::leptos::web_sys::FormData, + ) -> Result { + Ok(Self { + #field_name: #server_fn_path::codec::MultipartData::from(form_data.clone()), + }) + } + } + } + } + fn func_tokens(&self) -> TokenStream2 { let body = &self.body; // default values for args @@ -811,6 +837,8 @@ impl ToTokens for ServerFnCall { let impl_from = self.impl_from(); + let impl_from_form = self.impl_from_form(); + let deref_impl = self.deref_impl(); let inventory = self.submit_to_inventory(); @@ -826,6 +854,8 @@ impl ToTokens for ServerFnCall { #impl_from + #impl_from_form + #deref_impl #server_fn_impl From 9b0dde0ae87f22f8e1bd7641e191ebab274c510d Mon Sep 17 00:00:00 2001 From: Dzenan Jupic <56133904+DzenanJupic@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:24:14 +0200 Subject: [PATCH 4/4] Set the `enctype` on `[Multi]ActionForm` --- leptos/src/form.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/leptos/src/form.rs b/leptos/src/form.rs index 78f947e489..61ea081fa5 100644 --- a/leptos/src/form.rs +++ b/leptos/src/form.rs @@ -138,6 +138,7 @@ where let action_form = form() .action(ServFn::url()) .method("post") + .enctype(::CONTENT_TYPE) .on(submit, on_submit) .child(children()); if let Some(node_ref) = node_ref { @@ -206,6 +207,7 @@ where .action(ServFn::url()) .method("post") .attr("method", "post") + .enctype(::CONTENT_TYPE) .on(submit, on_submit) .child(children()); if let Some(node_ref) = node_ref { @@ -325,7 +327,7 @@ fn form_data_from_event( } mod enc_type { - pub trait EncType {} + pub trait EncType: server_fn::ContentType {} impl EncType for server_fn::codec::PostUrl {} impl EncType for server_fn::codec::MultipartFormData {}