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
|
// 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])
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue