@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
270 lines (238 loc) • 9.37 kB
JavaScript
/** @import { ActionResult, SubmitFunction } from './types.js' */
import { HttpError, SvelteKitError } from '@sveltejs/kit/internal';
import { DEV } from 'esm-env';
import { noop } from '../../../utils/functions.js';
import { refreshAll } from '../navigation/index.js';
import {
applyAction,
apply_action_navigation,
handle_error,
is_current_location
} from '../../client/client.js';
import { notify_version } from '#app/state/client';
import { deserialize } from './shared.js';
import { resolve_url } from '../../client/utils.js';
import * as e from '../../../messages/client-errors.js';
import * as w from '../../../messages/client-warnings.js';
export { applyAction, deserialize };
/**
* Shallow clone an element, so that we can access e.g. `form.action` without worrying
* that someone has added an `<input name="action">` (https://github.com/sveltejs/kit/issues/7593)
* @template {HTMLElement} T
* @param {T} element
* @returns {T}
*/
function clone(element) {
return /** @type {T} */ (HTMLElement.prototype.cloneNode.call(element));
}
/**
* This action enhances a `<form>` element that otherwise would work without JavaScript.
*
* The `submit` function is called upon submission with the given FormData and the `action` that should be triggered.
* If `cancel` is called, the form will not be submitted.
* You can use the abort `controller` to cancel the submission in case another one starts.
* 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 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
* - 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
* - `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
* @param {SubmitFunction<Success, Failure>} submit Submit callback
*/
export function enhance(form_element, submit = noop) {
if (DEV && clone(form_element).method !== 'post') {
e.enhance_invalid_method();
}
/**
* @param {{
* result: ActionResult;
* reset?: boolean;
* refreshAll?: boolean;
* invalidateAll?: boolean;
* navigate?: boolean;
* }} opts
*/
const fallback_callback = async ({
result,
reset = true,
refreshAll: should_refresh_all,
invalidateAll: deprecated_invalidate_all,
navigate = true
}) => {
if (DEV && deprecated_invalidate_all !== undefined) {
w.enhance_invalidate_all_deprecated();
}
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' && result.location !== undefined
? resolve_url(result.location)
: undefined;
if (
destination === undefined ||
destination.origin !== location.origin ||
is_current_location(destination.href)
) {
if (should_refresh_all && result.type !== 'redirect') {
await refreshAll();
}
await applyAction(result);
return;
}
// emulate the browser: navigate to where the submission lands, rendering that
// page with this result
await apply_action_navigation(destination.href, result, should_refresh_all);
};
/** @param {SubmitEvent} event */
async function handle_submit(event) {
const method = event.submitter?.hasAttribute('formmethod')
? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formMethod
: clone(form_element).method;
if (method !== 'post') return;
event.preventDefault();
const action = new URL(
// We can't do submitter.formAction directly because that property is always set
event.submitter?.hasAttribute('formaction')
? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formAction
: clone(form_element).action
);
const enctype = event.submitter?.hasAttribute('formenctype')
? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formEnctype
: clone(form_element).enctype;
const form_data = new FormData(form_element, event.submitter);
if (DEV && enctype !== 'multipart/form-data') {
for (const value of form_data.values()) {
if (value instanceof File) {
e.enhance_file_without_enctype();
}
}
}
const controller = new AbortController();
let cancelled = false;
const cancel = () => (cancelled = true);
const callback =
(await submit({
action,
cancel,
controller,
formData: form_data,
formElement: form_element,
submitter: event.submitter
})) ?? fallback_callback;
if (cancelled) return;
/** @type {ActionResult} */
let result;
try {
const headers = new Headers({
accept: 'application/json',
'x-sveltekit-action': 'true'
});
// do not explicitly set the `Content-Type` header when sending `FormData`
// or else it will interfere with the browser's header setting
// see https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects#sect4
if (enctype !== 'multipart/form-data') {
headers.set(
'Content-Type',
/^(:?application\/x-www-form-urlencoded|text\/plain)$/.test(enctype)
? enctype
: 'application/x-www-form-urlencoded'
);
}
// @ts-expect-error `URLSearchParams(form_data)` is kosher, but typescript doesn't know that
const body = enctype === 'multipart/form-data' ? form_data : new URLSearchParams(form_data);
const response = await fetch(action, {
method: 'POST',
headers,
cache: 'no-store',
body,
signal: controller.signal
});
// detect new deployments from the response header
notify_version(response.headers.get('x-sveltekit-version'));
const text = await response.text();
/** @type {any} */
let parsed;
try {
// an empty body carries no result for an error response
parsed = text === '' && !response.ok ? undefined : deserialize(text);
} catch (error) {
// A proxy may redirect to a login page or return a non-JSON error response.
if (response.ok && !response.redirected) throw error;
}
if (
parsed?.type === 'success' ||
parsed?.type === 'failure' ||
parsed?.type === 'redirect' ||
parsed?.type === 'error'
) {
result = parsed;
if (result.type === 'error' || result.type === 'failure') {
result.status = response.status;
}
} else if (response.redirected) {
// fetch has followed the HTTP redirect, so its original status is no longer available.
result = { type: 'redirect', status: 303, location: response.url };
} else if (!response.ok) {
// the action never ran, e.g. the CSRF check or a proxy rejected the request.
// an `App.Error`-shaped body is an expected error, anything else goes through `handleError`
throw parsed && typeof parsed === 'object' && typeof parsed.message === 'string'
? new HttpError({ ...parsed, status: response.status })
: new SvelteKitError(
response.status,
response.statusText,
typeof parsed === 'string' ? parsed : response.statusText
);
} else {
result = parsed;
}
} catch (error) {
if (/** @type {any} */ (error)?.name === 'AbortError') return;
result = {
type: 'error',
error: await handle_error(error, {
params: {},
route: { id: null },
url: new URL(location.href)
})
};
}
await callback({
action,
formData: form_data,
formElement: form_element,
update: (opts) =>
fallback_callback({
result,
reset: opts?.reset,
refreshAll: opts?.refreshAll,
invalidateAll: opts?.invalidateAll,
navigate: opts?.navigate
}),
// @ts-expect-error generic constraints stuff we don't care about
result
});
}
// @ts-expect-error
HTMLFormElement.prototype.addEventListener.call(form_element, 'submit', handle_submit);
return {
destroy() {
// @ts-expect-error
HTMLFormElement.prototype.removeEventListener.call(form_element, 'submit', handle_submit);
}
};
}