Skip to content
Merged
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
52 changes: 4 additions & 48 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,14 @@ jobs:

needs: [build]
runs-on: ubuntu-latest
# `next` is a nightly build. A failure there flags an upcoming TypeScript
# change without failing the workflow.
continue-on-error: ${{ matrix.ts == 'next' }}
strategy:
fail-fast: false
matrix:
node: ['24.x']
ts: ['5.6', '5.7', '5.8', '5.9', '6.0']
ts: ['5.6', '5.7', '5.8', '5.9', '6.0', '7.0', 'next']
react:
[
{
Expand Down Expand Up @@ -129,53 +132,6 @@ jobs:
pnpm exec tsc --version
pnpm type-tests

test-types-typescript-7:
name: 'Test Types: TypeScript 7 (advisory)'

runs-on: ubuntu-latest
# Advisory only. TypeScript 7 is not a supported target yet, so this job
# reports differences without blocking the workflow.
continue-on-error: true
strategy:
fail-fast: false
matrix:
node: ['24.x']

steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Install pnpm
uses: pnpm/setup@84cb39b217b10273981911c288cd62326dc7c6d2 # v2.0.2
with:
# Every job here runs its own install step, with the flags that job
# needs. pnpm/setup installs by default, which would install twice.
install: false

- name: Use node ${{ matrix.node }}
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: ${{ matrix.node }}
cache: 'pnpm'

- name: Install deps
run: pnpm install --frozen-lockfile

# Installed under an npm alias so the `typescript` devDependency, and
# every other tool that resolves it, stays on the supported version.
- name: Install TypeScript 7
run: pnpm add -D typescript-7@npm:typescript@7

# Invoked by path rather than through `pnpm exec tsc`. The alias also
# claims the `tsc` bin, so `pnpm exec tsc` happens to resolve to 7 today,
# but relying on that would let this job silently test the wrong compiler.
- name: Test types with TypeScript 7
run: |
node node_modules/typescript-7/bin/tsc --version
node node_modules/typescript-7/bin/tsc -p tsconfig.test.json --noEmit

are-the-types-wrong:
name: Check package config with are-the-types-wrong

Expand Down
42 changes: 32 additions & 10 deletions src/components/connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ import type {
} from './Context'
import { ReactReduxContext } from './Context'

// Accepts the same values as `MapStateToPropsParam`, but as a single function
// type whose return is `TStateProps | MapStateToProps<...>`. Inference prefers
// the non-naked `MapStateToProps` member, so a factory infers `TStateProps` from
// its inner function on every compiler, independent of union member order.
type MapStateToPropsParamForMergeProps<TStateProps, TOwnProps, State> =
| ((
state: State,
ownProps: TOwnProps,
) => TStateProps | MapStateToProps<TStateProps, TOwnProps, State>)
| null
| undefined

// Define some constant arrays just to avoid re-creating these
const EMPTY_ARRAY: [unknown, number] = [null, 0]
const NO_SUBSCRIPTION_ARRAY = [null, null]
Expand Down Expand Up @@ -293,7 +305,7 @@ export interface Connect<DefaultState = unknown> {
// and new (native, TS 7 / `tsgo`) compilers order union members differently,
// inferring `TStateProps` / `TDispatchProps` incorrectly on one of them.
// Listing the factory overload first makes the resolution order explicit and
// compiler-independent. The `mergeProps` overloads keep the unions on purpose
// compiler-independent. The `mergeProps` overloads can't be split this way
// (see the note on those overloads below).
// See https://github.com/reduxjs/react-redux/issues/2244
(): InferableComponentEnhancer<DispatchProp>
Expand Down Expand Up @@ -417,9 +429,9 @@ export interface Connect<DefaultState = unknown> {
mergeProps: MergeProps<undefined, DispatchProp, TOwnProps, TMergedProps>,
): InferableComponentEnhancerWithProps<TMergedProps, TOwnProps>

// `mergeProps` overloads keep the union parameters (see the note on the final
// `mergeProps` overload below) - splitting off a factory overload would
// collapse the inferred props to `{}`.
// `mergeProps` overloads don't split off a factory overload (see the note on
// the final `mergeProps` overload below) - doing so would collapse the
// inferred props to `{}`.

/** mapState and mergeProps */
<
Expand All @@ -429,7 +441,11 @@ export interface Connect<DefaultState = unknown> {
TMergedProps = {},
State = DefaultState,
>(
mapStateToProps: MapStateToPropsParam<TStateProps, TOwnProps, State>,
mapStateToProps: MapStateToPropsParamForMergeProps<
TStateProps,
TOwnProps,
State
>,
mapDispatchToProps: null | undefined,
mergeProps: MergeProps<TStateProps, DispatchProp, TOwnProps, TMergedProps>,
): InferableComponentEnhancerWithProps<TMergedProps, TOwnProps>
Expand Down Expand Up @@ -562,14 +578,16 @@ export interface Connect<DefaultState = unknown> {
TOwnProps
>

// NOTE: the `mergeProps` overloads keep the union parameters rather than the
// factory/plain split used above. `mergeProps` receives `stateProps` and
// NOTE: the `mergeProps` overloads accept both map forms in one overload
// rather than the factory/plain split used above. `mergeProps` receives `stateProps` and
// `dispatchProps` as contextually-typed (non-inferring) parameters, so
// `TStateProps` / `TDispatchProps` can only be inferred from `mapStateToProps`
// / `mapDispatchToProps`. With a dedicated factory overload listed first a
// plain map function gets captured by it and the inferred props collapse to
// `{}`. The union keeps both forms as inference candidates in a single
// overload, which is the original (and correct) behavior for these signatures.
// `{}`, because the contextually-typed `mergeProps` parameters keep the types
// from the failed overload attempt. `mapStateToProps` uses
// `MapStateToPropsParamForMergeProps` instead of the `MapStateToPropsParam`
// union so that a factory infers the same `TStateProps` on every compiler.

/** mapState, mapDispatch, mergeProps, and options */
<
Expand All @@ -579,7 +597,11 @@ export interface Connect<DefaultState = unknown> {
TMergedProps = {},
State = DefaultState,
>(
mapStateToProps: MapStateToPropsParam<TStateProps, TOwnProps, State>,
mapStateToProps: MapStateToPropsParamForMergeProps<
TStateProps,
TOwnProps,
State
>,
mapDispatchToProps: MapDispatchToPropsParam<TDispatchProps, TOwnProps>,
mergeProps: MergeProps<
TStateProps,
Expand Down
150 changes: 149 additions & 1 deletion test/typetests/connect-mapstate-mapdispatch.test-d.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React from 'react'
import type { ActionCreator, Dispatch } from 'redux'
import type { MapDispatchToProps, ReactReduxContextValue } from 'react-redux'
import type {
MapDispatchToProps,
MapStateToPropsParam,
ReactReduxContextValue,
} from 'react-redux'
import { connect } from 'react-redux'

// Test cases written in a way to isolate types and variables and verify the
Expand Down Expand Up @@ -450,6 +454,150 @@ describe('type tests', () => {
const verifyEverything = <WithEverything foo="bar" />
})

test('unannotated mergeProps infers props from every map form', () => {
interface OwnProps {
foo: string
}
interface StateProps {
bar: number
}
interface DispatchProps {
onClick: () => void
}

class TestComponent extends React.Component<
OwnProps & StateProps & Partial<DispatchProps>
> {}

const mapStateToProps = (_: unknown) => ({ bar: 1 })
const mapStateToPropsFactory = () => (_: unknown) => ({ bar: 1 })
const mapDispatchToProps = () => ({ onClick: () => {} })
const mapDispatchToPropsFactory = () => () => ({ onClick: () => {} })

const PlainState = connect(
mapStateToProps,
null,
(stateProps, dispatchProps, ownProps: OwnProps) => {
expectTypeOf(stateProps).toEqualTypeOf<StateProps>()
return { ...ownProps, bar: stateProps.bar }
},
)(TestComponent)
const verifyPlainState = <PlainState foo="bar" />

const FactoryState = connect(
mapStateToPropsFactory,
null,
(stateProps, dispatchProps, ownProps: OwnProps) => {
expectTypeOf(stateProps).toEqualTypeOf<StateProps>()
return { ...ownProps, bar: stateProps.bar }
},
)(TestComponent)
const verifyFactoryState = <FactoryState foo="bar" />

const PlainStatePlainDispatch = connect(
mapStateToProps,
mapDispatchToProps,
(stateProps, dispatchProps, ownProps: OwnProps) => {
expectTypeOf(stateProps).toEqualTypeOf<StateProps>()
expectTypeOf(dispatchProps).toEqualTypeOf<DispatchProps>()
return { ...ownProps, ...stateProps, ...dispatchProps }
},
)(TestComponent)
const verifyPlainStatePlainDispatch = <PlainStatePlainDispatch foo="bar" />

const PlainStateFactoryDispatch = connect(
mapStateToProps,
mapDispatchToPropsFactory,
(stateProps, dispatchProps, ownProps: OwnProps) => {
expectTypeOf(stateProps).toEqualTypeOf<StateProps>()
expectTypeOf(dispatchProps).toEqualTypeOf<DispatchProps>()
return { ...ownProps, ...stateProps, ...dispatchProps }
},
)(TestComponent)
const verifyPlainStateFactoryDispatch = (
<PlainStateFactoryDispatch foo="bar" />
)

const NullStateFactoryDispatch = connect(
null,
mapDispatchToPropsFactory,
(stateProps, dispatchProps, ownProps: OwnProps) => {
expectTypeOf(dispatchProps).toEqualTypeOf<DispatchProps>()
return { ...ownProps, bar: 1, ...dispatchProps }
},
)(TestComponent)
const verifyNullStateFactoryDispatch = (
<NullStateFactoryDispatch foo="bar" />
)

const FactoryStateObjectDispatch = connect(
mapStateToPropsFactory,
{ onClick: () => ({ type: 'click' }) },
(stateProps, dispatchProps, ownProps: OwnProps) => {
expectTypeOf(stateProps).toEqualTypeOf<StateProps>()
return { ...ownProps, ...stateProps, onClick: dispatchProps.onClick }
},
{},
)(TestComponent)
const verifyFactoryStateObjectDispatch = (
<FactoryStateObjectDispatch foo="bar" />
)

const FactoryStateFactoryDispatch = connect(
mapStateToPropsFactory,
mapDispatchToPropsFactory,
(stateProps, dispatchProps, ownProps: OwnProps) => {
expectTypeOf(stateProps).toEqualTypeOf<StateProps>()
expectTypeOf(dispatchProps).toEqualTypeOf<DispatchProps>()
return { ...ownProps, ...stateProps, ...dispatchProps }
},
)(TestComponent)
const verifyFactoryStateFactoryDispatch = (
<FactoryStateFactoryDispatch foo="bar" />
)

const InlinePlainState = connect(
(state) => ({ bar: 1, state }),
{ onClick: () => ({ type: 'click' }) },
(stateProps, dispatchProps, ownProps: OwnProps) => {
expectTypeOf(stateProps).toEqualTypeOf<{
bar: number
state: unknown
}>()
return { ...ownProps, bar: stateProps.bar }
},
)(TestComponent)
const verifyInlinePlainState = <InlinePlainState foo="bar" />

const InlineFactoryState = connect(
() => (state) => ({ bar: 1, state }),
null,
(stateProps, dispatchProps, ownProps: OwnProps) => {
expectTypeOf(stateProps).toEqualTypeOf<{
bar: number
state: unknown
}>()
return { ...ownProps, bar: stateProps.bar }
},
)(TestComponent)
const verifyInlineFactoryState = <InlineFactoryState foo="bar" />

const mapStateToPropsParam: MapStateToPropsParam<
StateProps,
OwnProps,
unknown
> = mapStateToPropsFactory
const ParamState = connect(
mapStateToPropsParam,
mapDispatchToProps,
(stateProps, dispatchProps, ownProps: OwnProps) => {
expectTypeOf(stateProps).toEqualTypeOf<StateProps>()
return { ...ownProps, ...stateProps, ...dispatchProps }
},
)(TestComponent)
const verifyParamState = <ParamState foo="bar" />
})

test('map state and dispatch and merge', () => {
interface OwnProps {
foo: string
Expand Down
Loading