`@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>
80 lines
2.1 KiB
TypeScript
80 lines
2.1 KiB
TypeScript
import { cleanup, render, screen } from '@testing-library/react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { MessageRenderBoundary } from './message-render-boundary'
|
|
|
|
afterEach(cleanup)
|
|
|
|
function Boom({ error }: { error: Error | null }): null {
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const lookupError = new Error('tapClientLookup: Index 2 out of bounds (length: 2)')
|
|
|
|
describe('MessageRenderBoundary', () => {
|
|
it('renders children when nothing throws', () => {
|
|
render(
|
|
<MessageRenderBoundary resetKey="a">
|
|
<div>content</div>
|
|
</MessageRenderBoundary>
|
|
)
|
|
|
|
expect(screen.getByText('content')).toBeTruthy()
|
|
})
|
|
|
|
it('swallows the transient tapClientLookup out-of-bounds store race', () => {
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
|
|
const { container } = render(
|
|
<MessageRenderBoundary resetKey="a">
|
|
<Boom error={lookupError} />
|
|
</MessageRenderBoundary>
|
|
)
|
|
|
|
expect(container.innerHTML).toBe('')
|
|
spy.mockRestore()
|
|
})
|
|
|
|
it('recovers on the next consistent snapshot when resetKey changes', () => {
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
|
|
const { rerender } = render(
|
|
<MessageRenderBoundary resetKey="a">
|
|
<Boom error={lookupError} />
|
|
</MessageRenderBoundary>
|
|
)
|
|
|
|
rerender(
|
|
<MessageRenderBoundary resetKey="b">
|
|
<Boom error={null} />
|
|
</MessageRenderBoundary>
|
|
)
|
|
|
|
rerender(
|
|
<MessageRenderBoundary resetKey="b">
|
|
<div>recovered</div>
|
|
</MessageRenderBoundary>
|
|
)
|
|
|
|
expect(screen.getByText('recovered')).toBeTruthy()
|
|
spy.mockRestore()
|
|
})
|
|
|
|
it('re-throws unrelated errors so real bugs still surface', () => {
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
|
|
expect(() =>
|
|
render(
|
|
<MessageRenderBoundary resetKey="a">
|
|
<Boom error={new Error('genuine render bug')} />
|
|
</MessageRenderBoundary>
|
|
)
|
|
).toThrow('genuine render bug')
|
|
|
|
spy.mockRestore()
|
|
})
|
|
})
|