Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions apps/sim/app/(landing)/components/hero/components/icon-button.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
'use client'

import type React from 'react'
import { motion } from 'framer-motion'

interface IconButtonProps {
children: React.ReactNode
onClick?: () => void
onMouseEnter?: () => void
style?: React.CSSProperties
'aria-label': string
isAutoHovered?: boolean
isActive?: boolean
}

export function IconButton({
Expand All @@ -17,22 +18,25 @@ export function IconButton({
onMouseEnter,
style,
'aria-label': ariaLabel,
isAutoHovered = false,
isActive = false,
}: IconButtonProps) {
return (
<button
type='button'
aria-label={ariaLabel}
onClick={onClick}
onMouseEnter={onMouseEnter}
className={`flex items-center justify-center rounded-xl border p-2 outline-none transition-all duration-300 ${
isAutoHovered
? 'border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)]'
: 'border-transparent hover:border-[#E5E5E5] hover:shadow-[0_2px_4px_0_rgba(0,0,0,0.08)]'
}`}
className='relative flex items-center justify-center rounded-xl p-2 outline-none'
style={style}
>
{children}
{isActive && (
<motion.div
layoutId='icon-highlight-pill'
className='absolute inset-0 rounded-xl border border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)]'
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
<span className='relative z-[1]'>{children}</span>
</button>
)
}
18 changes: 13 additions & 5 deletions apps/sim/app/(landing)/components/hero/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export default function Hero() {
*/
const [autoHoverIndex, setAutoHoverIndex] = React.useState(1)
const [isUserHovering, setIsUserHovering] = React.useState(false)
const [lastHoveredIndex, setLastHoveredIndex] = React.useState<number | null>(null)
const [hoveredIndex, setHoveredIndex] = React.useState<number | null>(null)
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)

/**
Expand Down Expand Up @@ -225,6 +225,12 @@ export default function Hero() {
}
}, [isUserHovering, visibleIconCount])

/**
* The active icon index used to position the shared highlight pill.
* When the user is hovering, use their hovered icon; otherwise use auto-hover.
*/
const activeIconIndex = isUserHovering && hoveredIndex !== null ? hoveredIndex : autoHoverIndex

/**
* Handle mouse enter on icon container
*/
Expand All @@ -239,10 +245,12 @@ export default function Hero() {
* Handle mouse leave on icon container
*/
const handleIconContainerMouseLeave = () => {
const lastIndex = hoveredIndex
setIsUserHovering(false)
setHoveredIndex(null)
// Start from the next icon after the last hovered one
if (lastHoveredIndex !== null) {
setAutoHoverIndex((lastHoveredIndex + 1) % visibleIconCount)
if (lastIndex !== null) {
setAutoHoverIndex((lastIndex + 1) % visibleIconCount)
}
Comment on lines 247 to 254
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale closure: hoveredIndex may be read before its setHoveredIndex update is applied

In React 18 with automatic batching, setHoveredIndex(index) (called in the icon's onMouseEnter) and the container's onMouseLeave handler can be triggered by two separate, rapid browser events. If the user hovers an icon and immediately leaves the container before React has re-rendered, handleIconContainerMouseLeave will execute with the closure's stale hoveredIndex value (the one from the last render, which is still null or a previous index). The auto-cycle will then resume from the wrong icon.

The reliable fix is to mirror the state into a useRef so the handler always reads the latest value:

const hoveredIndexRef = React.useRef<number | null>(null)

// in the onMouseEnter passed to each IconButton:
onMouseEnter={() => {
  setHoveredIndex(index)
  hoveredIndexRef.current = index
}}

// in handleIconContainerMouseLeave:
const handleIconContainerMouseLeave = () => {
  setIsUserHovering(false)
  setHoveredIndex(null)
  if (hoveredIndexRef.current !== null) {
    setAutoHoverIndex((hoveredIndexRef.current + 1) % visibleIconCount)
  }
  hoveredIndexRef.current = null
}

}

Expand Down Expand Up @@ -389,9 +397,9 @@ export default function Hero() {
key={service.key}
aria-label={service.label}
onClick={() => handleServiceClick(service.key as keyof typeof SERVICE_TEMPLATES)}
onMouseEnter={() => setLastHoveredIndex(index)}
onMouseEnter={() => setHoveredIndex(index)}
style={service.style}
isAutoHovered={!isUserHovering && index === autoHoverIndex}
isActive={index === activeIconIndex}
>
<Icon className='h-5 w-5 sm:h-6 sm:w-6' />
</IconButton>
Expand Down