wip(vapor): handle class / style merging behavior

This commit is contained in:
Evan You 2024-12-13 10:55:58 +08:00
parent 4160b6d567
commit f9a6e8cd58
No known key found for this signature in database
GPG Key ID: 00E9AB7A6704CE0A
6 changed files with 217 additions and 50 deletions

View 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
},
})

View 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')
})
})

View 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
}

View 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)
})
}

View 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
}
}
}
}

View 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
}