Merge pull request #92 from gbj/action-api

`Action` and `MultiAction` API changes
This commit is contained in:
Greg Johnston 2022-11-18 15:21:07 -05:00 committed by GitHub
commit 5ff806d35a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1153 additions and 391 deletions

View File

@ -23,6 +23,7 @@ members = [
"examples/parent-child",
"examples/router",
"examples/todomvc",
"examples/todo-app-sqlite",
"examples/view-tests",
# book

View File

@ -3,16 +3,19 @@
This example demonstrates how to use a function isomorphically, to run a server side function from the browser and receive a result.
## Server Side Rendering With Hydration
To run it as a server side app with hydration, first you should run
```bash
wasm-pack build --target=web --no-default-features --features=hydrate
```
to generate the Webassembly to provide hydration features for the server.
Then run the server with `cargo run` to serve the server side rendered HTML and the WASM bundle for hydration.
```bash
cargo run --no-default-features --features=ssr
```
> Note that if your hydration code changes, you will have to rerun the wasm-pack command above
> This should be temporary, and vastly improve once cargo-leptos becomes ready for prime time!
If for some reason you want to run it as a fully client side app, that can be done with the instructions below.

View File

@ -0,0 +1,46 @@
[package]
name = "todo-app-sqlite"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
actix-files = { version = "0.6", optional = true }
actix-web = { version = "4", optional = true, features = ["openssl", "macros"] }
anyhow = "1"
broadcaster = "1"
console_log = "0.2"
console_error_panic_hook = "0.1"
serde = { version = "1", features = ["derive"] }
futures = "0.3"
cfg-if = "1"
leptos = { path = "../../../leptos/leptos", default-features = false, features = [
"serde",
] }
leptos_meta = { path = "../../../leptos/meta", default-features = false }
leptos_router = { path = "../../../leptos/router", default-features = false }
log = "0.4"
simple_logger = "2"
gloo = { git = "https://github.com/rustwasm/gloo" }
sqlx = { version = "0.6", features = [
"runtime-tokio-rustls",
"sqlite",
], optional = true }
[features]
default = ["ssr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:actix-files",
"dep:actix-web",
"dep:sqlx",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
]
[package.metadata.cargo-all-features]
denylist = ["actix-files", "actix-web", "sqlx"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Greg Johnston
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,21 @@
# Leptos Counter Isomorphic Example
This example demonstrates how to use a server functions and multi-actions to build a simple todo app.
## Server Side Rendering With Hydration
To run it as a server side app with hydration, first you should run
```bash
wasm-pack build --target=web --no-default-features --features=hydrate
```
to generate the Webassembly to provide hydration features for the server.
Then run the server with `cargo run` to serve the server side rendered HTML and the WASM bundle for hydration.
```bash
cargo run
```
> Note that if your hydration code changes, you will have to rerun the wasm-pack command above
> This should be temporary, and vastly improve once cargo-leptos becomes ready for prime time!

Binary file not shown.

View File

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS todos
(
id INTEGER NOT NULL PRIMARY KEY,
title VARCHAR,
completed BOOLEAN
);

View File

@ -0,0 +1,22 @@
use cfg_if::cfg_if;
use leptos::*;
pub mod todo;
// Needs to be in lib.rs AFAIK because wasm-bindgen needs us to be compiling a lib. I may be wrong.
cfg_if! {
if #[cfg(feature = "hydrate")] {
use wasm_bindgen::prelude::wasm_bindgen;
use crate::todo::*;
#[wasm_bindgen]
pub fn hydrate() {
console_error_panic_hook::set_once();
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::hydrate(body().unwrap(), |cx| {
view! { cx, <TodoApp/> }
});
}
}
}

View File

@ -0,0 +1,109 @@
use cfg_if::cfg_if;
use leptos::*;
use leptos_router::*;
mod todo;
// boilerplate to run in different modes
cfg_if! {
// server-only stuff
if #[cfg(feature = "ssr")] {
use actix_files::{Files};
use actix_web::*;
use crate::todo::*;
#[get("{tail:.*}")]
async fn render(req: HttpRequest) -> impl Responder {
let path = req.path();
let path = "http://leptos".to_string() + path;
println!("path = {path}");
HttpResponse::Ok().content_type("text/html").body(format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Leptos Todos</title>
<style>.pending {{ color: purple; }}</style>
</head>
<body>
{}
</body>
<script type="module">import init, {{ hydrate }} from './pkg/todo_app_sqlite.js'; init().then(hydrate);</script>
</html>"#,
run_scope({
move |cx| {
let integration = ServerIntegration { path: path.clone() };
provide_context(cx, RouterIntegrationContext::new(integration));
view! { cx, <TodoApp/>}
}
})
))
}
#[post("/api/{tail:.*}")]
async fn handle_server_fns(
req: HttpRequest,
params: web::Path<String>,
body: web::Bytes,
) -> impl Responder {
let path = params.into_inner();
let accept_header = req
.headers()
.get("Accept")
.and_then(|value| value.to_str().ok());
if let Some(server_fn) = server_fn_by_path(path.as_str()) {
let body: &[u8] = &body;
match server_fn(&body).await {
Ok(serialized) => {
// if this is Accept: application/json then send a serialized JSON response
if let Some("application/json") = accept_header {
HttpResponse::Ok().body(serialized)
}
// otherwise, it's probably a <form> submit or something: redirect back to the referrer
else {
HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.content_type("application/json")
.body(serialized)
}
}
Err(e) => {
eprintln!("server function error: {e:#?}");
HttpResponse::InternalServerError().body(e.to_string())
}
}
} else {
HttpResponse::BadRequest().body(format!("Could not find a server function at that route."))
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let mut conn = db().await.expect("couldn't connect to DB");
sqlx::migrate!()
.run(&mut conn)
.await
.expect("could not run SQLx migrations");
crate::todo::register_server_functions();
HttpServer::new(|| {
App::new()
.service(Files::new("/pkg", "./pkg"))
.service(handle_server_fns)
.service(render)
//.wrap(middleware::Compress::default())
})
.bind(("127.0.0.1", 8081))?
.run()
.await
}
} else {
fn main() {
// no client-side main function
}
}
}

