diff --git a/package.json b/package.json index ead83b2590..6822b8a9bf 100644 --- a/package.json +++ b/package.json @@ -157,12 +157,16 @@ "vitest": "^4.0.14" }, "peerDependencies": { + "@redux-devtools/utils": "^3.1.1", "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { + "@redux-devtools/utils": { + "optional": true + }, "@types/react": { "optional": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34b1f69936..b13dd5bc83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@redux-devtools/utils': + specifier: ^3.1.1 + version: 3.1.1(@redux-devtools/core@4.1.1(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1))(react@19.2.0)(redux@5.0.1))(immutable@4.3.7)(redux@5.0.1) devDependencies: '@eslint/js': specifier: ^9.39.1 @@ -659,11 +663,35 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@redux-devtools/core@4.1.1': + resolution: {integrity: sha512-ZyyJwiHX4DFDU0llk45tYSFPoIMekdoKLz0Q7soowpNOtchvTxruQx4Xy//Cohkwsw+DH8W1amdo4C/NYT6ARA==} + peerDependencies: + react: ^16.8.4 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-redux: ^7.0.0 || ^8.0.0 || ^9.0.0 + redux: ^3.5.2 || ^4.0.0 || ^5.0.0 + '@redux-devtools/extension@3.3.0': resolution: {integrity: sha512-X34S/rC8S/M1BIrkYD1mJ5f8vlH0BDqxXrs96cvxSBo4FhMdbhU+GUGsmNYov1xjSyLMHgo8NYrUG8bNX7525g==} peerDependencies: redux: ^3.1.0 || ^4.0.0 || ^5.0.0 + '@redux-devtools/instrument@2.2.0': + resolution: {integrity: sha512-HKaL+ghBQ4ZQkM/kEQIKx8dNwz4E1oeiCDfdQlpPXxEi/BrisyrFFncAXb1y2HIJsLV9zSvQUR2jRtMDWgfi8w==} + peerDependencies: + redux: ^3.4.0 || ^4.0.0 || ^5.0.0 + + '@redux-devtools/serialize@0.4.2': + resolution: {integrity: sha512-YVqZCChJld5l3Ni2psEZ5loe9x5xpf9J4ckz+7OJdzCNsplC7vzjnkQbFxE6+ULZbywRVp+nSBslTXmaXqAw4A==} + peerDependencies: + immutable: ^4.0.0 + + '@redux-devtools/utils@3.1.1': + resolution: {integrity: sha512-l+m3/8a7lcxULInBADIqE/3Tt2DkTJm5MAGVA/4czMCXW0VE+gdjkoRFqgZhTBoDJW1fi1z8pdL+4G/+R1rDJw==} + peerDependencies: + '@redux-devtools/core': ^4.1.1 + immutable: ^4.3.7 + redux: ^4.0.0 || ^5.0.0 + '@rollup/plugin-alias@6.0.0': resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} engines: {node: '>=20.19.0'} @@ -878,6 +906,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/get-params@0.1.2': + resolution: {integrity: sha512-ujqPyr1UDsOTDngJPV+WFbR0iHT5AfZKlNPMX6XOCnQcMhEqR+r64dVC/nwYCitqjR3DcpWofnOEAInUQmI/eA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -898,6 +929,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/use-sync-external-store@1.5.0': resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} @@ -1646,6 +1680,9 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-params@0.1.2: + resolution: {integrity: sha512-41eOxtlGgHQRbFyA8KTH+w+32Em3cRdfBud7j67ulzmIfmaHX9doq47s0fa4P5o9H64BZX9nrYI6sJvk46Op+Q==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1931,6 +1968,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsan@3.1.14: + resolution: {integrity: sha512-wStfgOJqMv4QKktuH273f5fyi3D3vy2pHOiSDGPvpcS/q+wb/M7AK3vkCcaHbkZxDOlDU/lDJgccygKSG2OhtA==} + jsdom@27.2.0: resolution: {integrity: sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1986,6 +2026,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2059,6 +2102,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2224,6 +2272,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -3155,12 +3215,44 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@redux-devtools/core@4.1.1(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1))(react@19.2.0)(redux@5.0.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@redux-devtools/instrument': 2.2.0(redux@5.0.1) + react: 19.2.0 + react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1) + redux: 5.0.1 + '@redux-devtools/extension@3.3.0(redux@5.0.1)': dependencies: '@babel/runtime': 7.28.4 immutable: 4.3.7 redux: 5.0.1 + '@redux-devtools/instrument@2.2.0(redux@5.0.1)': + dependencies: + '@babel/runtime': 7.28.4 + lodash: 4.17.21 + redux: 5.0.1 + + '@redux-devtools/serialize@0.4.2(immutable@4.3.7)': + dependencies: + '@babel/runtime': 7.28.4 + immutable: 4.3.7 + jsan: 3.1.14 + + '@redux-devtools/utils@3.1.1(@redux-devtools/core@4.1.1(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1))(react@19.2.0)(redux@5.0.1))(immutable@4.3.7)(redux@5.0.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@redux-devtools/core': 4.1.1(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1))(react@19.2.0)(redux@5.0.1) + '@redux-devtools/serialize': 0.4.2(immutable@4.3.7) + '@types/get-params': 0.1.2 + get-params: 0.1.2 + immutable: 4.3.7 + jsan: 3.1.14 + nanoid: 5.1.6 + redux: 5.0.1 + '@rollup/plugin-alias@6.0.0(rollup@4.53.3)': optionalDependencies: rollup: 4.53.3 @@ -3315,6 +3407,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/get-params@0.1.2': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -3333,6 +3427,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/use-sync-external-store@1.5.0': {} '@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3)': @@ -4307,6 +4403,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-params@0.1.2: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -4588,6 +4686,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsan@3.1.14: {} + jsdom@27.2.0: dependencies: '@acemir/cssom': 0.9.24 @@ -4653,6 +4753,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.17.21: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -4712,6 +4814,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -4871,6 +4975,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.7 + redux: 5.0.1 + react@19.2.0: {} rechoir@0.6.2: diff --git a/src/middleware/devtools.ts b/src/middleware/devtools.ts index 4becf446d1..f4a43f6d7d 100644 --- a/src/middleware/devtools.ts +++ b/src/middleware/devtools.ts @@ -1,4 +1,5 @@ import type {} from '@redux-devtools/extension' +import type { ActionCreatorObject } from '@redux-devtools/utils'; import type { StateCreator, @@ -72,11 +73,44 @@ type StoreDevtools = S extends { } : never -export interface DevtoolsOptions extends Config { +type Join = Prefix extends "" + ? K + : `${Prefix}${K}`; + +type NextPrefix = Prefix extends "" + ? `${K}.` + : `${Prefix}${K}.`; + +type FunctionPaths = { + [K in keyof T & string]: T[K] extends (...args: any[]) => any + ? Join + : T[K] extends Record + ? FunctionPaths> extends infer P + ? P extends string + ? + | P + | `${Prefix}${K}.*` + | `${Prefix}${K}.**` + : never + : never + : never; +}[keyof T & string]; + +type ActionCreatorsMask> = { + [K in FunctionPaths | '*' | '**' | (string & {})]?: boolean | ( + // Allow overriding leafs + K extends '*' | '**' | `${string}.*` | `${string}.**` + ? never + : (...args: any[]) => void + ); +}; + +export interface DevtoolsOptions = {}> extends Omit { name?: string enabled?: boolean anonymousActionType?: string store?: string + actionCreators?: ActionCreatorsMask; } type Devtools = < @@ -86,7 +120,7 @@ type Devtools = < U = T, >( initializer: StateCreator, - devtoolsOptions?: DevtoolsOptions, + devtoolsOptions?: DevtoolsOptions, ) => StateCreator declare module '../vanilla' { @@ -131,7 +165,7 @@ const extractConnectionInformation = ( extensionConnector: NonNullable< (typeof window)['__REDUX_DEVTOOLS_EXTENSION__'] >, - options: Omit, + options: Omit, ) => { if (store === undefined) { return { @@ -200,9 +234,6 @@ const devtoolsImpl: DevtoolsImpl = return fn(set, get, api) } - const { connection, ...connectionInformation } = - extractConnectionInformation(store, extensionConnector, options) - let isRecording = true ;(api.setState as any) = ((state, replace, nameOrAction: Action) => { const r = set(state, replace as any) @@ -254,6 +285,57 @@ const devtoolsImpl: DevtoolsImpl = } const initialState = fn(api.setState, get, api) + let actionCreators: ActionCreatorObject[] = []; + let evalAction: (action: string, _actionCreators: ActionCreatorObject[]) => void | undefined; + + if (options.actionCreators) { + try { + evalAction = require('@redux-devtools/utils').evalAction; + actionCreators = getActionsArray( + initialState as Record, + options.actionCreators as ActionCreatorsMask<{}> + ); + // override to pass it to the extension connector, any is ok, we dont care about the type anymore + options.actionCreators = actionCreators as any; + } catch { + console.warn('[zustand devtools middleware] Please install @redux-devtools/utils to use actionCreators in devtools middleware'); + } + } + + const usingReduxMiddleware = + (api as any).dispatchFromDevtools && typeof (api as any).dispatch === 'function'; + if (!usingReduxMiddleware) { + (api as any).__dispatch = (payload: { + type: string, + args: unknown[] | Record + }) => { + if (!evalAction) { + console.warn('[zustand devtools middleware] Please install @redux-devtools/utils to use the action dropdown in the devtools'); + return; + } + + if (!('type' in payload)) { + console.error('[zustand devtools middleware] Payload must have a "type" property which should match an action creator'); + return; + } + + const action = actionCreators.find((action) => action.name === payload.type); + if (!action) { + console.error(`[zustand devtools middleware] Action type "${payload.type}" not found in actionCreators`); + return; + } + + if(Array.isArray(payload.args)) { + return action.func(...payload.args); + } + + return action.func(...action.args.map((arg) => (payload.args as Record ?? {})[arg])); + } + } + + // We connect after extracting action creators from the initial state + const { connection, ...connectionInformation } = extractConnectionInformation(store, extensionConnector, options); + if (connectionInformation.type === 'untracked') { connection?.init(initialState) } else { @@ -270,10 +352,7 @@ const devtoolsImpl: DevtoolsImpl = ) } - if ( - (api as any).dispatchFromDevtools && - typeof (api as any).dispatch === 'function' - ) { + if (usingReduxMiddleware) { let didWarnAboutReservedActionType = false const originalDispatch = (api as any).dispatch ;(api as any).dispatch = (...args: any[]) => { @@ -301,52 +380,91 @@ const devtoolsImpl: DevtoolsImpl = } ).subscribe((message: any) => { switch (message.type) { - case 'ACTION': + case 'ACTION': { + // When using the action dropdown we get an object with the selected action if (typeof message.payload !== 'string') { - console.error( - '[zustand devtools middleware] Unsupported action format', - ) - return + if (!evalAction) { + console.warn('[zustand devtools middleware] Please install @redux-devtools/utils to use the action dropdown in the devtools'); + return; + } + + if (usingReduxMiddleware) { + // When using the redux plugin, we dispatch the action ourselves. + const action = evalAction(message.payload, actionCreators as any[]); + return (api as any).dispatch(action); + } + + // When not using the `redux` middleware, action creators are expected + // to cause the state change themselves. + return evalAction(message.payload, actionCreators as any[]); } - return parseJsonThen<{ type: unknown; state?: PartialState }>( - message.payload, - (action) => { - if (action.type === '__setState') { - if (store === undefined) { - setStateFromDevtools(action.state as PartialState) - return - } - if (Object.keys(action.state as S).length !== 1) { - console.error( - ` - [zustand devtools middleware] Unsupported __setState action format. - When using 'store' option in devtools(), the 'state' should have only one key, which is a value of 'store' that was passed in devtools(), - and value of this only key should be a state object. Example: { "type": "__setState", "state": { "abc123Store": { "foo": "bar" } } } - `, - ) - } - const stateFromDevtools = (action.state as S)[store] - if ( - stateFromDevtools === undefined || - stateFromDevtools === null - ) { - return - } - if ( - JSON.stringify(api.getState()) !== - JSON.stringify(stateFromDevtools) - ) { - setStateFromDevtools(stateFromDevtools) - } - return - } - if (!(api as any).dispatchFromDevtools) return - if (typeof (api as any).dispatch !== 'function') return - ;(api as any).dispatch(action) - }, - ) + let action: { type: string, state?: PartialState, [key: string]: unknown }; + try { + action = JSON.parse(message.payload); + } catch { + let errorMessage = `[zustand devtools middleware] Malformed JSON. When dispatching custom actions, please format the payload as a JSON string. + Examples:`; + if (usingReduxMiddleware) { + errorMessage += ` + - { "type": "increment", "amount": 1 } + - { "type": "reset" } + `; + } else { + errorMessage += ` + - { "type": "increment" } + - { "type": "increment", "args": [1] } + If you have args with default values or want to be \`undefined\`, prefer using the object syntax so you can omit them. + - { "type": "increment", "args": { "amount": 1 } } + `; + } + console.error(errorMessage.replace(/\n\s+/g, '\n')); + return; + } + + if (action.type === '__setState') { + if (store === undefined) { + if (!('state' in action)) { + console.warn( + ` + [zustand devtools middleware] Calling __setState without a state property. + This may not be what you intended, you should explicitly set the state you want to set. + Example: { "type": "__setState", "state": { "foo": "bar" } } + `.replace(/\n\s+/g, '\n'), + ); + return; + } + setStateFromDevtools(action.state as PartialState) + return + } + if (Object.keys(action.state as S).length !== 1) { + console.error( + ` + [zustand devtools middleware] Unsupported __setState action format. + When using 'store' option in devtools(), the 'state' should have only one key, which is a value of 'store' that was passed in devtools(), + and value of this only key should be a state object. Example: { "type": "__setState", "state": { "abc123Store": { "foo": "bar" } } } + `.replace(/\n\s+/g, '\n'), + ) + } + const stateFromDevtools = (action.state as S)[store] + if ( + stateFromDevtools === undefined || + stateFromDevtools === null + ) { + return + } + if ( + JSON.stringify(api.getState()) !== + JSON.stringify(stateFromDevtools) + ) { + setStateFromDevtools(stateFromDevtools) + } + return + } + if (!usingReduxMiddleware) return (api as any).__dispatch(action); + return (api as any).dispatch(action); + } case 'DISPATCH': switch (message.payload.type) { case 'RESET': @@ -429,3 +547,65 @@ const parseJsonThen = (stringified: string, fn: (parsed: T) => void) => { } if (parsed !== undefined) fn(parsed as T) } + +function maskToRegex(mask: string): RegExp { + const escaped = mask + .replace(/\./g, '\\.') + .replace(/\*\*|\*/g, '.*'); + + return new RegExp(`^${escaped}$`); +} + +function getActionsArray>(actionCreators: T, mask: ActionCreatorsMask): ActionCreatorObject[] { + const getActions = require('@redux-devtools/utils').getActionsArray; + const flat = getActions(actionCreators); + const actions = new Map(); + const customActions = new Map( + getActions(mask).map((action: ActionCreatorObject) => [action.name, action]) + ); + + // Sort matchers by specificity + const matchers = Object.entries(mask ?? {}) + .map(([key, picked]) => ({ + matcher: maskToRegex(key), + picked, + key, + })).toSorted((a, b) => { + const aIsDoubleWildcard = a.key.includes('**'); + const bIsDoubleWildcard = b.key.includes('**'); + if (aIsDoubleWildcard !== bIsDoubleWildcard) { + return aIsDoubleWildcard ? -1 : 1; + } + + const aIsSingleWildcard = a.key.includes('*') && !aIsDoubleWildcard; + const bIsSingleWildcard = b.key.includes('*') && !bIsDoubleWildcard; + if (aIsSingleWildcard !== bIsSingleWildcard) { + return aIsSingleWildcard ? -1 : 1; + } + + return a.key.length - b.key.length; + }); + + for (const action of flat) { + for (const { matcher, picked } of matchers) { + if (matcher.test(action.name)) { + if (picked === false) { + actions.delete(action.name); + continue; + } + + actions.set(action.name, customActions.get(action.name) ?? { + name: action.name, + func: typeof picked === 'function' ? picked : action.func, + args: action.args, + }); + } + } + } + + for (const [name, action] of customActions.entries()) { + actions.set(name, action); + } + + return Array.from(actions.values()); +}