From 652790c937b8fba805cee40bd0c06f05c71271d4 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 28 Sep 2026 16:05:41 -0400 Subject: [PATCH 1/2] Fix `connect` factory overload types for TS7 --- src/components/connect.tsx | 42 +++-- .../connect-mapstate-mapdispatch.test-d.tsx | 150 +++++++++++++++++- 2 files changed, 181 insertions(+), 11 deletions(-) diff --git a/src/components/connect.tsx b/src/components/connect.tsx index 1c874cd20..3e6c92c39 100644 --- a/src/components/connect.tsx +++ b/src/components/connect.tsx @@ -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 = + | (( + state: State, + ownProps: TOwnProps, + ) => TStateProps | MapStateToProps) + | 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] @@ -293,7 +305,7 @@ export interface Connect { // 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 @@ -417,9 +429,9 @@ export interface Connect { mergeProps: MergeProps, ): InferableComponentEnhancerWithProps - // `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 */ < @@ -429,7 +441,11 @@ export interface Connect { TMergedProps = {}, State = DefaultState, >( - mapStateToProps: MapStateToPropsParam, + mapStateToProps: MapStateToPropsParamForMergeProps< + TStateProps, + TOwnProps, + State + >, mapDispatchToProps: null | undefined, mergeProps: MergeProps, ): InferableComponentEnhancerWithProps @@ -562,14 +578,16 @@ export interface Connect { 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 */ < @@ -579,7 +597,11 @@ export interface Connect { TMergedProps = {}, State = DefaultState, >( - mapStateToProps: MapStateToPropsParam, + mapStateToProps: MapStateToPropsParamForMergeProps< + TStateProps, + TOwnProps, + State + >, mapDispatchToProps: MapDispatchToPropsParam, mergeProps: MergeProps< TStateProps, diff --git a/test/typetests/connect-mapstate-mapdispatch.test-d.tsx b/test/typetests/connect-mapstate-mapdispatch.test-d.tsx index 3d12a3736..51ce5f846 100644 --- a/test/typetests/connect-mapstate-mapdispatch.test-d.tsx +++ b/test/typetests/connect-mapstate-mapdispatch.test-d.tsx @@ -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 @@ -450,6 +454,150 @@ describe('type tests', () => { const verifyEverything = }) + 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 + > {} + + 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() + return { ...ownProps, bar: stateProps.bar } + }, + )(TestComponent) + const verifyPlainState = + + const FactoryState = connect( + mapStateToPropsFactory, + null, + (stateProps, dispatchProps, ownProps: OwnProps) => { + expectTypeOf(stateProps).toEqualTypeOf() + return { ...ownProps, bar: stateProps.bar } + }, + )(TestComponent) + const verifyFactoryState = + + const PlainStatePlainDispatch = connect( + mapStateToProps, + mapDispatchToProps, + (stateProps, dispatchProps, ownProps: OwnProps) => { + expectTypeOf(stateProps).toEqualTypeOf() + expectTypeOf(dispatchProps).toEqualTypeOf() + return { ...ownProps, ...stateProps, ...dispatchProps } + }, + )(TestComponent) + const verifyPlainStatePlainDispatch = + + const PlainStateFactoryDispatch = connect( + mapStateToProps, + mapDispatchToPropsFactory, + (stateProps, dispatchProps, ownProps: OwnProps) => { + expectTypeOf(stateProps).toEqualTypeOf() + expectTypeOf(dispatchProps).toEqualTypeOf() + return { ...ownProps, ...stateProps, ...dispatchProps } + }, + )(TestComponent) + const verifyPlainStateFactoryDispatch = ( + + ) + + const NullStateFactoryDispatch = connect( + null, + mapDispatchToPropsFactory, + (stateProps, dispatchProps, ownProps: OwnProps) => { + expectTypeOf(dispatchProps).toEqualTypeOf() + return { ...ownProps, bar: 1, ...dispatchProps } + }, + )(TestComponent) + const verifyNullStateFactoryDispatch = ( + + ) + + const FactoryStateObjectDispatch = connect( + mapStateToPropsFactory, + { onClick: () => ({ type: 'click' }) }, + (stateProps, dispatchProps, ownProps: OwnProps) => { + expectTypeOf(stateProps).toEqualTypeOf() + return { ...ownProps, ...stateProps, onClick: dispatchProps.onClick } + }, + {}, + )(TestComponent) + const verifyFactoryStateObjectDispatch = ( + + ) + + const FactoryStateFactoryDispatch = connect( + mapStateToPropsFactory, + mapDispatchToPropsFactory, + (stateProps, dispatchProps, ownProps: OwnProps) => { + expectTypeOf(stateProps).toEqualTypeOf() + expectTypeOf(dispatchProps).toEqualTypeOf() + return { ...ownProps, ...stateProps, ...dispatchProps } + }, + )(TestComponent) + const verifyFactoryStateFactoryDispatch = ( + + ) + + 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 = + + 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 = + + const mapStateToPropsParam: MapStateToPropsParam< + StateProps, + OwnProps, + unknown + > = mapStateToPropsFactory + const ParamState = connect( + mapStateToPropsParam, + mapDispatchToProps, + (stateProps, dispatchProps, ownProps: OwnProps) => { + expectTypeOf(stateProps).toEqualTypeOf() + return { ...ownProps, ...stateProps, ...dispatchProps } + }, + )(TestComponent) + const verifyParamState = + }) + test('map state and dispatch and merge', () => { interface OwnProps { foo: string From fb48abcd261a3022df1fb883c521427151ca0d8d Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 28 Sep 2026 16:05:58 -0400 Subject: [PATCH 2/2] Update TS matrix to cover 5.x, 6, 7, and "next" --- .github/workflows/test.yml | 52 +++----------------------------------- 1 file changed, 4 insertions(+), 48 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 622a11845..fa4abb145 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: [ { @@ -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