View File

@ -0,0 +1,195 @@
use cfg_if::cfg_if;
use leptos::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
cfg_if! {
if #[cfg(feature = "ssr")] {
use sqlx::{Connection, SqliteConnection};
pub async fn db() -> Result<SqliteConnection, ServerFnError> {
Ok(SqliteConnection::connect("sqlite:Todos.db").await.map_err(|e| ServerFnError::ServerError(e.to_string()))?)
}
pub fn register_server_functions() {
GetTodos::register();
AddTodo::register();
DeleteTodo::register();
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
} else {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
}
}
#[server(GetTodos, "/api")]
pub async fn get_todos() -> Result<Vec<Todo>, ServerFnError> {
use futures::TryStreamExt;
let mut conn = db().await?;
let mut todos = Vec::new();
let mut rows = sqlx::query_as::<_, Todo>("SELECT * FROM todos").fetch(&mut conn);
while let Some(row) = rows
.try_next()
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?
{
todos.push(row);
}
Ok(todos)
}
#[server(AddTodo, "/api")]
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
let mut conn = db().await?;
// fake API delay
std::thread::sleep(std::time::Duration::from_millis(1250));
match sqlx::query("INSERT INTO todos (title, completed) VALUES ($1, false)")
.bind(title)
.execute(&mut conn)
.await
{
Ok(row) => Ok(()),
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
}
}
#[server(DeleteTodo, "/api")]
pub async fn delete_todo(id: u16) -> Result<(), ServerFnError> {
let mut conn = db().await?;
sqlx::query("DELETE FROM todos WHERE id = $1")
.bind(id)
.execute(&mut conn)
.await
.map(|_| ())
.map_err(|e| ServerFnError::ServerError(e.to_string()))
}
#[component]
pub fn TodoApp(cx: Scope) -> Element {
view! {
cx,
<div>
<Router>
<header>
<h1>"My Tasks"</h1>
</header>
<main>
<Routes>
<Route path="" element=|cx| view! {
cx,
<Todos/>
}/>
</Routes>
</main>
</Router>
</div>
}
}
#[component]
pub fn Todos(cx: Scope) -> Element {
let add_todo = create_server_multi_action::<AddTodo>(cx);
let delete_todo = create_server_action::<DeleteTodo>(cx);
let submissions = add_todo.submissions();
// track mutations that should lead us to refresh the list
let add_changed = add_todo.version;
let todo_deleted = delete_todo.version;
// list of todos is loaded from the server in reaction to changes
let todos = create_resource(
cx,
move || (add_changed(), todo_deleted()),
|_| get_todos()
);
let existing_todos = {
let delete_todo = delete_todo.clone();
move || {
todos
.read()
.map({
let delete_todo = delete_todo.clone();
move |todos| match todos {
Err(e) => {
vec![view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}]
}
Ok(todos) => {
if todos.is_empty() {
vec![view! { cx, <p>"No tasks were found."</p> }]
} else {
todos
.into_iter()
.map({
let delete_todo = delete_todo.clone();
move |todo| {
let delete_todo = delete_todo.clone();
view! {
cx,
<li>
{todo.title}
<ActionForm action=delete_todo.clone()>
<input type="hidden" name="id" value={todo.id}/>
<input type="submit" value="X"/>
</ActionForm>
</li>
}
}
})
.collect::<Vec<_>>()
}
}
}
})
.unwrap_or_default()
}
};
let pending_todos = move || {
submissions
.get()
.into_iter()
.filter(|submission| submission.pending().get())
.map(|submission| {
view! {
cx,
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li>
}
})
.collect::<Vec<_>>()
};
view! {
cx,
<div>
<MultiActionForm action=add_todo>
<label>
"Add a Todo"
<input type="text" name="title"/>
</label>
<input type="submit" value="Add"/>
</MultiActionForm>
<ul>
<div>{existing_todos}</div>
<div>{pending_todos}</div>
</ul>
</div>
}
}

271
leptos_server/src/action.rs Normal file
View File

