Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hungry-jokes-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': major
---

breaking: enhanced cross-page form actions now navigate to the action page on success and failure, matching native form behavior
59 changes: 47 additions & 12 deletions documentation/docs/20-core-concepts/30-form-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ We can also invoke the action from other pages (for example if there's a login w
</form>
```

Submitting this form takes you to `/login`, whether the action succeeds or fails — that's what the browser does, and [`use:enhance`](#Progressive-enhancement-use:enhance) emulates it. If you'd rather stay where you are and handle the result in place, there's a recipe for that in the [progressive enhancement](#Progressive-enhancement-use:enhance) section.

## Named actions

Instead of one `default` action, a page can have as many named actions as it needs:
Expand Down Expand Up @@ -361,13 +363,37 @@ The easiest way to progressively enhance a form is to add the `use:enhance` acti

Without an argument, `use:enhance` will emulate the browser-native behaviour, just without the full-page reloads. It will:

- update the `form` property, `page.form` and `page.status` on a successful or invalid response, but only if the action is on the same page you're submitting from. For example, if your form looks like `<form action="/somewhere/else" ..>`, the `form` prop and the `page.form` state will _not_ be updated. This is because in the native form submission case you would be redirected to the page the action is on. If you want to have them updated either way, use [`applyAction`](#Progressive-enhancement-Customising-use:enhance)
- if the action is on the page you're submitting from, emulate a reload of the current page — updating the `form` property, `page.form` and `page.status` on a successful or invalid response
- if the action is on a different page (for example `<form action="/somewhere/else" ..>`), navigate to the action's URL on a successful _or_ invalid response, populating that page's `form` property and `page.status`, just as a native submission would. A new history entry is pushed
- reset the `<form>` element
- invalidate all data using `invalidateAll` on a successful response
- refresh all data using `refreshAll` on a successful response
- call `goto` on a redirect response
- render the nearest `+error` boundary if an error occurs
- render the nearest `+error` boundary if an unexpected error occurs — the boundary nearest the _action's_ route, if the action is on a different page
- [reset focus](accessibility#Focus-management) to the appropriate element

> [!NOTE] Two details are worth knowing about the URL:
>
> Where a submission lands is `result.location` — the form's `action` with the `?/actionName` parameter removed, since that parameter has done its job once the action has run. `action="/login?/do-login"` lands on `/login`, and any other query parameters are kept, so `action="/login?redirectTo=/dashboard&/do-login"` lands on `/login?redirectTo=/dashboard`. The destination's `load` functions see that URL via `url.search`.
>
> Two consequences worth knowing:
>
> - A _relative_ action replaces the entire query string as a side effect of URL resolution — `action="?/delete"` on `/items?page=2` resolves to `/items?/delete`, so both enhanced and native submissions drop `page=2`. Write `action="?page=2&/delete"` to preserve it.
> - Without JavaScript the browser can't strip anything, so an unenhanced submission leaves you on the raw action URL (`/login?/do-login`). Reloading that URL is a plain `GET`, which doesn't re-run the action and renders the page normally, so the difference is cosmetic — but a `load` function can observe a different `url.search` depending on whether JavaScript is available.

If you don't want a cross-page submission to navigate — a common need for login or newsletter widgets that live in a layout and post to another page's action — pass `navigate: false` to `update`, which gives you the same behaviour as SvelteKit 2:

```svelte
<form
method="POST"
action="/other/page?/subscribe"
use:enhance={() => async ({ update }) => {
await update({ navigate: false });
}}
>
```

The result is applied to the page you're on: `form`, `page.form` and `page.status` are updated, and error results render the current route's nearest `+error` boundary.

### Customising use:enhance

To customise the behaviour, you can provide a `SubmitFunction` that runs immediately before the form is submitted, and (optionally) returns a callback that runs with the `ActionResult`.
Expand All @@ -392,7 +418,7 @@ To customise the behaviour, you can provide a `SubmitFunction` that runs immedia

You can use these functions to show and hide loading UI, and so on.

If you return a callback, you override the default post-submission behavior. To get it back, call `update`, which accepts `invalidateAll` and `reset` parameters, or use `applyAction` on the result:
If you return a callback, you override the default post-submission behavior. To get it back, call `update`, which accepts `navigate`, `refreshAll` and `reset` parameters, or use `applyAction` on the result:

```svelte
/// file: src/routes/login/+page.svelte
Expand All @@ -418,13 +444,19 @@ If you return a callback, you override the default post-submission behavior. To
>
```

> [!NOTE] `update` accepts these options:
>
> - `reset: false` if you don't want the `<form>` values to be reset after a successful submission
> - `refreshAll` controls whether all data is refreshed after submission. It defaults to `true` for successful results and `false` for failures. When the submission navigates to another page, setting it to `false` does _not_ prevent the destination's own `load` functions from running — it only allows shared layout data to be reused. `invalidateAll` is a deprecated alias for `refreshAll`
> - `navigate: false` applies a non-redirect result to the current page instead of navigating to `result.location`. Redirects are always followed

The behaviour of `applyAction(result)` depends on `result.type`:

- `success`, `failure` — sets `page.status` to `result.status` and updates `form` and `page.form` to `result.data` (regardless of where you are submitting from, in contrast to `update` from `enhance`)
- `redirect` — calls `goto(result.location, { invalidateAll: true })`
- `error` — renders the nearest `+error` boundary with `result.error`
- `success`, `failure` — sets `page.status` to `result.status` and updates `form` and `page.form` to `result.data`
- `redirect` — calls `goto(result.location, { refreshAll: true })`
- `error` — renders the current route's nearest `+error` boundary with `result.error`

In all cases, [focus will be reset](accessibility#Focus-management).
In all cases, [focus will be reset](accessibility#Focus-management). `applyAction` does not navigate to the `location` of a non-redirect result or refresh data. Use `update` to get the complete default enhanced behavior, including carrying form data and status into a cross-page navigation. When implementing enhancement manually, inspect `result.location` and use [`goto`](../$app-navigation#goto) or [`refreshAll`](../$app-navigation#refreshAll) as appropriate.

### Custom event listener

Expand Down Expand Up @@ -452,12 +484,15 @@ We can also implement progressive enhancement ourselves, without `use:enhance`,
/** @type {import('@sveltejs/kit').ActionResult} */
const result = deserialize(await response.text());

if (result.type === 'success') {
// rerun all `load` functions and queries, following the successful update
await refreshAll();
if (result.type !== 'redirect' && result.location !== undefined) {
if (result.location === location.pathname + location.search) {
if (result.type === 'success') await refreshAll();
} else {
await goto(result.location, { refreshAll: result.type === 'success' });
}
}

applyAction(result);
await applyAction(result);
}
</script>

Expand Down
22 changes: 16 additions & 6 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1941,15 +1941,20 @@ export type Actions<
* };
* }}
* ```
*
* Success and failure results carry the root-relative `pathname + search` of the action URL, with
* the `?/actionName` parameter removed. Redirect results carry the redirect target. Server-generated
* error results also carry the action location, while client-generated errors such as network
* failures do not. `update` uses this location to emulate native form navigation.
*/
export type ActionResult<
Success extends Record<string, unknown> | undefined = Record<string, any>,
Failure extends Record<string, unknown> | undefined = Record<string, any>
> =
| { type: 'success'; status: number; data?: Success }
| { type: 'failure'; status: number; data?: Failure }
| { type: 'success'; status: number; data?: Success; location: string }
| { type: 'failure'; status: number; data?: Failure; location: string }
| { type: 'redirect'; status: number; location: string }
| { type: 'error'; status?: number; error: App.Error };
| { type: 'error'; status?: number; error: App.Error; location?: string };

/**
* The object returned by the [`error`](https://svelte.dev/docs/kit/@sveltejs-kit#error) function.
Expand Down Expand Up @@ -1990,10 +1995,15 @@ export type SubmitFunction<
result: ActionResult<Success, Failure>;
/**
* Call this to get the default behavior of a form submission response.
* @param options Set `reset: false` if you don't want the `<form>` values to be reset after a successful submission.
* @param invalidateAll Set `invalidateAll: false` if you don't want the action to call `invalidateAll` after submission.
* @param options Set `reset: false` if you don't want the `<form>` values to be reset after a successful submission. `refreshAll` defaults to `true` for successful results and `false` for failures. When the submission navigates, setting it to `false` still runs the destination's `load` functions but may reuse shared layout data. Set `navigate: false` to apply non-redirect results to the current page instead of navigating to `result.location`. Redirects are always followed.
*/
update: (options?: { reset?: boolean; invalidateAll?: boolean }) => Promise<void>;
update: (options?: {
reset?: boolean;
refreshAll?: boolean;
navigate?: boolean;
/** @deprecated Use `refreshAll` instead. */
invalidateAll?: boolean;
}) => Promise<void>;
}) => MaybePromise<void>)
>;

