UNPKG

@sveltejs/kit

Version:

SvelteKit is the fastest way to build Svelte apps

675 lines (567 loc) 18.7 kB
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ /** @import { RemoteFormInput, RemoteForm, RemoteQueryOverride } from '@sveltejs/kit' */ /** @import { InternalRemoteFormIssue, RemoteFunctionResponse } from 'types' */ /** @import { Query } from './query.svelte.js' */ import { app_dir, base } from '$app/paths/internal/client'; import * as devalue from 'devalue'; import { DEV } from 'esm-env'; import { HttpError } from '@sveltejs/kit/internal'; import { app, remote_responses, _goto, set_nearest_error_page, invalidateAll } from '../client.js'; import { tick } from 'svelte'; import { refresh_queries, release_overrides } from './shared.svelte.js'; import { createAttachmentKey } from 'svelte/attachments'; import { convert_formdata, flatten_issues, create_field_proxy, deep_set, set_nested_value, throw_on_old_property_access, build_path_string, normalize_issue, serialize_binary_form, BINARY_FORM_CONTENT_TYPE } from '../../form-utils.js'; /** * Merge client issues into server issues. Server issues are persisted unless * a client-issue exists for the same path, in which case the client-issue overrides it. * @param {FormData} form_data * @param {InternalRemoteFormIssue[]} current_issues * @param {InternalRemoteFormIssue[]} client_issues * @returns {InternalRemoteFormIssue[]} */ function merge_with_server_issues(form_data, current_issues, client_issues) { const merged = [ ...current_issues.filter( (issue) => issue.server && !client_issues.some((i) => i.name === issue.name) ), ...client_issues ]; const keys = Array.from(form_data.keys()); return merged.sort((a, b) => keys.indexOf(a.name) - keys.indexOf(b.name)); } /** * Client-version of the `form` function from `$app/server`. * @template {RemoteFormInput} T * @template U * @param {string} id * @returns {RemoteForm<T, U>} */ export function form(id) { /** @type {Map<any, { count: number, instance: RemoteForm<T, U> }>} */ const instances = new Map(); /** @param {string | number | boolean} [key] */ function create_instance(key) { const action_id_without_key = id; const action_id = id + (key != undefined ? `/${JSON.stringify(key)}` : ''); const action = '?/remote=' + encodeURIComponent(action_id); /** * @type {Record<string, string | string[] | File | File[]>} */ let input = $state({}); /** @type {InternalRemoteFormIssue[]} */ let raw_issues = $state.raw([]); const issues = $derived(flatten_issues(raw_issues)); /** @type {any} */ let result = $state.raw(remote_responses[action_id]); /** @type {number} */ let pending_count = $state(0); /** @type {StandardSchemaV1 | undefined} */ let preflight_schema = undefined; /** @type {HTMLFormElement | null} */ let element = null; /** @type {Record<string, boolean>} */ let touched = {}; let submitted = false; /** * @param {FormData} form_data * @returns {Record<string, any>} */ function convert(form_data) { const data = convert_formdata(form_data); if (key !== undefined && !form_data.has('id')) { data.id = key; } return data; } /** * @param {HTMLFormElement} form * @param {FormData} form_data * @param {Parameters<RemoteForm<any, any>['enhance']>[0]} callback */ async function handle_submit(form, form_data, callback) { const data = convert(form_data); submitted = true; const validated = await preflight_schema?.['~standard'].validate(data); if (validated?.issues) { raw_issues = merge_with_server_issues( form_data, raw_issues, validated.issues.map((issue) => normalize_issue(issue, false)) ); return; } // TODO 3.0 remove this warning if (DEV) { const error = () => { throw new Error( 'Remote form functions no longer get passed a FormData object. The payload is now a POJO. See https://kit.svelte.dev/docs/remote-functions#form for details.' ); }; for (const key of [ 'append', 'delete', 'entries', 'forEach', 'get', 'getAll', 'has', 'keys', 'set', 'values' ]) { if (!(key in data)) { Object.defineProperty(data, key, { get: error }); } } } try { await callback({ form, data, submit: () => submit(form_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); } } /** * @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++; } // Increment pending count when submission starts pending_count++; /** @type {Array<Query<any> | RemoteQueryOverride>} */ let updates = []; /** @type {Promise<any> & { updates: (...args: any[]) => any }} */ const promise = (async () => { try { await Promise.resolve(); const { blob } = serialize_binary_form(convert(data), { remote_refreshes: updates.map((u) => u._key) }); const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, { method: 'POST', headers: { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'x-sveltekit-pathname': location.pathname, 'x-sveltekit-search': location.search }, body: blob }); 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) throw new Error('Failed to execute remote function'); } const form_result = /** @type { RemoteFunctionResponse} */ (await response.json()); // reset issues in case it's a redirect or error (but issues passed in that case) raw_issues = []; if (form_result.type === 'result') { ({ issues: raw_issues = [], result } = devalue.parse(form_result.result, app.decoders)); if (issues.$) { release_overrides(updates); } else { if (form_result.refreshes) { refresh_queries(form_result.refreshes, updates); } else { void invalidateAll(); } } } else if (form_result.type === 'redirect') { const refreshes = form_result.refreshes ?? ''; const invalidateAll = !refreshes && updates.length === 0; if (!invalidateAll) { refresh_queries(refreshes, updates); } // Use internal version to allow redirects to external URLs void _goto(form_result.location, { invalidateAll }, 0); } else { throw new HttpError(form_result.status ?? 500, form_result.error); } } catch (e) { result = undefined; release_overrides(updates); throw e; } finally { // Decrement pending count when submission completes pending_count--; 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, U>} */ const instance = {}; instance.method = 'POST'; instance.action = action; /** @param {Parameters<RemoteForm<any, 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 form_data = new FormData(form, event.submitter); if (DEV) { validate_form_data(form_data, clone(form).enctype); } await handle_submit(form, form_data, callback); }; }; /** @param {(event: SubmitEvent) => void} onsubmit */ function create_attachment(onsubmit) { return (/** @type {HTMLFormElement} */ form) => { if (element) { let message = `A form object can only be attached to a single \`<form>\` element`; if (DEV && !key) { const name = id.split('/').pop(); message += `. To create multiple instances, use \`${name}.for(key)\``; } throw new Error(message); } element = form; touched = {}; form.addEventListener('submit', onsubmit); form.addEventListener('input', (e) => { // strictly speaking it can be an HTMLTextAreaElement or HTMLSelectElement // but that makes the types unnecessarily awkward const element = /** @type {HTMLInputElement} */ (e.target); let name = element.name; if (!name) return; const is_array = name.endsWith('[]'); if (is_array) name = name.slice(0, -2); const is_file = element.type === 'file'; touched[name] = true; if (is_array) { let value; if (element.tagName === 'SELECT') { value = Array.from( element.querySelectorAll('option:checked'), (e) => /** @type {HTMLOptionElement} */ (e).value ); } else { const elements = /** @type {HTMLInputElement[]} */ ( Array.from(form.querySelectorAll(`[name="${name}[]"]`)) ); if (DEV) { for (const e of elements) { if ((e.type === 'file') !== is_file) { throw new Error( `Cannot mix and match file and non-file inputs under the same name ("${element.name}")` ); } } } value = is_file ? elements.map((input) => Array.from(input.files ?? [])).flat() : elements.map((element) => element.value); if (element.type === 'checkbox') { value = /** @type {string[]} */ (value.filter((_, i) => elements[i].checked)); } } set_nested_value(input, name, value); } else if (is_file) { if (DEV && element.multiple) { throw new Error( `Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${name}" to "${name}[]"` ); } const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0]; if (file) { set_nested_value(input, name, file); } else { // Remove the property by setting to undefined and clean up const path_parts = name.split(/\.|\[|\]/).filter(Boolean); let current = /** @type {any} */ (input); for (let i = 0; i < path_parts.length - 1; i++) { if (current[path_parts[i]] == null) return; current = current[path_parts[i]]; } delete current[path_parts[path_parts.length - 1]]; } } else { set_nested_value( input, name, element.type === 'checkbox' && !element.checked ? null : element.value ); } name = name.replace(/^[nb]:/, ''); touched[name] = true; }); form.addEventListener('reset', async () => { // need to wait a moment, because the `reset` event occurs before // the inputs are actually updated (so that it can be cancelled) await tick(); input = convert_formdata(new FormData(form)); }); return () => { element = null; preflight_schema = undefined; }; }; } instance[createAttachmentKey()] = create_attachment( form_onsubmit(({ submit, form }) => submit().then(() => { if (!issues.$) { form.reset(); } }) ) ); /** @param {Parameters<RemoteForm<any, any>['buttonProps']['enhance']>[0]} callback */ const form_action_onclick = (callback) => { /** @param {Event} event */ return async (event) => { const target = /** @type {HTMLButtonElement} */ (event.currentTarget); const form = target.form; if (!form) return; // Prevent this from firing the form's submit event event.stopPropagation(); event.preventDefault(); const form_data = new FormData(form, target); if (DEV) { const enctype = target.hasAttribute('formenctype') ? target.formEnctype : clone(form).enctype; validate_form_data(form_data, enctype); } await handle_submit(form, form_data, callback); }; }; /** @type {RemoteForm<any, 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, form }) => submit().then(() => { if (!issues.$) { form.reset(); } }) ) }; Object.defineProperty(button_props, 'enhance', { /** @type {RemoteForm<any, any>['buttonProps']['enhance']} */ value: (callback) => { return { type: 'submit', formmethod: 'POST', formaction: action, onclick: form_action_onclick(callback) }; } }); Object.defineProperty(button_props, 'pending', { get: () => pending_count }); let validate_id = 0; // TODO 3.0 remove if (DEV) { throw_on_old_property_access(instance); } Object.defineProperties(instance, { buttonProps: { value: button_props }, fields: { get: () => create_field_proxy( {}, () => input, (path, value) => { if (path.length === 0) { input = value; } else { deep_set(input, path.map(String), value); const key = build_path_string(path); touched[key] = true; } }, () => issues ) }, result: { get: () => result }, pending: { get: () => pending_count }, preflight: { /** @type {RemoteForm<T, U>['preflight']} */ value: (schema) => { preflight_schema = schema; return instance; } }, validate: { /** @type {RemoteForm<any, any>['validate']} */ value: async ({ includeUntouched = false, preflightOnly = false } = {}) => { if (!element) return; const id = ++validate_id; // wait a tick in case the user is calling validate() right after set() which takes time to propagate await tick(); const default_submitter = /** @type {HTMLElement | undefined} */ ( element.querySelector('button:not([type]), [type="submit"]') ); const form_data = new FormData(element, default_submitter); /** @type {InternalRemoteFormIssue[]} */ let array = []; const data = convert(form_data); const validated = await preflight_schema?.['~standard'].validate(data); if (validate_id !== id) { return; } if (validated?.issues) { array = validated.issues.map((issue) => normalize_issue(issue, false)); } else if (!preflightOnly) { const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, { method: 'POST', headers: { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'x-sveltekit-pathname': location.pathname, 'x-sveltekit-search': location.search }, body: serialize_binary_form(data, { validate_only: true }).blob }); const result = await response.json(); if (validate_id !== id) { return; } if (result.type === 'result') { array = /** @type {InternalRemoteFormIssue[]} */ ( devalue.parse(result.result, app.decoders) ); } } if (!includeUntouched && !submitted) { array = array.filter((issue) => touched[issue.name]); } const is_server_validation = !validated?.issues && !preflightOnly; raw_issues = is_server_validation ? array : merge_with_server_issues(form_data, raw_issues, array); } }, enhance: { /** @type {RemoteForm<any, any>['enhance']} */ value: (callback) => { return { method: 'POST', action, [createAttachmentKey()]: create_attachment(form_onsubmit(callback)) }; } } }); return instance; } const instance = create_instance(); Object.defineProperty(instance, 'for', { /** @type {RemoteForm<T, U>['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)); } /** * @param {FormData} form_data * @param {string} enctype */ function validate_form_data(form_data, enctype) { for (const key of form_data.keys()) { if (/^\$[.[]?/.test(key)) { throw new Error( '`$` is used to collect all FormData validation issues and cannot be used as the `name` of a form control' ); } } 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.' ); } } } }