Skip to content

Commit 3eef1cb

Browse files
authored
feat: take form resets into account for two way bindings (#10617)
* feat: take form resets into account for two way bindings When resetting a form, the value of the inputs within it get out of sync with the bound value of those inputs. This PR introduces a reset listener on the parent form to reset the value in that case closes #2659 * slightly different approach * tweaks, test * this is a breaking change, strictly speaking * bind:files * use capture phase * tweak wording * use promise, explain
1 parent 416bc85 commit 3eef1cb

File tree

9 files changed

+144
-13
lines changed

9 files changed

+144
-13
lines changed

.changeset/silly-ways-wash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
feat: take form resets into account for two way bindings

packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2892,6 +2892,10 @@ export const template_visitors = {
28922892
break;
28932893
}
28942894

2895+
case 'files':
2896+
call_expr = b.call(`$.bind_files`, state.node, getter, setter);
2897+
break;
2898+
28952899
case 'this':
28962900
call_expr = serialize_bind_this(node.expression, context, state.node);
28972901
break;

packages/svelte/src/compiler/phases/bindings.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,6 @@ export const binding_properties = {
182182
valid_elements: ['input', 'textarea', 'select']
183183
},
184184
files: {
185-
event: 'change',
186-
type: 'set',
187185
valid_elements: ['input'],
188186
omit_in_ssr: true
189187
}

packages/svelte/src/internal/client/dom/elements/bindings/input.js

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DEV } from 'esm-env';
22
import { render_effect, user_effect } from '../../../reactivity/effects.js';
33
import { stringify } from '../../../render.js';
4+
import { listen_to_event_and_reset_event } from './shared.js';
45

