Description
Describe the problem
Best practices around form data are slightly annoying — we want to encourage the use of native <form>
behaviour as far as possible (accessible, works without JS, etc) but the best UX involves optimistic updates, pending states, and client-controlled navigation.
So far our best attempt at squaring this circle is the enhance
action found in the project template. It's a decent start, but aside from being tucked away in an example rather than being a documented part of the framework itself, actions are fundamentally limited in what they can do as they cannot affect SSR (which means that method overriding and CSRF protection will always be relegated to userland).
Ideally we would have a solution that
- was a first-class part of the framework
- enabled best practices and best UX
- worked with SSR
Describe the proposed solution
I propose adding a <Form>
component. By default it would work just like a regular <form>
...
<script>
import { Form } from '$app/navigation';
</script>
<Form action="/todos.json" method="post">
<input name="description" aria-label="Add todo" placeholder="+ tap to add a todo">
</Form>
...but with additional features:
Automatic invalidation
Using the invalidate
API, the form could automatically update the UI upon a successful response. In the example above, using the form with JS disabled would show the endpoint response, meaning the endpoint would typically do something like this:
export async function post({ request, locals }) {
const data = await request.formData();
// write the todo to our database...
await db.write('todos', locals.user, data.get('description'));
// ...then redirect the user back to /todos so they see the updated page
return {
status: 303,
headers: {
location: '/todos'
}
};
}
If JS isn't disabled, <Form>
would submit the result via fetch
, meaning there's no need to redirect back to the page we're currently on. But we do want the page to reflect the new data. Assuming (reasonably) that the page is showing the result of a GET
request to /todos.json
, <Form>
can do this automatically:
// (this is pseudo-code, glossing over some details)
const url = new URL(action, location);
url.searchParams.set(method_override.parameter, method);
const res = await fetch(url, {
method: 'post',
body: new FormData(form)
});
// if the request succeeded, we invalidate the URL, causing
// the page's `load` function to run again, updating the UI
if (res.ok) {
invalidate(action);
}
Optimistic UI/pending states
For some updates it's reasonable to wait for confirmation from the server. For others, it might be better to immediately update the UI, possibly with a pending state of some kind:
<Form
action="/todos.json"
method="post"
pending={({ data }) => {
// we add a new todo object immediately — it will be destroyed
// by one without `pending: true` as soon as the action is invalidated
todos = [...todos, {
done: false,
text: data.get('text'),
pending: true
}];
}}
done={({ form }) => {
// clear the form
form.reset();
}}
>
<input name="description" aria-label="Add todo" placeholder="+ tap to add a todo">
</Form>
{#each todos as todo}
<div class="todo" class:done={todo.done} class:pending={todo.pending}>
<!-- todo goes here -->
</div>
{/each}
<style>
.pending {
opacity: 0.5;
}
</style>
this example glosses over one problem — typically you might generate a UUID on the server and use that in a keyed each block or something. ideally the pending todo would also have a UUID that the server accepted so that the invalidation didn't cause glitches. need to think on that some more
Error handling
There's a few things that could go wrong when submitting a form — network error, 4xx error (e.g. invalid data) or 5xx error (the server blew up). These are currently handled a bit inconsistently. If the handler returns an explicit error code, the page just shows the returned body
, whereas if an error is thrown, SvelteKit renders the error page.
#3532 suggests a way we can improve error handling, by rendering a page with validation errors. For progressively enhanced submissions, this wouldn't quite work — invalidating the action would cause a GET
request to happen, leaving the validation errors in limbo. We can pass them to an event handler easily enough...
<Form
action="/todos.json"
method="post"
pending={({ data }) => {...}}
done={({ form }) => {...}}
error={async ({ response }) => {
({ errors } = await response.json());
}}
/>
...but we don't have a great way to enforce correct error handling. Maybe we don't need to, as long as we provide the tools? Need to think on this some more.
Method overriding
Since the component would have access to the methodOverride
config, it could override the method or error when a disallowed method is used:
<Form action="/todos/{todo.uid}.json" method="patch">
<input aria-label="Edit todo" name="text" value={todo.text} />
<button class="save" aria-label="Save todo" />
</Form>
CSRF
We still need to draw the rest of the owl:
I think <Form>
has an important role to play here though. It could integrate with Kit's hypothetical CSRF config and automatically add a hidden input...
<!-- <Form> implementation -->
<script>
import { csrf } from '$app/env'; // or somewhere
</script>
<form {action} {method} on:submit|preventDefault={handler}>
<input type="hidden" name={csrf.key} value={csrf.value}>
<slot/>
</form>
...which we could then somehow validate on the server. For example — this may be a bit magical, but bear with me — maybe we could intercept request.formData()
and throw an error if CSRF checking (most likely using the double submit technique) fails? We could add some logic to our existing response proxy:
// pseudo-code
const proxy = new Proxy(response, {
get(response, key, _receiver) {
if (key === 'formData') {
const data = await response.formData();
const cookies = cookie.parse(request.headers.get('cookie'));
// check that the CSRF token the page was rendered with
// matches the cookie that was set alongside the page
if (data.get(csrf.key) !== cookies[csrf.cookie]) {
throw new Error('CSRF checking failed');
}
return data;
}
}
// ...
});
This would protect a lot of users against CSRF attacks without app developers really needing to do anything at all. We would need to discourage people from using <Form>
on prerendered pages, of course, which is easy to do during SSR.
Alternatives considered
The alternative is to leave it to userland. I don't think I've presented anything here that requires hooks into the framework proper — everything is using public APIs (real or hypothetical). But I think there's a ton of value in having this be built in, so that using progressively-enhanced form submissions is seen as the right way to handle data.
This is a big proposal with a lot of moving parts, so there are probably a variety of things I haven't considered. Eager to hear people's thoughts.
Importance
would make my life easier
Additional Information
No response