@ -0,0 +1,271 @@
use crate::{ServerFn, ServerFnError};
use leptos_reactive::{create_rw_signal, spawn_local, ReadSignal, RwSignal, Scope};
use std::{future::Future, pin::Pin, rc::Rc};
/// An action synchronizes an imperative `async` call to the synchronous reactive system.
///
/// If youre trying to load data by running an `async` function reactively, you probably
/// want to use a [Resource](leptos_reactive::Resource) instead. If youre trying to occasionally
/// run an `async` function in response to something like a user clicking a button, you're in the right place.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_action;
/// # run_scope(|cx| {
/// async fn send_new_todo_to_api(task: String) -> usize {
/// // do something...
/// // return a task id
/// 42
/// }
/// let save_data = create_action(cx, |task: &String| {
/// // `task` is given as `&String` because its value is available in `input`
/// send_new_todo_to_api(task.clone())
/// });
///
/// // the argument currently running
/// let input = save_data.input();
/// // the most recent returned result
/// let result_of_call = save_data.value();
/// // whether the call is pending
/// let pending = save_data.pending();
/// // how many times the action has run
/// // useful for reactively updating something else in response to a `dispatch` and response
/// let version = save_data.version;
///
/// // before we do anything
/// assert_eq!(input(), None); // no argument yet
/// assert_eq!(pending(), false); // isn't pending a response
/// assert_eq!(result_of_call(), None); // there's no "last value"
/// assert_eq!(version(), 0);
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// // dispatch the action
/// save_data.dispatch("My todo".to_string());
///
/// // when we're making the call
/// // assert_eq!(input(), Some("My todo".to_string()));
/// // assert_eq!(pending(), true); // is pending
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
///
/// // after call has resolved
/// assert_eq!(input(), None); // input clears out after resolved
/// assert_eq!(pending(), false); // no longer pending
/// assert_eq!(result_of_call(), Some(42));
/// assert_eq!(version(), 1);
/// # }
/// # });
/// ```
///
/// The input to the `async` function should always be a single value,
/// but it can be of any type. The argument is always passed by reference to the
/// function, because it is stored in [Action::input] as well.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_action;
/// # run_scope(|cx| {
/// // if there's a single argument, just use that
/// let action1 = create_action(cx, |input: &String| {
/// let input = input.clone();
/// async move { todo!() }
/// });
///
/// // if there are no arguments, use the unit type `()`
/// let action2 = create_action(cx, |input: &()| async { todo!() });
///
/// // if there are multiple arguments, use a tuple
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
/// # });
/// ```
#[derive(Clone)]
pub struct Action<I, O>
where
I: 'static,
O: 'static,
{
/// How many times the action has successfully resolved.
pub version: RwSignal<usize>,
/// The current argument that was dispatched to the `async` function.
/// `Some` while we are waiting for it to resolve, `None` if it has resolved.
pub input: RwSignal<Option<I>>,
/// The most recent return value of the `async` function.
pub value: RwSignal<Option<O>>,
pending: RwSignal<bool>,
url: Option<String>,
#[allow(clippy::complexity)]
action_fn: Rc<dyn Fn(&I) -> Pin<Box<dyn Future<Output = O>>>>,
}
impl<I, O> Action<I, O>
where
I: 'static,
O: 'static,
{
/// Calls the `async` function with a reference to the input type as its argument.
pub fn dispatch(&self, input: I) {
let fut = (self.action_fn)(&input);
self.input.set(Some(input));
let input = self.input;
let version = self.version;
let pending = self.pending;
let value = self.value;
pending.set(true);
spawn_local(async move {
let new_value = fut.await;
input.set(None);
pending.set(false);
value.set(Some(new_value));
version.update(|n| *n += 1);
})
}
/// Whether the action has been dispatched and is currently waiting for its future to be resolved.
pub fn pending(&self) -> ReadSignal<bool> {
self.pending.read_only()
}
/// The URL associated with the action (typically as part of a server function.)
/// This enables integration with the `ActionForm` component in `leptos_router`.
pub fn url(&self) -> Option<&str> {
self.url.as_deref()
}
/// Associates the URL of the given server function with this action.
/// This enables integration with the `ActionForm` component in `leptos_router`.
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
let prefix = T::prefix();
self.url = if prefix.is_empty() {
Some(T::url().to_string())
} else {
Some(prefix.to_string() + "/" + T::url())
};
self
}
}
/// Creates an [Action] to synchronize an imperative `async` call to the synchronous reactive system.
///
/// If youre trying to load data by running an `async` function reactively, you probably
/// want to use a [create_resource](leptos_reactive::create_resource) instead. If youre trying
/// to occasionally run an `async` function in response to something like a user clicking a button,
/// you're in the right place.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_action;
/// # run_scope(|cx| {
/// async fn send_new_todo_to_api(task: String) -> usize {
/// // do something...
/// // return a task id
/// 42
/// }
/// let save_data = create_action(cx, |task: &String| {
/// // `task` is given as `&String` because its value is available in `input`
/// send_new_todo_to_api(task.clone())
/// });
///
/// // the argument currently running
/// let input = save_data.input();
/// // the most recent returned result
/// let result_of_call = save_data.value();
/// // whether the call is pending
/// let pending = save_data.pending();
/// // how many times the action has run
/// // useful for reactively updating something else in response to a `dispatch` and response
/// let version = save_data.version;
///
/// // before we do anything
/// assert_eq!(input(), None); // no argument yet
/// assert_eq!(pending(), false); // isn't pending a response
/// assert_eq!(result_of_call(), None); // there's no "last value"
/// assert_eq!(version(), 0);
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// // dispatch the action
/// save_data.dispatch("My todo".to_string());
///
/// // when we're making the call
/// // assert_eq!(input(), Some("My todo".to_string()));
/// // assert_eq!(pending(), true); // is pending
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
///
/// // after call has resolved
/// assert_eq!(input(), None); // input clears out after resolved
/// assert_eq!(pending(), false); // no longer pending
/// assert_eq!(result_of_call(), Some(42));
/// assert_eq!(version(), 1);
/// # }
/// # });
/// ```
///
/// The input to the `async` function should always be a single value,
/// but it can be of any type. The argument is always passed by reference to the
/// function, because it is stored in [Action::input] as well.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_action;
/// # run_scope(|cx| {
/// // if there's a single argument, just use that
/// let action1 = create_action(cx, |input: &String| {
/// let input = input.clone();
/// async move { todo!() }
/// });
///
/// // if there are no arguments, use the unit type `()`
/// let action2 = create_action(cx, |input: &()| async { todo!() });
///
/// // if there are multiple arguments, use a tuple
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
/// # });
/// ```
pub fn create_action<I, O, F, Fu>(cx: Scope, action_fn: F) -> Action<I, O>
where
I: 'static,
O: 'static,
F: Fn(&I) -> Fu + 'static,
Fu: Future<Output = O> + 'static,
{
let version = create_rw_signal(cx, 0);
let input = create_rw_signal(cx, None);
let value = create_rw_signal(cx, None);
let pending = create_rw_signal(cx, false);
let action_fn = Rc::new(move |input: &I| {
let fut = action_fn(input);
Box::pin(async move { fut.await }) as Pin<Box<dyn Future<Output = O>>>
});
Action {
version,
url: None,
input,
value,
pending,
action_fn,
}
}
/// Creates an [Action] that can be used to call a server function.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::{create_server_action, ServerFnError, ServerFn};
/// # use leptos_macro::server;
///
/// #[server(MyServerFn)]
/// async fn my_server_fn() -> Result<(), ServerFnError> {
/// todo!()
/// }
///
/// # run_scope(|cx| {
/// let my_server_action = create_server_action::<MyServerFn>(cx);
/// # });
/// ```
pub fn create_server_action<S>(cx: Scope) -> Action<S, Result<S::Output, ServerFnError>>
where
S: Clone + ServerFn,
{
#[cfg(feature = "ssr")]
let c = |args: &S| S::call_fn(args.clone());
#[cfg(not(feature = "ssr"))]
let c = |args: &S| S::call_fn_client(args.clone());
create_action(cx, c).using_server_fn::<S>()
}

