@sveltejs/kit 3.0.0-next.11 → 3.0.0-next.12

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.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +8 -8
  3. package/src/constants.js +1 -1
  4. package/src/core/config/options.js +43 -35
  5. package/src/core/env.js +25 -11
  6. package/src/core/sync/sync.js +11 -14
  7. package/src/core/sync/utils.js +21 -1
  8. package/src/core/sync/{write_non_ambient.js → write_app_types.js} +23 -21
  9. package/src/core/sync/write_client_manifest.js +4 -12
  10. package/src/core/sync/write_env.js +6 -4
  11. package/src/core/sync/write_server.js +10 -15
  12. package/src/core/sync/write_tsconfig/index.js +245 -0
  13. package/src/core/sync/write_tsconfig/utils.js +162 -0
  14. package/src/core/sync/write_types/index.js +80 -88
  15. package/src/exports/internal/server/index.js +3 -1
  16. package/src/exports/public.d.ts +28 -51
  17. package/src/exports/vite/dev/index.js +88 -76
  18. package/src/exports/vite/index.js +364 -144
  19. package/src/exports/vite/module_ids.js +3 -1
  20. package/src/exports/vite/utils.js +1 -10
  21. package/src/runner.js +13 -0
  22. package/src/runtime/app/forms.js +4 -0
  23. package/src/runtime/app/manifest/index.js +1 -0
  24. package/src/runtime/app/paths/client.js +30 -30
  25. package/src/runtime/app/paths/internal/client.js +26 -0
  26. package/src/runtime/app/paths/server.js +22 -9
  27. package/src/runtime/app/paths/types.d.ts +11 -19
  28. package/src/runtime/app/server/remote/command.js +12 -7
  29. package/src/runtime/app/server/remote/form.js +29 -26
  30. package/src/runtime/app/server/remote/prerender.js +9 -2
  31. package/src/runtime/app/server/remote/query.js +5 -4
  32. package/src/runtime/app/server/remote/shared.js +48 -30
  33. package/src/runtime/app/service-worker/index.js +24 -0
  34. package/src/runtime/app/state/index.js +1 -1
  35. package/src/runtime/client/client.js +46 -9
  36. package/src/runtime/client/remote-functions/form.svelte.js +43 -51
  37. package/src/runtime/client/remote-functions/query/index.js +2 -2
  38. package/src/runtime/client/remote-functions/query-batch.svelte.js +2 -3
  39. package/src/runtime/client/remote-functions/query-live/iterator.js +5 -2
  40. package/src/runtime/client/remote-functions/shared.svelte.js +4 -1
  41. package/src/runtime/client/state.svelte.js +54 -21
  42. package/src/runtime/form-utils.js +89 -54
  43. package/src/runtime/server/cookie.js +1 -1
  44. package/src/runtime/server/data/index.js +31 -28
  45. package/src/runtime/server/errors.js +1 -1
  46. package/src/runtime/server/page/actions.js +3 -3
  47. package/src/runtime/server/page/render.js +19 -28
  48. package/src/runtime/server/remote-functions.js +66 -35
  49. package/src/runtime/server/respond.js +56 -14
  50. package/src/runtime/server/utils.js +10 -0
  51. package/src/types/ambient-private.d.ts +8 -0
  52. package/src/types/ambient.d.ts +22 -27
  53. package/src/types/global-private.d.ts +12 -0
  54. package/src/types/internal.d.ts +13 -3
  55. package/src/utils/url.js +12 -0
  56. package/src/version.js +1 -1
  57. package/types/index.d.ts +80 -98
  58. package/types/index.d.ts.map +4 -1
  59. package/src/core/sync/write_ambient.js +0 -18
  60. package/src/core/sync/write_tsconfig.js +0 -258
  61. /package/src/core/sync/{write_tsconfig_test → write_tsconfig/test-app}/package.json +0 -0
