hermes-agent/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx
Andrew Fiebert d221e369b8
fix(desktop): recover from transient assistant-ui index-lookup crash (#44493)
`@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
throws — rather than returning undefined — when a subscriber reads an index
the message/parts list no longer has. During high-frequency store replacement
(switching sessions mid-stream, gateway reconnect replay) a subscriber from
the previous, longer list is still in React's notification queue and reads one
slot past the new, shorter array before it can unmount. The throw
(`Index N out of bounds (length: N)`, the classic index === length off-by-one)
unwinds all the way to the root error boundary and blanks the entire window,
even though the store self-heals on the very next consistent snapshot.

Wrap each virtualized message group in a tiny boundary that swallows ONLY this
transient lookup race and auto-recovers when the message signature changes
(the existing list-mutation key). Any other error re-throws to the root
boundary, so genuine bugs still surface.

Upstream-tracked and unresolved: assistant-ui/assistant-ui#4051, #3652.

Co-authored-by: mollusk <mollusk@users.noreply.github.com>
2026-06-11 22:52:37 +00:00

48 lines
1.7 KiB
TypeScript

import { Component, type ReactNode } from 'react'
// `@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
// throws — rather than returning undefined — when a subscriber reads an index
// that the message/parts list no longer has. This races during high-frequency
// store replacement (session switch mid-stream, gateway reconnect replay): a
// subscriber from the previous, longer list is still in React's notification
// queue and reads one slot past the new, shorter array before it can unmount.
// The throw is transient and self-heals on the next consistent snapshot, but
// without a local boundary it unwinds to the root and blanks the whole app.
// Upstream-tracked: assistant-ui/assistant-ui#4051, #3652.
const isTransientLookupError = (error: unknown): boolean =>
error instanceof Error && /tapClient(Lookup|Resource).*out of bounds/.test(error.message)
interface Props {
// Changes whenever the message list mutates; remounting clears the caught
// error so the next consistent render recovers silently.
resetKey: string
children: ReactNode
}
export class MessageRenderBoundary extends Component<Props, { error: Error | null }> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidUpdate(prev: Props) {
if (this.state.error && prev.resetKey !== this.props.resetKey) {
this.setState({ error: null })
}
}
render() {
if (this.state.error) {
// Only swallow the transient store race; re-throw anything else so real
// bugs still reach the root error boundary.
if (!isTransientLookupError(this.state.error)) {
throw this.state.error
}
return null
}
return this.props.children
}
}