Skip to content

Commit f0b05c6

Browse files
committed
merge
2 parents 1a892df + 305ee13 commit f0b05c6

File tree

11 files changed

+123
-58
lines changed

11 files changed

+123
-58
lines changed

.changeset/honest-shoes-jam.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
fix: use `$derived` for form fields

.changeset/petite-lions-heal.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
docs: remove `@example` blocks to allow docs to deploy

.changeset/pink-ghosts-see.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
fix: require a value with `submit` and `hidden` fields

packages/kit/src/exports/public.d.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1886,9 +1886,9 @@ export type RemoteFormFieldValue = string | string[] | number | boolean | File |
18861886

18871887
type AsArgs<Type extends keyof InputTypeMap, Value> = Type extends 'checkbox'
18881888
? Value extends string[]
1889-
? [type: 'checkbox', value: Value[number] | (string & {})]
1889+
? [type: Type, value: Value[number] | (string & {})]
18901890
: [type: Type]
1891-
: Type extends 'radio' | 'submit'
1891+
: Type extends 'radio' | 'submit' | 'hidden'
18921892
? [type: Type, value: Value | (string & {})]
18931893
: [type: Type];
18941894

@@ -1975,19 +1975,6 @@ type InvalidField<T> =
19751975
* If an issue is a `string`, it applies to the form as a whole (and will show up in `fields.allIssues()`)
19761976
* Access properties to create field-specific issues: `invalid.fieldName('message')`.
19771977
* The type structure mirrors the input data structure for type-safe field access.
1978-
*
1979-
* @example
1980-
* ```ts
1981-
* invalid('Username or password is invalid');
1982-
* ```
1983-
*
1984-
* @example
1985-
* ```ts
1986-
* invalid(
1987-
* invalid.username('Username is taken'),
1988-
* invalid.items[0].qty('Insufficient stock')
1989-
* );
1990-
* ```
19911978
*/
19921979
export type Invalid<Input = any> = ((...issues: Array<string | StandardSchemaV1.Issue>) => never) &
19931980
InvalidField<Input>;

