From 21600bdb7ed4704e0ba9d6ae8a23f4b258879d87 Mon Sep 17 00:00:00 2001 From: Arya Emami Date: Fri, 5 Jan 2024 07:00:01 -0600 Subject: [PATCH 01/13] Initial implementation of `withTypes` --- src/hooks/useDispatch.ts | 29 +++++++++++++++++++--- src/hooks/useSelector.ts | 26 ++++++++++++++++--- src/hooks/useStore.ts | 44 +++++++++++++++++++++++++++++---- test/hooks/useSelector.spec.tsx | 6 +++++ test/typetests/counterApp.ts | 10 +++----- test/typetests/hooks.tsx | 14 ++++++++--- 6 files changed, 106 insertions(+), 23 deletions(-) diff --git a/src/hooks/useDispatch.ts b/src/hooks/useDispatch.ts index c5520dc43..98af8fb3d 100644 --- a/src/hooks/useDispatch.ts +++ b/src/hooks/useDispatch.ts @@ -1,9 +1,24 @@ -import type { Action, Dispatch, UnknownAction } from 'redux' import type { Context } from 'react' +import type { Action, Dispatch, UnknownAction } from 'redux' import type { ReactReduxContextValue } from '../components/Context' import { ReactReduxContext } from '../components/Context' -import { useStore as useDefaultStore, createStoreHook } from './useStore' +import { createStoreHook, useStore as useDefaultStore } from './useStore' + +export interface UseDispatch { + < + AppDispatch extends Dispatch = Dispatch + >(): AppDispatch + withTypes: >() => () => AppDispatch +} +// export interface UseDispatch< +// DispatchType extends Dispatch = Dispatch +// > { +// (): AppDispatch +// withTypes: < +// OverrideDispatchType extends DispatchType +// >() => UseDispatch +// } /** * Hook factory, which creates a `useDispatch` hook bound to a given context. @@ -20,13 +35,19 @@ export function createDispatchHook< // @ts-ignore context === ReactReduxContext ? useDefaultStore : createStoreHook(context) - return function useDispatch< + const useDispatch = < AppDispatch extends Dispatch = Dispatch - >(): AppDispatch { + >(): AppDispatch => { const store = useStore() // @ts-ignore return store.dispatch } + + Object.assign(useDispatch, { + withTypes: () => useDispatch, + }) + + return useDispatch as UseDispatch } /** diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index 629ead80d..c51f4947a 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -3,7 +3,7 @@ import { React } from '../utils/react' import type { ReactReduxContextValue } from '../components/Context' import { ReactReduxContext } from '../components/Context' -import type { EqualityFn, NoInfer } from '../types' +import type { EqualityFn, NoInfer, TypedUseSelectorHook } from '../types' import type { uSESWS } from '../utils/useSyncExternalStore' import { notInitialized } from '../utils/useSyncExternalStore' import { @@ -75,7 +75,21 @@ export interface UseSelector { selector: (state: TState) => Selected, options?: UseSelectorOptions ): Selected + withTypes: () => TypedUseSelectorHook } +// export interface UseSelector { +// ( +// selector: (state: TState) => Selected, +// equalityFn?: EqualityFn +// ): Selected +// ( +// selector: (state: TState) => Selected, +// options?: UseSelectorOptions +// ): Selected +// withTypes: < +// OverrideStateType extends StateType +// >() => UseSelector +// } let useSyncExternalStoreWithSelector = notInitialized as uSESWS export const initializeUseSelector = (fn: uSESWS) => { @@ -101,12 +115,12 @@ export function createSelectorHook( ? useDefaultReduxContext : createReduxContextHook(context) - return function useSelector( + const useSelector = ( selector: (state: TState) => Selected, equalityFnOrOptions: | EqualityFn> | UseSelectorOptions> = {} - ): Selected { + ): Selected => { const { equalityFn = refEquality, devModeChecks = {} } = typeof equalityFnOrOptions === 'function' ? { equalityFn: equalityFnOrOptions } @@ -215,6 +229,12 @@ export function createSelectorHook( return selectedState } + + Object.assign(useSelector, { + withTypes: () => useSelector, + }) + + return useSelector as UseSelector } /** diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts index cc529d315..ffea2eacb 100644 --- a/src/hooks/useStore.ts +++ b/src/hooks/useStore.ts @@ -1,12 +1,40 @@ import type { Context } from 'react' -import type { Action as BasicAction, UnknownAction, Store } from 'redux' +import type { Action as BasicAction, Store, UnknownAction } from 'redux' import type { ReactReduxContextValue } from '../components/Context' import { ReactReduxContext } from '../components/Context' import { - useReduxContext as useDefaultReduxContext, createReduxContextHook, + useReduxContext as useDefaultReduxContext, } from './useReduxContext' +export type StoreAction = StoreType extends Store< + any, + infer Action +> + ? Action + : never + +export interface UseStore { + (): Store< + State, + Action + > + + withTypes: () => () => AppStore +} +// export interface UseStore { +// < +// State extends ReturnType = ReturnType< +// StoreType['getState'] +// >, +// Action extends BasicAction = StoreAction +// >(): Store + +// withTypes: < +// OverrideStoreType extends StoreType +// >() => UseStore +// } + /** * Hook factory, which creates a `useStore` hook bound to a given context. * @@ -15,7 +43,7 @@ import { */ export function createStoreHook< S = unknown, - A extends BasicAction = UnknownAction + A extends BasicAction = BasicAction // @ts-ignore >(context?: Context | null> = ReactReduxContext) { const useReduxContext = @@ -24,15 +52,21 @@ export function createStoreHook< ? useDefaultReduxContext : // @ts-ignore createReduxContextHook(context) - return function useStore< + const useStore = < State = S, Action2 extends BasicAction = A // @ts-ignore - >() { + >() => { const { store } = useReduxContext() // @ts-ignore return store as Store } + + Object.assign(useStore, { + withTypes: () => useStore, + }) + + return useStore as UseStore } /** diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index 9108d18ff..f957e5ba1 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -1055,6 +1055,12 @@ describe('React', () => { }) }) }) + + test('useSelector.withTypes', () => { + const useAppSelector = useSelector.withTypes() + + expect(useAppSelector).toBe(useSelector) + }) }) describe('createSelectorHook', () => { diff --git a/test/typetests/counterApp.ts b/test/typetests/counterApp.ts index 7b976d814..97545a183 100644 --- a/test/typetests/counterApp.ts +++ b/test/typetests/counterApp.ts @@ -1,10 +1,5 @@ -import { - createSlice, - createAsyncThunk, - configureStore, - ThunkAction, - Action, -} from '@reduxjs/toolkit' +import type { Action, ThunkAction } from '@reduxjs/toolkit' +import { configureStore, createAsyncThunk, createSlice } from '@reduxjs/toolkit' export interface CounterState { counter: number @@ -46,6 +41,7 @@ const counterStore = configureStore({ middleware: (gdm) => gdm(), }) +export type AppStore = typeof counterStore export type AppDispatch = typeof counterStore.dispatch export type RootState = ReturnType export type AppThunk = ThunkAction< diff --git a/test/typetests/hooks.tsx b/test/typetests/hooks.tsx index a553ed29e..d298eb90d 100644 --- a/test/typetests/hooks.tsx +++ b/test/typetests/hooks.tsx @@ -18,15 +18,20 @@ import { useStore, } from '../../src/index' -import type { AppDispatch, RootState } from './counterApp' +import type { AppDispatch, AppStore, RootState } from './counterApp' import { incrementAsync } from './counterApp' import { expectExactType, expectType } from '../typeTestHelpers' function preTypedHooksSetup() { // Standard hooks setup - const useAppDispatch = () => useDispatch() - const useAppSelector: TypedUseSelectorHook = useSelector + const useAppDispatch = useDispatch.withTypes() + // const useAppDispatch = () => useDispatch() + // const useAppSelector: UseSelector = useSelector + const useAppSelector = useSelector.withTypes() + + useAppSelector((state) => state.counter) + const useAppStore = useStore.withTypes() function CounterComponent() { const dispatch = useAppDispatch() @@ -84,7 +89,8 @@ function testShallowEqual() { ) expectExactType(selected1) - const useAppSelector: TypedUseSelectorHook = useSelector + // const useAppSelector: UseSelector = useSelector + const useAppSelector = useSelector.withTypes() const selected2 = useAppSelector((state) => state.stateProp, shallowEqual) expectExactType(selected2) From 3dc456dee683306d981bddea18a8e8060a92f3c1 Mon Sep 17 00:00:00 2001 From: Arya Emami Date: Fri, 5 Jan 2024 07:05:40 -0600 Subject: [PATCH 02/13] Remove `UseSelector` overload --- src/hooks/useSelector.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index c51f4947a..c66b29732 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -69,11 +69,7 @@ export interface UseSelectorOptions { export interface UseSelector { ( selector: (state: TState) => Selected, - equalityFn?: EqualityFn - ): Selected - ( - selector: (state: TState) => Selected, - options?: UseSelectorOptions + equalityFnOrOptions?: EqualityFn | UseSelectorOptions ): Selected withTypes: () => TypedUseSelectorHook } From df1ad3833e8511e66076e85d792f4a42253e1725 Mon Sep 17 00:00:00 2001 From: Arya Emami Date: Fri, 5 Jan 2024 09:47:57 -0600 Subject: [PATCH 03/13] Add `IsEqual` type utility for type tests --- test/typeTestHelpers.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/typeTestHelpers.ts b/test/typeTestHelpers.ts index 08da24571..84b8b0dc2 100644 --- a/test/typeTestHelpers.ts +++ b/test/typeTestHelpers.ts @@ -27,6 +27,25 @@ type Equals = IsAny< never, IsAny > + +export type IsEqual = (() => G extends A ? 1 : 2) extends < + G +>() => G extends B ? 1 : 2 + ? true + : false + +export type IfEquals< + T, + U, + TypeIfEquals = unknown, + TypeIfNotEquals = never +> = IsEqual extends true ? TypeIfEquals : TypeIfNotEquals + +export declare const exactType: ( + draft: T & IfEquals, + expected: U & IfEquals +) => IfEquals + export function expectExactType(t: T) { return >(u: U) => {} } From b0831d20c4a00a937847a12f0ce9179cd11d75ca Mon Sep 17 00:00:00 2001 From: Arya Emami Date: Fri, 5 Jan 2024 09:48:50 -0600 Subject: [PATCH 04/13] Add type tests for `withTypes` --- test/typetests/hooks.tsx | 51 +++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/test/typetests/hooks.tsx b/test/typetests/hooks.tsx index d298eb90d..f9d815de7 100644 --- a/test/typetests/hooks.tsx +++ b/test/typetests/hooks.tsx @@ -21,17 +21,12 @@ import { import type { AppDispatch, AppStore, RootState } from './counterApp' import { incrementAsync } from './counterApp' -import { expectExactType, expectType } from '../typeTestHelpers' +import { exactType, expectExactType, expectType } from '../typeTestHelpers' function preTypedHooksSetup() { // Standard hooks setup - const useAppDispatch = useDispatch.withTypes() - // const useAppDispatch = () => useDispatch() - // const useAppSelector: UseSelector = useSelector - const useAppSelector = useSelector.withTypes() - - useAppSelector((state) => state.counter) - const useAppStore = useStore.withTypes() + const useAppDispatch = () => useDispatch() + const useAppSelector: TypedUseSelectorHook = useSelector function CounterComponent() { const dispatch = useAppDispatch() @@ -242,3 +237,43 @@ function testCreateHookFunctions() { >(createSelectorHook(Context)) expectType<() => Store>(createStoreHook(Context)) } + +function preTypedHooksSetupWithTypes() { + const useAppDispatch = useDispatch.withTypes() + + const useAppSelector = useSelector.withTypes() + + const useAppStore = useStore.withTypes() + + function CounterComponent() { + useAppSelector((state) => state.counter) + + const dispatch = useAppDispatch() + + expectExactType(dispatch) + + const store = useAppStore() + + expectExactType(store) + + expectExactType(store.dispatch) + + const state = store.getState() + + expectExactType(state) + + expectExactType(state.counter) + + store.dispatch(incrementAsync(1)) + + exactType(store.dispatch, dispatch) + + return ( +