mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-29 16:58:11 -05:00
fix(tag-dropdown): performance improvements and scroll bug fixes
- Add flatTagIndexMap for O(1) tag lookups (replaces O(n²) findIndex calls) - Memoize caret position calculation to avoid DOM manipulation on every render - Use refs for inputValue/cursorPosition to keep handleTagSelect callback stable - Change itemRefs from index-based to tag-based keys to prevent stale refs - Fix scroll jump in nested folders by removing scroll reset from registerFolder - Add onFolderEnter callback for scroll reset when entering folder via keyboard - Disable keyboard navigation wrap-around at boundaries - Simplify selection reset to single effect on flatTagList.length change Also: - Add safeCompare utility for timing-safe string comparison - Refactor webhook signature validation to use safeCompare Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ interface KeyboardNavigationHandlerProps {
|
||||
flatTagList: Array<{ tag: string; group?: BlockTagGroup }>
|
||||
nestedBlockTagGroups: NestedBlockTagGroup[]
|
||||
handleTagSelect: (tag: string, group?: BlockTagGroup) => void
|
||||
/** Called when entering a folder from root level via keyboard navigation */
|
||||
onFolderEnter?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +109,7 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
flatTagList,
|
||||
nestedBlockTagGroups,
|
||||
handleTagSelect,
|
||||
onFolderEnter,
|
||||
}) => {
|
||||
const { openFolder, closeFolder, isInFolder, currentFolder, setKeyboardNav } = usePopoverContext()
|
||||
const nestedNav = useNestedNavigation()
|
||||
@@ -251,7 +254,7 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
} else if (currentVisibleIndex < visibleIndices.length - 1) {
|
||||
newIndex = visibleIndices[currentVisibleIndex + 1]
|
||||
} else {
|
||||
newIndex = visibleIndices[0]
|
||||
newIndex = selectedIndex
|
||||
}
|
||||
setSelectedIndex(newIndex)
|
||||
scrollIntoView()
|
||||
@@ -269,7 +272,7 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
} else if (currentVisibleIndex > 0) {
|
||||
newIndex = visibleIndices[currentVisibleIndex - 1]
|
||||
} else {
|
||||
newIndex = visibleIndices[visibleIndices.length - 1]
|
||||
newIndex = selectedIndex
|
||||
}
|
||||
setSelectedIndex(newIndex)
|
||||
scrollIntoView()
|
||||
@@ -295,6 +298,7 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
currentFolderInfo.parentTag,
|
||||
currentFolderInfo.group
|
||||
)
|
||||
onFolderEnter?.()
|
||||
}
|
||||
}
|
||||
break
|
||||
@@ -346,6 +350,7 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
handleTagSelect,
|
||||
nestedNav,
|
||||
setKeyboardNav,
|
||||
onFolderEnter,
|
||||
])
|
||||
|
||||
return null
|
||||
|
||||
@@ -444,10 +444,12 @@ interface NestedTagRendererProps {
|
||||
nestedTag: NestedTag
|
||||
group: NestedBlockTagGroup
|
||||
flatTagList: Array<{ tag: string; group?: BlockTagGroup }>
|
||||
/** Map from tag string to index for O(1) lookups */
|
||||
flatTagIndexMap: Map<string, number>
|
||||
selectedIndex: number
|
||||
setSelectedIndex: (index: number) => void
|
||||
handleTagSelect: (tag: string, blockGroup?: BlockTagGroup) => void
|
||||
itemRefs: React.RefObject<Map<number, HTMLElement>>
|
||||
itemRefs: React.RefObject<Map<string, HTMLElement>>
|
||||
blocks: Record<string, BlockState>
|
||||
getMergedSubBlocks: (blockId: string) => Record<string, any>
|
||||
}
|
||||
@@ -469,6 +471,7 @@ interface FolderContentsProps extends NestedTagRendererProps {
|
||||
const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
group,
|
||||
flatTagList,
|
||||
flatTagIndexMap,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
handleTagSelect,
|
||||
@@ -483,7 +486,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
const currentNestedTag = nestedPath.length > 0 ? nestedPath[nestedPath.length - 1] : nestedTag
|
||||
|
||||
const parentTagIndex = currentNestedTag.parentTag
|
||||
? flatTagList.findIndex((item) => item.tag === currentNestedTag.parentTag)
|
||||
? (flatTagIndexMap.get(currentNestedTag.parentTag) ?? -1)
|
||||
: -1
|
||||
|
||||
return (
|
||||
@@ -493,7 +496,6 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
<PopoverItem
|
||||
active={parentTagIndex === selectedIndex && parentTagIndex >= 0}
|
||||
onMouseEnter={() => {
|
||||
// Skip selection update during keyboard navigation to prevent scroll-triggered selection changes
|
||||
if (isKeyboardNav) return
|
||||
setKeyboardNav(false)
|
||||
if (parentTagIndex >= 0) setSelectedIndex(parentTagIndex)
|
||||
@@ -504,8 +506,8 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
handleTagSelect(currentNestedTag.parentTag!, group)
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el && parentTagIndex >= 0) {
|
||||
itemRefs.current?.set(parentTagIndex, el)
|
||||
if (el && currentNestedTag.parentTag) {
|
||||
itemRefs.current?.set(currentNestedTag.parentTag, el)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -515,7 +517,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
|
||||
{/* Render leaf children as PopoverItems */}
|
||||
{currentNestedTag.children?.map((child) => {
|
||||
const childGlobalIndex = flatTagList.findIndex((item) => item.tag === child.fullTag)
|
||||
const childGlobalIndex = flatTagIndexMap.get(child.fullTag) ?? -1
|
||||
|
||||
const tagParts = child.fullTag.split('.')
|
||||
const outputPath = tagParts.slice(1).join('.')
|
||||
@@ -550,8 +552,8 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
handleTagSelect(child.fullTag, group)
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el && childGlobalIndex >= 0) {
|
||||
itemRefs.current?.set(childGlobalIndex, el)
|
||||
if (el) {
|
||||
itemRefs.current?.set(child.fullTag, el)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -568,7 +570,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
{/* Render nested children as clickable folder items */}
|
||||
{currentNestedTag.nestedChildren?.map((nestedChild) => {
|
||||
const parentGlobalIndex = nestedChild.parentTag
|
||||
? flatTagList.findIndex((item) => item.tag === nestedChild.parentTag)
|
||||
? (flatTagIndexMap.get(nestedChild.parentTag) ?? -1)
|
||||
: -1
|
||||
|
||||
return (
|
||||
@@ -583,12 +585,11 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// Navigate into the subfolder on click
|
||||
onNavigateIn(nestedChild)
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el && parentGlobalIndex >= 0) {
|
||||
itemRefs.current?.set(parentGlobalIndex, el)
|
||||
if (el && nestedChild.parentTag) {
|
||||
itemRefs.current?.set(nestedChild.parentTag, el)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -605,7 +606,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
* Wrapper component that uses shared nested navigation state from context.
|
||||
* Handles registration of the base folder and navigation callbacks.
|
||||
*/
|
||||
const FolderContents: React.FC<NestedTagRendererProps> = (props) => {
|
||||
const FolderContents: React.FC<Omit<NestedTagRendererProps, never>> = (props) => {
|
||||
const nestedNav = useNestedNavigation()
|
||||
const { currentFolder } = usePopoverContext()
|
||||
|
||||
@@ -638,6 +639,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
|
||||
nestedTag,
|
||||
group,
|
||||
flatTagList,
|
||||
flatTagIndexMap,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
handleTagSelect,
|
||||
@@ -653,7 +655,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
|
||||
const folderId = `${group.blockId}-${nestedTag.key}`
|
||||
|
||||
const parentGlobalIndex = nestedTag.parentTag
|
||||
? flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
|
||||
? (flatTagIndexMap.get(nestedTag.parentTag) ?? -1)
|
||||
: -1
|
||||
|
||||
return (
|
||||
@@ -675,8 +677,8 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
|
||||
}
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el && parentGlobalIndex >= 0) {
|
||||
itemRefs.current?.set(parentGlobalIndex, el)
|
||||
if (el && nestedTag.parentTag) {
|
||||
itemRefs.current?.set(nestedTag.parentTag, el)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -684,6 +686,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
|
||||
nestedTag={nestedTag}
|
||||
group={group}
|
||||
flatTagList={flatTagList}
|
||||
flatTagIndexMap={flatTagIndexMap}
|
||||
selectedIndex={selectedIndex}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
handleTagSelect={handleTagSelect}
|
||||
@@ -695,10 +698,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Leaf tag - render as a simple PopoverItem
|
||||
const globalIndex = nestedTag.fullTag
|
||||
? flatTagList.findIndex((item) => item.tag === nestedTag.fullTag)
|
||||
: -1
|
||||
const globalIndex = nestedTag.fullTag ? (flatTagIndexMap.get(nestedTag.fullTag) ?? -1) : -1
|
||||
|
||||
let tagDescription = ''
|
||||
|
||||
@@ -751,8 +751,8 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
|
||||
}
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el && globalIndex >= 0) {
|
||||
itemRefs.current?.set(globalIndex, el)
|
||||
if (el && nestedTag.fullTag) {
|
||||
itemRefs.current?.set(nestedTag.fullTag, el)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -767,7 +767,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get mouse enter handler that respects keyboard navigation mode.
|
||||
* Hook to get mouse enter handler that respects keyboard navigation state.
|
||||
* Returns a handler that only updates selection if not in keyboard mode.
|
||||
*/
|
||||
const useKeyboardAwareMouseEnter = (
|
||||
@@ -794,7 +794,7 @@ const VariableTagItem: React.FC<{
|
||||
selectedIndex: number
|
||||
setSelectedIndex: (index: number) => void
|
||||
handleTagSelect: (tag: string) => void
|
||||
itemRefs: React.RefObject<Map<number, HTMLElement>>
|
||||
itemRefs: React.RefObject<Map<string, HTMLElement>>
|
||||
variableInfo: { type: string; id: string } | null
|
||||
}> = ({
|
||||
tag,
|
||||
@@ -819,8 +819,8 @@ const VariableTagItem: React.FC<{
|
||||
handleTagSelect(tag)
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el && globalIndex >= 0) {
|
||||
itemRefs.current?.set(globalIndex, el)
|
||||
if (el) {
|
||||
itemRefs.current?.set(tag, el)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -845,7 +845,7 @@ const BlockRootTagItem: React.FC<{
|
||||
selectedIndex: number
|
||||
setSelectedIndex: (index: number) => void
|
||||
handleTagSelect: (tag: string, group?: BlockTagGroup) => void
|
||||
itemRefs: React.RefObject<Map<number, HTMLElement>>
|
||||
itemRefs: React.RefObject<Map<string, HTMLElement>>
|
||||
group: BlockTagGroup
|
||||
tagIcon: string | React.ComponentType<{ className?: string }>
|
||||
blockColor: string
|
||||
@@ -875,8 +875,8 @@ const BlockRootTagItem: React.FC<{
|
||||
handleTagSelect(rootTag, group)
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el && rootTagGlobalIndex >= 0) {
|
||||
itemRefs.current?.set(rootTagGlobalIndex, el)
|
||||
if (el) {
|
||||
itemRefs.current?.set(rootTag, el)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -916,16 +916,12 @@ const TagDropdownBackButton: React.FC = () => {
|
||||
|
||||
const handleBackClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// Try to navigate back in nested path first
|
||||
if (nestedNav?.navigateBack()) {
|
||||
// Successfully navigated back one level
|
||||
return
|
||||
}
|
||||
// At root folder level, close the folder
|
||||
closeFolder()
|
||||
}
|
||||
|
||||
// Just render the back button - the parent tag is rendered as the first item in FolderContentsInner
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -986,7 +982,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
inputRef,
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const itemRefs = useRef<Map<number, HTMLElement>>(new Map())
|
||||
const itemRefs = useRef<Map<string, HTMLElement>>(new Map())
|
||||
|
||||
const [nestedPath, setNestedPath] = useState<NestedTag[]>([])
|
||||
const baseFolderRef = useRef<{
|
||||
@@ -998,6 +994,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const handleTagSelectRef = useRef<((tag: string, group?: BlockTagGroup) => void) | null>(null)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const inputValueRef = useRef(inputValue)
|
||||
const cursorPositionRef = useRef(cursorPosition)
|
||||
inputValueRef.current = inputValue
|
||||
cursorPositionRef.current = cursorPosition
|
||||
|
||||
const { blocks, edges, loops, parallels } = useWorkflowStore(
|
||||
useShallow((state) => ({
|
||||
blocks: state.blocks,
|
||||
@@ -1700,27 +1701,27 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
return list
|
||||
}, [variableTags, nestedBlockTagGroups])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || selectedIndex < 0) return
|
||||
|
||||
const element = itemRefs.current.get(selectedIndex)
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
}, [selectedIndex, visible])
|
||||
/**
|
||||
* Map from tag string to its index in flatTagList for O(1) lookups.
|
||||
* Replaces O(n) findIndex calls throughout the component.
|
||||
*/
|
||||
const flatTagIndexMap = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
flatTagList.forEach((item, index) => {
|
||||
map.set(item.tag, index)
|
||||
})
|
||||
return map
|
||||
}, [flatTagList])
|
||||
|
||||
const handleTagSelect = useCallback(
|
||||
(tag: string, blockGroup?: BlockTagGroup) => {
|
||||
let liveCursor = cursorPosition
|
||||
let liveValue = inputValue
|
||||
let liveCursor = cursorPositionRef.current
|
||||
let liveValue = inputValueRef.current
|
||||
|
||||
if (typeof window !== 'undefined' && document?.activeElement) {
|
||||
const activeEl = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null
|
||||
if (activeEl && typeof activeEl.selectionStart === 'number') {
|
||||
liveCursor = activeEl.selectionStart ?? cursorPosition
|
||||
liveCursor = activeEl.selectionStart ?? cursorPositionRef.current
|
||||
if ('value' in activeEl && typeof activeEl.value === 'string') {
|
||||
liveValue = activeEl.value
|
||||
}
|
||||
@@ -1805,7 +1806,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
onSelect(newValue)
|
||||
onClose?.()
|
||||
},
|
||||
[inputValue, cursorPosition, workflowVariables, onSelect, onClose, getMergedSubBlocks]
|
||||
[workflowVariables, onSelect, onClose, getMergedSubBlocks]
|
||||
)
|
||||
|
||||
handleTagSelectRef.current = handleTagSelect
|
||||
@@ -1877,9 +1878,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
},
|
||||
registerFolder: (folderId, folderTitle, baseTag, group) => {
|
||||
baseFolderRef.current = { id: folderId, title: folderTitle, baseTag, group }
|
||||
if (scrollAreaRef.current) {
|
||||
scrollAreaRef.current.scrollTop = 0
|
||||
}
|
||||
},
|
||||
}),
|
||||
[nestedPath]
|
||||
@@ -1892,13 +1890,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= flatTagList.length) {
|
||||
setSelectedIndex(Math.max(0, flatTagList.length - 1))
|
||||
}
|
||||
}, [flatTagList.length, selectedIndex])
|
||||
setSelectedIndex(0)
|
||||
}, [flatTagList.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -1917,20 +1911,28 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
}, [visible, onClose])
|
||||
|
||||
/**
|
||||
* Memoized caret position and side calculation.
|
||||
* getCaretViewportPosition does DOM manipulation, so we avoid calling it on every render.
|
||||
*/
|
||||
const { caretViewport, side } = useMemo(() => {
|
||||
const inputElement = inputRef?.current
|
||||
if (!inputElement) {
|
||||
return { caretViewport: { left: 0, top: 0 }, side: 'bottom' as const }
|
||||
}
|
||||
|
||||
const viewport = getCaretViewportPosition(inputElement, cursorPosition, inputValue)
|
||||
const margin = 8
|
||||
const spaceAbove = viewport.top - margin
|
||||
const spaceBelow = window.innerHeight - viewport.top - margin
|
||||
const computedSide: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top'
|
||||
|
||||
return { caretViewport: viewport, side: computedSide }
|
||||
}, [cursorPosition, inputValue, inputRef])
|
||||
|
||||
if (!visible || tags.length === 0 || flatTagList.length === 0) return null
|
||||
|
||||
const inputElement = inputRef?.current
|
||||
let caretViewport = { left: 0, top: 0 }
|
||||
let side: 'top' | 'bottom' = 'bottom'
|
||||
|
||||
if (inputElement) {
|
||||
caretViewport = getCaretViewportPosition(inputElement, cursorPosition, inputValue)
|
||||
|
||||
const margin = 8
|
||||
const spaceAbove = caretViewport.top - margin
|
||||
const spaceBelow = window.innerHeight - caretViewport.top - margin
|
||||
side = spaceBelow >= spaceAbove ? 'bottom' : 'top'
|
||||
}
|
||||
|
||||
return (
|
||||
<NestedNavigationContext.Provider value={nestedNavigationValue}>
|
||||
@@ -1956,6 +1958,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
flatTagList={flatTagList}
|
||||
nestedBlockTagGroups={nestedBlockTagGroups}
|
||||
handleTagSelect={handleTagSelect}
|
||||
onFolderEnter={() => {
|
||||
if (scrollAreaRef.current) {
|
||||
scrollAreaRef.current.scrollTop = 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<PopoverContent
|
||||
maxHeight={240}
|
||||
@@ -1984,7 +1991,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
</PopoverSection>
|
||||
{variableTags.map((tag: string) => {
|
||||
const variableInfo = variableInfoMap?.[tag] || null
|
||||
const globalIndex = flatTagList.findIndex((item) => item.tag === tag)
|
||||
const globalIndex = flatTagIndexMap.get(tag) ?? -1
|
||||
|
||||
return (
|
||||
<VariableTagItem
|
||||
@@ -2027,7 +2034,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const rootTagFromTags = group.tags.find((tag) => tag === normalizedBlockName)
|
||||
const rootTag = rootTagFromTags || normalizedBlockName
|
||||
|
||||
const rootTagGlobalIndex = flatTagList.findIndex((item) => item.tag === rootTag)
|
||||
const rootTagGlobalIndex = flatTagIndexMap.get(rootTag) ?? -1
|
||||
|
||||
return (
|
||||
<div key={group.blockId}>
|
||||
@@ -2054,6 +2061,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
nestedTag={nestedTag}
|
||||
group={group}
|
||||
flatTagList={flatTagList}
|
||||
flatTagIndexMap={flatTagIndexMap}
|
||||
selectedIndex={selectedIndex}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
handleTagSelect={handleTagSelect}
|
||||
|
||||
@@ -513,18 +513,10 @@ const PopoverContent = React.forwardRef<
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [context])
|
||||
|
||||
React.useEffect(() => {
|
||||
const content = contentRef.current
|
||||
if (!content || !context?.isKeyboardNav || context.selectedIndex < 0) return
|
||||
|
||||
const items = content.querySelectorAll<HTMLElement>(
|
||||
'[role="menuitem"]:not([aria-disabled="true"])'
|
||||
)
|
||||
const selectedItem = items[context.selectedIndex]
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
}, [context?.selectedIndex, context?.isKeyboardNav])
|
||||
// Note: scrollIntoView for keyboard navigation is intentionally disabled here.
|
||||
// Components using Popover (like TagDropdown) should handle their own scroll
|
||||
// management to avoid conflicts between the popover's internal selection index
|
||||
// and the component's custom navigation state.
|
||||
|
||||
const hasUserWidthConstraint =
|
||||
maxWidth !== undefined ||
|
||||
@@ -715,7 +707,8 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
context?.setLastHoveredItem(null)
|
||||
if (itemIndex >= 0 && context) {
|
||||
// Don't update selection during keyboard navigation to prevent scroll jumps
|
||||
if (itemIndex >= 0 && context && !context.isKeyboardNav) {
|
||||
context.setSelectedIndex(itemIndex)
|
||||
}
|
||||
onMouseEnter?.(e)
|
||||
@@ -896,7 +889,8 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (itemIndex >= 0) {
|
||||
// Don't update selection during keyboard navigation to prevent scroll jumps
|
||||
if (itemIndex >= 0 && !isKeyboardNav) {
|
||||
setSelectedIndex(itemIndex)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
|
||||
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
@@ -82,3 +82,17 @@ export function generatePassword(length = 24): string {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two strings in constant time to prevent timing attacks.
|
||||
* Used for HMAC signature validation.
|
||||
* @param a - First string to compare
|
||||
* @param b - Second string to compare
|
||||
* @returns True if strings are equal, false otherwise
|
||||
*/
|
||||
export function safeCompare(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import crypto from 'crypto'
|
||||
import { db, workflowDeploymentVersion } from '@sim/db'
|
||||
import { account, webhook } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import {
|
||||
type SecureFetchResponse,
|
||||
secureFetchWithPinnedIP,
|
||||
@@ -517,16 +519,7 @@ export async function validateTwilioSignature(
|
||||
match: signatureBase64 === signature,
|
||||
})
|
||||
|
||||
if (signatureBase64.length !== signature.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = 0
|
||||
for (let i = 0; i < signatureBase64.length; i++) {
|
||||
result |= signatureBase64.charCodeAt(i) ^ signature.charCodeAt(i)
|
||||
}
|
||||
|
||||
return result === 0
|
||||
return safeCompare(signatureBase64, signature)
|
||||
} catch (error) {
|
||||
logger.error('Error validating Twilio signature:', error)
|
||||
return false
|
||||
@@ -1046,21 +1039,11 @@ export function validateMicrosoftTeamsSignature(
|
||||
|
||||
const providedSignature = signature.substring(5)
|
||||
|
||||
const crypto = require('crypto')
|
||||
const secretBytes = Buffer.from(hmacSecret, 'base64')
|
||||
const bodyBytes = Buffer.from(body, 'utf8')
|
||||
const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64')
|
||||
|
||||
if (computedHash.length !== providedSignature.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = 0
|
||||
for (let i = 0; i < computedHash.length; i++) {
|
||||
result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i)
|
||||
}
|
||||
|
||||
return result === 0
|
||||
return safeCompare(computedHash, providedSignature)
|
||||
} catch (error) {
|
||||
logger.error('Error validating Microsoft Teams signature:', error)
|
||||
return false
|
||||
@@ -1090,19 +1073,9 @@ export function validateTypeformSignature(
|
||||
|
||||
const providedSignature = signature.substring(7)
|
||||
|
||||
const crypto = require('crypto')
|
||||
const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64')
|
||||
|
||||
if (computedHash.length !== providedSignature.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = 0
|
||||
for (let i = 0; i < computedHash.length; i++) {
|
||||
result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i)
|
||||
}
|
||||
|
||||
return result === 0
|
||||
return safeCompare(computedHash, providedSignature)
|
||||
} catch (error) {
|
||||
logger.error('Error validating Typeform signature:', error)
|
||||
return false
|
||||
@@ -1127,7 +1100,6 @@ export function validateLinearSignature(secret: string, signature: string, body:
|
||||
return false
|
||||
}
|
||||
|
||||
const crypto = require('crypto')
|
||||
const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
|
||||
|
||||
logger.debug('Linear signature comparison', {
|
||||
@@ -1138,16 +1110,7 @@ export function validateLinearSignature(secret: string, signature: string, body:
|
||||
match: computedHash === signature,
|
||||
})
|
||||
|
||||
if (computedHash.length !== signature.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = 0
|
||||
for (let i = 0; i < computedHash.length; i++) {
|
||||
result |= computedHash.charCodeAt(i) ^ signature.charCodeAt(i)
|
||||
}
|
||||
|
||||
return result === 0
|
||||
return safeCompare(computedHash, signature)
|
||||
} catch (error) {
|
||||
logger.error('Error validating Linear signature:', error)
|
||||
return false
|
||||
@@ -1176,7 +1139,6 @@ export function validateCirclebackSignature(
|
||||
return false
|
||||
}
|
||||
|
||||
const crypto = require('crypto')
|
||||
const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
|
||||
|
||||
logger.debug('Circleback signature comparison', {
|
||||
@@ -1187,16 +1149,7 @@ export function validateCirclebackSignature(
|
||||
match: computedHash === signature,
|
||||
})
|
||||
|
||||
if (computedHash.length !== signature.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = 0
|
||||
for (let i = 0; i < computedHash.length; i++) {
|
||||
result |= computedHash.charCodeAt(i) ^ signature.charCodeAt(i)
|
||||
}
|
||||
|
||||
return result === 0
|
||||
return safeCompare(computedHash, signature)
|
||||
} catch (error) {
|
||||
logger.error('Error validating Circleback signature:', error)
|
||||
return false
|
||||
@@ -1230,7 +1183,6 @@ export function validateJiraSignature(secret: string, signature: string, body: s
|
||||
|
||||
const providedSignature = signature.substring(7)
|
||||
|
||||
const crypto = require('crypto')
|
||||
const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
|
||||
|
||||
logger.debug('Jira signature comparison', {
|
||||
@@ -1241,16 +1193,7 @@ export function validateJiraSignature(secret: string, signature: string, body: s
|
||||
match: computedHash === providedSignature,
|
||||
})
|
||||
|
||||
if (computedHash.length !== providedSignature.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = 0
|
||||
for (let i = 0; i < computedHash.length; i++) {
|
||||
result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i)
|
||||
}
|
||||
|
||||
return result === 0
|
||||
return safeCompare(computedHash, providedSignature)
|
||||
} catch (error) {
|
||||
logger.error('Error validating Jira signature:', error)
|
||||
return false
|
||||
@@ -1288,7 +1231,6 @@ export function validateFirefliesSignature(
|
||||
|
||||
const providedSignature = signature.substring(7)
|
||||
|
||||
const crypto = require('crypto')
|
||||
const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
|
||||
|
||||
logger.debug('Fireflies signature comparison', {
|
||||
@@ -1299,16 +1241,7 @@ export function validateFirefliesSignature(
|
||||
match: computedHash === providedSignature,
|
||||
})
|
||||
|
||||
if (computedHash.length !== providedSignature.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = 0
|
||||
for (let i = 0; i < computedHash.length; i++) {
|
||||
result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i)
|
||||
}
|
||||
|
||||
return result === 0
|
||||
return safeCompare(computedHash, providedSignature)
|
||||
} catch (error) {
|
||||
logger.error('Error validating Fireflies signature:', error)
|
||||
return false
|
||||
@@ -1333,7 +1266,6 @@ export function validateGitHubSignature(secret: string, signature: string, body:
|
||||
return false
|
||||
}
|
||||
|
||||
const crypto = require('crypto')
|
||||
let algorithm: 'sha256' | 'sha1'
|
||||
let providedSignature: string
|
||||
|
||||
@@ -1361,16 +1293,7 @@ export function validateGitHubSignature(secret: string, signature: string, body:
|
||||
match: computedHash === providedSignature,
|
||||
})
|
||||
|
||||
if (computedHash.length !== providedSignature.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = 0
|
||||
for (let i = 0; i < computedHash.length; i++) {
|
||||
result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i)
|
||||
}
|
||||
|
||||
return result === 0
|
||||
return safeCompare(computedHash, providedSignature)
|
||||
} catch (error) {
|
||||
logger.error('Error validating GitHub signature:', error)
|
||||
return false
|
||||
@@ -2556,7 +2479,6 @@ export function validateCalcomSignature(secret: string, signature: string, body:
|
||||
return false
|
||||
}
|
||||
|
||||
const crypto = require('crypto')
|
||||
const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
|
||||
|
||||
logger.debug('Cal.com signature comparison', {
|
||||
@@ -2567,17 +2489,7 @@ export function validateCalcomSignature(secret: string, signature: string, body:
|
||||
match: computedHash === signature,
|
||||
})
|
||||
|
||||
if (computedHash.length !== signature.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
let result = 0
|
||||
for (let i = 0; i < computedHash.length; i++) {
|
||||
result |= computedHash.charCodeAt(i) ^ signature.charCodeAt(i)
|
||||
}
|
||||
|
||||
return result === 0
|
||||
return safeCompare(computedHash, signature)
|
||||
} catch (error) {
|
||||
logger.error('Error validating Cal.com signature:', error)
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user