View File

@ -62,9 +62,14 @@
pub use form_urlencoded;
use leptos_reactive::*;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::{future::Future, pin::Pin, rc::Rc};
use std::{future::Future, pin::Pin};
use thiserror::Error;
mod action;
mod multi_action;
pub use action::*;
pub use multi_action::*;
#[cfg(any(feature = "ssr", doc))]
use std::{
collections::HashMap,
@ -264,297 +269,3 @@ where
T::from_json(&text).map_err(|e| ServerFnError::Deserialization(e.to_string()))
}
/// An action synchronizes an imperative `async` call to the synchronous reactive system.
///
/// If youre trying to load data by running an `async` function reactively, you probably
/// want to use a [Resource](leptos_reactive::Resource) instead. If youre trying to occasionally
/// run an `async` function in response to something like a user clicking a button, you're in the right place.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_action;
/// # run_scope(|cx| {
/// async fn send_new_todo_to_api(task: String) -> usize {
/// // do something...
/// // return a task id
/// 42
/// }
/// let save_data = create_action(cx, |task: &String| {
/// // `task` is given as `&String` because its value is available in `input`
/// send_new_todo_to_api(task.clone())
/// });
///
/// // the argument currently running
/// let input = save_data.input();
/// // the most recent returned result
/// let result_of_call = save_data.value();
/// // whether the call is pending
/// let pending = save_data.pending();
/// // how many times the action has run
/// // useful for reactively updating something else in response to a `dispatch` and response
/// let version = save_data.version;
///
/// // before we do anything
/// assert_eq!(input(), None); // no argument yet
/// assert_eq!(pending(), false); // isn't pending a response
/// assert_eq!(result_of_call(), None); // there's no "last value"
/// assert_eq!(version(), 0);
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// // dispatch the action
/// save_data.dispatch("My todo".to_string());
///
/// // when we're making the call
/// // assert_eq!(input(), Some("My todo".to_string()));
/// // assert_eq!(pending(), true); // is pending
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
///
/// // after call has resolved
/// assert_eq!(input(), None); // input clears out after resolved
/// assert_eq!(pending(), false); // no longer pending
/// assert_eq!(result_of_call(), Some(42));
/// assert_eq!(version(), 1);
/// # }
/// # });
/// ```
///
/// The input to the `async` function should always be a single value,
/// but it can be of any type. The argument is always passed by reference to the
/// function, because it is stored in [Action::input] as well.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_action;
/// # run_scope(|cx| {
/// // if there's a single argument, just use that
/// let action1 = create_action(cx, |input: &String| {
/// let input = input.clone();
/// async move { todo!() }
/// });
///
/// // if there are no arguments, use the unit type `()`
/// let action2 = create_action(cx, |input: &()| async { todo!() });
///
/// // if there are multiple arguments, use a tuple
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
/// # });
/// ```
#[derive(Clone)]
pub struct Action<I, O>
where
I: 'static,
O: 'static,
{
/// How many times the action has successfully resolved.
pub version: RwSignal<usize>,
input: RwSignal<Option<I>>,
value: RwSignal<Option<O>>,
pending: RwSignal<bool>,
url: Option<String>,
#[allow(clippy::complexity)]
action_fn: Rc<dyn Fn(&I) -> Pin<Box<dyn Future<Output = O>>>>,
}
impl<I, O> Action<I, O>
where
I: 'static,
O: 'static,
{
/// Calls the server function a reference to the input type as its argument.
pub fn dispatch(&self, input: I) {
let fut = (self.action_fn)(&input);
self.input.set(Some(input));
let input = self.input;
let version = self.version;
let pending = self.pending;
let value = self.value;
pending.set(true);
spawn_local(async move {
let new_value = fut.await;
input.set(None);
pending.set(false);
value.set(Some(new_value));
version.update(|n| *n += 1);
})
}
/// Whether the action has been dispatched and is currently waiting for its future to be resolved.
pub fn pending(&self) -> ReadSignal<bool> {
self.pending.read_only()
}
/// The argument that was dispatched to the `async` function,
/// only while we are waiting for it to resolve.
pub fn input(&self) -> ReadSignal<Option<I>> {
self.input.read_only()
}
/// The argument that was dispatched to the `async` function.
///
/// You probably don't need to call this unless you are implementing a form
/// or some other kind of wrapper for an action and need to set the input
/// based on its internal logic.
pub fn set_input(&self, value: I) {
self.input.set(Some(value));
}
/// The most recent return value of the `async` function.
pub fn value(&self) -> ReadSignal<Option<O>> {
self.value.read_only()
}
/// Sets the most recent return value of the `async` function.
///
/// You probably don't need to call this unless you are implementing a form
/// or some other kind of wrapper for an action and need to set the value
/// based on its internal logic.
pub fn set_value(&self, value: O) {
self.value.set(Some(value));
}
/// The URL associated with the action (typically as part of a server function.)
/// This enables integration with the `ActionForm` component in `leptos_router`.
pub fn url(&self) -> Option<&str> {
self.url.as_deref()
}
/// Associates the URL of the given server function with this action.
/// This enables integration with the `ActionForm` component in `leptos_router`.
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
let prefix = T::prefix();
self.url = if prefix.is_empty() {
Some(T::url().to_string())
} else {
Some(prefix.to_string() + "/" + T::url())
};
self
}
}
/// Creates an [Action] to synchronize an imperative `async` call to the synchronous reactive system.
///
/// If youre trying to load data by running an `async` function reactively, you probably
/// want to use a [create_resource](leptos_reactive::create_resource) instead. If youre trying
/// to occasionally run an `async` function in response to something like a user clicking a button,
/// you're in the right place.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_action;
/// # run_scope(|cx| {
/// async fn send_new_todo_to_api(task: String) -> usize {
/// // do something...
/// // return a task id
/// 42
/// }
/// let save_data = create_action(cx, |task: &String| {
/// // `task` is given as `&String` because its value is available in `input`
/// send_new_todo_to_api(task.clone())
/// });
///
/// // the argument currently running
/// let input = save_data.input();
/// // the most recent returned result
/// let result_of_call = save_data.value();
/// // whether the call is pending
/// let pending = save_data.pending();
/// // how many times the action has run
/// // useful for reactively updating something else in response to a `dispatch` and response
/// let version = save_data.version;
///
/// // before we do anything
/// assert_eq!(input(), None); // no argument yet
/// assert_eq!(pending(), false); // isn't pending a response
/// assert_eq!(result_of_call(), None); // there's no "last value"
/// assert_eq!(version(), 0);
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// // dispatch the action
/// save_data.dispatch("My todo".to_string());
///
/// // when we're making the call
/// // assert_eq!(input(), Some("My todo".to_string()));
/// // assert_eq!(pending(), true); // is pending
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
///
/// // after call has resolved
/// assert_eq!(input(), None); // input clears out after resolved
/// assert_eq!(pending(), false); // no longer pending
/// assert_eq!(result_of_call(), Some(42));
/// assert_eq!(version(), 1);
/// # }
/// # });
/// ```
///
/// The input to the `async` function should always be a single value,
/// but it can be of any type. The argument is always passed by reference to the
/// function, because it is stored in [Action::input] as well.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_action;
/// # run_scope(|cx| {
/// // if there's a single argument, just use that
/// let action1 = create_action(cx, |input: &String| {
/// let input = input.clone();
/// async move { todo!() }
/// });
///
/// // if there are no arguments, use the unit type `()`
/// let action2 = create_action(cx, |input: &()| async { todo!() });
///
/// // if there are multiple arguments, use a tuple
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
/// # });
/// ```
pub fn create_action<I, O, F, Fu>(cx: Scope, action_fn: F) -> Action<I, O>
where
I: 'static,
O: 'static,
F: Fn(&I) -> Fu + 'static,
Fu: Future<Output = O> + 'static,
{
let version = create_rw_signal(cx, 0);
let input = create_rw_signal(cx, None);
let value = create_rw_signal(cx, None);
let pending = create_rw_signal(cx, false);
let action_fn = Rc::new(move |input: &I| {
let fut = action_fn(input);
Box::pin(async move { fut.await }) as Pin<Box<dyn Future<Output = O>>>
});
Action {
version,
url: None,
input,
value,
pending,
action_fn,
}
}
/// Creates an [Action] that can be used to call a server function.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::{create_server_action, ServerFnError, ServerFn};
/// # use leptos_macro::server;
///
/// #[server(MyServerFn)]
/// async fn my_server_fn() -> Result<(), ServerFnError> {
/// todo!()
/// }
///
/// # run_scope(|cx| {
/// let my_server_action = create_server_action::<MyServerFn>(cx);
/// # });
/// ```
pub fn create_server_action<S>(cx: Scope) -> Action<S, Result<S::Output, ServerFnError>>
where
S: Clone + ServerFn,
{
#[cfg(feature = "ssr")]
let c = |args: &S| S::call_fn(args.clone());
#[cfg(not(feature = "ssr"))]
let c = |args: &S| S::call_fn_client(args.clone());
create_action(cx, c).using_server_fn::<S>()
}

