UNPKG

@sveltejs/kit

Version:

SvelteKit is the fastest way to build Svelte apps

831 lines (688 loc) • 22.6 kB
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ /** @import { RemoteFormInput, RemoteForm, RemoteQueryUpdate } from '$app/server' */ /** @import { InternalRemoteFormIssue } from 'types' */ import { app_dir, base } from '#app/paths'; import { DEV } from 'esm-env'; import * as e from '../../../messages/client-errors.js'; import * as w from '../../../messages/client-warnings.js'; import { query_responses, _goto, set_nearest_error_page, handle_error, refreshAll } from '../client.js'; import { page } from '#app/state/client'; import { tick } from 'svelte'; import { categorize_updates, fail_unhandled_refreshes, remote_request } from './shared.svelte.js'; import { createAttachmentKey } from 'svelte/attachments'; import { convert_formdata, flatten_issues, create_field_proxy, deep_set, set_nested_value, build_path_string, normalize_issue, serialize_binary_form, deep_get, DELETE_KEY, BINARY_FORM_CONTENT_TYPE, parse_form_key, coerce_form_value } 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 client_names = new Set(client_issues.map((issue) => issue.name)); const merged = [ ...current_issues.filter((issue) => issue.server && !client_names.has(issue.name)), ...client_issues ]; /** @type {Map<string, number>} */ const positions = new Map(); let i = 0; for (const key of form_data.keys()) { if (!positions.has(key)) positions.set(key, i); i++; } return merged.sort((a, b) => (positions.get(a.name) ?? -1) - (positions.get(b.name) ?? -1)); } /** * 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(); /** @type {StandardSchemaV1 | null} */ let shared_preflight_schema = null; /** @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 {string} */ let cached_search = ''; /** @type {string} */ let cached_query = ''; /** @returns {string} */ function get_action() { if (page.url.search !== cached_search) { cached_search = page.url.search; if (page.url.search) { const params = new URLSearchParams(page.url.search); params.delete('/remote'); cached_query = params.toString(); } else { cached_query = ''; } } return `?${cached_query && `${cached_query}&`}${action}`; } // the output of a non-enhanced submission that resulted in this page — // consume it so the form's state survives hydration (form outputs are // always value nodes; the server never serializes them as errors) /** @type {{ input?: Record<string, any>, issues?: InternalRemoteFormIssue[], result?: any } | undefined} */ const initial = query_responses[action_id]?.v; delete query_responses[action_id]; /** * @type {Record<string, string | string[] | File | File[]>} */ let input = $state(initial?.input ?? {}); /** @type {InternalRemoteFormIssue[]} */ let raw_issues = $state.raw(initial?.issues ?? []); const issues = $derived(flatten_issues(raw_issues)); /** @type {any} */ let result = $state.raw(initial?.result); /** @type {number} */ let pending_count = $state(0); /** @type {StandardSchemaV1 | undefined} */ let preflight_schema = undefined; /** * @param {Omit<RemoteForm<T, U>, 'enhance' | 'element'> & { readonly element: HTMLFormElement }} instance */ let enhance_callback = async (instance) => { if (await instance.submit()) { await tick(); // We call reset from the prototype to avoid DOM clobbering if (instance.element.isConnected) { HTMLFormElement.prototype.reset.call(instance.element); } } }; /** @type {HTMLFormElement | null} */ let element = null; /** @type {Record<string, boolean>} */ let touched = $state({}); /** @type {Record<string, boolean>} */ let dirty = $state({}); /** @type {Record<string, boolean>} */ let can_validate = {}; let submitted = $state(false); /** @type {InternalRemoteFormIssue[] | null} */ let unread_issues = null; /** @type {{ name: string; type: 'number' | 'boolean' | null; is_array: boolean } | null} */ let previous_submitter = null; /** * In dev, warn if there are validation issues going unread */ function warn_on_missing_issue_reads() { unread_issues = raw_issues; setTimeout(() => { if (unread_issues === null) { return; } if (unread_issues.length > 0) { const summary = unread_issues .map((issue) => issue.path.length === 0 ? ` - ${issue.message}` : ` - ${issue.path.join('.')} (${issue.message})` ) .join('\n'); w.remote_form_issues_ignored({ issues: summary }, { element: element ?? undefined }); } unread_issues = null; }); } /** * @param {FormData} form_data * @returns {Record<string, any>} */ function convert(form_data) { const data = convert_formdata(action_id_without_key, form_data); if (key !== undefined && !('id' in data)) { data.id = key; } return data; } /** * @param {FormData} form_data * @param {boolean} should_preflight * @returns {Promise<boolean> & { updates: (...args: any[]) => Promise<boolean> }} */ function submit(form_data, should_preflight) { // 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++; } let overrides = /** @type {Array<() => void> | null} */ (null); /** @type {Set<string> | null} */ let refreshes = null; /** @type {Error | undefined} */ let updates_error; const promise = /** @type {Promise<boolean> & { updates: (...args: RemoteQueryUpdate[]) => Promise<boolean> }} */ ( (async () => { try { await Promise.resolve(); if (updates_error) { throw updates_error; } if (should_preflight) { const valid = await preflight(form_data); if (!valid) return false; } const { blob } = serialize_binary_form(convert(form_data), { remote_refreshes: Array.from(refreshes ?? []) }); const response = await remote_request( `${base}/${app_dir}/remote/${action_id_without_key}`, { method: 'POST', headers: { 'Content-Type': BINARY_FORM_CONTENT_TYPE, // Forms cannot be called during rendering, so it's save to use location here 'x-sveltekit-pathname': location.pathname, 'x-sveltekit-search': location.search }, body: blob }, refreshes ); ({ issues: raw_issues = [], result } = response._ ?? {}); // if the developer took control of updates via `.updates(...)` (even with // no arguments), or the server performed explicit refreshes, don't invalidateAll const should_refresh = refreshes === null && !response.r; if (response.redirect) { // Use internal version to allow redirects to external URLs await _goto(response.redirect, { refreshAll: should_refresh }); return true; } const succeeded = raw_issues.length === 0; if (succeeded) { fail_unhandled_refreshes(refreshes); if (should_refresh) { await refreshAll(); } } else { if (DEV) { warn_on_missing_issue_reads(); } } return succeeded; } catch (e) { result = undefined; raw_issues = []; throw e; } finally { overrides?.forEach((fn) => fn()); void tick().then(() => { if (entry) { entry.count--; if (entry.count === 0) { instances.delete(key); } } }); } })() ); let updates_called = false; promise.updates = (...args) => { if (updates_called) { w.remote_updates_repeated({ invocation: 'form submission' }); return promise; } updates_called = true; try { ({ refreshes, overrides } = categorize_updates(args)); } catch (error) { updates_error = /** @type {Error} */ (error); } return promise; }; return promise; } /** * @param {HTMLFormElement} form * @param {FormData} form_data * @returns {Omit<RemoteForm<T, U>, 'enhance' | 'element'> & { readonly element: HTMLFormElement }} */ function create_enhance_callback_instance(form, form_data) { const { enhance: _enhance, ...descriptors } = Object.getOwnPropertyDescriptors(instance); void _enhance; return /** @type {Omit<RemoteForm<T, U>, 'enhance' | 'element'> & { readonly element: HTMLFormElement }} */ ( Object.defineProperties( {}, { ...descriptors, element: { value: form }, submit: { value: () => submit(form_data, false) } } ) ); } /** * @param {FormData} form_data */ async function preflight(form_data) { const data = convert(form_data); const schema = preflight_schema ?? shared_preflight_schema; const validated = await 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)) ); if (DEV) { warn_on_missing_issue_reads(); } return false; } // Preflight passed - clear stale client-side preflight issues if (preflight_schema) { raw_issues = raw_issues.filter((issue) => issue.server); } return true; } const instance = /** @type {RemoteForm<T, U>} */ ({}); instance.method = 'POST'; Object.defineProperty(instance, 'action', { get: get_action, enumerable: true }); instance[createAttachmentKey()] = (/** @type {HTMLFormElement} */ form) => { if (element) { e.remote_form_multiple_elements(); } element = form; touched = {}; dirty = {}; can_validate = {}; /** @param {SubmitEvent} event */ const handle_submit = 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; } const target = event.submitter?.hasAttribute('formtarget') ? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formTarget : clone(form).target; if (target === '_blank') { return; } event.preventDefault(); const form_data = new FormData(form, event.submitter); if ( previous_submitter !== null && !Array.from(form_data.keys()) .map((k) => parse_form_key(action_id_without_key, k).name) .includes(previous_submitter.name) ) { set_nested_value(input, previous_submitter, undefined); } if (event.submitter && /** @type {HTMLInputElement} */ (event.submitter).type !== 'image') { const name = event.submitter.getAttribute('name'); /** @type {null | ReturnType<typeof parse_form_key>} */ let submitter = null; const value = /** @type {any} */ (event.submitter).value; if (name !== null && value !== undefined) { submitter = parse_form_key(action_id_without_key, name); set_nested_value(input, submitter, coerce_form_value(submitter.type, value)); } previous_submitter = submitter; } else { previous_submitter = null; } if (DEV) { validate_form_data(form_data, clone(form).enctype); } submitted = true; try { // Increment pending count immediately so that `pending` reflects // the in-progress state during async preflight validation pending_count++; const valid = await preflight(form_data); if (!valid) return; await enhance_callback(create_enhance_callback_instance(form, form_data)); } catch (e) { const error = await handle_error(e, { params: {}, route: { id: null }, url: new URL(location.href) }); void set_nearest_error_page(error); } finally { pending_count--; } }; /** @param {Event} event */ const handle_input = (event) => { // strictly speaking it can be an HTMLTextAreaElement or HTMLSelectElement // but that makes the types unnecessarily awkward const element = /** @type {HTMLInputElement} */ (event.target); const name = element.name; if (!name) return; const field = parse_form_key(action_id_without_key, name); const is_file = element.type === 'file'; if (field.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 input of elements) { if ((input.type === 'file') !== is_file) { e.remote_form_mixed_inputs({ 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, field, is_file ? value : coerce_form_value(field.type, value)); } else if (is_file) { if (DEV && element.multiple) { e.remote_form_multiple_files({ name }); } const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0]; if (file) { set_nested_value(input, field, file); } else { set_nested_value(input, field, DELETE_KEY); } } else { set_nested_value( input, field, coerce_form_value( field.type, element.type === 'checkbox' && !element.checked ? null : element.value ) ); } dirty[field.name] = true; }; const handle_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(action_id_without_key, new FormData(form)); raw_issues = []; touched = {}; dirty = {}; can_validate = {}; submitted = false; }; /** @param {Event} e */ const handle_focusout = (e) => { const name = /** @type {HTMLInputElement} */ (e.target).name; if (!name) return; const field = parse_form_key(action_id_without_key, name); touched[field.name] = true; if (Object.hasOwn(dirty, field.name)) { can_validate[field.name] = true; } }; form.addEventListener('submit', handle_submit); form.addEventListener('input', handle_input); form.addEventListener('focusout', handle_focusout); form.addEventListener('reset', handle_reset); return () => { form.removeEventListener('submit', handle_submit); form.removeEventListener('input', handle_input); form.removeEventListener('focusout', handle_focusout); form.removeEventListener('reset', handle_reset); element = null; }; }; let validate_id = 0; Object.defineProperties(instance, { element: { get: () => element }, submit: { value: () => { if (!element) { e.remote_form_not_attached(); } const default_submitter = /** @type {HTMLElement | undefined} */ ( element.querySelector('button:not([type]), [type="submit"], [type="image"]') ); const form_data = new FormData(element, default_submitter); if (DEV) { validate_form_data(form_data, clone(element).enctype); } submitted = true; pending_count++; const submission = submit(form_data, true); const decrement = () => { pending_count--; }; void submission.then(decrement, decrement); return submission; } }, fields: { get: () => create_field_proxy({ form_id: action_id_without_key, get: () => input, set: (path, value) => { if (path.length === 0) { input = value; } else if (value !== deep_get(input, path)) { deep_set(input, path.map(String), value); const key = build_path_string(path); if (element) { touched[key] = true; dirty[key] = true; can_validate[key] = true; } } }, get_issues: (path, all) => { if (DEV && unread_issues !== null && path !== undefined) { unread_issues = unread_issues.filter((issue) => { return ( (all ? issue.path.slice(0, path.length) : issue.path).join('.') !== path.join('.') ); }); } return issues; }, get_touched: () => touched, get_dirty: () => dirty }) }, result: { get: () => result }, pending: { get: () => pending_count }, submitted: { get: () => submitted }, preflight: { /** @type {RemoteForm<T, U>['preflight']} */ value: (schema) => { preflight_schema = schema; if (key === undefined) { shared_preflight_schema = schema; } return instance; } }, validate: { /** @type {RemoteForm<any, any>['validate']} */ value: async ({ all = false, preflightOnly = false } = {}) => { const id = ++validate_id; // wait a tick in case the user is calling validate() right after set() which takes time to propagate await tick(); // the form may have been removed from the DOM while we were waiting if (!element) return; const default_submitter = /** @type {HTMLElement | undefined} */ ( element.querySelector('button:not([type]), [type="submit"], [type="image"]') ); const form_data = new FormData(element, default_submitter); /** @type {InternalRemoteFormIssue[]} */ let array = []; const data = convert(form_data); const schema = preflight_schema ?? shared_preflight_schema; const validated = await 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 result = await remote_request( `${base}/${app_dir}/remote/${action_id_without_key}`, { method: 'POST', headers: { 'Content-Type': BINARY_FORM_CONTENT_TYPE, // Validation should not be and will not be called during rendering, so it's save to use location here 'x-sveltekit-pathname': location.pathname, 'x-sveltekit-search': location.search }, body: serialize_binary_form(data, { validate_only: true }).blob } ); if (validate_id !== id) { return; } array = /** @type {InternalRemoteFormIssue[]} */ (result._); } if (!all && !submitted) { array = array.filter((issue) => can_validate[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: { /** * @param {(instance: Omit<RemoteForm<T, U>, 'enhance' | 'element'> & { readonly element: HTMLFormElement }) => any} callback */ value: (callback) => { enhance_callback = callback; return instance; } } }); 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(() => { entry.count += 1; instances.set(key, entry); return () => { entry.count--; void tick().then(() => { if (entry.count === 0 && instances.get(key) === entry) { instances.delete(key); } }); }; }); } 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)) { e.remote_form_reserved_field(); } } if (enctype !== 'multipart/form-data') { for (const value of form_data.values()) { if (value instanceof File) { e.enhance_file_without_enctype(); } } } }