@sveltejs/kit 2.40.0 → 2.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
- /** @import { RemoteForm, RemoteQueryOverride } from '@sveltejs/kit' */
1
+ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */
2
+ /** @import { RemoteFormInput, RemoteForm, RemoteQueryOverride } from '@sveltejs/kit' */
2
3
  /** @import { RemoteFunctionResponse } from 'types' */
3
4
  /** @import { Query } from './query.svelte.js' */
4
5
  import { app_dir, base } from '__sveltekit/paths';
@@ -15,15 +16,18 @@ import {
15
16
  } from '../client.js';
16
17
  import { tick } from 'svelte';
17
18
  import { refresh_queries, release_overrides } from './shared.svelte.js';
19
+ import { createAttachmentKey } from 'svelte/attachments';
20
+ import { convert_formdata, file_transport, flatten_issues } from '../../utils.js';
18
21
 
19
22
  /**
20
23
  * Client-version of the `form` function from `$app/server`.
21
- * @template T
24
+ * @template {RemoteFormInput} T
25
+ * @template U
22
26
  * @param {string} id
23
- * @returns {RemoteForm<T>}
27
+ * @returns {RemoteForm<T, U>}
24
28
  */
25
29
  export function form(id) {
26
- /** @type {Map<any, { count: number, instance: RemoteForm<T> }>} */
30
+ /** @type {Map<any, { count: number, instance: RemoteForm<T, U> }>} */
27
31
  const instances = new Map();
28
32
 
29
33
  /** @param {string | number | boolean} [key] */
@@ -31,12 +35,80 @@ export function form(id) {
31
35
  const action_id = id + (key != undefined ? `/${JSON.stringify(key)}` : '');
32
36
  const action = '?/remote=' + encodeURIComponent(action_id);
33
37
 
38
+ /** @type {Record<string, string | string[] | File | File[]>} */
39
+ let input = $state({});
40
+
41
+ /** @type {Record<string, StandardSchemaV1.Issue[]>} */
42
+ let issues = $state.raw({});
43
+
34
44
  /** @type {any} */
35
45
  let result = $state.raw(started ? undefined : remote_responses[action_id]);
36
46
 
37
47
  /** @type {number} */
38
48
  let pending_count = $state(0);
39
49
 
50
+ /** @type {StandardSchemaV1 | undefined} */
51
+ let preflight_schema = undefined;
52
+
53
+ /** @type {HTMLFormElement | null} */
54
+ let element = null;
55
+
56
+ /** @type {Record<string, boolean>} */
57
+ let touched = {};
58
+
59
+ /**
60
+ * @param {HTMLFormElement} form
61
+ * @param {FormData} form_data
62
+ * @param {Parameters<RemoteForm<any, any>['enhance']>[0]} callback
63
+ */
64
+ async function handle_submit(form, form_data, callback) {
65
+ const data = convert_formdata(form_data);
66
+
67
+ const validated = await preflight_schema?.['~standard'].validate(data);
68
+
69
+ if (validated?.issues) {
70
+ issues = flatten_issues(validated.issues);
71
+ return;
72
+ }
73
+
74
+ // TODO 3.0 remove this warning
75
+ if (DEV) {
76
+ const error = () => {
77
+ throw new Error(
78
+ '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.'
79
+ );
80
+ };
81
+ for (const key of [
82
+ 'append',
83
+ 'delete',
84
+ 'entries',
85
+ 'forEach',
86
+ 'get',
87
+ 'getAll',
88
+ 'has',
89
+ 'keys',
90
+ 'set',
91
+ 'values'
92
+ ]) {
93
+ if (!(key in data)) {
94
+ Object.defineProperty(data, key, { get: error });
95
+ }
96
+ }
97
+ }
98
+
99
+ try {
100
+ await callback({
101
+ form,
102
+ data,
103
+ submit: () => submit(form_data)
104
+ });
105
+ } catch (e) {
106
+ const error = e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message };
107
+ const status = e instanceof HttpError ? e.status : 500;
108
+ void set_nearest_error_page(error, status);
109
+ }
110
+ }
111
+
40
112
  /**
41
113
  * @param {FormData} data
42
114
  * @returns {Promise<any> & { updates: (...args: any[]) => any }}
@@ -64,13 +136,6 @@ export function form(id) {
64
136
  await Promise.resolve();
65
137
 
66
138
  if (updates.length > 0) {
67
- if (DEV) {
68
- if (data.get('sveltekit:remote_refreshes')) {
69
- throw new Error(
70
- 'The FormData key `sveltekit:remote_refreshes` is reserved for internal use and should not be set manually'
71
- );
72
- }
73
- }
74
139
  data.set('sveltekit:remote_refreshes', JSON.stringify(updates.map((u) => u._key)));
75
140
  }
76
141
 
@@ -88,9 +153,18 @@ export function form(id) {
88
153
  const form_result = /** @type { RemoteFunctionResponse} */ (await response.json());
89
154
 
90
155
  if (form_result.type === 'result') {
91
- result = devalue.parse(form_result.result, app.decoders);
92
-
93
- if (form_result.refreshes) {
156
+ ({
157
+ input = {},
158
+ issues = {},
159
+ result
160
+ } = devalue.parse(form_result.result, {
161
+ ...app.decoders,
162
+ File: file_transport.decode
163
+ }));
164
+
165
+ if (issues.$) {
166
+ release_overrides(updates);
167
+ } else if (form_result.refreshes) {
94
168
  refresh_queries(form_result.refreshes, updates);
95
169
  } else {
96
170
  void invalidateAll();
@@ -104,7 +178,6 @@ export function form(id) {
104
178
  // Use internal version to allow redirects to external URLs
105
179
  void _goto(form_result.location, { invalidateAll }, 0);
106
180
  } else {
107
- result = undefined;
108
181
  throw new HttpError(form_result.status ?? 500, form_result.error);
109
182
  }
110
183
  } catch (e) {
@@ -134,43 +207,13 @@ export function form(id) {
134
207
  return promise;
135
208
  }
136
209
 
137
- /** @type {RemoteForm<T>} */
210
+ /** @type {RemoteForm<T, U>} */
138
211
  const instance = {};
139
212
 
140
213
  instance.method = 'POST';
141
214
  instance.action = action;
142
215
 
143
- /**
144
- * @param {HTMLFormElement} form_element
145
- * @param {HTMLElement | null} submitter
146
- */
147
- function create_form_data(form_element, submitter) {
148
- const form_data = new FormData(form_element);
149
-
150
- if (DEV) {
151
- const enctype = submitter?.hasAttribute('formenctype')
152
- ? /** @type {HTMLButtonElement | HTMLInputElement} */ (submitter).formEnctype
153
- : clone(form_element).enctype;
154
- if (enctype !== 'multipart/form-data') {
155
- for (const value of form_data.values()) {
156
- if (value instanceof File) {
157
- throw new Error(
158
- '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.'
159
- );
160
- }
161
- }
162
- }
163
- }
164
-
165
- const submitter_name = submitter?.getAttribute('name');
166
- if (submitter_name) {
167
- form_data.append(submitter_name, submitter?.getAttribute('value') ?? '');
168
- }
169
-
170
- return form_data;
171
- }
172
-
173
- /** @param {Parameters<RemoteForm<any>['enhance']>[0]} callback */
216
+ /** @param {Parameters<RemoteForm<any, any>['enhance']>[0]} callback */
174
217
  const form_onsubmit = (callback) => {
175
218
  /** @param {SubmitEvent} event */
176
219
  return async (event) => {
@@ -194,26 +237,105 @@ export function form(id) {
194
237
 
195
238
  event.preventDefault();
196
239
 
197
- const data = create_form_data(form, event.submitter);
240
+ const form_data = new FormData(form);
198
241
 
199
- try {
200
- await callback({
201
- form,
202
- data,
203
- submit: () => submit(data)
204
- });
205
- } catch (e) {
206
- const error =
207
- e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message };
208
- const status = e instanceof HttpError ? e.status : 500;
209
- void set_nearest_error_page(error, status);
242
+ if (DEV) {
243
+ validate_form_data(form_data, clone(form).enctype);
210
244
  }
245
+
246
+ await handle_submit(form, form_data, callback);
211
247
  };
212
248
  };
213
249
 
214
- instance.onsubmit = form_onsubmit(({ submit, form }) => submit().then(() => form.reset()));
250
+ /** @param {(event: SubmitEvent) => void} onsubmit */
251
+ function create_attachment(onsubmit) {
252
+ return (/** @type {HTMLFormElement} */ form) => {
253
+ if (element) {
254
+ let message = `A form object can only be attached to a single \`<form>\` element`;
255
+ if (DEV && !key) {
256
+ const name = id.split('/').pop();
257
+ message += `. To create multiple instances, use \`${name}.for(key)\``;
258
+ }
259
+
260
+ throw new Error(message);
261
+ }
262
+
263
+ element = form;
264
+
265
+ touched = {};
266
+
267
+ form.addEventListener('submit', onsubmit);
268
+
269
+ form.addEventListener('input', (e) => {
270
+ // strictly speaking it can be an HTMLTextAreaElement or HTMLSelectElement
271
+ // but that makes the types unnecessarily awkward
272
+ const element = /** @type {HTMLInputElement} */ (e.target);
273
+
274
+ let name = element.name;
275
+ if (!name) return;
276
+
277
+ const is_array = name.endsWith('[]');
278
+ if (is_array) name = name.slice(0, -2);
279
+
280
+ const is_file = element.type === 'file';
281
+
282
+ touched[name] = true;
283
+
284
+ if (is_array) {
285
+ const elements = /** @type {HTMLInputElement[]} */ (
286
+ Array.from(form.querySelectorAll(`[name="${name}[]"]`))
287
+ );
288
+
289
+ if (DEV) {
290
+ for (const e of elements) {
291
+ if ((e.type === 'file') !== is_file) {
292
+ throw new Error(
293
+ `Cannot mix and match file and non-file inputs under the same name ("${element.name}")`
294
+ );
295
+ }
296
+ }
297
+ }
298
+
299
+ input[name] = is_file
300
+ ? elements.map((input) => Array.from(input.files ?? [])).flat()
301
+ : elements.map((element) => element.value);
302
+ } else if (is_file) {
303
+ if (DEV && element.multiple) {
304
+ throw new Error(
305
+ `Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${name}" to "${name}[]"`
306
+ );
307
+ }
308
+
309
+ const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0];
215
310
 
216
- /** @param {Parameters<RemoteForm<any>['buttonProps']['enhance']>[0]} callback */
311
+ if (file) {
312
+ input[name] = file;
313
+ } else {
314
+ delete input[name];
315
+ }
316
+ } else {
317
+ input[name] = element.value;
318
+ }
319
+ });
320
+
321
+ return () => {
322
+ element = null;
323
+ preflight_schema = undefined;
324
+ };
325
+ };
326
+ }
327
+
328
+ instance[createAttachmentKey()] = create_attachment(
329
+ form_onsubmit(({ submit, form }) =>
330
+ submit().then(() => {
331
+ if (!issues.$) {
332
+ form.reset();
333
+ }
334
+ })
335
+ )
336
+ );
337
+
338
+ /** @param {Parameters<RemoteForm<any, any>['buttonProps']['enhance']>[0]} callback */
217
339
  const form_action_onclick = (callback) => {
218
340
  /** @param {Event} event */
219
341
  return async (event) => {
@@ -225,34 +347,41 @@ export function form(id) {
225
347
  event.stopPropagation();
226
348
  event.preventDefault();
227
349
 
228
- const data = create_form_data(form, target);
350
+ const form_data = new FormData(form);
229
351
 
230
- try {
231
- await callback({
232
- form,
233
- data,
234
- submit: () => submit(data)
235
- });
236
- } catch (e) {
237
- const error =
238
- e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message };
239
- const status = e instanceof HttpError ? e.status : 500;
240
- void set_nearest_error_page(error, status);
352
+ if (DEV) {
353
+ const enctype = target.hasAttribute('formenctype')
354
+ ? target.formEnctype
355
+ : clone(form).enctype;
356
+
357
+ validate_form_data(form_data, enctype);
241
358
  }
359
+
360
+ if (target.name) {
361
+ form_data.append(target.name, target?.getAttribute('value') ?? '');
362
+ }
363
+
364
+ await handle_submit(form, form_data, callback);
242
365
  };
243
366
  };
244
367
 
245
- /** @type {RemoteForm<any>['buttonProps']} */
368
+ /** @type {RemoteForm<any, any>['buttonProps']} */
246
369
  // @ts-expect-error we gotta set enhance as a non-enumerable property
247
370
  const button_props = {
248
371
  type: 'submit',
249
372
  formmethod: 'POST',
250
373
  formaction: action,
251
- onclick: form_action_onclick(({ submit, form }) => submit().then(() => form.reset()))
374
+ onclick: form_action_onclick(({ submit, form }) =>
375
+ submit().then(() => {
376
+ if (!issues.$) {
377
+ form.reset();
378
+ }
379
+ })
380
+ )
252
381
  };
253
382
 
254
383
  Object.defineProperty(button_props, 'enhance', {
255
- /** @type {RemoteForm<any>['buttonProps']['enhance']} */
384
+ /** @type {RemoteForm<any, any>['buttonProps']['enhance']} */
256
385
  value: (callback) => {
257
386
  return {
258
387
  type: 'submit',
@@ -267,23 +396,106 @@ export function form(id) {
267
396
  get: () => pending_count
268
397
  });
269
398
 
399
+ let validate_id = 0;
400
+
270
401
  Object.defineProperties(instance, {
271
402
  buttonProps: {
272
403
  value: button_props
273
404
  },
405
+ input: {
406
+ get: () => input,
407
+ set: (v) => {
408
+ input = v;
409
+ }
410
+ },
411
+ issues: {
412
+ get: () => issues
413
+ },
274
414
  result: {
275
415
  get: () => result
276
416
  },
277
417
  pending: {
278
418
  get: () => pending_count
279
419
  },
420
+ field: {
421
+ value: (/** @type {string} */ name) => name
422
+ },
423
+ preflight: {
424
+ /** @type {RemoteForm<any, any>['preflight']} */
425
+ value: (schema) => {
426
+ preflight_schema = schema;
427
+ return instance;
428
+ }
429
+ },
430
+ validate: {
431
+ /** @type {RemoteForm<any, any>['validate']} */
432
+ value: async ({ includeUntouched = false } = {}) => {
433
+ if (!element) return;
434
+
435
+ const id = ++validate_id;
436
+
437
+ const form_data = new FormData(element);
438
+
439
+ /** @type {readonly StandardSchemaV1.Issue[]} */
440
+ let array = [];
441
+
442
+ const validated = await preflight_schema?.['~standard'].validate(
443
+ convert_formdata(form_data)
444
+ );
445
+
446
+ if (validated?.issues) {
447
+ array = validated.issues;
448
+ } else {
449
+ form_data.set('sveltekit:validate_only', 'true');
450
+
451
+ const response = await fetch(`${base}/${app_dir}/remote/${action_id}`, {
452
+ method: 'POST',
453
+ body: form_data
454
+ });
455
+
456
+ const result = await response.json();
457
+
458
+ if (validate_id !== id) {
459
+ return;
460
+ }
461
+
462
+ if (result.type === 'result') {
463
+ array = /** @type {StandardSchemaV1.Issue[]} */ (
464
+ devalue.parse(result.result, app.decoders)
465
+ );
466
+ }
467
+ }
468
+
469
+ if (!includeUntouched) {
470
+ array = array.filter((issue) => {
471
+ if (issue.path !== undefined) {
472
+ let path = '';
473
+
474
+ for (const segment of issue.path) {
475
+ const key = typeof segment === 'object' ? segment.key : segment;
476
+
477
+ if (typeof key === 'number') {
478
+ path += `[${key}]`;
479
+ } else if (typeof key === 'string') {
480
+ path += path === '' ? key : '.' + key;
481
+ }
482
+ }
483
+
484
+ return touched[path];
485
+ }
486
+ });
487
+ }
488
+
489
+ issues = flatten_issues(array);
490
+ }
491
+ },
280
492
  enhance: {
281
- /** @type {RemoteForm<any>['enhance']} */
493
+ /** @type {RemoteForm<any, any>['enhance']} */
282
494
  value: (callback) => {
283
495
  return {
284
496
  method: 'POST',
285
497
  action,
286
- onsubmit: form_onsubmit(callback)
498
+ [createAttachmentKey()]: create_attachment(form_onsubmit(callback))
287
499
  };
288
500
  }
289
501
  }
@@ -295,7 +507,7 @@ export function form(id) {
295
507
  const instance = create_instance();
296
508
 
297
509
  Object.defineProperty(instance, 'for', {
298
- /** @type {RemoteForm<any>['for']} */
510
+ /** @type {RemoteForm<T, U>['for']} */
299
511
  value: (key) => {
300
512
  const entry = instances.get(key) ?? { count: 0, instance: create_instance(key) };
301
513
 
@@ -335,3 +547,33 @@ export function form(id) {
335
547
  function clone(element) {
336
548
  return /** @type {T} */ (HTMLElement.prototype.cloneNode.call(element));
337
549
  }
550
+
551
+ /**
552
+ * @param {FormData} form_data
553
+ * @param {string} enctype
554
+ */
555
+ function validate_form_data(form_data, enctype) {
556
+ for (const key of form_data.keys()) {
557
+ if (key.startsWith('sveltekit:')) {
558
+ throw new Error(
559
+ 'FormData keys starting with `sveltekit:` are reserved for internal use and should not be set manually'
560
+ );
561
+ }
562
+
563
+ if (/^\$[.[]?/.test(key)) {
564
+ throw new Error(
565
+ '`$` is used to collect all FormData validation issues and cannot be used as the `name` of a form control'
566
+ );
567
+ }
568
+ }
569
+
570
+ if (enctype !== 'multipart/form-data') {
571
+ for (const value of form_data.values()) {
572
+ if (value instanceof File) {
573
+ throw new Error(
574
+ '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.'
575
+ );
576
+ }
577
+ }
578
+ }
579
+ }
@@ -408,29 +408,6 @@ export async function render_response({
408
408
  }`);
409
409
  }
410
410
 
411
- const { remote_data } = event_state;
412
-
413
- if (remote_data) {
414
- /** @type {Record<string, any>} */
415
- const remote = {};
416
-
417
- for (const key in remote_data) {
418
- remote[key] = await remote_data[key];
419
- }
420
-
421
- // TODO this is repeated in a few places — dedupe it
422
- const replacer = (/** @type {any} */ thing) => {
423
- for (const key in options.hooks.transport) {
424
- const encoded = options.hooks.transport[key].encode(thing);
425
- if (encoded) {
426
- return `app.decode('${key}', ${devalue.uneval(encoded, replacer)})`;
427
- }
428
- }
429
- };
430
-
431
- properties.push(`data: ${devalue.uneval(remote, replacer)}`);
432
- }
433
-
434
411
  // create this before declaring `data`, which may contain references to `${global}`
435
412
  blocks.push(`${global} = {
436
413
  ${properties.join(',\n\t\t\t\t\t\t')}
@@ -482,20 +459,45 @@ export async function render_response({
482
459
  args.push(`{\n${indent}\t${hydrate.join(`,\n${indent}\t`)}\n${indent}}`);
483
460
  }
484
461
 
462
+ const { remote_data } = event_state;
463
+
464
+ let serialized_remote_data = '';
465
+
466
+ if (remote_data) {
467
+ /** @type {Record<string, any>} */
468
+ const remote = {};
469
+
470
+ for (const key in remote_data) {
471
+ remote[key] = await remote_data[key];
472
+ }
473
+
474
+ // TODO this is repeated in a few places — dedupe it
475
+ const replacer = (/** @type {any} */ thing) => {
476
+ for (const key in options.hooks.transport) {
477
+ const encoded = options.hooks.transport[key].encode(thing);
478
+ if (encoded) {
479
+ return `app.decode('${key}', ${devalue.uneval(encoded, replacer)})`;
480
+ }
481
+ }
482
+ };
483
+
484
+ serialized_remote_data = `${global}.data = ${devalue.uneval(remote, replacer)};\n\n\t\t\t\t\t\t`;
485
+ }
486
+
485
487
  // `client.app` is a proxy for `bundleStrategy === 'split'`
486
488
  const boot = client.inline
487
489
  ? `${client.inline.script}
488
490
 
489
- __sveltekit_${options.version_hash}.app.start(${args.join(', ')});`
491
+ ${serialized_remote_data}${global}.app.start(${args.join(', ')});`
490
492
  : client.app
491
493
  ? `Promise.all([
492
494
  import(${s(prefixed(client.start))}),
493
495
  import(${s(prefixed(client.app))})
494
496
  ]).then(([kit, app]) => {
495
- kit.start(app, ${args.join(', ')});
497
+ ${serialized_remote_data}kit.start(app, ${args.join(', ')});
496
498
  });`
497
499
  : `import(${s(prefixed(client.start))}).then((app) => {
498
- app.start(${args.join(', ')})
500
+ ${serialized_remote_data}app.start(${args.join(', ')})
499
501
  });`;
500
502
 
501
503
  if (load_env_eagerly) {
@@ -12,6 +12,7 @@ import { normalize_error } from '../../utils/error.js';
12
12
  import { check_incorrect_fail_use } from './page/actions.js';
13
13
  import { DEV } from 'esm-env';
14
14
  import { record_span } from '../telemetry/record_span.js';
15
+ import { file_transport } from '../utils.js';
15
16
 
16
17
  /** @type {typeof handle_remote_call_internal} */
17
18
  export async function handle_remote_call(event, state, options, manifest, id) {
@@ -128,8 +129,8 @@ async function handle_remote_call_internal(event, state, options, manifest, id)
128
129
  return json(
129
130
  /** @type {RemoteFunctionResponse} */ ({
130
131
  type: 'result',
131
- result: stringify(data, transport),
132
- refreshes: await serialize_refreshes(form_client_refreshes)
132
+ result: stringify(data, { ...transport, File: file_transport }),
133
+ refreshes: data.issues ? {} : await serialize_refreshes(form_client_refreshes)
133
134
  })
134
135
  );
135
136
  }
@@ -262,7 +263,7 @@ async function handle_remote_form_post_internal(event, state, manifest, id) {
262
263
  const remotes = manifest._.remotes;
263
264
  const module = await remotes[hash]?.();
264
265
 
265
- let form = /** @type {RemoteForm<any>} */ (module?.default[name]);
266
+ let form = /** @type {RemoteForm<any, any>} */ (module?.default[name]);
266
267
 
267
268
  if (!form) {
268
269
  event.setHeaders({