Expand Down
90 changes: 51 additions & 39 deletions packages/kit/src/runtime/app/forms.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { DEV } from 'esm-env';
import { noop } from '../../utils/functions.js';
import { refreshAll } from './navigation.js';
import { applyAction, handle_error } from '../client/client.js';
import {
applyAction,
apply_action_navigation,
handle_error,
is_current_location
} from '../client/client.js';
import { notify_version } from '../client/state.svelte.js';
import { parse } from '#app/internal/transport';

Expand Down Expand Up @@ -30,10 +35,6 @@ export { applyAction };
* @returns {import('@sveltejs/kit').ActionResult<Success, Failure>}
*/
export function deserialize(result) {
if (result === '') {
return { type: 'success', status: 204, data: undefined };
}

const parsed = JSON.parse(result);
Comment thread
elliott-with-the-longest-name-on-github marked this conversation as resolved.

if (parsed.data) {
Expand Down Expand Up @@ -63,17 +64,18 @@ function clone(element) {
* If a function is returned, that function is called with the response from the server.
* If nothing is returned, the fallback will be used.
*
* If this function or its return value isn't set, it
* - falls back to updating the `form` prop with the returned data if the action is on the same page as the form
* - updates `page.status`
* - resets the `<form>` element and invalidates all data in case of successful submission with no redirect response
* If this function or its return value isn't set, it emulates the browser-native behaviour, just without the full-page reload. It
* - resets the `<form>` element and refreshes all data in case of a successful submission with no redirect response
* - updates the `form` prop, `page.form` and `page.status` if the action is on the same page as the form
* - navigates to the page the submission lands on — populating that page's `form` prop and `page.status` — on success and failure if that isn't the current page, just as a native form submission would, but with the `?/actionName` param stripped from the destination URL
* - redirects in case of a redirect response
* - redirects to the nearest error page in case of an unexpected error
* - renders the nearest error page in case of an unexpected error — the one nearest the action's route, if the action is on a different page
*
* If you provide a custom function with a callback and want to use the default behavior, invoke `update` in your callback.
* It accepts an options object
* - `reset: false` if you don't want the `<form>` values to be reset after a successful submission
* - `invalidateAll: false` if you don't want the action to call `invalidateAll` after submission
* - `refreshAll` to control whether all data is refreshed after submission; it defaults to `true` for successes and `false` for failures
* - `navigate: false` to apply non-redirect results to the current page rather than navigating to `result.location`; redirects are always followed
* @template {Record<string, unknown> | undefined} Success
* @template {Record<string, unknown> | undefined} Failure
* @param {HTMLFormElement} form_element The form element
Expand All @@ -86,37 +88,50 @@ export function enhance(form_element, submit = noop) {

/**
* @param {{
* action: URL;
* invalidateAll?: boolean;
* result: import('@sveltejs/kit').ActionResult;
* reset?: boolean
* reset?: boolean;
* refreshAll?: boolean;
* invalidateAll?: boolean;
* navigate?: boolean;
* }} opts
*/
const fallback_callback = async ({
action,
result,
reset = true,
invalidateAll: shouldInvalidateAll = true
refreshAll: should_refresh_all,
invalidateAll: deprecated_invalidate_all,
navigate = true
}) => {
if (result.type === 'success') {
if (reset) {
// We call reset from the prototype to avoid DOM clobbering
HTMLFormElement.prototype.reset.call(form_element);
}
if (shouldInvalidateAll) {
if (DEV && deprecated_invalidate_all !== undefined) {
console.warn(
'The `update({ invalidateAll })` option has been deprecated in favour of `update({ refreshAll })`'
);
}

should_refresh_all ??= deprecated_invalidate_all ?? result.type === 'success';

if (result.type === 'success' && reset) {
// We call reset from the prototype to avoid DOM clobbering
HTMLFormElement.prototype.reset.call(form_element);
}

const destination =
navigate && result.type !== 'redirect' && !is_current_location(result.location)
? result.location
: undefined;

if (destination === undefined) {
if (should_refresh_all && result.type !== 'redirect') {
await refreshAll();
}
}

// For success/failure results, only apply action if it belongs to the
// current page, otherwise `form` will be updated erroneously
if (
location.origin + location.pathname === action.origin + action.pathname ||
result.type === 'redirect' ||
result.type === 'error'
) {
await applyAction(result);
return;
}

// emulate the browser: navigate to where the submission lands, rendering that
// page with this result
await apply_action_navigation(destination, result, should_refresh_all);
};

/** @param {SubmitEvent} event */
Expand Down Expand Up @@ -202,13 +217,9 @@ export function enhance(form_element, submit = noop) {
// detect new deployments from the response header
notify_version(response.headers.get('x-sveltekit-version'));

if (response.status === 204) {
result = { type: 'success', status: 204 };
} else {
result = deserialize(await response.text());
if (result.type === 'error' || result.type === 'failure') {
result.status = response.status;
}
result = deserialize(await response.text());
if (result.type === 'error' || result.type === 'failure') {
result.status = response.status;
}
} catch (error) {
if (/** @type {any} */ (error)?.name === 'AbortError') return;
Expand All @@ -228,10 +239,11 @@ export function enhance(form_element, submit = noop) {
formElement: form_element,
update: (opts) =>
fallback_callback({
action,
result,
reset: opts?.reset,
invalidateAll: opts?.invalidateAll
refreshAll: opts?.refreshAll,
invalidateAll: opts?.invalidateAll,
navigate: opts?.navigate
}),
// @ts-expect-error generic constraints stuff we don't care about
result
Expand Down
Loading
Loading