'use client' import React, { useEffect, useRef, useSyncExternalStore } from 'react' import { Modal, ModalContent, ModalTitle } from '../Modal' import { toast } from '../Toast' import { TransactionalModalTemplate } from './TransactionalModalTemplate' import type { TransactionStep, TransactionConfig, TransactionModal, TransactionModalState, } from './types' type Action = | { type: 'COMPLETE_TRANSACTION' successLinkHref?: string successLinkText?: string successLinkAction?: { label: string; onClick: VoidFunction } closeButtonText?: string closeButtonCallback?: VoidFunction } | { type: 'START_TRANSACTION' steps: TransactionStep[] successBody: string successHeading: string context: string } | { type: 'ERROR'; error: string } | { type: 'NEXT_STEP' transactions?: { type?: string; hash: string }[] pending?: boolean } type CompleteTransaction = ( state?: Pick< TransactionModalState, | 'successLinkHref' | 'successLinkText' | 'successLinkAction' | 'closeButtonText' | 'closeButtonCallback' > & { delay?: number }, ) => Promise type HandleTransactionError = (error: any) => Promise type NextStep = ( state?: Pick & { pending?: boolean }, ) => void type StartTransaction = (newTransaction: TransactionConfig) => void /** * Simple external store for modal state. * Using useSyncExternalStore allows us to: * 1. Keep the TransactionModalComponent callback identity stable (preventing blinking) * 2. Still properly trigger re-renders when state changes (fixing React 19 issues) */ type ModalStateStore = { state: TransactionModalState networkExplorerTxUrl?: string } type ModalStore = { getSnapshot: () => ModalStateStore subscribe: (listener: () => void) => () => void } const createModalStore = ( initialState: TransactionModalState, initialNetworkExplorerTxUrl?: string, ): ModalStore & { update: (state: TransactionModalState, url?: string) => void } => { let currentState: ModalStateStore = { state: initialState, networkExplorerTxUrl: initialNetworkExplorerTxUrl, } const listeners = new Set<() => void>() return { getSnapshot: () => currentState, subscribe: (listener: () => void) => { listeners.add(listener) return () => listeners.delete(listener) }, update: (state: TransactionModalState, networkExplorerTxUrl?: string) => { currentState = { state, networkExplorerTxUrl } listeners.forEach((listener) => listener()) }, } } export const initialState: TransactionModalState = { progressBarSteps: [], steps: [], error: undefined, isTransactionComplete: false, currentStepIndex: 0, nextStepIndex: 1, successBody: '', successHeading: '', successLinkHref: '', successLinkText: '', successLinkAction: undefined, closeButtonText: '', closeButtonCallback: () => undefined, context: '', } export const reducer = ( state: TransactionModalState, action: Action, ): TransactionModalState => { const stateCopy: TransactionModalState = JSON.parse(JSON.stringify(state)) switch (action.type) { case 'START_TRANSACTION': return { ...initialState, // Show initial transaction state when user re-opens the modal. progressBarSteps: action.steps.map((step, index) => ({ status: index === 0 ? 'ACTIVE' : 'PENDING', label: step.label, })), steps: action.steps, successBody: action.successBody, successHeading: action.successHeading, context: action.context, } case 'NEXT_STEP': stateCopy.progressBarSteps[stateCopy.currentStepIndex].status = 'DONE' stateCopy.progressBarSteps[stateCopy.nextStepIndex].status = action.transactions || action.pending ? 'IN_PROGRESS' : 'DONE' return { ...stateCopy, currentStepIndex: stateCopy.nextStepIndex, nextStepIndex: stateCopy.nextStepIndex + 1, transactions: action.transactions, } case 'COMPLETE_TRANSACTION': return { ...stateCopy, progressBarSteps: state.progressBarSteps.map((step) => ({ ...step, status: 'DONE', })), isTransactionComplete: true, successLinkHref: action.successLinkHref, successLinkText: action.successLinkText, successLinkAction: action.successLinkAction, closeButtonText: action.closeButtonText, closeButtonCallback: action.closeButtonCallback, } case 'ERROR': stateCopy.progressBarSteps[stateCopy.currentStepIndex].status = 'ERROR' return { ...stateCopy, error: action.error } } } type Props = { isOpen?: boolean closeModal: VoidFunction store: ModalStore setModalClosedBeforeCompletion: (value: boolean) => void isInescapable?: boolean setIsOpen: React.Dispatch> router: { push: (href: string) => void } } const TransactionModal = ({ isOpen = true, closeModal, store, setModalClosedBeforeCompletion, isInescapable = false, setIsOpen, router, }: Props) => { // Subscribe to state changes via useSyncExternalStore // This ensures proper re-renders while keeping component identity stable const { state, networkExplorerTxUrl } = useSyncExternalStore( store.subscribe, store.getSnapshot, store.getSnapshot, // Server snapshot (same as client for this use case) ) const contextId = state.context const stepId = state.steps[state.currentStepIndex]?.id ?? state.currentStepIndex.toString() const onCloseClick = React.useCallback(() => { if (!state.isTransactionComplete) { setModalClosedBeforeCompletion(true) } state.closeButtonCallback?.() closeModal() }, [ state.isTransactionComplete, setModalClosedBeforeCompletion, state.closeButtonCallback, closeModal, ]) const onSuccessClick = React.useCallback( () => router.push(`${state.successLinkHref || '/'}`), [router, state.successLinkHref], ) const onOpenChange = React.useCallback( (open: boolean) => { if (isInescapable && !state.isTransactionComplete) { return } if (!open) { setModalClosedBeforeCompletion(true) } return setIsOpen(open) }, [ isInescapable, state.isTransactionComplete, setModalClosedBeforeCompletion, setIsOpen, ], ) return ( {/* Hidden title keeps DialogContent accessible for screen readers: `DialogContent` requires a `DialogTitle` for the component to be accessible for screen reader users. */} Transaction modal ) } /** * This modal makes an assumption that there are always at least 2 * steps in the transaction. */ export function useTransactionalModalBootstrap({ router, networkExplorerTxUrl, }: { networkExplorerTxUrl?: string router: { push: (href: string) => void } }) { const [state, dispatch] = React.useReducer(reducer, initialState) const [isOpen, setIsOpen] = React.useState(false) const openModal = React.useCallback(() => setIsOpen(true), [setIsOpen]) const closeModal = React.useCallback(() => setIsOpen(false), [setIsOpen]) const [modalClosedBeforeCompletion, setModalClosedBeforeCompletion] = React.useState(false) const startTransaction: StartTransaction = (newTransaction) => { dispatch({ type: 'START_TRANSACTION', ...newTransaction }) openModal() } const setErrorOnCurrentStep = (error: string) => { dispatch({ type: 'ERROR', error }) } /** * This handles the error that is thrown by the transaction. * * Since the type of error is any we have to commit some typescript crimes to differentiate between Wagmi and Ethers errors. */ const handleTransactionError: HandleTransactionError = (error) => { console.error(error) // both Viem and Ethers return a "message" key in the error object const ethersErrorMessage = 'message' in error ? error.message : undefined const ethersErrorCode = 'code' in error ? error.code : undefined // check if the error is a Wagmi error otherwise fall back to ethers error const viemErrorCode = 'cause' in error && error.cause ? error.cause.code : undefined const viemErrorMessage = 'shortMessage' in error ? error.shortMessage : undefined // Give preference to the viem message since the ethers error is defined in both error objects let errorMessage = viemErrorMessage || ethersErrorMessage || 'Something went wrong.' const errorCode = viemErrorCode || ethersErrorCode switch (errorCode) { case -32603: errorMessage = 'details' in error ? `${errorMessage}.. ${error.details}` : errorMessage break case 'ACTION_REJECTED': case 4001: errorMessage = 'Transaction was denied by the user.' break case 'UNPREDICTABLE_GAS_LIMIT': errorMessage = 'Transaction was not submitted as gas estimation failed - it may revert.' break } setErrorOnCurrentStep(errorMessage) return Promise.reject(error) } const nextStep: NextStep = React.useCallback( (state = {}) => { dispatch({ type: 'NEXT_STEP', ...state }) }, [dispatch], ) const completeTransaction: CompleteTransaction = async ({ delay = 12000, ...state } = {}) => { /** * We have to hard wait here to account for Atlas block ingestion delay * If we don't do this, the user's on-chain actions might not be reflected in the UI. * * TODO: Remove this wait and instead wait for n blocks where n is network dependent https://smartcontract-it.atlassian.net/browse/FRONT-3581 */ await new Promise((t) => setTimeout(t, delay)) dispatch({ type: 'COMPLETE_TRANSACTION', ...state }) } useEffect(() => { if ( modalClosedBeforeCompletion && (state.error || state.isTransactionComplete) ) { toast({ toastId: `${state.context}-modal-toast-tx-complete`, title: state.error ? 'Error' : state.successHeading, variant: state.error ? 'error' : 'success', description: state.error ? state.error : state.successBody, action: state.error ? undefined : state.successLinkAction, }) setModalClosedBeforeCompletion(false) } }, [ modalClosedBeforeCompletion, state.isTransactionComplete, state.context, state.error, state.successHeading, setModalClosedBeforeCompletion, ]) // Create a stable store that persists across renders. // The modal subscribes to this store via useSyncExternalStore, which: // 1. Keeps the TransactionModalComponent callback identity stable (prevents blinking) // 2. Properly triggers re-renders when state changes (fixes React 19 compatibility) const storeRef = useRef | null>(null) if (!storeRef.current) { storeRef.current = createModalStore(state, networkExplorerTxUrl) } // Update the store whenever state or networkExplorerTxUrl changes useEffect(() => { storeRef.current?.update(state, networkExplorerTxUrl) }, [state, networkExplorerTxUrl]) const TransactionModalComponent = React.useCallback( ({ isInescapable }: { isInescapable?: boolean }) => ( ), [isOpen, setIsOpen, closeModal, setModalClosedBeforeCompletion, router], ) return { ...state, completeTransaction, nextStep, handleTransactionError, startTransaction, TransactionModal: TransactionModalComponent, setIsOpen, isOpen, } }