Skip to content

Commit d140e73

Browse files
committed
fix(v-model): preserve remaining text input types before hydration
1 parent d2c458b commit d140e73

2 files changed

Lines changed: 82 additions & 12 deletions

File tree

packages/runtime-core/__tests__/hydration.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1710,6 +1710,57 @@ describe('SSR hydration', () => {
17101710
expect(state.text).toBe('user value')
17111711
})
17121712

1713+
test.each(['search', 'tel', 'password', 'email', 'url'] as const)(
1714+
'preserves text entered before v-model hydration on type="%s"',
1715+
async type => {
1716+
const state = reactive({ text: 'server value' })
1717+
const App = {
1718+
setup: () => state,
1719+
template: `<input type="${type}" v-model="text">`,
1720+
}
1721+
const container = document.createElement('div')
1722+
container.innerHTML = await renderToString(h(App))
1723+
const input = container.firstChild as HTMLInputElement
1724+
input.value = 'user value'
1725+
1726+
createSSRApp(App).mount(container)
1727+
await nextTick()
1728+
1729+
expect(input.value).toBe('user value')
1730+
expect(state.text).toBe('user value')
1731+
},
1732+
)
1733+
1734+
test('keeps bound value when a url input only differs by whitespace', async () => {
1735+
const state = reactive({ text: ' https://server.com ' })
1736+
const App = {
1737+
setup: () => state,
1738+
template: `<input type="url" v-model="text">`,
1739+
}
1740+
const container = document.createElement('div')
1741+
container.innerHTML = await renderToString(h(App))
1742+
1743+
createSSRApp(App).mount(container)
1744+
await nextTick()
1745+
1746+
expect(state.text).toBe(' https://server.com ')
1747+
})
1748+
1749+
test('keeps bound value when a multiple email input only differs by whitespace', async () => {
1750+
const state = reactive({ text: ' a@b.com , c@d.com ' })
1751+
const App = {
1752+
setup: () => state,
1753+
template: `<input type="email" multiple v-model="text">`,
1754+
}
1755+
const container = document.createElement('div')
1756+
container.innerHTML = await renderToString(h(App))
1757+
1758+
createSSRApp(App).mount(container)
1759+
await nextTick()
1760+
1761+
expect(state.text).toBe(' a@b.com , c@d.com ')
1762+
})
1763+
17131764
test('force hydrate checkbox with indeterminate', () => {
17141765
const { container } = mountWithHydration(
17151766
'<input type="checkbox" indeterminate>',

packages/runtime-dom/src/directives/vModel.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,35 @@ function castValue(value: string, trim?: boolean, number?: boolean | null) {
5656
return value
5757
}
5858

59+
const stripASCIIWhitespace = (value: string) =>
60+
value.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, '')
61+
62+
// Returns what the browser exposes as `value` for the server-rendered
63+
// `defaultValue`, or undefined for types whose value sanitization algorithm
64+
// rewrites the value in ways `defaultValue` cannot be compared against
65+
// (number, date, color, range...).
66+
// https://html.spec.whatwg.org/multipage/input.html#value-sanitization-algorithm
67+
function sanitizeDefaultValue(
68+
el: HTMLInputElement | HTMLTextAreaElement,
69+
): string | undefined {
70+
const value = el.defaultValue
71+
switch (el.type) {
72+
case 'textarea':
73+
return value.replace(/\r\n?/g, '\n')
74+
case 'text':
75+
case 'search':
76+
case 'tel':
77+
case 'password':
78+
return value.replace(/[\r\n]/g, '')
79+
case 'url':
80+
return stripASCIIWhitespace(value.replace(/[\r\n]/g, ''))
81+
case 'email':
82+
return (el as HTMLInputElement).multiple
83+
? value.split(',').map(stripASCIIWhitespace).join(',')
84+
: stripASCIIWhitespace(value.replace(/[\r\n]/g, ''))
85+
}
86+
}
87+
5988
// We are exporting the v-model runtime directly as vnode hooks so that it can
6089
// be tree-shaken in case v-model is never used.
6190
export const vModelText: ModelDirective<
@@ -65,13 +94,7 @@ export const vModelText: ModelDirective<
6594
created(el, { modifiers: { lazy, trim, number } }, vnode) {
6695
// During hydration, created runs on an element already in the DOM.
6796
if (el.parentNode) {
68-
if (el.type === 'text') {
69-
// Text inputs strip CR/LF from value, while defaultValue retains them.
70-
el[initialValueKey] = el.defaultValue.replace(/[\r\n]/g, '')
71-
} else if (el.type === 'textarea') {
72-
// Textareas normalize CRLF/CR to LF in value.
73-
el[initialValueKey] = el.defaultValue.replace(/\r\n?/g, '\n')
74-
}
97+
el[initialValueKey] = sanitizeDefaultValue(el)
7598
}
7699
el[assignKey] = getModelAssigner(vnode)
77100
const castToNumber =
@@ -100,11 +123,7 @@ export const vModelText: ModelDirective<
100123
const newValue = value == null ? '' : value
101124
const initialValue = el[initialValueKey]
102125
delete el[initialValueKey]
103-
if (
104-
initialValue !== undefined &&
105-
(el.type === 'text' || el.type === 'textarea') &&
106-
el.value !== initialValue
107-
) {
126+
if (initialValue !== undefined && el.value !== initialValue) {
108127
el[assignKey](castValue(el.value, trim, number))
109128
} else {
110129
el.value = newValue

0 commit comments

Comments
 (0)