mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-29 11:42:04 +00:00
`@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>
48 lines
1.7 KiB
TypeScript
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
|
|
}
|
|
}
|