From: daiwei Date: Mon, 22 Sep 2025 06:16:50 +0000 (+0800) Subject: chore: hydration mismatch handling X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=c42499c3f03ee9d0a1c1653493fab115365c8554;p=thirdparty%2Fvuejs%2Fcore.git chore: hydration mismatch handling --- diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index e41a4fdc9c..34ae218091 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -869,35 +869,61 @@ function propHasMismatch( mismatchType = MismatchTypes.STYLE mismatchKey = 'style' } - } else if ( - (el instanceof SVGElement && isKnownSvgAttr(key)) || - (el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key))) - ) { - if (isBooleanAttr(key)) { - actual = el.hasAttribute(key) - expected = includeBooleanAttr(clientValue) - } else if (clientValue == null) { - actual = el.hasAttribute(key) - expected = false - } else { - if (el.hasAttribute(key)) { - actual = el.getAttribute(key) - } else if (key === 'value' && el.tagName === 'TEXTAREA') { - // #10000 textarea.value can't be retrieved by `hasAttribute` - actual = (el as HTMLTextAreaElement).value - } else { - actual = false - } - expected = isRenderableAttrValue(clientValue) - ? String(clientValue) - : false - } + } else if (isValidHtmlOrSvgAttribute(el, key)) { + ;({ actual, expected } = getAttributeMismatch(el, key, clientValue)) if (actual !== expected) { mismatchType = MismatchTypes.ATTRIBUTE mismatchKey = key } } + return warnPropMismatch(el, mismatchKey, mismatchType, actual, expected) +} + +export function getAttributeMismatch( + el: Element, + key: string, + clientValue: any, +): { + actual: string | boolean | null | undefined + expected: string | boolean | null | undefined +} { + let actual: string | boolean | null | undefined + let expected: string | boolean | null | undefined + if (isBooleanAttr(key)) { + actual = el.hasAttribute(key) + expected = includeBooleanAttr(clientValue) + } else if (clientValue == null) { + actual = el.hasAttribute(key) + expected = false + } else { + if (el.hasAttribute(key)) { + actual = el.getAttribute(key) + } else if (key === 'value' && el.tagName === 'TEXTAREA') { + // #10000 textarea.value can't be retrieved by `hasAttribute` + actual = (el as HTMLTextAreaElement).value + } else { + actual = false + } + expected = isRenderableAttrValue(clientValue) ? String(clientValue) : false + } + return { actual, expected } +} + +export function isValidHtmlOrSvgAttribute(el: Element, key: string): boolean { + return ( + (el instanceof SVGElement && isKnownSvgAttr(key)) || + (el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key))) + ) +} + +export function warnPropMismatch( + el: Element & { $cls?: string }, + mismatchKey: string | undefined, + mismatchType: MismatchTypes | undefined, + actual: string | boolean | null | undefined, + expected: string | boolean | null | undefined, +): boolean { if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) { const format = (v: any) => v === false ? `(not rendered)` : `${mismatchKey}="${v}"` @@ -920,11 +946,11 @@ function propHasMismatch( return false } -function toClassSet(str: string): Set { +export function toClassSet(str: string): Set { return new Set(str.trim().split(/\s+/)) } -function isSetEqual(a: Set, b: Set): boolean { +export function isSetEqual(a: Set, b: Set): boolean { if (a.size !== b.size) { return false } @@ -936,7 +962,7 @@ function isSetEqual(a: Set, b: Set): boolean { return true } -function toStyleMap(str: string): Map { +export function toStyleMap(str: string): Map { const styleMap: Map = new Map() for (const item of str.split(';')) { let [key, value] = item.split(':') @@ -949,7 +975,10 @@ function toStyleMap(str: string): Map { return styleMap } -function isMapEqual(a: Map, b: Map): boolean { +export function isMapEqual( + a: Map, + b: Map, +): boolean { if (a.size !== b.size) { return false } @@ -991,7 +1020,7 @@ function resolveCssVars( const allowMismatchAttr = 'data-allow-mismatch' -enum MismatchTypes { +export enum MismatchTypes { TEXT = 0, CHILDREN = 1, CLASS = 2, @@ -1007,7 +1036,7 @@ const MismatchTypeString: Record = { [MismatchTypes.ATTRIBUTE]: 'attribute', } as const -function isMismatchAllowed( +export function isMismatchAllowed( el: Element | null, allowedType: MismatchTypes, ): boolean { diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 1c7fa5d78b..bbbd3d183b 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -597,6 +597,20 @@ export { markAsyncBoundary } from './helpers/useId' * @internal */ export { createInternalObject } from './internalObject' +/** + * @internal + */ +export { + MismatchTypes, + isMismatchAllowed, + toClassSet, + isSetEqual, + warnPropMismatch, + toStyleMap, + isMapEqual, + isValidHtmlOrSvgAttribute, + getAttributeMismatch, +} from './hydration' /** * @internal */ diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index 1eb43e0bba..f77d58c349 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -77,6 +77,26 @@ async function testWithVDOMApp( }) } +async function mountWithHydration( + html: string, + code: string, + data: runtimeDom.Ref, +) { + const container = document.createElement('div') + container.innerHTML = html + + const clientComp = compile(``, data, undefined, { + vapor: true, + ssr: false, + }) + const app = createVaporSSRApp(clientComp) + app.mount(container) + + return { + container, + } +} + async function testHydration( code: string, components: Record = {}, @@ -233,7 +253,7 @@ describe('Vapor Mode hydration', () => { data, ) expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( - `"
"`, + `"
"`, ) data.txt = 'foo' @@ -255,7 +275,7 @@ describe('Vapor Mode hydration', () => { expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( ` " - + " `, ) @@ -2900,15 +2920,649 @@ describe('Vapor Mode hydration', () => { test.todo('force hydrate custom element with dynamic props', () => {}) }) - describe.todo('data-allow-mismatch') - - describe.todo('mismatch handling') - describe.todo('Teleport') describe.todo('Suspense') }) +describe('mismatch handling', () => { + test('text node', async () => { + const foo = ref('bar') + const { container } = await mountWithHydration(`foo`, `{{data}}`, foo) + expect(container.textContent).toBe('bar') + expect(`Hydration text mismatch`).toHaveBeenWarned() + }) + + test('element text content', async () => { + const data = ref({ textContent: 'bar' }) + const { container } = await mountWithHydration( + `
foo
`, + `
`, + data, + ) + expect(container.innerHTML).toBe('
bar
') + expect(`Hydration text content mismatch`).toHaveBeenWarned() + }) + + test('element with v-html', async () => { + const data = ref('

bar

') + const { container } = await mountWithHydration( + `

foo

`, + `
`, + data, + ) + expect(container.innerHTML).toBe('

bar

') + expect(`Hydration children mismatch on`).toHaveBeenWarned() + }) + // test('not enough children', () => { + // const { container } = mountWithHydration(`
`, () => + // h('div', [h('span', 'foo'), h('span', 'bar')]), + // ) + // expect(container.innerHTML).toBe( + // '
foobar
', + // ) + // expect(`Hydration children mismatch`).toHaveBeenWarned() + // }) + // test('too many children', () => { + // const { container } = mountWithHydration( + // `
foobar
`, + // () => h('div', [h('span', 'foo')]), + // ) + // expect(container.innerHTML).toBe('
foo
') + // expect(`Hydration children mismatch`).toHaveBeenWarned() + // }) + test('complete mismatch', async () => { + const data = ref('span') + const { container } = await mountWithHydration( + `
foo
`, + `foo`, + data, + ) + expect(container.innerHTML).toBe('foo') + expect(`Hydration node mismatch`).toHaveBeenWarned() + }) + // test('fragment mismatch removal', () => { + // const { container } = mountWithHydration( + // `
foo
bar
`, + // () => h('div', [h('span', 'replaced')]), + // ) + // expect(container.innerHTML).toBe('
replaced
') + // expect(`Hydration node mismatch`).toHaveBeenWarned() + // }) + // test('fragment not enough children', () => { + // const { container } = mountWithHydration( + // `
foo
baz
`, + // () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]), + // ) + // expect(container.innerHTML).toBe( + // '
foo
bar
baz
', + // ) + // expect(`Hydration node mismatch`).toHaveBeenWarned() + // }) + // test('fragment too many children', () => { + // const { container } = mountWithHydration( + // `
foo
bar
baz
`, + // () => h('div', [[h('div', 'foo')], h('div', 'baz')]), + // ) + // expect(container.innerHTML).toBe( + // '
foo
baz
', + // ) + // // fragment ends early and attempts to hydrate the extra
bar
+ // // as 2nd fragment child. + // expect(`Hydration text content mismatch`).toHaveBeenWarned() + // // excessive children removal + // expect(`Hydration children mismatch`).toHaveBeenWarned() + // }) + // test('Teleport target has empty children', () => { + // const teleportContainer = document.createElement('div') + // teleportContainer.id = 'teleport' + // document.body.appendChild(teleportContainer) + // mountWithHydration('', () => + // h(Teleport, { to: '#teleport' }, [h('span', 'value')]), + // ) + // expect(teleportContainer.innerHTML).toBe(`value`) + // expect(`Hydration children mismatch`).toHaveBeenWarned() + // }) + // test('comment mismatch (element)', () => { + // const { container } = mountWithHydration(`
`, () => + // h('div', [createCommentVNode('hi')]), + // ) + // expect(container.innerHTML).toBe('
') + // expect(`Hydration node mismatch`).toHaveBeenWarned() + // }) + // test('comment mismatch (text)', () => { + // const { container } = mountWithHydration(`
foobar
`, () => + // h('div', [createCommentVNode('hi')]), + // ) + // expect(container.innerHTML).toBe('
') + // expect(`Hydration node mismatch`).toHaveBeenWarned() + // }) + test('class mismatch', async () => { + await mountWithHydration( + `
`, + `
`, + ref(['foo', 'bar']), + ) + + await mountWithHydration( + `
`, + `
`, + ref({ foo: true, bar: true }), + ) + + await mountWithHydration( + `
`, + `
`, + ref('foo bar'), + ) + + // svg classes + await mountWithHydration( + ``, + ``, + ref('foo bar'), + ) + + // class with different order + await mountWithHydration( + `
`, + `
`, + ref('bar foo'), + ) + expect(`Hydration class mismatch`).not.toHaveBeenWarned() + + // single root mismatch + const { container: root } = await mountWithHydration( + `
`, + `
`, + ref('baz'), + ) + expect(root.innerHTML).toBe('
') + expect(`Hydration class mismatch`).toHaveBeenWarned() + + // multiple root mismatch + const { container } = await mountWithHydration( + `
`, + `
`, + ref('foo'), + ) + expect(container.innerHTML).toBe('
') + expect(`Hydration class mismatch`).toHaveBeenWarned() + }) + + test('style mismatch', async () => { + await mountWithHydration( + `
`, + `
`, + ref({ color: 'red' }), + ) + + await mountWithHydration( + `
`, + `
`, + ref('color:red;'), + ) + + // style with different order + await mountWithHydration( + `
`, + `
`, + ref(`font-size: 12px; color:red;`), + ) + + expect(`Hydration style mismatch`).not.toHaveBeenWarned() + + // single root mismatch + const { container: root } = await mountWithHydration( + `
`, + `
`, + ref({ color: 'green' }), + ) + expect(root.innerHTML).toBe('
') + expect(`Hydration style mismatch`).toHaveBeenWarned() + + // multiple root mismatch + const { container } = await mountWithHydration( + `
`, + `
`, + ref({ color: 'green' }), + ) + expect(container.innerHTML).toBe( + '
', + ) + expect(`Hydration style mismatch`).toHaveBeenWarned() + }) + + test('style mismatch when no style attribute is present', async () => { + await mountWithHydration( + `
`, + `
`, + ref({ color: 'red' }), + ) + expect(`Hydration style mismatch`).toHaveBeenWarnedTimes(1) + }) + + test('style mismatch w/ v-show', async () => { + await mountWithHydration( + `
`, + `
`, + ref(false), + ) + expect(`Hydration style mismatch`).not.toHaveBeenWarned() + + // mismatch with single root + const { container: root } = await mountWithHydration( + `
`, + `
`, + ref(false), + ) + expect(root.innerHTML).toBe( + '
', + ) + expect(`Hydration style mismatch`).toHaveBeenWarned() + + // mismatch with multiple root + const { container } = await mountWithHydration( + `
`, + `
`, + ref({ show: false, style: 'color: red' }), + ) + expect(container.innerHTML).toBe( + '
', + ) + expect(`Hydration style mismatch`).toHaveBeenWarned() + }) + + test('attr mismatch', async () => { + await mountWithHydration( + `
`, + `
`, + ref('foo'), + ) + + await mountWithHydration( + `
`, + `
`, + ref(''), + ) + + await mountWithHydration( + `
`, + `
`, + ref(undefined), + ) + + // boolean + await mountWithHydration( + ``, + ref(true), + ) + + await mountWithHydration( + ``, + ref('multiple'), + ) + + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + await mountWithHydration( + `
`, + `
`, + ref('foo'), + ) + expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(1) + + await mountWithHydration( + `
`, + `
`, + ref('foo'), + ) + expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(2) + }) + + test('attr special case: textarea value', async () => { + await mountWithHydration( + ``, + ``, + ref('foo'), + ) + + await mountWithHydration( + ``, + ``, + ref(''), + ) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + + await mountWithHydration( + ``, + ``, + ref('bar'), + ) + expect(`Hydration attribute mismatch`).toHaveBeenWarned() + }) + + test('`, + ``, + ref('\nhello'), + ) + + await mountWithHydration( + ``, + ``, + ref('\nhello'), + ) + + await mountWithHydration( + ``, + ``, + ref({ textContent: '\nhello' }), + ) + expect(`Hydration text content mismatch`).not.toHaveBeenWarned() + }) + + test('
 with newlines at the beginning', async () => {
+    await mountWithHydration(`
\n
`, `
{{data}}
`, ref('\n')) + + await mountWithHydration( + `
\n
`, + `
`,
+      ref('\n'),
+    )
+
+    await mountWithHydration(
+      `
\n
`, + `
`,
+      ref({ textContent: '\n' }),
+    )
+    expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('boolean attr handling', async () => {
+    await mountWithHydration(
+      ``,
+      ``,
+      ref(false),
+    )
+
+    await mountWithHydration(
+      ``,
+      ``,
+      ref(true),
+    )
+
+    await mountWithHydration(
+      ``,
+      ``,
+      ref(true),
+    )
+    expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('client value is null or undefined', async () => {
+    await mountWithHydration(
+      `
`, + `
`, + ref(undefined), + ) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + await mountWithHydration(``, ``, ref(null)) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + }) + + test('should not warn against object values', async () => { + await mountWithHydration(``, ``, ref({})) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + }) + + test('should not warn on falsy bindings of non-property keys', async () => { + await mountWithHydration( + ``, + ``, + ref(undefined), + ) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + }) + + test('should not warn on non-renderable option values', async () => { + await mountWithHydration( + ``, + ``, + ref(['foo']), + ) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + }) + + test.todo('should not warn css v-bind', () => { + // const container = document.createElement('div') + // container.innerHTML = `
` + // const app = createSSRApp({ + // setup() { + // useCssVars(() => ({ + // foo: 'red', + // })) + // return () => h('div', { style: { color: 'var(--foo)' } }) + // }, + // }) + // app.mount(container) + // expect(`Hydration style mismatch`).not.toHaveBeenWarned() + }) + + test.todo( + 'css vars should only be added to expected on component root dom', + () => { + // const container = document.createElement('div') + // container.innerHTML = `
` + // const app = createSSRApp({ + // setup() { + // useCssVars(() => ({ + // foo: 'red', + // })) + // return () => + // h('div', null, [h('div', { style: { color: 'var(--foo)' } })]) + // }, + // }) + // app.mount(container) + // expect(`Hydration style mismatch`).not.toHaveBeenWarned() + }, + ) + + test.todo('css vars support fallthrough', () => { + // const container = document.createElement('div') + // container.innerHTML = `
` + // const app = createSSRApp({ + // setup() { + // useCssVars(() => ({ + // foo: 'red', + // })) + // return () => h(Child) + // }, + // }) + // const Child = { + // setup() { + // return () => h('div', { style: 'padding: 4px' }) + // }, + // } + // app.mount(container) + // expect(`Hydration style mismatch`).not.toHaveBeenWarned() + }) + + // vapor directive does not have a created hook + test('should not warn for directives that mutate DOM in created', () => { + // const container = document.createElement('div') + // container.innerHTML = `
` + // const vColor: ObjectDirective = { + // created(el, binding) { + // el.classList.add(binding.value) + // }, + // } + // const app = createSSRApp({ + // setup() { + // return () => + // withDirectives(h('div', { class: 'test' }), [[vColor, 'red']]) + // }, + // }) + // app.mount(container) + // expect(`Hydration style mismatch`).not.toHaveBeenWarned() + }) + + test.todo('escape css var name', () => { + // const container = document.createElement('div') + // container.innerHTML = `
` + // const app = createSSRApp({ + // setup() { + // useCssVars(() => ({ + // 'foo.bar': 'red', + // })) + // return () => h(Child) + // }, + // }) + // const Child = { + // setup() { + // return () => h('div', { style: 'padding: 4px' }) + // }, + // } + // app.mount(container) + // expect(`Hydration style mismatch`).not.toHaveBeenWarned() + }) +}) + +describe('data-allow-mismatch', () => { + test('element text content', async () => { + const data = ref({ textContent: 'bar' }) + const { container } = await mountWithHydration( + `
foo
`, + `
`, + data, + ) + expect(container.innerHTML).toBe( + '
bar
', + ) + expect(`Hydration text content mismatch`).not.toHaveBeenWarned() + }) + // test('not enough children', () => { + // const { container } = mountWithHydration( + // `
`, + // () => h('div', [h('span', 'foo'), h('span', 'bar')]), + // ) + // expect(container.innerHTML).toBe( + // '
foobar
', + // ) + // expect(`Hydration children mismatch`).not.toHaveBeenWarned() + // }) + // test('too many children', () => { + // const { container } = mountWithHydration( + // `
foobar
`, + // () => h('div', [h('span', 'foo')]), + // ) + // expect(container.innerHTML).toBe( + // '
foo
', + // ) + // expect(`Hydration children mismatch`).not.toHaveBeenWarned() + // }) + test('complete mismatch', async () => { + const { container } = await mountWithHydration( + `
foo
`, + `
foo
`, + ref('span'), + ) + expect(container.innerHTML).toBe( + '
foo
', + ) + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + }) + // test('fragment mismatch removal', () => { + // const { container } = mountWithHydration( + // `
foo
bar
`, + // () => h('div', [h('span', 'replaced')]), + // ) + // expect(container.innerHTML).toBe( + // '
replaced
', + // ) + // expect(`Hydration node mismatch`).not.toHaveBeenWarned() + // }) + // test('fragment not enough children', () => { + // const { container } = mountWithHydration( + // `
foo
baz
`, + // () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]), + // ) + // expect(container.innerHTML).toBe( + // '
foo
bar
baz
', + // ) + // expect(`Hydration node mismatch`).not.toHaveBeenWarned() + // }) + // test('fragment too many children', () => { + // const { container } = mountWithHydration( + // `
foo
bar
baz
`, + // () => h('div', [[h('div', 'foo')], h('div', 'baz')]), + // ) + // expect(container.innerHTML).toBe( + // '
foo
baz
', + // ) + // // fragment ends early and attempts to hydrate the extra
bar
+ // // as 2nd fragment child. + // expect(`Hydration text content mismatch`).not.toHaveBeenWarned() + // // excessive children removal + // expect(`Hydration children mismatch`).not.toHaveBeenWarned() + // }) + // test('comment mismatch (element)', () => { + // const { container } = mountWithHydration( + // `
`, + // () => h('div', [createCommentVNode('hi')]), + // ) + // expect(container.innerHTML).toBe( + // '
', + // ) + // expect(`Hydration node mismatch`).not.toHaveBeenWarned() + // }) + // test('comment mismatch (text)', () => { + // const { container } = mountWithHydration( + // `
foobar
`, + // () => h('div', [createCommentVNode('hi')]), + // ) + // expect(container.innerHTML).toBe( + // '
', + // ) + // expect(`Hydration node mismatch`).not.toHaveBeenWarned() + // }) + test('class mismatch', async () => { + await mountWithHydration( + `
`, + `
`, + ref('foo'), + ) + expect(`Hydration class mismatch`).not.toHaveBeenWarned() + }) + + test('style mismatch', async () => { + await mountWithHydration( + `
`, + `
`, + ref({ color: 'green' }), + ) + expect(`Hydration style mismatch`).not.toHaveBeenWarned() + }) + + test('attr mismatch', async () => { + await mountWithHydration( + `
`, + `
`, + ref('foo'), + ) + + await mountWithHydration( + `
`, + `
`, + ref('foo'), + ) + + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + }) +}) + describe('VDOM interop', () => { test('basic render vapor component', async () => { const data = ref(true) diff --git a/packages/runtime-vapor/src/directives/vShow.ts b/packages/runtime-vapor/src/directives/vShow.ts index d7dd22d678..782d05e156 100644 --- a/packages/runtime-vapor/src/directives/vShow.ts +++ b/packages/runtime-vapor/src/directives/vShow.ts @@ -1,14 +1,17 @@ import { + MismatchTypes, type VShowElement, vShowHidden, vShowOriginalDisplay, warn, + warnPropMismatch, } from '@vue/runtime-dom' import { renderEffect } from '../renderEffect' import { isVaporComponent } from '../component' import type { Block, TransitionBlock } from '../block' import { isArray } from '@vue/shared' import { DynamicFragment, VaporFragment } from '../fragment' +import { isHydrating, logMismatchError } from '../dom/hydration' export function applyVShow(target: Block, source: () => any): void { if (isVaporComponent(target)) { @@ -74,9 +77,29 @@ function setDisplay(target: Block, value: unknown): void { } } } else { - el.style.display = value ? el[vShowOriginalDisplay]! : 'none' + if ( + (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + isHydrating + ) { + if (!value && el.style.display !== 'none') { + warnPropMismatch( + el, + 'style', + MismatchTypes.STYLE, + `display: ${el.style.display}`, + 'display: none', + ) + logMismatchError() + + el.style.display = 'none' + el[vShowOriginalDisplay] = '' + } + } else { + el.style.display = value ? el[vShowOriginalDisplay]! : 'none' + } + + el[vShowHidden] = !value } - el[vShowHidden] = !value } else if (__DEV__) { warn( `v-show used on component with non-single-element root node ` + diff --git a/packages/runtime-vapor/src/dom/hydration.ts b/packages/runtime-vapor/src/dom/hydration.ts index 704494bad3..ef1fb1375a 100644 --- a/packages/runtime-vapor/src/dom/hydration.ts +++ b/packages/runtime-vapor/src/dom/hydration.ts @@ -1,4 +1,4 @@ -import { warn } from '@vue/runtime-dom' +import { MismatchTypes, isMismatchAllowed, warn } from '@vue/runtime-dom' import { type ChildItem, incrementIndexOffset, @@ -8,10 +8,15 @@ import { setInsertionState, } from '../insertionState' import { + _next, + child, + createElement, createTextNode, disableHydrationNodeLookup, enableHydrationNodeLookup, + parentNode, } from './node' +import { remove } from '../block' const isHydratingStack = [] as boolean[] export let isHydrating = false @@ -113,28 +118,23 @@ function adoptTemplateImpl(node: Node, template: string): Node | null { isComment(node, ']') && isComment(node.previousSibling!, '[') ) { - node = node.parentNode!.insertBefore(createTextNode(' '), node) - incrementIndexOffset(node.parentNode!) + const parent = parentNode(node)! + node = parent.insertBefore(createTextNode(), node) + incrementIndexOffset(parent) break } } } - if (__DEV__) { - const type = node.nodeType - if ( - (type === 8 && !template.startsWith(' { + newNode.setAttribute(attr.name, attr.value) + }) + container.insertBefore(newNode, next) + return newNode +} + +let hasLoggedMismatchError = false +export const logMismatchError = (): void => { + if (__TEST__ || hasLoggedMismatchError) { + return + } + // this error should show up in production + console.error('Hydration completed but contains mismatches.') + hasLoggedMismatchError = true +} diff --git a/packages/runtime-vapor/src/dom/node.ts b/packages/runtime-vapor/src/dom/node.ts index df291e40e7..945c55fe02 100644 --- a/packages/runtime-vapor/src/dom/node.ts +++ b/packages/runtime-vapor/src/dom/node.ts @@ -21,6 +21,11 @@ export function querySelector(selectors: string): Element | null { return document.querySelector(selectors) } +/*! @__NO_SIDE_EFFECTS__ */ +export function parentNode(node: Node): ParentNode | null { + return node.parentNode +} + /* @__NO_SIDE_EFFECTS__ */ const _txt: typeof _child = _child @@ -34,8 +39,7 @@ const __txt: typeof __child = (node: ParentNode): Node => { // since SSR doesn't generate whitespace placeholder text nodes, if firstChild // is null, manually insert a text node as the first child if (!n) { - node.textContent = ' ' - return node.firstChild! + return node.appendChild(createTextNode()) } return n diff --git a/packages/runtime-vapor/src/dom/prop.ts b/packages/runtime-vapor/src/dom/prop.ts index d9a38fc39d..22189c822b 100644 --- a/packages/runtime-vapor/src/dom/prop.ts +++ b/packages/runtime-vapor/src/dom/prop.ts @@ -6,20 +6,32 @@ import { normalizeClass, normalizeStyle, parseStringStyle, + stringifyStyle, toDisplayString, } from '@vue/shared' import { on } from './event' import { + MismatchTypes, currentInstance, + getAttributeMismatch, + isMapEqual, + isMismatchAllowed, + isSetEqual, + isValidHtmlOrSvgAttribute, mergeProps, patchStyle, shouldSetAsProp, + toClassSet, + toStyleMap, + vShowHidden, warn, + warnPropMismatch, } from '@vue/runtime-dom' import { type VaporComponentInstance, isApplyingFallthroughProps, } from '../component' +import { isHydrating, logMismatchError } from './hydration' type TargetElement = Element & { $root?: true @@ -57,6 +69,15 @@ export function setAttr(el: any, key: string, value: any): void { ;(el as any)._falseValue = value } + if ( + (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + isHydrating && + !attributeHasMismatch(el, key, value) + ) { + el[`$${key}`] = value + return + } + if (value !== el[`$${key}`]) { el[`$${key}`] = value if (value != null) { @@ -72,6 +93,14 @@ export function setDOMProp(el: any, key: string, value: any): void { return } + if ( + (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + isHydrating && + !attributeHasMismatch(el, key, value) + ) { + return + } + const prev = el[key] if (value === prev) { return @@ -112,15 +141,38 @@ export function setDOMProp(el: any, key: string, value: any): void { export function setClass(el: TargetElement, value: any): void { if (el.$root) { setClassIncremental(el, value) - } else if ((value = normalizeClass(value)) !== el.$cls) { - el.className = el.$cls = value + } else { + value = normalizeClass(value) + if ( + (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + isHydrating && + !classHasMismatch(el, value, false) + ) { + el.$cls = value + return + } + + if (value !== el.$cls) { + el.className = el.$cls = value + } } } function setClassIncremental(el: any, value: any): void { const cacheKey = `$clsi${isApplyingFallthroughProps ? '$' : ''}` + const normalizedValue = normalizeClass(value) + + if ( + (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + isHydrating && + !classHasMismatch(el, normalizedValue, true) + ) { + el[cacheKey] = normalizedValue + return + } + const prev = el[cacheKey] - if ((value = el[cacheKey] = normalizeClass(value)) !== prev) { + if ((value = el[cacheKey] = normalizedValue) !== prev) { const nextList = value.split(/\s+/) if (value) { el.classList.add(...nextList) @@ -137,20 +189,36 @@ export function setStyle(el: TargetElement, value: any): void { if (el.$root) { setStyleIncremental(el, value) } else { - const prev = el.$sty - value = el.$sty = normalizeStyle(value) - patchStyle(el, prev, value) + const normalizedValue = normalizeStyle(value) + if ( + (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + isHydrating && + !styleHasMismatch(el, value, normalizedValue, false) + ) { + el.$sty = normalizedValue + return + } + + patchStyle(el, el.$sty, (el.$sty = normalizedValue)) } } function setStyleIncremental(el: any, value: any): NormalizedStyle | undefined { const cacheKey = `$styi${isApplyingFallthroughProps ? '$' : ''}` - const prev = el[cacheKey] - value = el[cacheKey] = isString(value) + const normalizedValue = isString(value) ? parseStringStyle(value) : (normalizeStyle(value) as NormalizedStyle | undefined) - patchStyle(el, prev, value) - return value + + if ( + (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + isHydrating && + !styleHasMismatch(el, value, normalizedValue, true) + ) { + el[cacheKey] = normalizedValue + return + } + + patchStyle(el, el[cacheKey], (el[cacheKey] = normalizedValue)) } export function setValue(el: TargetElement, value: any): void { @@ -161,6 +229,15 @@ export function setValue(el: TargetElement, value: any): void { // store value as _value as well since // non-string values will be stringified. el._value = value + + if ( + (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + isHydrating && + !attributeHasMismatch(el, 'value', getClientText(el, value)) + ) { + return + } + // #4956: