Defer AppView wait behind a pending toast on post

Step 5 of sending a post (waiting for the AppView to index the new record)
can take an unbounded amount of time, but the post is already created after
step 4. Close the composer immediately after the record is written and show
a pending toast while the AppView wait runs in the background. When it
settles, swap the pending toast in place for the existing success toast.

Adds a 'pending' ToastType that renders a spinner via the existing Loader,
and a new Toast.promise() helper that drives the in-place swap on both
sonner and sonner-native.
This commit is contained in:
Claude
2026-05-17 09:38:18 +00:00
parent 45597de6e0
commit f03bf2a65f
7 changed files with 243 additions and 103 deletions
-5
View File
@@ -539,11 +539,6 @@
"count": 8
}
},
"src/view/com/composer/Composer.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/composer/drafts/state/queries.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
+12
View File
@@ -12,6 +12,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/ico
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import {dismiss} from '#/components/Toast/sonner'
import {type ToastType} from '#/components/Toast/types'
import {Text as BaseText} from '#/components/Typography'
@@ -22,6 +23,7 @@ export const ICONS = {
error: ErrorIcon,
warning: WarningIcon,
info: CircleInfo,
pending: CircleCheck,
}
const ToastConfigContext = createContext<{
@@ -79,6 +81,9 @@ export function Outer({children}: {children: React.ReactNode}) {
export function Icon({icon}: {icon?: React.ComponentType<SVGIconProps>}) {
const {type} = useContext(ToastConfigContext)
const styles = useToastStyles({type})
if (!icon && type === 'pending') {
return <Loader size="md" fill={styles.iconColor} />
}
const IconComponent = icon || ICONS[type]
return <IconComponent size="md" fill={styles.iconColor} />
}
@@ -173,6 +178,7 @@ export function Action(
},
warning: base,
info: base,
pending: base,
}[type]
}, [t, type])
@@ -304,6 +310,12 @@ function useToastStyles({type}: {type: ToastType}) {
iconColor: t.atoms.text.color,
textColor: t.atoms.text.color,
},
pending: {
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
borderColor: t.atoms.border_contrast_low.borderColor,
iconColor: t.atoms.text.color,
textColor: t.atoms.text.color,
},
}[type]
}, [t, type])
}
+54
View File
@@ -78,3 +78,57 @@ export function show(
)
}
}
type PromiseToastOptions<T> = Omit<BaseToastOptions, 'type'> & {
loading: React.ReactNode
success: React.ReactNode | ((data: T) => React.ReactNode)
error?: React.ReactNode | ((err: unknown) => React.ReactNode)
}
/**
* Show a toast tied to a promise. While the promise is pending, the toast
* displays the `loading` content with a spinner. When the promise settles, the
* same toast is swapped in place with the `success` or `error` content.
*/
export function promise<T>(
input: Promise<T>,
{loading, success, error, ...options}: PromiseToastOptions<T>,
): Promise<T> {
const id = nanoid()
const render = (
content: React.ReactNode,
type: 'pending' | 'success' | 'error',
) => {
sonner.custom(
<ToastConfigProvider id={id} type={type}>
{content}
</ToastConfigProvider>,
{
...options,
id,
duration: type === 'pending' ? Infinity : (options?.duration ?? DURATION),
dismissible: type === 'pending' ? false : options?.dismissible,
},
)
}
render(loading, 'pending')
return input.then(
data => {
const content = typeof success === 'function' ? success(data) : success
render(content, 'success')
return data
},
err => {
if (error !== undefined) {
const content = typeof error === 'function' ? error(err) : error
render(content, 'error')
} else {
sonner.dismiss(id)
}
throw err
},
)
}
+55
View File
@@ -78,3 +78,58 @@ export function show(
)
}
}
type PromiseToastOptions<T> = Omit<BaseToastOptions, 'type'> & {
loading: React.ReactNode
success: React.ReactNode | ((data: T) => React.ReactNode)
error?: React.ReactNode | ((err: unknown) => React.ReactNode)
}
/**
* Show a toast tied to a promise. While the promise is pending, the toast
* displays the `loading` content with a spinner. When the promise settles, the
* same toast is swapped in place with the `success` or `error` content.
*/
export function promise<T>(
input: Promise<T>,
{loading, success, error, ...options}: PromiseToastOptions<T>,
): Promise<T> {
const id = nanoid()
const render = (
content: React.ReactNode,
type: 'pending' | 'success' | 'error',
) => {
sonner(
<ToastConfigProvider id={id} type={type}>
{content}
</ToastConfigProvider>,
{
...options,
unstyled: true, // required on web
id,
duration: type === 'pending' ? Infinity : (options?.duration ?? DURATION),
dismissible: type === 'pending' ? false : options?.dismissible,
},
)
}
render(loading, 'pending')
return input.then(
data => {
const content = typeof success === 'function' ? success(data) : success
render(content, 'success')
return data
},
err => {
if (error !== undefined) {
const content = typeof error === 'function' ? error(err) : error
render(content, 'error')
} else {
sonner.dismiss(id)
}
throw err
},
)
}
+7 -1
View File
@@ -8,7 +8,13 @@ export type ExternalToast = Exclude<
undefined
>
export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info'
export type ToastType =
| 'default'
| 'success'
| 'error'
| 'warning'
| 'info'
| 'pending'
/**
* Not all properties are available on all platforms, so we pick out only those
+114 -97
View File
@@ -873,7 +873,6 @@ export const ComposePost = ({
setIsPublishing(true)
let postUri: string | undefined
let postSuccessData: OnPostSuccessData
try {
logger.info(`composer: posting...`)
postUri = (
@@ -896,15 +895,93 @@ export const ComposePost = ({
},
)
).uris[0]
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
logger.error(error, {
message: `Composer: create post failed`,
hasImages: filteredThread.posts.some(
p => p.embed.media?.type === 'images',
),
})
/*
* Wait for app view to have received the post(s). If this fails, it's
* ok, because the post _was_ actually published above.
*/
let err = cleanError(error.message)
if (
e instanceof apilib.ReplyDeletedError ||
err.includes('not locate record')
) {
err = l`We're sorry! The post you are replying to has been deleted.`
} else if (e instanceof EmbeddingDisabledError) {
err = l`This post's author has disabled quote posts.`
}
setError(err)
setIsPublishing(false)
return
}
// Stage 4 succeeded. Everything from here on runs against a post that
// already exists - fire metrics, clean up local state, close the composer,
// and let the AppView-ready wait happen in the background under a toast.
if (postUri) {
let index = 0
for (let post of filteredThread.posts) {
ax.metric('post:create', {
imageCount:
post.embed.media?.type === 'images'
? post.embed.media.images.length
: 0,
isReply: index > 0 || !!replyTo,
isPartOfThread: filteredThread.posts.length > 1,
hasLink: !!post.embed.link,
hasQuote: !!post.embed.quote,
langs: fromPostLanguages(currentLanguages),
logContext: 'Composer',
})
index++
}
}
if (filteredThread.posts.length > 1) {
ax.metric('thread:create', {
postCount: filteredThread.posts.length,
isReply: !!replyTo,
})
}
if (postUri && !replyTo) {
emitPostCreated()
}
// Clean up draft and its media after successful publish
if (composerState.draftId && composerState.originalLocalRefs) {
// Fire draft:post metric
if (loadedDraftCreatedAt) {
const draftAgeMs = Date.now() - new Date(loadedDraftCreatedAt).getTime()
ax.metric('draft:post', {
draftAgeMs,
wasEdited: composerState.isDirty,
})
}
logger.debug('post published, cleaning up draft', {
draftId: composerState.draftId,
mediaFileCount: composerState.originalLocalRefs.size,
})
cleanupPublishedDraft({
draftId: composerState.draftId,
originalLocalRefs: composerState.originalLocalRefs,
})
}
setLangPrefs.savePostLanguageToHistory()
onClose()
/*
* Wait for the AppView to have received the post(s) in the background.
* If this fails, it's ok - the post _was_ actually published above. We
* still want onPost/onPostSuccess to fire once the AppView is ready so
* downstream query invalidation reads back the new post.
*/
const appViewReady = (async () => {
let postSuccessData: OnPostSuccessData
try {
if (postUri) {
logger.info(`composer: waiting for app view`)
const posts = await retry(
5,
_e => true,
@@ -934,102 +1011,43 @@ export const ComposePost = ({
posts,
}
}
} catch (waitErr: any) {
} catch (waitErr) {
logger.info(`composer: waiting for app view failed`, {
safeMessage: waitErr,
})
}
} catch (e: any) {
logger.error(e, {
message: `Composer: create post failed`,
hasImages: filteredThread.posts.some(
p => p.embed.media?.type === 'images',
),
})
let err = cleanError(e.message)
if (
e instanceof apilib.ReplyDeletedError ||
err.includes('not locate record')
) {
err = l`We're sorry! The post you are replying to has been deleted.`
} else if (e instanceof EmbeddingDisabledError) {
err = l`This post's author has disabled quote posts.`
}
setError(err)
setIsPublishing(false)
return
} finally {
if (postUri) {
let index = 0
for (let post of filteredThread.posts) {
ax.metric('post:create', {
imageCount:
post.embed.media?.type === 'images'
? post.embed.media.images.length
: 0,
isReply: index > 0 || !!replyTo,
isPartOfThread: filteredThread.posts.length > 1,
hasLink: !!post.embed.link,
hasQuote: !!post.embed.quote,
langs: fromPostLanguages(currentLanguages),
logContext: 'Composer',
if (initQuote) {
// Wait for the quote count to update before triggering refetches.
try {
await whenAppViewReady(agent, initQuote.uri, res => {
const anchor = res.data.thread.at(0)
return (
AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) &&
anchor.value.post.quoteCount !== initQuote.quoteCount
)
})
index++
} catch (e) {
logger.info(`composer: quote count wait failed`, {safeMessage: e})
}
}
if (filteredThread.posts.length > 1) {
ax.metric('thread:create', {
postCount: filteredThread.posts.length,
isReply: !!replyTo,
})
}
}
if (postUri && !replyTo) {
emitPostCreated()
}
// Clean up draft and its media after successful publish
if (composerState.draftId && composerState.originalLocalRefs) {
// Fire draft:post metric
if (loadedDraftCreatedAt) {
const draftAgeMs = Date.now() - new Date(loadedDraftCreatedAt).getTime()
ax.metric('draft:post', {
draftAgeMs,
wasEdited: composerState.isDirty,
})
}
logger.debug('post published, cleaning up draft', {
draftId: composerState.draftId,
mediaFileCount: composerState.originalLocalRefs.size,
})
cleanupPublishedDraft({
draftId: composerState.draftId,
originalLocalRefs: composerState.originalLocalRefs,
})
}
setLangPrefs.savePostLanguageToHistory()
if (initQuote) {
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
whenAppViewReady(agent, initQuote.uri, res => {
const anchor = res.data.thread.at(0)
if (
AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) &&
anchor.value.post.quoteCount !== initQuote.quoteCount
) {
onPost?.(postUri)
onPostSuccess?.(postSuccessData)
return true
}
return false
})
} else {
onPost?.(postUri)
onPostSuccess?.(postSuccessData)
}
onClose()
setTimeout(() => {
Toast.show(
})()
Toast.promise(appViewReady, {
loading: (
<Toast.Outer>
<Toast.Icon />
<Toast.Text>
{filteredThread.posts.length > 1
? l`Sending posts…`
: replyTo
? l`Sending reply…`
: l`Sending post…`}
</Toast.Text>
</Toast.Outer>
),
success: () => (
<Toast.Outer>
<Toast.Icon />
<Toast.Text>
@@ -1051,10 +1069,9 @@ export const ComposePost = ({
</Trans>
</Toast.Action>
)}
</Toast.Outer>,
{type: 'success'},
)
}, 500)
</Toast.Outer>
),
})
}, [
l,
ax,
+1
View File
@@ -21,6 +21,7 @@ export const convertLegacyToastType = (
case 'error':
case 'warning':
case 'info':
case 'pending':
return type
// legacy ones need conversion
case 'xmark':