diff --git a/frontend/hooks/use-block-execution-tracker.ts b/frontend/hooks/use-block-execution-tracker.ts index dc5641c..97188b7 100644 --- a/frontend/hooks/use-block-execution-tracker.ts +++ b/frontend/hooks/use-block-execution-tracker.ts @@ -8,7 +8,7 @@ import type { Block } from '@/types/block' import type { SerializableEventData } from '@/types/events' import { useEvents } from './use-events' -const MAX_BLOCKS = 5000 +const MAX_BLOCKS = 200 // Highlight when total tx execution time exceeds block execution time. // Keep this as a single constant so UI/copy can stay consistent. @@ -20,6 +20,31 @@ export const PARALLEL_EXECUTION_RATIO_THRESHOLD = 1 export function useBlockExecutionTracker() { const [blocks, setBlocks] = useState([]) + /** + * Replace the last element of an array with a new value. + * Returns a new array (shallow copy) so React detects the change, + * but reuses all object references except the replaced element. + */ + const replaceLastBlock = (prev: Block[], updated: Block): Block[] => { + const next = prev.slice() + next[next.length - 1] = updated + return next + } + + /** + * Replace a single block by index. + * Returns a new array (shallow copy) with only that one element changed. + */ + const replaceBlockAt = ( + prev: Block[], + index: number, + updated: Block, + ): Block[] => { + const next = prev.slice() + next[index] = updated + return next + } + // Handle real-time events from the backend const handleEvent = useCallback((event: SerializableEventData) => { switch (event.payload.type) { @@ -32,7 +57,6 @@ export function useBlockExecutionTracker() { } setBlocks((prev) => { const existingBlock = prev.find((b) => b.id === payload.block_id) - let newBlocks: Block[] // Should never happen if (existingBlock) { @@ -40,30 +64,26 @@ export function useBlockExecutionTracker() { '2 BlockStart events received on block:', payload.block_number, ) - // Update existing block with new BlockStart data const lastBlock = prev[prev.length - 1] - newBlocks = [ - ...prev.slice(0, -1), - { - ...lastBlock, - state: 'proposed', - startTimestamp: BigInt(event.timestamp_ns), - }, - ] - } else { - // Create new block - newBlocks = [ - ...prev, - { - id: payload.block_id, - number: blockNumber, - state: 'proposed', - startTimestamp: BigInt(event.timestamp_ns), - transactions: [], - }, - ] + return replaceLastBlock(prev, { + ...lastBlock, + state: 'proposed', + startTimestamp: BigInt(event.timestamp_ns), + }) } + // Create new block — this is the only case that grows the array + const newBlocks = [ + ...prev, + { + id: payload.block_id, + number: blockNumber, + state: 'proposed' as const, + startTimestamp: BigInt(event.timestamp_ns), + transactions: [], + }, + ] + if (newBlocks.length > MAX_BLOCKS) { return newBlocks.slice(-Math.ceil(MAX_BLOCKS / 3)) } @@ -75,7 +95,6 @@ export function useBlockExecutionTracker() { case 'TxnHeaderStart': { const payload = event.payload setBlocks((prev) => { - // check if blocks is empty if (prev.length === 0) { console.warn( 'TxnHeaderStart event received but no blocks exist yet:', @@ -83,27 +102,23 @@ export function useBlockExecutionTracker() { ) return prev } - // add a transaction to the last block const lastBlock = prev[prev.length - 1] - return [ - ...prev.slice(0, -1), - { - ...lastBlock, - transactions: [ - ...(lastBlock.transactions ?? []), - { - id: payload.txn_index, - txnIndex: payload.txn_index, - txnHash: payload.txn_hash, - startTimestamp: BigInt(event.timestamp_ns), - transactionTime: undefined, // Will be calculated when TxnEnd is received - gasLimit: payload.gas_limit, - sender: payload.sender, - to: payload.to, - }, - ], - }, - ] + return replaceLastBlock(prev, { + ...lastBlock, + transactions: [ + ...(lastBlock.transactions ?? []), + { + id: payload.txn_index, + txnIndex: payload.txn_index, + txnHash: payload.txn_hash, + startTimestamp: BigInt(event.timestamp_ns), + transactionTime: undefined, + gasLimit: payload.gas_limit, + sender: payload.sender, + to: payload.to, + }, + ], + }) }) break } @@ -113,43 +128,35 @@ export function useBlockExecutionTracker() { console.warn('TxnEnd event missing txn_idx:', event) break } - if (event.txn_idx !== undefined) { - setBlocks((prev) => { - // check if blocks is empty - if (prev.length === 0) { - console.warn( - 'TxnEnd event received but no blocks exist yet:', - event, - ) - return prev - } - // update the last block - const lastBlock = prev[prev.length - 1] - return [ - ...prev.slice(0, -1), - { - ...lastBlock, - transactions: (lastBlock.transactions ?? []).map((tx) => - tx.txnIndex === event.txn_idx && tx.startTimestamp - ? { - ...tx, - endTimestamp: BigInt(event.timestamp_ns), - transactionTime: - BigInt(event.timestamp_ns) - tx.startTimestamp, - } - : tx, - ), - }, - ] + setBlocks((prev) => { + if (prev.length === 0) { + console.warn( + 'TxnEnd event received but no blocks exist yet:', + event, + ) + return prev + } + const lastBlock = prev[prev.length - 1] + return replaceLastBlock(prev, { + ...lastBlock, + transactions: (lastBlock.transactions ?? []).map((tx) => + tx.txnIndex === event.txn_idx && tx.startTimestamp + ? { + ...tx, + endTimestamp: BigInt(event.timestamp_ns), + transactionTime: + BigInt(event.timestamp_ns) - tx.startTimestamp, + } + : tx, + ), }) - } + }) break } case 'TxnEvmOutput': { const payload = event.payload setBlocks((prev) => { - // check if blocks is empty if (prev.length === 0) { console.warn( 'TxnEvmOutput event received but no blocks exist yet:', @@ -157,23 +164,19 @@ export function useBlockExecutionTracker() { ) return prev } - // update the last block - return prev.map((block, index) => - index === prev.length - 1 - ? { - ...block, - transactions: (block.transactions ?? []).map((tx) => - tx.txnIndex === payload.txn_index - ? { - ...tx, - status: payload.status, - gasUsed: payload.gas_used, - } - : tx, - ), - } - : block, - ) + const lastBlock = prev[prev.length - 1] + return replaceLastBlock(prev, { + ...lastBlock, + transactions: (lastBlock.transactions ?? []).map((tx) => + tx.txnIndex === payload.txn_index + ? { + ...tx, + status: payload.status, + gasUsed: payload.gas_used, + } + : tx, + ), + }) }) break } @@ -185,9 +188,12 @@ export function useBlockExecutionTracker() { break } setBlocks((prev) => { - return prev.map((block) => - block.number === blockNumber ? { ...block, state: 'voted' } : block, - ) + const index = prev.findIndex((b) => b.number === blockNumber) + if (index === -1) return prev + return replaceBlockAt(prev, index, { + ...prev[index], + state: 'voted', + }) }) break } @@ -199,11 +205,12 @@ export function useBlockExecutionTracker() { break } setBlocks((prev) => { - return prev.map((block) => - block.number === blockNumber - ? { ...block, state: 'finalized' } - : block, - ) + const index = prev.findIndex((b) => b.number === blockNumber) + if (index === -1) return prev + return replaceBlockAt(prev, index, { + ...prev[index], + state: 'finalized', + }) }) break } @@ -215,27 +222,28 @@ export function useBlockExecutionTracker() { break } setBlocks((prev) => { - return prev.map((block) => - block.number === blockNumber - ? { ...block, state: 'verified' } - : block, - ) + const index = prev.findIndex((b) => b.number === blockNumber) + if (index === -1) return prev + return replaceBlockAt(prev, index, { + ...prev[index], + state: 'verified', + }) }) break } case 'BlockEnd': setBlocks((prev) => { - return prev.map((block) => - block.number === event?.block_number && block.startTimestamp - ? { - ...block, - endTimestamp: BigInt(event.timestamp_ns), - executionTime: - BigInt(event.timestamp_ns) - block.startTimestamp, - } - : block, + const index = prev.findIndex( + (b) => b.number === event?.block_number && b.startTimestamp, ) + if (index === -1) return prev + const block = prev[index] + return replaceBlockAt(prev, index, { + ...block, + endTimestamp: BigInt(event.timestamp_ns), + executionTime: BigInt(event.timestamp_ns) - block.startTimestamp!, + }) }) break diff --git a/frontend/hooks/use-block-state-tracker.ts b/frontend/hooks/use-block-state-tracker.ts index a09151e..01ba61e 100644 --- a/frontend/hooks/use-block-state-tracker.ts +++ b/frontend/hooks/use-block-state-tracker.ts @@ -7,7 +7,7 @@ import { formatTimestamp } from '@/lib/timestamp' import type { Block, BlockState } from '@/types/block' import type { SerializableEventData } from '@/types/events' -const MAX_BLOCKS = 5000 +const MAX_BLOCKS = 200 interface UseBlockStateTrackerReturn { blocks: Block[] @@ -55,11 +55,11 @@ function applyEventToBlocks( case 'BlockFinalized': case 'BlockVerified': { const newState = EVENT_TO_STATE[payload.type] - return blocks.map((block) => - block.number === payload.block_number - ? { ...block, state: newState, timestamp } - : block, - ) + const index = blocks.findIndex((b) => b.number === payload.block_number) + if (index === -1) return blocks + const next = blocks.slice() + next[index] = { ...blocks[index], state: newState, timestamp } + return next } case 'BlockReject': { diff --git a/frontend/hooks/use-blockchain-scroll.ts b/frontend/hooks/use-blockchain-scroll.ts index cfea93c..a59a4f8 100644 --- a/frontend/hooks/use-blockchain-scroll.ts +++ b/frontend/hooks/use-blockchain-scroll.ts @@ -32,6 +32,11 @@ export function useBlockchainScroll({ const oldestBlockNumber = sortedBlocks.length > 0 ? sortedBlocks[0].number : null + // Keep a ref to the latest length so rAF callbacks always read the current value + // instead of closing over a stale sortedBlocks.length that may have been trimmed. + const sortedBlocksLengthRef = useRef(sortedBlocks.length) + sortedBlocksLengthRef.current = sortedBlocks.length + // Auto-scroll to the end when new blocks are added (only if following chain) useLayoutEffect(() => { if (!gridRef.current || newestBlockNumber === null || !isFollowingChain) @@ -58,11 +63,14 @@ export function useBlockchainScroll({ if (shouldScroll) { requestAnimationFrame(() => { - gridRef.current?.scrollToColumn({ - index: sortedBlocks.length - 1, - align: 'end', - behavior: 'smooth', - }) + const currentLength = sortedBlocksLengthRef.current + if (currentLength > 0) { + gridRef.current?.scrollToColumn({ + index: currentLength - 1, + align: 'end', + behavior: 'smooth', + }) + } }) } @@ -86,12 +94,12 @@ export function useBlockchainScroll({ document.visibilityState === 'visible' && wasHiddenRef.current && isFollowingChain && - sortedBlocks.length > 0 + sortedBlocksLengthRef.current > 0 ) { // Tab became visible after being hidden - scroll to end immediately // Use 'auto' (instant) to avoid jarring smooth animation of large distance gridRef.current?.scrollToColumn({ - index: sortedBlocks.length - 1, + index: sortedBlocksLengthRef.current - 1, align: 'end', behavior: 'auto', }) diff --git a/frontend/hooks/use-blockchain-slow-motion.ts b/frontend/hooks/use-blockchain-slow-motion.ts index 32708e6..4f4f472 100644 --- a/frontend/hooks/use-blockchain-slow-motion.ts +++ b/frontend/hooks/use-blockchain-slow-motion.ts @@ -8,6 +8,9 @@ import { } from '@/constants/block-state' import type { SerializableEventData } from '@/types/events' +/** Prevent unbounded queue growth during slow motion. */ +const MAX_QUEUE_SIZE = 10_000 + interface UseSlowMotionOptions { onProcessEvent: (event: SerializableEventData) => void onFlushEvents: (events: SerializableEventData[]) => void @@ -126,7 +129,9 @@ export function useBlockchainSlowMotion({ // Queue an event (called from the event handler) const queueEvent = useCallback((event: SerializableEventData) => { if (isSlowMotionRef.current) { - eventQueueRef.current.push(event) + if (eventQueueRef.current.length < MAX_QUEUE_SIZE) { + eventQueueRef.current.push(event) + } } else { // Normal mode - process immediately onProcessEventRef.current(event) diff --git a/frontend/hooks/use-contract-labels.ts b/frontend/hooks/use-contract-labels.ts index 2a528fe..773a594 100644 --- a/frontend/hooks/use-contract-labels.ts +++ b/frontend/hooks/use-contract-labels.ts @@ -4,6 +4,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { getTokenFromList } from '@/lib/token-list' import type { ContractLabelsResponse } from '@/types/contract' +/** Evict old entries when the cache exceeds this size. */ +const MAX_CACHED_LABELS = 500 + interface ContractLabelInfo { address: string name: string | null @@ -58,6 +61,13 @@ export function useContractLabels( const normalizedAddresses = addresses.map((a) => a.toLowerCase()) + // Evict stale entries when the cache grows too large. + // Clearing both lets currently-active addresses re-resolve on the next cycle. + if (resolvedAddressesRef.current.size > MAX_CACHED_LABELS) { + resolvedAddressesRef.current.clear() + setLabels(new Map()) + } + // Identify addresses that need resolution const tokenListResults: ContractLabelInfo[] = [] const addressesToFetch: string[] = []