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, diff --git a/packages/toolkit/src/configureStore.ts b/packages/toolkit/src/configureStore.ts index aae2307c9d..f89a86089a 100644 --- a/packages/toolkit/src/configureStore.ts +++ b/packages/toolkit/src/configureStore.ts @@ -24,7 +24,9 @@ import type { NoInfer, ExtractDispatchExtensions, ExtractStoreExtensions, + ExtractStateExtensions, } from './tsHelpers' +import { EnhancerArray } from './utils' const IS_PRODUCTION = process.env.NODE_ENV === 'production' @@ -34,8 +36,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 +109,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. @@ -128,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. @@ -197,12 +200,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 1f32530046..3ea7634832 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -105,7 +105,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..56e89a30d1 --- /dev/null +++ b/packages/toolkit/src/tests/EnhancerArray.typetest.ts @@ -0,0 +1,135 @@ +import { configureStore } from '@reduxjs/toolkit' +import type { StoreEnhancer } from 'redux' + +declare const expectType: (t: T) => T + +declare const enhancer1: StoreEnhancer< + { + has1: true + }, + { stateHas1: true } +> + +declare const enhancer2: StoreEnhancer< + { + has2: true + }, + { stateHas2: true } +> + +{ + // prepend single element + { + const store = configureStore({ + reducer: () => 0, + 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) + { + const store = configureStore({ + reducer: () => 0, + 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) + { + const store = configureStore({ + reducer: () => 0, + 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 + { + const store = configureStore({ + reducer: () => 0, + 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) + { + const store = configureStore({ + reducer: () => 0, + 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) + { + const store = configureStore({ + reducer: () => 0, + 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 + { + const store = configureStore({ + reducer: () => 0, + 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.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..d22bb776fd 100644 --- a/packages/toolkit/src/tests/configureStore.typetest.ts +++ b/packages/toolkit/src/tests/configureStore.typetest.ts @@ -194,6 +194,86 @@ 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) + } + + { + 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 3e8dc20f05..5ffc0196a0 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,56 @@ 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 {} + ? 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 + : {} + : {} + > + : 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 }