From ea8081f8bd55d3b741cfb963ca2b90377cad1e29 Mon Sep 17 00:00:00 2001 From: "ben.durrant" Date: Fri, 24 Feb 2023 20:29:11 +0000 Subject: [PATCH 1/3] create EnhancerArray class for proper inference of enhancer extensions --- packages/toolkit/src/configureStore.ts | 12 +- packages/toolkit/src/index.ts | 2 +- .../src/tests/EnhancerArray.typetest.ts | 103 ++++++++++++++++++ .../toolkit/src/tests/configureStore.test.ts | 4 +- .../src/tests/configureStore.typetest.ts | 14 +++ packages/toolkit/src/tsHelpers.ts | 28 ++++- packages/toolkit/src/utils.ts | 45 +++++++- 7 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 packages/toolkit/src/tests/EnhancerArray.typetest.ts diff --git a/packages/toolkit/src/configureStore.ts b/packages/toolkit/src/configureStore.ts index aae2307c9d..7c9b0cfb87 100644 --- a/packages/toolkit/src/configureStore.ts +++ b/packages/toolkit/src/configureStore.ts @@ -25,6 +25,7 @@ import type { ExtractDispatchExtensions, ExtractStoreExtensions, } from './tsHelpers' +import { EnhancerArray } from './utils' const IS_PRODUCTION = process.env.NODE_ENV === 'production' @@ -34,8 +35,8 @@ const IS_PRODUCTION = process.env.NODE_ENV === 'production' * @public */ export type ConfigureEnhancersCallback = ( - defaultEnhancers: readonly StoreEnhancer[] -) => [...E] + defaultEnhancers: EnhancerArray<[StoreEnhancer<{}, {}>]> +) => E /** * Options for `configureStore()`. @@ -107,7 +108,7 @@ type Enhancers = ReadonlyArray export interface ToolkitStore< S = any, A extends Action = AnyAction, - M extends Middlewares = Middlewares, + M extends Middlewares = Middlewares > extends Store { /** * The `dispatch` method of your store, enhanced by all its middlewares. @@ -197,12 +198,13 @@ export function configureStore< }) } - let storeEnhancers: Enhancers = [middlewareEnhancer] + const defaultEnhancers = new EnhancerArray(middlewareEnhancer) + let storeEnhancers: Enhancers = defaultEnhancers if (Array.isArray(enhancers)) { storeEnhancers = [middlewareEnhancer, ...enhancers] } else if (typeof enhancers === 'function') { - storeEnhancers = enhancers(storeEnhancers) + storeEnhancers = enhancers(defaultEnhancers) } const composedEnhancer = finalCompose(...storeEnhancers) as StoreEnhancer diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 2ea3e1384c..2937987fd7 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -103,7 +103,7 @@ export type { // types ActionReducerMapBuilder, } from './mapBuilders' -export { MiddlewareArray } from './utils' +export { MiddlewareArray, EnhancerArray } from './utils' export { createEntityAdapter } from './entities/create_adapter' export type { diff --git a/packages/toolkit/src/tests/EnhancerArray.typetest.ts b/packages/toolkit/src/tests/EnhancerArray.typetest.ts new file mode 100644 index 0000000000..500483965d --- /dev/null +++ b/packages/toolkit/src/tests/EnhancerArray.typetest.ts @@ -0,0 +1,103 @@ +import { configureStore } from '@reduxjs/toolkit' +import type { StoreEnhancer } from 'redux' + +declare const expectType: (t: T) => T + +declare const enhancer1: StoreEnhancer<{ + has1: true +}> + +declare const enhancer2: StoreEnhancer<{ + has2: true +}> + +{ + // prepend single element + { + const store = configureStore({ + reducer: () => 0, + enhancers: (dE) => dE.prepend(enhancer1), + }) + expectType(store.has1) + + // @ts-expect-error + expectType(store.has2) + } + + // prepend multiple (rest) + { + const store = configureStore({ + reducer: () => 0, + enhancers: (dE) => dE.prepend(enhancer1, enhancer2), + }) + expectType(store.has1) + expectType(store.has2) + + // @ts-expect-error + expectType(store.has3) + } + + // prepend multiple (array notation) + { + const store = configureStore({ + reducer: () => 0, + enhancers: (dE) => dE.prepend([enhancer1, enhancer2] as const), + }) + expectType(store.has1) + expectType(store.has2) + + // @ts-expect-error + expectType(store.has3) + } + + // concat single element + { + const store = configureStore({ + reducer: () => 0, + enhancers: (dE) => dE.concat(enhancer1), + }) + expectType(store.has1) + + // @ts-expect-error + expectType(store.has2) + } + + // prepend multiple (rest) + { + const store = configureStore({ + reducer: () => 0, + enhancers: (dE) => dE.concat(enhancer1, enhancer2), + }) + expectType(store.has1) + expectType(store.has2) + + // @ts-expect-error + expectType(store.has3) + } + + // concat multiple (array notation) + { + const store = configureStore({ + reducer: () => 0, + enhancers: (dE) => dE.concat([enhancer1, enhancer2] as const), + }) + expectType(store.has1) + expectType(store.has2) + + // @ts-expect-error + expectType(store.has3) + } + + // concat and prepend + { + const store = configureStore({ + reducer: () => 0, + enhancers: (dE) => dE.concat(enhancer1).prepend(enhancer2), + }) + expectType(store.has1) + expectType(store.has2) + + // @ts-expect-error + expectType(store.has3) + } +} diff --git a/packages/toolkit/src/tests/configureStore.test.ts b/packages/toolkit/src/tests/configureStore.test.ts index 49831e51e6..aa93a5f842 100644 --- a/packages/toolkit/src/tests/configureStore.test.ts +++ b/packages/toolkit/src/tests/configureStore.test.ts @@ -231,9 +231,7 @@ describe('configureStore', () => { const store = configureStore({ reducer, - enhancers: (defaultEnhancers) => { - return [...defaultEnhancers, dummyEnhancer] - }, + enhancers: (defaultEnhancers) => defaultEnhancers.concat(dummyEnhancer), }) expect(dummyEnhancerCalled).toBe(true) diff --git a/packages/toolkit/src/tests/configureStore.typetest.ts b/packages/toolkit/src/tests/configureStore.typetest.ts index ae7d4872c7..4f76687b45 100644 --- a/packages/toolkit/src/tests/configureStore.typetest.ts +++ b/packages/toolkit/src/tests/configureStore.typetest.ts @@ -194,6 +194,20 @@ const _anyMiddleware: any = () => () => () => {} ) expectType(store.someProperty) expectType(store.anotherProperty) + + const storeWithCallback = configureStore({ + reducer: () => 0, + enhancers: (defaultEnhancers) => + defaultEnhancers + .prepend(anotherPropertyStoreEnhancer) + .concat(somePropertyStoreEnhancer), + }) + + expectType>( + store.dispatch + ) + expectType(storeWithCallback.someProperty) + expectType(storeWithCallback.anotherProperty) } } diff --git a/packages/toolkit/src/tsHelpers.ts b/packages/toolkit/src/tsHelpers.ts index 3e8dc20f05..93b4fc2443 100644 --- a/packages/toolkit/src/tsHelpers.ts +++ b/packages/toolkit/src/tsHelpers.ts @@ -1,5 +1,5 @@ import type { Middleware, StoreEnhancer } from 'redux' -import type { MiddlewareArray } from './utils' +import type { EnhancerArray, MiddlewareArray } from './utils' /** * return True if T is `any`, otherwise return False @@ -101,9 +101,29 @@ export type ExtractDispatchExtensions = M extends MiddlewareArray< ? ExtractDispatchFromMiddlewareTuple<[...M], {}> : never -export type ExtractStoreExtensions = E extends any[] - ? UnionToIntersection ? Ext extends {} ? Ext : {} : {}> - : {} +type ExtractStoreExtensionsFromEnhancerTuple< + EnhancerTuple extends any[], + Acc extends {} +> = EnhancerTuple extends [infer Head, ...infer Tail] + ? ExtractStoreExtensionsFromEnhancerTuple< + Tail, + Acc & (Head extends StoreEnhancer ? IsAny : {}) + > + : Acc + +export type ExtractStoreExtensions = E extends EnhancerArray< + infer EnhancerTuple +> + ? ExtractStoreExtensionsFromEnhancerTuple + : E extends ReadonlyArray + ? UnionToIntersection< + E[number] extends StoreEnhancer + ? Ext extends {} + ? Ext + : {} + : {} + > + : never /** * Helper type. Passes T out again, but boxes it in a way that it cannot diff --git a/packages/toolkit/src/utils.ts b/packages/toolkit/src/utils.ts index 40957c2788..2d3784293c 100644 --- a/packages/toolkit/src/utils.ts +++ b/packages/toolkit/src/utils.ts @@ -1,5 +1,5 @@ import createNextState, { isDraftable } from 'immer' -import type { Middleware } from 'redux' +import type { Middleware, StoreEnhancer } from 'redux' export function getTimeMeasureUtils(maxDelay: number, fnName: string) { let elapsed = 0 @@ -70,6 +70,49 @@ export class MiddlewareArray< } } +/** + * @public + */ +export class EnhancerArray< + Enhancers extends StoreEnhancer[] +> extends Array { + constructor(...items: Enhancers) + constructor(...args: any[]) { + super(...args) + Object.setPrototypeOf(this, EnhancerArray.prototype) + } + + static get [Symbol.species]() { + return EnhancerArray as any + } + + concat>>( + items: AdditionalEnhancers + ): EnhancerArray<[...Enhancers, ...AdditionalEnhancers]> + + concat>>( + ...items: AdditionalEnhancers + ): EnhancerArray<[...Enhancers, ...AdditionalEnhancers]> + concat(...arr: any[]) { + return super.concat.apply(this, arr) + } + + prepend>>( + items: AdditionalEnhancers + ): EnhancerArray<[...AdditionalEnhancers, ...Enhancers]> + + prepend>>( + ...items: AdditionalEnhancers + ): EnhancerArray<[...AdditionalEnhancers, ...Enhancers]> + + prepend(...arr: any[]) { + if (arr.length === 1 && Array.isArray(arr[0])) { + return new EnhancerArray(...arr[0].concat(this)) + } + return new EnhancerArray(...arr.concat(this)) + } +} + export function freezeDraftable(val: T) { return isDraftable(val) ? createNextState(val, () => {}) : val } From 9604e73f68def317e656c8902f4da23b7aa9b065 Mon Sep 17 00:00:00 2001 From: "ben.durrant" Date: Fri, 24 Feb 2023 21:01:21 +0000 Subject: [PATCH 2/3] add state extensions from enhancers --- packages/toolkit/src/configureStore.ts | 4 +- .../src/tests/EnhancerArray.typetest.ts | 44 +++++++++++-- .../src/tests/configureStore.typetest.ts | 66 +++++++++++++++++++ packages/toolkit/src/tsHelpers.ts | 29 +++++++- 4 files changed, 135 insertions(+), 8 deletions(-) diff --git a/packages/toolkit/src/configureStore.ts b/packages/toolkit/src/configureStore.ts index 7c9b0cfb87..f89a86089a 100644 --- a/packages/toolkit/src/configureStore.ts +++ b/packages/toolkit/src/configureStore.ts @@ -24,6 +24,7 @@ import type { NoInfer, ExtractDispatchExtensions, ExtractStoreExtensions, + ExtractStateExtensions, } from './tsHelpers' import { EnhancerArray } from './utils' @@ -129,7 +130,8 @@ export type EnhancedStore< A extends Action = AnyAction, M extends Middlewares = Middlewares, E extends Enhancers = Enhancers -> = ToolkitStore & ExtractStoreExtensions +> = ToolkitStore, A, M> & + ExtractStoreExtensions /** * A friendly abstraction over the standard Redux `createStore()` function. diff --git a/packages/toolkit/src/tests/EnhancerArray.typetest.ts b/packages/toolkit/src/tests/EnhancerArray.typetest.ts index 500483965d..56e89a30d1 100644 --- a/packages/toolkit/src/tests/EnhancerArray.typetest.ts +++ b/packages/toolkit/src/tests/EnhancerArray.typetest.ts @@ -3,13 +3,19 @@ import type { StoreEnhancer } from 'redux' declare const expectType: (t: T) => T -declare const enhancer1: StoreEnhancer<{ - has1: true -}> +declare const enhancer1: StoreEnhancer< + { + has1: true + }, + { stateHas1: true } +> -declare const enhancer2: StoreEnhancer<{ - has2: true -}> +declare const enhancer2: StoreEnhancer< + { + has2: true + }, + { stateHas2: true } +> { // prepend single element @@ -19,9 +25,12 @@ declare const enhancer2: StoreEnhancer<{ enhancers: (dE) => dE.prepend(enhancer1), }) expectType(store.has1) + expectType(store.getState().stateHas1) // @ts-expect-error expectType(store.has2) + // @ts-expect-error + expectType(store.getState().stateHas2) } // prepend multiple (rest) @@ -31,10 +40,14 @@ declare const enhancer2: StoreEnhancer<{ enhancers: (dE) => dE.prepend(enhancer1, enhancer2), }) expectType(store.has1) + expectType(store.getState().stateHas1) expectType(store.has2) + expectType(store.getState().stateHas2) // @ts-expect-error expectType(store.has3) + // @ts-expect-error + expectType(store.getState().stateHas3) } // prepend multiple (array notation) @@ -44,10 +57,14 @@ declare const enhancer2: StoreEnhancer<{ enhancers: (dE) => dE.prepend([enhancer1, enhancer2] as const), }) expectType(store.has1) + expectType(store.getState().stateHas1) expectType(store.has2) + expectType(store.getState().stateHas2) // @ts-expect-error expectType(store.has3) + // @ts-expect-error + expectType(store.getState().stateHas3) } // concat single element @@ -57,9 +74,12 @@ declare const enhancer2: StoreEnhancer<{ enhancers: (dE) => dE.concat(enhancer1), }) expectType(store.has1) + expectType(store.getState().stateHas1) // @ts-expect-error expectType(store.has2) + // @ts-expect-error + expectType(store.getState().stateHas2) } // prepend multiple (rest) @@ -69,10 +89,14 @@ declare const enhancer2: StoreEnhancer<{ enhancers: (dE) => dE.concat(enhancer1, enhancer2), }) expectType(store.has1) + expectType(store.getState().stateHas1) expectType(store.has2) + expectType(store.getState().stateHas2) // @ts-expect-error expectType(store.has3) + // @ts-expect-error + expectType(store.getState().stateHas3) } // concat multiple (array notation) @@ -82,10 +106,14 @@ declare const enhancer2: StoreEnhancer<{ enhancers: (dE) => dE.concat([enhancer1, enhancer2] as const), }) expectType(store.has1) + expectType(store.getState().stateHas1) expectType(store.has2) + expectType(store.getState().stateHas2) // @ts-expect-error expectType(store.has3) + // @ts-expect-error + expectType(store.getState().stateHas3) } // concat and prepend @@ -95,9 +123,13 @@ declare const enhancer2: StoreEnhancer<{ enhancers: (dE) => dE.concat(enhancer1).prepend(enhancer2), }) expectType(store.has1) + expectType(store.getState().stateHas1) expectType(store.has2) + expectType(store.getState().stateHas2) // @ts-expect-error expectType(store.has3) + // @ts-expect-error + expectType(store.getState().stateHas3) } } diff --git a/packages/toolkit/src/tests/configureStore.typetest.ts b/packages/toolkit/src/tests/configureStore.typetest.ts index 4f76687b45..d22bb776fd 100644 --- a/packages/toolkit/src/tests/configureStore.typetest.ts +++ b/packages/toolkit/src/tests/configureStore.typetest.ts @@ -209,6 +209,72 @@ const _anyMiddleware: any = () => () => () => {} expectType(storeWithCallback.someProperty) expectType(storeWithCallback.anotherProperty) } + + { + type StateExtendingEnhancer = StoreEnhancer<{}, { someProperty: string }> + + const someStateExtendingEnhancer: StateExtendingEnhancer = + (next) => + // @ts-expect-error how do you properly return an enhancer that extends state? + (...args) => { + const store = next(...args) + const getState = () => ({ + ...store.getState(), + someProperty: 'some value', + }) + return { + ...store, + getState, + } + } + + type AnotherStateExtendingEnhancer = StoreEnhancer< + {}, + { anotherProperty: number } + > + + const anotherStateExtendingEnhancer: AnotherStateExtendingEnhancer = + (next) => + // @ts-expect-error any input on this would be great + (...args) => { + const store = next(...args) + const getState = () => ({ + ...store.getState(), + anotherProperty: 123, + }) + return { + ...store, + getState, + } + } + + const store = configureStore({ + reducer: () => ({ aProperty: 0 }), + enhancers: [ + someStateExtendingEnhancer, + anotherStateExtendingEnhancer, + // this doesn't work without the as const + ] as const, + }) + + const state = store.getState() + + expectType(state.aProperty) + expectType(state.someProperty) + expectType(state.anotherProperty) + + const storeWithCallback = configureStore({ + reducer: () => ({ aProperty: 0 }), + enhancers: (dE) => + dE.concat(someStateExtendingEnhancer, anotherStateExtendingEnhancer), + }) + + const stateWithCallback = storeWithCallback.getState() + + expectType(stateWithCallback.aProperty) + expectType(stateWithCallback.someProperty) + expectType(stateWithCallback.anotherProperty) + } } /** diff --git a/packages/toolkit/src/tsHelpers.ts b/packages/toolkit/src/tsHelpers.ts index 93b4fc2443..5ffc0196a0 100644 --- a/packages/toolkit/src/tsHelpers.ts +++ b/packages/toolkit/src/tsHelpers.ts @@ -119,7 +119,34 @@ export type ExtractStoreExtensions = E extends EnhancerArray< ? UnionToIntersection< E[number] extends StoreEnhancer ? Ext extends {} - ? Ext + ? IsAny + : {} + : {} + > + : never + +type ExtractStateExtensionsFromEnhancerTuple< + EnhancerTuple extends any[], + Acc extends {} +> = EnhancerTuple extends [infer Head, ...infer Tail] + ? ExtractStateExtensionsFromEnhancerTuple< + Tail, + Acc & + (Head extends StoreEnhancer + ? IsAny + : {}) + > + : Acc + +export type ExtractStateExtensions = E extends EnhancerArray< + infer EnhancerTuple +> + ? ExtractStateExtensionsFromEnhancerTuple + : E extends ReadonlyArray + ? UnionToIntersection< + E[number] extends StoreEnhancer + ? StateExt extends {} + ? IsAny : {} : {} > From 5ad74aa5d97f64d4d66dd5c7c0a618eff9b0c8dc Mon Sep 17 00:00:00 2001 From: "ben.durrant" Date: Fri, 24 Feb 2023 21:09:28 +0000 Subject: [PATCH 3/3] update docs usage --- docs/api/configureStore.mdx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/api/configureStore.mdx b/docs/api/configureStore.mdx index 6f37c89a80..e3199c6660 100644 --- a/docs/api/configureStore.mdx +++ b/docs/api/configureStore.mdx @@ -18,7 +18,7 @@ to the store setup for a better development experience. ```ts no-transpile type ConfigureEnhancersCallback = ( - defaultEnhancers: StoreEnhancer[] + defaultEnhancers: EnhancerArray<[StoreEnhancer]> ) => StoreEnhancer[] interface ConfigureStoreOptions< @@ -107,7 +107,8 @@ a list of the specific options that are available. Defaults to `true`. #### `trace` -The Redux DevTools Extension recently added [support for showing action stack traces](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/Features/Trace.md) that show exactly where each action was dispatched. + +The Redux DevTools Extension recently added [support for showing action stack traces](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/Features/Trace.md) that show exactly where each action was dispatched. Capturing the traces can add a bit of overhead, so the DevTools Extension allows users to configure whether action stack traces are captured by [setting the 'trace' argument](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#trace). If the DevTools are enabled by passing `true` or an object, then `configureStore` will default to enabling capturing action stack traces in development mode only. @@ -129,7 +130,7 @@ If defined as a callback function, it will be called with the existing array of and should return a new array of enhancers. This is primarily useful for cases where a store enhancer needs to be added in front of `applyMiddleware`, such as `redux-first-router` or `redux-offline`. -Example: `enhancers: (defaultEnhancers) => [offline, ...defaultEnhancers]` will result in a final setup +Example: `enhancers: (defaultEnhancers) => defaultEnhancers.prepend(offline)` will result in a final setup of `[offline, applyMiddleware, devToolsExtension]`. ## Usage @@ -195,7 +196,7 @@ const preloadedState = { visibilityFilter: 'SHOW_COMPLETED', } -const debounceNotify = _.debounce(notify => notify()); +const debounceNotify = _.debounce((notify) => notify()) const store = configureStore({ reducer,