Skip to content

Commit 0d51dba

Browse files
trueadmdummdidummRich-Harris
authored
fix: ensure bound input content is resumed on hydration (#11986)
* fix: ensure bound input content is resumed on hydration * fix: ensure bound input content is resumed on hydration * Update packages/svelte/src/internal/client/dom/elements/bindings/input.js Co-authored-by: Simon H <[email protected]> * fix: ensure bound input content is resumed on hydration * fix: ensure bound input content is resumed on hydration * Update packages/svelte/src/internal/client/dom/elements/bindings/input.js Co-authored-by: Simon H <[email protected]> * add test * add test * add test * add test * Update packages/svelte/src/internal/client/dom/elements/bindings/input.js Co-authored-by: Simon H <[email protected]> * add test * add test * newlines between multi-line blocks, let the code breathe --------- Co-authored-by: Simon H <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent f1c9edc commit 0d51dba

File tree

8 files changed

+125
-16
lines changed

8 files changed

+125
-16
lines changed

.changeset/flat-feet-visit.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+
fix: ensure bound input content is resumed on hydration

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { listen_to_event_and_reset_event } from './shared.js';
44
import * as e from '../../../errors.js';
55
import { get_proxied_value, is } from '../../../proxy.js';
66
import { queue_micro_task } from '../../task.js';
7+
import { hydrating } from '../../hydration.js';
78

89
/**
910
* @param {HTMLInputElement} input
@@ -29,8 +30,12 @@ export function bind_value(input, get_value, update) {
2930

3031
var value = get_value();
3132

32-
// @ts-ignore
33-
input.__value = value;
33+
// If we are hydrating and the value has since changed, then use the update value
34+
// from the input instead.
35+
if (hydrating && input.defaultValue !== input.value) {
36+
update(input.value);
37+
return;
38+
}
3439

3540
if (is_numberlike_input(input) && value === to_number(input.value)) {
3641
// handles 0 vs 00 case (see https://github.com/sveltejs/svelte/issues/9959)
@@ -60,6 +65,9 @@ export function bind_group(inputs, group_index, input, get_value, update) {
6065
var is_checkbox = input.getAttribute('type') === 'checkbox';
6166
var binding_group = inputs;
6267

68+
// needs to be let or related code isn't treeshaken out if it's always false
69+
let hydration_mismatch = false;
70+
6371
if (group_index !== null) {
6472
for (var index of group_index) {
6573
var group = binding_group;
@@ -94,6 +102,13 @@ export function bind_group(inputs, group_index, input, get_value, update) {
94102
render_effect(() => {
95103
var value = get_value();
96104

105+
// If we are hydrating and the value has since changed, then use the update value
106+
// from the input instead.
107+
if (hydrating && input.defaultChecked !== input.checked) {
108+
hydration_mismatch = true;
109+
return;
110+
}
111+
97112
if (is_checkbox) {
98113
value = value || [];
99114
// @ts-ignore
@@ -115,6 +130,20 @@ export function bind_group(inputs, group_index, input, get_value, update) {
115130
queue_micro_task(() => {
116131
// necessary to maintain binding group order in all insertion scenarios. TODO optimise
117132
binding_group.sort((a, b) => (a.compareDocumentPosition(b) === 4 ? -1 : 1));
133+
134+
if (hydration_mismatch) {
135+
var value;
136+
137+
if (is_checkbox) {
138+
value = get_binding_group_value(binding_group, value, input.checked);
139+
} else {
140+
var hydration_input = binding_group.find((input) => input.checked);
141+
// @ts-ignore
142+
value = hydration_input?.__value;
143+
}
144+
145+
update(value);
146+
}
118147
});
119148
}
120149

packages/svelte/tests/runtime-legacy/shared.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
5959
};
6060
logs: any[];
6161
warnings: any[];
62+
hydrate: Function;
6263
}) => void | Promise<void>;
6364
test_ssr?: (args: { assert: Assert }) => void | Promise<void>;
6465
accessors?: boolean;
@@ -103,6 +104,10 @@ export function runtime_suite(runes: boolean) {
103104
if (config.skip_mode?.includes('hydrate')) return true;
104105
}
105106

107+
if (variant === 'dom' && config.skip_mode?.includes('client')) {
108+
return 'no-test';
109+
}
110+
106111
if (variant === 'ssr') {
107112
if (
108113
(config.mode && !config.mode.includes('server')) ||
@@ -161,6 +166,7 @@ async function run_test_variant(
161166

162167
let logs: string[] = [];
163168
let warnings: string[] = [];
169+
let manual_hydrate = false;
164170

165171
{
166172
// use some crude static analysis to determine if logs/warnings are intercepted.
@@ -180,6 +186,10 @@ async function run_test_variant(
180186
console.log = (...args) => logs.push(...args);
181187
}
182188

189+
if (str.slice(0, i).includes('hydrate')) {
190+
manual_hydrate = true;
191+
}
192+
183193
if (str.slice(0, i).includes('warnings') || config.warnings) {
184194
// eslint-disable-next-line no-console
185195
console.warn = (...args) => {
@@ -297,17 +307,30 @@ async function run_test_variant(
297307

298308
let instance: any;
299309
let props: any;
310+
let hydrate_fn: Function = () => {
311+
throw new Error('Ensure dom mode is skipped');
312+
};
300313

301314
if (runes) {
302315
props = proxy({ ...(config.props || {}) });
303-
304-
const render = variant === 'hydrate' ? hydrate : mount;
305-
instance = render(mod.default, {
306-
target,
307-
props,
308-
intro: config.intro,
309-
recover: config.recover ?? false
310-
});
316+
if (manual_hydrate) {
317+
hydrate_fn = () => {
318+
instance = hydrate(mod.default, {
319+
target,
320+
props,
321+
intro: config.intro,
322+
recover: config.recover ?? false
323+
});
324+
};
325+
} else {
326+
const render = variant === 'hydrate' ? hydrate : mount;
327+
instance = render(mod.default, {
328+
target,
329+
props,
330+
intro: config.intro,
331+
recover: config.recover ?? false
332+
});
333+
}
311334
} else {
312335
instance = createClassComponent({
313336
component: mod.default,
@@ -357,7 +380,8 @@ async function run_test_variant(
357380
raf,
358381
compileOptions,
359382
logs,
360-
warnings
383+
warnings,
384+
hydrate: hydrate_fn
361385
});
362386
}
363387

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
skip_mode: ['client'],
5+
6+
test({ assert, target, hydrate }) {
7+
const inputs = /** @type {NodeListOf<HTMLInputElement>} */ (target.querySelectorAll('input'));
8+
inputs[1].checked = true;
9+
inputs[1].dispatchEvent(new window.Event('change'));
10+
// Hydration shouldn't reset the value to 1
11+
hydrate();
12+
13+
assert.htmlEqual(
14+
target.innerHTML,
15+
'<input name="foo" type="radio" value="1"><input name="foo" type="radio" value="2"><input name="foo" type="radio" value="3">\n2'
16+
);
17+
}
18+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
let value = $state(1);
3+
</script>
4+
5+
{#each [1, 2, 3] as number}
6+
<input type="radio" name="foo" value={number} bind:group={value}>
7+
{/each}
8+
9+
{value}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
skip_mode: ['client'],
5+
6+
test({ assert, target, hydrate }) {
7+
const input = /** @type {HTMLInputElement} */ (target.querySelector('input'));
8+
input.value = 'foo';
9+
input.dispatchEvent(new window.Event('input'));
10+
// Hydration shouldn't reset the value to empty
11+
hydrate();
12+
13+
assert.htmlEqual(target.innerHTML, '<input type="text">\nfoo');
14+
}
15+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script>
2+
let value = $state('');
3+
</script>
4+
5+
<input type="text" bind:value={value}>
6+
{value}

playgrounds/demo/src/entry-client.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import App from './App.svelte';
44
const root = document.getElementById('root')!;
55
const render = root.firstChild?.nextSibling ? hydrate : mount;
66

7-
const component = render(App, {
8-
target: document.getElementById('root')!
9-
});
10-
// @ts-ignore
11-
window.unmount = () => unmount(component);
7+
setTimeout(() => {
8+
const component = render(App, {
9+
target: document.getElementById('root')!
10+
});
11+
// @ts-ignore
12+
window.unmount = () => unmount(component);
13+
}, 2000)
14+

0 commit comments

Comments
 (0)