@openrewrite/rewrite 8.75.2 → 8.75.4

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.
Files changed (48) hide show
  1. package/dist/javascript/format/format.d.ts +2 -0
  2. package/dist/javascript/format/format.d.ts.map +1 -1
  3. package/dist/javascript/format/format.js +97 -69
  4. package/dist/javascript/format/format.js.map +1 -1
  5. package/dist/javascript/format/minimum-viable-spacing-visitor.d.ts.map +1 -1
  6. package/dist/javascript/format/minimum-viable-spacing-visitor.js +1 -3
  7. package/dist/javascript/format/minimum-viable-spacing-visitor.js.map +1 -1
  8. package/dist/javascript/format/whitespace-reconciler.d.ts +6 -0
  9. package/dist/javascript/format/whitespace-reconciler.d.ts.map +1 -1
  10. package/dist/javascript/format/whitespace-reconciler.js +31 -1
  11. package/dist/javascript/format/whitespace-reconciler.js.map +1 -1
  12. package/dist/javascript/recipes/index.d.ts +1 -0
  13. package/dist/javascript/recipes/index.d.ts.map +1 -1
  14. package/dist/javascript/recipes/index.js +1 -0
  15. package/dist/javascript/recipes/index.js.map +1 -1
  16. package/dist/javascript/recipes/order-imports.d.ts.map +1 -1
  17. package/dist/javascript/recipes/order-imports.js +5 -0
  18. package/dist/javascript/recipes/order-imports.js.map +1 -1
  19. package/dist/javascript/recipes/remove-dependency.d.ts +29 -0
  20. package/dist/javascript/recipes/remove-dependency.d.ts.map +1 -0
  21. package/dist/javascript/recipes/remove-dependency.js +261 -0
  22. package/dist/javascript/recipes/remove-dependency.js.map +1 -0
  23. package/dist/javascript/remove-import.d.ts.map +1 -1
  24. package/dist/javascript/remove-import.js +9 -8
  25. package/dist/javascript/remove-import.js.map +1 -1
  26. package/dist/rewrite-javascript-version.txt +1 -1
  27. package/dist/rpc/request/prepare-recipe.d.ts +1 -0
  28. package/dist/rpc/request/prepare-recipe.d.ts.map +1 -1
  29. package/dist/rpc/request/prepare-recipe.js +18 -9
  30. package/dist/rpc/request/prepare-recipe.js.map +1 -1
  31. package/dist/test/rewrite-test.d.ts +1 -0
  32. package/dist/test/rewrite-test.d.ts.map +1 -1
  33. package/dist/test/rewrite-test.js +18 -7
  34. package/dist/test/rewrite-test.js.map +1 -1
  35. package/dist/visitor.d.ts.map +1 -1
  36. package/dist/visitor.js +7 -2
  37. package/dist/visitor.js.map +1 -1
  38. package/package.json +12 -11
  39. package/src/javascript/format/format.ts +99 -71
  40. package/src/javascript/format/minimum-viable-spacing-visitor.ts +1 -4
  41. package/src/javascript/format/whitespace-reconciler.ts +27 -2
  42. package/src/javascript/recipes/index.ts +1 -0
  43. package/src/javascript/recipes/order-imports.ts +6 -0
  44. package/src/javascript/recipes/remove-dependency.ts +345 -0
  45. package/src/javascript/remove-import.ts +10 -9
  46. package/src/rpc/request/prepare-recipe.ts +20 -9
  47. package/src/test/rewrite-test.ts +17 -3
  48. package/src/visitor.ts +7 -2
