survive session-bundle rebuild in chat, feeds, prefs, and age assurance

Review fixes (PR #11182 round 2). Client identity is no longer stable
across token refresh - on web, a cross-tab sync rebuilds the bundle and
disposes the old clients (whose fetch now throws) - so anything that
captured a client at construction time must re-point at the live one:

- MessagesEventBus and Convo get updateClient(); providers sync it via
  effect. No remount, so poll cursors and optimistic pendingMessages
  survive.
- FeedAPI implementations get setClient(); the post-feed queryFn and
  pollLatest re-point page-held apis before fetching (Merge/Home apis
  are stateful across pages, so they cannot be rebuilt per fetch).
- usePreferencesQuery applies fetched labeler dids to the live appview
  client (applyLabelersToClient, factored from session/moderation),
  restoring the old BskyAgent.getPreferences header side effect.
- ageAssurance redirect overlay/dialog polling is mount-only with
  render-synced refs; a client swap mid-poll no longer latches the
  unmounted flag and strands the overlay.
- Convo message-failure classification defers to lex's shouldRetry()
  instead of treating all status-less errors as recoverable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-17 15:31:22 +03:00
parent 4c2771eca3
commit 2696104e6d
21 changed files with 201 additions and 60 deletions
+27 -14
View File
@@ -178,18 +178,28 @@ function Inner() {
const {_} = useLingui()
const {hasSession} = useSession()
const appviewClient = useAppviewClient()
const polling = useRef(false)
const unmounted = useRef(false)
/*
* The poll effect is mount-only so a session-bundle rebuild (web cross-tab
* token sync, which swaps the appview client identity) does not restart the
* flow or permanently latch it. Read the volatile values through refs kept
* fresh each render so the next retry attempt picks up the current client.
*/
const clientRef = useRef(appviewClient)
clientRef.current = appviewClient
const hasSessionRef = useRef(hasSession)
hasSessionRef.current = hasSession
const openMetricFired = useRef(false)
const [error, setError] = useState(false)
const [success, setSuccess] = useState(false)
const {close} = useRedirectOverlayContext()
useEffect(() => {
if (polling.current) return
let cancelled = false
polling.current = true
ax.metric('ageAssurance:redirectDialogOpen', {})
if (!openMetricFired.current) {
openMetricFired.current = true
ax.metric('ageAssurance:redirectDialogOpen', {})
}
wait(
3e3,
@@ -197,10 +207,12 @@ function Inner() {
5,
() => true,
async () => {
if (!hasSession) return
if (unmounted.current) return
if (!hasSessionRef.current) return
if (cancelled) return
const data = await refetchAgeAssuranceServerState({appviewClient})
const data = await refetchAgeAssuranceServerState({
appviewClient: clientRef.current,
})
if (data?.state.status !== 'assured') {
throw new Error(
@@ -215,23 +227,24 @@ function Inner() {
)
.then(async data => {
if (!data) return
if (!hasSession) return
if (unmounted.current) return
if (!hasSessionRef.current) return
if (cancelled) return
setSuccess(true)
ax.metric('ageAssurance:redirectDialogSuccess', {})
})
.catch(() => {
if (unmounted.current) return
if (cancelled) return
setError(true)
ax.metric('ageAssurance:redirectDialogFail', {})
})
return () => {
unmounted.current = true
cancelled = true
}
}, [ax, hasSession, appviewClient])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (success) {
return (