refactor: replace recursion with promise sequence in `useInvoiceable` (#752)
* refactor: replace recursion with promise sequence This commit refactors `useInvoicable`. The hard-to-follow recursion was replaced by awaiting promises which resolve or reject when one step of our JIT invoice flow is done. Therefore, `onSubmit` is now fully agnostic of JIT invoices. The handler only returns when payment + action was successful or canceled - just like when a custodial zap was successful. * refactor more and fix bugs * move invoice cancel logic into hook where invoice is also created * fix missing invoice cancellation if user closes modal or goes back. * refactor promise logic: it makes more sense to wrap the payment promise with the modal promise than the other way around. * Fix unhandled rejection * Fix unnecessary prop drilling * Fix modal not closed after successful action * Fix unnecessary async promise executor * Use function to set state
This commit is contained in:
parent
1f355140f3
commit
9ef0c81245
|
@ -784,7 +784,7 @@ export function Form ({
|
|||
// and use them as variables in their GraphQL mutation
|
||||
if (invoiceable && onSubmit) {
|
||||
const options = typeof invoiceable === 'object' ? invoiceable : undefined
|
||||
onSubmit = useInvoiceable(onSubmit, { callback: clearLocalStorage, ...options })
|
||||
onSubmit = useInvoiceable(onSubmit, options)
|
||||
}
|
||||
|
||||
const onSubmitInner = useCallback(async (values, ...args) => {
|
||||
|
@ -796,13 +796,14 @@ export function Form ({
|
|||
if (cost) {
|
||||
values.cost = cost
|
||||
}
|
||||
|
||||
const options = await onSubmit(values, ...args)
|
||||
if (!storageKeyPrefix || options?.keepLocalStorage) return
|
||||
await onSubmit(values, ...args)
|
||||
if (!storageKeyPrefix) return
|
||||
clearLocalStorage(values)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
const msg = err.message || err.toString?.()
|
||||
// handle errors from JIT invoices by ignoring them
|
||||
if (msg === 'modal closed' || msg === 'invoice canceled') return
|
||||
toaster.danger(err.message || err.toString?.())
|
||||
}
|
||||
}, [onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix])
|
||||
|
|
|
@ -9,7 +9,6 @@ import { INVOICE } from '../fragments/wallet'
|
|||
import InvoiceStatus from './invoice-status'
|
||||
import { useMe } from './me'
|
||||
import { useShowModal } from './modal'
|
||||
import { sleep } from '../lib/time'
|
||||
import Countdown from './countdown'
|
||||
import PayerData from './payer-data'
|
||||
import Bolt11Info from './bolt11-info'
|
||||
|
@ -95,18 +94,12 @@ export function Invoice ({ invoice, modal, onPayment, info, successVerb }) {
|
|||
)
|
||||
}
|
||||
|
||||
const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresAt, ...props }) => {
|
||||
const JITInvoice = ({ invoice: { id, hash, hmac, expiresAt }, onPayment, onCancel, onRetry }) => {
|
||||
const { data, loading, error } = useQuery(INVOICE, {
|
||||
pollInterval: 1000,
|
||||
variables: { id }
|
||||
})
|
||||
const [cancelInvoice] = useMutation(gql`
|
||||
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
||||
cancelInvoice(hash: $hash, hmac: $hmac) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`)
|
||||
const [retryError, setRetryError] = useState(0)
|
||||
if (error) {
|
||||
if (error.message?.includes('invoice not found')) {
|
||||
return
|
||||
|
@ -117,28 +110,37 @@ const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresA
|
|||
return <QrSkeleton description status='loading' />
|
||||
}
|
||||
|
||||
const retry = !!onRetry
|
||||
let errorStatus = 'Something went wrong trying to perform the action after payment.'
|
||||
if (errorCount > 1) {
|
||||
if (retryError > 0) {
|
||||
errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Invoice invoice={data.invoice} modal {...props} />
|
||||
{errorCount > 0
|
||||
<Invoice invoice={data.invoice} modal onPayment={onPayment} successVerb='received' />
|
||||
{retry
|
||||
? (
|
||||
<>
|
||||
<div className='my-3'>
|
||||
<InvoiceStatus variant='failed' status={errorStatus} />
|
||||
</div>
|
||||
<div className='d-flex flex-row mt-3 justify-content-center'>
|
||||
<Button className='mx-1' variant='info' onClick={repeat}>Retry</Button>
|
||||
<Button
|
||||
className='mx-1' variant='info' onClick={async () => {
|
||||
try {
|
||||
await onRetry()
|
||||
} catch (err) {
|
||||
console.error('retry error:', err)
|
||||
setRetryError(retryError => retryError + 1)
|
||||
}
|
||||
}}
|
||||
>Retry
|
||||
</Button>
|
||||
<Button
|
||||
className='mx-1'
|
||||
variant='danger' onClick={async () => {
|
||||
await cancelInvoice({ variables: { hash, hmac } })
|
||||
onClose()
|
||||
}}
|
||||
variant='danger'
|
||||
onClick={onCancel}
|
||||
>Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -150,17 +152,12 @@ const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresA
|
|||
}
|
||||
|
||||
const defaultOptions = {
|
||||
forceInvoice: false,
|
||||
requireSession: false,
|
||||
callback: null, // (formValues) => void
|
||||
replaceModal: false
|
||||
forceInvoice: false
|
||||
}
|
||||
// TODO: refactor this so it can be easily understood
|
||||
// there's lots of state cascading paired with logic
|
||||
// independent of the state, and it's hard to follow
|
||||
export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
||||
const me = useMe()
|
||||
const [createInvoice, { data }] = useMutation(gql`
|
||||
const [createInvoice] = useMutation(gql`
|
||||
mutation createInvoice($amount: Int!) {
|
||||
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) {
|
||||
id
|
||||
|
@ -169,92 +166,91 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|||
expiresAt
|
||||
}
|
||||
}`)
|
||||
const showModal = useShowModal()
|
||||
const [formValues, setFormValues] = useState()
|
||||
const [submitArgs, setSubmitArgs] = useState()
|
||||
|
||||
let errorCount = 0
|
||||
const onPayment = useCallback(
|
||||
(onClose, hmac) => {
|
||||
return async ({ id, satsReceived, expiresAt, hash }) => {
|
||||
await sleep(500)
|
||||
const repeat = () => {
|
||||
onClose()
|
||||
// call onSubmit handler and pass invoice data
|
||||
onSubmit({ satsReceived, hash, hmac, ...formValues }, ...submitArgs)
|
||||
.then(() => {
|
||||
options?.callback?.(formValues)
|
||||
})
|
||||
.catch((error) => {
|
||||
// if error happened after payment, show repeat and cancel options
|
||||
// by passing `errorCount` and `repeat`
|
||||
console.error(error)
|
||||
errorCount++
|
||||
showModal(onClose => (
|
||||
<MutationInvoice
|
||||
id={id}
|
||||
hash={hash}
|
||||
hmac={hmac}
|
||||
expiresAt={expiresAt}
|
||||
onClose={onClose}
|
||||
onPayment={onPayment(onClose, hmac)}
|
||||
successVerb='received'
|
||||
errorCount={errorCount}
|
||||
repeat={repeat}
|
||||
/>
|
||||
), { keepOpen: true })
|
||||
})
|
||||
}
|
||||
// prevents infinite loop of calling `onPayment`
|
||||
if (errorCount === 0) await repeat()
|
||||
const [cancelInvoice] = useMutation(gql`
|
||||
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
||||
cancelInvoice(hash: $hash, hmac: $hmac) {
|
||||
id
|
||||
}
|
||||
}, [onSubmit, submitArgs]
|
||||
)
|
||||
|
||||
const invoice = data?.createInvoice
|
||||
useEffect(() => {
|
||||
if (invoice) {
|
||||
showModal(onClose => (
|
||||
<MutationInvoice
|
||||
id={invoice.id}
|
||||
hash={invoice.hash}
|
||||
hmac={invoice.hmac}
|
||||
expiresAt={invoice.expiresAt}
|
||||
onClose={onClose}
|
||||
onPayment={onPayment(onClose, invoice.hmac)}
|
||||
successVerb='received'
|
||||
/>
|
||||
), { replaceModal: options.replaceModal, keepOpen: true }
|
||||
)
|
||||
}
|
||||
}, [invoice?.id])
|
||||
`)
|
||||
|
||||
const showModal = useShowModal()
|
||||
|
||||
// this function will be called before the Form's onSubmit handler is called
|
||||
// and the form must include `cost` or `amount` as a value
|
||||
const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => {
|
||||
// action only allowed if logged in
|
||||
// some actions require a session
|
||||
if (!me && options.requireSession) {
|
||||
throw new Error('you must be logged in')
|
||||
}
|
||||
|
||||
// if no cost is passed, just try the action first
|
||||
// educated guesses where action might pass in the invoice amount
|
||||
// (field 'cost' has highest precedence)
|
||||
cost ??= formValues.amount
|
||||
|
||||
// attempt action for the first time
|
||||
if (!cost || (me && !options.forceInvoice)) {
|
||||
try {
|
||||
return await onSubmit(formValues, ...submitArgs)
|
||||
} catch (error) {
|
||||
if (!payOrLoginError(error)) {
|
||||
if (!payOrLoginError(error) || !cost) {
|
||||
// can't handle error here - bail
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
setFormValues(formValues)
|
||||
setSubmitArgs(submitArgs)
|
||||
await createInvoice({ variables: { amount: cost } })
|
||||
// tell onSubmit handler that we want to keep local storage
|
||||
// even though the submit handler was "successful"
|
||||
return { keepLocalStorage: true }
|
||||
}, [onSubmit, setFormValues, setSubmitArgs, createInvoice, !!me])
|
||||
|
||||
// initial attempt of action failed. we will create an invoice, pay and retry now.
|
||||
const { data, error } = await createInvoice({ variables: { amount: cost } })
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
const inv = data.createInvoice
|
||||
|
||||
// wait until invoice is paid or modal is closed
|
||||
let modalClose
|
||||
await new Promise((resolve, reject) => {
|
||||
showModal(onClose => {
|
||||
modalClose = onClose
|
||||
return (
|
||||
<JITInvoice
|
||||
invoice={inv}
|
||||
onPayment={resolve}
|
||||
/>
|
||||
)
|
||||
}, { keepOpen: true, onClose: reject })
|
||||
})
|
||||
|
||||
const retry = () => onSubmit({ hash: inv.hash, hmac: inv.hmac, ...formValues }, ...submitArgs)
|
||||
// first retry
|
||||
try {
|
||||
const ret = await retry()
|
||||
modalClose()
|
||||
return ret
|
||||
} catch (error) {
|
||||
console.error('retry error:', error)
|
||||
}
|
||||
|
||||
// retry until success or cancel
|
||||
return await new Promise((resolve, reject) => {
|
||||
const cancelAndReject = async () => {
|
||||
await cancelInvoice({ variables: { hash: inv.hash, hmac: inv.hmac } })
|
||||
reject(new Error('invoice canceled'))
|
||||
}
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<JITInvoice
|
||||
invoice={inv}
|
||||
onCancel={async () => {
|
||||
await cancelAndReject()
|
||||
onClose()
|
||||
}}
|
||||
onRetry={async () => {
|
||||
resolve(await retry())
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}, { keepOpen: true, onClose: cancelAndReject })
|
||||
})
|
||||
}, [onSubmit, createInvoice, !!me])
|
||||
|
||||
return onSubmitWrapper
|
||||
}
|
||||
|
|
|
@ -268,7 +268,9 @@ export function useZap () {
|
|||
if (payOrLoginError(error)) {
|
||||
// call non-idempotent version
|
||||
const amount = sats - meSats
|
||||
showInvoiceModal({ amount }, { variables: { ...variables, sats: amount } })
|
||||
try {
|
||||
await showInvoiceModal({ amount }, { variables: { ...variables, sats: amount } })
|
||||
} catch (error) {}
|
||||
return
|
||||
}
|
||||
console.error(error)
|
||||
|
|
|
@ -33,6 +33,7 @@ export default function useModal () {
|
|||
}
|
||||
const previousModalContent = modalStack[modalStack.length - 1]
|
||||
setModalStack(modalStack.slice(0, -1))
|
||||
modalOptions?.onClose?.()
|
||||
return setModalContent(previousModalContent)
|
||||
}, [modalStack, setModalStack])
|
||||
|
||||
|
|
Loading…
Reference in New Issue