Skip to content

fix: ensure bound input content is resumed on hydration #11986

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jun 11, 2024
Merged
5 changes: 5 additions & 0 deletions .changeset/flat-feet-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

fix: ensure bound input content is resumed on hydration
33 changes: 31 additions & 2 deletions packages/svelte/src/internal/client/dom/elements/bindings/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { listen_to_event_and_reset_event } from './shared.js';
import * as e from '../../../errors.js';
import { get_proxied_value, is } from '../../../proxy.js';
import { queue_micro_task } from '../../task.js';
import { hydrating } from '../../hydration.js';

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

var value = get_value();

// @ts-ignore
input.__value = value;
// If we are hydrating and the value has since changed, then use the update value
// from the input instead.
if (hydrating && input.defaultValue !== input.value) {
update(input.value);
return;
}

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

// needs to be let or related code isn't treeshaken out if it's always false
let hydration_mismatch = false;

if (group_index !== null) {
for (var index of group_index) {
var group = binding_group;
Expand Down Expand Up @@ -94,6 +102,13 @@ export function bind_group(inputs, group_index, input, get_value, update) {
render_effect(() => {
var value = get_value();

// If we are hydrating and the value has since changed, then use the update value
// from the input instead.
if (hydrating && input.defaultChecked !== input.checked) {
hydration_mismatch = true;
return;
}

if (is_checkbox) {
value = value || [];
// @ts-ignore
Expand All @@ -115,6 +130,20 @@ export function bind_group(inputs, group_index, input, get_value, update) {
queue_micro_task(() => {
// necessary to maintain binding group order in all insertion scenarios. TODO optimise
binding_group.sort((a, b) => (a.compareDocumentPosition(b) === 4 ? -1 : 1));

if (hydration_mismatch) {
var value;

if (is_checkbox) {
value = get_binding_group_value(binding_group, value, input.checked);
} else {
var hydration_input = binding_group.find((input) => input.checked);
// @ts-ignore
value = hydration_input?.__value;
}

update(value);
}
});
}

Expand Down
42 changes: 33 additions & 9 deletions packages/svelte/tests/runtime-legacy/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
};
logs: any[];
warnings: any[];
hydrate: Function;
}) => void | Promise<void>;
test_ssr?: (args: { assert: Assert }) => void | Promise<void>;
accessors?: boolean;
Expand Down Expand Up @@ -103,6 +104,10 @@ export function runtime_suite(runes: boolean) {
if (config.skip_mode?.includes('hydrate')) return true;
}

if (variant === 'dom' && config.skip_mode?.includes('client')) {
return 'no-test';
}

if (variant === 'ssr') {
if (
(config.mode && !config.mode.includes('server')) ||
Expand Down Expand Up @@ -161,6 +166,7 @@ async function run_test_variant(

let logs: string[] = [];
let warnings: string[] = [];
let manual_hydrate = false;

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

if (str.slice(0, i).includes('hydrate')) {
manual_hydrate = true;
}

if (str.slice(0, i).includes('warnings') || config.warnings) {
// eslint-disable-next-line no-console
console.warn = (...args) => {
Expand Down Expand Up @@ -297,17 +307,30 @@ async function run_test_variant(

let instance: any;
let props: any;
let hydrate_fn: Function = () => {
throw new Error('Ensure dom mode is skipped');
};

if (runes) {
props = proxy({ ...(config.props || {}) });

const render = variant === 'hydrate' ? hydrate : mount;
instance = render(mod.default, {
target,
props,
intro: config.intro,
recover: config.recover ?? false
});
if (manual_hydrate) {
hydrate_fn = () => {
instance = hydrate(mod.default, {
target,
props,
intro: config.intro,
recover: config.recover ?? false
});
};
} else {
const render = variant === 'hydrate' ? hydrate : mount;
instance = render(mod.default, {
target,
props,
intro: config.intro,
recover: config.recover ?? false
});
}
} else {
instance = createClassComponent({
component: mod.default,
Expand Down Expand Up @@ -357,7 +380,8 @@ async function run_test_variant(
raf,
compileOptions,
logs,
warnings
warnings,
hydrate: hydrate_fn
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { test } from '../../test';

export default test({
skip_mode: ['client'],

test({ assert, target, hydrate }) {
const inputs = /** @type {NodeListOf<HTMLInputElement>} */ (target.querySelectorAll('input'));
inputs[1].checked = true;
inputs[1].dispatchEvent(new window.Event('change'));
// Hydration shouldn't reset the value to 1
hydrate();

assert.htmlEqual(
target.innerHTML,
'<input name="foo" type="radio" value="1"><input name="foo" type="radio" value="2"><input name="foo" type="radio" value="3">\n2'
);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script>
let value = $state(1);
</script>

{#each [1, 2, 3] as number}
<input type="radio" name="foo" value={number} bind:group={value}>
{/each}

{value}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { test } from '../../test';

export default test({
skip_mode: ['client'],

test({ assert, target, hydrate }) {
const input = /** @type {HTMLInputElement} */ (target.querySelector('input'));
input.value = 'foo';
input.dispatchEvent(new window.Event('input'));
// Hydration shouldn't reset the value to empty
hydrate();

assert.htmlEqual(target.innerHTML, '<input type="text">\nfoo');
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
let value = $state('');
</script>

<input type="text" bind:value={value}>
{value}
13 changes: 8 additions & 5 deletions playgrounds/demo/src/entry-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import App from './App.svelte';
const root = document.getElementById('root')!;
const render = root.firstChild?.nextSibling ? hydrate : mount;

const component = render(App, {
target: document.getElementById('root')!
});
// @ts-ignore
window.unmount = () => unmount(component);
setTimeout(() => {
const component = render(App, {
target: document.getElementById('root')!
});
// @ts-ignore
window.unmount = () => unmount(component);
}, 2000)

Loading