ekzyis 0193ac97fe Fix toast progress bar jump due to end flow hack
If an underlying toast finished, an empty toast that automatically immediately hides was dispatched to end the flow ("end flow hack").

If this empty toast had the same tag, the code marked the top toast as hidden even though it was not hidden.

This meant that during render, the animation-delay for the top toast (which was already rendered) was added again, leading to a progress bar jump.

This is fixed by no longer using this "end flow hack" where a toast is dispatched but a dedicated function to end flows.
2024-03-25 20:32:23 +01:00

274 lines
8.4 KiB
JavaScript

import { useRouter } from 'next/router'
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import Button from 'react-bootstrap/Button'
import Toast from 'react-bootstrap/Toast'
import ToastBody from 'react-bootstrap/ToastBody'
import ToastContainer from 'react-bootstrap/ToastContainer'
import styles from './toast.module.css'
const ToastContext = createContext(() => {})
export const TOAST_DEFAULT_DELAY_MS = 5000
const ensureFlow = (toasts, newToast) => {
const { flowId } = newToast
if (flowId) {
// replace previous toast with same flow id
const idx = toasts.findIndex(toast => toast.flowId === flowId)
if (idx === -1) return [...toasts, newToast]
return [
...toasts.slice(0, idx),
newToast,
...toasts.slice(idx + 1)
]
}
return [...toasts, newToast]
}
const mapHidden = ({ id, tag }) => toast => {
// mark every previous toast with same tag as hidden
if (toast.tag === tag && toast.id !== id) return { ...toast, hidden: true }
return toast
}
export const ToastProvider = ({ children }) => {
const router = useRouter()
const [toasts, setToasts] = useState([])
const toastId = useRef(0)
const dispatchToast = useCallback((toast) => {
toast = {
...toast,
createdAt: +new Date(),
id: toastId.current++
}
setToasts(toasts => ensureFlow(toasts, toast).map(mapHidden(toast)))
return () => removeToast(toast)
}, [])
const removeToast = useCallback(({ id, onCancel, tag }) => {
setToasts(toasts => toasts.filter(toast => {
if (toast.id === id) {
// remove the toast with the passed id with no exceptions
return false
}
const sameTag = tag && tag === toast.tag
if (!sameTag) {
// don't touch toasts with different tags
return true
}
const toRemoveHasCancel = !!toast.onCancel || !!toast.onUndo
if (toRemoveHasCancel) {
// don't remove this toast so the user can decide to cancel this toast now
return true
}
// remove toasts with same tag if they are not cancelable
return false
}))
}, [])
const endFlow = useCallback((flowId) => {
setToasts(toasts => toasts.filter(toast => toast.flowId !== flowId))
}, [])
const toaster = useMemo(() => ({
success: (body, options) => {
const toast = {
body,
variant: 'success',
autohide: true,
delay: TOAST_DEFAULT_DELAY_MS,
tag: options?.tag || body,
...options
}
return dispatchToast(toast)
},
warning: (body, options) => {
const toast = {
body,
variant: 'warning',
autohide: true,
delay: TOAST_DEFAULT_DELAY_MS,
tag: options?.tag || body,
...options
}
return dispatchToast(toast)
},
danger: (body, options) => {
const toast = {
body,
variant: 'danger',
autohide: false,
tag: options?.tag || body,
...options
}
return dispatchToast(toast)
},
endFlow
}), [dispatchToast, removeToast, endFlow])
// Only clear toasts with no cancel function on page navigation
// since navigation should not interfere with being able to cancel an action.
useEffect(() => {
const handleRouteChangeStart = () => setToasts(toasts => toasts.filter(({ onCancel, onUndo }) => onCancel || onUndo), [])
router.events.on('routeChangeStart', handleRouteChangeStart)
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart)
}
}, [router])
// this function merges toasts with the same tag into one toast.
// for example: 3x 'zap pending' -> '(3) zap pending'
const tagReducer = (toasts, toast) => {
const { tag } = toast
// has tag?
if (!tag) return [...toasts, toast]
// existing tag?
const idx = toasts.findIndex(toast => toast.tag === tag)
if (idx === -1) return [...toasts, toast]
// merge toasts with same tag
const prevToast = toasts[idx]
let { amount } = prevToast
amount = amount ? amount + 1 : 2
const body = `(${amount}) ${toast.body}`
return [
...toasts.slice(0, idx),
{ ...toast, amount, body },
...toasts.slice(idx + 1)
]
}
// only show toast with highest ID of each tag
const visibleToasts = toasts.reduce(tagReducer, [])
return (
<ToastContext.Provider value={toaster}>
<ToastContainer className={`pb-3 pe-3 ${styles.toastContainer}`} position='bottom-end' containerPosition='fixed'>
{visibleToasts.map(toast => {
const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
const onClose = () => {
toast.onUndo?.()
toast.onCancel?.()
toast.onClose?.()
removeToast(toast)
}
const buttonElement = toast.onUndo
? <div className={`${styles.toastUndo} ${textStyle}`}>undo</div>
: toast.onCancel
? <div className={`${styles.toastCancel} ${textStyle}`}>cancel</div>
: <div className={`${styles.toastClose} ${textStyle}`}>X</div>
// a toast is unhidden if it was hidden before since it now gets rendered
const unhidden = toast.hidden
// we only need to start the animation at a different timing when it was hidden by another toast before.
// if we don't do this, the animation for rerendered toasts skips ahead and toast delay and animation get out of sync.
const elapsed = (+new Date() - toast.createdAt)
const animationDelay = unhidden ? `-${elapsed}ms` : undefined
const animationDuration = `${toast.delay}ms`
return (
<Toast
key={toast.id} bg={toast.variant} show autohide={toast.autohide}
delay={toast.delay} className={`${styles.toast} ${styles[toast.variant]} ${textStyle}`} onClose={() => removeToast(toast)}
>
<ToastBody>
<div className='d-flex align-items-center'>
<div className='flex-grow-1'>{toast.body}</div>
<Button
variant={null}
className='p-0 ps-2'
aria-label='close'
onClick={onClose}
>{buttonElement}
</Button>
</div>
</ToastBody>
{toast.progressBar && <div className={`${styles.progressBar} ${styles[toast.variant]}`} style={{ animationDuration, animationDelay }} />}
</Toast>
)
})}
</ToastContainer>
{children}
</ToastContext.Provider>
)
}
export const useToast = () => useContext(ToastContext)
export const withToastFlow = (toaster) => flowFn => {
const wrapper = async (...args) => {
const {
flowId,
type: t,
onPending,
pendingMessage,
onSuccess,
onCancel,
onError,
onUndo,
hideError,
hideSuccess,
skipToastFlow,
timeout,
...toastProps
} = flowFn(...args)
let canceled
if (skipToastFlow) return onPending()
toaster.warning(pendingMessage || `${t} pending`, {
progressBar: !!timeout,
delay: timeout || TOAST_DEFAULT_DELAY_MS,
onCancel: onCancel
? async () => {
try {
await onCancel()
canceled = true
toaster.warning(`${t} canceled`, { ...toastProps, flowId })
} catch (err) {
toaster.danger(`failed to cancel ${t}`, { ...toastProps, flowId })
}
}
: undefined,
onUndo: onUndo
? async () => {
try {
await onUndo()
canceled = true
} catch (err) {
toaster.danger(`failed to undo ${t}`, { ...toastProps, flowId })
}
}
: undefined,
flowId,
...toastProps
})
try {
const ret = await onPending()
if (!canceled) {
if (hideSuccess) {
toaster.endFlow(flowId)
} else {
toaster.success(`${t} successful`, { ...toastProps, flowId })
}
await onSuccess?.()
}
return ret
} catch (err) {
// ignore errors if canceled since they might be caused by cancellation
if (canceled) return
const reason = err?.message?.toString().toLowerCase() || 'unknown reason'
if (hideError) {
toaster.endFlow(flowId)
} else {
toaster.danger(`${t} failed: ${reason}`, { ...toastProps, flowId })
}
await onError?.()
throw err
}
}
return wrapper
}