@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
313 lines (268 loc) • 9.15 kB
JavaScript
/** @import { RemoteForm, RemoteQueryOverride } from '@sveltejs/kit' */
/** @import { RemoteFunctionResponse } from 'types' */
/** @import { Query } from './query.svelte.js' */
import { app_dir } from '__sveltekit/paths';
import * as devalue from 'devalue';
import { DEV } from 'esm-env';
import { HttpError } from '@sveltejs/kit/internal';
import { app, remote_responses, started, goto, set_nearest_error_page } from '../client.js';
import { create_remote_cache_key } from '../../shared.js';
import { tick } from 'svelte';
import { refresh_queries, release_overrides } from './shared.svelte.js';
/**
* Client-version of the `form` function from `$app/server`.
* @template T
* @param {string} id
* @returns {RemoteForm<T>}
*/
export function form(id) {
/** @type {Map<any, { count: number, instance: RemoteForm<T> }>} */
const instances = new Map();
/** @param {string | number | boolean} [key] */
function create_instance(key) {
const action_id = id + (key != undefined ? `/${JSON.stringify(key)}` : '');
const action = '?/remote=' + encodeURIComponent(action_id);
/** @type {any} */
let result = $state(
!started ? (remote_responses[create_remote_cache_key(action, '')] ?? undefined) : undefined
);
/**
* @param {FormData} data
* @returns {Promise<any> & { updates: (...args: any[]) => any }}
*/
function submit(data) {
// Store a reference to the current instance and increment the usage count for the duration
// of the request. This ensures that the instance is not deleted in case of an optimistic update
// (e.g. when deleting an item in a list) that fails and wants to surface an error to the user afterwards.
// If the instance would be deleted in the meantime, the error property would be assigned to the old,
// no-longer-visible instance, so it would never be shown to the user.
const entry = instances.get(key);
if (entry) {
entry.count++;
}
/** @type {Array<Query<any> | RemoteQueryOverride>} */
let updates = [];
/** @type {Promise<any> & { updates: (...args: any[]) => any }} */
const promise = (async () => {
try {
await Promise.resolve();
if (updates.length > 0) {
if (DEV) {
if (data.get('sveltekit:remote_refreshes')) {
throw new Error(
'The FormData key `sveltekit:remote_refreshes` is reserved for internal use and should not be set manually'
);
}
}
data.set('sveltekit:remote_refreshes', JSON.stringify(updates.map((u) => u._key)));
}
const response = await fetch(`/${app_dir}/remote/${action_id}`, {
method: 'POST',
body: data
});
if (!response.ok) {
// We only end up here in case of a network error or if the server has an internal error
// (which shouldn't happen because we handle errors on the server and always send a 200 response)
result = undefined;
throw new Error('Failed to execute remote function');
}
const form_result = /** @type { RemoteFunctionResponse} */ (await response.json());
if (form_result.type === 'result') {
result = devalue.parse(form_result.result, app.decoders);
refresh_queries(form_result.refreshes, updates);
} else if (form_result.type === 'redirect') {
const refreshes = form_result.refreshes ?? '';
const invalidateAll = !refreshes && updates.length === 0;
if (!invalidateAll) {
refresh_queries(refreshes, updates);
}
void goto(form_result.location, { invalidateAll });
} else {
result = undefined;
throw new HttpError(500, form_result.error);
}
} catch (e) {
release_overrides(updates);
throw e;
} finally {
void tick().then(() => {
if (entry) {
entry.count--;
if (entry.count === 0) {
instances.delete(key);
}
}
});
}
})();
promise.updates = (...args) => {
updates = args;
return promise;
};
return promise;
}
/** @type {RemoteForm<T>} */
const instance = {};
instance.method = 'POST';
instance.action = action;
/**
* @param {HTMLFormElement} form_element
* @param {HTMLElement | null} submitter
*/
function create_form_data(form_element, submitter) {
const form_data = new FormData(form_element);
if (DEV) {
const enctype = submitter?.hasAttribute('formenctype')
? /** @type {HTMLButtonElement | HTMLInputElement} */ (submitter).formEnctype
: clone(form_element).enctype;
if (enctype !== 'multipart/form-data') {
for (const value of form_data.values()) {
if (value instanceof File) {
throw new Error(
'Your form contains <input type="file"> fields, but is missing the necessary `enctype="multipart/form-data"` attribute. This will lead to inconsistent behavior between enhanced and native forms. For more details, see https://github.com/sveltejs/kit/issues/9819.'
);
}
}
}
}
const submitter_name = submitter?.getAttribute('name');
if (submitter_name) {
form_data.append(submitter_name, submitter?.getAttribute('value') ?? '');
}
return form_data;
}
/** @param {Parameters<RemoteForm<any>['enhance']>[0]} callback */
const form_onsubmit = (callback) => {
/** @param {SubmitEvent} event */
return async (event) => {
const form = /** @type {HTMLFormElement} */ (event.target);
const method = event.submitter?.hasAttribute('formmethod')
? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formMethod
: clone(form).method;
if (method !== 'post') return;
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).action
);
if (action.searchParams.get('/remote') !== action_id) {
return;
}
event.preventDefault();
const data = create_form_data(form, event.submitter);
try {
await callback({
form,
data,
submit: () => submit(data)
});
} catch (e) {
const error =
e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message };
const status = e instanceof HttpError ? e.status : 500;
void set_nearest_error_page(error, status);
}
};
};
instance.onsubmit = form_onsubmit(({ submit }) => submit());
/** @param {Parameters<RemoteForm<any>['buttonProps']['enhance']>[0]} callback */
const form_action_onclick = (callback) => {
/** @param {Event} event */
return async (event) => {
const target = /** @type {HTMLButtonElement} */ (event.target);
const form = target.form;
if (!form) return;
// Prevent this from firing the form's submit event
event.stopPropagation();
event.preventDefault();
const data = create_form_data(form, target);
try {
await callback({
form,
data,
submit: () => submit(data)
});
} catch (e) {
const error =
e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message };
const status = e instanceof HttpError ? e.status : 500;
void set_nearest_error_page(error, status);
}
};
};
/** @type {RemoteForm<any>['buttonProps']} */
// @ts-expect-error we gotta set enhance as a non-enumerable property
const button_props = {
type: 'submit',
formmethod: 'POST',
formaction: action,
onclick: form_action_onclick(({ submit }) => submit())
};
Object.defineProperty(button_props, 'enhance', {
/** @type {RemoteForm<any>['buttonProps']['enhance']} */
value: (callback) => {
return {
type: 'submit',
formmethod: 'POST',
formaction: action,
onclick: form_action_onclick(callback)
};
}
});
Object.defineProperties(instance, {
buttonProps: {
value: button_props
},
result: {
get: () => result
},
enhance: {
/** @type {RemoteForm<any>['enhance']} */
value: (callback) => {
return {
method: 'POST',
action,
onsubmit: form_onsubmit(callback)
};
}
}
});
return instance;
}
const instance = create_instance();
Object.defineProperty(instance, 'for', {
/** @type {RemoteForm<any>['for']} */
value: (key) => {
const entry = instances.get(key) ?? { count: 0, instance: create_instance(key) };
try {
$effect.pre(() => {
return () => {
entry.count--;
void tick().then(() => {
if (entry.count === 0) {
instances.delete(key);
}
});
};
});
entry.count += 1;
instances.set(key, entry);
} catch {
// not in an effect context
}
return entry.instance;
}
});
return instance;
}
/**
* 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));
}