@openrewrite/rewrite 8.75.1 → 8.75.3

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 (35) 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/order-imports.d.ts.map +1 -1
  13. package/dist/javascript/recipes/order-imports.js +5 -0
  14. package/dist/javascript/recipes/order-imports.js.map +1 -1
  15. package/dist/javascript/remove-import.d.ts.map +1 -1
  16. package/dist/javascript/remove-import.js +9 -8
  17. package/dist/javascript/remove-import.js.map +1 -1
  18. package/dist/rpc/server.js +0 -0
  19. package/dist/test/rewrite-test.d.ts +1 -0
  20. package/dist/test/rewrite-test.d.ts.map +1 -1
  21. package/dist/test/rewrite-test.js +13 -3
  22. package/dist/test/rewrite-test.js.map +1 -1
  23. package/dist/visitor.d.ts.map +1 -1
  24. package/dist/visitor.js +7 -2
  25. package/dist/visitor.js.map +1 -1
  26. package/package.json +7 -2
  27. package/src/javascript/format/format.ts +99 -71
  28. package/src/javascript/format/minimum-viable-spacing-visitor.ts +1 -4
  29. package/src/javascript/format/whitespace-reconciler.ts +27 -2
  30. package/src/javascript/recipes/order-imports.ts +6 -0
  31. package/src/javascript/remove-import.ts +10 -9
  32. package/src/rpc/server.ts +0 -0
  33. package/src/test/rewrite-test.ts +16 -3
  34. package/src/visitor.ts +7 -2
  35. package/dist/rewrite-javascript-version.txt +0 -1
@@ -322,14 +322,14 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
322
322
 
