]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
wip(vapor): handle class / style merging behavior
authorEvan You <evan@vuejs.org>
Fri, 13 Dec 2024 02:55:58 +0000 (10:55 +0800)
committerEvan You <evan@vuejs.org>
Fri, 13 Dec 2024 10:00:58 +0000 (18:00 +0800)
packages/runtime-vapor/__tests__/apiSetupContext.spec.ts
packages/runtime-vapor/__tests__/componentAttrs.spec.ts
packages/runtime-vapor/__tests__/dom/prop.spec.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/componentProps.ts
packages/runtime-vapor/src/dom/prop.ts

index 7eb8a0cf0ea25c7c3b447c98e3f0236dd102ee3e..9bc5da2ea3cb99baad52e12845d45c9add755472 100644 (file)
@@ -72,9 +72,12 @@ describe('api: setup context', () => {
 
     const Child = defineVaporComponent({
       inheritAttrs: false,
-      setup(props, { attrs }) {
+      setup(_props, { attrs }) {
         const el = document.createElement('div')
-        renderEffect(() => setDynamicProps(el, [attrs]))
+        let prev: any
+        renderEffect(() => {
+          prev = setDynamicProps(el, [attrs], prev, true)
+        })
         return el
       },
     })
@@ -110,7 +113,10 @@ describe('api: setup context', () => {
         const n0 = createComponent(Wrapper, null, {
           default: () => {
             const n0 = template('<div>')() as HTMLDivElement
-            renderEffect(() => setDynamicProps(n0, [attrs], true))
+            let prev: any
+            renderEffect(() => {
+              prev = setDynamicProps(n0, [attrs], prev, true)
+            })
             return n0
           },
         })
index 99544d50dcb6afe0b2face1cbffcdebed1b6d5c9..87b0721f4effb89646c8b815cada94cbbf0b8a40 100644 (file)
@@ -1,6 +1,15 @@
-import { nextTick, ref, watchEffect } from '@vue/runtime-dom'
-import { createComponent, setText, template } from '../src'
+import { type Ref, nextTick, ref, watchEffect } from '@vue/runtime-dom'
+import {
+  createComponent,
+  defineVaporComponent,
+  renderEffect,
+  setClassIncremental,
+  setStyleIncremental,
+  setText,
+  template,
+} from '../src'
 import { makeRender } from './_utils'
+import { stringifyStyle } from '@vue/shared'
 
 const define = makeRender<any>()
 
@@ -132,4 +141,135 @@ describe('attribute fallthrough', () => {
     await nextTick()
     expect(host.innerHTML).toBe('<div foo="2" id="b">2</div>')
   })
+
+  it('should merge classes', async () => {
+    const rootClass = ref('root')
+    const parentClass = ref('parent')
+    const childClass = ref('child')
+
+    const Child = defineVaporComponent({
+      setup() {
+        const n = document.createElement('div')
+        renderEffect(() => {
+          // binding on template root generates incremental class setter
+          setClassIncremental(n, childClass.value)
+        })
+        return n
+      },
+    })
+
+    const Parent = defineVaporComponent({
+      setup() {
+        return createComponent(
+          Child,
+          {
+            class: () => parentClass.value,
+          },
+          null,
+          true, // pass single root flag
+        )
+      },
+    })
+
+    const { host } = define({
+      setup() {
+        return createComponent(Parent, {
+          class: () => rootClass.value,
+        })
+      },
+    }).render()
+
+    const list = host.children[0].classList
+    // assert classes without being order-sensitive
+    function assertClasses(cls: string[]) {
+      expect(list.length).toBe(cls.length)
+      for (const c of cls) {
+        expect(list.contains(c)).toBe(true)
+      }
+    }
+
+    assertClasses(['root', 'parent', 'child'])
+
+    rootClass.value = 'root1'
+    await nextTick()
+    assertClasses(['root1', 'parent', 'child'])
+
+    parentClass.value = 'parent1'
+    await nextTick()
+    assertClasses(['root1', 'parent1', 'child'])
+
+    childClass.value = 'child1'
+    await nextTick()
+    assertClasses(['root1', 'parent1', 'child1'])
+  })
+
+  it('should merge styles', async () => {
+    const rootStyle: Ref<string | Record<string, string>> = ref('color:red')
+    const parentStyle: Ref<string | null> = ref('font-size:12px')
+    const childStyle = ref('font-weight:bold')
+
+    const Child = defineVaporComponent({
+      setup() {
+        const n = document.createElement('div')
+        renderEffect(() => {
+          // binding on template root generates incremental class setter
+          setStyleIncremental(n, childStyle.value)
+        })
+        return n
+      },
+    })
+
+    const Parent = defineVaporComponent({
+      setup() {
+        return createComponent(
+          Child,
+          {
+            style: () => parentStyle.value,
+          },
+          null,
+          true, // pass single root flag
+        )
+      },
+    })
+
+    const { host } = define({
+      setup() {
+        return createComponent(Parent, {
+          style: () => rootStyle.value,
+        })
+      },
+    }).render()
+
+    const el = host.children[0] as HTMLElement
+
+    function getCSS() {
+      return el.style.cssText.replace(/\s+/g, '')
+    }
+
+    function assertStyles() {
+      const css = getCSS()
+      expect(css).toContain(stringifyStyle(rootStyle.value))
+      if (parentStyle.value) {
+        expect(css).toContain(stringifyStyle(parentStyle.value))
+      }
+      expect(css).toContain(stringifyStyle(childStyle.value))
+    }
+
+    assertStyles()
+
+    rootStyle.value = { color: 'green' }
+    await nextTick()
+    assertStyles()
+    expect(getCSS()).not.toContain('color:red')
+
+    parentStyle.value = null
+    await nextTick()
+    assertStyles()
+    expect(getCSS()).not.toContain('font-size:12px')
+
+    childStyle.value = 'font-weight:500'
+    await nextTick()
+    assertStyles()
+    expect(getCSS()).not.toContain('font-size:bold')
+  })
 })
index 3838540357b1defe2d2c84e2fae3325f29fbbb50..ead9e75cd504befc37d9888ea7bd2bd97f9d5179 100644 (file)
@@ -305,13 +305,12 @@ describe('patchProp', () => {
 
   describe('setDynamicProp', () => {
     const element = document.createElement('div')
-    let prev: any
     function setDynamicProp(
       key: string,
       value: any,
       el = element.cloneNode(true) as HTMLElement,
     ) {
-      prev = _setDynamicProp(el, key, prev, value)
+      _setDynamicProp(el, key, value)
       return el
     }
 
index 56203792702b091eadf96fe66023f0887b6af5c8..199b5ba6a8dabc77d7e27e8cc6872b8858ad202e 100644 (file)
@@ -210,7 +210,12 @@ export function createComponent(
     Object.keys(instance.attrs).length
   ) {
     renderEffect(() => {
-      setDynamicProps(instance.block as Element, [instance.attrs])
+      setDynamicProps(
+        instance.block as Element,
+        [instance.attrs],
+        true, // root
+        true, // fallthrough
+      )
     })
   }
 
@@ -421,7 +426,7 @@ export function createComponentWithFallback(
 
   if (rawProps) {
     renderEffect(() => {
-      setDynamicProps(el, [resolveDynamicProps(rawProps)])
+      setDynamicProps(el, [resolveDynamicProps(rawProps)], isSingleRoot)
     })
   }
 
index 2876a4e0d76f1c721cd89129a42c63a6d8cf97ed..e3e1d6a32c6a371f44354cb650742c4320a47f6e 100644 (file)
@@ -4,6 +4,7 @@ import {
   YES,
   camelize,
   hasOwn,
+  isArray,
   isFunction,
   isString,
 } from '@vue/shared'
@@ -171,6 +172,8 @@ export function getPropsProxyHandlers(
 
 export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown {
   if (key === '$') return
+  // need special merging behavior for class & style
+  const merged = key === 'class' || key === 'style' ? ([] as any[]) : undefined
   const dynamicSources = rawProps.$
   if (dynamicSources) {
     let i = dynamicSources.length
@@ -180,13 +183,23 @@ export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown {
       isDynamic = isFunction(source)
       source = isDynamic ? (source as Function)() : source
       if (hasOwn(source, key)) {
-        return isDynamic ? source[key] : source[key]()
+        const value = isDynamic ? source[key] : source[key]()
+        if (merged) {
+          merged.push(value)
+        } else {
+          return value
+        }
       }
     }
   }
   if (hasOwn(rawProps, key)) {
-    return rawProps[key]()
+    if (merged) {
+      merged.push(rawProps[key]())
+    } else {
+      return rawProps[key]()
+    }
   }
+  return merged
 }
 
 export function hasAttrFromRawProps(rawProps: RawProps, key: string): boolean {
@@ -299,9 +312,17 @@ export function resolveDynamicProps(props: RawProps): Record<string, unknown> {
       const isDynamic = isFunction(source)
       const resolved = isDynamic ? source() : source
       for (const key in resolved) {
-        mergedRawProps[key] = isDynamic
-          ? resolved[key]
-          : (resolved[key] as Function)()
+        const value = isDynamic ? resolved[key] : (resolved[key] as Function)()
+        if (key === 'class' || key === 'style') {
+          const existing = mergedRawProps[key]
+          if (isArray(existing)) {
+            existing.push(value)
+          } else {
+            mergedRawProps[key] = [existing, value]
+          }
+        } else {
+          mergedRawProps[key] = value
+        }
       }
     }
   }
index 3bba642b43d1a3c0748ea05fa9c63a45521934be..790c134e0efb30f2ece0d2540ea39c72ca2cf73c 100644 (file)
@@ -14,10 +14,7 @@ import { mergeProps, patchStyle, shouldSetAsProp, warn } from '@vue/runtime-dom'
 type TargetElement = Element & {
   $html?: string
   $cls?: string
-  $clsi?: string
   $sty?: NormalizedStyle | string | undefined
-  $styi?: NormalizedStyle | undefined
-  $dprops?: Record<string, any>
 }
 
 export function setText(el: Node & { $txt?: string }, ...values: any[]): void {
@@ -48,10 +45,14 @@ export function setClass(el: TargetElement, value: any): void {
  * Used on single root elements so it can patch class independent of fallthrough
  * attributes.
  */
-export function setClassIncremental(el: TargetElement, value: any): void {
-  const prev = el.$clsi
-  if ((value = normalizeClass(value)) !== prev) {
-    el.$clsi = value
+export function setClassIncremental(
+  el: any,
+  value: any,
+  fallthrough?: boolean,
+): void {
+  const cacheKey = `$clsi${fallthrough ? '$' : ''}`
+  const prev = el[cacheKey]
+  if ((value = el[cacheKey] = normalizeClass(value)) !== prev) {
     const nextList = value.split(/\s+/)
     el.classList.add(...nextList)
     if (prev) {
@@ -73,12 +74,18 @@ export function setStyle(el: TargetElement, value: any): void {
  * Used on single root elements so it can patch class independent of fallthrough
  * attributes.
  */
-export function setStyleIncremental(el: TargetElement, value: any): void {
-  const prev = el.$styi
-  value = el.$styi = isString(value)
+export function setStyleIncremental(
+  el: any,
+  value: any,
+  fallthrough?: boolean,
+): NormalizedStyle | undefined {
+  const cacheKey = `$styi${fallthrough ? '$' : ''}`
+  const prev = el[cacheKey]
+  value = el[cacheKey] = isString(value)
     ? parseStringStyle(value)
     : (normalizeStyle(value) as NormalizedStyle | undefined)
   patchStyle(el, prev, value)
+  return value
 }
 
 export function setAttr(el: any, key: string, value: any): void {
@@ -158,37 +165,25 @@ export function setDOMProp(el: any, key: string, value: any): void {
 }
 
 export function setDynamicProps(
-  el: TargetElement,
+  el: any,
   args: any[],
   root = false,
+  fallthrough = false,
 ): void {
   const props = args.length > 1 ? mergeProps(...args) : args[0]
-  const oldProps = el.$dprops
+  const cacheKey = `$dprops${fallthrough ? '$' : ''}`
+  const prevKeys = el[cacheKey] as string[]
 
-  if (oldProps) {
-    for (const key in oldProps) {
-      // TODO should these keys be allowed as dynamic keys? The current logic of the runtime-core will throw an error
-      if (key === 'textContent' || key === 'innerHTML') {
-        continue
-      }
-
-      const oldValue = oldProps[key]
-      const hasNewValue = props[key] || props['.' + key] || props['^' + key]
-      if (oldValue && !hasNewValue) {
-        setDynamicProp(el, key, oldValue, null, root)
+  if (prevKeys) {
+    for (const key of prevKeys) {
+      if (!(key in props)) {
+        setDynamicProp(el, key, null, root, fallthrough)
       }
     }
   }
 
-  const prev = (el.$dprops = Object.create(null))
-  for (const key in props) {
-    setDynamicProp(
-      el,
-      key,
-      oldProps ? oldProps[key] : undefined,
-      (prev[key] = props[key]),
-      root,
-    )
+  for (const key of (el[cacheKey] = Object.keys(props))) {
+    setDynamicProp(el, key, props[key], root, fallthrough)
   }
 }
 
@@ -198,21 +193,21 @@ export function setDynamicProps(
 export function setDynamicProp(
   el: TargetElement,
   key: string,
-  prev: any,
   value: any,
   root?: boolean,
-): void {
+  fallthrough?: boolean,
+): any {
   // TODO
   const isSVG = false
   if (key === 'class') {
     if (root) {
-      setClassIncremental(el, value)
+      return setClassIncremental(el, value, fallthrough)
     } else {
       setClass(el, value)
     }
   } else if (key === 'style') {
     if (root) {
-      setStyleIncremental(el, value)
+      return setStyleIncremental(el, value, fallthrough)
     } else {
       setStyle(el, value)
     }
@@ -238,4 +233,5 @@ export function setDynamicProp(
     // TODO special case for <input v-model type="checkbox">
     setAttr(el, key, value)
   }
+  return value
 }