packages/kit/src/runtime/app/server/remote/form.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export function form(validate_or_fn, maybe_fn) {
189189
return create_field_proxy(
190190
{},
191191
() => data?.input ?? {},
192+
() => {},
192193
(path, value) => {
193194
if (data) {
194195
// don't override a submission

packages/kit/src/runtime/client/remote-functions/form.svelte.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import {
1616
create_field_proxy,
1717
deep_set,
1818
set_nested_value,
19-
throw_on_old_property_access
19+
throw_on_old_property_access,
20+
split_path,
21+
build_path_string
2022
} from '../../form-utils.svelte.js';
2123

2224
/**
@@ -62,6 +64,13 @@ export function form(id) {
6264
*/
6365
let input = $state.raw({});
6466

67+
// TODO 3.0: Remove versions state and related logic; it's a workaround for $derived not updating when created inside $effects
68+
/**
69+
* This allows us to update individual fields granularly
70+
* @type {Record<string, number>}
71+
*/
72+
const versions = $state({});
73+
6574
/** @type {Record<string, InternalRemoteFormIssue[]>} */
6675
let issues = $state.raw({});
6776

@@ -212,6 +221,13 @@ export function form(id) {
212221
} else {
213222
input = {};
214223

224+
for (const [key, value] of Object.entries(versions)) {
225+
if (value !== undefined) {
226+
versions[key] ??= 0;
227+
versions[key] += 1;
228+
}
229+
}
230+
215231
if (form_result.refreshes) {
216232
refresh_queries(form_result.refreshes, updates);
217233
} else {
@@ -390,6 +406,18 @@ export function form(id) {
390406
element.type === 'checkbox' && !element.checked ? null : element.value
391407
);
392408
}
409+
410+
versions[name] ??= 0;
411+
versions[name] += 1;
412+
413+
const path = split_path(name);
414+
415+
while (path.pop() !== undefined) {
416+
const name = build_path_string(path);
417+
418+
versions[name] ??= 0;
419+
versions[name] += 1;
420+
}
393421
});
394422

395423
return () => {
@@ -431,6 +459,7 @@ export function form(id) {
431459
create_field_proxy(
432460
{},
433461
() => input,
462+
(path) => versions[path],
434463
(path, value) => {
435464
if (path.length === 0) {
436465
input = value;

packages/kit/src/runtime/form-utils.svelte.js

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
44

55
import { DEV } from 'esm-env';
6+
import { untrack } from 'svelte';
67

78
/**
89
* Sets a value in a nested object using a path string, not mutating the original object but returning a new object
@@ -174,19 +175,27 @@ export function deep_get(object, path) {
174175
* Creates a proxy-based field accessor for form data
175176
* @param {any} target - Function or empty POJO
176177
* @param {() => Record<string, any>} get_input - Function to get current input data
178+
* @param {(path: string) => void} depend - Function to make an effect depend on a specific field
177179
* @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data
178180
* @param {() => Record<string, InternalRemoteFormIssue[]>} get_issues - Function to get current issues
179181
* @param {(string | number)[]} path - Current access path
180182
* @returns {any} Proxy object with name(), value(), and issues() methods
181183
*/
182-
export function create_field_proxy(target, get_input, set_input, get_issues, path = []) {
184+
export function create_field_proxy(target, get_input, depend, set_input, get_issues, path = []) {
185+
const path_string = build_path_string(path);
186+
187+
const get_value = () => {
188+
depend(path_string);
189+
return untrack(() => deep_get(get_input(), path));
190+
};
191+
183192
return new Proxy(target, {
184193
get(target, prop) {
185194
if (typeof prop === 'symbol') return target[prop];
186195

187196
// Handle array access like jobs[0]
188197
if (/^\d+$/.test(prop)) {
189-
return create_field_proxy({}, get_input, set_input, get_issues, [
198+
return create_field_proxy({}, get_input, depend, set_input, get_issues, [
190199
...path,
191200
parseInt(prop, 10)
192201
]);
@@ -199,18 +208,17 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
199208
set_input(path, newValue);
200209
return newValue;
201210
};
202-
return create_field_proxy(set_func, get_input, set_input, get_issues, [...path, prop]);
211+
return create_field_proxy(set_func, get_input, depend, set_input, get_issues, [
212+
...path,
213+
prop
214+
]);
203215
}
204216

205217
if (prop === 'value') {
206-
const value_func = function () {
207-
// TODO Ideally we'd create a $derived just above and use it here but we can't because of push_reaction which prevents
208-
// changes to deriveds created within an effect to rerun the effect - an argument for
209-
// reverting that change in async mode?
210-
// TODO we did that in Svelte now; bump Svelte version and use $derived here
211-
return deep_get(get_input(), path);
212-
};
213-
return create_field_proxy(value_func, get_input, set_input, get_issues, [...path, prop]);
218+
return create_field_proxy(get_value, get_input, depend, set_input, get_issues, [
219+
...path,
220+
prop
221+
]);
214222
}
215223

216224
if (prop === 'issues' || prop === 'allIssues') {
@@ -230,7 +238,10 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
230238
}));
231239
};
232240

233-
return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]);
241+
return create_field_proxy(issues_func, get_input, depend, set_input, get_issues, [
242+
...path,
243+
prop
244+
]);
234245
}
235246

236247
if (prop === 'as') {
@@ -266,11 +277,11 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
266277
base_props.type = type === 'file multiple' ? 'file' : type;
267278
}
268279

269-
// Handle submit inputs
270-
if (type === 'submit') {
280+
// Handle submit and hidden inputs
281+
if (type === 'submit' || type === 'hidden') {
271282
if (DEV) {
272283
if (!input_value) {
273-
throw new Error('Submit inputs must have a value');
284+
throw new Error(`\`${type}\` inputs must have a value`);
274285
}
275286
}
276287

@@ -286,8 +297,7 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
286297
value: {
287298
enumerable: true,
288299
get() {
289-
const input = get_input();
290-
return deep_get(input, path);
300+
return get_value();
291301
}
292302
}
293303
});
@@ -310,8 +320,7 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
310320
checked: {
311321
enumerable: true,
312322
get() {
313-
const input = get_input();
314-
const value = deep_get(input, path);
323+
const value = get_value();
315324

316325
if (type === 'radio') {
317326
return value === input_value;
@@ -334,8 +343,7 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
334343
files: {
335344
enumerable: true,
336345
get() {
337-
const input = get_input();
338-
const value = deep_get(input, path);
346+
const value = get_value();
339347

340348
// Convert File/File[] to FileList-like object
341349
if (value instanceof File) {
@@ -375,20 +383,21 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
375383
value: {
376384
enumerable: true,
377385
get() {
378-
const input = get_input();
379-
const value = deep_get(input, path);
380-
386+
const value = get_value();
381387
return value != null ? String(value) : '';
382388
}
383389
}
384390
});
385391
};
386392

387-
return create_field_proxy(as_func, get_input, set_input, get_issues, [...path, 'as']);
393+
return create_field_proxy(as_func, get_input, depend, set_input, get_issues, [
394+
...path,
395+
'as'
396+
]);
388397
}
389398

390399
// Handle property access (nested fields)
391-
return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]);
400+
return create_field_proxy({}, get_input, depend, set_input, get_issues, [...path, prop]);
392401
}
393402
});
394403
}
@@ -398,7 +407,7 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
398407
* @param {(string | number)[]} path
399408
* @returns {string}
400409
*/
401-
function build_path_string(path) {
410+
export function build_path_string(path) {
402411
let result = '';
403412

404413
for (const segment of path) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script>
2+
import { myform } from './form.remote.js';
3+
</script>
4+
5+
<form {...myform}>
6+
<input {...myform.fields.message.as('text')} />
7+
8+
<select {...myform.fields.number.as('select')}>
9+
<option>one</option>
10+
<option>two</option>
11+
<option>three</option>
12+
</select>
13+
14+
<button>submit</button>
15+
</form>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { form } from '$app/server';
2+
import * as v from 'valibot';
3+
4+
export const myform = form(
5+
v.object({
6+
message: v.string(),
7+
number: v.picklist(['one', 'two', 'three'])
8+
}),
9+
(_data) => {}
10+
);

packages/kit/test/apps/basics/test/test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1976,6 +1976,18 @@ test.describe('remote functions', () => {
19761976
const arrayValue = await page.locator('#array-value').textContent();
19771977
expect(JSON.parse(arrayValue)).toEqual([{ leaf: 'array-0-leaf' }, { leaf: 'array-1-leaf' }]);
19781978
});
1979+
1980+
test('selects are not nuked when unrelated controls change', async ({
1981+
page,
1982+
javaScriptEnabled
1983+
}) => {
1984+
if (!javaScriptEnabled) return;
1985+
1986+
await page.goto('/remote/form/select-untouched');
1987+
1988+
await page.fill('input', 'hello');
1989+
await expect(page.locator('select')).toHaveValue('one');
1990+
});
19791991
});
19801992

19811993
test.describe('params prop', () => {

0 commit comments

Comments
 (0)