From adb461d3057e00e46a7a170e1f194fa7595cc5fe Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Sat, 11 Nov 2023 11:34:56 +0000 Subject: [PATCH 1/8] Require calling buildCreateSlice to use create.asyncThunk --- packages/toolkit/src/createSlice.ts | 364 ++++++++++-------- packages/toolkit/src/index.ts | 2 + .../toolkit/src/tests/createSlice.test.ts | 22 +- .../toolkit/src/tests/createSlice.typetest.ts | 23 +- 4 files changed, 239 insertions(+), 172 deletions(-) diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index bc43c15ad4..4c92da4ca9 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -24,7 +24,15 @@ import type { AsyncThunkPayloadCreator, OverrideThunkApiConfigs, } from './createAsyncThunk' -import { createAsyncThunk } from './createAsyncThunk' +import { createAsyncThunk as _createAsyncThunk } from './createAsyncThunk' + +const asyncThunkSymbol = Symbol.for('rtk-slice-createasyncthunk') +// type is annotated because it's too long to infer +export const asyncThunkCreator: { + [asyncThunkSymbol]: typeof _createAsyncThunk +} = { + [asyncThunkSymbol]: _createAsyncThunk, +} interface InjectIntoConfig extends InjectConfig { reducerPath?: NewReducerPath @@ -569,6 +577,12 @@ function getType(slice: string, actionKey: string): string { return `${slice}/${actionKey}` } +interface BuildCreateSliceConfig { + creators?: { + asyncThunk?: typeof asyncThunkCreator + } +} + /** * A function that accepts an initial state, an object full of reducer * functions, and a "slice name", and automatically generates @@ -577,192 +591,204 @@ function getType(slice: string, actionKey: string): string { * * @public */ -export function createSlice< - State, - CaseReducers extends SliceCaseReducers, - Name extends string, - Selectors extends SliceSelectors, - ReducerPath extends string = Name ->( - options: CreateSliceOptions -): Slice { - const { name, reducerPath = name as unknown as ReducerPath } = options - if (!name) { - throw new Error('`name` is a required option for createSlice') - } - - if ( - typeof process !== 'undefined' && - process.env.NODE_ENV === 'development' - ) { - if (options.initialState === undefined) { - console.error( - 'You must provide an `initialState` value that is not `undefined`. You may have misspelled `initialState`' - ) +export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { + const cAT = creators?.asyncThunk?.[asyncThunkSymbol] + return function createSlice< + State, + CaseReducers extends SliceCaseReducers, + Name extends string, + Selectors extends SliceSelectors, + ReducerPath extends string = Name + >( + options: CreateSliceOptions< + State, + CaseReducers, + Name, + ReducerPath, + Selectors + > + ): Slice { + const { name, reducerPath = name as unknown as ReducerPath } = options + if (!name) { + throw new Error('`name` is a required option for createSlice') } - } - const reducers = - (typeof options.reducers === 'function' - ? options.reducers(buildReducerCreators()) - : options.reducers) || {} + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV === 'development' + ) { + if (options.initialState === undefined) { + console.error( + 'You must provide an `initialState` value that is not `undefined`. You may have misspelled `initialState`' + ) + } + } - const reducerNames = Object.keys(reducers) + const reducers = + (typeof options.reducers === 'function' + ? options.reducers(buildReducerCreators()) + : options.reducers) || {} - const context: ReducerHandlingContext = { - sliceCaseReducersByName: {}, - sliceCaseReducersByType: {}, - actionCreators: {}, - sliceMatchers: [], - } + const reducerNames = Object.keys(reducers) - reducerNames.forEach((reducerName) => { - const reducerDefinition = reducers[reducerName] - const reducerDetails: ReducerDetails = { - reducerName, - type: getType(name, reducerName), - createNotation: typeof options.reducers === 'function', - } - if (isAsyncThunkSliceReducerDefinition(reducerDefinition)) { - handleThunkCaseReducerDefinition( - reducerDetails, - reducerDefinition, - context - ) - } else { - handleNormalReducerDefinition( - reducerDetails, - reducerDefinition, - context - ) + const context: ReducerHandlingContext = { + sliceCaseReducersByName: {}, + sliceCaseReducersByType: {}, + actionCreators: {}, + sliceMatchers: [], } - }) - function buildReducer() { - if (process.env.NODE_ENV !== 'production') { - if (typeof options.extraReducers === 'object') { - throw new Error( - "The object notation for `createSlice.extraReducers` has been removed. Please use the 'builder callback' notation instead: https://redux-toolkit.js.org/api/createSlice" + reducerNames.forEach((reducerName) => { + const reducerDefinition = reducers[reducerName] + const reducerDetails: ReducerDetails = { + reducerName, + type: getType(name, reducerName), + createNotation: typeof options.reducers === 'function', + } + if (isAsyncThunkSliceReducerDefinition(reducerDefinition)) { + handleThunkCaseReducerDefinition( + reducerDetails, + reducerDefinition, + context, + cAT + ) + } else { + handleNormalReducerDefinition( + reducerDetails, + reducerDefinition, + context ) } - } - const [ - extraReducers = {}, - actionMatchers = [], - defaultCaseReducer = undefined, - ] = - typeof options.extraReducers === 'function' - ? executeReducerBuilderCallback(options.extraReducers) - : [options.extraReducers] - - const finalCaseReducers = { - ...extraReducers, - ...context.sliceCaseReducersByType, - } + }) - return createReducer(options.initialState, (builder) => { - for (let key in finalCaseReducers) { - builder.addCase(key, finalCaseReducers[key] as CaseReducer) - } - for (let sM of context.sliceMatchers) { - builder.addMatcher(sM.matcher, sM.reducer) - } - for (let m of actionMatchers) { - builder.addMatcher(m.matcher, m.reducer) + function buildReducer() { + if (process.env.NODE_ENV !== 'production') { + if (typeof options.extraReducers === 'object') { + throw new Error( + "The object notation for `createSlice.extraReducers` has been removed. Please use the 'builder callback' notation instead: https://redux-toolkit.js.org/api/createSlice" + ) + } } - if (defaultCaseReducer) { - builder.addDefaultCase(defaultCaseReducer) + const [ + extraReducers = {}, + actionMatchers = [], + defaultCaseReducer = undefined, + ] = + typeof options.extraReducers === 'function' + ? executeReducerBuilderCallback(options.extraReducers) + : [options.extraReducers] + + const finalCaseReducers = { + ...extraReducers, + ...context.sliceCaseReducersByType, } - }) - } - - const selectSelf = (state: State) => state - - const injectedSelectorCache = new WeakMap< - Slice, - WeakMap< - (rootState: any) => State | undefined, - Record any> - > - >() - let _reducer: ReducerWithInitialState - - const slice: Slice = { - name, - reducerPath, - reducer(state, action) { - if (!_reducer) _reducer = buildReducer() + return createReducer(options.initialState, (builder) => { + for (let key in finalCaseReducers) { + builder.addCase(key, finalCaseReducers[key] as CaseReducer) + } + for (let sM of context.sliceMatchers) { + builder.addMatcher(sM.matcher, sM.reducer) + } + for (let m of actionMatchers) { + builder.addMatcher(m.matcher, m.reducer) + } + if (defaultCaseReducer) { + builder.addDefaultCase(defaultCaseReducer) + } + }) + } - return _reducer(state, action) - }, - actions: context.actionCreators as any, - caseReducers: context.sliceCaseReducersByName as any, - getInitialState() { - if (!_reducer) _reducer = buildReducer() + const selectSelf = (state: State) => state - return _reducer.getInitialState() - }, - getSelectors(selectState: (rootState: any) => State = selectSelf) { - let selectorCache = injectedSelectorCache.get(this) - if (!selectorCache) { - selectorCache = new WeakMap() - injectedSelectorCache.set(this, selectorCache) - } - let cached = selectorCache.get(selectState) - if (!cached) { - cached = {} - for (const [name, selector] of Object.entries( - options.selectors ?? {} - )) { - cached[name] = (rootState: any, ...args: any[]) => { - let sliceState = selectState.call(this, rootState) - if (typeof sliceState === 'undefined') { - // check if injectInto has been called - if (this !== slice) { - sliceState = this.getInitialState() - } else if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'selectState returned undefined for an uninjected slice reducer' - ) + const injectedSelectorCache = new WeakMap< + Slice, + WeakMap< + (rootState: any) => State | undefined, + Record any> + > + >() + + let _reducer: ReducerWithInitialState + + const slice: Slice = { + name, + reducerPath, + reducer(state, action) { + if (!_reducer) _reducer = buildReducer() + + return _reducer(state, action) + }, + actions: context.actionCreators as any, + caseReducers: context.sliceCaseReducersByName as any, + getInitialState() { + if (!_reducer) _reducer = buildReducer() + + return _reducer.getInitialState() + }, + getSelectors(selectState: (rootState: any) => State = selectSelf) { + let selectorCache = injectedSelectorCache.get(this) + if (!selectorCache) { + selectorCache = new WeakMap() + injectedSelectorCache.set(this, selectorCache) + } + let cached = selectorCache.get(selectState) + if (!cached) { + cached = {} + for (const [name, selector] of Object.entries( + options.selectors ?? {} + )) { + cached[name] = (rootState: any, ...args: any[]) => { + let sliceState = selectState.call(this, rootState) + if (typeof sliceState === 'undefined') { + // check if injectInto has been called + if (this !== slice) { + sliceState = this.getInitialState() + } else if (process.env.NODE_ENV !== 'production') { + throw new Error( + 'selectState returned undefined for an uninjected slice reducer' + ) + } } + return selector(sliceState, ...args) } - return selector(sliceState, ...args) } + selectorCache.set(selectState, cached) } - selectorCache.set(selectState, cached) - } - return cached as any - }, - selectSlice(state) { - let sliceState = state[this.reducerPath] - if (typeof sliceState === 'undefined') { - // check if injectInto has been called - if (this !== slice) { - sliceState = this.getInitialState() - } else if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'selectSlice returned undefined for an uninjected slice reducer' - ) + return cached as any + }, + selectSlice(state) { + let sliceState = state[this.reducerPath] + if (typeof sliceState === 'undefined') { + // check if injectInto has been called + if (this !== slice) { + sliceState = this.getInitialState() + } else if (process.env.NODE_ENV !== 'production') { + throw new Error( + 'selectSlice returned undefined for an uninjected slice reducer' + ) + } } - } - return sliceState - }, - get selectors() { - return this.getSelectors(this.selectSlice) - }, - injectInto(injectable, { reducerPath: pathOpt, ...config } = {}) { - const reducerPath = pathOpt ?? this.reducerPath - injectable.inject({ reducerPath, reducer: this.reducer }, config) - return { - ...this, - reducerPath, - } as any - }, + return sliceState + }, + get selectors() { + return this.getSelectors(this.selectSlice) + }, + injectInto(injectable, { reducerPath: pathOpt, ...config } = {}) { + const reducerPath = pathOpt ?? this.reducerPath + injectable.inject({ reducerPath, reducer: this.reducer }, config) + return { + ...this, + reducerPath, + } as any + }, + } + return slice } - return slice } +export const createSlice = buildCreateSlice() + interface ReducerHandlingContext { sliceCaseReducersByName: Record< string, @@ -868,11 +894,17 @@ function isCaseReducerWithPrepareDefinition( function handleThunkCaseReducerDefinition( { type, reducerName }: ReducerDetails, reducerDefinition: AsyncThunkSliceReducerDefinition, - context: ReducerHandlingContext + context: ReducerHandlingContext, + cAT: typeof _createAsyncThunk | undefined ) { + if (!cAT) { + throw new Error( + 'Cannot use create.asyncThunk without custom initialisation' + ) + } const { payloadCreator, fulfilled, pending, rejected, settled, options } = reducerDefinition - const thunk = createAsyncThunk(type, payloadCreator, options as any) + const thunk = cAT(type, payloadCreator, options as any) context.actionCreators[reducerName] = thunk if (fulfilled) { diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 2b8fc18ad2..b43d3e8777 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -70,6 +70,8 @@ export type { export { // js createSlice, + buildCreateSlice, + asyncThunkCreator, ReducerType, } from './createSlice' diff --git a/packages/toolkit/src/tests/createSlice.test.ts b/packages/toolkit/src/tests/createSlice.test.ts index 4755a4e5dc..117ea8f92e 100644 --- a/packages/toolkit/src/tests/createSlice.test.ts +++ b/packages/toolkit/src/tests/createSlice.test.ts @@ -1,6 +1,8 @@ import { vi } from 'vitest' import type { PayloadAction, WithSlice } from '@reduxjs/toolkit' import { + asyncThunkCreator, + buildCreateSlice, configureStore, combineSlices, createSlice, @@ -571,6 +573,18 @@ describe('createSlice', () => { }) }) describe('reducers definition with asyncThunks', () => { + it('is disabled by default', () => { + expect(() => + createSlice({ + name: 'test', + initialState: [] as any[], + reducers: (create) => ({ thunk: create.asyncThunk(() => {}) }), + }) + ).toThrowErrorMatchingInlineSnapshot('"Cannot use create.asyncThunk without custom initialisation"') + }) + const createThunkSlice = buildCreateSlice({ + creators: { asyncThunk: asyncThunkCreator }, + }) function pending(state: any[], action: any) { state.push(['pendingReducer', action]) } @@ -585,7 +599,7 @@ describe('createSlice', () => { } test('successful thunk', async () => { - const slice = createSlice({ + const slice = createThunkSlice({ name: 'test', initialState: [] as any[], reducers: (create) => ({ @@ -628,7 +642,7 @@ describe('createSlice', () => { }) test('rejected thunk', async () => { - const slice = createSlice({ + const slice = createThunkSlice({ name: 'test', initialState: [] as any[], reducers: (create) => ({ @@ -672,7 +686,7 @@ describe('createSlice', () => { }) test('with options', async () => { - const slice = createSlice({ + const slice = createThunkSlice({ name: 'test', initialState: [] as any[], reducers: (create) => ({ @@ -721,7 +735,7 @@ describe('createSlice', () => { }) test('has caseReducers for the asyncThunk', async () => { - const slice = createSlice({ + const slice = createThunkSlice({ name: 'test', initialState: [], reducers: (create) => ({ diff --git a/packages/toolkit/src/tests/createSlice.typetest.ts b/packages/toolkit/src/tests/createSlice.typetest.ts index 94ad5b773c..de0e781c1d 100644 --- a/packages/toolkit/src/tests/createSlice.typetest.ts +++ b/packages/toolkit/src/tests/createSlice.typetest.ts @@ -16,8 +16,15 @@ import type { ThunkDispatch, ValidateSliceCaseReducers, } from '@reduxjs/toolkit' -import { configureStore, isRejected } from '@reduxjs/toolkit' -import { createAction, createSlice } from '@reduxjs/toolkit' +import { + configureStore, + isRejected, + createAction, + createSlice, + buildCreateSlice, + asyncThunkCreator, + createAsyncThunk, +} from '@reduxjs/toolkit' import { expectExactType, expectType, expectUnknown } from './helpers' import { castDraft } from 'immer' @@ -849,3 +856,15 @@ const value = actionCreators.anyKey // @ts-expect-error counterSlice.selectSlice({}) } + +/** + * Test: buildCreateSlice + */ +{ + expectType(buildCreateSlice()) + buildCreateSlice({ + // @ts-expect-error not possible to recreate shape because symbol is not exported + creators: { asyncThunk: { [Symbol()]: createAsyncThunk } }, + }) + buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } }) +} From 31c8a93aae61d503f94d9fe9adec9ed0aa33a1a4 Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Sat, 11 Nov 2023 11:53:54 +0000 Subject: [PATCH 2/8] update docs --- docs/api/createSlice.mdx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/api/createSlice.mdx b/docs/api/createSlice.mdx index 45995fd752..18809c08da 100644 --- a/docs/api/createSlice.mdx +++ b/docs/api/createSlice.mdx @@ -138,7 +138,7 @@ const todosSlice = createSlice({ Alternatively, the `reducers` field can be a callback which receives a "create" object. -The main benefit of this is that you can create [async thunks](./createAsyncThunk) as part of your slice. Types are also slightly simplified for prepared reducers. +The main benefit of this is that you can create [async thunks](./createAsyncThunk) as part of your slice (though for bundle size reasons, you [need a bit of setup for this](#createasyncthunk)). Types are also slightly simplified for prepared reducers. ```ts title="Creator callback for reducers" import { createSlice, nanoid } from '@reduxjs/toolkit' @@ -240,6 +240,27 @@ create.preparedReducer( Creates an async thunk instead of an action creator. +:::warning Setup + +To avoid pulling `createAsyncThunk` into the bundle size of `createSlice` by default, some extra setup is required to use `create.asyncThunk`. + +The version of `createSlice` exported from RTK will throw an error if `create.asyncThunk` is called. + +Instead, import `buildCreateSlice` and `asyncThunkCreator`, and create your own version of `createSlice`: + +```ts +import { buildCreateSlice, asyncThunkCreator } from '@reduxjs/toolkit' + +// name is up to you +export const createSliceWithThunks = buildCreateSlice({ + creators: { asyncThunk: asyncThunkCreator }, +}) +``` + +Then import this `createSlice` as needed instead of the exported version from RTK. + +::: + **Parameters** - **payloadCreator** The thunk [payload creator](./createAsyncThunk#payloadcreator). From 68a85f2c5e290482fcee93b72a5acf87fbb8bed8 Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Sat, 11 Nov 2023 11:58:35 +0000 Subject: [PATCH 3/8] move JSDoc --- packages/toolkit/src/createSlice.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index 4c92da4ca9..f8f68684eb 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -583,14 +583,6 @@ interface BuildCreateSliceConfig { } } -/** - * A function that accepts an initial state, an object full of reducer - * functions, and a "slice name", and automatically generates - * action creators and action types that correspond to the - * reducers and state. - * - * @public - */ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { const cAT = creators?.asyncThunk?.[asyncThunkSymbol] return function createSlice< @@ -787,6 +779,14 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { } } +/** + * A function that accepts an initial state, an object full of reducer + * functions, and a "slice name", and automatically generates + * action creators and action types that correspond to the + * reducers and state. + * + * @public + */ export const createSlice = buildCreateSlice() interface ReducerHandlingContext { From 1cf1601040754ae77c45107ee458255ec85e2a0e Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Sat, 11 Nov 2023 12:10:37 +0000 Subject: [PATCH 4/8] expectExactType --- packages/toolkit/src/tests/createSlice.typetest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolkit/src/tests/createSlice.typetest.ts b/packages/toolkit/src/tests/createSlice.typetest.ts index de0e781c1d..90be16d4fd 100644 --- a/packages/toolkit/src/tests/createSlice.typetest.ts +++ b/packages/toolkit/src/tests/createSlice.typetest.ts @@ -861,7 +861,7 @@ const value = actionCreators.anyKey * Test: buildCreateSlice */ { - expectType(buildCreateSlice()) + expectExactType(createSlice)(buildCreateSlice()) buildCreateSlice({ // @ts-expect-error not possible to recreate shape because symbol is not exported creators: { asyncThunk: { [Symbol()]: createAsyncThunk } }, From 1890c5428b53fb5eb40415162b8acb10328e19d8 Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Sun, 12 Nov 2023 23:43:43 +0000 Subject: [PATCH 5/8] update error message --- errors.json | 3 ++- packages/toolkit/src/createSlice.ts | 3 ++- packages/toolkit/src/tests/createSlice.test.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/errors.json b/errors.json index ad527b78d8..c18837426e 100644 --- a/errors.json +++ b/errors.json @@ -33,5 +33,6 @@ "31": "\"middleware\" field must be a callback", "32": "When using custom hooks for context, all hooks need to be provided: .\\nHook was either not provided or not a function.", "33": "Existing Redux context detected. If you already have a store set up, please use the traditional Redux setup.", - "34": "selectSlice returned undefined for an uninjected slice reducer" + "34": "selectSlice returned undefined for an uninjected slice reducer", + "35": "Cannot use `create.asyncThunk` in the built-in `createSlice`. Use `buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })` to create a customised version of `createSlice`." } \ No newline at end of file diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index f8f68684eb..9c20e89ec1 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -899,7 +899,8 @@ function handleThunkCaseReducerDefinition( ) { if (!cAT) { throw new Error( - 'Cannot use create.asyncThunk without custom initialisation' + 'Cannot use `create.asyncThunk` in the built-in `createSlice`. ' + + 'Use `buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })` to create a customised version of `createSlice`.' ) } const { payloadCreator, fulfilled, pending, rejected, settled, options } = diff --git a/packages/toolkit/src/tests/createSlice.test.ts b/packages/toolkit/src/tests/createSlice.test.ts index 117ea8f92e..496a011e95 100644 --- a/packages/toolkit/src/tests/createSlice.test.ts +++ b/packages/toolkit/src/tests/createSlice.test.ts @@ -580,7 +580,7 @@ describe('createSlice', () => { initialState: [] as any[], reducers: (create) => ({ thunk: create.asyncThunk(() => {}) }), }) - ).toThrowErrorMatchingInlineSnapshot('"Cannot use create.asyncThunk without custom initialisation"') + ).toThrowErrorMatchingInlineSnapshot('"Cannot use `create.asyncThunk` in the built-in `createSlice`. Use `buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })` to create a customised version of `createSlice`."') }) const createThunkSlice = buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator }, From 8f6cea6791955c2239dc432940d26e1bafcd832d Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Tue, 14 Nov 2023 13:50:52 +0000 Subject: [PATCH 6/8] Create standardised methods of modifying reducer handler context. --- packages/toolkit/src/createSlice.ts | 150 ++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 18 deletions(-) diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index 9c20e89ec1..d0699eb5d8 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -13,9 +13,9 @@ import type { ReducerWithInitialState, } from './createReducer' import { createReducer } from './createReducer' -import type { ActionReducerMapBuilder } from './mapBuilders' +import type { ActionReducerMapBuilder, TypedActionCreator } from './mapBuilders' import { executeReducerBuilderCallback } from './mapBuilders' -import type { Id, Tail } from './tsHelpers' +import type { Id, Tail, TypeGuard } from './tsHelpers' import type { InjectConfig } from './combineSlices' import type { AsyncThunk, @@ -630,6 +630,43 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { sliceMatchers: [], } + const contextMethods: ReducerHandlingContextMethods = { + addCase( + typeOrActionCreator: string | TypedActionCreator, + reducer: CaseReducer + ) { + const type = + typeof typeOrActionCreator === 'string' + ? typeOrActionCreator + : typeOrActionCreator.type + if (!type) { + throw new Error( + '`context.addCase` cannot be called with an empty action type' + ) + } + if (type in context.sliceCaseReducersByType) { + throw new Error( + '`context.addCase` cannot be called with two reducers for the same action type: ' + + type + ) + } + context.sliceCaseReducersByType[type] = reducer + return contextMethods + }, + addMatcher(matcher, reducer) { + context.sliceMatchers.push({ matcher, reducer }) + return contextMethods + }, + exposeAction(name, actionCreator) { + context.actionCreators[name] = actionCreator + return contextMethods + }, + exposeCaseReducer(name, reducer) { + context.sliceCaseReducersByName[name] = reducer + return contextMethods + }, + } + reducerNames.forEach((reducerName) => { const reducerDefinition = reducers[reducerName] const reducerDetails: ReducerDetails = { @@ -641,14 +678,14 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { handleThunkCaseReducerDefinition( reducerDetails, reducerDefinition, - context, + contextMethods, cAT ) } else { handleNormalReducerDefinition( reducerDetails, reducerDefinition, - context + contextMethods ) } }) @@ -803,9 +840,84 @@ interface ReducerHandlingContext { actionCreators: Record } +interface ReducerHandlingContextMethods { + /** + * Adds a case reducer to handle a single action type. + * @param actionCreator - Either a plain action type string, or an action creator generated by [`createAction`](./createAction) that can be used to determine the action type. + * @param reducer - The actual case reducer function. + */ + addCase>( + actionCreator: ActionCreator, + reducer: CaseReducer> + ): ReducerHandlingContextMethods + /** + * Adds a case reducer to handle a single action type. + * @param actionCreator - Either a plain action type string, or an action creator generated by [`createAction`](./createAction) that can be used to determine the action type. + * @param reducer - The actual case reducer function. + */ + addCase>( + type: Type, + reducer: CaseReducer + ): ReducerHandlingContextMethods + + /** + * Allows you to match incoming actions against your own filter function instead of only the `action.type` property. + * @remarks + * If multiple matcher reducers match, all of them will be executed in the order + * they were defined in - even if a case reducer already matched. + * All calls to `builder.addMatcher` must come after any calls to `builder.addCase` and before any calls to `builder.addDefaultCase`. + * @param matcher - A matcher function. In TypeScript, this should be a [type predicate](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) + * function + * @param reducer - The actual case reducer function. + * + */ + addMatcher( + matcher: TypeGuard, + reducer: CaseReducer + ): ReducerHandlingContextMethods + /** + * Add an action to be exposed under the final `slice.actions` key. + * @param name The key to be exposed as. + * @param actionCreator The action to expose. + * @example + * context.exposeAction("addPost", createAction("addPost")); + * + * export const { addPost } = slice.actions + * + * dispatch(addPost(post)) + */ + exposeAction( + name: string, + actionCreator: Function + ): ReducerHandlingContextMethods + /** + * Add a case reducer to be exposed under the final `slice.caseReducers` key. + * @param name The key to be exposed as. + * @param reducer The reducer to expose. + * @example + * context.exposeCaseReducer("addPost", (state, action: PayloadAction) => { + * state.push(action.payload) + * }) + * + * slice.caseReducers.addPost([], addPost(post)) + */ + exposeCaseReducer( + name: string, + reducer: + | CaseReducer + | Pick< + AsyncThunkSliceReducerDefinition, + 'fulfilled' | 'rejected' | 'pending' | 'settled' + > + ): ReducerHandlingContextMethods +} + interface ReducerDetails { + /** The key the reducer was defined under */ reducerName: string + /** The predefined action type, i.e. `${slice.name}/${reducerName}` */ type: string + /** Whether create. notation was used when defining reducers */ createNotation: boolean } @@ -852,7 +964,7 @@ function handleNormalReducerDefinition( maybeReducerWithPrepare: | CaseReducer | CaseReducerWithPrepare>, - context: ReducerHandlingContext + context: ReducerHandlingContextMethods ) { let caseReducer: CaseReducer let prepareCallback: PrepareAction | undefined @@ -870,11 +982,13 @@ function handleNormalReducerDefinition( } else { caseReducer = maybeReducerWithPrepare } - context.sliceCaseReducersByName[reducerName] = caseReducer - context.sliceCaseReducersByType[type] = caseReducer - context.actionCreators[reducerName] = prepareCallback - ? createAction(type, prepareCallback) - : createAction(type) + context + .addCase(type, caseReducer) + .exposeCaseReducer(reducerName, caseReducer) + .exposeAction( + reducerName, + prepareCallback ? createAction(type, prepareCallback) : createAction(type) + ) } function isAsyncThunkSliceReducerDefinition( @@ -894,7 +1008,7 @@ function isCaseReducerWithPrepareDefinition( function handleThunkCaseReducerDefinition( { type, reducerName }: ReducerDetails, reducerDefinition: AsyncThunkSliceReducerDefinition, - context: ReducerHandlingContext, + context: ReducerHandlingContextMethods, cAT: typeof _createAsyncThunk | undefined ) { if (!cAT) { @@ -906,27 +1020,27 @@ function handleThunkCaseReducerDefinition( const { payloadCreator, fulfilled, pending, rejected, settled, options } = reducerDefinition const thunk = cAT(type, payloadCreator, options as any) - context.actionCreators[reducerName] = thunk + context.exposeAction(reducerName, thunk) if (fulfilled) { - context.sliceCaseReducersByType[thunk.fulfilled.type] = fulfilled + context.addCase(thunk.fulfilled, fulfilled) } if (pending) { - context.sliceCaseReducersByType[thunk.pending.type] = pending + context.addCase(thunk.pending, pending) } if (rejected) { - context.sliceCaseReducersByType[thunk.rejected.type] = rejected + context.addCase(thunk.rejected, rejected) } if (settled) { - context.sliceMatchers.push({ matcher: thunk.settled, reducer: settled }) + context.addMatcher(thunk.settled, settled) } - context.sliceCaseReducersByName[reducerName] = { + context.exposeCaseReducer(reducerName, { fulfilled: fulfilled || noop, pending: pending || noop, rejected: rejected || noop, settled: settled || noop, - } + }) } function noop() {} From ea2bdec42165710835e77a56d9d41b9b1b5d29d6 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 14 Nov 2023 12:26:37 -0500 Subject: [PATCH 7/8] Update errors.json --- errors.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/errors.json b/errors.json index c18837426e..daa3c06132 100644 --- a/errors.json +++ b/errors.json @@ -34,5 +34,7 @@ "32": "When using custom hooks for context, all hooks need to be provided: .\\nHook was either not provided or not a function.", "33": "Existing Redux context detected. If you already have a store set up, please use the traditional Redux setup.", "34": "selectSlice returned undefined for an uninjected slice reducer", - "35": "Cannot use `create.asyncThunk` in the built-in `createSlice`. Use `buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })` to create a customised version of `createSlice`." + "35": "Cannot use `create.asyncThunk` in the built-in `createSlice`. Use `buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })` to create a customised version of `createSlice`.", + "36": "`context.addCase` cannot be called with an empty action type", + "37": "`context.addCase` cannot be called with two reducers for the same action type: type" } \ No newline at end of file From a385388073cd9f39ef00aa04cb09f470d3363251 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 14 Nov 2023 12:26:51 -0500 Subject: [PATCH 8/8] Mark `cAT` with `PURE` to ensure tree-shaking --- packages/toolkit/src/createAsyncThunk.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index e8a28cbbb3..f75653bda9 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -487,7 +487,7 @@ type CreateAsyncThunk = { > } -export const createAsyncThunk = (() => { +export const createAsyncThunk = /* @__PURE__ */ (() => { function createAsyncThunk< Returned, ThunkArg,