From 3afc09d99fb983e6831826db5dd1edb60b928389 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 19 Mar 2026 11:09:45 -0700 Subject: [PATCH 1/7] improvement(react): replace unnecessary useEffect patterns with better React primitives --- apps/sim/app/(auth)/login/login-form.tsx | 43 ++++++---------- .../reset-password/reset-password-content.tsx | 17 +++---- apps/sim/app/(auth)/signup/signup-form.tsx | 45 ++++++----------- apps/sim/app/chat/[identifier]/chat.tsx | 24 +++++---- apps/sim/app/chat/components/input/input.tsx | 21 +++----- .../voice-interface/voice-interface.tsx | 50 ++++++++++--------- .../workspace/[workspaceId]/files/files.tsx | 6 +-- .../document-tags-modal.tsx | 33 +++--------- .../add-documents-modal.tsx | 46 +++++++++-------- .../rename-document-modal.tsx | 12 +++-- .../edit-knowledge-base-modal.tsx | 12 +++-- .../credentials/credentials-manager.tsx | 18 +++---- .../hooks/use-profile-picture-upload.ts | 16 +++--- .../components/help-modal/help-modal.tsx | 45 ++++++++++------- .../components/context-menu/context-menu.tsx | 33 ++++++------ .../create-workspace-modal.tsx | 29 +++++++---- .../components/permissions-table.tsx | 19 ++++--- .../components/invite-modal/invite-modal.tsx | 10 ++-- .../w/components/sidebar/sidebar.tsx | 37 +++++++++----- .../components/emcn/components/code/code.tsx | 12 +++-- .../emcn/components/combobox/combobox.tsx | 25 +++------- .../components/date-picker/date-picker.tsx | 9 ++-- .../emcn/components/popover/popover.tsx | 41 +++++++++------ .../components/time-picker/time-picker.tsx | 6 ++- 24 files changed, 301 insertions(+), 308 deletions(-) diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 85e924fd328..f6e842a602f 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { Eye, EyeOff } from 'lucide-react' import Link from 'next/link' @@ -99,15 +99,21 @@ export default function LoginPage({ const router = useRouter() const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) - const [_mounted, setMounted] = useState(false) const [showPassword, setShowPassword] = useState(false) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) const buttonClass = useBrandedButtonClass() - const [callbackUrl, setCallbackUrl] = useState('/workspace') - const [isInviteFlow, setIsInviteFlow] = useState(false) + const callbackUrlParam = searchParams?.get('callbackUrl') + const invalidCallbackRef = useRef(false) + if (callbackUrlParam && !validateCallbackUrl(callbackUrlParam) && !invalidCallbackRef.current) { + invalidCallbackRef.current = true + logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam }) + } + const callbackUrl = + callbackUrlParam && validateCallbackUrl(callbackUrlParam) ? callbackUrlParam : '/workspace' + const isInviteFlow = searchParams?.get('invite_flow') === 'true' const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) const [forgotPasswordEmail, setForgotPasswordEmail] = useState('') @@ -120,30 +126,11 @@ export default function LoginPage({ const [email, setEmail] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [resetSuccessMessage, setResetSuccessMessage] = useState(null) - - useEffect(() => { - setMounted(true) - - if (searchParams) { - const callback = searchParams.get('callbackUrl') - if (callback) { - if (validateCallbackUrl(callback)) { - setCallbackUrl(callback) - } else { - logger.warn('Invalid callback URL detected and blocked:', { url: callback }) - } - } - - const inviteFlow = searchParams.get('invite_flow') === 'true' - setIsInviteFlow(inviteFlow) - - const resetSuccess = searchParams.get('resetSuccess') === 'true' - if (resetSuccess) { - setResetSuccessMessage('Password reset successful. Please sign in with your new password.') - } - } - }, [searchParams]) + const [resetSuccessMessage, setResetSuccessMessage] = useState(() => + searchParams?.get('resetSuccess') === 'true' + ? 'Password reset successful. Please sign in with your new password.' + : null + ) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { diff --git a/apps/sim/app/(auth)/reset-password/reset-password-content.tsx b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx index 9127c6e0b42..a48eedc5f8a 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-content.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, useEffect, useState } from 'react' +import { Suspense, useState } from 'react' import { createLogger } from '@sim/logger' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' @@ -22,14 +22,9 @@ function ResetPasswordContent() { text: '', }) - useEffect(() => { - if (!token) { - setStatusMessage({ - type: 'error', - text: 'Invalid or missing reset token. Please request a new password reset link.', - }) - } - }, [token]) + const tokenError = !token + ? 'Invalid or missing reset token. Please request a new password reset link.' + : null const handleResetPassword = async (password: string) => { try { @@ -87,8 +82,8 @@ function ResetPasswordContent() { token={token} onSubmit={handleResetPassword} isSubmitting={isSubmitting} - statusType={statusMessage.type} - statusMessage={statusMessage.text} + statusType={tokenError ? 'error' : statusMessage.type} + statusMessage={tokenError ?? statusMessage.text} /> diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index b04ad8af4c9..0a8138053a1 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, useEffect, useState } from 'react' +import { Suspense, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { Eye, EyeOff } from 'lucide-react' import Link from 'next/link' @@ -82,49 +82,32 @@ function SignupFormContent({ const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const [isLoading, setIsLoading] = useState(false) - const [, setMounted] = useState(false) const [showPassword, setShowPassword] = useState(false) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) - const [email, setEmail] = useState('') + const [email, setEmail] = useState(() => searchParams.get('email') ?? '') const [emailError, setEmailError] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [redirectUrl, setRedirectUrl] = useState('') - const [isInviteFlow, setIsInviteFlow] = useState(false) const buttonClass = useBrandedButtonClass() + const redirectUrl = useMemo( + () => searchParams.get('redirect') || searchParams.get('callbackUrl') || '', + [searchParams] + ) + const isInviteFlow = useMemo( + () => + searchParams.get('invite_flow') === 'true' || + redirectUrl.startsWith('/invite/') || + redirectUrl.startsWith('/credential-account/'), + [searchParams, redirectUrl] + ) + const [name, setName] = useState('') const [nameErrors, setNameErrors] = useState([]) const [showNameValidationError, setShowNameValidationError] = useState(false) - useEffect(() => { - setMounted(true) - const emailParam = searchParams.get('email') - if (emailParam) { - setEmail(emailParam) - } - - // Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl) - const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl') - if (redirectParam) { - setRedirectUrl(redirectParam) - - if ( - redirectParam.startsWith('/invite/') || - redirectParam.startsWith('/credential-account/') - ) { - setIsInviteFlow(true) - } - } - - const inviteFlowParam = searchParams.get('invite_flow') - if (inviteFlowParam === 'true') { - setIsInviteFlow(true) - } - }, [searchParams]) - const validatePassword = (passwordValue: string): string[] => { const errors: string[] = [] diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index 549e450d4a5..14f7736050d 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -190,16 +190,18 @@ export default function ChatClient({ identifier }: { identifier: string }) { return () => container.removeEventListener('scroll', handleScroll) }, [handleScroll]) - useEffect(() => { - if (isStreamingResponse) { - setUserHasScrolled(false) - - isUserScrollingRef.current = true - setTimeout(() => { - isUserScrollingRef.current = false - }, 1000) - } - }, [isStreamingResponse]) + /** + * Resets scroll tracking state when a new streaming response begins. + * Suppresses scroll detection briefly to avoid false positives from + * programmatic scrolls. + */ + const resetScrollStateForStreaming = useCallback(() => { + setUserHasScrolled(false) + isUserScrollingRef.current = true + setTimeout(() => { + isUserScrollingRef.current = false + }, 1000) + }, []) const fetchChatConfig = async () => { try { @@ -300,7 +302,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { filesCount: files?.length, }) - setUserHasScrolled(false) + resetScrollStateForStreaming() const userMessage: ChatMessage = { id: crypto.randomUUID(), diff --git a/apps/sim/app/chat/components/input/input.tsx b/apps/sim/app/chat/components/input/input.tsx index 5c9bfea95be..25402cf475b 100644 --- a/apps/sim/app/chat/components/input/input.tsx +++ b/apps/sim/app/chat/components/input/input.tsx @@ -71,11 +71,6 @@ export const ChatInput: React.FC<{ } } - // Adjust height on input change - useEffect(() => { - adjustTextareaHeight() - }, [inputValue]) - // Close the input when clicking outside (only when empty) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -94,17 +89,14 @@ export const ChatInput: React.FC<{ return () => document.removeEventListener('mousedown', handleClickOutside) }, [inputValue]) - // Handle focus and initial height when activated - useEffect(() => { - if (isActive && textareaRef.current) { - textareaRef.current.focus() - adjustTextareaHeight() // Adjust height when becoming active - } - }, [isActive]) - const handleActivate = () => { setIsActive(true) - // Focus is now handled by the useEffect above + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.focus() + adjustTextareaHeight() + } + }) } // Handle file selection @@ -186,6 +178,7 @@ export const ChatInput: React.FC<{ const handleInputChange = (e: React.ChangeEvent) => { setInputValue(e.target.value) + adjustTextareaHeight() } // Handle voice start with smooth transition to voice-first mode diff --git a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx index fd7f291c31a..9c9cc265395 100644 --- a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx +++ b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx @@ -78,9 +78,10 @@ export function VoiceInterface({ const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle') const isCallEndedRef = useRef(false) - useEffect(() => { - currentStateRef.current = state - }, [state]) + const updateState = useCallback((next: 'idle' | 'listening' | 'agent_speaking') => { + setState(next) + currentStateRef.current = next + }, []) const recognitionRef = useRef(null) const mediaStreamRef = useRef(null) @@ -97,9 +98,10 @@ export function VoiceInterface({ (window as WindowWithSpeech).webkitSpeechRecognition ) - useEffect(() => { - isMutedRef.current = isMuted - }, [isMuted]) + const updateIsMuted = useCallback((next: boolean) => { + setIsMuted(next) + isMutedRef.current = next + }, []) const setResponseTimeout = useCallback(() => { if (responseTimeoutRef.current) { @@ -108,7 +110,7 @@ export function VoiceInterface({ responseTimeoutRef.current = setTimeout(() => { if (currentStateRef.current === 'listening') { - setState('idle') + updateState('idle') } }, 5000) }, []) @@ -123,10 +125,10 @@ export function VoiceInterface({ useEffect(() => { if (isPlayingAudio && state !== 'agent_speaking') { clearResponseTimeout() - setState('agent_speaking') + updateState('agent_speaking') setCurrentTranscript('') - setIsMuted(true) + updateIsMuted(true) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { track.enabled = false @@ -141,17 +143,17 @@ export function VoiceInterface({ } } } else if (!isPlayingAudio && state === 'agent_speaking') { - setState('idle') + updateState('idle') setCurrentTranscript('') - setIsMuted(false) + updateIsMuted(false) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { track.enabled = true }) } } - }, [isPlayingAudio, state, clearResponseTimeout]) + }, [isPlayingAudio, state, clearResponseTimeout, updateState, updateIsMuted]) const setupAudio = useCallback(async () => { try { @@ -310,7 +312,7 @@ export function VoiceInterface({ return } - setState('listening') + updateState('listening') setCurrentTranscript('') if (recognitionRef.current) { @@ -320,10 +322,10 @@ export function VoiceInterface({ logger.error('Error starting recognition:', error) } } - }, [isInitialized, isMuted, state]) + }, [isInitialized, isMuted, state, updateState]) const stopListening = useCallback(() => { - setState('idle') + updateState('idle') setCurrentTranscript('') if (recognitionRef.current) { @@ -333,15 +335,15 @@ export function VoiceInterface({ // Ignore } } - }, []) + }, [updateState]) const handleInterrupt = useCallback(() => { if (state === 'agent_speaking') { onInterrupt?.() - setState('listening') + updateState('listening') setCurrentTranscript('') - setIsMuted(false) + updateIsMuted(false) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { track.enabled = true @@ -356,14 +358,14 @@ export function VoiceInterface({ } } } - }, [state, onInterrupt]) + }, [state, onInterrupt, updateState, updateIsMuted]) const handleCallEnd = useCallback(() => { isCallEndedRef.current = true - setState('idle') + updateState('idle') setCurrentTranscript('') - setIsMuted(false) + updateIsMuted(false) if (recognitionRef.current) { try { @@ -376,7 +378,7 @@ export function VoiceInterface({ clearResponseTimeout() onInterrupt?.() onCallEnd?.() - }, [onCallEnd, onInterrupt, clearResponseTimeout]) + }, [onCallEnd, onInterrupt, clearResponseTimeout, updateState, updateIsMuted]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -397,7 +399,7 @@ export function VoiceInterface({ } const newMutedState = !isMuted - setIsMuted(newMutedState) + updateIsMuted(newMutedState) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { @@ -410,7 +412,7 @@ export function VoiceInterface({ } else if (state === 'idle') { startListening() } - }, [isMuted, state, handleInterrupt, stopListening, startListening]) + }, [isMuted, state, handleInterrupt, stopListening, startListening, updateIsMuted]) useEffect(() => { if (isSupported) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 448a1b251d6..ac156edf562 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -151,8 +151,6 @@ export function Files() { } const justCreatedFileIdRef = useRef(null) - const filesRef = useRef(files) - filesRef.current = files const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 }) @@ -485,11 +483,11 @@ export function Files() { if (isJustCreated) { setPreviewMode('editor') } else { - const file = selectedFileId ? filesRef.current.find((f) => f.id === selectedFileId) : null + const file = selectedFileId ? files.find((f) => f.id === selectedFileId) : null const canPreview = file ? isPreviewable(file) : false setPreviewMode(canPreview ? 'preview' : 'editor') } - }, [selectedFileId]) + }, [selectedFileId, files]) useEffect(() => { if (!selectedFile) return diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx index c947e017477..9332581b82d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { Badge, @@ -20,10 +20,7 @@ import { cn } from '@/lib/core/utils/cn' import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants' import type { DocumentTag } from '@/lib/knowledge/tags/types' import type { DocumentData } from '@/lib/knowledge/types' -import { - type TagDefinition, - useKnowledgeBaseTagDefinitions, -} from '@/hooks/kb/use-knowledge-base-tag-definitions' +import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions' import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions' import { useNextAvailableSlotMutation, useUpdateDocumentTags } from '@/hooks/queries/kb/knowledge' @@ -100,7 +97,6 @@ export function DocumentTagsModal({ const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook - const [documentTags, setDocumentTags] = useState([]) const [editingTagIndex, setEditingTagIndex] = useState(null) const [isCreatingTag, setIsCreatingTag] = useState(false) const [isSavingTag, setIsSavingTag] = useState(false) @@ -110,12 +106,13 @@ export function DocumentTagsModal({ value: '', }) - const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => { - const tags: DocumentTag[] = [] + const documentTags = useMemo(() => { + if (!documentData || !tagDefinitions) return [] + const tags: DocumentTag[] = [] ALL_TAG_SLOTS.forEach((slot) => { - const rawValue = docData[slot] - const definition = definitions.find((def) => def.tagSlot === slot) + const rawValue = documentData[slot] + const definition = tagDefinitions.find((def) => def.tagSlot === slot) if (rawValue !== null && rawValue !== undefined && definition) { const stringValue = String(rawValue).trim() @@ -131,11 +128,7 @@ export function DocumentTagsModal({ }) return tags - }, []) - - const handleTagsChange = useCallback((newTags: DocumentTag[]) => { - setDocumentTags(newTags) - }, []) + }, [documentData, tagDefinitions]) const handleSaveDocumentTags = useCallback( async (tagsToSave: DocumentTag[]) => { @@ -173,7 +166,6 @@ export function DocumentTagsModal({ const handleRemoveTag = async (index: number) => { const updatedTags = documentTags.filter((_, i) => i !== index) - handleTagsChange(updatedTags) try { await handleSaveDocumentTags(updatedTags) @@ -284,8 +276,6 @@ export function DocumentTagsModal({ updatedTags = [...documentTags, newTag] } - handleTagsChange(updatedTags) - if (currentEditingIndex !== null && originalTag) { const currentDefinition = kbTagDefinitions.find( (def) => def.displayName.toLowerCase() === originalTag.displayName.toLowerCase() @@ -361,13 +351,6 @@ export function DocumentTagsModal({ const canAddNewTag = kbTagDefinitions.length < MAX_TAG_SLOTS || availableDefinitions.length > 0 - useEffect(() => { - if (documentData && tagDefinitions && !isSavingTag) { - const rebuiltTags = buildDocumentTags(documentData, tagDefinitions) - setDocumentTags(rebuiltTags) - } - }, [documentData, tagDefinitions, buildDocumentTags, isSavingTag]) - const handleClose = (openState: boolean) => { if (!openState) { setIsCreatingTag(false) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx index 56463b60f7b..491b32b07ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { Loader2, RotateCcw, X } from 'lucide-react' import { useParams } from 'next/navigation' @@ -64,26 +64,32 @@ export function AddDocumentsModal({ } }, [files]) - useEffect(() => { - if (open) { - setFiles([]) - setFileError(null) - setIsDragging(false) - setDragCounter(0) - setRetryingIndexes(new Set()) - clearError() - } - }, [open, clearError]) + /** Resets state on open and handles close with upload guard */ + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (newOpen) { + setFiles([]) + setFileError(null) + setIsDragging(false) + setDragCounter(0) + setRetryingIndexes(new Set()) + clearError() + } else { + if (isUploading) return + setFiles([]) + setFileError(null) + clearError() + setIsDragging(false) + setDragCounter(0) + setRetryingIndexes(new Set()) + } + onOpenChange(newOpen) + }, + [isUploading, clearError, onOpenChange] + ) const handleClose = () => { - if (isUploading) return - setFiles([]) - setFileError(null) - clearError() - setIsDragging(false) - setDragCounter(0) - setRetryingIndexes(new Set()) - onOpenChange(false) + handleOpenChange(false) } const processFiles = async (fileList: FileList | File[]) => { @@ -220,7 +226,7 @@ export function AddDocumentsModal({ } return ( - + New Documents diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx index e701c171bb0..751d44373d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { createLogger } from '@sim/logger' import { Button, @@ -39,12 +39,14 @@ export function RenameDocumentModal({ const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(null) - useEffect(() => { - if (open) { + /** Resets form state when the modal opens and forwards the open state change */ + const handleOpenChange = (newOpen: boolean) => { + if (newOpen) { setName(initialName) setError(null) } - }, [open, initialName]) + onOpenChange(newOpen) + } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -76,7 +78,7 @@ export function RenameDocumentModal({ } return ( - + Rename Document
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx index bf0699813c1..5676524034d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { zodResolver } from '@hookform/resolvers/zod' import { createLogger } from '@sim/logger' import { useForm } from 'react-hook-form' @@ -71,15 +71,17 @@ export function EditKnowledgeBaseModal({ const nameValue = watch('name') - useEffect(() => { - if (open) { + /** Resets form state when the modal opens and forwards the open state change */ + const handleOpenChange = (newOpen: boolean) => { + if (newOpen) { setError(null) reset({ name: initialName, description: initialDescription, }) } - }, [open, initialName, initialDescription, reset]) + onOpenChange(newOpen) + } const onSubmit = async (data: FormValues) => { setIsSubmitting(true) @@ -97,7 +99,7 @@ export function EditKnowledgeBaseModal({ } return ( - + Edit Knowledge Base diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index 15ff5a19079..e974c961364 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -493,16 +493,16 @@ export function CredentialsManager() { setEnvVars(JSON.parse(JSON.stringify(initialVars))) }, [variables]) - useEffect(() => { - if (workspaceEnvData) { - if (hasSavedRef.current) { - hasSavedRef.current = false - } else { - setWorkspaceVars(workspaceEnvData?.workspace || {}) - initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {} - } + const prevWorkspaceEnvDataRef = useRef(workspaceEnvData) + if (workspaceEnvData && workspaceEnvData !== prevWorkspaceEnvDataRef.current) { + prevWorkspaceEnvDataRef.current = workspaceEnvData + if (hasSavedRef.current) { + hasSavedRef.current = false + } else { + setWorkspaceVars(workspaceEnvData?.workspace || {}) + initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {} } - }, [workspaceEnvData]) + } const scrollToBottom = useCallback(() => { requestAnimationFrame(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts index cb4c1497b52..1a4bf889032 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts @@ -26,15 +26,15 @@ export function useProfilePictureUpload({ const [fileName, setFileName] = useState(null) const [isUploading, setIsUploading] = useState(false) - useEffect(() => { - if (currentImage !== previewUrl) { - if (previewRef.current && previewRef.current !== currentImage) { - URL.revokeObjectURL(previewRef.current) - previewRef.current = null - } - setPreviewUrl(currentImage || null) + const prevCurrentImageRef = useRef(currentImage) + if (currentImage !== prevCurrentImageRef.current) { + prevCurrentImageRef.current = currentImage + if (previewRef.current && previewRef.current !== currentImage) { + URL.revokeObjectURL(previewRef.current) + previewRef.current = null } - }, [currentImage, previewUrl]) + setPreviewUrl(currentImage || null) + } const validateFile = useCallback((file: File): string | null => { if (file.size > MAX_FILE_SIZE) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx index a51cef4be24..c4a7f78003f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx @@ -89,21 +89,32 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM }) /** - * Reset all state when modal opens/closes + * Reset all form and UI state to prepare for a fresh modal session */ - useEffect(() => { - if (open) { - setSubmitStatus(null) - setImages([]) - setIsDragging(false) - setIsProcessing(false) - reset({ - subject: '', - message: '', - type: DEFAULT_REQUEST_TYPE, - }) - } - }, [open, reset]) + const resetModalState = useCallback(() => { + setSubmitStatus(null) + setImages([]) + setIsDragging(false) + setIsProcessing(false) + reset({ + subject: '', + message: '', + type: DEFAULT_REQUEST_TYPE, + }) + }, [reset]) + + /** + * Wrap onOpenChange to reset state when the modal opens + */ + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (newOpen) { + resetModalState() + } + onOpenChange(newOpen) + }, + [onOpenChange, resetModalState] + ) /** * Fix z-index for popover/dropdown when inside modal @@ -415,11 +426,11 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM * Handle modal close action */ const handleClose = useCallback(() => { - onOpenChange(false) - }, [onOpenChange]) + handleOpenChange(false) + }, [handleOpenChange]) return ( - + Help & Support diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index a6b8d5df91b..3801a94c064 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, DropdownMenu, @@ -39,29 +39,25 @@ function ColorGrid({ hexInput, setHexInput, onColorChange, - isOpen, + buttonRefs, }: { hexInput: string setHexInput: (color: string) => void onColorChange?: (color: string) => void - isOpen: boolean + buttonRefs: RefObject<(HTMLButtonElement | null)[]> }) { const [focusedIndex, setFocusedIndex] = useState(-1) const gridRef = useRef(null) - const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]) useEffect(() => { - if (isOpen && gridRef.current) { - const selectedIndex = WORKFLOW_COLORS.findIndex( - ({ color }) => color.toLowerCase() === hexInput.toLowerCase() - ) - const initialIndex = selectedIndex >= 0 ? selectedIndex : 0 - setFocusedIndex(initialIndex) - setTimeout(() => { - buttonRefs.current[initialIndex]?.focus() - }, 50) - } - }, [isOpen, hexInput]) + const selectedIndex = WORKFLOW_COLORS.findIndex( + ({ color }) => color.toLowerCase() === hexInput.toLowerCase() + ) + const idx = selectedIndex >= 0 ? selectedIndex : 0 + setFocusedIndex(idx) + buttonRefs.current[idx]?.focus() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const handleKeyDown = useCallback( (e: React.KeyboardEvent, index: number) => { @@ -176,10 +172,10 @@ function ColorPickerSubmenu({ handleHexFocus: (e: React.FocusEvent) => void disabled?: boolean }) { - const [isSubOpen, setIsSubOpen] = useState(false) + const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]) return ( - + Change color @@ -190,7 +186,7 @@ function ColorPickerSubmenu({ hexInput={hexInput} setHexInput={setHexInput} onColorChange={onColorChange} - isOpen={isSubOpen} + buttonRefs={buttonRefs} />
e.preventDefault()} > {showOpenInNewTab && onOpenInNewTab && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx index 1f775d13b3e..2af9996c839 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { Button, Input, @@ -30,12 +30,15 @@ export function CreateWorkspaceModal({ const [name, setName] = useState('') const inputRef = useRef(null) - useEffect(() => { - if (open) { - setName('') - requestAnimationFrame(() => inputRef.current?.focus()) - } - }, [open]) + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (newOpen) { + setName('') + } + onOpenChange(newOpen) + }, + [onOpenChange] + ) const handleSubmit = useCallback(async () => { const trimmed = name.trim() @@ -54,8 +57,14 @@ export function CreateWorkspaceModal({ ) return ( - - + + { + e.preventDefault() + inputRef.current?.focus() + }} + > Create Workspace - @@ -1131,7 +1144,7 @@ export const Sidebar = memo(function Sidebar() { } hover={tasksHover} - onClick={() => router.push(`/workspace/${workspaceId}/home`)} + onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)} ariaLabel='Tasks' className='mt-[6px]' > diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx index 94f865b01ed..c15817f2cd7 100644 --- a/apps/sim/components/emcn/components/code/code.tsx +++ b/apps/sim/components/emcn/components/code/code.tsx @@ -869,9 +869,11 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({ return { matchOffsets: offsets, matchCount: cumulative } }, [lines.length, displayLines, visibleLineIndices, searchQuery]) - useEffect(() => { + const prevMatchCountRef = useRef(matchCount) + if (prevMatchCountRef.current !== matchCount) { + prevMatchCountRef.current = matchCount onMatchCountChange?.(matchCount) - }, [matchCount, onMatchCountChange]) + } // Only process visible lines for efficiency (not all lines) const visibleLines = useMemo(() => { @@ -1062,9 +1064,11 @@ const ViewerInner = memo(function ViewerInner({ return { cumulativeMatches: cumulative, matchCount: cumulative[cumulative.length - 1] } }, [lines.length, displayLines, visibleLineIndices, searchQuery]) - useEffect(() => { + const prevMatchCountRef = useRef(matchCount) + if (prevMatchCountRef.current !== matchCount) { + prevMatchCountRef.current = matchCount onMatchCountChange?.(matchCount) - }, [matchCount, onMatchCountChange]) + } // Pre-compute highlighted lines with search for visible indices (for gutter mode) const highlightedVisibleLines = useMemo(() => { diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index 4b922ae8111..d9790a1f3fe 100644 --- a/apps/sim/components/emcn/components/combobox/combobox.tsx +++ b/apps/sim/components/emcn/components/combobox/combobox.tsx @@ -462,13 +462,16 @@ const Combobox = memo( [disabled, editable, inputRef] ) + const effectiveHighlightedIndex = + highlightedIndex >= 0 && highlightedIndex < filteredOptions.length ? highlightedIndex : -1 + /** * Scroll highlighted option into view */ useEffect(() => { - if (highlightedIndex >= 0 && dropdownRef.current) { + if (effectiveHighlightedIndex >= 0 && dropdownRef.current) { const highlightedElement = dropdownRef.current.querySelector( - `[data-option-index="${highlightedIndex}"]` + `[data-option-index="${effectiveHighlightedIndex}"]` ) if (highlightedElement) { highlightedElement.scrollIntoView({ @@ -477,19 +480,7 @@ const Combobox = memo( }) } } - }, [highlightedIndex]) - - /** - * Adjust highlighted index when filtered options change - */ - useEffect(() => { - setHighlightedIndex((prev) => { - if (prev >= 0 && prev < filteredOptions.length) { - return prev - } - return -1 - }) - }, [filteredOptions]) + }, [effectiveHighlightedIndex]) const SelectedIcon = selectedOption?.icon @@ -713,7 +704,7 @@ const Combobox = memo( const globalIndex = filteredOptions.findIndex( (o) => o.value === option.value ) - const isHighlighted = globalIndex === highlightedIndex + const isHighlighted = globalIndex === effectiveHighlightedIndex const OptionIcon = option.icon return ( @@ -789,7 +780,7 @@ const Combobox = memo( const isSelected = multiSelect ? multiSelectValues?.includes(option.value) : effectiveSelectedValue === option.value - const isHighlighted = index === highlightedIndex + const isHighlighted = index === effectiveHighlightedIndex const OptionIcon = option.icon return ( diff --git a/apps/sim/components/emcn/components/date-picker/date-picker.tsx b/apps/sim/components/emcn/components/date-picker/date-picker.tsx index 67fa14d273e..0a597dec581 100644 --- a/apps/sim/components/emcn/components/date-picker/date-picker.tsx +++ b/apps/sim/components/emcn/components/date-picker/date-picker.tsx @@ -559,12 +559,15 @@ const DatePicker = React.forwardRef((props, ref } }, [open, isRangeMode, initialStart, initialEnd]) - React.useEffect(() => { - if (!isRangeMode && selectedDate) { + const singleValueKey = !isRangeMode && selectedDate ? selectedDate.getTime() : undefined + const [prevSingleValueKey, setPrevSingleValueKey] = React.useState(singleValueKey) + if (singleValueKey !== prevSingleValueKey) { + setPrevSingleValueKey(singleValueKey) + if (selectedDate) { setViewMonth(selectedDate.getMonth()) setViewYear(selectedDate.getFullYear()) } - }, [isRangeMode, selectedDate]) + } /** * Handles selection of a specific day in single mode. diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index 561f041a6cf..784908e92ea 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -226,6 +226,7 @@ const Popover: React.FC = ({ size = 'md', colorScheme = 'default', open, + onOpenChange, ...props }) => { const [currentFolder, setCurrentFolder] = React.useState(null) @@ -251,21 +252,29 @@ const Popover: React.FC = ({ } }, []) - React.useEffect(() => { - if (open === false) { - setCurrentFolder(null) - setFolderTitle(null) - setOnFolderSelect(null) - setSearchQuery('') - setLastHoveredItem(null) - setIsKeyboardNav(false) - setSelectedIndex(-1) - registeredItemsRef.current = [] - } else { - // Reset hover state when opening to prevent stale submenu from previous menu - setLastHoveredItem(null) - } - }, [open]) + /** Resets all navigation state to initial values */ + const resetState = React.useCallback(() => { + setCurrentFolder(null) + setFolderTitle(null) + setOnFolderSelect(null) + setSearchQuery('') + setLastHoveredItem(null) + setIsKeyboardNav(false) + setSelectedIndex(-1) + registeredItemsRef.current = [] + }, []) + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + setLastHoveredItem(null) + } else { + resetState() + } + onOpenChange?.(nextOpen) + }, + [onOpenChange, resetState] + ) const openFolder = React.useCallback( (id: string, title: string, onLoad?: () => void | Promise, onSelect?: () => void) => { @@ -336,7 +345,7 @@ const Popover: React.FC = ({ return ( - + {children} diff --git a/apps/sim/components/emcn/components/time-picker/time-picker.tsx b/apps/sim/components/emcn/components/time-picker/time-picker.tsx index 1bd45418b15..4bc776b347a 100644 --- a/apps/sim/components/emcn/components/time-picker/time-picker.tsx +++ b/apps/sim/components/emcn/components/time-picker/time-picker.tsx @@ -135,13 +135,15 @@ const TimePicker = React.forwardRef( const [hour, setHour] = React.useState(parsed.hour) const [minute, setMinute] = React.useState(parsed.minute) const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsed.ampm) + const [prevValue, setPrevValue] = React.useState(value) - React.useEffect(() => { + if (value !== prevValue) { + setPrevValue(value) const newParsed = parseTime(value || '') setHour(newParsed.hour) setMinute(newParsed.minute) setAmpm(newParsed.ampm) - }, [value]) + } React.useEffect(() => { if (open) { From 50b3987ef1e79fab0b00215cffd47c806642ca75 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 19 Mar 2026 11:32:42 -0700 Subject: [PATCH 2/7] fix(react): revert unsafe render-time side effects to useEffect --- .../document-tags-modal.tsx | 33 ++++++++++++++----- .../hooks/use-profile-picture-upload.ts | 16 ++++----- .../w/components/sidebar/sidebar.tsx | 30 +++++++++-------- .../components/emcn/components/code/code.tsx | 12 +++---- 4 files changed, 53 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx index 9332581b82d..c947e017477 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' import { Badge, @@ -20,7 +20,10 @@ import { cn } from '@/lib/core/utils/cn' import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants' import type { DocumentTag } from '@/lib/knowledge/tags/types' import type { DocumentData } from '@/lib/knowledge/types' -import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions' +import { + type TagDefinition, + useKnowledgeBaseTagDefinitions, +} from '@/hooks/kb/use-knowledge-base-tag-definitions' import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions' import { useNextAvailableSlotMutation, useUpdateDocumentTags } from '@/hooks/queries/kb/knowledge' @@ -97,6 +100,7 @@ export function DocumentTagsModal({ const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook + const [documentTags, setDocumentTags] = useState([]) const [editingTagIndex, setEditingTagIndex] = useState(null) const [isCreatingTag, setIsCreatingTag] = useState(false) const [isSavingTag, setIsSavingTag] = useState(false) @@ -106,13 +110,12 @@ export function DocumentTagsModal({ value: '', }) - const documentTags = useMemo(() => { - if (!documentData || !tagDefinitions) return [] - + const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => { const tags: DocumentTag[] = [] + ALL_TAG_SLOTS.forEach((slot) => { - const rawValue = documentData[slot] - const definition = tagDefinitions.find((def) => def.tagSlot === slot) + const rawValue = docData[slot] + const definition = definitions.find((def) => def.tagSlot === slot) if (rawValue !== null && rawValue !== undefined && definition) { const stringValue = String(rawValue).trim() @@ -128,7 +131,11 @@ export function DocumentTagsModal({ }) return tags - }, [documentData, tagDefinitions]) + }, []) + + const handleTagsChange = useCallback((newTags: DocumentTag[]) => { + setDocumentTags(newTags) + }, []) const handleSaveDocumentTags = useCallback( async (tagsToSave: DocumentTag[]) => { @@ -166,6 +173,7 @@ export function DocumentTagsModal({ const handleRemoveTag = async (index: number) => { const updatedTags = documentTags.filter((_, i) => i !== index) + handleTagsChange(updatedTags) try { await handleSaveDocumentTags(updatedTags) @@ -276,6 +284,8 @@ export function DocumentTagsModal({ updatedTags = [...documentTags, newTag] } + handleTagsChange(updatedTags) + if (currentEditingIndex !== null && originalTag) { const currentDefinition = kbTagDefinitions.find( (def) => def.displayName.toLowerCase() === originalTag.displayName.toLowerCase() @@ -351,6 +361,13 @@ export function DocumentTagsModal({ const canAddNewTag = kbTagDefinitions.length < MAX_TAG_SLOTS || availableDefinitions.length > 0 + useEffect(() => { + if (documentData && tagDefinitions && !isSavingTag) { + const rebuiltTags = buildDocumentTags(documentData, tagDefinitions) + setDocumentTags(rebuiltTags) + } + }, [documentData, tagDefinitions, buildDocumentTags, isSavingTag]) + const handleClose = (openState: boolean) => { if (!openState) { setIsCreatingTag(false) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts index 1a4bf889032..cb4c1497b52 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts @@ -26,15 +26,15 @@ export function useProfilePictureUpload({ const [fileName, setFileName] = useState(null) const [isUploading, setIsUploading] = useState(false) - const prevCurrentImageRef = useRef(currentImage) - if (currentImage !== prevCurrentImageRef.current) { - prevCurrentImageRef.current = currentImage - if (previewRef.current && previewRef.current !== currentImage) { - URL.revokeObjectURL(previewRef.current) - previewRef.current = null + useEffect(() => { + if (currentImage !== previewUrl) { + if (previewRef.current && previewRef.current !== currentImage) { + URL.revokeObjectURL(previewRef.current) + previewRef.current = null + } + setPreviewUrl(currentImage || null) } - setPreviewUrl(currentImage || null) - } + }, [currentImage, previewUrl]) const validateFile = useCallback((file: File): string | null => { if (file.size > MAX_FILE_SIZE) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index bbbd94e1f47..ae32f81e323 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -641,6 +641,16 @@ export const Sidebar = memo(function Sidebar() { setIsTaskDeleteModalOpen(true) }, [tasks]) + const navigateToPage = useCallback( + (path: string) => { + if (!isCollapsed) { + setSidebarWidth(SIDEBAR_WIDTH.MIN) + } + router.push(path) + }, + [isCollapsed, setSidebarWidth, router] + ) + const handleConfirmDeleteTasks = useCallback(() => { const { taskIds: taskIdsToDelete } = contextMenuSelectionRef.current if (taskIdsToDelete.length === 0) return @@ -663,7 +673,7 @@ export const Sidebar = memo(function Sidebar() { deleteTasksMutation.mutate(taskIdsToDelete, { onSuccess: onDeleteSuccess }) } setIsTaskDeleteModalOpen(false) - }, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, router]) + }, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, navigateToPage]) const [visibleTaskCount, setVisibleTaskCount] = useState(5) const [renamingTaskId, setRenamingTaskId] = useState(null) @@ -772,19 +782,11 @@ export const Sidebar = memo(function Sidebar() { }) }, [workflowId, workflowsLoading]) - /** - * Navigates to a non-workflow page and resets the sidebar width to minimum - * when the sidebar is expanded. - */ - const navigateToPage = useCallback( - (path: string) => { - if (!isCollapsed) { - setSidebarWidth(SIDEBAR_WIDTH.MIN) - } - router.push(path) - }, - [isCollapsed, setSidebarWidth, router] - ) + useEffect(() => { + if (!isOnWorkflowPage && !isCollapsed) { + setSidebarWidth(SIDEBAR_WIDTH.MIN) + } + }, [isOnWorkflowPage, isCollapsed, setSidebarWidth]) const handleCreateWorkflow = useCallback(async () => { const workflowId = await createWorkflow() diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx index c15817f2cd7..94f865b01ed 100644 --- a/apps/sim/components/emcn/components/code/code.tsx +++ b/apps/sim/components/emcn/components/code/code.tsx @@ -869,11 +869,9 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({ return { matchOffsets: offsets, matchCount: cumulative } }, [lines.length, displayLines, visibleLineIndices, searchQuery]) - const prevMatchCountRef = useRef(matchCount) - if (prevMatchCountRef.current !== matchCount) { - prevMatchCountRef.current = matchCount + useEffect(() => { onMatchCountChange?.(matchCount) - } + }, [matchCount, onMatchCountChange]) // Only process visible lines for efficiency (not all lines) const visibleLines = useMemo(() => { @@ -1064,11 +1062,9 @@ const ViewerInner = memo(function ViewerInner({ return { cumulativeMatches: cumulative, matchCount: cumulative[cumulative.length - 1] } }, [lines.length, displayLines, visibleLineIndices, searchQuery]) - const prevMatchCountRef = useRef(matchCount) - if (prevMatchCountRef.current !== matchCount) { - prevMatchCountRef.current = matchCount + useEffect(() => { onMatchCountChange?.(matchCount) - } + }, [matchCount, onMatchCountChange]) // Pre-compute highlighted lines with search for visible indices (for gutter mode) const highlightedVisibleLines = useMemo(() => { From 350f113afbf30be9abb1271384413e34b03676a7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 19 Mar 2026 11:56:17 -0700 Subject: [PATCH 3/7] fix(react): restore useEffect for modals, scroll, and env sync - Modals (create-workspace, rename-document, edit-knowledge-base): restore useEffect watching `open` prop for form reset on programmatic open, since Radix onOpenChange doesn't fire for parent-driven prop changes - Popover: add useEffect watching `open` for programmatic close reset - Chat scroll: restore useEffect watching `isStreamingResponse` so the 1s suppression timer starts when streaming begins, not before the fetch - Credentials manager: revert render-time pattern to useEffect for initial sync from cached React Query data (useRef captures initial value, making the !== check always false on mount) --- apps/sim/app/chat/[identifier]/chat.tsx | 24 +++++++++---------- .../rename-document-modal.tsx | 11 +++++---- .../edit-knowledge-base-modal.tsx | 11 +++++---- .../credentials/credentials-manager.tsx | 11 ++++----- .../create-workspace-modal.tsx | 11 +++++---- .../emcn/components/popover/popover.tsx | 10 +++++--- 6 files changed, 44 insertions(+), 34 deletions(-) diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index 14f7736050d..549e450d4a5 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -190,18 +190,16 @@ export default function ChatClient({ identifier }: { identifier: string }) { return () => container.removeEventListener('scroll', handleScroll) }, [handleScroll]) - /** - * Resets scroll tracking state when a new streaming response begins. - * Suppresses scroll detection briefly to avoid false positives from - * programmatic scrolls. - */ - const resetScrollStateForStreaming = useCallback(() => { - setUserHasScrolled(false) - isUserScrollingRef.current = true - setTimeout(() => { - isUserScrollingRef.current = false - }, 1000) - }, []) + useEffect(() => { + if (isStreamingResponse) { + setUserHasScrolled(false) + + isUserScrollingRef.current = true + setTimeout(() => { + isUserScrollingRef.current = false + }, 1000) + } + }, [isStreamingResponse]) const fetchChatConfig = async () => { try { @@ -302,7 +300,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { filesCount: files?.length, }) - resetScrollStateForStreaming() + setUserHasScrolled(false) const userMessage: ChatMessage = { id: crypto.randomUUID(), diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx index 751d44373d0..a78d1034551 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { createLogger } from '@sim/logger' import { Button, @@ -39,12 +39,15 @@ export function RenameDocumentModal({ const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(null) - /** Resets form state when the modal opens and forwards the open state change */ - const handleOpenChange = (newOpen: boolean) => { - if (newOpen) { + useEffect(() => { + if (open) { setName(initialName) setError(null) } + }, [open, initialName]) + + /** Resets form state when the modal opens and forwards the open state change */ + const handleOpenChange = (newOpen: boolean) => { onOpenChange(newOpen) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx index 5676524034d..af5c955d105 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { zodResolver } from '@hookform/resolvers/zod' import { createLogger } from '@sim/logger' import { useForm } from 'react-hook-form' @@ -71,15 +71,18 @@ export function EditKnowledgeBaseModal({ const nameValue = watch('name') - /** Resets form state when the modal opens and forwards the open state change */ - const handleOpenChange = (newOpen: boolean) => { - if (newOpen) { + useEffect(() => { + if (open) { setError(null) reset({ name: initialName, description: initialDescription, }) } + }, [open, initialName, initialDescription, reset]) + + /** Forwards the open state change to the parent */ + const handleOpenChange = (newOpen: boolean) => { onOpenChange(newOpen) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index e974c961364..ee553726c17 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -493,16 +493,15 @@ export function CredentialsManager() { setEnvVars(JSON.parse(JSON.stringify(initialVars))) }, [variables]) - const prevWorkspaceEnvDataRef = useRef(workspaceEnvData) - if (workspaceEnvData && workspaceEnvData !== prevWorkspaceEnvDataRef.current) { - prevWorkspaceEnvDataRef.current = workspaceEnvData + useEffect(() => { + if (!workspaceEnvData) return if (hasSavedRef.current) { hasSavedRef.current = false } else { - setWorkspaceVars(workspaceEnvData?.workspace || {}) - initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {} + setWorkspaceVars(workspaceEnvData.workspace || {}) + initialWorkspaceVarsRef.current = workspaceEnvData.workspace || {} } - } + }, [workspaceEnvData]) const scrollToBottom = useCallback(() => { requestAnimationFrame(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx index 2af9996c839..cd7fdb54915 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Button, Input, @@ -30,11 +30,14 @@ export function CreateWorkspaceModal({ const [name, setName] = useState('') const inputRef = useRef(null) + useEffect(() => { + if (open) { + setName('') + } + }, [open]) + const handleOpenChange = useCallback( (newOpen: boolean) => { - if (newOpen) { - setName('') - } onOpenChange(newOpen) }, [onOpenChange] diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index 784908e92ea..8702c41a5ac 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -264,16 +264,20 @@ const Popover: React.FC = ({ registeredItemsRef.current = [] }, []) + React.useEffect(() => { + if (!open) { + resetState() + } + }, [open, resetState]) + const handleOpenChange = React.useCallback( (nextOpen: boolean) => { if (nextOpen) { setLastHoveredItem(null) - } else { - resetState() } onOpenChange?.(nextOpen) }, - [onOpenChange, resetState] + [onOpenChange] ) const openFolder = React.useCallback( From c56ff7a97edadd9d84a122f93a5c69d5171b46f0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 19 Mar 2026 12:10:39 -0700 Subject: [PATCH 4/7] fix(react): restore useEffect for help/invite modals, combobox index reset - Help modal: restore useEffect watching `open` for form reset on programmatic open (same Radix onOpenChange pattern as other modals) - Invite modal: restore useEffect watching `open` to clear error on programmatic open - Combobox: restore useEffect to reset highlightedIndex when filtered options shrink (prevents stale index from reappearing when options grow) - Remove no-op handleOpenChange wrappers in rename-document and edit-knowledge-base modals (now pure pass-throughs after useEffect fix) --- .../rename-document-modal.tsx | 7 +----- .../edit-knowledge-base-modal.tsx | 7 +----- .../components/help-modal/help-modal.tsx | 23 +++++++------------ .../components/invite-modal/invite-modal.tsx | 10 +++++--- .../emcn/components/combobox/combobox.tsx | 9 ++++++++ 5 files changed, 26 insertions(+), 30 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx index a78d1034551..e701c171bb0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx @@ -46,11 +46,6 @@ export function RenameDocumentModal({ } }, [open, initialName]) - /** Resets form state when the modal opens and forwards the open state change */ - const handleOpenChange = (newOpen: boolean) => { - onOpenChange(newOpen) - } - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -81,7 +76,7 @@ export function RenameDocumentModal({ } return ( - + Rename Document diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx index af5c955d105..bf0699813c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx @@ -81,11 +81,6 @@ export function EditKnowledgeBaseModal({ } }, [open, initialName, initialDescription, reset]) - /** Forwards the open state change to the parent */ - const handleOpenChange = (newOpen: boolean) => { - onOpenChange(newOpen) - } - const onSubmit = async (data: FormValues) => { setIsSubmitting(true) setError(null) @@ -102,7 +97,7 @@ export function EditKnowledgeBaseModal({ } return ( - + Edit Knowledge Base diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx index c4a7f78003f..c017e6162e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx @@ -103,18 +103,11 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM }) }, [reset]) - /** - * Wrap onOpenChange to reset state when the modal opens - */ - const handleOpenChange = useCallback( - (newOpen: boolean) => { - if (newOpen) { - resetModalState() - } - onOpenChange(newOpen) - }, - [onOpenChange, resetModalState] - ) + useEffect(() => { + if (open) { + resetModalState() + } + }, [open, resetModalState]) /** * Fix z-index for popover/dropdown when inside modal @@ -426,11 +419,11 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM * Handle modal close action */ const handleClose = useCallback(() => { - handleOpenChange(false) - }, [handleOpenChange]) + onOpenChange(false) + }, [onOpenChange]) return ( - + Help & Support diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx index 77694b2cc27..117817f1c30 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx @@ -85,6 +85,12 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr const isRemovingMember = removeMember.isPending const isRemovingInvitation = cancelInvitation.isPending + useEffect(() => { + if (open) { + setErrorMessage(null) + } + }, [open]) + useEffect(() => { const intervalsRef = cooldownIntervalsRef.current return () => { @@ -459,9 +465,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr { - if (newOpen) { - setErrorMessage(null) - } else { + if (!newOpen) { resetState() } onOpenChange(newOpen) diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index d9790a1f3fe..5e63acf9ca7 100644 --- a/apps/sim/components/emcn/components/combobox/combobox.tsx +++ b/apps/sim/components/emcn/components/combobox/combobox.tsx @@ -465,6 +465,15 @@ const Combobox = memo( const effectiveHighlightedIndex = highlightedIndex >= 0 && highlightedIndex < filteredOptions.length ? highlightedIndex : -1 + /** + * Reset highlighted index when filtered options shrink below it + */ + useEffect(() => { + if (highlightedIndex >= 0 && highlightedIndex >= filteredOptions.length) { + setHighlightedIndex(-1) + } + }, [filteredOptions.length, highlightedIndex]) + /** * Scroll highlighted option into view */ From f8e8f7fd740b9cbc77d95088d4435e38fa1da41c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 19 Mar 2026 12:23:18 -0700 Subject: [PATCH 5/7] fix(context-menu): use requestAnimationFrame for ColorGrid focus, remove no-op wrapper in create-workspace-modal - ColorGrid: replaced setTimeout with requestAnimationFrame for initial focus to wait for submenu paint completion - create-workspace-modal: removed handleOpenChange pass-through wrapper, use onOpenChange directly --- .../components/context-menu/context-menu.tsx | 4 ++- .../create-workspace-modal.tsx | 32 +++++++------------ 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 3801a94c064..ae179d5d79f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -55,7 +55,9 @@ function ColorGrid({ ) const idx = selectedIndex >= 0 ? selectedIndex : 0 setFocusedIndex(idx) - buttonRefs.current[idx]?.focus() + requestAnimationFrame(() => { + buttonRefs.current[idx]?.focus() + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx index cd7fdb54915..5193680eb89 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button, Input, @@ -36,31 +36,21 @@ export function CreateWorkspaceModal({ } }, [open]) - const handleOpenChange = useCallback( - (newOpen: boolean) => { - onOpenChange(newOpen) - }, - [onOpenChange] - ) - - const handleSubmit = useCallback(async () => { + const handleSubmit = async () => { const trimmed = name.trim() if (!trimmed || isCreating) return await onConfirm(trimmed) - }, [name, isCreating, onConfirm]) + } - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - void handleSubmit() - } - }, - [handleSubmit] - ) + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + void handleSubmit() + } + } return ( - + { @@ -85,7 +75,7 @@ export function CreateWorkspaceModal({ /> -