View File

@ -0,0 +1,276 @@
use crate::{ServerFn, ServerFnError};
use leptos_reactive::{create_rw_signal, spawn_local, ReadSignal, RwSignal, Scope};
use std::{future::Future, pin::Pin, rc::Rc};
/// An action that synchronizes multiple imperative `async` calls to the reactive system,
/// tracking the progress of each one.
///
/// Where an [Action](crate::Action) fires a single call, a `MultiAction` allows you to
/// keep track of multiple in-flight actions.
///
/// If youre trying to load data by running an `async` function reactively, you probably
/// want to use a [Resource](leptos_reactive::Resource) instead. If youre trying to occasionally
/// run an `async` function in response to something like a user adding a task to a todo list,
/// youre in the right place.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_multi_action;
/// # run_scope(|cx| {
/// async fn send_new_todo_to_api(task: String) -> usize {
/// // do something...
/// // return a task id
/// 42
/// }
/// let add_todo = create_multi_action(cx, |task: &String| {
/// // `task` is given as `&String` because its value is available in `input`
/// send_new_todo_to_api(task.clone())
/// });
///
/// add_todo.dispatch("Buy milk".to_string());
/// add_todo.dispatch("???".to_string());
/// add_todo.dispatch("Profit!!!".to_string());
/// # });
/// ```
///
/// The input to the `async` function should always be a single value,
/// but it can be of any type. The argument is always passed by reference to the
/// function, because it is stored in [MultiAction::input] as well.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_multi_action;
/// # run_scope(|cx| {
/// // if there's a single argument, just use that
/// let action1 = create_multi_action(cx, |input: &String| {
/// let input = input.clone();
/// async move { todo!() }
/// });
///
/// // if there are no arguments, use the unit type `()`
/// let action2 = create_multi_action(cx, |input: &()| async { todo!() });
///
/// // if there are multiple arguments, use a tuple
/// let action3 = create_multi_action(cx, |input: &(usize, String)| async { todo!() });
/// # });
/// ```
#[derive(Clone)]
pub struct MultiAction<I, O>
where
I: 'static,
O: 'static,
{
cx: Scope,
/// How many times an action has successfully resolved.
pub version: RwSignal<usize>,
submissions: RwSignal<Vec<Submission<I, O>>>,
url: Option<String>,
#[allow(clippy::complexity)]
action_fn: Rc<dyn Fn(&I) -> Pin<Box<dyn Future<Output = O>>>>,
}
/// An action that has been submitted by dispatching it to a [MultiAction](crate::MultiAction).
pub struct Submission<I, O>
where
I: 'static,
O: 'static,
{
/// The current argument that was dispatched to the `async` function.
/// `Some` while we are waiting for it to resolve, `None` if it has resolved.
pub input: RwSignal<Option<I>>,
/// The most recent return value of the `async` function.
pub value: RwSignal<Option<O>>,
pub(crate) pending: RwSignal<bool>,
/// Controls this submission has been canceled.
pub canceled: RwSignal<bool>,
}
impl<I, O> Clone for Submission<I, O> {
fn clone(&self) -> Self {
Self {
input: self.input,
value: self.value,
pending: self.pending,
canceled: self.canceled,
}
}
}
impl<I, O> Copy for Submission<I, O> {}
impl<I, O> Submission<I, O>
where
I: 'static,
O: 'static,
{
/// Whether this submission is currently waiting to resolve.
pub fn pending(&self) -> ReadSignal<bool> {
self.pending.read_only()
}
/// Cancels the submission, preventing it from resolving.
pub fn cancel(&self) {
self.canceled.set(true);
}
}
impl<I, O> MultiAction<I, O>
where
I: 'static,
O: 'static,
{
/// Calls the `async` function with a reference to the input type as its argument.
pub fn dispatch(&self, input: I) {
let cx = self.cx;
let fut = (self.action_fn)(&input);
let submission = Submission {
input: create_rw_signal(cx, Some(input)),
value: create_rw_signal(cx, None),
pending: create_rw_signal(cx, true),
canceled: create_rw_signal(cx, false),
};
self.submissions.update(|subs| subs.push(submission));
let canceled = submission.canceled;
let input = submission.input;
let pending = submission.pending;
let value = submission.value;
let version = self.version;
spawn_local(async move {
let new_value = fut.await;
let canceled = cx.untrack(move || canceled.get());
input.set(None);
pending.set(false);
if !canceled {
value.set(Some(new_value));
}
version.update(|n| *n += 1);
})
}
/// The set of all submissions to this multi-action.
pub fn submissions(&self) -> ReadSignal<Vec<Submission<I, O>>> {
self.submissions.read_only()
}
/// The URL associated with the action (typically as part of a server function.)
/// This enables integration with the `MultiActionForm` component in `leptos_router`.
pub fn url(&self) -> Option<&str> {
self.url.as_deref()
}
/// Associates the URL of the given server function with this action.
/// This enables integration with the `MultiActionForm` component in `leptos_router`.
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
let prefix = T::prefix();
self.url = if prefix.is_empty() {
Some(T::url().to_string())
} else {
Some(prefix.to_string() + "/" + T::url())
};
self
}
}
/// Creates an [MultiAction] to synchronize an imperative `async` call to the synchronous reactive system.
///
/// If youre trying to load data by running an `async` function reactively, you probably
/// want to use a [create_resource](leptos_reactive::create_resource) instead. If youre trying
/// to occasionally run an `async` function in response to something like a user clicking a button,
/// you're in the right place.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_multi_action;
/// # run_scope(|cx| {
/// async fn send_new_todo_to_api(task: String) -> usize {
/// // do something...
/// // return a task id
/// 42
/// }
/// let add_todo = create_multi_action(cx, |task: &String| {
/// // `task` is given as `&String` because its value is available in `input`
/// send_new_todo_to_api(task.clone())
/// });
///
/// add_todo.dispatch("Buy milk".to_string());
/// add_todo.dispatch("???".to_string());
/// add_todo.dispatch("Profit!!!".to_string());
///
/// assert_eq!(add_todo.submissions().get().len(), 3);
/// # });
/// ```
///
/// The input to the `async` function should always be a single value,
/// but it can be of any type. The argument is always passed by reference to the
/// function, because it is stored in [MultiAction::input] as well.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::create_multi_action;
/// # run_scope(|cx| {
/// // if there's a single argument, just use that
/// let action1 = create_multi_action(cx, |input: &String| {
/// let input = input.clone();
/// async move { todo!() }
/// });
///
/// // if there are no arguments, use the unit type `()`
/// let action2 = create_multi_action(cx, |input: &()| async { todo!() });
///
/// // if there are multiple arguments, use a tuple
/// let action3 = create_multi_action(cx, |input: &(usize, String)| async { todo!() });
/// # });
/// ```
pub fn create_multi_action<I, O, F, Fu>(cx: Scope, action_fn: F) -> MultiAction<I, O>
where
I: 'static,
O: 'static,
F: Fn(&I) -> Fu + 'static,
Fu: Future<Output = O> + 'static,
{
let version = create_rw_signal(cx, 0);
let submissions = create_rw_signal(cx, Vec::new());
let action_fn = Rc::new(move |input: &I| {
let fut = action_fn(input);
Box::pin(async move { fut.await }) as Pin<Box<dyn Future<Output = O>>>
});
MultiAction {
cx,
version,
submissions,
url: None,
action_fn,
}
}
/// Creates an [MultiAction] that can be used to call a server function.
///
/// ```rust
/// # use leptos_reactive::run_scope;
/// # use leptos_server::{create_server_multi_action, ServerFnError, ServerFn};
/// # use leptos_macro::server;
///
/// #[server(MyServerFn)]
/// async fn my_server_fn() -> Result<(), ServerFnError> {
/// todo!()
/// }
///
/// # run_scope(|cx| {
/// let my_server_multi_action = create_server_multi_action::<MyServerFn>(cx);
/// # });
/// ```
pub fn create_server_multi_action<S>(cx: Scope) -> MultiAction<S, Result<S::Output, ServerFnError>>
where
S: Clone + ServerFn,
{
#[cfg(feature = "ssr")]
let c = |args: &S| S::call_fn(args.clone());
#[cfg(not(feature = "ssr"))]
let c = |args: &S| S::call_fn_client(args.clone());
create_multi_action(cx, c).using_server_fn::<S>()
}

