]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(runtime-vapor): support `v-bind()` in CSS (#12621)
authoredison <daiwei521@126.com>
Thu, 20 Nov 2025 13:07:32 +0000 (21:07 +0800)
committerGitHub <noreply@github.com>
Thu, 20 Nov 2025 13:07:32 +0000 (21:07 +0800)
14 files changed:
packages/compiler-sfc/src/compileScript.ts
packages/compiler-sfc/src/style/cssVars.ts
packages/runtime-core/src/component.ts
packages/runtime-dom/src/helpers/useCssVars.ts
packages/runtime-dom/src/index.ts
packages/runtime-vapor/__tests__/helpers/useCssVars.spec.ts [new file with mode: 0644]
packages/runtime-vapor/__tests__/hydration.spec.ts
packages/runtime-vapor/src/apiCreateFor.ts
packages/runtime-vapor/src/components/Teleport.ts
packages/runtime-vapor/src/dom/prop.ts
packages/runtime-vapor/src/fragment.ts
packages/runtime-vapor/src/helpers/useCssVars.ts [new file with mode: 0644]
packages/runtime-vapor/src/index.ts
packages/runtime-vapor/src/vdomInterop.ts

index 022dd2ba0282984916b23ba56b637b63cadcc689..835daa2fc679c2b9c3bd300662352f944e045f56 100644 (file)
@@ -34,7 +34,7 @@ import {
   normalScriptDefaultVar,
   processNormalScript,
 } from './script/normalScript'
-import { CSS_VARS_HELPER, genCssVarsCode } from './style/cssVars'
+import { genCssVarsCode, getCssVarsHelper } from './style/cssVars'
 import {
   type SFCTemplateCompileOptions,
   compileTemplate,
@@ -825,7 +825,7 @@ export function compileScript(
     // no need to do this when targeting SSR
     !ssr
   ) {
-    ctx.helperImports.add(CSS_VARS_HELPER)
+    ctx.helperImports.add(getCssVarsHelper(vapor))
     ctx.helperImports.add('unref')
     ctx.s.prependLeft(
       startOffset,
@@ -834,6 +834,7 @@ export function compileScript(
         ctx.bindingMetadata,
         scopeId,
         !!options.isProd,
+        vapor,
       )}\n`,
     )
   }
index 313380c3b40397a7b4c7a2fe08a01ed3ead49e14..b626663b51874d37150f4c1b53d73cdeccb6e13d 100644 (file)
@@ -14,6 +14,10 @@ import { getEscapedCssVarName } from '@vue/shared'
 
 export const CSS_VARS_HELPER = `useCssVars`
 
+export function getCssVarsHelper(vapor: boolean | undefined): string {
+  return vapor ? `useVaporCssVars` : CSS_VARS_HELPER
+}
+
 export function genCssVarsFromList(
   vars: string[],
   id: string,
@@ -168,6 +172,7 @@ export function genCssVarsCode(
   bindings: BindingMetadata,
   id: string,
   isProd: boolean,
+  vapor?: boolean,
 ) {
   const varsExp = genCssVarsFromList(vars, id, isProd)
   const exp = createSimpleExpression(varsExp, false)
@@ -188,7 +193,7 @@ export function genCssVarsCode(
           })
           .join('')
 
-  return `_${CSS_VARS_HELPER}(_ctx => (${transformedString}))`
+  return `_${getCssVarsHelper(vapor)}(_ctx => (${transformedString}))`
 }
 
 // <script setup> already gets the calls injected as part of the transform
index 2bd8bc6209a1436c69ec0a5beaa7d7b0c52688cb..5d8f497291ee16678570031ec23224d22c40cfc9 100644 (file)
@@ -461,6 +461,17 @@ export interface GenericComponentInstance {
    * @internal
    */
   suspense: SuspenseBoundary | null
+  /**
+   * `updateTeleportCssVars`
+   * For updating css vars on contained teleports
+   * @internal
+   */
+  ut?: (vars?: Record<string, string>) => void
+  /**
+   * dev only. For style v-bind hydration mismatch checks
+   * @internal
+   */
+  getCssVars?: () => Record<string, string>
 
   // lifecycle
   /**
@@ -690,18 +701,6 @@ export interface ComponentInternalInstance extends GenericComponentInstance {
    * @internal
    */
   n?: () => Promise<void>
-  /**
-   * `updateTeleportCssVars`
-   * For updating css vars on contained teleports
-   * @internal
-   */
-  ut?: (vars?: Record<string, unknown>) => void
-
-  /**
-   * dev only. For style v-bind hydration mismatch checks
-   * @internal
-   */
-  getCssVars?: () => Record<string, unknown>
 
   /**
    * v2 compat only, for caching mutated $options
index 3032143d9a7e2e0c4231e2b643787f38309f1e07..a569e688a7dbe994ed5c57a8de35cca24abc8bd1 100644 (file)
@@ -1,5 +1,6 @@
 import {
   Fragment,
+  type GenericComponentInstance,
   Static,
   type VNode,
   getCurrentInstance,
@@ -22,51 +23,25 @@ export function useCssVars(
 ): void {
   if (!__BROWSER__ && !__TEST__) return
 
-  const instance = getCurrentInstance()
-  /* v8 ignore start */
-  if (!instance) {
-    __DEV__ &&
-      warn(`useCssVars is called without current active component instance.`)
-    return
-  }
-  /* v8 ignore stop */
-
-  const updateTeleports = (instance.ut = (vars = getter(instance.proxy)) => {
-    Array.from(
-      document.querySelectorAll(`[data-v-owner="${instance.uid}"]`),
-    ).forEach(node => setVarsOnNode(node, vars))
-  })
-
-  if (__DEV__) {
-    instance.getCssVars = () => getter(instance.proxy)
-  }
-
-  const setVars = () => {
-    const vars = getter(instance.proxy)
+  const instance = getCurrentInstance()! // to be check in baseUseCssVars
+  const getVars = () => getter(instance.proxy)
+  const setVars = (vars: Record<string, any>) => {
     if (instance.ce) {
       setVarsOnNode(instance.ce as any, vars)
     } else {
       setVarsOnVNode(instance.subTree, vars)
     }
-    updateTeleports(vars)
   }
 
-  // handle cases where child component root is affected
-  // and triggers reflow in onMounted
-  onBeforeUpdate(() => {
-    queuePostFlushCb(setVars)
-  })
-
-  onMounted(() => {
-    // run setVars synchronously here, but run as post-effect on changes
-    watch(setVars, NOOP, { flush: 'post' })
-    const ob = new MutationObserver(setVars)
-    ob.observe(instance.subTree.el!.parentNode, { childList: true })
-    onUnmounted(() => ob.disconnect())
-  })
+  baseUseCssVars(
+    instance as GenericComponentInstance,
+    () => instance.subTree.el!.parentNode!,
+    getVars,
+    setVars,
+  )
 }
 
-function setVarsOnVNode(vnode: VNode, vars: Record<string, unknown>) {
+function setVarsOnVNode(vnode: VNode, vars: Record<string, string>) {
   if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
     const suspense = vnode.suspense!
     vnode = suspense.activeBranch!
@@ -96,7 +71,60 @@ function setVarsOnVNode(vnode: VNode, vars: Record<string, unknown>) {
   }
 }
 
-function setVarsOnNode(el: Node, vars: Record<string, unknown>) {
+/**
+ * @internal
+ * shared between vdom and vapor
+ */
+export function baseUseCssVars(
+  instance: GenericComponentInstance | null,
+  getParentNode: () => Node,
+  getVars: () => Record<string, any>,
+  setVars: (vars: Record<string, any>) => void,
+): void {
+  /* v8 ignore start */
+  if (!instance) {
+    __DEV__ &&
+      warn(`useCssVars is called without current active component instance.`)
+    return
+  }
+  /* v8 ignore stop */
+
+  if (__DEV__) {
+    instance.getCssVars = getVars
+  }
+
+  const updateTeleports = (instance.ut = (vars = getVars()) => {
+    Array.from(
+      document.querySelectorAll(`[data-v-owner="${instance.uid}"]`),
+    ).forEach(node => setVarsOnNode(node, vars))
+  })
+
+  const applyCssCars = () => {
+    const vars = getVars()
+    setVars(vars)
+    updateTeleports(vars)
+  }
+
+  // handle cases where child component root is affected
+  // and triggers reflow in onMounted
+  onBeforeUpdate(() => {
+    queuePostFlushCb(applyCssCars)
+  })
+
+  onMounted(() => {
+    // run setVars synchronously here, but run as post-effect on changes
+    watch(applyCssCars, NOOP, { flush: 'post' })
+    const ob = new MutationObserver(applyCssCars)
+    ob.observe(getParentNode(), { childList: true })
+    onUnmounted(() => ob.disconnect())
+  })
+}
+
+/**
+ * @internal
+ * shared between vdom and vapor
+ */
+export function setVarsOnNode(el: Node, vars: Record<string, string>): void {
   if (el.nodeType === 1) {
     const style = (el as HTMLElement).style
     let cssText = ''
index 8ea346895528af7221795c92363c9436bb8504b8..71284708f0f3df4e0841c11ca7a1ae2e2ca0011d 100644 (file)
@@ -329,6 +329,10 @@ export { patchStyle } from './modules/style'
  * @internal
  */
 export { shouldSetAsProp } from './patchProp'
+/**
+ * @internal
+ */
+export { baseUseCssVars, setVarsOnNode } from './helpers/useCssVars'
 /**
  * @internal
  */
diff --git a/packages/runtime-vapor/__tests__/helpers/useCssVars.spec.ts b/packages/runtime-vapor/__tests__/helpers/useCssVars.spec.ts
new file mode 100644 (file)
index 0000000..1f73df6
--- /dev/null
@@ -0,0 +1,414 @@
+import {
+  VaporTeleport,
+  createComponent,
+  createIf,
+  createPlainElement,
+  defineVaporComponent,
+  defineVaporCustomElement,
+  renderEffect,
+  setStyle,
+  template,
+  useVaporCssVars,
+  withVaporCtx,
+} from '@vue/runtime-vapor'
+import { nextTick, onMounted, reactive, ref } from '@vue/runtime-core'
+import { makeRender } from '../_utils'
+import type { VaporComponent } from '../../src/component'
+
+const define = makeRender()
+
+describe('useVaporCssVars', () => {
+  async function assertCssVars(getApp: (state: any) => VaporComponent) {
+    const state = reactive({ color: 'red' })
+    const App = getApp(state)
+    const root = document.createElement('div')
+
+    define(App).render({}, root)
+    for (const c of [].slice.call(root.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe(`red`)
+    }
+
+    state.color = 'green'
+    await nextTick()
+    for (const c of [].slice.call(root.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('green')
+    }
+  }
+
+  test('basic', async () => {
+    const t0 = template('<div></div>')
+    await assertCssVars(state => ({
+      setup() {
+        useVaporCssVars(() => state)
+        const n0 = t0()
+        return n0
+      },
+    }))
+  })
+
+  test('on multiple root', async () => {
+    const t0 = template('<div></div>')
+    await assertCssVars(state => ({
+      setup() {
+        useVaporCssVars(() => state)
+        const n0 = t0()
+        const n1 = t0()
+        return [n0, n1]
+      },
+    }))
+  })
+
+  test('on HOCs', async () => {
+    const t0 = template('<div></div>')
+    const Child = defineVaporComponent({
+      setup() {
+        const n0 = t0()
+        return n0
+      },
+    })
+    await assertCssVars(state => ({
+      setup() {
+        useVaporCssVars(() => state)
+        return createComponent(Child)
+      },
+    }))
+  })
+
+  test.todo('on suspense root', async () => {})
+
+  test.todo('with v-if & async component & suspense', async () => {})
+
+  test('with subTree changes', async () => {
+    const state = reactive({ color: 'red' })
+    const value = ref(true)
+    const root = document.createElement('div')
+    const t0 = template('<div></div>')
+
+    define({
+      setup() {
+        useVaporCssVars(() => state)
+        const n0 = createIf(
+          () => value.value,
+          () => {
+            const n2 = t0()
+            return n2
+          },
+          () => {
+            const n4 = t0()
+            const n5 = t0()
+            return [n4, n5]
+          },
+        )
+        return n0
+      },
+    }).render({}, root)
+
+    // css vars use with fallback tree
+    for (const c of [].slice.call(root.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe(`red`)
+    }
+
+    value.value = false
+    await nextTick()
+    for (const c of [].slice.call(root.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('red')
+    }
+  })
+
+  test('with subTree change inside HOC', async () => {
+    const state = reactive({ color: 'red' })
+    const value = ref(true)
+    const root = document.createElement('div')
+
+    const Child = defineVaporComponent({
+      setup(_, { slots }) {
+        return slots.default!()
+      },
+    })
+
+    const t0 = template('<div></div>')
+    define({
+      setup() {
+        useVaporCssVars(() => state)
+        return createComponent(Child, null, {
+          default: () => {
+            return createIf(
+              () => value.value,
+              () => {
+                const n2 = t0()
+                return n2
+              },
+              () => {
+                const n4 = t0()
+                const n5 = t0()
+                return [n4, n5]
+              },
+            )
+          },
+        })
+      },
+    }).render({}, root)
+
+    await nextTick()
+    // css vars use with fallback tree
+    for (const c of [].slice.call(root.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe(`red`)
+    }
+
+    value.value = false
+    await nextTick()
+    for (const c of [].slice.call(root.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('red')
+    }
+  })
+
+  test('with teleport', async () => {
+    const state = reactive({ color: 'red' })
+    const target = document.createElement('div')
+    document.body.appendChild(target)
+
+    define({
+      setup() {
+        useVaporCssVars(() => state)
+        return createComponent(
+          VaporTeleport,
+          {
+            to: () => target,
+          },
+          {
+            default: () => template('<div></div>', true)(),
+          },
+        )
+      },
+    }).render()
+
+    await nextTick()
+    for (const c of [].slice.call(target.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('red')
+    }
+
+    state.color = 'green'
+    await nextTick()
+    for (const c of [].slice.call(target.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('green')
+    }
+  })
+
+  test('with teleport in child slot', async () => {
+    const state = reactive({ color: 'red' })
+    const target = document.createElement('div')
+    document.body.appendChild(target)
+
+    const Child = defineVaporComponent({
+      setup(_, { slots }) {
+        return slots.default!()
+      },
+    })
+
+    define({
+      setup() {
+        useVaporCssVars(() => state)
+        return createComponent(Child, null, {
+          default: () =>
+            createComponent(
+              VaporTeleport,
+              { to: () => target },
+              {
+                default: () => template('<div></div>', true)(),
+              },
+            ),
+        })
+      },
+    }).render()
+
+    await nextTick()
+    for (const c of [].slice.call(target.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('red')
+    }
+
+    state.color = 'green'
+    await nextTick()
+    for (const c of [].slice.call(target.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('green')
+    }
+  })
+
+  test('with teleport(change subTree)', async () => {
+    const state = reactive({ color: 'red' })
+    const target = document.createElement('div')
+    document.body.appendChild(target)
+    const toggle = ref(false)
+
+    define({
+      setup() {
+        useVaporCssVars(() => state)
+        return createComponent(
+          VaporTeleport,
+          { to: () => target },
+          {
+            default: withVaporCtx(() => {
+              const n0 = template('<div></div>', true)()
+              const n1 = createIf(
+                () => toggle.value,
+                () => template('<div></div>', true)(),
+              )
+              return [n0, n1]
+            }),
+          },
+        )
+      },
+    }).render()
+
+    await nextTick()
+    for (const c of [].slice.call(target.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('red')
+      expect((c as HTMLElement).outerHTML.includes('data-v-owner')).toBe(true)
+    }
+
+    toggle.value = true
+    await nextTick()
+    expect(target.children.length).toBe(2)
+    for (const c of [].slice.call(target.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('red')
+      expect((c as HTMLElement).outerHTML.includes('data-v-owner')).toBe(true)
+    }
+  })
+
+  test('with teleport(disabled)', async () => {
+    const state = reactive({ color: 'red' })
+    const target = document.createElement('div')
+    document.body.appendChild(target)
+
+    const { host } = define({
+      setup() {
+        useVaporCssVars(() => state)
+        return createComponent(
+          VaporTeleport,
+          { to: () => target, disabled: () => true },
+          {
+            default: withVaporCtx(() => template('<div></div>', true)()),
+          },
+        )
+      },
+    }).render()
+
+    await nextTick()
+    expect(target.children.length).toBe(0)
+    expect(host.children[0].outerHTML.includes('data-v-owner')).toBe(true)
+  })
+
+  test('with string style', async () => {
+    const state = reactive({ color: 'red' })
+    const root = document.createElement('div')
+    const disabled = ref(false)
+    const t0 = template('<h1></h1>')
+
+    define({
+      setup() {
+        useVaporCssVars(() => state)
+        const n0 = t0() as any
+        renderEffect(() =>
+          setStyle(n0, state.color ? 'pointer-events: none' : undefined),
+        )
+        return n0
+      },
+    }).render({}, root)
+
+    await nextTick()
+    for (const c of [].slice.call(root.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('red')
+    }
+
+    disabled.value = true
+    await nextTick()
+    for (const c of [].slice.call(root.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('red')
+    }
+  })
+
+  test('with delay mount child', async () => {
+    const state = reactive({ color: 'red' })
+    const value = ref(false)
+    const root = document.createElement('div')
+
+    const Child = defineVaporComponent({
+      setup() {
+        onMounted(() => {
+          const childEl = root.children[0]
+          expect(getComputedStyle(childEl!).getPropertyValue(`--color`)).toBe(
+            `red`,
+          )
+        })
+        return template('<div id="childId"></div>')()
+      },
+    })
+
+    define({
+      setup() {
+        useVaporCssVars(() => state)
+        return createIf(
+          () => value.value,
+          () => createComponent(Child),
+          () => template('<div></div>')(),
+        )
+      },
+    }).render({}, root)
+
+    await nextTick()
+    // css vars use with fallback tree
+    for (const c of [].slice.call(root.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe(`red`)
+    }
+
+    // mount child
+    value.value = true
+    await nextTick()
+    for (const c of [].slice.call(root.children as any)) {
+      expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe(`red`)
+    }
+  })
+
+  test('with custom element', async () => {
+    const state = reactive({ color: 'red' })
+    const CE = defineVaporCustomElement({
+      setup() {
+        useVaporCssVars(() => state)
+        return template('<div>hello</div>', true)()
+      },
+    })
+
+    customElements.define('css-vars-ce', CE)
+
+    const { html } = define({
+      setup() {
+        return createPlainElement('css-vars-ce', null, null, true)
+      },
+    }).render()
+
+    expect(html()).toBe('<css-vars-ce style="--color: red;"></css-vars-ce>')
+
+    state.color = 'green'
+    await nextTick()
+    expect(html()).toBe('<css-vars-ce style="--color: green;"></css-vars-ce>')
+  })
+
+  test('should set vars before child component onMounted hook', () => {
+    const state = reactive({ color: 'red' })
+    const root = document.createElement('div')
+    let colorInOnMount
+
+    define({
+      setup() {
+        useVaporCssVars(() => state)
+        onMounted(() => {
+          colorInOnMount = (
+            root.children[0] as HTMLElement
+          ).style.getPropertyValue(`--color`)
+        })
+        return template('<div></div>')()
+      },
+    }).render({}, root)
+
+    expect(colorInOnMount).toBe(`red`)
+  })
+})
index 8beb89edb9184ac5961642afe0ba2daf09365ee9..dac39b2be25ae5b11d43a759dad582d7658572ce 100644 (file)
@@ -1,8 +1,15 @@
 import {
+  child,
+  createComponent,
   createPlainElement,
   createVaporSSRApp,
   defineVaporAsyncComponent,
+  defineVaporComponent,
   delegateEvents,
+  renderEffect,
+  setStyle,
+  template,
+  useVaporCssVars,
 } from '../src'
 import { defineAsyncComponent, nextTick, reactive, ref } from '@vue/runtime-dom'
 import { isString } from '@vue/shared'
@@ -4456,58 +4463,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
@@ -4529,24 +4533,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 5bc4473997cb95ef5a0c6f5280329d77f2d664f3..f4a636d7758d497764645be3ae96609050732191 100644 (file)
@@ -402,6 +402,8 @@ export const createFor = (
     } else {
       oldBlocks = []
     }
+
+    if (frag.updated) frag.updated.forEach(m => m())
     setActiveSub(prevSub)
   }
 
index 3c061bc0b14e7a4b04ed52af5892c81e1af61633..1c5f783655f3129c3836e0797306fb6e70936787 100644 (file)
@@ -21,7 +21,7 @@ import {
 import { rawPropsProxyHandlers } from '../componentProps'
 import { renderEffect } from '../renderEffect'
 import { extend, isArray } from '@vue/shared'
-import { VaporFragment } from '../fragment'
+import { VaporFragment, isFragment } from '../fragment'
 import {
   advanceHydrationNode,
   currentHydrationNode,
@@ -48,6 +48,7 @@ export class TeleportFragment extends VaporFragment {
   private rawProps?: LooseRawProps
   private resolvedProps?: TeleportProps
   private rawSlots?: LooseRawSlots
+  isDisabled?: boolean
 
   target?: ParentNode | null
   targetAnchor?: Node | null
@@ -78,6 +79,7 @@ export class TeleportFragment extends VaporFragment {
           rawPropsProxyHandlers,
         ) as any as TeleportProps,
       )
+      this.isDisabled = isTeleportDisabled(this.resolvedProps!)
       this.handlePropsUpdate()
     })
 
@@ -97,8 +99,24 @@ export class TeleportFragment extends VaporFragment {
       )
     })
 
+    const nodes = this.nodes
+    // register updateCssVars to root fragments's update hooks so that
+    // it will be called when root fragment changed
+    if (this.parentComponent && this.parentComponent.ut) {
+      if (isFragment(nodes)) {
+        ;(nodes.updated || (nodes.updated = [])).push(() => updateCssVars(this))
+      } else if (isArray(nodes)) {
+        nodes.forEach(node => {
+          if (isFragment(node)) {
+            ;(node.updated || (node.updated = [])).push(() =>
+              updateCssVars(this),
+            )
+          }
+        })
+      }
+    }
+
     if (__DEV__) {
-      const nodes = this.nodes
       if (isVaporComponent(nodes)) {
         nodes.parentTeleport = this
       } else if (isArray(nodes)) {
@@ -162,6 +180,7 @@ export class TeleportFragment extends VaporFragment {
         }
 
         mount(target, this.targetAnchor!)
+        updateCssVars(this)
       } else if (__DEV__) {
         warn(
           `Invalid Teleport target on ${this.targetAnchor ? 'update' : 'mount'}:`,
@@ -172,8 +191,9 @@ export class TeleportFragment extends VaporFragment {
     }
 
     // mount into main container
-    if (isTeleportDisabled(this.resolvedProps!)) {
+    if (this.isDisabled) {
       mount(this.parent, this.anchor!)
+      updateCssVars(this)
     }
     // mount into target container
     else {
@@ -330,3 +350,23 @@ function locateTeleportEndAnchor(
   }
   return null
 }
+
+function updateCssVars(frag: TeleportFragment) {
+  const ctx = frag.parentComponent as GenericComponentInstance
+  if (ctx && ctx.ut) {
+    let node, anchor
+    if (frag.isDisabled) {
+      node = frag.placeholder
+      anchor = frag.anchor
+    } else {
+      node = frag.targetStart
+      anchor = frag.targetAnchor
+    }
+    while (node && node !== anchor) {
+      if (node.nodeType === 1)
+        (node as Element).setAttribute('data-v-owner', String(ctx.uid))
+      node = node.nextSibling
+    }
+    ctx.ut()
+  }
+}
index 9167d2b9b3b484049e59d33aad5537ecf5dbd81c..946fecee813edd4c8a4b4db8942f2de0cf5e797e 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,20 @@ function setClassIncremental(el: any, value: any): void {
   }
 }
 
+/**
+ * dev only
+ * defer style matching checks until hydration completes (instance.block is set) if
+ * the component uses style v-bind or the element contains CSS variables, to correctly
+ * verify if the element is the component root.
+ */
+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 +249,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 +277,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))
@@ -548,6 +585,7 @@ function styleHasMismatch(
   value: any,
   normalizedValue: string | NormalizedStyle | undefined,
   isIncremental: boolean,
+  instance = currentInstance,
 ): boolean {
   const actual = el.getAttribute('style')
   const actualStyleMap = toStyleMap(actual || '')
@@ -559,7 +597,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) {
@@ -582,6 +623,39 @@ function styleHasMismatch(
   return false
 }
 
+/**
+ * dev only
+ */
+function resolveCssVars(
+  instance: VaporComponentInstance,
+  block: Block,
+  expectedMap: Map<string, string>,
+): void {
+  if (!instance.isMounted) return
+  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)
index d6a0f8b6c659d20b1c3dad8ebdbebc0e26bbfdad..df4891c32865ec37f44588165c236e28e0a29359 100644 (file)
@@ -52,6 +52,9 @@ export class VaporFragment<T extends Block = Block>
     refKey: string | undefined,
   ) => void
 
+  // hooks
+  updated?: ((nodes?: Block) => void)[]
+
   constructor(nodes: T) {
     this.nodes = nodes
   }
@@ -80,7 +83,6 @@ export class DynamicFragment extends VaporFragment {
     scope: EffectScope,
   ) => boolean)[]
   beforeMount?: ((newKey: any, nodes: Block, scope: EffectScope) => void)[]
-  mounted?: ((nodes: Block, scope: EffectScope) => void)[]
 
   constructor(anchorLabel?: string) {
     super([])
@@ -189,8 +191,8 @@ export class DynamicFragment extends VaporFragment {
 
       if (parent) {
         insert(this.nodes, parent, this.anchor)
-        if (this.mounted) {
-          this.mounted.forEach(hook => hook(this.nodes, this.scope!))
+        if (this.updated) {
+          this.updated.forEach(hook => hook(this.nodes))
         }
       }
     } else {
diff --git a/packages/runtime-vapor/src/helpers/useCssVars.ts b/packages/runtime-vapor/src/helpers/useCssVars.ts
new file mode 100644 (file)
index 0000000..a807ffa
--- /dev/null
@@ -0,0 +1,55 @@
+import {
+  type GenericComponentInstance,
+  baseUseCssVars,
+  currentInstance,
+  setVarsOnNode,
+} from '@vue/runtime-dom'
+import { type VaporComponentInstance, isVaporComponent } from '../component'
+import { isArray } from '@vue/shared'
+import type { Block } from '../block'
+
+export function useVaporCssVars(getter: () => Record<string, string>): void {
+  if (!__BROWSER__ && !__TEST__) return
+  const instance = currentInstance as VaporComponentInstance
+  baseUseCssVars(
+    instance,
+    () => resolveParentNode(instance.block),
+    getter,
+    vars => setVars(instance, vars),
+  )
+}
+
+function resolveParentNode(block: Block): Node {
+  if (block instanceof Node) {
+    return block.parentNode!
+  } else if (isArray(block)) {
+    return resolveParentNode(block[0])
+  } else if (isVaporComponent(block)) {
+    return resolveParentNode(block.block!)
+  } else {
+    return resolveParentNode(block.nodes)
+  }
+}
+
+function setVars(
+  instance: VaporComponentInstance,
+  vars: Record<string, string>,
+): void {
+  if ((instance as GenericComponentInstance).ce) {
+    setVarsOnNode((instance as GenericComponentInstance).ce as any, vars)
+  } else {
+    setVarsOnBlock(instance.block, vars)
+  }
+}
+
+function setVarsOnBlock(block: Block, vars: Record<string, string>): void {
+  if (block instanceof Node) {
+    setVarsOnNode(block, vars)
+  } else if (isArray(block)) {
+    block.forEach(child => setVarsOnBlock(child, vars))
+  } else if (isVaporComponent(block)) {
+    setVarsOnBlock(block.block!, vars)
+  } else {
+    setVarsOnBlock(block.nodes, vars)
+  }
+}
index c20c62490628d35037032f61f65db48ed3696c68..ed2c63812c2ab984f8197ad00a88b8ea9292a8a1 100644 (file)
@@ -54,6 +54,7 @@ export {
   getDefaultValue,
 } from './apiCreateFor'
 export { createTemplateRefSetter } from './apiTemplateRef'
+export { useVaporCssVars } from './helpers/useCssVars'
 export { createDynamicComponent } from './apiCreateDynamicComponent'
 export { applyVShow } from './directives/vShow'
 export {
index f46ae195e003c51b735f6d93543eecb847d73f60..3c4a961f0c9004d9a1ea8d2fbcc012249d9c4c08 100644 (file)
@@ -383,6 +383,7 @@ function createVDOMComponent(
     }
 
     frag.nodes = vnode.el as any
+    if (frag.updated) frag.updated.forEach(m => m())
   }
 
   frag.remove = unmount
@@ -448,6 +449,8 @@ function renderVDOMSlot(
         internals.um(oldVNode, parentComponent as any, null)
       }
     }
+
+    if (frag.updated) frag.updated.forEach(m => m())
   }
 
   const render = (parentNode?: ParentNode, anchor?: Node | null) => {