]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
chore: hydration mismatch handling
authordaiwei <daiwei521@126.com>
Mon, 22 Sep 2025 06:16:50 +0000 (14:16 +0800)
committerdaiwei <daiwei521@126.com>
Mon, 22 Sep 2025 06:16:50 +0000 (14:16 +0800)
packages/runtime-core/src/hydration.ts
packages/runtime-core/src/index.ts
packages/runtime-vapor/__tests__/hydration.spec.ts
packages/runtime-vapor/src/directives/vShow.ts
packages/runtime-vapor/src/dom/hydration.ts
packages/runtime-vapor/src/dom/node.ts
packages/runtime-vapor/src/dom/prop.ts
packages/runtime-vapor/src/dom/template.ts

index e41a4fdc9c9e78c08ae9d933de9966b2392e97bb..34ae2180916fc53df003757f626ca14c065b30a1 100644 (file)
@@ -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<string> {
+export function toClassSet(str: string): Set<string> {
   return new Set(str.trim().split(/\s+/))
 }
 
-function isSetEqual(a: Set<string>, b: Set<string>): boolean {
+export function isSetEqual(a: Set<string>, b: Set<string>): boolean {
   if (a.size !== b.size) {
     return false
   }
@@ -936,7 +962,7 @@ function isSetEqual(a: Set<string>, b: Set<string>): boolean {
   return true
 }
 
-function toStyleMap(str: string): Map<string, string> {
+export function toStyleMap(str: string): Map<string, string> {
   const styleMap: Map<string, string> = new Map()
   for (const item of str.split(';')) {
     let [key, value] = item.split(':')
@@ -949,7 +975,10 @@ function toStyleMap(str: string): Map<string, string> {
   return styleMap
 }
 
-function isMapEqual(a: Map<string, string>, b: Map<string, string>): boolean {
+export function isMapEqual(
+  a: Map<string, string>,
+  b: Map<string, string>,
+): 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, string> = {
   [MismatchTypes.ATTRIBUTE]: 'attribute',
 } as const
 
-function isMismatchAllowed(
+export function isMismatchAllowed(
   el: Element | null,
   allowedType: MismatchTypes,
 ): boolean {
index 1c7fa5d78bebf4f03f81e87bc30edeb32716a7de..bbbd3d183b8620dca393bf87e2a14e30492071a5 100644 (file)
@@ -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
  */
index 1eb43e0bbaecae8e8833cb9690813a1ac37a93a7..f77d58c349ab2b7e9d33ff9b4efd5eda975aa596 100644 (file)
@@ -77,6 +77,26 @@ async function testWithVDOMApp(
   })
 }
 
+async function mountWithHydration(
+  html: string,
+  code: string,
+  data: runtimeDom.Ref<any>,
+) {
+  const container = document.createElement('div')
+  container.innerHTML = html
+
+  const clientComp = compile(`<template>${code}</template>`, data, undefined, {
+    vapor: true,
+    ssr: false,
+  })
+  const app = createVaporSSRApp(clientComp)
+  app.mount(container)
+
+  return {
+    container,
+  }
+}
+
 async function testHydration(
   code: string,
   components: Record<string, string | { code: string; vapor: boolean }> = {},
@@ -233,7 +253,7 @@ describe('Vapor Mode hydration', () => {
         data,
       )
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
-        `"<div> </div>"`,
+        `"<div></div>"`,
       )
 
       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(
+      `<div>foo</div>`,
+      `<div v-bind="data"></div>`,
+      data,
+    )
+    expect(container.innerHTML).toBe('<div>bar</div>')
+    expect(`Hydration text content mismatch`).toHaveBeenWarned()
+  })
+
+  test('element with v-html', async () => {
+    const data = ref('<p>bar</p>')
+    const { container } = await mountWithHydration(
+      `<div><p>foo</p></div>`,
+      `<div v-html="data"></div>`,
+      data,
+    )
+    expect(container.innerHTML).toBe('<div><p>bar</p></div>')
+    expect(`Hydration children mismatch on`).toHaveBeenWarned()
+  })
+  // test('not enough children', () => {
+  //   const { container } = mountWithHydration(`<div></div>`, () =>
+  //     h('div', [h('span', 'foo'), h('span', 'bar')]),
+  //   )
+  //   expect(container.innerHTML).toBe(
+  //     '<div><span>foo</span><span>bar</span></div>',
+  //   )
+  //   expect(`Hydration children mismatch`).toHaveBeenWarned()
+  // })
+  // test('too many children', () => {
+  //   const { container } = mountWithHydration(
+  //     `<div><span>foo</span><span>bar</span></div>`,
+  //     () => h('div', [h('span', 'foo')]),
+  //   )
+  //   expect(container.innerHTML).toBe('<div><span>foo</span></div>')
+  //   expect(`Hydration children mismatch`).toHaveBeenWarned()
+  // })
+  test('complete mismatch', async () => {
+    const data = ref('span')
+    const { container } = await mountWithHydration(
+      `<div>foo</div>`,
+      `<component :is="data">foo</component>`,
+      data,
+    )
+    expect(container.innerHTML).toBe('<span>foo</span><!--dynamic-component-->')
+    expect(`Hydration node mismatch`).toHaveBeenWarned()
+  })
+  // test('fragment mismatch removal', () => {
+  //   const { container } = mountWithHydration(
+  //     `<div><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
+  //     () => h('div', [h('span', 'replaced')]),
+  //   )
+  //   expect(container.innerHTML).toBe('<div><span>replaced</span></div>')
+  //   expect(`Hydration node mismatch`).toHaveBeenWarned()
+  // })
+  // test('fragment not enough children', () => {
+  //   const { container } = mountWithHydration(
+  //     `<div><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
+  //     () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]),
+  //   )
+  //   expect(container.innerHTML).toBe(
+  //     '<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>',
+  //   )
+  //   expect(`Hydration node mismatch`).toHaveBeenWarned()
+  // })
+  // test('fragment too many children', () => {
+  //   const { container } = mountWithHydration(
+  //     `<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
+  //     () => h('div', [[h('div', 'foo')], h('div', 'baz')]),
+  //   )
+  //   expect(container.innerHTML).toBe(
+  //     '<div><!--[--><div>foo</div><!--]--><div>baz</div></div>',
+  //   )
+  //   // fragment ends early and attempts to hydrate the extra <div>bar</div>
+  //   // 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('<!--teleport start--><!--teleport end-->', () =>
+  //     h(Teleport, { to: '#teleport' }, [h('span', 'value')]),
+  //   )
+  //   expect(teleportContainer.innerHTML).toBe(`<span>value</span>`)
+  //   expect(`Hydration children mismatch`).toHaveBeenWarned()
+  // })
+  // test('comment mismatch (element)', () => {
+  //   const { container } = mountWithHydration(`<div><span></span></div>`, () =>
+  //     h('div', [createCommentVNode('hi')]),
+  //   )
+  //   expect(container.innerHTML).toBe('<div><!--hi--></div>')
+  //   expect(`Hydration node mismatch`).toHaveBeenWarned()
+  // })
+  // test('comment mismatch (text)', () => {
+  //   const { container } = mountWithHydration(`<div>foobar</div>`, () =>
+  //     h('div', [createCommentVNode('hi')]),
+  //   )
+  //   expect(container.innerHTML).toBe('<div><!--hi--></div>')
+  //   expect(`Hydration node mismatch`).toHaveBeenWarned()
+  // })
+  test('class mismatch', async () => {
+    await mountWithHydration(
+      `<div class="foo bar"></div>`,
+      `<div :class="data"></div>`,
+      ref(['foo', 'bar']),
+    )
+
+    await mountWithHydration(
+      `<div class="foo bar"></div>`,
+      `<div :class="data"></div>`,
+      ref({ foo: true, bar: true }),
+    )
+
+    await mountWithHydration(
+      `<div class="foo bar"></div>`,
+      `<div :class="data"></div>`,
+      ref('foo bar'),
+    )
+
+    // svg classes
+    await mountWithHydration(
+      `<svg class="foo bar"></svg>`,
+      `<svg :class="data"></svg>`,
+      ref('foo bar'),
+    )
+
+    // class with different order
+    await mountWithHydration(
+      `<div class="foo bar"></div>`,
+      `<div :class="data"></div>`,
+      ref('bar foo'),
+    )
+    expect(`Hydration class mismatch`).not.toHaveBeenWarned()
+
+    // single root mismatch
+    const { container: root } = await mountWithHydration(
+      `<div class="foo bar"></div>`,
+      `<div :class="data"></div>`,
+      ref('baz'),
+    )
+    expect(root.innerHTML).toBe('<div class="foo bar baz"></div>')
+    expect(`Hydration class mismatch`).toHaveBeenWarned()
+
+    // multiple root mismatch
+    const { container } = await mountWithHydration(
+      `<div class="foo bar"></div><span/>`,
+      `<div :class="data"></div><span/>`,
+      ref('foo'),
+    )
+    expect(container.innerHTML).toBe('<div class="foo"></div><span></span>')
+    expect(`Hydration class mismatch`).toHaveBeenWarned()
+  })
+
+  test('style mismatch', async () => {
+    await mountWithHydration(
+      `<div style="color:red;"></div>`,
+      `<div :style="data"></div>`,
+      ref({ color: 'red' }),
+    )
+
+    await mountWithHydration(
+      `<div style="color:red;"></div>`,
+      `<div :style="data"></div>`,
+      ref('color:red;'),
+    )
+
+    // style with different order
+    await mountWithHydration(
+      `<div style="color:red; font-size: 12px;"></div>`,
+      `<div :style="data"></div>`,
+      ref(`font-size: 12px; color:red;`),
+    )
+
+    expect(`Hydration style mismatch`).not.toHaveBeenWarned()
+
+    // single root mismatch
+    const { container: root } = await mountWithHydration(
+      `<div style="color:red;"></div>`,
+      `<div :style="data"></div>`,
+      ref({ color: 'green' }),
+    )
+    expect(root.innerHTML).toBe('<div style="color: green;"></div>')
+    expect(`Hydration style mismatch`).toHaveBeenWarned()
+
+    // multiple root mismatch
+    const { container } = await mountWithHydration(
+      `<div style="color:red;"></div><span/>`,
+      `<div :style="data"></div><span/>`,
+      ref({ color: 'green' }),
+    )
+    expect(container.innerHTML).toBe(
+      '<div style="color: green;"></div><span></span>',
+    )
+    expect(`Hydration style mismatch`).toHaveBeenWarned()
+  })
+
+  test('style mismatch when no style attribute is present', async () => {
+    await mountWithHydration(
+      `<div></div>`,
+      `<div :style="data"></div>`,
+      ref({ color: 'red' }),
+    )
+    expect(`Hydration style mismatch`).toHaveBeenWarnedTimes(1)
+  })
+
+  test('style mismatch w/ v-show', async () => {
+    await mountWithHydration(
+      `<div style="color:red;display:none"></div>`,
+      `<div v-show="data" style="color: red;"></div>`,
+      ref(false),
+    )
+    expect(`Hydration style mismatch`).not.toHaveBeenWarned()
+
+    // mismatch with single root
+    const { container: root } = await mountWithHydration(
+      `<div style="color:red;"></div>`,
+      `<div v-show="data" style="color: red;"></div>`,
+      ref(false),
+    )
+    expect(root.innerHTML).toBe(
+      '<div style="color: red; display: none;"></div>',
+    )
+    expect(`Hydration style mismatch`).toHaveBeenWarned()
+
+    // mismatch with multiple root
+    const { container } = await mountWithHydration(
+      `<div style="color:red;"></div><span/>`,
+      `<div v-show="data.show" :style="data.style"></div><span/>`,
+      ref({ show: false, style: 'color: red' }),
+    )
+    expect(container.innerHTML).toBe(
+      '<div style="color: red; display: none;"></div><span></span>',
+    )
+    expect(`Hydration style mismatch`).toHaveBeenWarned()
+  })
+
+  test('attr mismatch', async () => {
+    await mountWithHydration(
+      `<div id="foo"></div>`,
+      `<div :id="data"></div>`,
+      ref('foo'),
+    )
+
+    await mountWithHydration(
+      `<div spellcheck></div>`,
+      `<div :spellcheck="data"></div>`,
+      ref(''),
+    )
+
+    await mountWithHydration(
+      `<div></div>`,
+      `<div :id="data"></div>`,
+      ref(undefined),
+    )
+
+    // boolean
+    await mountWithHydration(
+      `<select multiple></div>`,
+      `<select :multiple="data"></select>`,
+      ref(true),
+    )
+
+    await mountWithHydration(
+      `<select multiple></div>`,
+      `<select :multiple="data"></select>`,
+      ref('multiple'),
+    )
+
+    expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+    await mountWithHydration(
+      `<div></div>`,
+      `<div :id="data"></div>`,
+      ref('foo'),
+    )
+    expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(1)
+
+    await mountWithHydration(
+      `<div id="bar"></div>`,
+      `<div :id="data"></div>`,
+      ref('foo'),
+    )
+    expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(2)
+  })
+
+  test('attr special case: textarea value', async () => {
+    await mountWithHydration(
+      `<textarea>foo</textarea>`,
+      `<textarea :value="data"></textarea>`,
+      ref('foo'),
+    )
+
+    await mountWithHydration(
+      `<textarea></textarea>`,
+      `<textarea :value="data"></textarea>`,
+      ref(''),
+    )
+    expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+
+    await mountWithHydration(
+      `<textarea>foo</textarea>`,
+      `<textarea :value="data"></textarea>`,
+      ref('bar'),
+    )
+    expect(`Hydration attribute mismatch`).toHaveBeenWarned()
+  })
+
+  test('<textarea> with newlines at the beginning', async () => {
+    await mountWithHydration(
+      `<textarea>\nhello</textarea>`,
+      `<textarea :value="data"></textarea>`,
+      ref('\nhello'),
+    )
+
+    await mountWithHydration(
+      `<textarea>\nhello</textarea>`,
+      `<textarea v-text="data"></textarea>`,
+      ref('\nhello'),
+    )
+
+    await mountWithHydration(
+      `<textarea>\nhello</textarea>`,
+      `<textarea v-bind="data"></textarea>`,
+      ref({ textContent: '\nhello' }),
+    )
+    expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('<pre> with newlines at the beginning', async () => {
+    await mountWithHydration(`<pre>\n</pre>`, `<pre>{{data}}</pre>`, ref('\n'))
+
+    await mountWithHydration(
+      `<pre>\n</pre>`,
+      `<pre v-text="data"></pre>`,
+      ref('\n'),
+    )
+
+    await mountWithHydration(
+      `<pre>\n</pre>`,
+      `<pre v-bind="data"></pre>`,
+      ref({ textContent: '\n' }),
+    )
+    expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('boolean attr handling', async () => {
+    await mountWithHydration(
+      `<input />`,
+      `<input :readonly="data" />`,
+      ref(false),
+    )
+
+    await mountWithHydration(
+      `<input readonly />`,
+      `<input :readonly="data" />`,
+      ref(true),
+    )
+
+    await mountWithHydration(
+      `<input readonly="readonly" />`,
+      `<input :readonly="data" />`,
+      ref(true),
+    )
+    expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('client value is null or undefined', async () => {
+    await mountWithHydration(
+      `<div></div>`,
+      `<div :draggable="data"></div>`,
+      ref(undefined),
+    )
+    expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+    await mountWithHydration(`<input />`, `<input :type="data" />`, ref(null))
+    expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('should not warn against object values', async () => {
+    await mountWithHydration(`<input />`, `<input :from="data" />`, ref({}))
+    expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('should not warn on falsy bindings of non-property keys', async () => {
+    await mountWithHydration(
+      `<button></button>`,
+      `<button :href="data"></button>`,
+      ref(undefined),
+    )
+    expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('should not warn on non-renderable option values', async () => {
+    await mountWithHydration(
+      `<select><option>hello</option></select>`,
+      `<select><option :value="data">hello</option></select>`,
+      ref(['foo']),
+    )
+    expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+  })
+
+  test.todo('should not warn css v-bind', () => {
+    // const container = document.createElement('div')
+    // container.innerHTML = `<div style="--foo:red;color:var(--foo);" />`
+    // 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 = `<div style="--foo:red;"><div style="color:var(--foo);" /></div>`
+      // 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 = `<div style="padding: 4px;--foo:red;"></div>`
+    // 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 = `<div class="test red"></div>`
+    // 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 = `<div style="padding: 4px;--foo\\.bar:red;"></div>`
+    // 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(
+      `<div data-allow-mismatch="text">foo</div>`,
+      `<div v-bind="data"></div>`,
+      data,
+    )
+    expect(container.innerHTML).toBe(
+      '<div data-allow-mismatch="text">bar</div>',
+    )
+    expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
+  })
+  // test('not enough children', () => {
+  //   const { container } = mountWithHydration(
+  //     `<div data-allow-mismatch="children"></div>`,
+  //     () => h('div', [h('span', 'foo'), h('span', 'bar')]),
+  //   )
+  //   expect(container.innerHTML).toBe(
+  //     '<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>',
+  //   )
+  //   expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+  // })
+  // test('too many children', () => {
+  //   const { container } = mountWithHydration(
+  //     `<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>`,
+  //     () => h('div', [h('span', 'foo')]),
+  //   )
+  //   expect(container.innerHTML).toBe(
+  //     '<div data-allow-mismatch="children"><span>foo</span></div>',
+  //   )
+  //   expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+  // })
+  test('complete mismatch', async () => {
+    const { container } = await mountWithHydration(
+      `<div data-allow-mismatch="children"><div>foo</div></div>`,
+      `<div><component :is="data">foo</component></div>`,
+      ref('span'),
+    )
+    expect(container.innerHTML).toBe(
+      '<div data-allow-mismatch="children"><span>foo</span><!--dynamic-component--></div>',
+    )
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+  })
+  // test('fragment mismatch removal', () => {
+  //   const { container } = mountWithHydration(
+  //     `<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
+  //     () => h('div', [h('span', 'replaced')]),
+  //   )
+  //   expect(container.innerHTML).toBe(
+  //     '<div data-allow-mismatch="children"><span>replaced</span></div>',
+  //   )
+  //   expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+  // })
+  // test('fragment not enough children', () => {
+  //   const { container } = mountWithHydration(
+  //     `<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
+  //     () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]),
+  //   )
+  //   expect(container.innerHTML).toBe(
+  //     '<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>',
+  //   )
+  //   expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+  // })
+  // test('fragment too many children', () => {
+  //   const { container } = mountWithHydration(
+  //     `<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
+  //     () => h('div', [[h('div', 'foo')], h('div', 'baz')]),
+  //   )
+  //   expect(container.innerHTML).toBe(
+  //     '<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>',
+  //   )
+  //   // fragment ends early and attempts to hydrate the extra <div>bar</div>
+  //   // 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(
+  //     `<div data-allow-mismatch="children"><span></span></div>`,
+  //     () => h('div', [createCommentVNode('hi')]),
+  //   )
+  //   expect(container.innerHTML).toBe(
+  //     '<div data-allow-mismatch="children"><!--hi--></div>',
+  //   )
+  //   expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+  // })
+  // test('comment mismatch (text)', () => {
+  //   const { container } = mountWithHydration(
+  //     `<div data-allow-mismatch="children">foobar</div>`,
+  //     () => h('div', [createCommentVNode('hi')]),
+  //   )
+  //   expect(container.innerHTML).toBe(
+  //     '<div data-allow-mismatch="children"><!--hi--></div>',
+  //   )
+  //   expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+  // })
+  test('class mismatch', async () => {
+    await mountWithHydration(
+      `<div class="foo bar" data-allow-mismatch="class"></div>`,
+      `<div :class="data"></div>`,
+      ref('foo'),
+    )
+    expect(`Hydration class mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('style mismatch', async () => {
+    await mountWithHydration(
+      `<div style="color:red;" data-allow-mismatch="style"></div>`,
+      `<div :style="data"></div>`,
+      ref({ color: 'green' }),
+    )
+    expect(`Hydration style mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('attr mismatch', async () => {
+    await mountWithHydration(
+      `<div data-allow-mismatch="attribute"></div>`,
+      `<div :id="data"></div>`,
+      ref('foo'),
+    )
+
+    await mountWithHydration(
+      `<div id="bar" data-allow-mismatch="attribute"></div>`,
+      `<div :id="data"></div>`,
+      ref('foo'),
+    )
+
+    expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+  })
+})
+
 describe('VDOM interop', () => {
   test('basic render vapor component', async () => {
     const data = ref(true)
index d7dd22d6781a6e04082e6af0aa38ac510276c82c..782d05e156a4d5af9410982d2a770c528ad06a4a 100644 (file)
@@ -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 ` +
index 704494bad3c828c6c38805387f505796abef0137..ef1fb1375ae68f9a7cc4388d014085d224ab6a34 100644 (file)
@@ -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('<!')) ||
-      (type === 1 &&
-        !template.startsWith(`<` + (node as Element).tagName.toLowerCase())) ||
-      (type === 3 &&
-        template.trim() &&
-        !template.startsWith((node as Text).data))
-    ) {
-      // TODO recover and provide more info
-      warn(`adopted: `, node)
-      warn(`template: ${template}`)
-      warn('hydration mismatch!')
-    }
+  const type = node.nodeType
+  if (
+    // comment node
+    (type === 8 && !template.startsWith('<!')) ||
+    // element node
+    (type === 1 &&
+      !template.startsWith(`<` + (node as Element).tagName.toLowerCase()))
+  ) {
+    node = handleMismatch(node, template)
   }
 
   advanceHydrationNode(node)
@@ -210,8 +210,10 @@ function locateHydrationNodeImpl(): void {
   }
 
   if (__DEV__ && !node) {
-    // TODO more info
-    warn('Hydration mismatch in ', insertionParent)
+    throw new Error(
+      `No current hydration node was found.\n` +
+        `this is likely a Vue internal bug.`,
+    )
   }
 
   resetInsertionState()
@@ -252,3 +254,64 @@ export function locateFragmentEndAnchor(label: string = ']'): Comment | null {
   }
   return null
 }
+
+function handleMismatch(node: Node, template: string): Node {
+  if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
+    ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
+      warn(
+        `Hydration node mismatch:\n- rendered on server:`,
+        node,
+        node.nodeType === 3
+          ? `(text)`
+          : isComment(node, '[[')
+            ? `(start of block node)`
+            : ``,
+        `\n- expected on client:`,
+        template,
+      )
+    logMismatchError()
+  }
+
+  // fragment
+  if (isComment(node, '[')) {
+    const end = locateEndAnchor(node as Anchor)
+    while (true) {
+      const next = _next(node)
+      if (next && next !== end) {
+        remove(next, parentNode(node)!)
+      } else {
+        break
+      }
+    }
+  }
+
+  const next = _next(node)
+  const container = parentNode(node)!
+  remove(node, container)
+
+  // fast path for text nodes
+  if (template[0] !== '<') {
+    return container.insertBefore(createTextNode(template), next)
+  }
+
+  // element node
+  const t = createElement('template') as HTMLTemplateElement
+  t.innerHTML = template
+  const newNode = child(t.content).cloneNode(true) as Element
+  newNode.innerHTML = (node as Element).innerHTML
+  Array.from((node as Element).attributes).forEach(attr => {
+    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
+}
index df291e40e7c3690d93a617ffc507e7a926464bf3..945c55fe02e81806ee098e5f8149dad7ebce1f75 100644 (file)
@@ -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
index d9a38fc39d46180847be28728ee6d780509c8385..22189c822b3e0497235eb8953e889716dbf1069e 100644 (file)
@@ -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: <option> value will fallback to its text content so we need to
   // compare against its attribute value instead.
   const oldValue = el.tagName === 'OPTION' ? el.getAttribute('value') : el.value
@@ -179,28 +256,81 @@ export function setValue(el: TargetElement, value: any): void {
  * `toDisplayString`
  */
 export function setText(el: Text & { $txt?: string }, value: string): void {
+  if (isHydrating) {
+    const clientText = getClientText(el.parentNode!, value)
+    if (el.nodeValue == clientText) {
+      el.$txt = clientText
+      return
+    }
+
+    ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
+      warn(
+        `Hydration text mismatch in`,
+        el.parentNode,
+        `\n  - rendered on server: ${JSON.stringify((el as Text).data)}` +
+          `\n  - expected on client: ${JSON.stringify(value)}`,
+      )
+    logMismatchError()
+  }
+
   if (el.$txt !== value) {
     el.nodeValue = el.$txt = value
   }
 }
 
 /**
- * Used by
- * - setDynamicProps, need to guard with `toDisplayString`
- * - v-text on dynamic component, value passed here is already converted
+ * Used by setDynamicProps only, so need to guard with `toDisplayString`
  */
 export function setElementText(
   el: Node & { $txt?: string },
   value: unknown,
-  isConverted: boolean = false,
 ): void {
-  if (el.$txt !== (value = isConverted ? value : toDisplayString(value))) {
+  value = toDisplayString(value)
+  if (isHydrating) {
+    let clientText = getClientText(el, value as string)
+    if (el.textContent === clientText) {
+      el.$txt = clientText
+      return
+    }
+
+    if (!isMismatchAllowed(el as Element, MismatchTypes.TEXT)) {
+      ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
+        warn(
+          `Hydration text content mismatch on`,
+          el,
+          `\n  - rendered on server: ${el.textContent}` +
+            `\n  - expected on client: ${clientText}`,
+        )
+      logMismatchError()
+    }
+  }
+
+  if (el.$txt !== value) {
     el.textContent = el.$txt = value as string
   }
 }
 
 export function setHtml(el: TargetElement, value: any): void {
   value = value == null ? '' : value
+
+  if (isHydrating) {
+    if (el.innerHTML === value) {
+      el.$html = value
+      return
+    }
+
+    if (!isMismatchAllowed(el, MismatchTypes.CHILDREN)) {
+      if (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) {
+        warn(
+          `Hydration children mismatch on`,
+          el,
+          `\nServer rendered element contains different child nodes from client nodes.`,
+        )
+      }
+      logMismatchError()
+    }
+  }
+
   if (el.$html !== value) {
     el.innerHTML = el.$html = value
   }
@@ -285,3 +415,83 @@ export function optimizePropertyLookup(): void {
     (Text.prototype as any).$txt =
       ''
 }
+
+function classHasMismatch(
+  el: TargetElement | any,
+  expected: string,
+  isIncremental: boolean,
+): boolean {
+  const actual = el.getAttribute('class')
+  const actualClassSet = toClassSet(actual || '')
+  const expectedClassSet = toClassSet(expected)
+
+  const hasMismatch = isIncremental
+    ? // check if the expected classes are present in the actual classes
+      Array.from(expectedClassSet).some(cls => !actualClassSet.has(cls))
+    : !isSetEqual(actualClassSet, expectedClassSet)
+
+  if (hasMismatch) {
+    warnPropMismatch(el, 'class', MismatchTypes.CLASS, actual, expected)
+    logMismatchError()
+    return true
+  }
+
+  return false
+}
+
+function styleHasMismatch(
+  el: TargetElement | any,
+  value: any,
+  normalizedValue: string | NormalizedStyle | undefined,
+  isIncremental: boolean,
+): boolean {
+  const actual = el.getAttribute('style')
+  const actualStyleMap = toStyleMap(actual || '')
+  const expected = isString(value) ? value : stringifyStyle(normalizedValue)
+  const expectedStyleMap = toStyleMap(expected)
+
+  // If `v-show=false`, `display: 'none'` should be added to expected
+  if (el[vShowHidden]) {
+    expectedStyleMap.set('display', 'none')
+  }
+
+  // TODO: handle css vars
+
+  const hasMismatch = isIncremental
+    ? // check if the expected styles are present in the actual styles
+      Array.from(expectedStyleMap.entries()).some(
+        ([key, val]) => actualStyleMap.get(key) !== val,
+      )
+    : !isMapEqual(actualStyleMap, expectedStyleMap)
+
+  if (hasMismatch) {
+    warnPropMismatch(el, 'style', MismatchTypes.STYLE, actual, expected)
+    logMismatchError()
+    return true
+  }
+
+  return false
+}
+
+function attributeHasMismatch(el: any, key: string, value: any): boolean {
+  if (isValidHtmlOrSvgAttribute(el, key)) {
+    const { actual, expected } = getAttributeMismatch(el, key, value)
+    if (actual !== expected) {
+      warnPropMismatch(el, key, MismatchTypes.ATTRIBUTE, actual, expected)
+      logMismatchError()
+      return true
+    }
+  }
+  return false
+}
+
+function getClientText(el: Node, value: string): string {
+  if (
+    value[0] === '\n' &&
+    ((el as Element).tagName === 'PRE' ||
+      (el as Element).tagName === 'TEXTAREA')
+  ) {
+    value = value.slice(1)
+  }
+  return value
+}
index 9c4b3ca1d355ce489af887c077f825d278145714..2022cf339d498742af9d56e9abdfc2c4eac3e14f 100644 (file)
@@ -19,10 +19,7 @@ export function template(
   const fn = () => {
     if (isHydrating) {
       currentTemplateFn = fn
-      if (__DEV__ && !currentHydrationNode) {
-        // TODO this should not happen
-        throw new Error('No current hydration node')
-      }
+
       // do not cache the adopted node in node because it contains child nodes
       // this avoids duplicate rendering of children
       const adopted = adoptTemplate(currentHydrationNode!, html)!