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:
ekzyis 2024-01-17 01:40:11 +01:00 committed by GitHub
parent 1f355140f3
commit 9ef0c81245
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 99 additions and 99 deletions

View File

@ -784,7 +784,7 @@ export function Form ({
// and use them as variables in their GraphQL mutation // and use them as variables in their GraphQL mutation
if (invoiceable && onSubmit) { if (invoiceable && onSubmit) {
const options = typeof invoiceable === 'object' ? invoiceable : undefined const options = typeof invoiceable === 'object' ? invoiceable : undefined
onSubmit = useInvoiceable(onSubmit, { callback: clearLocalStorage, ...options }) onSubmit = useInvoiceable(onSubmit, options)
} }
const onSubmitInner = useCallback(async (values, ...args) => { const onSubmitInner = useCallback(async (values, ...args) => {
@ -796,13 +796,14 @@ export function Form ({
if (cost) { if (cost) {
values.cost = cost values.cost = cost
} }
await onSubmit(values, ...args)
const options = await onSubmit(values, ...args) if (!storageKeyPrefix) return
if (!storageKeyPrefix || options?.keepLocalStorage) return
clearLocalStorage(values) clearLocalStorage(values)
} }
} catch (err) { } 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?.()) toaster.danger(err.message || err.toString?.())
} }
}, [onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix]) }, [onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix])

View File

@ -9,7 +9,6 @@ import { INVOICE } from '../fragments/wallet'
import InvoiceStatus from './invoice-status' import InvoiceStatus from './invoice-status'
import { useMe } from './me' import { useMe } from './me'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { sleep } from '../lib/time'
import Countdown from './countdown' import Countdown from './countdown'
import PayerData from './payer-data' import PayerData from './payer-data'
import Bolt11Info from './bolt11-info' 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, { const { data, loading, error } = useQuery(INVOICE, {
pollInterval: 1000, pollInterval: 1000,
variables: { id } variables: { id }
}) })
const [cancelInvoice] = useMutation(gql` const [retryError, setRetryError] = useState(0)
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
id
}
}
`)
if (error) { if (error) {
if (error.message?.includes('invoice not found')) { if (error.message?.includes('invoice not found')) {
return return
@ -117,28 +110,37 @@ const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresA
return <QrSkeleton description status='loading' /> return <QrSkeleton description status='loading' />
} }
const retry = !!onRetry
let errorStatus = 'Something went wrong trying to perform the action after payment.' 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.' errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.'
} }
return ( return (
<> <>
<Invoice invoice={data.invoice} modal {...props} /> <Invoice invoice={data.invoice} modal onPayment={onPayment} successVerb='received' />
{errorCount > 0 {retry
? ( ? (
<> <>
<div className='my-3'> <div className='my-3'>
<InvoiceStatus variant='failed' status={errorStatus} /> <InvoiceStatus variant='failed' status={errorStatus} />
</div> </div>
<div className='d-flex flex-row mt-3 justify-content-center'> <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 <Button
className='mx-1' className='mx-1'
variant='danger' onClick={async () => { variant='danger'
await cancelInvoice({ variables: { hash, hmac } }) onClick={onCancel}
onClose()
}}
>Cancel >Cancel
</Button> </Button>
</div> </div>
@ -150,17 +152,12 @@ const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresA
} }
const defaultOptions = { const defaultOptions = {
forceInvoice: false,
requireSession: false, requireSession: false,
callback: null, // (formValues) => void forceInvoice: false
replaceModal: 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) => { export const useInvoiceable = (onSubmit, options = defaultOptions) => {
const me = useMe() const me = useMe()
const [createInvoice, { data }] = useMutation(gql` const [createInvoice] = useMutation(gql`
mutation createInvoice($amount: Int!) { mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) { createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) {
id id
@ -169,92 +166,91 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
expiresAt expiresAt
} }
}`) }`)
const [cancelInvoice] = useMutation(gql`
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
id
}
}
`)
const showModal = useShowModal() 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()
}
}, [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])
// 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) => { const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => {
// action only allowed if logged in // some actions require a session
if (!me && options.requireSession) { if (!me && options.requireSession) {
throw new Error('you must be logged in') 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 cost ??= formValues.amount
// attempt action for the first time
if (!cost || (me && !options.forceInvoice)) { if (!cost || (me && !options.forceInvoice)) {
try { try {
return await onSubmit(formValues, ...submitArgs) return await onSubmit(formValues, ...submitArgs)
} catch (error) { } catch (error) {
if (!payOrLoginError(error)) { if (!payOrLoginError(error) || !cost) {
// can't handle error here - bail
throw error throw error
} }
} }
} }
setFormValues(formValues)
setSubmitArgs(submitArgs) // initial attempt of action failed. we will create an invoice, pay and retry now.
await createInvoice({ variables: { amount: cost } }) const { data, error } = await createInvoice({ variables: { amount: cost } })
// tell onSubmit handler that we want to keep local storage if (error) {
// even though the submit handler was "successful" throw error
return { keepLocalStorage: true } }
}, [onSubmit, setFormValues, setSubmitArgs, createInvoice, !!me]) 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 return onSubmitWrapper
} }

View File

@ -268,7 +268,9 @@ export function useZap () {
if (payOrLoginError(error)) { if (payOrLoginError(error)) {
// call non-idempotent version // call non-idempotent version
const amount = sats - meSats const amount = sats - meSats
showInvoiceModal({ amount }, { variables: { ...variables, sats: amount } }) try {
await showInvoiceModal({ amount }, { variables: { ...variables, sats: amount } })
} catch (error) {}
return return
} }
console.error(error) console.error(error)

View File

@ -33,6 +33,7 @@ export default function useModal () {
} }
const previousModalContent = modalStack[modalStack.length - 1] const previousModalContent = modalStack[modalStack.length - 1]
setModalStack(modalStack.slice(0, -1)) setModalStack(modalStack.slice(0, -1))
modalOptions?.onClose?.()
return setModalContent(previousModalContent) return setModalContent(previousModalContent)
}, [modalStack, setModalStack]) }, [modalStack, setModalStack])