@sveltejs/kit 2.43.7 → 2.44.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,6 +1,6 @@
1
1
  /** @import { StandardSchemaV1 } from '@standard-schema/spec' */
2
2
  /** @import { RemoteFormInput, RemoteForm, RemoteQueryOverride } from '@sveltejs/kit' */
3
- /** @import { RemoteFunctionResponse } from 'types' */
3
+ /** @import { InternalRemoteFormIssue, RemoteFunctionResponse } from 'types' */
4
4
  /** @import { Query } from './query.svelte.js' */
5
5
  import { app_dir, base } from '$app/paths/internal/client';
6
6
  import * as devalue from 'devalue';
@@ -10,7 +10,34 @@ import { app, remote_responses, _goto, set_nearest_error_page, invalidateAll } f
10
10
  import { tick } from 'svelte';
11
11
  import { refresh_queries, release_overrides } from './shared.svelte.js';
12
12
  import { createAttachmentKey } from 'svelte/attachments';
13
- import { convert_formdata, file_transport, flatten_issues } from '../../utils.js';
13
+ import {
14
+ convert_formdata,
15
+ flatten_issues,
16
+ create_field_proxy,
17
+ deep_set,
18
+ set_nested_value,
19
+ throw_on_old_property_access
20
+ } from '../../form-utils.svelte.js';
21
+
22
+ /**
23
+ * Merge client issues into server issues
24
+ * @param {Record<string, InternalRemoteFormIssue[]>} current_issues
25
+ * @param {Record<string, InternalRemoteFormIssue[]>} client_issues
26
+ * @returns {Record<string, InternalRemoteFormIssue[]>}
27
+ */
28
+ function merge_with_server_issues(current_issues, client_issues) {
29
+ const merged_issues = Object.fromEntries(
30
+ Object.entries(current_issues)
31
+ .map(([key, issue_list]) => [key, issue_list.filter((issue) => issue.server)])
32
+ .filter(([, issue_list]) => issue_list.length > 0)
33
+ );
34
+
35
+ for (const [key, new_issue_list] of Object.entries(client_issues)) {
36
+ merged_issues[key] = [...(merged_issues[key] || []), ...new_issue_list];
37
+ }
38
+
39
+ return merged_issues;
40
+ }
14
41
 
15
42
  /**
16
43
  * Client-version of the `form` function from `$app/server`.
@@ -28,10 +55,14 @@ export function form(id) {
28
55
  const action_id = id + (key != undefined ? `/${JSON.stringify(key)}` : '');
29
56
  const action = '?/remote=' + encodeURIComponent(action_id);
30
57
 
31
- /** @type {Record<string, string | string[] | File | File[]>} */
32
- let input = $state({});
58
+ /**
59
+ * By making this $state.raw() and creating a new object each time we update it,
60
+ * all consumers along the update chain are properly invalidated.
61
+ * @type {Record<string, string | string[] | File | File[]>}
62
+ */
63
+ let input = $state.raw({});
33
64
 
34
- /** @type {Record<string, StandardSchemaV1.Issue[]>} */
65
+ /** @type {Record<string, InternalRemoteFormIssue[]>} */
35
66
  let issues = $state.raw({});
36
67
 
37
68
  /** @type {any} */