@@ -10,21 +10,58 @@ import { SvelteKitError } from '@sveltejs/kit/internal';
10
10
  const decoder = new TextDecoder();
11
11
 
12
12
  /**
13
- * Sets a value in a nested object using a path string, mutating the original object
13
+ * Sets a parsed form field value in a nested object, mutating the original object.
14
14
  * @param {Record<string, any>} object
15
- * @param {string} path_string
15
+ * @param {{ name: string; type: 'number' | 'boolean' | null }} field
16
16
  * @param {any} value
17
17
  */
18
- export function set_nested_value(object, path_string, value) {
19
- if (path_string.startsWith('n:')) {
20
- path_string = path_string.slice(2);
21
- value = value === '' ? undefined : parseFloat(value);
22
- } else if (path_string.startsWith('b:')) {
23
- path_string = path_string.slice(2);
24
- value = value === 'on';
18
+ export function set_nested_value(object, field, value) {
19
+ deep_set(object, split_path(field.name), value);
20
+ }
21
+
22
+ /**
23
+ * Separates a form field's path from the metadata encoded in its name.
24
+ * @param {string} form_id
25
+ * @param {string} key
26
+ * @returns {{ name: string; type: 'number' | 'boolean' | null; is_array: boolean }}
27
+ */
28
+ export function parse_form_key(form_id, key) {
29
+ const suffix = '/' + form_id;
30
+ let name = key;
31
+
32
+ if (!name.endsWith(suffix)) {
33
+ throw new Error(`Form contained a field that wasn't created with form.fields.as(...): ${name}`);
25
34
  }
26
35
 
27
- deep_set(object, split_path(path_string), value);
36
+ name = name.slice(0, -suffix.length);
37
+
38
+ /** @type {'number' | 'boolean' | null} */
39
+ let type = null;
40
+
41
+ if (name.startsWith('n:')) {
42
+ name = name.slice(2);
43
+ type = 'number';
44
+ } else if (name.startsWith('b:')) {
45
+ name = name.slice(2);
46
+ type = 'boolean';
47
+ }
48
+
49
+ const is_array = name.endsWith('[]');
50
+ if (is_array) name = name.slice(0, -2);
51
+
52
+ return { name, type, is_array };
53
+ }
54
+
55
+ /**
56
+ * @param {'number' | 'boolean' | null} type
57
+ * @param {any} value
58
+ * @returns {any}
59
+ */
60
+ export function coerce_form_value(type, value) {
61
+ if (Array.isArray(value)) return value.map((value) => coerce_form_value(type, value));
62
+ if (type === 'number') return value === '' ? undefined : parseFloat(value);
63
+ if (type === 'boolean') return value === 'on';
64
+ return value;
28
65
  }
29
66
 
30
67
  /** Pass this to set_nested_value to delete the last part of the given path */
@@ -32,38 +69,36 @@ export const DELETE_KEY = {};
32
69
 
33
70
  /**
34
71
  * Convert `FormData` into a POJO
72
+ * @param {string} form_id
35
73
  * @param {FormData} data
36
74
  */
37
- export function convert_formdata(data) {
75
+ export function convert_formdata(form_id, data) {
38
76
  /** @type {Record<string, any>} */
39
77
  const result = {};
40
78
 
41
- for (let key of data.keys()) {
42
- const is_array = key.endsWith('[]');
79
+ for (const field_name of data.keys()) {
43
80
  /** @type {any[]} */
44
- let values = data.getAll(key);
81
+ const values = data.getAll(field_name);
45
82
 
46
- if (is_array) key = key.slice(0, -2);
83
+ const field = parse_form_key(form_id, field_name);
47
84
 
48
85
  // an empty `<input type="file">` will submit a non-existent file, bizarrely
49
- values = values.filter(
86
+ const entries = values.filter(
50
87
  (entry) => typeof entry === 'string' || entry.name !== '' || entry.size > 0
51
88
  );
52
- if (values.length === 0 && !is_array) continue;
53
-
54
- if (key.startsWith('n:')) {
55
- key = key.slice(2);
56
- values = values.map((v) => (v === '' ? undefined : parseFloat(/** @type {string} */ (v))));
57
- } else if (key.startsWith('b:')) {
58
- key = key.slice(2);
59
- values = values.map((v) => v === 'on');
60
- }
89
+ if (entries.length === 0 && !field.is_array) continue;
61
90
 
62
- if (values.length > 1 && !is_array) {
63
- throw new Error(`Form cannot contain duplicated keys — "${key}" has ${values.length} values`);
91
+ if (entries.length > 1 && !field.is_array) {
92
+ throw new Error(
93
+ `Form cannot contain duplicated keys — "${field.name}" has ${entries.length} values`
94
+ );
64
95
  }
65
96
 
66
- set_nested_value(result, key, is_array ? values : values[0]);
97
+ set_nested_value(
98
+ result,
99
+ field,
100
+ coerce_form_value(field.type, field.is_array ? entries : entries[0])
101
+ );
67
102
  }
68
103
 
69
104
  return result;
@@ -139,12 +174,13 @@ export function serialize_binary_form(data, meta) {
139
174
 
140
175
  /**
141
176
  * @param {Request} request
177
+ * @param {string} form_id
142
178
  * @returns {Promise<{ data: Record<string, any>; meta: BinaryFormMeta; form_data: FormData | null }>}
143
179
  */
144
- export async function deserialize_binary_form(request) {
180
+ export async function deserialize_binary_form(request, form_id) {
145
181
  if (request.headers.get('content-type') !== BINARY_FORM_CONTENT_TYPE) {
146
182
  const form_data = await request.formData();
147
- return { data: convert_formdata(form_data), meta: {}, form_data };
183
+ return { data: convert_formdata(form_id, form_data), meta: {}, form_data };
148
184
  }
149
185
  if (!request.body) {
150
186
  throw deserialize_error('no body');
@@ -653,18 +689,21 @@ function deep_clone(value) {
653
689
 
654
690
  /**
655
691
  * Creates a proxy-based field accessor for form data
692
+ * @param {{
693
+ * form_id: string,
694
+ * get: () => Record<string, any>,
695
+ * set: (path: (string | number)[], value: any) => void,
696
+ * get_issues: (path?: (string | number)[], all?: boolean) => Record<string, InternalRemoteFormIssue[]>,
697
+ * get_touched: () => Record<string, boolean>,
698
+ * get_dirty: () => Record<string, boolean>
699
+ * }} context - Form context, including value accessors and form metadata
656
700
  * @param {any} target - Function or empty POJO
657
- * @param {() => Record<string, any>} get - Function to get current input data
658
- * @param {(path: (string | number)[], value: any) => void} set - Function to set input data
659
- * @param {(path?: (string | number)[], all?: boolean) => Record<string, InternalRemoteFormIssue[]>} get_issues - Function to get current issues
660
- * @param {() => Record<string, boolean>} get_touched - Function to get touched fields
661
- * @param {() => Record<string, boolean>} get_dirty - Function to get dirty fields
662
701
  * @param {(string | number)[]} path - Current access path
663
702
  * @returns {any} Proxy object with name(), value(), and issues() methods
664
703
  */
665
- export function create_field_proxy(target, get, set, get_issues, get_touched, get_dirty, path) {
704
+ export function create_field_proxy(context, target = {}, path = []) {
666
705
  const get_value = () => {
667
- const value = deep_get(get(), path);
706
+ const value = deep_get(context.get(), path);
668
707
  return deep_clone(value);
669
708
  };
670
709
 
@@ -674,10 +713,7 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge
674
713
 
675
714
  // Handle array access like jobs[0]
676
715
  if (/^\d+$/.test(prop)) {
677
- return create_field_proxy({}, get, set, get_issues, get_touched, get_dirty, [
678
- ...path,
679
- parseInt(prop, 10)
680
- ]);
716
+ return create_field_proxy(context, {}, [...path, parseInt(prop, 10)]);
681
717
  }
682
718
 
683
719
  const key = build_path_string(path);
@@ -685,20 +721,19 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge
685
721
 
686
722
  if (prop === 'set') {
687
723
  const set_func = function (/** @type {any} */ newValue) {
688
- set(path, newValue);
724
+ context.set(path, newValue);
689
725
  return newValue;
690
726
  };
691
-
692
- return create_field_proxy(set_func, get, set, get_issues, get_touched, get_dirty, next);
727
+ return create_field_proxy(context, set_func, next);
693
728
  }
694
729
 
695
730
  if (prop === 'value') {
696
- return create_field_proxy(get_value, get, set, get_issues, get_touched, get_dirty, next);
731
+ return create_field_proxy(context, get_value, next);
697
732
  }
698
733
 
699
734
  if (prop === 'issues' || prop === 'allIssues') {
700
735
  const issues_func = () => {
701
- const all_issues = get_issues(path, prop === 'allIssues')[key === '' ? '$' : key];
736
+ const all_issues = context.get_issues(path, prop === 'allIssues')[key === '' ? '$' : key];
702
737
 
703
738
  if (prop === 'allIssues') {
704
739
  return all_issues?.map((issue) => ({
@@ -717,12 +752,12 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge
717
752
  return issues?.length ? issues : undefined;
718
753
  };
719
754
 
720
- return create_field_proxy(issues_func, get, set, get_issues, get_touched, get_dirty, next);
755
+ return create_field_proxy(context, issues_func, next);
721
756
  }
722
757
 
723
758
  if (prop === 'touched' || prop === 'dirty') {
724
759
  const fn = () => {
725
- const object = prop === 'dirty' ? get_dirty() : get_touched();
760
+ const object = prop === 'dirty' ? context.get_dirty() : context.get_touched();
726
761
 
727
762
  if (key === '') {
728
763
  return Object.keys(object).length > 0;
@@ -745,7 +780,7 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge
745
780
  return false;
746
781
  };
747
782
 
748
- return create_field_proxy(fn, get, set, get_issues, get_touched, get_dirty, next);
783
+ return create_field_proxy(context, fn, next);
749
784
  }
750
785
 
751
786
  if (prop === 'as') {
@@ -759,14 +794,14 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge
759
794
  type === 'select multiple' ||
760
795
  (type === 'checkbox' && typeof input_value === 'string');
761
796
 
762
- const prefix = get_type_prefix(type, is_array, input_value);
797
+ const type_prefix = get_type_prefix(type, is_array, input_value);
763
798
 
764
799
  // Base properties for all input types
765
800
  /** @type {Record<string, any>} */
766
801
  const base_props = {
767
- name: prefix + key + (is_array ? '[]' : ''),
802
+ name: type_prefix + key + (is_array ? '[]' : '') + '/' + context.form_id,
768
803
  get 'aria-invalid'() {
769
- const issues = get_issues();
804
+ const issues = context.get_issues();
770
805
  return key in issues ? 'true' : undefined;
771
806
  }
772
807
  };
@@ -911,11 +946,11 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge
911
946
  });
912
947
  };
913
948
 
914
- return create_field_proxy(as_func, get, set, get_issues, get_touched, get_dirty, next);
949
+ return create_field_proxy(context, as_func, next);
915
950
  }
916
951
 
917
952
  // Handle property access (nested fields)
918
- return create_field_proxy({}, get, set, get_issues, get_touched, get_dirty, next);
953
+ return create_field_proxy(context, {}, next);
919
954
  }
920
955
  });
921
956
  }
@@ -73,7 +73,7 @@ export function get_cookies(request, url) {
73
73
  httpOnly: true,
74
74
  path: '/',
75
75
  sameSite: 'lax',
76
- secure: url.hostname === 'localhost' && url.protocol === 'http:' ? false : true
76
+ secure: !__SVELTEKIT_DEV__ && !(url.hostname === 'localhost' && url.protocol === 'http:')
77
77
  };
78
78
 
79
79
  /** @type {import('@sveltejs/kit').Cookies} */
@@ -7,6 +7,7 @@ import { load_server_data } from '../page/load_data.js';
7
7
  import { handle_error_and_jsonify } from '../errors.js';
8
8
  import { normalize_path } from '../../../utils/url.js';
9
9
  import { text_encoder } from '../../utils.js';
10
+ import { with_version_header } from '../utils.js';
10
11
 
11
12
  /**
12
13
  * @param {import('@sveltejs/kit').RequestEvent} event
@@ -31,9 +32,7 @@ export async function render_data(
31
32
  ) {
32
33
  if (!route.page) {
33
34
  // requesting /__data.json should fail for a +server.js
34
- return new Response(undefined, {
35
- status: 404
36
- });
35
+ return with_version_header(new Response(undefined, { status: 404 }));
37
36
  }
38
37
 
39
38
  try {
@@ -123,26 +122,28 @@ export async function render_data(
123
122
  return json_response(data);
124
123
  }
125
124
 
126
- return new Response(
127
- new ReadableStream({
128
- async start(controller) {
129
- controller.enqueue(text_encoder.encode(data));
130
- for await (const chunk of chunks) {
131
- controller.enqueue(text_encoder.encode(chunk));
125
+ return with_version_header(
126
+ new Response(
127
+ new ReadableStream({
128
+ async start(controller) {
129
+ controller.enqueue(text_encoder.encode(data));
130
+ for await (const chunk of chunks) {
131
+ controller.enqueue(text_encoder.encode(chunk));
132
+ }
133
+ controller.close();
134
+ },
135
+
136
+ type: 'bytes'
137
+ }),
138
+ {
139
+ headers: {
140
+ // we use a proprietary content type to prevent buffering.
141
+ // the `text` prefix makes it inspectable
142
+ 'content-type': 'text/sveltekit-data',
143
+ 'cache-control': 'private, no-store'
132
144
  }
133
- controller.close();
134
- },
135
-
136
- type: 'bytes'
137
- }),
138
- {
139
- headers: {
140
- // we use a proprietary content type to prevent buffering.
141
- // the `text` prefix makes it inspectable
142
- 'content-type': 'text/sveltekit-data',
143
- 'cache-control': 'private, no-store'
144
145
  }
145
- }
146
+ )
146
147
  );
147
148
  } catch (e) {
148
149
  const error = normalize_error(e);
@@ -161,13 +162,15 @@ export async function render_data(
161
162
  * @param {number} [status]
162
163
  */
163
164
  function json_response(json, status = 200) {
164
- return text(typeof json === 'string' ? json : JSON.stringify(json), {
165
- status,
166
- headers: {
167
- 'content-type': 'application/json',
168
- 'cache-control': 'private, no-store'
169
- }
170
- });
165
+ return with_version_header(
166
+ text(typeof json === 'string' ? json : JSON.stringify(json), {
167
+ status,
168
+ headers: {
169
+ 'content-type': 'application/json',
170
+ 'cache-control': 'private, no-store'
171
+ }
172
+ })
173
+ );
171
174
  }
172
175
 
173
176
  /**
@@ -17,7 +17,7 @@ export async function handle_fatal_error(event, state, options, error) {
17
17
  const body = await handle_error_and_jsonify(event, state, options, error);
18
18
  const status = body.status;
19
19
 
20
- // ideally we'd use sec-fetch-dest instead, but Safari quelle surprise doesn't support it
20
+ // sec-fetch-dest would be nicer, but non-browser clients and plain HTTP hosts don't send it
21
21
  const type = negotiate(event.request.headers.get('accept') || 'text/html', [
22
22
  'application/json',
23
23
  'text/html'
@@ -7,7 +7,7 @@ import { HttpError, Redirect, ActionFailure, SvelteKitError } from '@sveltejs/ki
7
7
  import { with_request_store, merge_tracing } from '@sveltejs/kit/internal/server';
8
8
  import { normalize_error } from '../../../utils/error.js';
9
9
  import { is_form_content_type, negotiate } from '../../../utils/http.js';
10
- import { create_replacer } from '../utils.js';
10
+ import { create_replacer, with_version_header } from '../utils.js';
11
11
  import { handle_error_and_jsonify } from '../errors.js';
12
12
  import { record_span } from '../../telemetry/record_span.js';
13
13
 
@@ -95,7 +95,7 @@ export async function handle_action_json_request(event, event_state, options, se
95
95
  });
96
96
  } else {
97
97
  // no data returned — use 204 No Content (without a body, per the spec)
98
- return new Response(null, { status: 204 });
98
+ return with_version_header(new Response(null, { status: 204 }));
99
99
  }
100
100
  } catch (e) {
101
101
  const err = normalize_error(e);
@@ -148,7 +148,7 @@ export function action_json_redirect(redirect) {
148
148
  * @param {ResponseInit} [init]
149
149
  */
150
150
  function action_json(data, init) {
151
- return json(data, init);
151
+ return with_version_header(json(data, init));
152
152
  }
153
153
 
154
154
  /**
@@ -304,6 +304,19 @@ export async function render_response({
304
304
  head.add_style(style, attributes);
305
305
  }
306
306
 
307
+ /**
308
+ * see the `output.linkHeaderPreload` option for details on why we have multiple options here
309
+ * @param {string} path
310
+ * @param {string[]} attributes
311
+ */
312
+ const add_preload = (path, attributes) => {
313
+ if (options.link_header_preload && !state.prerendering) {
314
+ link_headers.add(`<${encodeURI(path)}>; ${attributes.join('; ')}; nopush`);
315
+ } else {
316
+ head.add_link_tag(path, attributes);
317
+ }
318
+ };
319
+
307
320
  for (const dep of stylesheets) {
308
321
  const path = prefixed(dep);
309
322
 
@@ -328,18 +341,7 @@ export async function render_response({
328
341
  if (resolve_opts.preload({ type: 'font', path })) {
329
342
  const ext = dep.slice(dep.lastIndexOf('.') + 1);
330
343
 
331
- if (options.link_header_preload && !state.prerendering) {
332
- link_headers.add(
333
- `<${encodeURI(path)}>; rel="preload"; as="font"; type="font/${ext}"; crossorigin; nopush`
334
- );
335
- } else {
336
- head.add_link_tag(path, [
337
- 'rel="preload"',
338
- 'as="font"',
339
- `type="font/${ext}"`,
340
- 'crossorigin'
341
- ]);
342
- }
344
+ add_preload(path, ['rel="preload"', 'as="font"', `type="font/${ext}"`, 'crossorigin']);
343
345
  }
344
346
  }
345
347
 
@@ -368,23 +370,12 @@ export async function render_response({
368
370
  }
369
371
 
370
372
  if (!client.inline) {
371
- const included_modulepreloads = Array.from(modulepreloads, (dep) => prefixed(dep)).filter(
372
- (path) => resolve_opts.preload({ type: 'js', path })
373
- );
374
-
375
- /** @type {(path: string) => void} */
376
- let add_preload;
373
+ for (const dep of modulepreloads) {
374
+ const path = prefixed(dep);
377
375
 
378
- // see the output.preloadStrategy option for details on why we have multiple options here
379
- if (options.link_header_preload && !state.prerendering) {
380
- add_preload = (path) =>
381
- link_headers.add(`<${encodeURI(path)}>; rel="modulepreload"; nopush`);
382
- } else {
383
- add_preload = (path) => head.add_link_tag(path, ['rel="modulepreload"']);
384
- }
385
-
386
- for (const path of included_modulepreloads) {
387
- add_preload(path);
376
+ if (resolve_opts.preload({ type: 'js', path })) {
377
+ add_preload(path, ['rel="modulepreload"']);
378
+ }
388
379
  }
389
380
  }
390
381
 
@@ -13,6 +13,7 @@ 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
15
  import { deserialize_binary_form } from '../form-utils.js';
16
+ import { with_version_header } from './utils.js';
16
17
 
17
18
  /**
18
19
  * How long (in milliseconds) to wait after the last message was sent before
@@ -28,11 +29,12 @@ export async function handle_remote_call(event, state, options, manifest, id) {
28
29
  attributes: {
29
30
  'sveltekit.remote.call.id': id
30
31
  },
31
- fn: (current) => {
32
+ fn: async (current) => {
32
33
  const traced_event = merge_tracing(event, current);
33
- return with_request_store({ event: traced_event, state }, () =>
34
+ const response = await with_request_store({ event: traced_event, state }, () =>
34
35
  handle_remote_call_internal(traced_event, state, options, manifest, id)
35
36
  );
37
+ return with_version_header(response);
36
38
  }
37
39
  });
38
40
  }
@@ -240,7 +242,11 @@ async function handle_remote_call_internal(event, state, options, manifest, id)
240
242
  );
241
243
  }
242
244
 
243
- const { data: input, meta, form_data } = await deserialize_binary_form(event.request);
245
+ const {
246
+ data: input,
247
+ meta,
248
+ form_data
249
+ } = await deserialize_binary_form(event.request, internals.id);
244
250
  state.remote.requested = create_requested_map(meta.remote_refreshes);
245
251
 
246
252
  // If this is a keyed form instance (created via form.for(key)), add the key to the form data (unless already set)
@@ -368,43 +374,64 @@ export async function collect_remote_data(data, event, state, options) {
368
374
  /** @type {Promise<any>[]} */
369
375
  const promises = [];
370
376
 
377
+ // Keys the explicit pass has serialized. Invoking a query's `fn` there can, as a
378
+ // side effect, register the same query in `state.remote.implicit` (via
379
+ // `get_response`), so we skip those keys in the implicit pass below to avoid
380
+ // processing them twice.
381
+ /** @type {Set<string>} */
382
+ const processed = new Set();
383
+
371
384
  if (state.remote.explicit) {
372
- for (const [remote_key, { internals, fn }] of state.remote.explicit) {
373
- // there were explicit refreshes/reconnects (via `refresh()`/`set()`/`reconnect()`),
374
- // so the client should apply these single-flight updates instead of calling `invalidateAll()`
375
- data.r = true;
385
+ const { explicit } = state.remote;
376
386
 
377
- const type = /** @type {'p' | 'q' | 'l'} */ (
378
- internals.type === 'query_live' ? 'l' : internals.type[0]
379
- );
387
+ /** @type {Promise<void>[]} */
388
+ const inflight = [];
389
+
390
+ const drain = () => {
391
+ for (const [remote_key, { internals, fn }] of explicit) {
392
+ explicit.delete(remote_key);
393
+ if (processed.has(remote_key)) continue;
394
+ processed.add(remote_key);
395
+
396
+ // there were explicit refreshes/reconnects (via `refresh()`/`set()`/`reconnect()`),
397
+ // so the client should apply these single-flight updates instead of calling `invalidateAll()`
398
+ data.r = true;
380
399
 
381
- // `fn` is deferred until now so the query runs after any state mutations
382
- // in the command/form body. If the query was re-awaited in the meantime,
383
- // `fn` returns the existing (fresh) cache entry rather than re-running.
384
- // Kick off the query immediately and collect the promise so that multiple
385
- // explicit refreshes run concurrently rather than serially.
386
- const promise = fn();
387
-
388
- promises.push(
389
- promise.then(
390
- (v) => {
391
- ((data[type] ??= {})[remote_key] ??= {}).v = v;
392
- },
393
- async (e) => {
394
- if (e instanceof Redirect) {
395
- // already handled elsewhere
396
- return;
400
+ const type = /** @type {'p' | 'q' | 'l'} */ (
401
+ internals.type === 'query_live' ? 'l' : internals.type[0]
402
+ );
403
+
404
+ // `fn` is deferred until now so the query runs after any state mutations
405
+ // in the command/form body. If the query was re-awaited in the meantime,
406
+ // `fn` returns the existing (fresh) cache entry rather than re-running.
407
+ inflight.push(
408
+ fn().then(
409
+ (v) => {
410
+ // a fresh value replaces the node entirely, so a re-run can't leave
411
+ // a stale error from a previous run alongside the new value
412
+ (data[type] ??= {})[remote_key] = { v };
413
+ drain();
414
+ },
415
+ async (e) => {
416
+ if (!(e instanceof Redirect)) {
417
+ // (a Redirect is already handled elsewhere)
418
+ (data[type] ??= {})[remote_key] = { e: await convert_error(e) };
419
+ }
420
+ drain();
397
421
  }
422
+ )
423
+ );
424
+ }
425
+ };
398
426
 
399
- ((data[type] ??= {})[remote_key] ??= {}).e = await convert_error(e);
400
- }
401
- )
402
- );
427
+ drain();
428
+
429
+ // `inflight` grows as settles drain newly-refreshed queries
430
+ for (const promise of inflight) {
431
+ await promise;
403
432
  }
404
433
  }
405
434
 
406
- await Promise.all(promises);
407
-
408
435
  if (state.remote.implicit) {
409
436
  for (const [internals, record] of state.remote.implicit) {
410
437
  // Private (non-exported) remote functions have no `id` and must never be
@@ -416,6 +443,10 @@ export async function collect_remote_data(data, event, state, options) {
416
443
  // form outputs are registered under the client-side action id directly
417
444
  const remote_key = internals.type === 'form' ? key : create_remote_key(internals.id, key);
418
445
 
446
+ // already serialized by the explicit pass (which always awaits and wins),
447
+ // so don't reprocess it here with the implicit "still loading" heuristic
448
+ if (processed.has(remote_key)) continue;
449
+
419
450
  const type = /** @type {'p' | 'q' | 'l' | 'f'} */ (
420
451
  internals.type === 'query_live' ? 'l' : internals.type[0]
421
452
  );
@@ -540,9 +571,9 @@ async function handle_remote_form_post_internal(event, state, manifest, id) {
540
571
  }
541
572
 
542
573
  try {
543
- const fn = /** @type {RemoteFormInternals} */ (/** @type {any} */ (form).__).fn;
574
+ const __ = /** @type {RemoteFormInternals} */ (/** @type {any} */ (form).__);
544
575
 
545
- const { data, meta, form_data } = await deserialize_binary_form(event.request);
576
+ const { data, meta, form_data } = await deserialize_binary_form(event.request, __.id);
546
577
 
547
578
  if (action_id && !('id' in data)) {
548
579
  data.id = JSON.parse(decodeURIComponent(action_id));
@@ -550,7 +581,7 @@ async function handle_remote_form_post_internal(event, state, manifest, id) {
550
581
 
551
582
  await with_request_store(
552
583
  { event, state: { ...state, is_in_remote_form_or_command: true } },
553
- () => fn(data, meta, form_data)
584
+ () => __.fn(data, meta, form_data)
554
585
  );
555
586
 
556
587
  // We don't want the data to appear on `let { form } = $props()`, which is why we're not returning it.