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({ const Child = defineVaporComponent({
inheritAttrs: false, inheritAttrs: false,
setup(props, { attrs }) { setup(_props, { attrs }) {
const el = document.createElement('div') const el = document.createElement('div')
renderEffect(() => setDynamicProps(el, [attrs])) let prev: any
renderEffect(() => {
prev = setDynamicProps(el, [attrs], prev, true)
})
return el return el
}, },
}) })
@ -110,7 +113,10 @@ describe('api: setup context', () => {
const n0 = createComponent(Wrapper, null, { const n0 = createComponent(Wrapper, null, {
default: () => { default: () => {
const n0 = template('<div>')() as HTMLDivElement const n0 = template('<div>')() as HTMLDivElement
renderEffect(() => setDynamicProps(n0, [attrs], true)) let prev: any
renderEffect(() => {
prev = setDynamicProps(n0, [attrs], prev, true)
})
return n0 return n0
}, },
}) })

View File

@ -1,6 +1,15 @@
import { nextTick, ref, watchEffect } from '@vue/runtime-dom' import { type Ref, nextTick, ref, watchEffect } from '@vue/runtime-dom'
import { createComponent, setText, template } from '../src' import {
createComponent,
defineVaporComponent,
renderEffect,
setClassIncremental,
setStyleIncremental,
setText,
template,
} from '../src'
import { makeRender } from './_utils' import { makeRender } from './_utils'
import { stringifyStyle } from '@vue/shared'
const define = makeRender<any>() const define = makeRender<any>()
@ -132,4 +141,135 @@ describe('attribute fallthrough', () => {
await nextTick() await nextTick()
expect(host.innerHTML).toBe('<div foo="2" id="b">2</div>') 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', () => { describe('setDynamicProp', () => {
const element = document.createElement('div') const element = document.createElement('div')
let prev: any
function setDynamicProp( function setDynamicProp(
key: string, key: string,
value: any, value: any,
el = element.cloneNode(true) as HTMLElement, el = element.cloneNode(true) as HTMLElement,
) { ) {
prev = _setDynamicProp(el, key, prev, value) _setDynamicProp(el, key, value)
return el return el
} }

View File

@ -210,7 +210,12 @@ export function createComponent(
Object.keys(instance.attrs).length Object.keys(instance.attrs).length
) { ) {
renderEffect(() => { 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) { if (rawProps) {
renderEffect(() => { renderEffect(() => {
setDynamicProps(el, [resolveDynamicProps(rawProps)]) setDynamicProps(el, [resolveDynamicProps(rawProps)], isSingleRoot)
}) })
} }

View File

@ -4,6 +4,7 @@ import {
YES, YES,
camelize, camelize,
hasOwn, hasOwn,
isArray,
isFunction, isFunction,
isString, isString,
} from '@vue/shared' } from '@vue/shared'
@ -171,6 +172,8 @@ export function getPropsProxyHandlers(
export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown { export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown {
if (key === '$') return if (key === '$') return
// need special merging behavior for class & style
const merged = key === 'class' || key === 'style' ? ([] as any[]) : undefined
const dynamicSources = rawProps.$ const dynamicSources = rawProps.$
if (dynamicSources) { if (dynamicSources) {
let i = dynamicSources.length let i = dynamicSources.length
@ -180,13 +183,23 @@ export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown {
isDynamic = isFunction(source) isDynamic = isFunction(source)
source = isDynamic ? (source as Function)() : source source = isDynamic ? (source as Function)() : source
if (hasOwn(source, key)) { 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)) { 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 { 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 isDynamic = isFunction(source)
const resolved = isDynamic ? source() : source const resolved = isDynamic ? source() : source
for (const key in resolved) { for (const key in resolved) {
mergedRawProps[key] = isDynamic const value = isDynamic ? resolved[key] : (resolved[key] as Function)()
? resolved[key] if (key === 'class' || key === 'style') {
: (resolved[key] as Function)() 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 & { type TargetElement = Element & {
$html?: string $html?: string
$cls?: string $cls?: string
$clsi?: string
$sty?: NormalizedStyle | string | undefined $sty?: NormalizedStyle | string | undefined
$styi?: NormalizedStyle | undefined
$dprops?: Record<string, any>
} }
export function setText(el: Node & { $txt?: string }, ...values: any[]): void { 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 * Used on single root elements so it can patch class independent of fallthrough
* attributes. * attributes.
*/ */
export function setClassIncremental(el: TargetElement, value: any): void { export function setClassIncremental(
const prev = el.$clsi el: any,
if ((value = normalizeClass(value)) !== prev) { value: any,
el.$clsi = value fallthrough?: boolean,
): void {
const cacheKey = `$clsi${fallthrough ? '$' : ''}`
const prev = el[cacheKey]
if ((value = el[cacheKey] = normalizeClass(value)) !== prev) {
const nextList = value.split(/\s+/) const nextList = value.split(/\s+/)
el.classList.add(...nextList) el.classList.add(...nextList)
if (prev) { 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 * Used on single root elements so it can patch class independent of fallthrough
* attributes. * attributes.
*/ */
export function setStyleIncremental(el: TargetElement, value: any): void { export function setStyleIncremental(
const prev = el.$styi el: any,
value = el.$styi = isString(value) value: any,
fallthrough?: boolean,
): NormalizedStyle | undefined {
const cacheKey = `$styi${fallthrough ? '$' : ''}`
const prev = el[cacheKey]
value = el[cacheKey] = isString(value)
? parseStringStyle(value) ? parseStringStyle(value)
: (normalizeStyle(value) as NormalizedStyle | undefined) : (normalizeStyle(value) as NormalizedStyle | undefined)
patchStyle(el, prev, value) patchStyle(el, prev, value)
return value
} }
export function setAttr(el: any, key: string, value: any): void { 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( export function setDynamicProps(
el: TargetElement, el: any,
args: any[], args: any[],
root = false, root = false,
fallthrough = false,
): void { ): void {
const props = args.length > 1 ? mergeProps(...args) : args[0] 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) { if (prevKeys) {
for (const key in oldProps) { for (const key of prevKeys) {
// TODO should these keys be allowed as dynamic keys? The current logic of the runtime-core will throw an error if (!(key in props)) {
if (key === 'textContent' || key === 'innerHTML') { setDynamicProp(el, key, null, root, fallthrough)
continue
}
const oldValue = oldProps[key]
const hasNewValue = props[key] || props['.' + key] || props['^' + key]
if (oldValue && !hasNewValue) {
setDynamicProp(el, key, oldValue, null, root)
} }
} }
} }
const prev = (el.$dprops = Object.create(null)) for (const key of (el[cacheKey] = Object.keys(props))) {
for (const key in props) { setDynamicProp(el, key, props[key], root, fallthrough)
setDynamicProp(
el,
key,
oldProps ? oldProps[key] : undefined,
(prev[key] = props[key]),
root,
)
} }
} }
@ -198,21 +193,21 @@ export function setDynamicProps(
export function setDynamicProp( export function setDynamicProp(
el: TargetElement, el: TargetElement,
key: string, key: string,
prev: any,
value: any, value: any,
root?: boolean, root?: boolean,
): void { fallthrough?: boolean,
): any {
// TODO // TODO
const isSVG = false const isSVG = false
if (key === 'class') { if (key === 'class') {
if (root) { if (root) {
setClassIncremental(el, value) return setClassIncremental(el, value, fallthrough)
} else { } else {
setClass(el, value) setClass(el, value)
} }
} else if (key === 'style') { } else if (key === 'style') {
if (root) { if (root) {
setStyleIncremental(el, value) return setStyleIncremental(el, value, fallthrough)
} else { } else {
setStyle(el, value) setStyle(el, value)
} }
@ -238,4 +233,5 @@ export function setDynamicProp(
// TODO special case for <input v-model type="checkbox"> // TODO special case for <input v-model type="checkbox">
setAttr(el, key, value) setAttr(el, key, value)
} }
return value
} }