diff --git a/frontend/src/components/MilestoneBuilder.tsx b/frontend/src/components/MilestoneBuilder.tsx index f6b4590..859ed2d 100644 --- a/frontend/src/components/MilestoneBuilder.tsx +++ b/frontend/src/components/MilestoneBuilder.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useMemo } from 'react' export interface MilestoneInput { amount: string @@ -8,10 +8,54 @@ export interface MilestoneInput { interface MilestoneBuilderProps { milestones: MilestoneInput[] onChange: (milestones: MilestoneInput[]) => void + onValidationChange?: (isValid: boolean) => void } -export default function MilestoneBuilder({ milestones, onChange }: MilestoneBuilderProps) { +export default function MilestoneBuilder({ milestones, onChange, onValidationChange }: MilestoneBuilderProps) { const [errors, setErrors] = useState>({}) + const [duplicates, setDuplicates] = useState>(new Set()) + + // Check for duplicate amounts and invalid values + const validationStatus = useMemo(() => { + const amountMap = new Map() + const newDuplicates = new Set() + let hasErrors = false + + milestones.forEach((milestone, index) => { + const numValue = parseFloat(milestone.amount) + + // Check for individual validation errors + if (milestone.amount && (isNaN(numValue) || numValue <= 0)) { + hasErrors = true + } + + // Track amounts for duplicate detection + if (milestone.amount && !isNaN(numValue) && numValue > 0) { + const key = numValue.toString() + if (!amountMap.has(key)) { + amountMap.set(key, []) + } + amountMap.get(key)!.push(index) + } + }) + + // Mark duplicates + amountMap.forEach((indices) => { + if (indices.length > 1) { + indices.forEach(index => newDuplicates.add(index)) + hasErrors = true + } + }) + + setDuplicates(newDuplicates) + const isValid = !hasErrors && milestones.length > 0 && milestones.every(m => m.amount.trim() !== '') + + if (onValidationChange) { + onValidationChange(isValid) + } + + return { isValid, hasErrors } + }, [milestones, onValidationChange]) const addMilestone = () => { onChange([...milestones, { amount: '', description: '' }]) @@ -141,6 +185,9 @@ export default function MilestoneBuilder({ milestones, onChange }: MilestoneBuil {errors[index] && (

{errors[index]}

)} + {duplicates.has(index) && !errors[index] && ( +

⚠️ This amount is duplicated in another milestone

+ )}
diff --git a/frontend/src/hooks/useAgreementEvents.ts b/frontend/src/hooks/useAgreementEvents.ts index 0240027..acacbc7 100644 --- a/frontend/src/hooks/useAgreementEvents.ts +++ b/frontend/src/hooks/useAgreementEvents.ts @@ -49,8 +49,11 @@ export function useAgreementEvents(agreementId: string | null) { async (signal: AbortSignal): Promise => { if (!agreementId) return true // treat no-id as "success" (no-op) - setIsLoading(true) - setError(null) + // Guard state updates for unmounted components + if (!unmountedRef.current) { + setIsLoading(true) + setError(null) + } // Combine the caller's signal with a per-request timeout signal. const timeoutId = setTimeout(() => { diff --git a/frontend/src/hooks/useCountUp.ts b/frontend/src/hooks/useCountUp.ts index 3843266..48ef118 100644 --- a/frontend/src/hooks/useCountUp.ts +++ b/frontend/src/hooks/useCountUp.ts @@ -11,38 +11,52 @@ export function useCountUp( ): number { const { duration = 500, minThreshold = 5 } = options - const [displayValue, setDisplayValue] = useState(targetValue) - const previousValueRef = useRef(targetValue) + // Validate and clamp target value + const validatedTarget = validateAndClampTarget(targetValue) + + const [displayValue, setDisplayValue] = useState(validatedTarget) + const previousValueRef = useRef(validatedTarget) const animationFrameRef = useRef(null) const startTimeRef = useRef(null) + const iterationCountRef = useRef(0) useEffect(() => { const startValue = previousValueRef.current - const change = Math.abs(targetValue - startValue) + const change = Math.abs(validatedTarget - startValue) if (change === 0) return if (change < minThreshold) { - setDisplayValue(targetValue) - previousValueRef.current = targetValue + setDisplayValue(validatedTarget) + previousValueRef.current = validatedTarget return } const startTime = performance.now() startTimeRef.current = startTime + iterationCountRef.current = 0 const animate = (currentTime: number) => { + // Safeguard against infinite animation loops (max 1000 iterations) + iterationCountRef.current += 1 + if (iterationCountRef.current > 1000) { + setDisplayValue(validatedTarget) + previousValueRef.current = validatedTarget + animationFrameRef.current = null + return + } + const elapsed = currentTime - startTime const progress = Math.min(elapsed / duration, 1) - const current = startValue + (targetValue - startValue) * progress + const current = startValue + (validatedTarget - startValue) * progress setDisplayValue(Math.round(current)) if (progress < 1) { animationFrameRef.current = requestAnimationFrame(animate) } else { - setDisplayValue(targetValue) - previousValueRef.current = targetValue + setDisplayValue(validatedTarget) + previousValueRef.current = validatedTarget animationFrameRef.current = null } } @@ -55,7 +69,30 @@ export function useCountUp( animationFrameRef.current = null } } - }, [targetValue, duration, minThreshold]) + }, [validatedTarget, duration, minThreshold]) return displayValue } + +/** + * Validates and clamps the target value for count-up animation. + * + * Ensures: + * - Input is a finite number (rejects NaN and Infinity) + * - Value is within reasonable range [0, 1e9] + * - Returns a safe default (0) for invalid inputs + * + * @param value - The target value to validate + * @returns A valid, finite number within the safe range + */ +function validateAndClampTarget(value: number): number { + // Check if value is a finite number + if (!Number.isFinite(value)) { + return 0 + } + + // Clamp to safe range [0, 1e9] + const MIN_VALUE = 0 + const MAX_VALUE = 1e9 + return Math.max(MIN_VALUE, Math.min(MAX_VALUE, value)) +} diff --git a/frontend/src/hooks/useStellarStatus.ts b/frontend/src/hooks/useStellarStatus.ts index f966001..2dc81fe 100644 --- a/frontend/src/hooks/useStellarStatus.ts +++ b/frontend/src/hooks/useStellarStatus.ts @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { RPC_URL } from '../lib/config' +import { createAbortSignalWithTimeout } from '../lib/abort' type NetworkStatus = 'online' | 'degraded' | 'offline' | 'checking' @@ -28,7 +29,7 @@ export function useStellarStatus(intervalMs = 60000): StellarStatus { method: 'getHealth', params: [], }), - signal: AbortSignal.timeout(5000), + signal: createAbortSignalWithTimeout(5000), }) const latency = Date.now() - start diff --git a/frontend/src/lib/abort.ts b/frontend/src/lib/abort.ts new file mode 100644 index 0000000..65ca86b --- /dev/null +++ b/frontend/src/lib/abort.ts @@ -0,0 +1,38 @@ +/** + * Creates an AbortSignal that automatically aborts after the specified timeout. + * Provides a fallback for browsers that don't support AbortSignal.timeout(). + * + * @param timeoutMs - The timeout in milliseconds + * @returns An AbortSignal that aborts after the specified timeout + */ +export function createAbortSignalWithTimeout(timeoutMs: number): AbortSignal { + // Check if AbortSignal.timeout is supported (Chrome 103+, Safari 15.4+, etc.) + if (typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal) { + try { + return AbortSignal.timeout(timeoutMs) + } catch { + // Fallback if native implementation fails + return createAbortSignalFallback(timeoutMs) + } + } + + // Fallback for older browsers + return createAbortSignalFallback(timeoutMs) +} + +/** + * Fallback implementation using AbortController and setTimeout. + * Works on all browsers that support AbortController. + */ +function createAbortSignalFallback(timeoutMs: number): AbortSignal { + const controller = new AbortController() + const timeoutId = setTimeout(() => { + controller.abort() + }, timeoutMs) + + // Store the timeoutId on the signal so it can be cleaned up if needed + const signal = controller.signal as any + signal._timeoutId = timeoutId + + return signal +}