56
/**
67
* @param {HTMLInputElement} input
@@ -9,7 +10,7 @@ import { stringify } from '../../../render.js';
910
* @returns {void}
1011
*/
1112
export function bind_value(input, get_value, update) {
12-
input.addEventListener('input', () => {
13+
listen_to_event_and_reset_event(input, 'input', () => {
1314
if (DEV && input.type === 'checkbox') {
1415
throw new Error(
1516
'Using bind:value together with a checkbox input is not allowed. Use bind:checked instead'
@@ -72,16 +73,22 @@ export function bind_group(inputs, group_index, input, get_value, update) {
7273

7374
binding_group.push(input);
7475

75-
input.addEventListener('change', () => {
76-
// @ts-ignore
77-
var value = input.__value;
76+
listen_to_event_and_reset_event(
77+
input,
78+
'change',
79+
() => {
80+
// @ts-ignore
81+
var value = input.__value;
7882

79-
if (is_checkbox) {
80-
value = get_binding_group_value(binding_group, value, input.checked);
81-
}
83+
if (is_checkbox) {
84+
value = get_binding_group_value(binding_group, value, input.checked);
85+
}
8286

83-
update(value);
84-
});
87+
update(value);
88+
},
89+
// TODO better default value handling
90+
() => update(is_checkbox ? [] : null)
91+
);
8592

8693
render_effect(() => {
8794
var value = get_value();
@@ -119,7 +126,7 @@ export function bind_group(inputs, group_index, input, get_value, update) {
119126
* @returns {void}
120127
*/
121128
export function bind_checked(input, get_value, update) {
122-
input.addEventListener('change', () => {
129+
listen_to_event_and_reset_event(input, 'change', () => {
123130
var value = input.checked;
124131
update(value);
125132
});
@@ -173,3 +180,17 @@ function is_numberlike_input(input) {
173180
function to_number(value) {
174181
return value === '' ? null : +value;
175182
}
183+
184+
/**
185+
* @param {HTMLInputElement} input
186+
* @param {() => FileList | null} get_value
187+
* @param {(value: FileList | null) => void} update
188+
*/
189+
export function bind_files(input, get_value, update) {
190+
listen_to_event_and_reset_event(input, 'change', () => {
191+
update(input.files);
192+
});
193+
render_effect(() => {
194+
input.files = get_value();
195+
});
196+
}

packages/svelte/src/internal/client/dom/elements/bindings/select.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { effect } from '../../../reactivity/effects.js';
2+
import { listen_to_event_and_reset_event } from './shared.js';
23
import { untrack } from '../../../runtime.js';
34

45
/**
@@ -76,7 +77,7 @@ export function init_select(select, get_value) {
7677
export function bind_select_value(select, get_value, update) {
7778
var mounting = true;
7879

79-
select.addEventListener('change', () => {
80+
listen_to_event_and_reset_event(select, 'change', () => {
8081
/** @type {unknown} */
8182
var value;
8283

packages/svelte/src/internal/client/dom/elements/bindings/shared.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,51 @@ export function listen(target, events, handler, call_handler_immediately = true)
2525
};
2626
});
2727
}
28+
29+
let listening_to_form_reset = false;
30+
31+
/**
32+
* Listen to the given event, and then instantiate a global form reset listener if not already done,
33+
* to notify all bindings when the form is reset
34+
* @param {HTMLElement} element
35+
* @param {string} event
36+
* @param {() => void} handler
37+
* @param {() => void} [on_reset]
38+
*/
39+
export function listen_to_event_and_reset_event(element, event, handler, on_reset = handler) {
40+
element.addEventListener(event, handler);
41+
// @ts-expect-error
42+
const prev = element.__on_r;
43+
if (prev) {
44+
// special case for checkbox that can have multiple binds (group & checked)
45+
// @ts-expect-error
46+
element.__on_r = () => {
47+
prev();
48+
on_reset();
49+
};
50+
} else {
51+
// @ts-expect-error
52+
element.__on_r = on_reset;
53+
}
54+
55+
if (!listening_to_form_reset) {
56+
listening_to_form_reset = true;
57+
document.addEventListener(
58+
'reset',
59+
(evt) => {
60+
// Needs to happen one tick later or else the dom properties of the form
61+
// elements have not updated to their reset values yet
62+
Promise.resolve().then(() => {
63+
if (!evt.defaultPrevented) {
64+
for (const e of /**@type {HTMLFormElement} */ (evt.target).elements) {
65+
// @ts-expect-error
66+
e.__on_r?.();
67+
}
68+
}
69+
});
70+
},
71+
// In the capture phase to guarantee we get noticed of it (no possiblity of stopPropagation)
72+
{ capture: true }
73+
);
74+
}
75+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
async test({ assert, target }) {
5+
const p = target.querySelector('p');
6+
7+
assert.htmlEqual(
8+
p?.innerHTML || '',
9+
`{"text":"text","checkbox":true,"radio_group":"a","checkbox_group":["a"],"select":"b","textarea":"textarea"}`
10+
);
11+
12+
await target.querySelector('button')?.click();
13+
await Promise.resolve();
14+
assert.htmlEqual(
15+
p?.innerHTML || '',
16+
`{"text":"","checkbox":false,"radio_group":null,"checkbox_group":[],"select":"a","textarea":""}`
17+
);
18+
}
19+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script>
2+
let text = $state('text');
3+
let checkbox = $state(true);
4+
let radio_group = $state('a');
5+
let checkbox_group = $state(['a']);
6+
let select = $state('b');
7+
let textarea = $state('textarea');
8+
</script>
9+
10+
<p>{JSON.stringify({ text, checkbox, radio_group, checkbox_group, select, textarea })}</p>
11+
12+
<form>
13+
<input bind:value={text} />
14+
15+
<input type="checkbox" bind:checked={checkbox} />
16+
17+
<input type="radio" name="radio" value="a" bind:group={radio_group} />
18+
<input type="radio" name="radio" value="b" bind:group={radio_group} />
19+
20+
<input type="checkbox" name="checkbox" value="a" bind:group={checkbox_group} />
21+
<input type="checkbox" name="checkbox" value="b" bind:group={checkbox_group} />
22+
23+
<select bind:value={select}>
24+
<option value="a">a</option>
25+
<option value="b">b</option>
26+
</select>
27+
28+
<textarea bind:value={textarea}></textarea>
29+
30+
<button type="button" onclick={(e) => e.target.form.reset()}>Reset</button>
31+
</form>

sites/svelte-5-preview/src/routes/docs/content/03-appendix/02-breaking-changes.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,7 @@ In Svelte 4, `null` and `undefined` were printed as the corresponding string. In
179179
### `bind:files` values can only be `null`, `undefined` or `FileList`
180180

181181
`bind:files` is now a two-way binding. As such, when setting a value, it needs to be either falsy (`null` or `undefined`) or of type `FileList`.
182+
183+
### Bindings now react to form resets
184+
185+
Previously, bindings did not take into account `reset` event of forms, and therefore values could get out of sync with the DOM. Svelte 5 fixes this by placing a `reset` listener on the document and invoking bindings where necessary.

0 commit comments

Comments
 (0)