View File

@ -23,6 +23,7 @@ urlencoding = "2"
thiserror = "1"
typed-builder = "0.10"
serde_urlencoded = "0.7"
serde = "1"
js-sys = { version = "0.3" }
wasm-bindgen = { version = "0.2" }
wasm-bindgen-futures = { version = "0.4" }

View File

@ -69,93 +69,9 @@ where
return;
}
ev.prevent_default();
let submitter = ev.unchecked_ref::<web_sys::SubmitEvent>().submitter();
let navigate = use_navigate(cx);
let (form, method, action, enctype) = match &submitter {
Some(el) => {
if let Some(form) = el.dyn_ref::<web_sys::HtmlFormElement>() {
(
form.clone(),
form.get_attribute("method")
.unwrap_or_else(|| "get".to_string())
.to_lowercase(),
form.get_attribute("action")
.unwrap_or_else(|| "".to_string())
.to_lowercase(),
form.get_attribute("enctype")
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
.to_lowercase(),
)
} else if let Some(input) = el.dyn_ref::<web_sys::HtmlInputElement>() {
let form = ev
.target()
.unwrap()
.unchecked_into::<web_sys::HtmlFormElement>();
(
form.clone(),
input.get_attribute("method").unwrap_or_else(|| {
form.get_attribute("method")
.unwrap_or_else(|| "get".to_string())
.to_lowercase()
}),
input.get_attribute("action").unwrap_or_else(|| {
form.get_attribute("action")
.unwrap_or_else(|| "".to_string())
.to_lowercase()
}),
input.get_attribute("enctype").unwrap_or_else(|| {
form.get_attribute("enctype")
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
.to_lowercase()
}),
)
} else if let Some(button) = el.dyn_ref::<web_sys::HtmlButtonElement>() {
let form = ev
.target()
.unwrap()
.unchecked_into::<web_sys::HtmlFormElement>();
(
form.clone(),
button.get_attribute("method").unwrap_or_else(|| {
form.get_attribute("method")
.unwrap_or_else(|| "get".to_string())
.to_lowercase()
}),
button.get_attribute("action").unwrap_or_else(|| {
form.get_attribute("action")
.unwrap_or_else(|| "".to_string())
.to_lowercase()
}),
button.get_attribute("enctype").unwrap_or_else(|| {
form.get_attribute("enctype")
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
.to_lowercase()
}),
)
} else {
leptos_dom::debug_warn!("<Form/> cannot be submitted from a tag other than <form>, <input>, or <button>");
panic!()
}
}
None => match ev.target() {
None => {
leptos_dom::debug_warn!("<Form/> SubmitEvent fired without a target.");
panic!()
}
Some(form) => {
let form = form.unchecked_into::<web_sys::HtmlFormElement>();
(
form.clone(),
form.get_attribute("method")
.unwrap_or_else(|| "get".to_string()),
form.get_attribute("action").unwrap_or_default(),
form.get_attribute("enctype")
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string()),
)
}
},
};
let (form, method, action, enctype) = extract_form_attributes(&ev);
let form_data = web_sys::FormData::new_with_form(&form).unwrap_throw();
if let Some(on_form_data) = on_form_data.clone() {
@ -258,16 +174,15 @@ where
""
}.to_string();
let version = props.action.version;
let value = props.action.value;
let input = props.action.input;
let on_form_data = {
let action = props.action.clone();
Rc::new(move |form_data: &web_sys::FormData| {
let data =
web_sys::UrlSearchParams::new_with_str_sequence_sequence(&form_data).unwrap_throw();
let data = data.to_string().as_string().unwrap_or_default();
let data = serde_urlencoded::from_str::<I>(&data);
let data = action_input_from_form_data(form_data);
match data {
Ok(data) => action.set_input(data),
Ok(data) => input.set(Some(data)),
Err(e) => log::error!("{e}"),
}
})
@ -291,9 +206,9 @@ where
match O::from_json(
&json.as_string().expect("couldn't get String from JsString"),
) {
Ok(res) => action.set_value(Ok(res)),
Ok(res) => value.set(Some(Ok(res))),
Err(e) => {
action.set_value(Err(ServerFnError::Deserialization(e.to_string())))
value.set(Some(Err(ServerFnError::Deserialization(e.to_string()))))
}
}
}
@ -315,3 +230,167 @@ where
.build(),
)
}
/// Properties that can be passed to the [MultiActionForm] component, which
/// automatically turns a server [MultiAction](leptos_server::MultiAction) into an HTML
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
/// progressively enhanced to use client-side routing.
#[derive(TypedBuilder)]
pub struct MultiActionFormProps<I, O>
where
I: 'static,
O: 'static,
{
/// The action from which to build the form. This should include a URL, which can be generated
/// by default using [create_server_action](leptos_server::create_server_action) or added
/// manually using [leptos_server::Action::using_server_fn].
pub action: MultiAction<I, Result<O, ServerFnError>>,
/// Component children; should include the HTML of the form elements.
pub children: Box<dyn Fn() -> Vec<Element>>,
}
/// Automatically turns a server [MultiAction](leptos_server::MultiAction) into an HTML
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
/// progressively enhanced to use client-side routing.
#[allow(non_snake_case)]
pub fn MultiActionForm<I, O>(cx: Scope, props: MultiActionFormProps<I, O>) -> Element
where
I: Clone + ServerFn + 'static,
O: Clone + Serializable + 'static,
{
let multi_action = props.action;
let action = if let Some(url) = multi_action.url() {
url
} else {
debug_warn!("<MultiActionForm/> action needs a URL. Either use create_server_action() or Action::using_server_fn().");
""
}.to_string();
let on_submit = move |ev: web_sys::Event| {
if ev.default_prevented() {
return;
}
let (form, method, action, enctype) = extract_form_attributes(&ev);
let form_data = web_sys::FormData::new_with_form(&form).unwrap_throw();
let data = action_input_from_form_data(&form_data);
match data {
Err(e) => log::error!("{e}"),
Ok(input) => {
ev.prevent_default();
multi_action.dispatch(input);
}
}
};
let children = (props.children)();
view! { cx,
<form
method="POST"
action=action
on:submit=on_submit
>
{children}
</form>
}
}
fn extract_form_attributes(
ev: &web_sys::Event,
) -> (web_sys::HtmlFormElement, String, String, String) {
let submitter = ev.unchecked_ref::<web_sys::SubmitEvent>().submitter();
match &submitter {
Some(el) => {
if let Some(form) = el.dyn_ref::<web_sys::HtmlFormElement>() {
(
form.clone(),
form.get_attribute("method")
.unwrap_or_else(|| "get".to_string())
.to_lowercase(),
form.get_attribute("action")
.unwrap_or_else(|| "".to_string())
.to_lowercase(),
form.get_attribute("enctype")
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
.to_lowercase(),
)
} else if let Some(input) = el.dyn_ref::<web_sys::HtmlInputElement>() {
let form = ev
.target()
.unwrap()
.unchecked_into::<web_sys::HtmlFormElement>();
(
form.clone(),
input.get_attribute("method").unwrap_or_else(|| {
form.get_attribute("method")
.unwrap_or_else(|| "get".to_string())
.to_lowercase()
}),
input.get_attribute("action").unwrap_or_else(|| {
form.get_attribute("action")
.unwrap_or_else(|| "".to_string())
.to_lowercase()
}),
input.get_attribute("enctype").unwrap_or_else(|| {
form.get_attribute("enctype")
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
.to_lowercase()
}),
)
} else if let Some(button) = el.dyn_ref::<web_sys::HtmlButtonElement>() {
let form = ev
.target()
.unwrap()
.unchecked_into::<web_sys::HtmlFormElement>();
(
form.clone(),
button.get_attribute("method").unwrap_or_else(|| {
form.get_attribute("method")
.unwrap_or_else(|| "get".to_string())
.to_lowercase()
}),
button.get_attribute("action").unwrap_or_else(|| {
form.get_attribute("action")
.unwrap_or_else(|| "".to_string())
.to_lowercase()
}),
button.get_attribute("enctype").unwrap_or_else(|| {
form.get_attribute("enctype")
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
.to_lowercase()
}),
)
} else {
leptos_dom::debug_warn!("<Form/> cannot be submitted from a tag other than <form>, <input>, or <button>");
panic!()
}
}
None => match ev.target() {
None => {
leptos_dom::debug_warn!("<Form/> SubmitEvent fired without a target.");
panic!()
}
Some(form) => {
let form = form.unchecked_into::<web_sys::HtmlFormElement>();
(
form.clone(),
form.get_attribute("method")
.unwrap_or_else(|| "get".to_string()),
form.get_attribute("action").unwrap_or_default(),
form.get_attribute("enctype")
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string()),
)
}
},
}
}
fn action_input_from_form_data<I: serde::de::DeserializeOwned>(
form_data: &web_sys::FormData,
) -> Result<I, serde_urlencoded::de::Error> {
let data = web_sys::UrlSearchParams::new_with_str_sequence_sequence(&form_data).unwrap_throw();
let data = data.to_string().as_string().unwrap_or_default();
serde_urlencoded::from_str::<I>(&data)
}