@omnimod/plugin-redux-to-toolkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.mjs +246 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 salnika
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @omnimod/plugin-redux-to-toolkit
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@omnimod/plugin-redux-to-toolkit)
|
|
4
|
+
[](https://github.com/Salnika/omnimod/actions/workflows/ci.yml)
|
|
5
|
+
[](../../LICENSE)
|
|
6
|
+
|
|
7
|
+
omnimod plugin that migrates legacy Redux store setup to Redux Toolkit.
|
|
8
|
+
|
|
9
|
+
## Use With The CLI
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
omnimod run redux-to-toolkit "src/**/*.{ts,tsx,js,jsx}"
|
|
13
|
+
omnimod run redux-to-toolkit "src/**/*.{ts,tsx,js,jsx}" --write
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Use As A Library
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { reduxToToolkit } from "@omnimod/plugin-redux-to-toolkit";
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Notes
|
|
23
|
+
|
|
24
|
+
The plugin rewrites supported `createStore` usage to `configureStore` and updates
|
|
25
|
+
imports. Reducer rewrites, middleware-specific behavior, and connected component
|
|
26
|
+
patterns may need manual follow-up.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/plugin.d.ts
|
|
2
|
+
type ReduxToToolkitOptions = Record<string, unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* Migrate legacy Redux to Redux Toolkit. Rewrites `redux` imports to
|
|
5
|
+
* `@reduxjs/toolkit` (re-exported helpers) and `createStore`/`legacy_createStore`
|
|
6
|
+
* to `configureStore`, rewriting `createStore(rootReducer[, preloadedState])`
|
|
7
|
+
* call sites into `configureStore({ reducer, preloadedState })`. Semantic
|
|
8
|
+
* migrations (switch reducers, `connect`, action creators, saga/thunk) are left
|
|
9
|
+
* untouched with a `warn` diagnostic and a `// TODO(omnimod)` breadcrumb.
|
|
10
|
+
*/
|
|
11
|
+
declare const reduxToToolkit: import("@omnimod/core").Plugin<ReduxToToolkitOptions, unknown>;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { type ReduxToToolkitOptions, reduxToToolkit as default, reduxToToolkit };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { definePlugin, walk } from "@omnimod/core";
|
|
2
|
+
import { cast } from "@omnimod/plugin-utils";
|
|
3
|
+
//#region src/plugin.ts
|
|
4
|
+
const RTK_MODULE = "@reduxjs/toolkit";
|
|
5
|
+
const REDUX_MODULE = "redux";
|
|
6
|
+
const RTK_REEXPORTED = /* @__PURE__ */ new Set([
|
|
7
|
+
"combineReducers",
|
|
8
|
+
"compose",
|
|
9
|
+
"bindActionCreators",
|
|
10
|
+
"applyMiddleware"
|
|
11
|
+
]);
|
|
12
|
+
const STORE_CREATORS = /* @__PURE__ */ new Set(["createStore", "legacy_createStore"]);
|
|
13
|
+
/**
|
|
14
|
+
* Migrate legacy Redux to Redux Toolkit. Rewrites `redux` imports to
|
|
15
|
+
* `@reduxjs/toolkit` (re-exported helpers) and `createStore`/`legacy_createStore`
|
|
16
|
+
* to `configureStore`, rewriting `createStore(rootReducer[, preloadedState])`
|
|
17
|
+
* call sites into `configureStore({ reducer, preloadedState })`. Semantic
|
|
18
|
+
* migrations (switch reducers, `connect`, action creators, saga/thunk) are left
|
|
19
|
+
* untouched with a `warn` diagnostic and a `// TODO(omnimod)` breadcrumb.
|
|
20
|
+
*/
|
|
21
|
+
const reduxToToolkit = definePlugin({
|
|
22
|
+
name: "redux-to-toolkit",
|
|
23
|
+
description: "Migrate legacy Redux to Redux Toolkit.",
|
|
24
|
+
include: ["**/*.{ts,tsx,js,jsx,mts,cts}"],
|
|
25
|
+
transform(file) {
|
|
26
|
+
const reduxImport = findReduxImport(file);
|
|
27
|
+
const storeCreatorLocals = /* @__PURE__ */ new Set();
|
|
28
|
+
if (reduxImport) {
|
|
29
|
+
for (const [imported, local] of reduxImport.named) if (STORE_CREATORS.has(imported)) storeCreatorLocals.add(local);
|
|
30
|
+
rewriteReduxImport(file, reduxImport);
|
|
31
|
+
}
|
|
32
|
+
if (storeCreatorLocals.size > 0) rewriteStoreCalls(file, storeCreatorLocals);
|
|
33
|
+
reportSemanticFollowups(file);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
/** Find the first `import ... from "redux"` declaration in the file. */
|
|
37
|
+
function findReduxImport(file) {
|
|
38
|
+
for (const stmt of file.program.body) {
|
|
39
|
+
if (stmt.type !== "ImportDeclaration") continue;
|
|
40
|
+
const imp = cast(stmt);
|
|
41
|
+
if (imp.source.value !== REDUX_MODULE) continue;
|
|
42
|
+
const named = /* @__PURE__ */ new Map();
|
|
43
|
+
let hasNonNamed = false;
|
|
44
|
+
for (const spec of imp.specifiers) if (spec.type === "ImportSpecifier") {
|
|
45
|
+
const s = cast(spec);
|
|
46
|
+
const imported = s.imported.type === "Identifier" ? cast(s.imported).name : null;
|
|
47
|
+
if (imported === null) {
|
|
48
|
+
hasNonNamed = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
named.set(imported, s.local.name);
|
|
52
|
+
} else hasNonNamed = true;
|
|
53
|
+
return {
|
|
54
|
+
node: imp,
|
|
55
|
+
named,
|
|
56
|
+
hasNonNamed
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Rewrite the `redux` import to `@reduxjs/toolkit`. Every named specifier we care
|
|
63
|
+
* about (re-exported helpers + store creators) is available from RTK, so in the
|
|
64
|
+
* common all-named case we just retarget the source and swap store-creator names.
|
|
65
|
+
* If any specifier is unknown (some other redux export) or the declaration has a
|
|
66
|
+
* default/namespace binding, we split: known names move to a new RTK import and
|
|
67
|
+
* the original `redux` import keeps the rest.
|
|
68
|
+
*/
|
|
69
|
+
function rewriteReduxImport(file, reduxImport) {
|
|
70
|
+
const { node, named, hasNonNamed } = reduxImport;
|
|
71
|
+
const knownNames = [];
|
|
72
|
+
const unknownNames = [];
|
|
73
|
+
for (const imported of named.keys()) if (RTK_REEXPORTED.has(imported) || STORE_CREATORS.has(imported)) knownNames.push(imported);
|
|
74
|
+
else unknownNames.push(imported);
|
|
75
|
+
if (knownNames.length === 0) return;
|
|
76
|
+
if (!hasNonNamed && unknownNames.length === 0) {
|
|
77
|
+
for (const spec of node.specifiers) {
|
|
78
|
+
if (spec.type !== "ImportSpecifier") continue;
|
|
79
|
+
const s = cast(spec);
|
|
80
|
+
if (s.imported.type !== "Identifier") continue;
|
|
81
|
+
const importedName = cast(s.imported).name;
|
|
82
|
+
if (STORE_CREATORS.has(importedName)) file.magic.update(s.start, s.end, "configureStore");
|
|
83
|
+
}
|
|
84
|
+
file.magic.update(node.source.start, node.source.end, JSON.stringify(RTK_MODULE));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const rtkSpecifiers = [];
|
|
88
|
+
for (const imported of knownNames) {
|
|
89
|
+
if (STORE_CREATORS.has(imported)) {
|
|
90
|
+
rtkSpecifiers.push("configureStore");
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const local = named.get(imported) ?? imported;
|
|
94
|
+
rtkSpecifiers.push(local === imported ? imported : `${imported} as ${local}`);
|
|
95
|
+
}
|
|
96
|
+
const rtkImport = `import { ${[...new Set(rtkSpecifiers)].join(", ")} } from ${JSON.stringify(RTK_MODULE)};`;
|
|
97
|
+
removeKnownSpecifiers(file, node, knownNames);
|
|
98
|
+
file.magic.prependLeft(node.start, `${rtkImport}\n`);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Remove the given imported names from an import declaration's specifier list,
|
|
102
|
+
* adjusting the surrounding commas so the remaining specifiers stay well-formed.
|
|
103
|
+
*/
|
|
104
|
+
function removeKnownSpecifiers(file, node, removeImported) {
|
|
105
|
+
const toRemove = new Set(removeImported);
|
|
106
|
+
const specs = node.specifiers;
|
|
107
|
+
for (let i = 0; i < specs.length; i++) {
|
|
108
|
+
const spec = specs[i];
|
|
109
|
+
if (!spec || spec.type !== "ImportSpecifier") continue;
|
|
110
|
+
const s = cast(spec);
|
|
111
|
+
if (s.imported.type !== "Identifier") continue;
|
|
112
|
+
if (!toRemove.has(cast(s.imported).name)) continue;
|
|
113
|
+
const prev = specs[i - 1];
|
|
114
|
+
const next = specs[i + 1];
|
|
115
|
+
if (next) file.magic.remove(spec.start, next.start);
|
|
116
|
+
else if (prev) file.magic.remove(prev.end, spec.end);
|
|
117
|
+
else file.magic.remove(spec.start, spec.end);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Rewrite `createStore(reducer[, preloadedState][, enhancer])` call sites. */
|
|
121
|
+
function rewriteStoreCalls(file, storeCreatorLocals) {
|
|
122
|
+
walk(file.program, (node) => {
|
|
123
|
+
if (node.type !== "CallExpression") return;
|
|
124
|
+
const call = cast(node);
|
|
125
|
+
if (call.callee.type !== "Identifier") return;
|
|
126
|
+
if (!storeCreatorLocals.has(cast(call.callee).name)) return;
|
|
127
|
+
const args = call.arguments;
|
|
128
|
+
const reducerArg = args[0];
|
|
129
|
+
if (!reducerArg) {
|
|
130
|
+
file.report({
|
|
131
|
+
message: "`createStore()` called without a reducer; convert to `configureStore({ reducer })` manually.",
|
|
132
|
+
severity: "warn"
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const reducerText = file.source.slice(reducerArg.start, reducerArg.end);
|
|
137
|
+
const secondArg = args[1];
|
|
138
|
+
const thirdArg = args[2];
|
|
139
|
+
const configParts = [`reducer: ${reducerText}`];
|
|
140
|
+
let enhancerText = null;
|
|
141
|
+
for (const extra of [secondArg, thirdArg]) {
|
|
142
|
+
if (!extra) continue;
|
|
143
|
+
if (isEnhancerLike(extra, file)) enhancerText = file.source.slice(extra.start, extra.end);
|
|
144
|
+
else {
|
|
145
|
+
const preloadedText = file.source.slice(extra.start, extra.end);
|
|
146
|
+
configParts.push(`preloadedState: ${preloadedText}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const objectLiteral = `configureStore({ ${configParts.join(", ")} })`;
|
|
150
|
+
file.magic.update(call.start, call.end, objectLiteral);
|
|
151
|
+
if (enhancerText !== null) {
|
|
152
|
+
file.report({
|
|
153
|
+
message: "Middleware/enhancers passed to `createStore` must be moved to `configureStore`'s `middleware`/`enhancers` option. Left the original as a TODO.",
|
|
154
|
+
severity: "warn"
|
|
155
|
+
});
|
|
156
|
+
file.magic.appendLeft(call.end, ` /* TODO(omnimod): move to configureStore middleware/enhancers: ${enhancerText} */`);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/** Heuristic: is this argument an enhancer (applyMiddleware/compose call) vs preloadedState? */
|
|
161
|
+
function isEnhancerLike(arg, file) {
|
|
162
|
+
if (arg.type !== "CallExpression") return false;
|
|
163
|
+
const calleeName = calleeRootName(cast(arg).callee);
|
|
164
|
+
if (calleeName === "applyMiddleware" || calleeName === "compose") return true;
|
|
165
|
+
const text = file.source.slice(arg.start, arg.end);
|
|
166
|
+
return /applyMiddleware|compose|enhancer|middleware/i.test(text);
|
|
167
|
+
}
|
|
168
|
+
/** Root identifier name of a (possibly member/call) callee, else null. */
|
|
169
|
+
function calleeRootName(node) {
|
|
170
|
+
if (node.type === "Identifier") return cast(node).name;
|
|
171
|
+
if (node.type === "MemberExpression") return calleeRootName(cast(node).object);
|
|
172
|
+
if (node.type === "CallExpression") return calleeRootName(cast(node).callee);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Report (but never rewrite) the semantic migrations that need human judgement:
|
|
177
|
+
* switch-based reducers, `connect(...)`, and saga/thunk middleware imports.
|
|
178
|
+
*/
|
|
179
|
+
function reportSemanticFollowups(file) {
|
|
180
|
+
let reportedSwitchReducer = false;
|
|
181
|
+
let reportedConnect = false;
|
|
182
|
+
walk(file.program, (node) => {
|
|
183
|
+
if (!reportedSwitchReducer && isSwitchReducer(node)) {
|
|
184
|
+
reportedSwitchReducer = true;
|
|
185
|
+
file.report({
|
|
186
|
+
message: "Switch-statement reducer detected: convert it to `createSlice` (reducers map + generated action creators).",
|
|
187
|
+
severity: "warn"
|
|
188
|
+
});
|
|
189
|
+
file.magic.appendLeft(node.start, "// TODO(omnimod): convert this switch reducer to createSlice.\n");
|
|
190
|
+
}
|
|
191
|
+
if (!reportedConnect && isConnectCall(node)) {
|
|
192
|
+
reportedConnect = true;
|
|
193
|
+
file.report({
|
|
194
|
+
message: "`connect()` detected: prefer the `useSelector`/`useDispatch` hooks from react-redux.",
|
|
195
|
+
severity: "warn"
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
for (const stmt of file.program.body) {
|
|
200
|
+
if (stmt.type !== "ImportDeclaration") continue;
|
|
201
|
+
const src = cast(stmt).source.value;
|
|
202
|
+
if (src === "redux-saga" || src.startsWith("redux-saga/") || src === "redux-thunk") file.report({
|
|
203
|
+
message: `\`${src}\` middleware detected: RTK ships \`createAsyncThunk\`/listener middleware; migrate async logic accordingly.`,
|
|
204
|
+
severity: "info"
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* True for a function whose body is (or immediately contains) a `switch` on an
|
|
210
|
+
* `action.type`-shaped discriminant and whose first param defaults to an initial
|
|
211
|
+
* state — the classic hand-written reducer shape.
|
|
212
|
+
*/
|
|
213
|
+
function isSwitchReducer(node) {
|
|
214
|
+
let params;
|
|
215
|
+
let body;
|
|
216
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
217
|
+
const fn = cast(node);
|
|
218
|
+
params = fn.params;
|
|
219
|
+
body = fn.body;
|
|
220
|
+
} else if (node.type === "FunctionDeclaration") {
|
|
221
|
+
const fn = cast(node);
|
|
222
|
+
params = fn.params;
|
|
223
|
+
body = fn.body;
|
|
224
|
+
} else if (node.type === "FunctionExpression") {
|
|
225
|
+
const fn = cast(node);
|
|
226
|
+
params = fn.params;
|
|
227
|
+
body = fn.body;
|
|
228
|
+
} else return false;
|
|
229
|
+
if (params.length < 2) return false;
|
|
230
|
+
if (!(params[0]?.type === "AssignmentPattern")) return false;
|
|
231
|
+
if (!body || body.type !== "BlockStatement") return false;
|
|
232
|
+
return (body.body ?? []).some((stmt) => stmt.type === "SwitchStatement");
|
|
233
|
+
}
|
|
234
|
+
/** True for `connect(...)(Component)` or `connect(...)` from react-redux. */
|
|
235
|
+
function isConnectCall(node) {
|
|
236
|
+
if (node.type !== "CallExpression") return false;
|
|
237
|
+
const inner = cast(node).callee;
|
|
238
|
+
if (inner.type === "Identifier" && cast(inner).name === "connect") return true;
|
|
239
|
+
if (inner.type === "CallExpression") {
|
|
240
|
+
const innerCall = cast(inner);
|
|
241
|
+
if (innerCall.callee.type === "Identifier" && cast(innerCall.callee).name === "connect") return true;
|
|
242
|
+
}
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
//#endregion
|
|
246
|
+
export { reduxToToolkit as default, reduxToToolkit };
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omnimod/plugin-redux-to-toolkit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "omnimod plugin: migrate legacy Redux to Redux Toolkit.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"codemod",
|
|
7
|
+
"migration",
|
|
8
|
+
"omnimod-plugin",
|
|
9
|
+
"redux",
|
|
10
|
+
"redux-toolkit"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://salnika.github.io/omnimod/",
|
|
13
|
+
"bugs": "https://github.com/salnika/omnimod/issues",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "salnika",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/salnika/omnimod.git",
|
|
19
|
+
"directory": "packages/plugin-redux-to-toolkit"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./dist/index.mjs",
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@omnimod/plugin-utils": "0.1.0",
|
|
34
|
+
"@omnimod/core": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^25.6.2",
|
|
38
|
+
"@typescript/native-preview": "7.0.0-dev.20260509.2",
|
|
39
|
+
"typescript": "^6.0.3",
|
|
40
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2",
|
|
41
|
+
"vite-plus": "0.2.2"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "vp pack",
|
|
45
|
+
"dev": "vp pack --watch",
|
|
46
|
+
"test": "vp test",
|
|
47
|
+
"check": "vp check"
|
|
48
|
+
}
|
|
49
|
+
}
|