@@ -0,0 +1,345 @@
1
+ /*
2
+ * Copyright 2026 the original author or authors.
3
+ * <p>
4
+ * Licensed under the Moderne Source Available License (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ * <p>
8
+ * https://docs.moderne.io/licensing/moderne-source-available-license
9
+ * <p>
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import {Option, ScanningRecipe} from "../../recipe";
18
+ import {ExecutionContext} from "../../execution";
19
+ import {TreeVisitor} from "../../visitor";
20
+ import {Tree} from "../../tree";
21
+ import {getMemberKeyName, isJson, isObject, Json, JsonVisitor} from "../../json";
22
+ import {isDocuments, isYaml, Yaml} from "../../yaml";
23
+ import {isPlainText, PlainText} from "../../text";
24
+ import {
25
+ allDependencyScopes,
26
+ DependencyScope,
27
+ findNodeResolutionResult,
28
+ PackageManager,
29
+ serializeNpmrcConfigs
30
+ } from "../node-resolution-result";
31
+ import {markupWarn} from "../../markers";
32
+ import {TreePrinters} from "../../print";
33
+ import {
34
+ createDependencyRecipeAccumulator,
35
+ createLockFileEditor,
36
+ DependencyRecipeAccumulator,
37
+ getAllLockFileNames,
38
+ getLockFileName,
39
+ parseLockFileContent,
40
+ runInstallIfNeeded,
41
+ runInstallInTempDir,
42
+ storeInstallResult,
43
+ updateNodeResolutionMarker
44
+ } from "../package-manager";
45
+ import * as path from "path";
46
+
47
+ interface ProjectUpdateInfo {
48
+ packageJsonPath: string;
49
+ originalPackageJson: string;
50
+ dependencyScopes: DependencyScope[];
51
+ packageManager: PackageManager;
52
+ configFiles?: Record<string, string>;
53
+ }
54
+
55
+ interface Accumulator extends DependencyRecipeAccumulator<ProjectUpdateInfo> {
56
+ originalLockFiles: Map<string, string>;
57
+ }
58
+
59
+ export class RemoveDependency extends ScanningRecipe<Accumulator> {
60
+ readonly name = "org.openrewrite.javascript.dependencies.remove-dependency";
61
+ readonly displayName = "Remove npm dependency";
62
+ readonly description = "Removes a dependency from `package.json` and updates the lock file by running the package manager.";
63
+
64
+ @Option({
65
+ displayName: "Package name",
66
+ description: "The name of the npm package to remove (e.g., `lodash`, `@types/node`)",
67
+ example: "lodash"
68
+ })
69
+ packageName!: string;
70
+
71
+ @Option({
72
+ displayName: "Scope",
73
+ description: "The dependency scope to remove from. If not specified, the dependency is removed from all scopes where it is found.",
74
+ example: "dependencies",
75
+ required: false
76
+ })
77
+ scope?: DependencyScope;
78
+
79
+ initialValue(_ctx: ExecutionContext): Accumulator {
80
+ return {
81
+ ...createDependencyRecipeAccumulator<ProjectUpdateInfo>(),
82
+ originalLockFiles: new Map()
83
+ };
84
+ }
85
+
86
+ async scanner(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
87
+ const recipe = this;
88
+ const LOCK_FILE_NAMES = getAllLockFileNames();
89
+
90
+ return new class extends TreeVisitor<Tree, ExecutionContext> {
91
+ protected async accept(tree: Tree, ctx: ExecutionContext): Promise<Tree | undefined> {
92
+ if (isJson(tree) && tree.kind === Json.Kind.Document) {
93
+ return this.handleJsonDocument(tree as Json.Document, ctx);
94
+ }
95
+ if (isYaml(tree) && isDocuments(tree)) {
96
+ return this.handleYamlDocument(tree, ctx);
97
+ }
98
+ if (isPlainText(tree)) {
99
+ return this.handlePlainTextDocument(tree as PlainText, ctx);
100
+ }
101
+ return tree;
102
+ }
103
+
104
+ private async handleJsonDocument(doc: Json.Document, _ctx: ExecutionContext): Promise<Json | undefined> {
105
+ const basename = path.basename(doc.sourcePath);
106
+
107
+ if (LOCK_FILE_NAMES.includes(basename)) {
108
+ acc.originalLockFiles.set(doc.sourcePath, await TreePrinters.print(doc));
109
+ return doc;
110
+ }
111
+
112
+ if (!doc.sourcePath.endsWith('package.json')) {
113
+ return doc;
114
+ }
115
+
116
+ const marker = findNodeResolutionResult(doc);
117
+ if (!marker) {
118
+ return doc;
119
+ }
120
+
121
+ const scopesToCheck = recipe.scope ? [recipe.scope] : allDependencyScopes;
122
+ const foundScopes: DependencyScope[] = [];
123
+
124
+ for (const scope of scopesToCheck) {
125
+ const deps = marker[scope];
126
+ if (deps?.some(d => d.name === recipe.packageName)) {
127
+ foundScopes.push(scope);
128
+ }
129
+ }
130
+
131
+ if (foundScopes.length === 0) {
132
+ return doc;
133
+ }
134
+
135
+ const pm = marker.packageManager ?? PackageManager.Npm;
136
+
137
+ const configFiles: Record<string, string> = {};
138
+ const npmrcContent = serializeNpmrcConfigs(marker.npmrcConfigs);
139
+ if (npmrcContent) {
140
+ configFiles['.npmrc'] = npmrcContent;
141
+ }
142
+
143
+ acc.projectsToUpdate.set(doc.sourcePath, {
144
+ packageJsonPath: doc.sourcePath,
145
+ originalPackageJson: await TreePrinters.print(doc),
146
+ dependencyScopes: foundScopes,
147
+ packageManager: pm,
148
+ configFiles: Object.keys(configFiles).length > 0 ? configFiles : undefined
149
+ });
150
+
151
+ return doc;
152
+ }
153
+
154
+ private async handleYamlDocument(docs: Yaml.Documents, _ctx: ExecutionContext): Promise<Yaml.Documents | undefined> {
155
+ const basename = path.basename(docs.sourcePath);
156
+ if (LOCK_FILE_NAMES.includes(basename)) {
157
+ acc.originalLockFiles.set(docs.sourcePath, await TreePrinters.print(docs));
158
+ }
159
+ return docs;
160
+ }
161
+
162
+ private async handlePlainTextDocument(text: PlainText, _ctx: ExecutionContext): Promise<PlainText | undefined> {
163
+ const basename = path.basename(text.sourcePath);
164
+ if (LOCK_FILE_NAMES.includes(basename)) {
165
+ acc.originalLockFiles.set(text.sourcePath, await TreePrinters.print(text));
166
+ }
167
+ return text;
168
+ }
169
+ };
170
+ }
171
+
172
+ async editorWithData(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
173
+ const recipe = this;
174
+
175
+ const jsonEditor = new class extends JsonVisitor<ExecutionContext> {
176
+ protected async visitDocument(doc: Json.Document, ctx: ExecutionContext): Promise<Json | undefined> {
177
+ const sourcePath = doc.sourcePath;
178
+
179
+ if (sourcePath.endsWith('package.json')) {
180
+ const updateInfo = acc.projectsToUpdate.get(sourcePath);
181
+ if (!updateInfo) {
182
+ return doc;
183
+ }
184
+
185
+ const failureMessage = await runInstallIfNeeded(sourcePath, acc, () =>
186
+ recipe.runPackageManagerInstall(acc, updateInfo, ctx)
187
+ );
188
+ if (failureMessage) {
189
+ return markupWarn(
190
+ doc,
191
+ `Failed to remove ${recipe.packageName}`,
192
+ failureMessage
193
+ );
194
+ }
195
+
196
+ const modifiedDoc = removeDependencyFromJson(
197
+ doc,
198
+ recipe.packageName,
199
+ updateInfo.dependencyScopes
200
+ );
201
+
202
+ return updateNodeResolutionMarker(modifiedDoc, updateInfo, acc);
203
+ }
204
+
205
+ const lockFileName = path.basename(sourcePath);
206
+ if (getAllLockFileNames().includes(lockFileName)) {
207
+ const updatedLockContent = acc.updatedLockFiles.get(sourcePath);
208
+ if (updatedLockContent) {
209
+ const parsed = await parseLockFileContent(updatedLockContent, sourcePath, lockFileName) as Json.Document;
210
+ return {
211
+ ...doc,
212
+ value: parsed.value,
213
+ eof: parsed.eof
214
+ } as Json.Document;
215
+ }
216
+ }
217
+
218
+ return doc;
219
+ }
220
+ };
221
+
222
+ return createLockFileEditor(jsonEditor, acc);
223
+ }
224
+
225
+ private async runPackageManagerInstall(
226
+ acc: Accumulator,
227
+ updateInfo: ProjectUpdateInfo,
228
+ _ctx: ExecutionContext
229
+ ): Promise<void> {
230
+ const modifiedPackageJson = this.createModifiedPackageJson(
231
+ updateInfo.originalPackageJson,
232
+ updateInfo.dependencyScopes
233
+ );
234
+
235
+ const lockFileName = getLockFileName(updateInfo.packageManager);
236
+ const packageJsonDir = path.dirname(updateInfo.packageJsonPath);
237
+ const lockFilePath = packageJsonDir === '.'
238
+ ? lockFileName
239
+ : path.join(packageJsonDir, lockFileName);
240
+
241
+ const originalLockFileContent = acc.originalLockFiles.get(lockFilePath);
242
+
243
+ const result = await runInstallInTempDir(
244
+ updateInfo.packageManager,
245
+ modifiedPackageJson,
246
+ {
247
+ originalLockFileContent,
248
+ configFiles: updateInfo.configFiles
249
+ }
250
+ );
251
+
252
+ storeInstallResult(result, acc, updateInfo, modifiedPackageJson);
253
+ }
254
+
255
+ private createModifiedPackageJson(
256
+ originalContent: string,
257
+ scopes: DependencyScope[]
258
+ ): string {
259
+ const packageJson = JSON.parse(originalContent);
260
+
261
+ for (const scope of scopes) {
262
+ if (packageJson[scope] && packageJson[scope][this.packageName]) {
263
+ delete packageJson[scope][this.packageName];
264
+
265
+ if (Object.keys(packageJson[scope]).length === 0) {
266
+ delete packageJson[scope];
267
+ }
268
+ }
269
+ }
270
+
271
+ return JSON.stringify(packageJson, null, 2);
272
+ }
273
+ }
274
+
275
+ function removeDependencyFromJson(
276
+ doc: Json.Document,
277
+ packageName: string,
278
+ scopes: DependencyScope[]
279
+ ): Json.Document {
280
+ if (!isObject(doc.value)) {
281
+ return doc;
282
+ }
283
+
284
+ const targetScopes = new Set<string>(scopes);
285
+ const rootObj = doc.value;
286
+ let changed = false;
287
+ const result: Json.RightPadded<Json>[] = [];
288
+
289
+ for (const rp of rootObj.members) {
290
+ const member = rp.element as Json.Member;
291
+ const keyName = getMemberKeyName(member);
292
+
293
+ if (!keyName || !targetScopes.has(keyName) || !isObject(member.value)) {
294
+ result.push(rp);
295
+ continue;
296
+ }
297
+
298
+ const scopeObj = member.value;
299
+ const filtered = scopeObj.members.filter(depRp =>
300
+ getMemberKeyName(depRp.element as Json.Member) !== packageName
301
+ );
302
+
303
+ if (filtered.length === scopeObj.members.length) {
304
+ result.push(rp);
305
+ continue;
306
+ }
307
+
308
+ changed = true;
309
+
310
+ if (filtered.length === 0) {
311
+ continue;
312
+ }
313
+
314
+ const lastOriginalAfter = scopeObj.members[scopeObj.members.length - 1].after;
315
+ filtered[filtered.length - 1] = {
316
+ ...filtered[filtered.length - 1],
317
+ after: lastOriginalAfter
318
+ };
319
+
320
+ result.push({
321
+ ...rp,
322
+ element: {
323
+ ...member,
324
+ value: {...scopeObj, members: filtered} as Json.Object
325
+ }
326
+ } as Json.RightPadded<Json>);
327
+ }
328
+
329
+ if (!changed) {
330
+ return doc;
331
+ }
332
+
333
+ if (result.length > 0 && result.length < rootObj.members.length) {
334
+ const originalLastAfter = rootObj.members[rootObj.members.length - 1].after;
335
+ result[result.length - 1] = {
336
+ ...result[result.length - 1],
337
+ after: originalLastAfter
338
+ };
339
+ }
340
+
341
+ return {
342
+ ...doc,
343
+ value: {...rootObj, members: result} as Json.Object
344
+ };
345
+ }
@@ -1,7 +1,7 @@
1
1
  import {JavaScriptVisitor} from "./visitor";
2
2
  import {J} from "../java";
3
3
  import {JS, JSX} from "./tree";
4
- import {mapAsync} from "../util";
4
+ import {mapAsync, updateIfChanged} from "../util";
5
5
  import {ElementRemovalFormatter} from "../java";
6
6
 
7
7
  /**
@@ -146,7 +146,7 @@ export class RemoveImport<P> extends JavaScriptVisitor<P> {
146
146
  return this.produceJavaScript(compilationUnit, p, async draft => {
147
147
  const formatter = new ElementRemovalFormatter<J>(true); // Preserve file headers from first import
148
148
 
149
- draft.statements = await mapAsync(compilationUnit.statements, async (stmt) => {
149
+ const newStatements = await mapAsync(compilationUnit.statements, async (stmt) => {
150
150
  const statement = stmt.element;
151
151
 
152
152
  // Handle ES6 imports
@@ -159,7 +159,7 @@ export class RemoveImport<P> extends JavaScriptVisitor<P> {
159
159
  }
160
160
 
161
161
  const finalResult = formatter.processKept(result) as JS.Import;
162
- return {...stmt, element: finalResult};
162
+ return updateIfChanged(stmt, {element: finalResult});
163
163
  }
164
164
 
165
165
  // Handle CommonJS require statements
@@ -174,7 +174,7 @@ export class RemoveImport<P> extends JavaScriptVisitor<P> {
174
174
  }
175
175
 
176
176
  const finalResult = formatter.processKept(result) as J.VariableDeclarations;
177
- return {...stmt, element: finalResult};
177
+ return updateIfChanged(stmt, {element: finalResult});
178
178
  }
179
179
 
180
180
  // Handle JS.ScopedVariableDeclarations (multi-variable var/let/const)
@@ -194,7 +194,7 @@ export class RemoveImport<P> extends JavaScriptVisitor<P> {
194
194
  varFormatter.markRemoved(varDecl);
195
195
  } else {
196
196
  const formattedVarDecl = varFormatter.processKept(result as J.VariableDeclarations);
197
- filteredVariables.push({...v, element: formattedVarDecl});
197
+ filteredVariables.push(updateIfChanged(v, {element: formattedVarDecl}));
198
198
  }
199
199
  } else {
200
200
  filteredVariables.push(v);
@@ -210,20 +210,21 @@ export class RemoveImport<P> extends JavaScriptVisitor<P> {
210
210
  ? formatter.processKept({...scopedVarDecl, variables: filteredVariables})
211
211
  : formatter.processKept(statement);
212
212
 
213
- return {...stmt, element: finalElement};
213
+ return updateIfChanged(stmt, {element: finalElement});
214
214
  }
215
215
 
216
216
  // For any other statement type, apply prefix from removed elements
217
217
  if (statement) {
218
218
  const finalStatement = formatter.processKept(statement);
219
- return {...stmt, element: finalStatement};
219
+ return updateIfChanged(stmt, {element: finalStatement});
220
220
  }
221
221
 
222
222
  return stmt;
223
223
  });
224
224
 
225
- // Filter out undefined (removed) statements
226
- draft.statements = draft.statements.filter(s => s !== undefined);
225
+ draft.statements = newStatements.some(s => s === undefined)
226
+ ? newStatements.filter(s => s !== undefined)
227
+ : newStatements;
227
228
  draft.eof = await this.visitSpace(compilationUnit.eof, p);
228
229
  });
229
230
  }
@@ -58,6 +58,8 @@ export class PrepareRecipe {
58
58
 
59
59
  preparedRecipes.set(id, recipe);
60
60
 
61
+ await this.installSubRecipes(recipe, marketplace);
62
+
61
63
  const result = {
62
64
  id: id,
63
65
  descriptor: await recipe.descriptor(),
@@ -73,6 +75,15 @@ export class PrepareRecipe {
73
75
  );
74
76
  }
75
77
 
78
+ private static async installSubRecipes(recipe: Recipe, marketplace: RecipeMarketplace) {
79
+ for (const subRecipe of await recipe.recipeList()) {
80
+ if (!marketplace.findRecipe(subRecipe.name)) {
81
+ await marketplace.install(subRecipe.constructor as any, []);
82
+ await this.installSubRecipes(subRecipe, marketplace);
83
+ }
84
+ }
85
+ }
86
+
76
87
  /**
77
88
  * For preconditions that can be evaluated on the remote peer, let the remote peer
78
89
  * evaluate them and know that we will only have to do the visit work if the
@@ -110,22 +121,22 @@ export class PrepareRecipe {
110
121
  }
111
122
  )
112
123
  }
113
- this.visitorTypePrecondition(preconditions, visitor.v);
124
+ await this.visitorTypePrecondition(preconditions, visitor.v);
114
125
  } else {
115
- this.visitorTypePrecondition(preconditions, visitor!);
126
+ await this.visitorTypePrecondition(preconditions, visitor!);
116
127
  }
117
128
  return recipe;
118
129
  }
119
130
 
120
- private static visitorTypePrecondition(preconditions: Precondition[], v: TreeVisitor<any, ExecutionContext>): Precondition[] {
131
+ private static async visitorTypePrecondition(preconditions: Precondition[], v: TreeVisitor<any, ExecutionContext>): Promise<Precondition[]> {
121
132
  let treeType: string | undefined;
122
133
 
123
- // Use CommonJS require to defer loading and avoid circular dependencies
124
- const {JsonVisitor} = require("../../json");
125
- const {JavaScriptVisitor} = require("../../javascript");
126
- const {JavaVisitor} = require("../../java");
127
- const {PlainTextVisitor} = require("../../text");
128
- const {YamlVisitor} = require("../../yaml");
134
+ // Use dynamic import to defer loading and avoid circular dependencies
135
+ const {JsonVisitor} = await import("../../json/index.js");
136
+ const {JavaScriptVisitor} = await import("../../javascript/index.js");
137
+ const {JavaVisitor} = await import("../../java/index.js");
138
+ const {PlainTextVisitor} = await import("../../text/index.js");
139
+ const {YamlVisitor} = await import("../../yaml/index.js");
129
140
 
130
141
  if (v instanceof JsonVisitor) {
131
142
  treeType = "org.openrewrite.json.tree.Json";
@@ -13,6 +13,7 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
+ import {expect} from "vitest";
16
17
  import {Recipe} from "../recipe";
17
18
  import {ExecutionContext} from "../execution";
18
19
  import {noopVisitor, TreeVisitor} from "../visitor";
@@ -40,6 +41,7 @@ export interface SourceSpec<T extends SourceFile> {
40
41
 
41
42
  export class RecipeSpec {
42
43
  checkParsePrintIdempotence: boolean = true
44
+ allowEmptyDiff: boolean = false
43
45
 
44
46
  recipe: Recipe = new NoopRecipe()
45
47
 
@@ -162,9 +164,21 @@ export class RecipeSpec {
162
164
 
163
165
  if (!spec.after) {
164
166
  if (after && after !== result?.before) {
165
- expect(await TreePrinters.print(after)).toEqual(dedent(spec.before!));
166
- // TODO: Consider throwing an error, as there should typically have been no change to the LST
167
- // fail("Expected after to be undefined.");
167
+ const actual = await TreePrinters.print(after);
168
+ const expected = dedent(spec.before!);
169
+ if (actual === expected) {
170
+ if (!this.allowEmptyDiff) {
171
+ throw new Error(
172
+ "An empty diff was generated. The recipe incorrectly " +
173
+ "changed the AST without changing the printed output."
174
+ );
175
+ }
176
+ } else {
177
+ throw new Error(
178
+ "Expected no change but recipe modified the file.\n" +
179
+ `Expected:\n${expected}\n\nActual:\n${actual}`
180
+ );
181
+ }
168
182
  }
169
183
  if (spec.afterRecipe) {
170
184
  await spec.afterRecipe(matchingSpec![1]);
package/src/visitor.ts CHANGED
@@ -145,13 +145,18 @@ export abstract class TreeVisitor<T extends Tree, P> {
145
145
  const newMarkers = await this.visitMarkers(before.markers, p);
146
146
 
147
147
  if (recipe) {
148
- // Remove markers before Mutative drafting to avoid cycles, then restore after
148
+ // Remove markers before Mutative drafting to avoid cycles, then restore after.
149
+ // The spread cost is paid unconditionally, but it enables the identity check below.
149
150
  const withoutMarkers = { ...before, markers: emptyMarkers };
150
151
  const result = await produceAsync(withoutMarkers, recipe);
151
152
  if (result === undefined) {
152
153
  return undefined;
153
154
  }
154
- // Restore markers (use newMarkers since we visited them)
155
+ // Mutative's produceAsync returns the same reference when no draft mutations occurred
156
+ // (structural sharing), so reference equality is a reliable no-change check.
157
+ if (result === withoutMarkers && newMarkers === before.markers) {
158
+ return before;
159
+ }
155
160
  return { ...result, markers: newMarkers } as T;
156
161
  }
157
162