]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
test: enhance hydration tests for CSS variable handling and mismatch scenarios
authordaiwei <daiwei521@126.com>
Thu, 13 Nov 2025 08:24:01 +0000 (16:24 +0800)
committerdaiwei <daiwei521@126.com>
Thu, 13 Nov 2025 08:24:01 +0000 (16:24 +0800)
packages/runtime-vapor/__tests__/hydration.spec.ts
packages/runtime-vapor/src/dom/prop.ts

index 7094a0f60af7f6e19df41d4616f08c6db6907d7a..00faacb009a2284c6499cd7caded3019fb9720ff 100644 (file)
@@ -1,7 +1,14 @@
 import {
+  child,
+  createComponent,
   createVaporSSRApp,
   defineVaporAsyncComponent,
+  defineVaporComponent,
   delegateEvents,
+  renderEffect,
+  setStyle,
+  template,
+  useVaporCssVars,
 } from '../src'
 import { defineAsyncComponent, nextTick, reactive, ref } from '@vue/runtime-dom'
 import { isString } from '@vue/shared'
@@ -4437,58 +4444,55 @@ describe('mismatch handling', () => {
     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('should not warn css v-bind', async () => {
+    const container = document.createElement('div')
+    container.innerHTML = `<div style="--foo:red;color:var(--foo);" />`
+    const app = createVaporSSRApp({
+      setup() {
+        useVaporCssVars(() => ({ foo: 'red' }))
+        const n0 = template('<div></div>', true)() as any
+        renderEffect(() => setStyle(n0, { color: 'var(--foo)' }))
+        return n0
+      },
+    })
+    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('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 = createVaporSSRApp({
+      setup() {
+        useVaporCssVars(() => ({ foo: 'red' }))
+        const n0 = template('<div><div></div></div>', true)() as any
+        const n1 = child(n0) as any
+        renderEffect(() => setStyle(n1, { color: 'var(--foo)' }))
+        return n0
+      },
+    })
+    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()
+  test('css vars support fallthrough', () => {
+    const container = document.createElement('div')
+    container.innerHTML = `<div style="padding: 4px;--foo:red;"></div>`
+    const app = createVaporSSRApp({
+      setup() {
+        useVaporCssVars(() => ({ foo: 'red' }))
+        return createComponent(Child)
+      },
+    })
+    const Child = defineVaporComponent({
+      setup() {
+        const n0 = template('<div></div>', true)() as any
+        renderEffect(() => setStyle(n0, { padding: '4px' }))
+        return n0
+      },
+    })
+    app.mount(container)
+    expect(`Hydration style mismatch`).not.toHaveBeenWarned()
   })
 
   // vapor directive does not have a created hook
@@ -4510,24 +4514,24 @@ describe('mismatch handling', () => {
     // 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()
+  test('escape css var name', () => {
+    const container = document.createElement('div')
+    container.innerHTML = `<div style="padding: 4px;--foo\\.bar:red;"></div>`
+    const app = createVaporSSRApp({
+      setup() {
+        useVaporCssVars(() => ({ 'foo.bar': 'red' }))
+        return createComponent(Child)
+      },
+    })
+    const Child = defineVaporComponent({
+      setup() {
+        const n0 = template('<div></div>', true)() as any
+        renderEffect(() => setStyle(n0, { padding: '4px' }))
+        return n0
+      },
+    })
+    app.mount(container)
+    expect(`Hydration style mismatch`).not.toHaveBeenWarned()
   })
 })
 
index c7f9631509f22dfdcb352d98d7df1987f36a3bf7..4b067fa2406befea17e2adb0975ce90cdbc67a94 100644 (file)
@@ -2,11 +2,13 @@ import {
   type NormalizedStyle,
   camelize,
   canSetValueDirectly,
+  getEscapedCssVarName,
   includeBooleanAttr,
   isArray,
   isOn,
   isString,
   normalizeClass,
+  normalizeCssVarValue,
   normalizeStyle,
   parseStringStyle,
   stringifyStyle,
@@ -14,6 +16,7 @@ import {
 } from '@vue/shared'
 import { on } from './event'
 import {
+  type GenericComponentInstance,
   MismatchTypes,
   currentInstance,
   getAttributeMismatch,
@@ -23,6 +26,7 @@ import {
   isValidHtmlOrSvgAttribute,
   mergeProps,
   patchStyle,
+  queuePostFlushCb,
   shouldSetAsProp,
   toClassSet,
   toStyleMap,
@@ -38,7 +42,7 @@ import {
   isVaporComponent,
 } from '../component'
 import { isHydrating, logMismatchError } from './hydration'
-import type { Block } from '../block'
+import { type Block, normalizeBlock } from '../block'
 import type { VaporElement } from '../apiDefineVaporCustomElement'
 
 type TargetElement = Element & {
@@ -224,6 +228,21 @@ function setClassIncremental(el: any, value: any): void {
   }
 }
 
+/**
+ * dev only
+ * if a component uses style v-bind, or an el's style contains style vars (potentially
+ * fallthrough from parent components), then style matching checks must be deferred until
+ * after hydration (when instance.block is set). At this point, it can be confirmed
+ * whether the el is at the root level of the component.
+ */
+function shouldDeferCheckStyleMismatch(el: TargetElement): boolean {
+  return (
+    __DEV__ &&
+    (!!currentInstance!.getCssVars ||
+      Object.values((el as HTMLElement).style).some(v => v.startsWith('--')))
+  )
+}
+
 export function setStyle(el: TargetElement, value: any): void {
   if (el.$root) {
     setStyleIncremental(el, value)
@@ -231,11 +250,22 @@ export function setStyle(el: TargetElement, value: any): void {
     const normalizedValue = normalizeStyle(value)
     if (
       (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
-      isHydrating &&
-      !styleHasMismatch(el, value, normalizedValue, false)
+      isHydrating
     ) {
-      el.$sty = normalizedValue
-      return
+      if (shouldDeferCheckStyleMismatch(el)) {
+        const instance = currentInstance as VaporComponentInstance
+        queuePostFlushCb(() => {
+          if (!styleHasMismatch(el, value, normalizedValue, false, instance)) {
+            el.$sty = normalizedValue
+            return
+          }
+          patchStyle(el, el.$sty, (el.$sty = normalizedValue))
+        })
+        return
+      } else if (!styleHasMismatch(el, value, normalizedValue, false)) {
+        el.$sty = normalizedValue
+        return
+      }
     }
 
     patchStyle(el, el.$sty, (el.$sty = normalizedValue))
@@ -248,13 +278,21 @@ function setStyleIncremental(el: any, value: any): NormalizedStyle | undefined {
     ? parseStringStyle(value)
     : (normalizeStyle(value) as NormalizedStyle | undefined)
 
-  if (
-    (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
-    isHydrating &&
-    !styleHasMismatch(el, value, normalizedValue, true)
-  ) {
-    el[cacheKey] = normalizedValue
-    return
+  if ((__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && isHydrating) {
+    if (shouldDeferCheckStyleMismatch(el)) {
+      const instance = currentInstance as VaporComponentInstance
+      queuePostFlushCb(() => {
+        if (!styleHasMismatch(el, value, normalizedValue, true, instance)) {
+          el[cacheKey] = normalizedValue
+          return
+        }
+        patchStyle(el, el[cacheKey], (el[cacheKey] = normalizedValue))
+      })
+      return
+    } else if (!styleHasMismatch(el, value, normalizedValue, true)) {
+      el[cacheKey] = normalizedValue
+      return
+    }
   }
 
   patchStyle(el, el[cacheKey], (el[cacheKey] = normalizedValue))
@@ -554,6 +592,7 @@ function styleHasMismatch(
   value: any,
   normalizedValue: string | NormalizedStyle | undefined,
   isIncremental: boolean,
+  instance = currentInstance,
 ): boolean {
   const actual = el.getAttribute('style')
   const actualStyleMap = toStyleMap(actual || '')
@@ -565,7 +604,10 @@ function styleHasMismatch(
     expectedStyleMap.set('display', 'none')
   }
 
-  // TODO: handle css vars
+  // handle css vars
+  if (instance) {
+    resolveCssVars(instance as VaporComponentInstance, el, expectedStyleMap)
+  }
 
   let hasMismatch: boolean = false
   if (isIncremental) {
@@ -588,6 +630,44 @@ function styleHasMismatch(
   return false
 }
 
+/**
+ * dev only
+ */
+function resolveCssVars(
+  instance: VaporComponentInstance,
+  block: Block,
+  expectedMap: Map<string, string>,
+): void {
+  if (__DEV__ && !instance.isMounted) {
+    throw new Error(
+      'resolveCssVars should NOT be called before component is mounted',
+    )
+  }
+
+  const rootBlocks = normalizeBlock(instance)
+  if (
+    (instance as GenericComponentInstance).getCssVars &&
+    normalizeBlock(block).every(b => rootBlocks.includes(b))
+  ) {
+    const cssVars = (instance as GenericComponentInstance).getCssVars!()
+    for (const key in cssVars) {
+      const value = normalizeCssVarValue(cssVars[key])
+      expectedMap.set(`--${getEscapedCssVarName(key, false)}`, value)
+    }
+  }
+
+  if (
+    normalizeBlock(block).every(b => rootBlocks.includes(b)) &&
+    instance.parent
+  ) {
+    resolveCssVars(
+      instance.parent as VaporComponentInstance,
+      instance.block,
+      expectedMap,
+    )
+  }
+}
+
 function attributeHasMismatch(el: any, key: string, value: any): boolean {
   if (isValidHtmlOrSvgAttribute(el, key)) {
     const { actual, expected } = getAttributeMismatch(el, key, value)