323
323
  protected async visitImportDeclaration(jsImport: JS.Import, p: P): Promise<J | undefined> {
324
324
  const ret = await super.visitImportDeclaration(jsImport, p) as JS.Import;
325
+ // Mutative detects same-value assignments and returns the original reference
326
+ // when nothing actually changed, so no guard function is needed.
325
327
  return produce(ret, draft => {
326
328
  if (draft.importClause) {
327
- // Space after 'import' keyword:
328
- // - If there's a default import (name), space goes in importClause.prefix
329
- // - If typeOnly (import type ...), space goes in importClause.prefix (before 'type')
330
- // - If only namedBindings (no default, no type), space goes in namedBindings.prefix (importClause.prefix is empty)
331
- const hasDefaultImport = !!draft.importClause.name;
332
- draft.importClause.prefix.whitespace = (hasDefaultImport || draft.importClause.typeOnly) ? " " : "";
329
+ // Space after 'import' keyword always goes on importClause.prefix.
330
+ // The parser is consistent here: both ImportClause and NamedBindings share
331
+ // the same trivia span, but ImportClause consumes it first.
332
+ draft.importClause.prefix.whitespace = " ";
333
333
  if (draft.importClause.name) {
334
334
  // For import equals declarations (import X = Y), use assignment spacing
335
335
  // For regular imports (import X from 'Y'), no space after name
@@ -338,22 +338,22 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
338
338
  : "";
339
339
  }
340
340
  if (draft.importClause.namedBindings) {
341
- // Space before namedBindings - always needed
342
- draft.importClause.namedBindings.prefix.whitespace = " ";
341
+ const hasDefaultImport = !!draft.importClause.name;
342
+ if (hasDefaultImport || draft.importClause.typeOnly) {
343
+ draft.importClause.namedBindings.prefix.whitespace = " ";
344
+ }
343
345
  if (draft.importClause.namedBindings.kind == JS.Kind.NamedImports) {
344
346
  const ni = draft.importClause.namedBindings as Draft<JS.NamedImports>;
345
- // Check if this is a multi-line import (any element's prefix has a newline)
346
347
  const isMultiLine = ni.elements.elements.some(e => e.element.prefix.whitespace.includes("\n"));
347
348
  if (!isMultiLine) {
348
- // Single-line: adjust brace spacing
349
- ni.elements.elements[0].element.prefix.whitespace = this.style.within.es6ImportExportBraces ? " " : "";
350
- ni.elements.elements[ni.elements.elements.length - 1].after.whitespace = this.style.within.es6ImportExportBraces ? " " : "";
349
+ const braceSpace = this.style.within.es6ImportExportBraces ? " " : "";
350
+ ni.elements.elements[0].element.prefix.whitespace = braceSpace;
351
+ ni.elements.elements[ni.elements.elements.length - 1].after.whitespace = braceSpace;
351
352
  } else {
352
353
  // Multi-line: apply beforeComma rule to last element's after (for trailing commas)
353
- // If it has only spaces (no newline), it's the space before a trailing comma
354
- const lastAfter = ni.elements.elements[ni.elements.elements.length - 1].after.whitespace;
355
- if (!lastAfter.includes("\n") && lastAfter.trim() === "") {
356
- ni.elements.elements[ni.elements.elements.length - 1].after.whitespace = this.style.other.beforeComma ? " " : "";
354
+ const lastAfter = ni.elements.elements[ni.elements.elements.length - 1].after;
355
+ if (!lastAfter.whitespace.includes("\n") && lastAfter.whitespace.trim() === "") {
356
+ lastAfter.whitespace = this.style.other.beforeComma ? " " : "";
357
357
  }
358
358
  }
359
359
  }
@@ -751,6 +751,9 @@ export class WrappingAndBracesVisitor<P> extends JavaScriptVisitor<P> {
751
751
 
752
752
  protected async visitVariableDeclarations(multiVariable: J.VariableDeclarations, p: P): Promise<J.VariableDeclarations> {
753
753
  const v = await super.visitVariableDeclarations(multiVariable, p) as J.VariableDeclarations;
754
+ if (v.leadingAnnotations.length === 0) {
755
+ return v;
756
+ }
754
757
  const parent = this.cursor.parentTree()?.value;
755
758
  if (parent?.kind === J.Kind.Block) {
756
759
  return produce(v, draft => {
@@ -769,6 +772,9 @@ export class WrappingAndBracesVisitor<P> extends JavaScriptVisitor<P> {
769
772
 
770
773
  protected async visitMethodDeclaration(method: J.MethodDeclaration, p: P): Promise<J.MethodDeclaration> {
771
774
  const m = await super.visitMethodDeclaration(method, p) as J.MethodDeclaration;
775
+ if (m.leadingAnnotations.length === 0) {
776
+ return m;
777
+ }
772
778
  return produce(m, draft => {
773
779
  draft.leadingAnnotations = this.withNewlines(draft.leadingAnnotations);
774
780
  if (draft.leadingAnnotations.length > 0) {
@@ -789,21 +795,29 @@ export class WrappingAndBracesVisitor<P> extends JavaScriptVisitor<P> {
789
795
  const e = await super.visitElse(elsePart, p) as J.If.Else;
790
796
  const hasBody = e.body.element.kind === J.Kind.Block || e.body.element.kind === J.Kind.If;
791
797
 
792
- return produce(e, draft => {
793
- if (hasBody) {
794
- const shouldHaveNewline = this.style.ifStatement.elseOnNewLine;
795
- const hasNewline = draft.prefix.whitespace.includes("\n");
798
+ if (!hasBody) {
799
+ return e;
800
+ }
801
+
802
+ const shouldHaveNewline = this.style.ifStatement.elseOnNewLine;
803
+ const hasNewline = e.prefix.whitespace.includes("\n");
804
+ if ((shouldHaveNewline && !hasNewline) || (!shouldHaveNewline && hasNewline)) {
805
+ return produce(e, draft => {
796
806
  if (shouldHaveNewline && !hasNewline) {
797
807
  draft.prefix.whitespace = "\n" + draft.prefix.whitespace;
798
- } else if (!shouldHaveNewline && hasNewline) {
808
+ } else {
799
809
  draft.prefix.whitespace = "";
800
810
  }
801
- }
802
- });
811
+ });
812
+ }
813
+ return e;
803
814
  }
804
815
 
805
816
  protected async visitClassDeclaration(classDecl: J.ClassDeclaration, p: P): Promise<J.ClassDeclaration> {
806
817
  const j = await super.visitClassDeclaration(classDecl, p) as J.ClassDeclaration;
818
+ if (j.leadingAnnotations.length === 0) {
819
+ return j;
820
+ }
807
821
  return produce(j, draft => {
808
822
  draft.leadingAnnotations = this.withNewlines(draft.leadingAnnotations);
809
823
  if (draft.leadingAnnotations.length > 0) {
@@ -821,61 +835,75 @@ export class WrappingAndBracesVisitor<P> extends JavaScriptVisitor<P> {
821
835
 
822
836
  protected async visitBlock(block: J.Block, p: P): Promise<J.Block> {
823
837
  const b = await super.visitBlock(block, p) as J.Block;
824
- return produce(b, draft => {
825
- const parentKind = this.cursor.parent?.value.kind;
826
-
827
- // Check if this is a "simple" block (empty or contains only a single J.Empty)
828
- const isSimpleBlock = draft.statements.length === 0 ||
829
- (draft.statements.length === 1 && draft.statements[0].element.kind === J.Kind.Empty);
838
+ const parentKind = this.cursor.parent?.value.kind;
830
839
 
831
- // Helper to format block on one line
832
- const formatOnOneLine = () => {
833
- // Format as {} - remove any newlines from end whitespace
834
- if (draft.end.whitespace.includes("\n")) {
835
- draft.end.whitespace = draft.end.whitespace.replace(/\n\s*/g, "");
836
- }
837
- // Also remove newlines from statement padding if there's a J.Empty
838
- if (draft.statements.length === 1) {
839
- if (draft.statements[0].element.prefix.whitespace.includes("\n")) {
840
- draft.statements[0].element.prefix.whitespace = "";
841
- }
842
- if (draft.statements[0].after.whitespace.includes("\n")) {
843
- draft.statements[0].after.whitespace = "";
844
- }
845
- }
846
- };
840
+ // Check if this is a "simple" block (empty or contains only a single J.Empty)
841
+ const isSimpleBlock = b.statements.length === 0 ||
842
+ (b.statements.length === 1 && b.statements[0].element.kind === J.Kind.Empty);
847
843
 
848
- // Object literals and type literals: always format empty ones as {} on single line
849
- if (parentKind === J.Kind.NewClass || parentKind === JS.Kind.TypeLiteral) {
850
- if (isSimpleBlock) {
851
- formatOnOneLine();
852
- }
853
- return;
844
+ // Object literals and type literals: always format empty ones as {} on single line
845
+ if (parentKind === J.Kind.NewClass || parentKind === JS.Kind.TypeLiteral) {
846
+ if (isSimpleBlock && this.blockIsMultiLine(b)) {
847
+ return produce(b, draft => {
848
+ this.formatBlockOnOneLine(draft);
849
+ });
854
850
  }
851
+ return b;
852
+ }
855
853
 
856
- if (isSimpleBlock) {
857
- // Determine which style option applies based on parent
858
- const isMethodOrFunctionBody = parentKind === J.Kind.Lambda ||
859
- parentKind === J.Kind.MethodDeclaration;
860
- const keepInOneLine = isMethodOrFunctionBody
861
- ? this.style.keepWhenReformatting.simpleMethodsInOneLine
862
- : this.style.keepWhenReformatting.simpleBlocksInOneLine;
863
-
864
- if (keepInOneLine) {
865
- formatOnOneLine();
866
- } else {
867
- // Format with newline between { and }
868
- if (!draft.end.whitespace.includes("\n")) {
869
- draft.end = this.withNewlineSpace(draft.end);
870
- }
854
+ if (isSimpleBlock) {
855
+ const isMethodOrFunctionBody = parentKind === J.Kind.Lambda ||
856
+ parentKind === J.Kind.MethodDeclaration;
857
+ const keepInOneLine = isMethodOrFunctionBody
858
+ ? this.style.keepWhenReformatting.simpleMethodsInOneLine
859
+ : this.style.keepWhenReformatting.simpleBlocksInOneLine;
860
+
861
+ if (keepInOneLine) {
862
+ if (this.blockIsMultiLine(b)) {
863
+ return produce(b, draft => {
864
+ this.formatBlockOnOneLine(draft);
865
+ });
871
866
  }
872
867
  } else {
873
- // Non-simple blocks: ensure closing brace is on its own line
874
- if (!draft.end.whitespace.includes("\n") && !draft.statements[draft.statements.length - 1].after.whitespace.includes("\n")) {
875
- draft.end = this.withNewlineSpace(draft.end);
868
+ if (!b.end.whitespace.includes("\n")) {
869
+ return produce(b, draft => {
870
+ draft.end = this.withNewlineSpace(draft.end);
871
+ });
876
872
  }
877
873
  }
878
- });
874
+ } else {
875
+ // Non-simple blocks: ensure closing brace is on its own line
876
+ if (!b.end.whitespace.includes("\n") && !b.statements[b.statements.length - 1].after.whitespace.includes("\n")) {
877
+ return produce(b, draft => {
878
+ draft.end = this.withNewlineSpace(draft.end);
879
+ });
880
+ }
881
+ }
882
+
883
+ return b;
884
+ }
885
+
886
+ private blockIsMultiLine(b: J.Block): boolean {
887
+ if (b.end.whitespace.includes("\n")) return true;
888
+ if (b.statements.length === 1) {
889
+ if (b.statements[0].element.prefix.whitespace.includes("\n")) return true;
890
+ if (b.statements[0].after.whitespace.includes("\n")) return true;
891
+ }
892
+ return false;
893
+ }
894
+
895
+ private formatBlockOnOneLine(draft: Draft<J.Block>): void {
896
+ if (draft.end.whitespace.includes("\n")) {
897
+ draft.end.whitespace = draft.end.whitespace.replace(/\n\s*/g, "");
898
+ }
899
+ if (draft.statements.length === 1) {
900
+ if (draft.statements[0].element.prefix.whitespace.includes("\n")) {
901
+ draft.statements[0].element.prefix.whitespace = "";
902
+ }
903
+ if (draft.statements[0].after.whitespace.includes("\n")) {
904
+ draft.statements[0].after.whitespace = "";
905
+ }
906
+ }
879
907
  }
880
908
 
881
909
  protected async visitSwitch(aSwitch: J.Switch, p: P): Promise<J | undefined> {
@@ -1100,7 +1128,7 @@ export class BlankLinesVisitor<P> extends JavaScriptVisitor<P> {
1100
1128
  }
1101
1129
  }
1102
1130
 
1103
- private ensurePrefixHasNewLine<T extends J>(node: Draft<J>) {
1131
+ private ensurePrefixHasNewLine(node: Draft<J>) {
1104
1132
  if (!node.prefix) return;
1105
1133
 
1106
1134
  // Check if newline already exists in the effective last whitespace
@@ -84,6 +84,7 @@ export class MinimumViableSpacingVisitor<P> extends JavaScriptVisitor<P> {
84
84
 
85
85
  // Note: typeParameters should NOT have space before them - they immediately follow the class name
86
86
  // e.g., "class DataTable<Row>" not "class DataTable <Row>"
87
+ // Note: body.prefix spacing (space before '{') is handled by SpacesVisitor, not here.
87
88
 
88
89
  if (c.extends && c.extends.before.whitespace === "") {
89
90
  c = produce(c, draft => {
@@ -100,10 +101,6 @@ export class MinimumViableSpacingVisitor<P> extends JavaScriptVisitor<P> {
100
101
  });
101
102
  }
102
103
 
103
- c = produce(c, draft => {
104
- draft.body.prefix.whitespace = "";
105
- });
106
-
107
104
  return c;
108
105
  }
109
106
 
@@ -13,7 +13,7 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import {isIdentifier, isLiteral, isSpace, J, Type} from '../../java';
16
+ import {isIdentifier, isLiteral, isSpace, J, TextComment, Type} from '../../java';
17
17
  import {JS} from "../tree";
18
18
 
19
19
  /**
@@ -163,7 +163,9 @@ export class WhitespaceReconciler {
163
163
 
164
164
  // Space nodes - copy when reconciling, don't recurse
165
165
  if (isSpace(original)) {
166
- return this.shouldReconcile() ? formatted : original;
166
+ if (!this.shouldReconcile()) return original;
167
+ if (isSpace(formatted) && this.spacesEqual(original, formatted as J.Space)) return original;
168
+ return formatted;
167
169
  }
168
170
 
169
171
  // Track entering target subtree (using referential equality)
@@ -247,6 +249,10 @@ export class WhitespaceReconciler {
247
249
  // Space values and markers: copy from formatted when reconciling
248
250
  if ((isSpace(originalValue)) || key === 'markers') {
249
251
  if (this.shouldReconcile() && formattedValue !== originalValue) {
252
+ // For spaces, check structural equality to avoid unnecessary new objects
253
+ if (isSpace(originalValue) && isSpace(formattedValue) && this.spacesEqual(originalValue, formattedValue as J.Space)) {
254
+ continue;
255
+ }
250
256
  result = { ...result, [key]: formattedValue } as VisitableNode;
251
257
  }
252
258
  continue;
@@ -297,6 +303,25 @@ export class WhitespaceReconciler {
297
303
  return this.reconcileState === 'reconciling';
298
304
  }
299
305
 
306
+ /**
307
+ * Structurally compare two Space objects for equality.
308
+ * Currently only TextComment has additional properties (text, multiline);
309
+ * if new comment types are added, extend the comparison here.
310
+ */
311
+ private spacesEqual(a: J.Space, b: J.Space): boolean {
312
+ if (a.whitespace !== b.whitespace) return false;
313
+ if (a.comments.length !== b.comments.length) return false;
314
+ for (let i = 0; i < a.comments.length; i++) {
315
+ const ca = a.comments[i], cb = b.comments[i];
316
+ if (ca.kind !== cb.kind || ca.suffix !== cb.suffix) return false;
317
+ if (ca.kind === J.Kind.TextComment) {
318
+ const ta = ca as TextComment, tb = cb as TextComment;
319
+ if (ta.text !== tb.text || ta.multiline !== tb.multiline) return false;
320
+ }
321
+ }
322
+ return true;
323
+ }
324
+
300
325
  /**
301
326
  * Checks if two nodes with different kinds are semantically equivalent.
302
327
  * This handles cases like Prettier's quoteProps option which can change
@@ -88,6 +88,12 @@ export class OrderImports extends Recipe {
88
88
  return originalImportPosition[aPadded.element.id] - originalImportPosition[bPadded.element.id];
89
89
  });
90
90
 
91
+ // Check if anything actually changed
92
+ const alreadySorted = sortedSpecifiers.every((s, i) => s === imports[i]);
93
+ if (alreadySorted) {
94
+ return cu;
95
+ }
96
+
91
97
  const cuWithImportsSorted = await produceAsync(cu, async draft => {
92
98
  draft.statements = [...sortedSpecifiers, ...restStatements];
93
99
  });
@@ -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
  }
package/src/rpc/server.ts CHANGED
File without changes
@@ -40,6 +40,7 @@ export interface SourceSpec<T extends SourceFile> {
40
40
 
41
41
  export class RecipeSpec {
42
42
  checkParsePrintIdempotence: boolean = true
43
+ allowEmptyDiff: boolean = false
43
44
 
44
45
  recipe: Recipe = new NoopRecipe()
45
46
 
@@ -162,9 +163,21 @@ export class RecipeSpec {
162
163
 
163
164
  if (!spec.after) {
164
165
  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.");
166
+ const actual = await TreePrinters.print(after);
167
+ const expected = dedent(spec.before!);
168
+ if (actual === expected) {
169
+ if (!this.allowEmptyDiff) {
170
+ throw new Error(
171
+ "An empty diff was generated. The recipe incorrectly " +
172
+ "changed the AST without changing the printed output."
173
+ );
174
+ }
175
+ } else {
176
+ throw new Error(
177
+ "Expected no change but recipe modified the file.\n" +
178
+ `Expected:\n${expected}\n\nActual:\n${actual}`
179
+ );
180
+ }
168
181
  }
169
182
  if (spec.afterRecipe) {
170
183
  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
 
@@ -1 +0,0 @@
1
- 8.75.1