From e3f9bc4f5dfda7db19a8099460c4890a5b0d8f99 Mon Sep 17 00:00:00 2001 From: Bartosz Sofinski Date: Tue, 7 Jul 2026 19:11:12 +0200 Subject: [PATCH] Merge state hooks in usePseudoStates and useStyleTransition --- .../src/native/modules/usePseudoStates.js | 56 +++++++------ .../src/native/modules/useStyleTransition.js | 80 +++++++++---------- 2 files changed, 72 insertions(+), 64 deletions(-) diff --git a/packages/react-strict-dom/src/native/modules/usePseudoStates.js b/packages/react-strict-dom/src/native/modules/usePseudoStates.js index 5a954b4a..249503b5 100644 --- a/packages/react-strict-dom/src/native/modules/usePseudoStates.js +++ b/packages/react-strict-dom/src/native/modules/usePseudoStates.js @@ -30,17 +30,29 @@ type Interaction = { handlers: ?InteractionHandlers }; +type PseudoState = { + +active: boolean, + +focus: boolean, + +mouseHover: boolean, + +pointerHover: boolean +}; + +const defaultState: PseudoState = { + active: false, + focus: false, + mouseHover: false, + pointerHover: false +}; + export function usePseudoStates(style: Style): Interaction { - const [focus, setFocus] = React.useState(false); - const [mouseHover, setMouseHover] = React.useState(false); - const [pointerHover, setPointerHover] = React.useState(false); - const [active, setActive] = React.useState(false); + const [state, setState] = React.useState(defaultState); let isHoverStyledElement = false; let isFocusStyledElement = false; let isActiveStyledElement = false; - for (const styleValue of Object.values(style)) { + for (const key in style) { + const styleValue = style[key]; if (styleValue != null && typeof styleValue === 'object') { if (styleValue.hasOwnProperty(':hover')) { isHoverStyledElement = true; @@ -64,39 +76,35 @@ export function usePseudoStates(style: Style): Interaction { const handlers = React.useMemo(() => { let value = null; if (isHoverStyledElement || isFocusStyledElement || isActiveStyledElement) { + const set = (changes: Partial) => + setState((prev) => ({ ...prev, ...changes })); value = {} as InteractionHandlers; if (isHoverStyledElement) { - value.onMouseEnter = () => setMouseHover(true); - value.onMouseLeave = () => setMouseHover(false); - value.onPointerEnter = () => setPointerHover(true); + value.onMouseEnter = () => set({ mouseHover: true }); + value.onMouseLeave = () => set({ mouseHover: false }); + value.onPointerEnter = () => set({ pointerHover: true }); } if (isFocusStyledElement) { - value.onBlur = () => setFocus(false); - value.onFocus = () => setFocus(true); + value.onBlur = () => set({ focus: false }); + value.onFocus = () => set({ focus: true }); } if (isActiveStyledElement) { - value.onPointerCancel = () => setActive(false); - value.onPointerDown = () => setActive(true); - value.onPointerUp = () => setActive(false); + value.onPointerCancel = () => set({ active: false }); + value.onPointerDown = () => set({ active: true }); + value.onPointerUp = () => set({ active: false }); } if (isHoverStyledElement || isActiveStyledElement) { - value.onPointerLeave = () => { - if (isHoverStyledElement) { - setPointerHover(false); - } - if (isActiveStyledElement) { - setActive(false); - } - }; + value.onPointerLeave = () => + set({ active: false, pointerHover: false }); } } return value; }, [isHoverStyledElement, isFocusStyledElement, isActiveStyledElement]); return { - active, - focus, - hover: mouseHover || pointerHover, + active: state.active, + focus: state.focus, + hover: state.mouseHover || state.pointerHover, handlers }; } diff --git a/packages/react-strict-dom/src/native/modules/useStyleTransition.js b/packages/react-strict-dom/src/native/modules/useStyleTransition.js index 36609d26..0bdb48fb 100644 --- a/packages/react-strict-dom/src/native/modules/useStyleTransition.js +++ b/packages/react-strict-dom/src/native/modules/useStyleTransition.js @@ -23,13 +23,20 @@ type AnimatedStyle = { [string]: ?ReactNativeStyleValue | ReadonlyArray }; -type TransitionMetadata = Readonly<{ +type Transition = Readonly<{ + animatedValue: ReactNative.Animated.Value, delay: number, duration: number, timingFunction: string | null, shouldUseNativeDriver: boolean }>; +type TransitionState = Readonly<{ + currentStyle: ReactNativeStyle | void, + previousStyle: ReactNativeStyle | void, + transition: Transition | void +}>; + const INPUT_RANGE: ReadonlyArray = [0, 1]; function isNumber(num: unknown): num is number { @@ -290,43 +297,28 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { return output; }, {}); - const [currentStyle, setCurrentStyle] = - React.useState(style); - const [previousStyle, setPreviousStyle] = - React.useState(undefined); - const [animatedValue, setAnimatedValue] = - React.useState(undefined); - - // This ref is utilized as a performance optimization so that the effect that contains the - // animation trigger only is called when the animated value's identity changes. As far as the effect - // is concerned it just needs the most up to date version of these transition properties; - const transitionMetadataRef = React.useRef({ - delay: transitionDelay, - duration: transitionDuration, - timingFunction: transitionTimingFunction, - shouldUseNativeDriver: canUseNativeDriver(transitionStyle) - }); - // effect to sync the transition metadata - React.useEffect(() => { - transitionMetadataRef.current = { - delay: transitionDelay, - duration: transitionDuration, - timingFunction: transitionTimingFunction, - shouldUseNativeDriver: canUseNativeDriver(transitionStyle) - }; - }, [ - transitionDelay, - transitionDuration, - transitionStyle, - transitionTimingFunction - ]); + const [{ currentStyle, previousStyle, transition }, setTransitionState] = + React.useState(() => ({ + currentStyle: style, + previousStyle: undefined, + transition: undefined + })); + + const animatedValue = transition?.animatedValue; // effect to trigger a transition - // REMEMBER: it is super important that this effect's dependency array **only** contains the animated value + // The transition object gets a new identity only when a new transition + // starts, so this effect runs exactly once per transition, using the timing + // values captured at that moment. React.useEffect(() => { - if (animatedValue !== undefined) { - const { delay, duration, timingFunction, shouldUseNativeDriver } = - transitionMetadataRef.current; + if (transition !== undefined) { + const { + animatedValue, + delay, + duration, + timingFunction, + shouldUseNativeDriver + } = transition; const animation = getAnimation( animatedValue, @@ -341,7 +333,7 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { animation.stop(); }; } - }, [animatedValue]); + }, [transition]); if ( _delay == null && @@ -354,10 +346,18 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { } if (transitionStyleHasChanged(transitionStyle, currentStyle)) { - setCurrentStyle(style); - setPreviousStyle(currentStyle); - setAnimatedValue(new ReactNative.Animated.Value(0)); - // This commit will be thrown away due to the above state setters so we can bail out early + setTransitionState({ + currentStyle: style, + previousStyle: currentStyle, + transition: { + animatedValue: new ReactNative.Animated.Value(0), + delay: transitionDelay, + duration: transitionDuration, + timingFunction: transitionTimingFunction, + shouldUseNativeDriver: canUseNativeDriver(transitionStyle) + } + }); + // This commit will be thrown away due to the above state setter so we can bail out early return style; }