diff --git a/packages/runtime-vapor/__tests__/apiSetupContext.spec.ts b/packages/runtime-vapor/__tests__/apiSetupContext.spec.ts index 7eb8a0cf0..9bc5da2ea 100644 --- a/packages/runtime-vapor/__tests__/apiSetupContext.spec.ts +++ b/packages/runtime-vapor/__tests__/apiSetupContext.spec.ts @@ -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('
')() as HTMLDivElement - renderEffect(() => setDynamicProps(n0, [attrs], true)) + let prev: any + renderEffect(() => { + prev = setDynamicProps(n0, [attrs], prev, true) + }) return n0 }, }) diff --git a/packages/runtime-vapor/__tests__/componentAttrs.spec.ts b/packages/runtime-vapor/__tests__/componentAttrs.spec.ts index 99544d50d..87b0721f4 100644 --- a/packages/runtime-vapor/__tests__/componentAttrs.spec.ts +++ b/packages/runtime-vapor/__tests__/componentAttrs.spec.ts @@ -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() @@ -132,4 +141,135 @@ describe('attribute fallthrough', () => { await nextTick() expect(host.innerHTML).toBe('
2
') }) + + 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> = ref('color:red') + const parentStyle: Ref = 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') + }) }) diff --git a/packages/runtime-vapor/__tests__/dom/prop.spec.ts b/packages/runtime-vapor/__tests__/dom/prop.spec.ts index 383854035..ead9e75cd 100644 --- a/packages/runtime-vapor/__tests__/dom/prop.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/prop.spec.ts @@ -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 } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 562037927..199b5ba6a 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -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) }) } diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 2876a4e0d..e3e1d6a32 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -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 { 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 + } } } } diff --git a/packages/runtime-vapor/src/dom/prop.ts b/packages/runtime-vapor/src/dom/prop.ts index 3bba642b4..790c134e0 100644 --- a/packages/runtime-vapor/src/dom/prop.ts +++ b/packages/runtime-vapor/src/dom/prop.ts @@ -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 } 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 setAttr(el, key, value) } + return value }