Skip to content

Commit 1c7e565

Browse files
Migrate from Immer to Mutative for immutable LST updates (#450)
* Migrate from Immer to Mutative for immutable LST updates - Replace `immer` with `mutative` dependency in setup docs and npm install commands - Change all `produce()` calls to `create()` from mutative across examples and templates - Update recipe registration to use `RecipeMarketplace` with `CategoryDescriptor` category paths instead of `RecipeRegistry` - Add documentation for category hierarchy patterns with nested descriptors - Maintain `produceAsync()` from `@openrewrite/rewrite` for async operations * Adjust for RecipeMarketplace
1 parent b1de579 commit 1c7e565

15 files changed

+160
-121
lines changed

docs/authoring-recipes/javascript-recipe-development-environment.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ Next, let's install the core OpenRewrite framework and the required dependencies
7676
# Core OpenRewrite framework - should be compatible with your Moderne CLI version
7777
npm install @openrewrite/rewrite
7878

79-
# Immer is required for modifying LSTs immutably
80-
npm install immer
79+
# Mutative is required for modifying LSTs immutably
80+
npm install mutative
8181

8282
# Development dependencies for testing and building
8383
npm install --save-dev typescript jest @jest/globals ts-jest rimraf
@@ -192,21 +192,24 @@ Add the following build and test scripts to your `package.json`:
192192
In order for the Moderne CLI to discover your recipes, you need to export them. To do so, create a `src/index.ts` file that looks like:
193193

194194
```typescript title="src/index.ts"
195-
import { RecipeRegistry } from '@openrewrite/rewrite';
195+
import { RecipeMarketplace, CategoryDescriptor } from '@openrewrite/rewrite';
196196
import { MyRecipe } from './my-recipe';
197197

198198
export { MyRecipe } from './my-recipe';
199199
// Export additional recipes here
200200

201+
// Define category hierarchy for your recipes
202+
export const MyPackage: CategoryDescriptor[] = [{displayName: "My Recipes"}];
203+
201204
/**
202205
* Activates and registers all recipes in this module.
203206
* This function is called by OpenRewrite to discover available recipes.
204207
*
205-
* @param registry The recipe registry to register recipes with
208+
* @param marketplace The recipe marketplace to install recipes into
206209
*/
207-
export function activate(registry: RecipeRegistry) {
208-
registry.register(MyRecipe);
209-
// Register additional recipes here
210+
export async function activate(marketplace: RecipeMarketplace): Promise<void> {
211+
await marketplace.install(MyRecipe, MyPackage);
212+
// Install additional recipes here
210213
}
211214
```
212215

docs/authoring-recipes/writing-a-javascript-refactoring-recipe.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,22 @@ Note that this recipe doesn't _do_ anything, yet - we'll get to that later.
7979
Next, let's ensure your recipe can be discovered and run by the [Moderne CLI](https://docs.moderne.io/user-documentation/moderne-cli/getting-started/cli-intro). Update your `src/index.ts` to export and register the recipe:
8080

8181
```typescript title="index.ts"
82-
import { RecipeRegistry } from '@openrewrite/rewrite';
82+
import { RecipeMarketplace, CategoryDescriptor } from '@openrewrite/rewrite';
8383
import { MigrateUtilFunctions } from './migrate-util-functions';
8484

8585
export { MigrateUtilFunctions } from './migrate-util-functions';
8686

87+
// Define category hierarchy for your recipes
88+
export const MyPackage: CategoryDescriptor[] = [{displayName: "My Recipes"}];
89+
8790
/**
8891
* Activates and registers all recipes in this module.
8992
* This function is called by OpenRewrite to discover available recipes.
9093
*
91-
* @param registry The recipe registry to register recipes with
94+
* @param marketplace The recipe marketplace to install recipes into
9295
*/
93-
export function activate(registry: RecipeRegistry) {
94-
registry.register(MigrateUtilFunctions);
96+
export async function activate(marketplace: RecipeMarketplace): Promise<void> {
97+
await marketplace.install(MigrateUtilFunctions, MyPackage);
9598
}
9699
```
97100

openrewrite-recipe-writer/skills/writing-openrewrite-recipes-js/SKILL.md

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Verification guide:
6161

6262
```bash
6363
npm install @openrewrite/rewrite@next # Latest features
64-
npm install --save-dev typescript @types/node immer @jest/globals jest
64+
npm install --save-dev typescript @types/node @jest/globals jest
6565
```
6666

6767
### TypeScript Configuration
@@ -89,8 +89,8 @@ Follow this checklist when creating recipes:
8989
- [ ] Create visitor extending `JavaScriptVisitor`
9090
- [ ] Override visit methods for target AST nodes
9191
- [ ] **For pattern-based transformations:** Use `rewrite()` helper with `tryOn()` method
92-
- [ ] **For manual AST modifications:** Use `produce()` from `immer` for immutable updates
93-
- [ ] **For async operations in produce:** Use `produceAsync()` from `@openrewrite/rewrite`
92+
- [ ] **For manual AST modifications:** Use `create()` from `mutative` for immutable updates
93+
- [ ] **For async operations:** Use `produceAsync()` from `@openrewrite/rewrite`
9494
- [ ] Write tests using `RecipeSpec` and `rewriteRun()`
9595
- [ ] Register recipe in `activate()` function (see [Recipe Registration](#recipe-registration))
9696

@@ -390,9 +390,9 @@ if (!isMethodInvocation(node)) {
390390
// Now TypeScript knows node is J.MethodInvocation
391391
```
392392

393-
4. **Use produce() for modifications:**
393+
4. **Use create() for modifications:**
394394
```typescript
395-
return produce(node, draft => {
395+
return create(node, draft => {
396396
draft.name = newName;
397397
});
398398
```
@@ -651,15 +651,17 @@ const x = capture<J.Literal>({
651651
});
652652
```
653653

654-
### Immer produce() issues
654+
### Mutative create() issues
655655
```typescript
656+
import {create} from "mutative";
657+
656658
// ❌ Wrong - reassigning draft
657-
return produce(node, draft => {
659+
return create(node, draft => {
658660
draft = someOtherNode; // Won't work
659661
});
660662

661663
// ✅ Correct - modify properties
662-
return produce(node, draft => {
664+
return create(node, draft => {
663665
draft.name = newName;
664666
});
665667
```
@@ -754,35 +756,61 @@ import {RecipeSpec} from "@openrewrite/rewrite/test";
754756
import {javascript, typescript, jsx, tsx, npm, packageJson} from "@openrewrite/rewrite/javascript";
755757

756758
// Recipe Registration
757-
import {RecipeRegistry} from "@openrewrite/rewrite";
759+
import {RecipeMarketplace, CategoryDescriptor} from "@openrewrite/rewrite";
758760
```
759761

760762
## Recipe Registration
761763

762-
To make recipes discoverable by OpenRewrite, export an `activate()` function from the package entry point (typically `index.ts`). This function receives a `RecipeRegistry` and registers recipe classes with it.
764+
To make recipes discoverable by OpenRewrite, export an `activate()` function from the package entry point (typically `index.ts`). This function receives a `RecipeMarketplace` and installs recipe classes with a category path.
763765

764766
### Basic Registration
765767

766768
```typescript
767-
import { RecipeRegistry } from '@openrewrite/rewrite';
769+
import { RecipeMarketplace, CategoryDescriptor } from '@openrewrite/rewrite';
768770
import { MyRecipe } from './my-recipe';
769771
import { AnotherRecipe } from './another-recipe';
770772

771-
export async function activate(registry: RecipeRegistry): Promise<void> {
772-
registry.register(MyRecipe);
773-
registry.register(AnotherRecipe);
773+
// Define your package's root category
774+
export const MyPackage: CategoryDescriptor[] = [{displayName: "My Package"}];
775+
776+
// Define subcategories (they extend the parent path)
777+
export const Search: CategoryDescriptor[] = [...MyPackage, {displayName: "Search"}];
778+
export const Cleanup: CategoryDescriptor[] = [...MyPackage, {displayName: "Cleanup"}];
779+
780+
export async function activate(marketplace: RecipeMarketplace): Promise<void> {
781+
await marketplace.install(MyRecipe, MyPackage);
782+
await marketplace.install(AnotherRecipe, Cleanup);
774783
}
775784

776785
// Also export recipe classes for direct use
777786
export { MyRecipe } from './my-recipe';
778787
export { AnotherRecipe } from './another-recipe';
779788
```
780789

790+
### Category Paths
791+
792+
Categories are specified as arrays of `CategoryDescriptor` objects, from shallowest to deepest:
793+
794+
```typescript
795+
// Root category for JavaScript recipes
796+
export const JavaScript: CategoryDescriptor[] = [{displayName: "JavaScript"}];
797+
798+
// Nested category: JavaScript > Search
799+
export const Search: CategoryDescriptor[] = [...JavaScript, {displayName: "Search"}];
800+
801+
// Nested category: JavaScript > Migrate > TypeScript
802+
export const Migrate: CategoryDescriptor[] = [...JavaScript, {displayName: "Migrate"}];
803+
export const MigrateTypeScript: CategoryDescriptor[] = [...Migrate, {
804+
displayName: "TypeScript",
805+
description: "Migrate TypeScript-specific patterns"
806+
}];
807+
```
808+
781809
### Important Notes
782810

783-
1. **Pass the class, not an instance**: `registry.register(MyRecipe)` not `registry.register(new MyRecipe())`
811+
1. **Pass the class, not an instance**: `marketplace.install(MyRecipe, Category)` not `marketplace.install(new MyRecipe(), Category)`
784812

785-
2. **Recipes must be instantiable without arguments**: The registry creates a temporary instance to read the recipe's `name` property. Recipes with required options (no defaults) cannot be registered this way.
813+
2. **Recipes must be instantiable without arguments**: The marketplace creates a temporary instance to read the recipe's descriptor. Recipes with required options (no defaults) cannot be registered this way.
786814

787815
```typescript
788816
// ✅ Can be registered - no required options
@@ -813,13 +841,18 @@ export class RequiredOptionRecipe extends Recipe {
813841
}
814842
```
815843

816-
3. **Async function**: The `activate()` function should be `async` and return `Promise<void>`.
844+
3. **Async function**: The `activate()` function should be `async` and return `Promise<void>`. Each `install()` call should be awaited.
817845

818846
### Complete Example
819847

820848
```typescript
821849
// src/index.ts
822-
import { RecipeRegistry } from '@openrewrite/rewrite';
850+
import { RecipeMarketplace, CategoryDescriptor } from '@openrewrite/rewrite';
851+
852+
// Define category hierarchy
853+
export const MyLibrary: CategoryDescriptor[] = [{displayName: "My Library"}];
854+
export const Migrations: CategoryDescriptor[] = [...MyLibrary, {displayName: "Migrations"}];
855+
export const Search: CategoryDescriptor[] = [...MyLibrary, {displayName: "Search"}];
823856

824857
// Re-export all recipes for direct import
825858
export { MigrateApiCalls } from './migrate-api-calls';
@@ -834,9 +867,9 @@ import { UpdateImports } from './update-imports';
834867
/**
835868
* Register all recipes that can be instantiated without arguments.
836869
*/
837-
export async function activate(registry: RecipeRegistry): Promise<void> {
838-
registry.register(MigrateApiCalls);
839-
registry.register(UpdateImports);
870+
export async function activate(marketplace: RecipeMarketplace): Promise<void> {
871+
await marketplace.install(MigrateApiCalls, Migrations);
872+
await marketplace.install(UpdateImports, Migrations);
840873
// FindDeprecatedUsage omitted - requires options
841874
}
842875
```

openrewrite-recipe-writer/skills/writing-openrewrite-recipes-js/assets/template-basic-recipe.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import {ExecutionContext, Recipe} from "@openrewrite/rewrite";
1717
import {JavaScriptVisitor} from "@openrewrite/rewrite/javascript";
1818
import {J} from "@openrewrite/rewrite/java";
19-
import {produce} from "immer";
19+
import {create} from "mutative";
2020

2121
/**
2222
* TODO: Add recipe description
@@ -38,8 +38,8 @@ export class MyRecipe extends Recipe {
3838
const visited = await super.visitMethodInvocation(method, ctx) as J.MethodInvocation;
3939

4040
// TODO: Add transformation logic here
41-
// Example: Modify the method invocation using produce
42-
// return produce(visited, draft => {
41+
// Example: Modify the method invocation using create
42+
// return create(visited, draft => {
4343
// // Make changes to draft
4444
// });
4545

openrewrite-recipe-writer/skills/writing-openrewrite-recipes-js/assets/template-recipe-with-options.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import {ExecutionContext, Option, Recipe} from "@openrewrite/rewrite";
1717
import {JavaScriptVisitor} from "@openrewrite/rewrite/javascript";
1818
import {J} from "@openrewrite/rewrite/java";
19-
import {produce} from "immer";
19+
import {create} from "mutative";
2020

2121
/**
2222
* TODO: Add recipe description
@@ -52,7 +52,7 @@ export class MyConfigurableRecipe extends Recipe {
5252
// TODO: Add transformation logic using optionValue
5353
// Example:
5454
// if (someCondition) {
55-
// return produce(visited, draft => {
55+
// return create(visited, draft => {
5656
// // Use optionValue to make changes
5757
// });
5858
// }

openrewrite-recipe-writer/skills/writing-openrewrite-recipes-js/examples/manual-bind-to-arrow-simple.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import {ExecutionContext, Recipe, TreeVisitor} from "@openrewrite/rewrite";
99
import {J, isIdentifier} from "@openrewrite/rewrite/java";
1010
import {JavaScriptVisitor, JS, capture, pattern, template, raw, rewrite} from "@openrewrite/rewrite/javascript";
11-
import {produce} from "immer";
11+
import {create} from "mutative";
1212

1313
export class ManualBindToArrowSimple extends Recipe {
1414
name = "org.openrewrite.javascript.react.manual-bind-to-arrow-simple";
@@ -71,9 +71,9 @@ class BindingRemovalVisitor extends JavaScriptVisitor<ExecutionContext> {
7171

7272
// Remove binding statements by filtering out indices
7373
if (indicesToRemove.size > 0) {
74-
return produce(method, draft => {
74+
return create(method, draft => {
7575
if (draft.body) {
76-
draft.body = produce(draft.body, bodyDraft => {
76+
draft.body = create(draft.body, bodyDraft => {
7777
bodyDraft.statements = bodyDraft.statements.filter(
7878
(_, index) => !indicesToRemove.has(index)
7979
);
@@ -228,7 +228,7 @@ class CompleteTransformVisitor extends JavaScriptVisitor<ExecutionContext> {
228228
* 1. Using pattern().configure() with context and dependencies for type attribution
229229
* 2. Multi-pass transformation strategy
230230
* 3. Using ExecutionContext to pass data between visitor passes
231-
* 4. Handling complex AST transformations with produce()
231+
* 4. Handling complex AST transformations with create()
232232
*
233233
* Production considerations:
234234
* - Need to handle 'self' variable cleanup

openrewrite-recipe-writer/skills/writing-openrewrite-recipes-js/examples/manual-bind-to-arrow.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import {ExecutionContext, Recipe, TreeVisitor} from "@openrewrite/rewrite";
1717
import {J, Expression, Statement, isIdentifier, isLiteral} from "@openrewrite/rewrite/java";
1818
import {JavaScriptVisitor, capture, pattern, template, raw} from "@openrewrite/rewrite/javascript";
19-
import {produce} from "immer";
19+
import {create} from "mutative";
2020

2121
export class ManualBindToArrow extends Recipe {
2222
name = "org.openrewrite.javascript.react.manual-bind-to-arrow";
@@ -104,9 +104,9 @@ class ManualBindToArrowVisitor extends JavaScriptVisitor<ExecutionContext> {
104104

105105
// Remove binding statements
106106
if (statementsToRemove.length > 0) {
107-
const newMethod = produce(method, draft => {
107+
const newMethod = create(method, draft => {
108108
if (draft.body) {
109-
draft.body = produce(draft.body, bodyDraft => {
109+
draft.body = create(draft.body, bodyDraft => {
110110
bodyDraft.statements = bodyDraft.statements.filter(
111111
stmt => !statementsToRemove.includes(stmt)
112112
);
@@ -140,8 +140,8 @@ class ManualBindToArrowVisitor extends JavaScriptVisitor<ExecutionContext> {
140140

141141
// Now convert marked methods to arrow functions
142142
if (this.methodsToConvert.size > 0) {
143-
result = produce(result, draft => {
144-
draft.body = produce(draft.body, bodyDraft => {
143+
result = create(result, draft => {
144+
draft.body = create(draft.body, bodyDraft => {
145145
bodyDraft.statements = bodyDraft.statements.map(stmt => {
146146
const stmtElement = stmt.element;
147147

@@ -201,9 +201,9 @@ class ManualBindToArrowVisitor extends JavaScriptVisitor<ExecutionContext> {
201201
// Note: This is a simplified approach; full implementation would need
202202
// to properly handle async, type annotations, and parameter defaults
203203

204-
return produce(stmt, draft => {
204+
return create(stmt, draft => {
205205
// Preserve comments from original method
206-
draft.element = produce(method, methodDraft => {
206+
draft.element = create(method, methodDraft => {
207207
// Convert to variable declaration with arrow function
208208
// This is a conceptual representation; actual implementation
209209
// would use template application
@@ -218,8 +218,8 @@ class ManualBindToArrowVisitor extends JavaScriptVisitor<ExecutionContext> {
218218
* Remove constructors that only contain super() calls.
219219
*/
220220
private removeEmptyConstructors(classDecl: J.ClassDeclaration): J.ClassDeclaration {
221-
return produce(classDecl, draft => {
222-
draft.body = produce(draft.body, bodyDraft => {
221+
return create(classDecl, draft => {
222+
draft.body = create(draft.body, bodyDraft => {
223223
bodyDraft.statements = bodyDraft.statements.filter(stmt => {
224224
const stmtElement = stmt.element;
225225

0 commit comments

Comments
 (0)