@@ -49,6 +80,8 @@ export function form(id) {
49
80
  /** @type {Record<string, boolean>} */
50
81
  let touched = {};
51
82
 
83
+ let submitted = false;
84
+
52
85
  /**
53
86
  * @param {HTMLFormElement} form
54
87
  * @param {FormData} form_data
@@ -57,10 +90,13 @@ export function form(id) {
57
90
  async function handle_submit(form, form_data, callback) {
58
91
  const data = convert_formdata(form_data);
59
92
 
93
+ submitted = true;
94
+
60
95
  const validated = await preflight_schema?.['~standard'].validate(data);
61
96
 
62
97
  if (validated?.issues) {
63
- issues = flatten_issues(validated.issues);
98
+ const client_issues = flatten_issues(validated.issues, false);
99
+ issues = merge_with_server_issues(issues, client_issues);
64
100
  return;
65
101
  }
66
102
 
@@ -134,7 +170,11 @@ export function form(id) {
134
170
 
135
171
  const response = await fetch(`${base}/${app_dir}/remote/${action_id}`, {
136
172
  method: 'POST',
137
- body: data
173
+ body: data,
174
+ headers: {
175
+ 'x-sveltekit-pathname': location.pathname,
176
+ 'x-sveltekit-search': location.search
177
+ }
138
178
  });
139
179
 
140
180
  if (!response.ok) {
@@ -146,21 +186,25 @@ export function form(id) {
146
186
  const form_result = /** @type { RemoteFunctionResponse} */ (await response.json());
147
187
 
148
188
  if (form_result.type === 'result') {
149
- ({
150
- input = {},
151
- issues = {},
152
- result
153
- } = devalue.parse(form_result.result, {
154
- ...app.decoders,
155
- File: file_transport.decode
156
- }));
189
+ ({ issues = {}, result } = devalue.parse(form_result.result, app.decoders));
190
+
191
+ // Mark server issues with server: true
192
+ for (const issue_list of Object.values(issues)) {
193
+ for (const issue of issue_list) {
194
+ issue.server = true;
195
+ }
196
+ }
157
197
 
158
198
  if (issues.$) {
159
199
  release_overrides(updates);
160
- } else if (form_result.refreshes) {
161
- refresh_queries(form_result.refreshes, updates);
162
200
  } else {
163
- void invalidateAll();
201
+ input = {};
202
+
203
+ if (form_result.refreshes) {
204
+ refresh_queries(form_result.refreshes, updates);
205
+ } else {
206
+ void invalidateAll();
207
+ }
164
208
  }
165
209
  } else if (form_result.type === 'redirect') {
166
210
  const refreshes = form_result.refreshes ?? '';
@@ -275,23 +319,37 @@ export function form(id) {
275
319
  touched[name] = true;
276
320
 
277
321
  if (is_array) {
278
- const elements = /** @type {HTMLInputElement[]} */ (
279
- Array.from(form.querySelectorAll(`[name="${name}[]"]`))
280
- );
322
+ let value;
281
323
 
282
- if (DEV) {
283
- for (const e of elements) {
284
- if ((e.type === 'file') !== is_file) {
285
- throw new Error(
286
- `Cannot mix and match file and non-file inputs under the same name ("${element.name}")`
287
- );
324
+ if (element.tagName === 'SELECT') {
325
+ value = Array.from(
326
+ element.querySelectorAll('option:checked'),
327
+ (e) => /** @type {HTMLOptionElement} */ (e).value
328
+ );
329
+ } else {
330
+ const elements = /** @type {HTMLInputElement[]} */ (
331
+ Array.from(form.querySelectorAll(`[name="${name}[]"]`))
332
+ );
333
+
334
+ if (DEV) {
335
+ for (const e of elements) {
336
+ if ((e.type === 'file') !== is_file) {
337
+ throw new Error(
338
+ `Cannot mix and match file and non-file inputs under the same name ("${element.name}")`
339
+ );
340
+ }
288
341
  }
289
342
  }
343
+
344
+ value = is_file
345
+ ? elements.map((input) => Array.from(input.files ?? [])).flat()
346
+ : elements.map((element) => element.value);
347
+ if (element.type === 'checkbox') {
348
+ value = /** @type {string[]} */ (value.filter((_, i) => elements[i].checked));
349
+ }
290
350
  }
291
351
 
292
- input[name] = is_file
293
- ? elements.map((input) => Array.from(input.files ?? [])).flat()
294
- : elements.map((element) => element.value);
352
+ input = set_nested_value(input, name, value);
295
353
  } else if (is_file) {
296
354
  if (DEV && element.multiple) {
297
355
  throw new Error(
@@ -302,12 +360,23 @@ export function form(id) {
302
360
  const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0];
303
361
 
304
362
  if (file) {
305
- input[name] = file;
363
+ input = set_nested_value(input, name, file);
306
364
  } else {
307
- delete input[name];
365
+ // Remove the property by setting to undefined and clean up
366
+ const path_parts = name.split(/\.|\[|\]/).filter(Boolean);
367
+ let current = /** @type {any} */ (input);
368
+ for (let i = 0; i < path_parts.length - 1; i++) {
369
+ if (current[path_parts[i]] == null) return;
370
+ current = current[path_parts[i]];
371
+ }
372
+ delete current[path_parts[path_parts.length - 1]];
308
373
  }
309
374
  } else {
310
- input[name] = element.value;
375
+ input = set_nested_value(
376
+ input,
377
+ name,
378
+ element.type === 'checkbox' && !element.checked ? null : element.value
379
+ );
311
380
  }
312
381
  });
313
382
 
@@ -387,18 +456,29 @@ export function form(id) {
387
456
 
388
457
  let validate_id = 0;
389
458
 
459
+ // TODO 3.0 remove
460
+ if (DEV) {
461
+ throw_on_old_property_access(instance);
462
+ }
463
+
390
464
  Object.defineProperties(instance, {
391
465
  buttonProps: {
392
466
  value: button_props
393
467
  },
394
- input: {
395
- get: () => input,
396
- set: (v) => {
397
- input = v;
398
- }
399
- },
400
- issues: {
401
- get: () => issues
468
+ fields: {
469
+ get: () =>
470
+ create_field_proxy(
471
+ {},
472
+ () => input,
473
+ (path, value) => {
474
+ if (path.length === 0) {
475
+ input = value;
476
+ } else {
477
+ input = deep_set(input, path.map(String), value);
478
+ }
479
+ },
480
+ () => issues
481
+ )
402
482
  },
403
483
  result: {
404
484
  get: () => result
@@ -406,11 +486,8 @@ export function form(id) {
406
486
  pending: {
407
487
  get: () => pending_count
408
488
  },
409
- field: {
410
- value: (/** @type {string} */ name) => name
411
- },
412
489
  preflight: {
413
- /** @type {RemoteForm<any, any>['preflight']} */
490
+ /** @type {RemoteForm<T, U>['preflight']} */
414
491
  value: (schema) => {
415
492
  preflight_schema = schema;
416
493
  return instance;
@@ -455,7 +532,7 @@ export function form(id) {
455
532
  }
456
533
  }
457
534
 
458
- if (!includeUntouched) {
535
+ if (!includeUntouched && !submitted) {
459
536
  array = array.filter((issue) => {
460
537
  if (issue.path !== undefined) {
461
538
  let path = '';
@@ -475,7 +552,10 @@ export function form(id) {
475
552
  });
476
553
  }
477
554
 
478
- issues = flatten_issues(array);
555
+ const is_server_validation = !validated?.issues;
556
+ const new_issues = flatten_issues(array, is_server_validation);
557
+
558
+ issues = is_server_validation ? new_issues : merge_with_server_issues(issues, new_issues);
479
559
  }
480
560
  },
481
561
  enhance: {
@@ -1,17 +1,28 @@
1
1
  /** @import { RemoteQueryFunction } from '@sveltejs/kit' */
2
2
  /** @import { RemoteFunctionResponse } from 'types' */
3
3
  import { app_dir, base } from '$app/paths/internal/client';
4
- import { app, goto, remote_responses } from '../client.js';
4
+ import { app, goto, query_map, remote_responses } from '../client.js';
5
5
  import { tick } from 'svelte';
6
6
  import { create_remote_function, remote_request } from './shared.svelte.js';
7
7
  import * as devalue from 'devalue';
8
8
  import { HttpError, Redirect } from '@sveltejs/kit/internal';
9
+ import { DEV } from 'esm-env';
9
10
 
10
11
  /**
11
12
  * @param {string} id
12
13
  * @returns {RemoteQueryFunction<any, any>}
13
14
  */
14
15
  export function query(id) {
16
+ if (DEV) {
17
+ // If this reruns as part of HMR, refresh the query
18
+ for (const [key, entry] of query_map) {
19
+ if (key === id || key.startsWith(id + '/')) {
20
+ // use optional chaining in case a prerender function was turned into a query
21
+ entry.resource.refresh?.();
22
+ }
23
+ }
24
+ }
25
+
15
26
  return create_remote_function(id, (cache_key, payload) => {
16
27
  return new Query(cache_key, async () => {
17
28
  if (Object.hasOwn(remote_responses, cache_key)) {
@@ -12,7 +12,15 @@ import { create_remote_cache_key, stringify_remote_arg } from '../../shared.js';
12
12
  * @param {string} url
13
13
  */
14
14
  export async function remote_request(url) {
15
- const response = await fetch(url);
15
+ const response = await fetch(url, {
16
+ headers: {
17
+ // TODO in future, when we support forking, we will likely need
18
+ // to grab this from context as queries will run before
19
+ // `location.pathname` is updated
20
+ 'x-sveltekit-pathname': location.pathname,
21
+ 'x-sveltekit-search': location.search
22
+ }
23
+ });
16
24
 
17
25
  if (!response.ok) {
18
26
  throw new HttpError(500, 'Failed to execute remote function');