@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
831 lines (688 loc) • 22.6 kB
JavaScript
/** @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();
}
}
}
}