From 11305edd3a577cdce5613cb64735b892506f191a Mon Sep 17 00:00:00 2001 From: huajingwen Date: Fri, 3 Apr 2026 15:51:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E5=8F=98=E6=9B=B4transition-duration=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../animationHooks/useTransitionHooks.ts | 108 +-- .../test/runtime/react/animationHooks.spec.js | 769 ++++++++++++++++++ 2 files changed, 826 insertions(+), 51 deletions(-) create mode 100644 packages/webpack-plugin/test/runtime/react/animationHooks.spec.js diff --git a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts index 75f8014f08..cc90fdf277 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts @@ -94,6 +94,7 @@ function parseTransitionSingleProp (vals: string[], property: string) { } }).filter(item => item !== undefined) } + // transition 解析 function parseTransitionStyle (originalStyle: ExtendedViewStyle) { let transitionData: AnimationDataType[] = [] @@ -205,61 +206,58 @@ export default function useTransitionHooks (props: AnimationHooksPropsType const runOnJSCallbackRef = useRef({}) const runOnJSCallback = useRunOnJSCallback(runOnJSCallbackRef) // 根据 animation action 创建&驱动动画 - function createAnimation () { + function createAnimation (key: string, transitionKey: string, timing: { delay?: number, duration: number, easing: EasingFunction }) { let transformTransitionendDone = false - animatedKeys.forEach(key => { - // console.log(`createAnimation key=${key} originalStyle=`, originalStyle) - const isTransformKey = isTransform(key) - let ruleV = originalStyle[key] - if (isTransformKey) { - const transform = getTransformObj(originalStyle.transform!) - ruleV = transform[key] - } - let toVal = ruleV !== undefined - ? ruleV - : transitionSupportedProperty[key] - const shareVal = shareValMap[key].value - if (percentExp.test(`${toVal}`) && !percentExp.test(shareVal as string) && !isNaN(+shareVal)) { - // 获取到的toVal为百分比格式化shareValMap为百分比 - shareValMap[key].value = `${shareVal as number * 100}%` - } else if (percentExp.test(shareVal as string) && !percentExp.test(toVal as string) && !isNaN(+toVal)) { - // 初始值为百分比则格式化toVal为百分比 - toVal = `${toVal * 100}%` - } else if (typeof toVal !== typeof shareVal) { - // 动画起始值和终态值类型不一致报错提示一下 - warn(`[Mpx runtime error]: Value types of property ${key} must be consistent during the animation`) - } - if ((toVal === 'auto' && !isNaN(+shareVal)) || (shareVal === 'auto' && !isNaN(+toVal))) { - // 有 auto 直接赋值不做动画 - shareValMap[key].value = toVal - } else { - // console.log(`key=${key} oldVal=${shareValMap[key].value} newVal=${toVal}`) - const { delay = 0, duration, easing } = transitionMap[isTransformKey ? 'transform' : key] - // console.log('animationOptions=', { delay, duration, easing }) - let callback - if (transitionend && (!isTransformKey || !transformTransitionendDone)) { - runOnJSCallbackRef.current = { - animationCallback: (duration: number, finished: boolean, current?: AnimatableValue) => { - transitionend(finished, current, duration) - } - } - callback = (finished?: boolean, current?: AnimatableValue) => { - 'worklet' - // 动画结束后设置下一次transformOrigin - if (finished) { - runOnJS(runOnJSCallback)('animationCallback', duration, finished, current) - } + const { delay = 0, duration, easing } = timing + const isTransformKey = isTransform(key) + let ruleV = originalStyle[key] + if (isTransformKey) { + const transform = getTransformObj(originalStyle.transform!) + ruleV = transform[key] + } + let toVal = ruleV !== undefined + ? ruleV + : transitionSupportedProperty[key] + const shareVal = shareValMap[key].value + if (percentExp.test(`${toVal}`) && !percentExp.test(shareVal as string) && !isNaN(+shareVal)) { + // 获取到的toVal为百分比格式化shareValMap为百分比 + shareValMap[key].value = `${shareVal as number * 100}%` + } else if (percentExp.test(shareVal as string) && !percentExp.test(toVal as string) && !isNaN(+toVal)) { + // 初始值为百分比则格式化toVal为百分比 + toVal = `${toVal * 100}%` + } else if (typeof toVal !== typeof shareVal) { + // 动画起始值和终态值类型不一致报错提示一下 + warn(`[Mpx runtime error]: Value types of property ${key} must be consistent during the animation`) + } + if ((toVal === 'auto' && !isNaN(+shareVal)) || (shareVal === 'auto' && !isNaN(+toVal))) { + // 有 auto 直接赋值不做动画 + shareValMap[key].value = toVal + } else { + // console.log(`key=${key} oldVal=${shareValMap[key].value} newVal=${toVal}`) + // console.log('animationOptions=', { delay, duration, easing }) + let callback + if (transitionend && (!isTransformKey || !transformTransitionendDone)) { + runOnJSCallbackRef.current = { + animationCallback: (duration: number, finished: boolean, current?: AnimatableValue) => { + transitionend(finished, current, duration) } } - const animation = getAnimation({ key, value: toVal! }, { delay, duration, easing }, callback) - // Todo transform 有多个属性时也仅执行一次 transitionend(对齐wx) - if (isTransformKey) { - transformTransitionendDone = true + callback = (finished?: boolean, current?: AnimatableValue) => { + 'worklet' + // 动画结束后设置下一次transformOrigin + if (finished) { + runOnJS(runOnJSCallback)('animationCallback', duration, finished, current) + } } - shareValMap[key].value = animation } - // console.log(`useTransitionHooks, ${key}=`, animation) - }) + const animation = getAnimation({ key, value: toVal! }, { delay, duration, easing }, callback) + // Todo transform 有多个属性时也仅执行一次 transitionend(对齐wx) + if (isTransformKey) { + transformTransitionendDone = true + } + shareValMap[key].value = animation + } + // console.log(`useTransitionHooks, ${key}=`, animation) } // ** style 更新 useEffect(() => { @@ -269,7 +267,15 @@ export default function useTransitionHooks (props: AnimationHooksPropsType animationDeps.current = 1 return } - createAnimation() + // 从当前 style 解析最新 timing + const currentTransitionMap = parseTransitionStyle(originalStyle) + animatedKeys.forEach(key => { + const transitionKey = isTransform(key) ? 'transform' : key + const timing = currentTransitionMap[transitionKey] // || transitionMap[transitionKey] + if (timing) { + createAnimation(key, transitionKey, timing) + } + }) }, [originalStyle]) // ** 清空动画 useEffect(() => { diff --git a/packages/webpack-plugin/test/runtime/react/animationHooks.spec.js b/packages/webpack-plugin/test/runtime/react/animationHooks.spec.js new file mode 100644 index 0000000000..74b47b65fe --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react/animationHooks.spec.js @@ -0,0 +1,769 @@ +// Inline parseValues and pure parsing functions for isolated testing. +// Keep in sync with the actual implementation in animationHooks/useTransitionHooks.ts. + +function parseValues (str, char = ' ') { + let stack = 0 + let temp = '' + const result = [] + for (let i = 0; i < str.length; i++) { + if (str[i] === '(') { + stack++ + } else if (str[i] === ')') { + stack-- + } + if (stack !== 0 || str[i] !== char) { + temp += str[i] + } + if ((stack === 0 && str[i] === char) || i === str.length - 1) { + result.push(temp.trim()) + temp = '' + } + } + return result +} + +function dash2hump (str) { + return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : '')) +} + +const propName = { + transition: '', + transitionDuration: 'duration', + transitionProperty: 'property', + transitionTimingFunction: 'easing', + transitionDelay: 'delay' +} +const timingFunctionExp = /^(step-start|step-end|steps)/ +const secondRegExp = /^\s*(\d*(?:\.\d+)?)(s|ms)?\s*$/ +const cubicBezierExp = /cubic-bezier\(["']?(.*?)["']?\)/ +const easingKey = { + linear: 'linear', + ease: 'ease', + 'ease-in': 'ease-in', + 'ease-in-out': 'ease-in-out', + 'ease-out': 'ease-out' +} + +function getUnit (duration) { + const match = secondRegExp.exec(duration) + return match ? match[2] === 's' ? +match[1] * 1000 : +match[1] : 0 +} + +function parseTransitionSingleProp (vals, property) { + let setDuration = false + property = propName[property] + return vals.map(val => { + if (Object.keys(easingKey).includes(val) || cubicBezierExp.test(val)) { + return { easing: val } + } + if (timingFunctionExp.test(val)) { + return undefined + } + if (secondRegExp.test(val)) { + const newProperty = property || (!setDuration ? 'duration' : 'delay') + setDuration = true + return { + [newProperty]: getUnit(val) + } + } + return { + property: dash2hump(val) + } + }).filter(item => item !== undefined) +} + +function parseTransitionStyle (originalStyle) { + let transitionData = [] + Object.entries(originalStyle).filter(arr => arr[0].includes('transition')).forEach(([prop, value]) => { + if (prop === 'transition') { + const vals = parseValues(value, ',').map(item => { + return parseTransitionSingleProp(parseValues(item), prop).reduce((map, subItem) => { + return Object.assign(map, subItem) + }, {}) + }) + if (transitionData.length) { + transitionData = (vals.length > transitionData.length ? vals : transitionData).map((transitionItem, i) => { + const valItem = vals[i] || {} + const current = transitionData[i] || {} + return Object.assign({}, current, valItem) + }) + } else { + transitionData = vals + } + } else { + const vals = parseTransitionSingleProp(parseValues(value, ','), prop) + if (transitionData.length) { + transitionData = (vals.length > transitionData.length ? vals : transitionData).map((transitionItem, i) => { + const valItem = vals[i] || vals[vals.length - 1] + const current = transitionData[i] || transitionData[transitionData.length - 1] + return Object.assign({}, current, valItem) + }) + } else { + transitionData = vals + } + } + }) + const supportedProperties = [ + 'color', 'borderColor', 'borderBottomColor', 'borderLeftColor', 'borderRightColor', 'borderTopColor', + 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius', + 'borderRadius', 'borderBottomWidth', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth', 'borderWidth', + 'margin', 'marginBottom', 'marginLeft', 'marginRight', 'marginTop', 'marginHorizontal', 'marginVertical', + 'maxHeight', 'maxWidth', 'minHeight', 'minWidth', + 'padding', 'paddingBottom', 'paddingLeft', 'paddingRight', 'paddingTop', 'paddingHorizontal', 'paddingVertical', + 'fontSize', 'letterSpacing', + 'opacity', 'backgroundColor', + 'width', 'height', 'top', 'right', 'bottom', 'left', + 'rotateX', 'rotateY', 'rotateZ', 'scaleX', 'scaleY', 'skewX', 'skewY', 'translateX', 'translateY', + 'transformOrigin', 'transform' + ] + const supportedPropertySet = new Set(supportedProperties) + const transitionMap = transitionData.reduce((acc, cur) => { + const { property = '', duration = 0, delay = 0, easing = 'ease' } = cur + if ((supportedPropertySet.has(dash2hump(property)) || property === 'transform') && duration > 0) { + acc[property] = { duration, delay, easing } + } + return acc + }, {}) + return transitionMap +} + +describe('useTransitionHooks - parseTransitionStyle', () => { + describe('parseTransitionSingleProp', () => { + it('should parse duration in seconds', () => { + const result = parseTransitionSingleProp(['0.35s'], 'transitionDuration') + expect(result).toEqual([{ duration: 350 }]) + }) + + it('should parse duration in milliseconds', () => { + const result = parseTransitionSingleProp(['350ms'], 'transitionDuration') + expect(result).toEqual([{ duration: 350 }]) + }) + + it('should parse property name', () => { + const result = parseTransitionSingleProp(['transform'], 'transitionProperty') + expect(result).toEqual([{ property: 'transform' }]) + }) + + it('should parse easing name', () => { + const result = parseTransitionSingleProp(['ease-in-out'], 'transitionTimingFunction') + expect(result).toEqual([{ easing: 'ease-in-out' }]) + }) + + it('should parse cubic-bezier easing', () => { + const result = parseTransitionSingleProp(['cubic-bezier(0.25, 0.1, 0.25, 1.0)'], 'transitionTimingFunction') + expect(result).toEqual([{ easing: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)' }]) + }) + + it('should mark step timing functions as unsupported', () => { + const result = parseTransitionSingleProp(['step-start'], 'transitionTimingFunction') + expect(result).toEqual([]) + }) + + it('should parse delay values when property is transitionDelay', () => { + // propName['transitionDelay'] === 'delay', so all time values map to delay + const result = parseTransitionSingleProp(['0.35s', '0.1s'], 'transitionDelay') + expect(result).toEqual([{ delay: 350 }, { delay: 100 }]) + }) + + it('should parse combined duration and property from shorthand', () => { + const result = parseTransitionSingleProp(['0.35s', 'ease-in-out', 'transform'], 'transition') + expect(result).toHaveLength(3) + const merged = result.reduce((map, subItem) => Object.assign(map, subItem), {}) + expect(merged).toMatchObject({ duration: 350, easing: 'ease-in-out', property: 'transform' }) + }) + }) + + describe('parseTransitionStyle', () => { + it('should parse transition shorthand with all values', () => { + const style = { + transition: 'transform 0.35s ease-in-out' + } + const result = parseTransitionStyle(style) + expect(result).toHaveProperty('transform') + expect(result.transform.duration).toBe(350) + expect(result.transform.easing).toBe('ease-in-out') + }) + + it('should parse transition-property and transition-duration separately', () => { + const style = { + transitionProperty: 'transform', + transitionDuration: '0.7s' + } + const result = parseTransitionStyle(style) + expect(result).toHaveProperty('transform') + expect(result.transform.duration).toBe(700) + }) + + it('should parse transition-duration as 0 (zero)', () => { + const style = { + transition: 'transform 0.35s ease-in-out', + transitionDuration: '0' + } + const result = parseTransitionStyle(style) + // duration 0 should not be included in transitionMap + expect(result).toEqual({}) + }) + + it('should update duration when transition-duration changes', () => { + const initialStyle = { + transition: 'transform 0.35s ease-in-out' + } + const result1 = parseTransitionStyle(initialStyle) + expect(result1.transform.duration).toBe(350) + + const updatedStyle = { + transition: 'transform 0.7s ease-in-out' + } + const result2 = parseTransitionStyle(updatedStyle) + expect(result2.transform.duration).toBe(700) + }) + + it('should update duration when transition-duration is changed separately', () => { + const initialStyle = { + transitionProperty: 'transform', + transitionDuration: '0.35s' + } + const result1 = parseTransitionStyle(initialStyle) + expect(result1.transform.duration).toBe(350) + + const updatedStyle = { + transitionProperty: 'transform', + transitionDuration: '0.7s' + } + const result2 = parseTransitionStyle(updatedStyle) + expect(result2.transform.duration).toBe(700) + }) + + it('should switch duration between 0 and non-zero', () => { + const enabledStyle = { + transition: 'transform 0.35s ease-in-out' + } + expect(parseTransitionStyle(enabledStyle).transform.duration).toBe(350) + + const disabledStyle = { + transition: 'transform 0s ease-in-out' + } + expect(parseTransitionStyle(disabledStyle)).toEqual({}) + }) + + it('should handle dynamic enable/disable via separate transition-duration', () => { + const enabledStyle = { + transitionProperty: 'transform', + transitionDuration: '0.35s', + transitionTimingFunction: 'ease-in-out' + } + const result1 = parseTransitionStyle(enabledStyle) + expect(result1.transform.duration).toBe(350) + + const disabledStyle = { + transitionProperty: 'transform', + transitionDuration: '0', + transitionTimingFunction: 'ease-in-out' + } + const result2 = parseTransitionStyle(disabledStyle) + expect(result2).toEqual({}) + }) + + it('should preserve easing when duration changes', () => { + const style1 = { + transition: 'transform 0.35s ease-in-out' + } + const style2 = { + transition: 'transform 0.7s ease-in-out' + } + const style3 = { + transition: 'transform 1.2s linear' + } + + expect(parseTransitionStyle(style1).transform.easing).toBe('ease-in-out') + expect(parseTransitionStyle(style2).transform.easing).toBe('ease-in-out') + expect(parseTransitionStyle(style3).transform.easing).toBe('linear') + }) + + it('should handle multiple transition properties', () => { + const style = { + transition: 'opacity 0.3s ease, transform 0.5s ease-in-out' + } + const result = parseTransitionStyle(style) + expect(result.opacity.duration).toBe(300) + expect(result.transform.duration).toBe(500) + }) + + it('should parse non-shorthand transition properties in order', () => { + const style = { + transitionProperty: 'transform', + transitionDuration: '0.35s', + transitionTimingFunction: 'ease-in-out', + transitionDelay: '0.1s' + } + const result = parseTransitionStyle(style) + expect(result.transform).toEqual({ + duration: 350, + delay: 100, + easing: 'ease-in-out' + }) + }) + + it('should handle margin transition', () => { + const style = { + transition: 'marginLeft 0.3s ease' + } + const result = parseTransitionStyle(style) + expect(result.marginLeft.duration).toBe(300) + }) + + it('should handle opacity transition', () => { + const style = { + transition: 'opacity 0.25s ease-out' + } + const result = parseTransitionStyle(style) + expect(result.opacity.duration).toBe(250) + expect(result.opacity.easing).toBe('ease-out') + }) + + it('should handle width and height transitions', () => { + const style = { + transition: 'width 0.4s linear, height 0.6s ease' + } + const result = parseTransitionStyle(style) + expect(result.width.duration).toBe(400) + expect(result.height.duration).toBe(600) + }) + + it('should return empty object when no valid transition exists', () => { + const style = { + transition: 'transform step-start' + } + const result = parseTransitionStyle(style) + expect(result).toEqual({}) + }) + + it('should not include transition with duration 0 in result', () => { + const style = { + transition: 'transform 0s ease-in-out' + } + const result = parseTransitionStyle(style) + expect(result).toEqual({}) + }) + + it('should re-parse and return new object on each call with different style', () => { + const style1 = { transition: 'transform 0.35s ease-in-out' } + const style2 = { transition: 'transform 0.7s ease-in-out' } + const result1 = parseTransitionStyle(style1) + const result2 = parseTransitionStyle(style2) + expect(result1).not.toBe(result2) + expect(result1.transform.duration).toBe(350) + expect(result2.transform.duration).toBe(700) + }) + }) + + describe('dynamic transition-duration update scenarios', () => { + it('scenario: toggle transition on/off by switching duration between 0 and non-zero', () => { + const onStyle = { + transition: 'transform 0.35s ease-in-out' + } + const offStyle = { + transition: 'transform 0s ease-in-out' + } + expect(parseTransitionStyle(onStyle).transform.duration).toBe(350) + expect(parseTransitionStyle(offStyle)).toEqual({}) + }) + + it('scenario: computed style switching between 0.35s and 0.7s duration', () => { + const fastStyle = { + transition: 'transform 0.35s ease-in-out' + } + const slowStyle = { + transition: 'transform 0.7s ease-in-out' + } + expect(parseTransitionStyle(fastStyle).transform.duration).toBe(350) + expect(parseTransitionStyle(slowStyle).transform.duration).toBe(700) + }) + + it('scenario: computed style with transition-duration string "0" disables transition', () => { + const enabledStyle = { + transition: 'transform 0.35s ease-in-out', + transitionDuration: '0.7s' + } + const disabledStyle = { + transition: 'transform 0.35s ease-in-out', + transitionDuration: '0' + } + expect(parseTransitionStyle(enabledStyle).transform.duration).toBe(700) + expect(parseTransitionStyle(disabledStyle)).toEqual({}) + }) + + it('scenario: separate transition-duration override with longer duration', () => { + const style1 = { + transition: 'transform 0.35s ease-in-out', + transitionDuration: '1.2s' + } + const style2 = { + transition: 'transform 0.35s ease-in-out', + transitionDuration: '2s' + } + expect(parseTransitionStyle(style1).transform.duration).toBe(1200) + expect(parseTransitionStyle(style2).transform.duration).toBe(2000) + }) + + it('scenario: separate transition-duration override with string "0" disables transition', () => { + const enabledStyle = { + transition: 'transform 0.35s ease-in-out', + transitionDuration: '0.7s' + } + const disabledStyle = { + transition: 'transform 0.35s ease-in-out', + transitionDuration: '0' + } + expect(parseTransitionStyle(enabledStyle).transform.duration).toBe(700) + expect(parseTransitionStyle(disabledStyle)).toEqual({}) + }) + }) + + describe('delay and easing dynamic update', () => { + it('should parse and update delay via separate transition-delay', () => { + const style1 = { + transitionProperty: 'transform', + transitionDuration: '0.35s', + transitionTimingFunction: 'ease-in-out', + transitionDelay: '0.1s' + } + const result1 = parseTransitionStyle(style1) + expect(result1.transform).toEqual({ + duration: 350, + delay: 100, + easing: 'ease-in-out' + }) + + const style2 = { + transitionProperty: 'transform', + transitionDuration: '0.35s', + transitionTimingFunction: 'ease-in-out', + transitionDelay: '0.5s' + } + const result2 = parseTransitionStyle(style2) + expect(result2.transform.delay).toBe(500) + }) + + it('should parse and update easing via separate transition-timing-function', () => { + const style1 = { + transitionProperty: 'transform', + transitionDuration: '0.35s', + transitionTimingFunction: 'linear' + } + const result1 = parseTransitionStyle(style1) + expect(result1.transform.easing).toBe('linear') + + const style2 = { + transitionProperty: 'transform', + transitionDuration: '0.35s', + transitionTimingFunction: 'ease-out' + } + const result2 = parseTransitionStyle(style2) + expect(result2.transform.easing).toBe('ease-out') + }) + + it('should switch easing between named and cubic-bezier', () => { + const easeStyle = { + transition: 'transform 0.35s ease-in-out' + } + expect(parseTransitionStyle(easeStyle).transform.easing).toBe('ease-in-out') + + const cubicStyle = { + transition: 'transform 0.35s cubic-bezier(0.42, 0, 0.58, 1)' + } + const cubicResult = parseTransitionStyle(cubicStyle) + expect(cubicResult.transform.easing).toBe('cubic-bezier(0.42, 0, 0.58, 1)') + }) + + it('should override shorthand easing with separate transition-timing-function', () => { + const style1 = { + transition: 'transform 0.35s linear', + transitionTimingFunction: 'ease-in-out' + } + expect(parseTransitionStyle(style1).transform.easing).toBe('ease-in-out') + + const style2 = { + transition: 'transform 0.35s ease-in-out', + transitionTimingFunction: 'linear' + } + expect(parseTransitionStyle(style2).transform.easing).toBe('linear') + }) + + it('should override shorthand delay with separate transition-delay', () => { + const style1 = { + transition: 'transform 0.35s 0.1s linear', + transitionDelay: '0.5s' + } + expect(parseTransitionStyle(style1).transform.delay).toBe(500) + }) + }) + + describe('backgroundColor transition', () => { + it('should parse backgroundColor from shorthand', () => { + const style = { + transition: 'backgroundColor 0.3s ease-out' + } + const result = parseTransitionStyle(style) + expect(result.backgroundColor.duration).toBe(300) + expect(result.backgroundColor.easing).toBe('ease-out') + }) + + it('should parse backgroundColor with separate properties', () => { + const style = { + transitionProperty: 'backgroundColor', + transitionDuration: '0.5s', + transitionTimingFunction: 'ease' + } + const result = parseTransitionStyle(style) + expect(result.backgroundColor.duration).toBe(500) + expect(result.backgroundColor.easing).toBe('ease') + }) + }) + + describe('transform sub-properties', () => { + it('should parse rotateX via transform shorthand', () => { + const style = { + transition: 'transform 0.4s ease-in-out' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(400) + expect(result.transform.easing).toBe('ease-in-out') + }) + + it('should parse rotateY via transform shorthand', () => { + const style = { + transition: 'transform 0.5s ease-out' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(500) + }) + + it('should parse rotateZ via transform shorthand', () => { + const style = { + transition: 'transform 0.35s linear' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(350) + expect(result.transform.easing).toBe('linear') + }) + + it('should parse scaleX via transform shorthand', () => { + const style = { + transition: 'transform 0.3s ease-in-out' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(300) + }) + + it('should parse scaleY via transform shorthand', () => { + const style = { + transition: 'transform 0.4s ease-in-out' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(400) + }) + + it('should parse skewX via transform shorthand', () => { + const style = { + transition: 'transform 0.25s ease-out' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(250) + }) + + it('should parse skewY via transform shorthand', () => { + const style = { + transition: 'transform 0.25s ease-in-out' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(250) + }) + + it('should parse translateX via transform shorthand', () => { + const style = { + transition: 'transform 0.5s ease' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(500) + }) + + it('should parse translateY via transform shorthand', () => { + const style = { + transition: 'transform 0.5s ease' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(500) + }) + + it('should update duration for transform when using separate transition-duration', () => { + const style1 = { + transitionProperty: 'transform', + transitionDuration: '0.35s', + transitionTimingFunction: 'ease-in-out' + } + const result1 = parseTransitionStyle(style1) + expect(result1.transform.duration).toBe(350) + + const style2 = { + transitionProperty: 'transform', + transitionDuration: '0.8s', + transitionTimingFunction: 'linear' + } + const result2 = parseTransitionStyle(style2) + expect(result2.transform.duration).toBe(800) + expect(result2.transform.easing).toBe('linear') + }) + }) + + describe('opacity transition', () => { + it('should parse opacity from shorthand', () => { + const style = { + transition: 'opacity 0.25s ease-out' + } + const result = parseTransitionStyle(style) + expect(result.opacity.duration).toBe(250) + expect(result.opacity.easing).toBe('ease-out') + }) + + it('should parse opacity with delay', () => { + const style = { + transition: 'opacity 0.3s 0.1s ease-in-out' + } + const result = parseTransitionStyle(style) + expect(result.opacity.duration).toBe(300) + expect(result.opacity.delay).toBe(100) + }) + }) + + describe('combined opacity + transform transition', () => { + it('should parse both opacity and transform with different timings', () => { + const style = { + transition: 'opacity 0.3s ease, transform 0.5s ease-in-out' + } + const result = parseTransitionStyle(style) + expect(result.opacity.duration).toBe(300) + expect(result.transform.duration).toBe(500) + }) + + it('should parse combined transition with backgroundColor', () => { + const style = { + transition: 'opacity 0.2s ease, backgroundColor 0.4s ease-out, transform 0.6s linear' + } + const result = parseTransitionStyle(style) + expect(result.opacity.duration).toBe(200) + expect(result.backgroundColor.duration).toBe(400) + expect(result.transform.duration).toBe(600) + }) + + it('should handle shorthand transition overriding separate properties', () => { + const style = { + transitionProperty: 'opacity, transform', + transitionDuration: '0.3s, 0.5s', + transitionTimingFunction: 'ease, linear', + transition: 'opacity 0.7s ease-in-out, transform 0.8s ease-out' + } + const result = parseTransitionStyle(style) + // separate properties come first, shorthand comes later and overrides + expect(result.opacity.duration).toBe(700) + expect(result.opacity.easing).toBe('ease-in-out') + expect(result.transform.duration).toBe(800) + expect(result.transform.easing).toBe('ease-out') + }) + }) + + describe('duration/delay/easing override precedence', () => { + it('separate transition-duration should override shorthand duration', () => { + const style = { + transition: 'transform 0.35s ease-in-out', + transitionDuration: '1s' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(1000) + }) + + it('separate transition-delay should override shorthand delay', () => { + const style = { + transition: 'transform 0.35s 0.1s ease-in-out', + transitionDelay: '0.5s' + } + const result = parseTransitionStyle(style) + expect(result.transform.delay).toBe(500) + }) + + it('separate transition-timing-function should override shorthand easing', () => { + const style = { + transition: 'transform 0.35s ease-in-out', + transitionTimingFunction: 'linear' + } + const result = parseTransitionStyle(style) + expect(result.transform.easing).toBe('linear') + }) + + it('all separate properties should override shorthand completely', () => { + const shorthandOnly = { + transition: 'transform 0.35s 0.1s ease-in-out' + } + const separateOverrides = { + transition: 'transform 0.35s 0.1s ease-in-out', + transitionDuration: '0.7s', + transitionDelay: '0.3s', + transitionTimingFunction: 'linear' + } + const shorthandResult = parseTransitionStyle(shorthandOnly) + const overrideResult = parseTransitionStyle(separateOverrides) + expect(overrideResult.transform.duration).not.toBe(shorthandResult.transform.duration) + expect(overrideResult.transform.delay).not.toBe(shorthandResult.transform.delay) + expect(overrideResult.transform.easing).not.toBe(shorthandResult.transform.easing) + expect(overrideResult.transform.duration).toBe(700) + expect(overrideResult.transform.delay).toBe(300) + expect(overrideResult.transform.easing).toBe('linear') + }) + }) + + describe('edge cases for duration/delay/easing', () => { + it('should handle duration in milliseconds string', () => { + const style = { + transition: 'transform 500ms ease' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(500) + }) + + it('should handle duration with decimal seconds', () => { + const style = { + transition: 'transform 0.123s ease' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(123) + }) + + it('should handle delay with decimal seconds', () => { + const style = { + transition: 'transform 0.35s 0.25s ease' + } + const result = parseTransitionStyle(style) + expect(result.transform.duration).toBe(350) + expect(result.transform.delay).toBe(250) + }) + + it('should handle all five easing names', () => { + const easings = ['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out'] + easings.forEach(easing => { + const style = { + transition: `transform 0.35s ${easing}` + } + const result = parseTransitionStyle(style) + expect(result.transform.easing).toBe(easing) + }) + }) + + it('should handle cubic-bezier with various values', () => { + const style = { + transition: 'transform 0.35s cubic-bezier(0, 0, 1, 1)' + } + const result = parseTransitionStyle(style) + expect(result.transform.easing).toBe('cubic-bezier(0, 0, 1, 1)') + }) + }) +}) From 07c8e805906baa8d07c9fd35a62ecb5bb418b946 Mon Sep 17 00:00:00 2001 From: huajingwen Date: Wed, 8 Apr 2026 17:24:44 +0800 Subject: [PATCH 2/4] diff transition --- .../animationHooks/useTransitionHooks.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts index cc90fdf277..696190d694 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts @@ -43,6 +43,7 @@ const propName = { const behaviorExp = /^(allow-discrete|normal)$/ const defaultValueExp = /^(inherit|initial|revert|revert-layer|unset)$/ const timingFunctionExp = /^(step-start|step-end|steps)/ +const transitionKeys = ['transition', 'transitionDuration', 'transitionProperty', 'transitionTimingFunction', 'transitionDelay'] as const // cubic-bezier 参数解析 function getBezierParams (str: string) { // ease 0.25, 0.1, 0.25, 1.0 @@ -145,7 +146,7 @@ function parseTransitionStyle (originalStyle: ExtendedViewStyle) { const transitionMap = transitionData.reduce((acc, cur) => { // hasOwn(transitionSupportedProperty, dash2hump(val)) || val === Transform const { property = '', duration = 0, delay = 0, easing = Easing.inOut(Easing.ease) } = cur - if ((hasOwn(transitionSupportedProperty, dash2hump(property)) || property === 'transform') && duration > 0) { + if ((hasOwn(transitionSupportedProperty, dash2hump(property)) || property === 'transform') && duration >= 0) { acc[property] = { duration, delay, @@ -163,8 +164,8 @@ export default function useTransitionHooks (props: AnimationHooksPropsType const { style: originalStyle = {}, transitionend } = props // style变更标识(首次render不执行),初始值为0,首次渲染后为1 const animationDeps = useRef(0) - // 记录上次style map - // const lastStyleRef = useRef({} as {[propName: keyof ExtendedViewStyle]: number|string}) + // 记录上次 transition 相关属性,用于判断是否需要重新解析 + const lastTransitionStyleRef = useRef>({}) // ** 从 style 中获取动画数据 const transitionMap = useMemo(() => { return parseTransitionStyle(originalStyle) @@ -267,6 +268,19 @@ export default function useTransitionHooks (props: AnimationHooksPropsType animationDeps.current = 1 return } + // 仅当 transition 相关属性变化时才重新解析 + const prevStyle = lastTransitionStyleRef.current + const hasTransitionChanged = transitionKeys.some(key => prevStyle[key] !== originalStyle[key]) + if (!hasTransitionChanged) { + return + } + lastTransitionStyleRef.current = { + transition: originalStyle.transition, + transitionDuration: originalStyle.transitionDuration, + transitionProperty: originalStyle.transitionProperty, + transitionTimingFunction: originalStyle.transitionTimingFunction, + transitionDelay: originalStyle.transitionDelay + } // 从当前 style 解析最新 timing const currentTransitionMap = parseTransitionStyle(originalStyle) animatedKeys.forEach(key => { From 756bc696bd9f847b16f86d815d356f754e671259 Mon Sep 17 00:00:00 2001 From: huajingwen Date: Thu, 9 Apr 2026 19:09:33 +0800 Subject: [PATCH 3/4] =?UTF-8?q?add=20duration=20delay=20=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../animationHooks/useTransitionHooks.ts | 139 +++++++++--------- 1 file changed, 69 insertions(+), 70 deletions(-) diff --git a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts index 696190d694..9c8e2bc83b 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts @@ -166,17 +166,15 @@ export default function useTransitionHooks (props: AnimationHooksPropsType const animationDeps = useRef(0) // 记录上次 transition 相关属性,用于判断是否需要重新解析 const lastTransitionStyleRef = useRef>({}) - // ** 从 style 中获取动画数据 - const transitionMap = useMemo(() => { - return parseTransitionStyle(originalStyle) - }, []) // ** style prop sharedValue interpolateOutput: SharedValue - const { shareValMap, animatedKeys, animatedStyleKeys } = useMemo(() => { + const { shareValMap, animatedKeys, animatedStyleKeys, transitionMap } = useMemo(() => { // 记录需要执行动画的 propName const animatedKeys = [] as string[] // 有动画样式的 style key(useAnimatedStyle使用) const animatedStyleKeys = [] as (string|string[])[] const transforms = [] as string[] + // ** 从 style 中获取动画数据 + const transitionMap = parseTransitionStyle(originalStyle) const shareValMap = Object.keys(transitionMap).reduce((valMap, property) => { // const { property } = transition || {} if (property === 'transform') { @@ -201,64 +199,69 @@ export default function useTransitionHooks (props: AnimationHooksPropsType return { shareValMap, animatedKeys, - animatedStyleKeys + animatedStyleKeys, + transitionMap } }, []) + const transitionMapRef = useRef(transitionMap) const runOnJSCallbackRef = useRef({}) const runOnJSCallback = useRunOnJSCallback(runOnJSCallbackRef) // 根据 animation action 创建&驱动动画 - function createAnimation (key: string, transitionKey: string, timing: { delay?: number, duration: number, easing: EasingFunction }) { + function createAnimation () { let transformTransitionendDone = false - const { delay = 0, duration, easing } = timing - const isTransformKey = isTransform(key) - let ruleV = originalStyle[key] - if (isTransformKey) { - const transform = getTransformObj(originalStyle.transform!) - ruleV = transform[key] - } - let toVal = ruleV !== undefined - ? ruleV - : transitionSupportedProperty[key] - const shareVal = shareValMap[key].value - if (percentExp.test(`${toVal}`) && !percentExp.test(shareVal as string) && !isNaN(+shareVal)) { - // 获取到的toVal为百分比格式化shareValMap为百分比 - shareValMap[key].value = `${shareVal as number * 100}%` - } else if (percentExp.test(shareVal as string) && !percentExp.test(toVal as string) && !isNaN(+toVal)) { - // 初始值为百分比则格式化toVal为百分比 - toVal = `${toVal * 100}%` - } else if (typeof toVal !== typeof shareVal) { - // 动画起始值和终态值类型不一致报错提示一下 - warn(`[Mpx runtime error]: Value types of property ${key} must be consistent during the animation`) - } - if ((toVal === 'auto' && !isNaN(+shareVal)) || (shareVal === 'auto' && !isNaN(+toVal))) { - // 有 auto 直接赋值不做动画 - shareValMap[key].value = toVal - } else { - // console.log(`key=${key} oldVal=${shareValMap[key].value} newVal=${toVal}`) - // console.log('animationOptions=', { delay, duration, easing }) - let callback - if (transitionend && (!isTransformKey || !transformTransitionendDone)) { - runOnJSCallbackRef.current = { - animationCallback: (duration: number, finished: boolean, current?: AnimatableValue) => { - transitionend(finished, current, duration) + animatedKeys.forEach(key => { + // console.log(`createAnimation key=${key} originalStyle=`, originalStyle) + const isTransformKey = isTransform(key) + let ruleV = originalStyle[key] + if (isTransformKey) { + const transform = getTransformObj(originalStyle.transform!) + ruleV = transform[key] + } + let toVal = ruleV !== undefined + ? ruleV + : transitionSupportedProperty[key] + const shareVal = shareValMap[key].value + if (percentExp.test(`${toVal}`) && !percentExp.test(shareVal as string) && !isNaN(+shareVal)) { + // 获取到的toVal为百分比格式化shareValMap为百分比 + shareValMap[key].value = `${shareVal as number * 100}%` + } else if (percentExp.test(shareVal as string) && !percentExp.test(toVal as string) && !isNaN(+toVal)) { + // 初始值为百分比则格式化toVal为百分比 + toVal = `${toVal * 100}%` + } else if (typeof toVal !== typeof shareVal) { + // 动画起始值和终态值类型不一致报错提示一下 + warn(`[Mpx runtime error]: Value types of property ${key} must be consistent during the animation`) + } + if ((toVal === 'auto' && !isNaN(+shareVal)) || (shareVal === 'auto' && !isNaN(+toVal))) { + // 有 auto 直接赋值不做动画 + shareValMap[key].value = toVal + } else { + // console.log(`key=${key} oldVal=${shareValMap[key].value} newVal=${toVal}`) + const { delay = 0, duration = 0, easing = Easing.inOut(Easing.ease) } = transitionMapRef.current[isTransformKey ? 'transform' : key] || {} + // console.log('animationOptions=', { delay, duration, easing }) + let callback + if (transitionend && (!isTransformKey || !transformTransitionendDone)) { + runOnJSCallbackRef.current = { + animationCallback: (duration: number, finished: boolean, current?: AnimatableValue) => { + transitionend(finished, current, duration) + } } - } - callback = (finished?: boolean, current?: AnimatableValue) => { - 'worklet' - // 动画结束后设置下一次transformOrigin - if (finished) { - runOnJS(runOnJSCallback)('animationCallback', duration, finished, current) + callback = (finished?: boolean, current?: AnimatableValue) => { + 'worklet' + // 动画结束后设置下一次transformOrigin + if (finished) { + runOnJS(runOnJSCallback)('animationCallback', duration, finished, current) + } } } + const animation = getAnimation({ key, value: toVal! }, { delay, duration, easing }, callback) + // Todo transform 有多个属性时也仅执行一次 transitionend(对齐wx) + if (isTransformKey) { + transformTransitionendDone = true + } + shareValMap[key].value = animation } - const animation = getAnimation({ key, value: toVal! }, { delay, duration, easing }, callback) - // Todo transform 有多个属性时也仅执行一次 transitionend(对齐wx) - if (isTransformKey) { - transformTransitionendDone = true - } - shareValMap[key].value = animation - } - // console.log(`useTransitionHooks, ${key}=`, animation) + // console.log(`useTransitionHooks, ${key}=`, animation) + }) } // ** style 更新 useEffect(() => { @@ -271,25 +274,21 @@ export default function useTransitionHooks (props: AnimationHooksPropsType // 仅当 transition 相关属性变化时才重新解析 const prevStyle = lastTransitionStyleRef.current const hasTransitionChanged = transitionKeys.some(key => prevStyle[key] !== originalStyle[key]) - if (!hasTransitionChanged) { - return - } - lastTransitionStyleRef.current = { - transition: originalStyle.transition, - transitionDuration: originalStyle.transitionDuration, - transitionProperty: originalStyle.transitionProperty, - transitionTimingFunction: originalStyle.transitionTimingFunction, - transitionDelay: originalStyle.transitionDelay + const currentTransitionMap = hasTransitionChanged + ? parseTransitionStyle(originalStyle) + : transitionMapRef.current + if (hasTransitionChanged) { + lastTransitionStyleRef.current = { + transition: originalStyle.transition, + transitionDuration: originalStyle.transitionDuration, + transitionProperty: originalStyle.transitionProperty, + transitionTimingFunction: originalStyle.transitionTimingFunction, + transitionDelay: originalStyle.transitionDelay + } + transitionMapRef.current = currentTransitionMap } // 从当前 style 解析最新 timing - const currentTransitionMap = parseTransitionStyle(originalStyle) - animatedKeys.forEach(key => { - const transitionKey = isTransform(key) ? 'transform' : key - const timing = currentTransitionMap[transitionKey] // || transitionMap[transitionKey] - if (timing) { - createAnimation(key, transitionKey, timing) - } - }) + createAnimation() }, [originalStyle]) // ** 清空动画 useEffect(() => { From 0a9087857ef16c004f7d168f3eb38bb7fa112fa4 Mon Sep 17 00:00:00 2001 From: huajingwen Date: Thu, 9 Apr 2026 19:36:50 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../animationHooks/useTransitionHooks.ts | 2 +- .../test/runtime/react/animationHooks.spec.js | 769 ------------------ 2 files changed, 1 insertion(+), 770 deletions(-) delete mode 100644 packages/webpack-plugin/test/runtime/react/animationHooks.spec.js diff --git a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts index 9c8e2bc83b..f6f4ce63bc 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/useTransitionHooks.ts @@ -275,6 +275,7 @@ export default function useTransitionHooks (props: AnimationHooksPropsType const prevStyle = lastTransitionStyleRef.current const hasTransitionChanged = transitionKeys.some(key => prevStyle[key] !== originalStyle[key]) const currentTransitionMap = hasTransitionChanged + // 从当前 style 解析最新 timing ? parseTransitionStyle(originalStyle) : transitionMapRef.current if (hasTransitionChanged) { @@ -287,7 +288,6 @@ export default function useTransitionHooks (props: AnimationHooksPropsType } transitionMapRef.current = currentTransitionMap } - // 从当前 style 解析最新 timing createAnimation() }, [originalStyle]) // ** 清空动画 diff --git a/packages/webpack-plugin/test/runtime/react/animationHooks.spec.js b/packages/webpack-plugin/test/runtime/react/animationHooks.spec.js deleted file mode 100644 index 74b47b65fe..0000000000 --- a/packages/webpack-plugin/test/runtime/react/animationHooks.spec.js +++ /dev/null @@ -1,769 +0,0 @@ -// Inline parseValues and pure parsing functions for isolated testing. -// Keep in sync with the actual implementation in animationHooks/useTransitionHooks.ts. - -function parseValues (str, char = ' ') { - let stack = 0 - let temp = '' - const result = [] - for (let i = 0; i < str.length; i++) { - if (str[i] === '(') { - stack++ - } else if (str[i] === ')') { - stack-- - } - if (stack !== 0 || str[i] !== char) { - temp += str[i] - } - if ((stack === 0 && str[i] === char) || i === str.length - 1) { - result.push(temp.trim()) - temp = '' - } - } - return result -} - -function dash2hump (str) { - return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : '')) -} - -const propName = { - transition: '', - transitionDuration: 'duration', - transitionProperty: 'property', - transitionTimingFunction: 'easing', - transitionDelay: 'delay' -} -const timingFunctionExp = /^(step-start|step-end|steps)/ -const secondRegExp = /^\s*(\d*(?:\.\d+)?)(s|ms)?\s*$/ -const cubicBezierExp = /cubic-bezier\(["']?(.*?)["']?\)/ -const easingKey = { - linear: 'linear', - ease: 'ease', - 'ease-in': 'ease-in', - 'ease-in-out': 'ease-in-out', - 'ease-out': 'ease-out' -} - -function getUnit (duration) { - const match = secondRegExp.exec(duration) - return match ? match[2] === 's' ? +match[1] * 1000 : +match[1] : 0 -} - -function parseTransitionSingleProp (vals, property) { - let setDuration = false - property = propName[property] - return vals.map(val => { - if (Object.keys(easingKey).includes(val) || cubicBezierExp.test(val)) { - return { easing: val } - } - if (timingFunctionExp.test(val)) { - return undefined - } - if (secondRegExp.test(val)) { - const newProperty = property || (!setDuration ? 'duration' : 'delay') - setDuration = true - return { - [newProperty]: getUnit(val) - } - } - return { - property: dash2hump(val) - } - }).filter(item => item !== undefined) -} - -function parseTransitionStyle (originalStyle) { - let transitionData = [] - Object.entries(originalStyle).filter(arr => arr[0].includes('transition')).forEach(([prop, value]) => { - if (prop === 'transition') { - const vals = parseValues(value, ',').map(item => { - return parseTransitionSingleProp(parseValues(item), prop).reduce((map, subItem) => { - return Object.assign(map, subItem) - }, {}) - }) - if (transitionData.length) { - transitionData = (vals.length > transitionData.length ? vals : transitionData).map((transitionItem, i) => { - const valItem = vals[i] || {} - const current = transitionData[i] || {} - return Object.assign({}, current, valItem) - }) - } else { - transitionData = vals - } - } else { - const vals = parseTransitionSingleProp(parseValues(value, ','), prop) - if (transitionData.length) { - transitionData = (vals.length > transitionData.length ? vals : transitionData).map((transitionItem, i) => { - const valItem = vals[i] || vals[vals.length - 1] - const current = transitionData[i] || transitionData[transitionData.length - 1] - return Object.assign({}, current, valItem) - }) - } else { - transitionData = vals - } - } - }) - const supportedProperties = [ - 'color', 'borderColor', 'borderBottomColor', 'borderLeftColor', 'borderRightColor', 'borderTopColor', - 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius', - 'borderRadius', 'borderBottomWidth', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth', 'borderWidth', - 'margin', 'marginBottom', 'marginLeft', 'marginRight', 'marginTop', 'marginHorizontal', 'marginVertical', - 'maxHeight', 'maxWidth', 'minHeight', 'minWidth', - 'padding', 'paddingBottom', 'paddingLeft', 'paddingRight', 'paddingTop', 'paddingHorizontal', 'paddingVertical', - 'fontSize', 'letterSpacing', - 'opacity', 'backgroundColor', - 'width', 'height', 'top', 'right', 'bottom', 'left', - 'rotateX', 'rotateY', 'rotateZ', 'scaleX', 'scaleY', 'skewX', 'skewY', 'translateX', 'translateY', - 'transformOrigin', 'transform' - ] - const supportedPropertySet = new Set(supportedProperties) - const transitionMap = transitionData.reduce((acc, cur) => { - const { property = '', duration = 0, delay = 0, easing = 'ease' } = cur - if ((supportedPropertySet.has(dash2hump(property)) || property === 'transform') && duration > 0) { - acc[property] = { duration, delay, easing } - } - return acc - }, {}) - return transitionMap -} - -describe('useTransitionHooks - parseTransitionStyle', () => { - describe('parseTransitionSingleProp', () => { - it('should parse duration in seconds', () => { - const result = parseTransitionSingleProp(['0.35s'], 'transitionDuration') - expect(result).toEqual([{ duration: 350 }]) - }) - - it('should parse duration in milliseconds', () => { - const result = parseTransitionSingleProp(['350ms'], 'transitionDuration') - expect(result).toEqual([{ duration: 350 }]) - }) - - it('should parse property name', () => { - const result = parseTransitionSingleProp(['transform'], 'transitionProperty') - expect(result).toEqual([{ property: 'transform' }]) - }) - - it('should parse easing name', () => { - const result = parseTransitionSingleProp(['ease-in-out'], 'transitionTimingFunction') - expect(result).toEqual([{ easing: 'ease-in-out' }]) - }) - - it('should parse cubic-bezier easing', () => { - const result = parseTransitionSingleProp(['cubic-bezier(0.25, 0.1, 0.25, 1.0)'], 'transitionTimingFunction') - expect(result).toEqual([{ easing: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)' }]) - }) - - it('should mark step timing functions as unsupported', () => { - const result = parseTransitionSingleProp(['step-start'], 'transitionTimingFunction') - expect(result).toEqual([]) - }) - - it('should parse delay values when property is transitionDelay', () => { - // propName['transitionDelay'] === 'delay', so all time values map to delay - const result = parseTransitionSingleProp(['0.35s', '0.1s'], 'transitionDelay') - expect(result).toEqual([{ delay: 350 }, { delay: 100 }]) - }) - - it('should parse combined duration and property from shorthand', () => { - const result = parseTransitionSingleProp(['0.35s', 'ease-in-out', 'transform'], 'transition') - expect(result).toHaveLength(3) - const merged = result.reduce((map, subItem) => Object.assign(map, subItem), {}) - expect(merged).toMatchObject({ duration: 350, easing: 'ease-in-out', property: 'transform' }) - }) - }) - - describe('parseTransitionStyle', () => { - it('should parse transition shorthand with all values', () => { - const style = { - transition: 'transform 0.35s ease-in-out' - } - const result = parseTransitionStyle(style) - expect(result).toHaveProperty('transform') - expect(result.transform.duration).toBe(350) - expect(result.transform.easing).toBe('ease-in-out') - }) - - it('should parse transition-property and transition-duration separately', () => { - const style = { - transitionProperty: 'transform', - transitionDuration: '0.7s' - } - const result = parseTransitionStyle(style) - expect(result).toHaveProperty('transform') - expect(result.transform.duration).toBe(700) - }) - - it('should parse transition-duration as 0 (zero)', () => { - const style = { - transition: 'transform 0.35s ease-in-out', - transitionDuration: '0' - } - const result = parseTransitionStyle(style) - // duration 0 should not be included in transitionMap - expect(result).toEqual({}) - }) - - it('should update duration when transition-duration changes', () => { - const initialStyle = { - transition: 'transform 0.35s ease-in-out' - } - const result1 = parseTransitionStyle(initialStyle) - expect(result1.transform.duration).toBe(350) - - const updatedStyle = { - transition: 'transform 0.7s ease-in-out' - } - const result2 = parseTransitionStyle(updatedStyle) - expect(result2.transform.duration).toBe(700) - }) - - it('should update duration when transition-duration is changed separately', () => { - const initialStyle = { - transitionProperty: 'transform', - transitionDuration: '0.35s' - } - const result1 = parseTransitionStyle(initialStyle) - expect(result1.transform.duration).toBe(350) - - const updatedStyle = { - transitionProperty: 'transform', - transitionDuration: '0.7s' - } - const result2 = parseTransitionStyle(updatedStyle) - expect(result2.transform.duration).toBe(700) - }) - - it('should switch duration between 0 and non-zero', () => { - const enabledStyle = { - transition: 'transform 0.35s ease-in-out' - } - expect(parseTransitionStyle(enabledStyle).transform.duration).toBe(350) - - const disabledStyle = { - transition: 'transform 0s ease-in-out' - } - expect(parseTransitionStyle(disabledStyle)).toEqual({}) - }) - - it('should handle dynamic enable/disable via separate transition-duration', () => { - const enabledStyle = { - transitionProperty: 'transform', - transitionDuration: '0.35s', - transitionTimingFunction: 'ease-in-out' - } - const result1 = parseTransitionStyle(enabledStyle) - expect(result1.transform.duration).toBe(350) - - const disabledStyle = { - transitionProperty: 'transform', - transitionDuration: '0', - transitionTimingFunction: 'ease-in-out' - } - const result2 = parseTransitionStyle(disabledStyle) - expect(result2).toEqual({}) - }) - - it('should preserve easing when duration changes', () => { - const style1 = { - transition: 'transform 0.35s ease-in-out' - } - const style2 = { - transition: 'transform 0.7s ease-in-out' - } - const style3 = { - transition: 'transform 1.2s linear' - } - - expect(parseTransitionStyle(style1).transform.easing).toBe('ease-in-out') - expect(parseTransitionStyle(style2).transform.easing).toBe('ease-in-out') - expect(parseTransitionStyle(style3).transform.easing).toBe('linear') - }) - - it('should handle multiple transition properties', () => { - const style = { - transition: 'opacity 0.3s ease, transform 0.5s ease-in-out' - } - const result = parseTransitionStyle(style) - expect(result.opacity.duration).toBe(300) - expect(result.transform.duration).toBe(500) - }) - - it('should parse non-shorthand transition properties in order', () => { - const style = { - transitionProperty: 'transform', - transitionDuration: '0.35s', - transitionTimingFunction: 'ease-in-out', - transitionDelay: '0.1s' - } - const result = parseTransitionStyle(style) - expect(result.transform).toEqual({ - duration: 350, - delay: 100, - easing: 'ease-in-out' - }) - }) - - it('should handle margin transition', () => { - const style = { - transition: 'marginLeft 0.3s ease' - } - const result = parseTransitionStyle(style) - expect(result.marginLeft.duration).toBe(300) - }) - - it('should handle opacity transition', () => { - const style = { - transition: 'opacity 0.25s ease-out' - } - const result = parseTransitionStyle(style) - expect(result.opacity.duration).toBe(250) - expect(result.opacity.easing).toBe('ease-out') - }) - - it('should handle width and height transitions', () => { - const style = { - transition: 'width 0.4s linear, height 0.6s ease' - } - const result = parseTransitionStyle(style) - expect(result.width.duration).toBe(400) - expect(result.height.duration).toBe(600) - }) - - it('should return empty object when no valid transition exists', () => { - const style = { - transition: 'transform step-start' - } - const result = parseTransitionStyle(style) - expect(result).toEqual({}) - }) - - it('should not include transition with duration 0 in result', () => { - const style = { - transition: 'transform 0s ease-in-out' - } - const result = parseTransitionStyle(style) - expect(result).toEqual({}) - }) - - it('should re-parse and return new object on each call with different style', () => { - const style1 = { transition: 'transform 0.35s ease-in-out' } - const style2 = { transition: 'transform 0.7s ease-in-out' } - const result1 = parseTransitionStyle(style1) - const result2 = parseTransitionStyle(style2) - expect(result1).not.toBe(result2) - expect(result1.transform.duration).toBe(350) - expect(result2.transform.duration).toBe(700) - }) - }) - - describe('dynamic transition-duration update scenarios', () => { - it('scenario: toggle transition on/off by switching duration between 0 and non-zero', () => { - const onStyle = { - transition: 'transform 0.35s ease-in-out' - } - const offStyle = { - transition: 'transform 0s ease-in-out' - } - expect(parseTransitionStyle(onStyle).transform.duration).toBe(350) - expect(parseTransitionStyle(offStyle)).toEqual({}) - }) - - it('scenario: computed style switching between 0.35s and 0.7s duration', () => { - const fastStyle = { - transition: 'transform 0.35s ease-in-out' - } - const slowStyle = { - transition: 'transform 0.7s ease-in-out' - } - expect(parseTransitionStyle(fastStyle).transform.duration).toBe(350) - expect(parseTransitionStyle(slowStyle).transform.duration).toBe(700) - }) - - it('scenario: computed style with transition-duration string "0" disables transition', () => { - const enabledStyle = { - transition: 'transform 0.35s ease-in-out', - transitionDuration: '0.7s' - } - const disabledStyle = { - transition: 'transform 0.35s ease-in-out', - transitionDuration: '0' - } - expect(parseTransitionStyle(enabledStyle).transform.duration).toBe(700) - expect(parseTransitionStyle(disabledStyle)).toEqual({}) - }) - - it('scenario: separate transition-duration override with longer duration', () => { - const style1 = { - transition: 'transform 0.35s ease-in-out', - transitionDuration: '1.2s' - } - const style2 = { - transition: 'transform 0.35s ease-in-out', - transitionDuration: '2s' - } - expect(parseTransitionStyle(style1).transform.duration).toBe(1200) - expect(parseTransitionStyle(style2).transform.duration).toBe(2000) - }) - - it('scenario: separate transition-duration override with string "0" disables transition', () => { - const enabledStyle = { - transition: 'transform 0.35s ease-in-out', - transitionDuration: '0.7s' - } - const disabledStyle = { - transition: 'transform 0.35s ease-in-out', - transitionDuration: '0' - } - expect(parseTransitionStyle(enabledStyle).transform.duration).toBe(700) - expect(parseTransitionStyle(disabledStyle)).toEqual({}) - }) - }) - - describe('delay and easing dynamic update', () => { - it('should parse and update delay via separate transition-delay', () => { - const style1 = { - transitionProperty: 'transform', - transitionDuration: '0.35s', - transitionTimingFunction: 'ease-in-out', - transitionDelay: '0.1s' - } - const result1 = parseTransitionStyle(style1) - expect(result1.transform).toEqual({ - duration: 350, - delay: 100, - easing: 'ease-in-out' - }) - - const style2 = { - transitionProperty: 'transform', - transitionDuration: '0.35s', - transitionTimingFunction: 'ease-in-out', - transitionDelay: '0.5s' - } - const result2 = parseTransitionStyle(style2) - expect(result2.transform.delay).toBe(500) - }) - - it('should parse and update easing via separate transition-timing-function', () => { - const style1 = { - transitionProperty: 'transform', - transitionDuration: '0.35s', - transitionTimingFunction: 'linear' - } - const result1 = parseTransitionStyle(style1) - expect(result1.transform.easing).toBe('linear') - - const style2 = { - transitionProperty: 'transform', - transitionDuration: '0.35s', - transitionTimingFunction: 'ease-out' - } - const result2 = parseTransitionStyle(style2) - expect(result2.transform.easing).toBe('ease-out') - }) - - it('should switch easing between named and cubic-bezier', () => { - const easeStyle = { - transition: 'transform 0.35s ease-in-out' - } - expect(parseTransitionStyle(easeStyle).transform.easing).toBe('ease-in-out') - - const cubicStyle = { - transition: 'transform 0.35s cubic-bezier(0.42, 0, 0.58, 1)' - } - const cubicResult = parseTransitionStyle(cubicStyle) - expect(cubicResult.transform.easing).toBe('cubic-bezier(0.42, 0, 0.58, 1)') - }) - - it('should override shorthand easing with separate transition-timing-function', () => { - const style1 = { - transition: 'transform 0.35s linear', - transitionTimingFunction: 'ease-in-out' - } - expect(parseTransitionStyle(style1).transform.easing).toBe('ease-in-out') - - const style2 = { - transition: 'transform 0.35s ease-in-out', - transitionTimingFunction: 'linear' - } - expect(parseTransitionStyle(style2).transform.easing).toBe('linear') - }) - - it('should override shorthand delay with separate transition-delay', () => { - const style1 = { - transition: 'transform 0.35s 0.1s linear', - transitionDelay: '0.5s' - } - expect(parseTransitionStyle(style1).transform.delay).toBe(500) - }) - }) - - describe('backgroundColor transition', () => { - it('should parse backgroundColor from shorthand', () => { - const style = { - transition: 'backgroundColor 0.3s ease-out' - } - const result = parseTransitionStyle(style) - expect(result.backgroundColor.duration).toBe(300) - expect(result.backgroundColor.easing).toBe('ease-out') - }) - - it('should parse backgroundColor with separate properties', () => { - const style = { - transitionProperty: 'backgroundColor', - transitionDuration: '0.5s', - transitionTimingFunction: 'ease' - } - const result = parseTransitionStyle(style) - expect(result.backgroundColor.duration).toBe(500) - expect(result.backgroundColor.easing).toBe('ease') - }) - }) - - describe('transform sub-properties', () => { - it('should parse rotateX via transform shorthand', () => { - const style = { - transition: 'transform 0.4s ease-in-out' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(400) - expect(result.transform.easing).toBe('ease-in-out') - }) - - it('should parse rotateY via transform shorthand', () => { - const style = { - transition: 'transform 0.5s ease-out' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(500) - }) - - it('should parse rotateZ via transform shorthand', () => { - const style = { - transition: 'transform 0.35s linear' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(350) - expect(result.transform.easing).toBe('linear') - }) - - it('should parse scaleX via transform shorthand', () => { - const style = { - transition: 'transform 0.3s ease-in-out' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(300) - }) - - it('should parse scaleY via transform shorthand', () => { - const style = { - transition: 'transform 0.4s ease-in-out' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(400) - }) - - it('should parse skewX via transform shorthand', () => { - const style = { - transition: 'transform 0.25s ease-out' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(250) - }) - - it('should parse skewY via transform shorthand', () => { - const style = { - transition: 'transform 0.25s ease-in-out' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(250) - }) - - it('should parse translateX via transform shorthand', () => { - const style = { - transition: 'transform 0.5s ease' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(500) - }) - - it('should parse translateY via transform shorthand', () => { - const style = { - transition: 'transform 0.5s ease' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(500) - }) - - it('should update duration for transform when using separate transition-duration', () => { - const style1 = { - transitionProperty: 'transform', - transitionDuration: '0.35s', - transitionTimingFunction: 'ease-in-out' - } - const result1 = parseTransitionStyle(style1) - expect(result1.transform.duration).toBe(350) - - const style2 = { - transitionProperty: 'transform', - transitionDuration: '0.8s', - transitionTimingFunction: 'linear' - } - const result2 = parseTransitionStyle(style2) - expect(result2.transform.duration).toBe(800) - expect(result2.transform.easing).toBe('linear') - }) - }) - - describe('opacity transition', () => { - it('should parse opacity from shorthand', () => { - const style = { - transition: 'opacity 0.25s ease-out' - } - const result = parseTransitionStyle(style) - expect(result.opacity.duration).toBe(250) - expect(result.opacity.easing).toBe('ease-out') - }) - - it('should parse opacity with delay', () => { - const style = { - transition: 'opacity 0.3s 0.1s ease-in-out' - } - const result = parseTransitionStyle(style) - expect(result.opacity.duration).toBe(300) - expect(result.opacity.delay).toBe(100) - }) - }) - - describe('combined opacity + transform transition', () => { - it('should parse both opacity and transform with different timings', () => { - const style = { - transition: 'opacity 0.3s ease, transform 0.5s ease-in-out' - } - const result = parseTransitionStyle(style) - expect(result.opacity.duration).toBe(300) - expect(result.transform.duration).toBe(500) - }) - - it('should parse combined transition with backgroundColor', () => { - const style = { - transition: 'opacity 0.2s ease, backgroundColor 0.4s ease-out, transform 0.6s linear' - } - const result = parseTransitionStyle(style) - expect(result.opacity.duration).toBe(200) - expect(result.backgroundColor.duration).toBe(400) - expect(result.transform.duration).toBe(600) - }) - - it('should handle shorthand transition overriding separate properties', () => { - const style = { - transitionProperty: 'opacity, transform', - transitionDuration: '0.3s, 0.5s', - transitionTimingFunction: 'ease, linear', - transition: 'opacity 0.7s ease-in-out, transform 0.8s ease-out' - } - const result = parseTransitionStyle(style) - // separate properties come first, shorthand comes later and overrides - expect(result.opacity.duration).toBe(700) - expect(result.opacity.easing).toBe('ease-in-out') - expect(result.transform.duration).toBe(800) - expect(result.transform.easing).toBe('ease-out') - }) - }) - - describe('duration/delay/easing override precedence', () => { - it('separate transition-duration should override shorthand duration', () => { - const style = { - transition: 'transform 0.35s ease-in-out', - transitionDuration: '1s' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(1000) - }) - - it('separate transition-delay should override shorthand delay', () => { - const style = { - transition: 'transform 0.35s 0.1s ease-in-out', - transitionDelay: '0.5s' - } - const result = parseTransitionStyle(style) - expect(result.transform.delay).toBe(500) - }) - - it('separate transition-timing-function should override shorthand easing', () => { - const style = { - transition: 'transform 0.35s ease-in-out', - transitionTimingFunction: 'linear' - } - const result = parseTransitionStyle(style) - expect(result.transform.easing).toBe('linear') - }) - - it('all separate properties should override shorthand completely', () => { - const shorthandOnly = { - transition: 'transform 0.35s 0.1s ease-in-out' - } - const separateOverrides = { - transition: 'transform 0.35s 0.1s ease-in-out', - transitionDuration: '0.7s', - transitionDelay: '0.3s', - transitionTimingFunction: 'linear' - } - const shorthandResult = parseTransitionStyle(shorthandOnly) - const overrideResult = parseTransitionStyle(separateOverrides) - expect(overrideResult.transform.duration).not.toBe(shorthandResult.transform.duration) - expect(overrideResult.transform.delay).not.toBe(shorthandResult.transform.delay) - expect(overrideResult.transform.easing).not.toBe(shorthandResult.transform.easing) - expect(overrideResult.transform.duration).toBe(700) - expect(overrideResult.transform.delay).toBe(300) - expect(overrideResult.transform.easing).toBe('linear') - }) - }) - - describe('edge cases for duration/delay/easing', () => { - it('should handle duration in milliseconds string', () => { - const style = { - transition: 'transform 500ms ease' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(500) - }) - - it('should handle duration with decimal seconds', () => { - const style = { - transition: 'transform 0.123s ease' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(123) - }) - - it('should handle delay with decimal seconds', () => { - const style = { - transition: 'transform 0.35s 0.25s ease' - } - const result = parseTransitionStyle(style) - expect(result.transform.duration).toBe(350) - expect(result.transform.delay).toBe(250) - }) - - it('should handle all five easing names', () => { - const easings = ['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out'] - easings.forEach(easing => { - const style = { - transition: `transform 0.35s ${easing}` - } - const result = parseTransitionStyle(style) - expect(result.transform.easing).toBe(easing) - }) - }) - - it('should handle cubic-bezier with various values', () => { - const style = { - transition: 'transform 0.35s cubic-bezier(0, 0, 1, 1)' - } - const result = parseTransitionStyle(style) - expect(result.transform.easing).toBe('cubic-bezier(0, 0, 1, 1)') - }) - }) -})