Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/components/connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function subscribeUpdates(
renderIsScheduled: React.MutableRefObject<boolean>,
isMounted: React.MutableRefObject<boolean>,
childPropsFromStoreUpdate: React.MutableRefObject<unknown>,
latestSubscriptionCallbackError: React.MutableRefObject<Error | undefined>,
notifyNestedSubs: () => void,
// forceComponentUpdateDispatch: React.Dispatch<any>,
additionalSubscribeListener: () => void,
Expand Down Expand Up @@ -133,6 +134,7 @@ function subscribeUpdates(
} catch (e) {
error = e
lastThrownError = e as Error | null
latestSubscriptionCallbackError.current = e as Error
}

if (!error) {
Expand Down Expand Up @@ -667,6 +669,10 @@ function _connect<

const actualChildPropsSelector = React.useMemo(() => {
const selector = () => {
if (latestSubscriptionCallbackError.current) {
throw latestSubscriptionCallbackError.current
}

// Tricky logic here:
// - This render may have been triggered by a Redux store update that produced new child props
// - However, we may have gotten new wrapper props after that
Expand Down Expand Up @@ -710,6 +716,7 @@ function _connect<
renderIsScheduled,
isMounted,
childPropsFromStoreUpdate,
latestSubscriptionCallbackError,
notifyNestedSubs,
reactListener,
)
Expand Down
75 changes: 75 additions & 0 deletions test/components/connect.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,81 @@ describe('React', () => {
})
})

it('should surface mapStateToProps errors to an error boundary', async () => {
type RootStateType = {
foo?: string[]
}
interface ActionType {
type: 'CLEAR' | 'INIT'
}

class ErrorBoundary extends React.Component<
{ children: ReactNode },
{ error: Error | null }
> {
state = { error: null }

static getDerivedStateFromError(error: Error) {
return { error }
}

render() {
if (this.state.error) {
return <div>error boundary</div>
}

return this.props.children
}
}

const store = createStore(
(state: RootStateType = { foo: ['ok'] }, action: ActionType) => {
if (action.type === 'CLEAR') {
return { foo: undefined }
}

return state
},
)

const mapStateToProps = vi.fn((state: RootStateType) => ({
count: state.foo!.length,
}))

class Child extends React.Component<{ count: number }> {
render() {
return <div>{this.props.count}</div>
}
}

const ConnectedChild = connect<{ count: number }, {}, {}, RootStateType>(
mapStateToProps,
)(Child)

const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})

rtl.render(
<ProviderMock store={store}>
<ErrorBoundary>
<ConnectedChild />
</ErrorBoundary>
</ProviderMock>,
)

expect(await rtl.screen.findByText('1')).toBeVisible()

rtl.act(() => {
store.dispatch({ type: 'CLEAR' })
})

expect(await rtl.screen.findByText('error boundary')).toBeVisible()
expect(mapStateToProps).toHaveBeenCalled()

consoleErrorSpy.mockRestore()
})

it('should notify nested components through a blocking component', () => {
type RootStateType = number
interface ActionType {
Expand Down
Loading