Repository: prettier/prettier (JavaScript)

Issue:
Different output for optional call/member expression using `typescript` parser vs `babel` and `babel-ts`
**Prettier 3.1.1**
[Playground link](https://prettier.io/playground/#N4Igxg9gdgLgprEAuEB6VACAwgQQMoCiGAjEgDpToYByEATgLYCGANhgEZwAWTAbgJb0ANBhhc4GAAb8AzgQCOAV1aSMYVm3Z04TANYyAdBQBmiqGBiCoGGRAZwAYmYtXqTewAoKGG3bgAZaABzACFtPX4oIIAFJjp3OHg6N3shbwwmKAgxODpAqNj4+ySUuDSoAEoMYHTtGEU6a1kFZRYPJgNmAAcPDwBtGBEAfQBdKoBeAD5RAH4DfgATCpF2TqYe-sGMUYnpmDnFioqAbgoAXwoKKgB1cWsmMDA4GRlRCCkmVUyFqXZVWQwEC6lmgrBEMAAnl1nmA6PxgRgFhBnlAAOQwDjhXRXTA5NQaFaKDHsJicNjfDikuAsAC0MFeSKMUFM5hB1ls9icrNcCS81l89nyoSxkRicQSJQS5R8mWy4jywUKEtypXKVRq-LqDSaciUrHac26vQGwzGGCms3mSxWhvWxq2O3NewOSxO50ulEwuEIGAATORPTR6MxNNw+II6ODxFJjPwWElVOoWGxFDJnhkMEE6BBFNCfiwmBCcxiPF0ia8cjiMnRswB3DBwAAeXW0LysGC64uKuVe0FE0ZkCQwLEiEiYrzIIFj8dyk4qTMgUBkGJyADVWIoJOMMAB5dgAKzgFgMCBgcOeHg5AWCe8PFlK8+nST5Pn6I+XooAkgszRar-5ZBgAAVCAsDsTttBAgAJXI4HmcwWEUBYL3fSwom-ZYKDdKAq2ufgxAyawgTZVgMHsBhODoBtm1bGQrBWKlaXpDBdDgOAugrLgAXHDMsxzPMqwLIsiUJFcoRhOEETMPjc1efDAVgd4mAwKA4HrEdVIXaBl37OB10QrddwPI8YBPWBzxkS8-CFW8TIfAwn1yF8MDfQCvx-XYBQCQCQLAhgILgaDYIOBCkJQtz0OtLDThwwNvSIABmAMqFoRhSM4HgBGEXTRDgfyC3gaiW2eOi+y0HR9AyFdo1jOgdMiJIuggAqrCZFgICCIJcgMdqgmcyQcGHYJcvyphCuXOEogwWt8K4DNFwWfCrFYJAMAAEmAMAeCgVSWCtM4RApHJ2T8Mj6AkRd4FgfsxrUaA0M3V4zEsNgNoAKTwHdqAMCbRX4YwIQ8dr1DZCozkkaKPRuWbCMBYFlrYcjKKK2j6NEcSZFheEMSRFF0UxCqctq5cqwa3ImpavsKUiZcdB+cq9E4iQ0wWtqOq6ugeo6-rBvaqb4FG8az1FaaYeUhaltBFhVo2rbMl2l0DsIn5jq8s7tDu2BTxujFLsiR6MGeuN1uAD6vp+4Won+wHgbGqwwYhypjhAIQQGIqwZGQUA4jrQoEE9lBWFrQtPddrQHlYmA8ASADVOQYxWDTMP4jASO8E7MBRWQM9N1dtMGH4bO6FzkAm2hOF7FgVggNyKA4n4Z548TsoQDoqIWDgABFRRsjgJuWCTkB9xkRs8FFDvu97-vB6UXvomzDjkBAccaVUuBkIWF2QDPJg41FPzmCXjQt7boIO5wGBhfYIk4GiXJY77pAE4HluuBgBgWFufDngzuA8H9paAhIRLzAC8LevBNyfigMhWAeAsbAhwNAvAkIO7Txbi2CAaZrjxC6EvYqaY6C8D7q7GmuQYDzyYEEQ+T9m6uwggQpeJIyRb20Eofg2gKFUKYGg12MBSR4QWGIZAvpXapkCqSAOz9B55U4AsDe-hMhBGUF1BwwYxpoSCEfIkEAt4tgagIoRSBiC+gAAxnDOEAA)
<!-- prettier-ignore -->
```sh
--parser babel
--print-width 120
```

**Input:**
<!-- prettier-ignore -->
```jsx
// CASE 1:
// Normal behavior, the `isEqual` call breaks.
function someFunctionName(
  someLongBreakingParameterName,
  anotherLongParameterName,
) {
  return isEqual(a.map(([t, _]) => t?.id), b.map(([t, _]) => t?.id));
}

// When access to `a` and `b` is optional, typescript doesn't break
// the call, but babel and babel-ts do.
function someFunctionName(
  someLongBreakingParameterName,
  anotherLongParameterName,
) {
  return isEqual(a?.map(([t, _]) => t?.id), b?.map(([t, _]) => t?.id));
}

// CASE 2:
// Normal behavior, the `filter` call uses a grouped layout (puts the
// arrow expression parameters on the same line as "filter").
const theValue = Object.entries(someLongObjectName).filter(
  ([listingId]) => someListToCompareToHere.includes(listingId),
);

// With an optional member expression, babel-ts keeps this as a grouped
// layout, but typescript ungroups it onto a new line.
const theValue = Object.entries(someLongObjectName).filter(
  ([listingId]) => someListToCompareToHere?.includes(listingId),
);

// CASE 3:
// Normal behavior, the template expression breaks at the first interpolation.
logger.log(
  `A long template string with a conditional: ${channel.id}, and then some more content that continues until ${JSON.stringify(location)}`
);

// With an optional member expression, typescript doesn't break the first
// interpolation and instead breaks the second.
logger.log(
  `A long template string with a conditional: ${channel?.id}, and then some more content that continues until ${JSON.stringify(location)}`
);
```

**Output:**
<!-- prettier-ignore -->
```jsx
// CASE 1:
// Normal behavior, the `isEqual` call breaks.
function someFunctionName(someLongBreakingParameterName, anotherLongParameterName) {
  return isEqual(
    a.map(([t, _]) => t?.id),
    b.map(([t, _]) => t?.id),
  );
}

// When access to `a` and `b` is optional, typescript doesn't break
// the call, but babel and babel-ts do.
function someFunctionName(someLongBreakingParameterName, anotherLongParameterName) {
  return isEqual(
    a?.map(([t, _]) => t?.id),
    b?.map(([t, _]) => t?.id),
  );
}

// CASE 2:
// Normal behavior, the `filter` call uses a grouped layout (puts the
// arrow expression parameters on the same line as "filter").
const theValue = Object.entries(someLongObjectName).filter(([listingId]) =>
  someListToCompareToHere.includes(listingId),
);

// With an optional member expression, babel-ts keeps this as a grouped
// layout, but typescript ungroups it onto a new line.
const theValue = Object.entries(someLongObjectName).filter(([listingId]) =>
  someListToCompareToHere?.includes(listingId),
);

// CASE 3:
// Normal behavior, the template expression breaks at the first interpolation.
logger.log(
  `A long template string with a conditional: ${
    channel.id
  }, and then some more content that continues until ${JSON.stringify(location)}`,
);

// With an optional member expression, typescript doesn't break the first
// interpolation and instead breaks the second.
logger.log(
  `A long template string with a conditional: ${
    channel?.id
  }, and then some more content that continues until ${JSON.stringify(location)}`,
);

```

**Expected behavior:**

The output from each of these 3 cases should match regardless of which parser is used (`babel`, `babel-ts`, or `typescript`).

The consistent difference here is whether the expression is `optional` or not. Inspecting the parsed ASTs, Babel (and thus babel-ts) parses these as distinct node types (`OptionalCallExpression` and `OptionalMemberExpression`), but the TypeScript parser just uses the plain `CallExpression` and `MemberExpression` nodes with an extra `optional` property on them.

From https://github.com/prettier/prettier/issues/10244, it seems like maybe in the past (or in a different situation), these were parsed as `ChainExpression`, which then got transformed by Prettier into the appropriate optional expression nodes, but that is no longer the case, so the transform doesn't pick them up and convert as needed.




Hints:
As an additional note, it looks like the parser hierarchy is set up to prefer `typescript` first and then use `babel-ts` otherwise: https://github.com/prettier/prettier/blob/c1ebae2e8303841e59bc02e9e719a93e964ab0e9/src/language-js/languages.evaluate.js#L61.

I wonder if preferring `babel-ts` first would make sense, though, since that's already true for regular js, and it would mean more consistent parsing to match it. Obviously TypeScript itself is the best source of truth, but the potential differences in the AST over time will lead to this kind of drift and might be avoidable by using the same parser when possible.
For CASE1, it probably comes from here.
https://github.com/prettier/prettier/blob/1d45bce62575cc4415225545a4d6941f9b91748a/src/language-js/utils/index.js#L718

To find anonymous function in argument, it only checks CallExpression and OptionalCallExpression. However, for TypeScript ESTree, expression like `a?.map(([t, _]) => t?.id)` is not CallExpression nor OptionalCallExpression, but ChainExpression, that has CallExpression as a child ([playground](https://prettier.io/playground/#N4Igxg9gdgLgprEAuEAjA-AOgLYEMAOAFIQNowA0ABAPoC6AlJQLwB8lMWAlgCb0A6UEORAR8MTtADOyULgBOciAHcACvITSUuADZLcAT2nDUc3GADWcGAGVc2OABlOUOMgBmOyXGOmLV6-hmzgDmyDByAK7eIF7YnGGR0XAAHvhwcpz2sDoAKulQ8pxwmh7aXsKSIdpwAIoREPDuntEAVpLJ1lW19Y1IpeUgAI49cCqK+JoguJIAtC5w3AtCIOG4nNohAMIQ2HjIU9ray5VQwdUAgjDhnKgR8CrpTi5NZdEAFjDY2gDqb5zwkkCYDg1g0-04ADd-vp9mBJEYQBCogBJKCLWDWMAZMTnNHWGD6aovAb4RReb6mfD7UnFdIQ1zCZxeOQwMa4YJ7PrNYSBOTM-YEtKSLGcMTLUnOGDfHgwN7IACMACYAAzCORwYacdVsjm4YnRGC4VDS7iy5CK4QRLw5I0lbkgODYVALRbcBy4U4RdlwABiEDkeCuIX2uDuEBAAF8I0A)).

Perhaps `isCallExpression` can cause other similar inconsistencies.
Edit: A similar case looks handled well:
https://github.com/prettier/prettier/blob/eebf0e4b5ec8ac24393c56ced4b4819d4c551f31/src/language-js/print/assignment.js#L352

Patch:
diff --git a/src/language-js/print/call-expression.js b/src/language-js/print/call-expression.js
--- a/src/language-js/print/call-expression.js
+++ b/src/language-js/print/call-expression.js
@@ -61,7 +61,11 @@ function printCallExpression(path, options, print) {
     !isDynamicImport &&
     !isNew &&
     isMemberish(node.callee) &&
-    !path.call((path) => pathNeedsParens(path, options), "callee")
+    !path.call(
+      (path) => pathNeedsParens(path, options),
+      "callee",
+      ...(node.callee.type === "ChainExpression" ? ["expression"] : []),
+    )
   ) {
     return printMemberChain(path, options, print);
   }
diff --git a/src/language-js/print/member-chain.js b/src/language-js/print/member-chain.js
--- a/src/language-js/print/member-chain.js
+++ b/src/language-js/print/member-chain.js
@@ -50,6 +50,14 @@ import {
 // MemberExpression and CallExpression. We need to traverse the AST
 // and make groups out of it to print it in the desired way.
 function printMemberChain(path, options, print) {
+  /* c8 ignore next 6 */
+  if (path.node.type === "ChainExpression") {
+    return path.call(
+      () => printMemberChain(path, options, print),
+      "expression",
+    );
+  }
+
   const { parent } = path;
   const isExpressionStatement =
     !parent || parent.type === "ExpressionStatement";
@@ -88,6 +96,11 @@ function printMemberChain(path, options, print) {
 
   function rec(path) {
     const { node } = path;
+
+    if (node.type === "ChainExpression") {
+      return path.call(() => rec(path), "expression");
+    }
+
     if (
       isCallExpression(node) &&
       (isMemberish(node.callee) || isCallExpression(node.callee))
diff --git a/src/language-js/print/member.js b/src/language-js/print/member.js
--- a/src/language-js/print/member.js
+++ b/src/language-js/print/member.js
@@ -1,19 +1,31 @@
 import { group, indent, label, softline } from "../../document/builders.js";
 import {
+  getCallArguments,
   isCallExpression,
   isMemberExpression,
   isNumericLiteral,
 } from "../utils/index.js";
 import { printOptionalToken } from "./misc.js";
 
+const isCallExpressionWithArguments = (node) => {
+  if (node.type === "ChainExpression" || node.type === "TSNonNullExpression") {
+    node = node.expression;
+  }
+  return isCallExpression(node) && getCallArguments(node).length > 0;
+};
+
 function printMemberExpression(path, options, print) {
   const objectDoc = print("object");
   const lookupDoc = printMemberLookup(path, options, print);
-  const { node, parent } = path;
+  const { node } = path;
   const firstNonMemberParent = path.findAncestor(
     (node) =>
       !(isMemberExpression(node) || node.type === "TSNonNullExpression"),
   );
+  const firstNonChainElementWrapperParent = path.findAncestor(
+    (node) =>
+      !(node.type === "ChainExpression" || node.type === "TSNonNullExpression"),
+  );
 
   const shouldInline =
     (firstNonMemberParent &&
@@ -24,13 +36,10 @@ function printMemberExpression(path, options, print) {
     node.computed ||
     (node.object.type === "Identifier" &&
       node.property.type === "Identifier" &&
-      !isMemberExpression(parent)) ||
-    ((parent.type === "AssignmentExpression" ||
-      parent.type === "VariableDeclarator") &&
-      ((isCallExpression(node.object) && node.object.arguments.length > 0) ||
-        (node.object.type === "TSNonNullExpression" &&
-          isCallExpression(node.object.expression) &&
-          node.object.expression.arguments.length > 0) ||
+      !isMemberExpression(firstNonChainElementWrapperParent)) ||
+    ((firstNonChainElementWrapperParent.type === "AssignmentExpression" ||
+      firstNonChainElementWrapperParent.type === "VariableDeclarator") &&
+      (isCallExpressionWithArguments(node.object) ||
         objectDoc.label?.memberChain));
 
   return label(objectDoc.label, [
diff --git a/src/language-js/utils/create-type-check-function.js b/src/language-js/utils/create-type-check-function.js
--- a/src/language-js/utils/create-type-check-function.js
+++ b/src/language-js/utils/create-type-check-function.js
@@ -1,5 +1,12 @@
-function createTypeCheckFunction(types) {
-  types = new Set(types);
+/** @typedef {import("../types/estree.js").Node} Node */
+/** @typedef {import("../types/estree.js").Comment} Comment */
+
+/**
+ * @param {string[]} typesArray
+ * @returns {(node: Node | Comment) => Boolean}
+ */
+function createTypeCheckFunction(typesArray) {
+  const types = new Set(typesArray);
   return (node) => types.has(node?.type);
 }
 
diff --git a/src/language-js/utils/index.js b/src/language-js/utils/index.js
--- a/src/language-js/utils/index.js
+++ b/src/language-js/utils/index.js
@@ -124,10 +124,6 @@ const isLineComment = createTypeCheckFunction([
   "InterpreterDirective",
 ]);
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
 const isExportDeclaration = createTypeCheckFunction([
   "ExportDefaultDeclaration",
   "DeclareExportDeclaration",
@@ -136,19 +132,11 @@ const isExportDeclaration = createTypeCheckFunction([
   "DeclareExportAllDeclaration",
 ]);
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
 const isArrayOrTupleExpression = createTypeCheckFunction([
   "ArrayExpression",
   "TupleExpression",
 ]);
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
 const isObjectOrRecordExpression = createTypeCheckFunction([
   "ObjectExpression",
   "RecordExpression",
@@ -191,10 +179,6 @@ function isRegExpLiteral(node) {
   );
 }
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
 const isLiteral = createTypeCheckFunction([
   "Literal",
   "BooleanLiteral",
@@ -207,10 +191,6 @@ const isLiteral = createTypeCheckFunction([
   "StringLiteral",
 ]);
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
 const isSingleWordType = createTypeCheckFunction([
   "Identifier",
   "ThisExpression",
@@ -220,20 +200,12 @@ const isSingleWordType = createTypeCheckFunction([
   "Import",
 ]);
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
 const isObjectType = createTypeCheckFunction([
   "ObjectTypeAnnotation",
   "TSTypeLiteral",
   "TSMappedType",
 ]);
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
 const isFunctionOrArrowExpression = createTypeCheckFunction([
   "FunctionExpression",
   "ArrowFunctionExpression",
@@ -269,10 +241,6 @@ function isAngularTestWrapper(node) {
   );
 }
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
 const isJsxElement = createTypeCheckFunction(["JSXElement", "JSXFragment"]);
 
 function isGetterOrSetter(node) {
@@ -314,10 +282,6 @@ function isTypeAnnotationAFunction(node) {
   );
 }
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
 const isBinaryish = createTypeCheckFunction([
   "BinaryExpression",
   "LogicalExpression",
@@ -438,23 +402,22 @@ function isTestCall(node, parent) {
   return false;
 }
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
-const isCallExpression = createTypeCheckFunction([
-  "CallExpression",
-  "OptionalCallExpression",
-]);
+/** @return {(node: Node) => boolean} */
+const skipChainExpression = (fn) => (node) => {
+  if (node?.type === "ChainExpression") {
+    node = node.expression;
+  }
 
-/**
- * @param {Node} node
- * @returns {boolean}
- */
-const isMemberExpression = createTypeCheckFunction([
-  "MemberExpression",
-  "OptionalMemberExpression",
-]);
+  return fn(node);
+};
+
+const isCallExpression = skipChainExpression(
+  createTypeCheckFunction(["CallExpression", "OptionalCallExpression"]),
+);
+
+const isMemberExpression = skipChainExpression(
+  createTypeCheckFunction(["MemberExpression", "OptionalMemberExpression"]),
+);
 
 /**
  *
@@ -717,7 +680,7 @@ function isFunctionCompositionArgs(args) {
         return true;
       }
     } else if (isCallExpression(arg)) {
-      for (const childArg of arg.arguments) {
+      for (const childArg of getCallArguments(arg)) {
         if (isFunctionOrArrowExpression(childArg)) {
           return true;
         }
@@ -1050,6 +1013,10 @@ function getCallArguments(node) {
     return callArgumentsCache.get(node);
   }
 
+  if (node.type === "ChainExpression") {
+    return getCallArguments(node.expression);
+  }
+
   let args = node.arguments;
   if (node.type === "ImportExpression") {
     args = [node.source];
@@ -1071,6 +1038,14 @@ function getCallArguments(node) {
 
 function iterateCallArgumentsPath(path, iteratee) {
   const { node } = path;
+
+  if (node.type === "ChainExpression") {
+    return path.call(
+      () => iterateCallArgumentsPath(path, iteratee),
+      "expression",
+    );
+  }
+
   if (node.type === "ImportExpression") {
     path.call((sourcePath) => iteratee(sourcePath, 0), "source");
 
@@ -1089,17 +1064,22 @@ function iterateCallArgumentsPath(path, iteratee) {
 }
 
 function getCallArgumentSelector(node, index) {
+  const selectors = [];
+  if (node.type === "ChainExpression") {
+    selectors.push("expression");
+  }
+
   if (node.type === "ImportExpression") {
     if (index === 0 || index === (node.attributes || node.options ? -2 : -1)) {
-      return "source";
+      return [...selectors, "source"];
     }
     // import attributes
     if (node.attributes && (index === 1 || index === -1)) {
-      return "attributes";
+      return [...selectors, "attributes"];
     }
     // deprecated import assertions
     if (node.options && (index === 1 || index === -1)) {
-      return "options";
+      return [...selectors, "options"];
     }
     throw new RangeError("Invalid argument index");
   }
@@ -1110,7 +1090,7 @@ function getCallArgumentSelector(node, index) {
   if (index < 0 || index >= node.arguments.length) {
     throw new RangeError("Invalid argument index");
   }
-  return ["arguments", index];
+  return [...selectors, "arguments", index];
 }
 
 function isPrettierIgnoreComment(comment) {


Test patch:
diff --git a/tests/format/js/chain-expression/__snapshots__/jsfmt.spec.js.snap b/tests/format/js/chain-expression/__snapshots__/jsfmt.spec.js.snap
--- a/tests/format/js/chain-expression/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/format/js/chain-expression/__snapshots__/jsfmt.spec.js.snap
@@ -1,5 +1,86 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`issue-15785-1.js format 1`] = `
+====================================options=====================================
+parsers: ["babel", "typescript", "flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+function someFunctionName(
+  someLongBreakingParameterName,
+  anotherLongParameterName,
+) {
+  return isEqual(a?.map(([t, _]) => t?.id), b?.map(([t, _]) => t?.id));
+  return isEqual(a.map(([t, _]) => t?.id), b.map(([t, _]) => t?.id));
+}
+
+
+=====================================output=====================================
+function someFunctionName(
+  someLongBreakingParameterName,
+  anotherLongParameterName,
+) {
+  return isEqual(
+    a?.map(([t, _]) => t?.id),
+    b?.map(([t, _]) => t?.id),
+  );
+  return isEqual(
+    a.map(([t, _]) => t?.id),
+    b.map(([t, _]) => t?.id),
+  );
+}
+
+================================================================================
+`;
+
+exports[`issue-15785-2.js format 1`] = `
+====================================options=====================================
+parsers: ["babel", "typescript", "flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const theValue = Object.entries(someLongObjectName).filter(
+  ([listingId]) => someListToCompareToHere?.includes(listingId),
+);
+const theValue2 = Object.entries(someLongObjectName).filter(
+  ([listingId]) => someListToCompareToHere.includes(listingId),
+);
+
+=====================================output=====================================
+const theValue = Object.entries(someLongObjectName).filter(([listingId]) =>
+  someListToCompareToHere?.includes(listingId),
+);
+const theValue2 = Object.entries(someLongObjectName).filter(([listingId]) =>
+  someListToCompareToHere.includes(listingId),
+);
+
+================================================================================
+`;
+
+exports[`issue-15785-3.js format 1`] = `
+====================================options=====================================
+parsers: ["babel", "typescript", "flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+logger.log(
+  \`A long template string with a conditional: \${channel?.id}, and then some more content that continues until \${JSON.stringify(location)}\`
+);
+logger.log(
+  \`A long template string with a conditional: \${channel.id}, and then some more content that continues until \${JSON.stringify(location)}\`
+);
+
+=====================================output=====================================
+logger.log(
+  \`A long template string with a conditional: \${channel?.id}, and then some more content that continues until \${JSON.stringify(location)}\`,
+);
+logger.log(
+  \`A long template string with a conditional: \${channel.id}, and then some more content that continues until \${JSON.stringify(location)}\`,
+);
+
+================================================================================
+`;
+
 exports[`test.js format 1`] = `
 ====================================options=====================================
 parsers: ["babel", "typescript", "flow"]
@@ -27,3 +108,111 @@ new (a?.())();
 
 ================================================================================
 `;
+
+exports[`test-2.js format 1`] = `
+====================================options=====================================
+parsers: ["babel", "typescript", "flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+{{{{
+        const [lastCommittedX, lastCommittedY] =
+          multiElement?.lastCommittedPoint ?? [0, 0];
+}}}}
+
+=====================================output=====================================
+{
+  {
+    {
+      {
+        const [lastCommittedX, lastCommittedY] =
+          multiElement?.lastCommittedPoint ?? [0, 0];
+      }
+    }
+  }
+}
+
+================================================================================
+`;
+
+exports[`test-3.js format 1`] = `
+====================================options=====================================
+parsers: ["babel", "typescript", "flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+function getLanguageByInterpreter(languages, file) {
+  return languages.find((language) => language.interpreters?.includes(interpreter),
+  );
+  return languages.find((language) =>language.interpreters.includes(interpreter),
+  );
+}
+
+=====================================output=====================================
+function getLanguageByInterpreter(languages, file) {
+  return languages.find((language) =>
+    language.interpreters?.includes(interpreter),
+  );
+  return languages.find((language) =>
+    language.interpreters.includes(interpreter),
+  );
+}
+
+================================================================================
+`;
+
+exports[`test-4.js format 1`] = `
+====================================options=====================================
+parsers: ["babel", "typescript", "flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+// https://github.com/marmelab/react-admin/blob/8c1ddfeb4e011a94609d709c0f4f139fb5d480e8/packages/ra-data-localstorage/src/index.ts#L112
+export default (params) => {
+    return {
+        updateMany: (resource, params) => {
+            updateLocalStorage(() => {
+                params.ids.forEach(id => {
+                    const index = data.data.data.data.data[resource]?.findIndex(
+                        record => record.id == id
+                    );
+                    const index2 = data.data.data.data.data[resource].findIndex(
+                        record => record.id == id
+                    );
+                    data[resource][index] = {
+                        ...data[resource][index],
+                        ...params.data,
+                    };
+                });
+            });
+            return baseDataProvider.updateMany(resource, params);
+        },
+    }
+}
+
+=====================================output=====================================
+// https://github.com/marmelab/react-admin/blob/8c1ddfeb4e011a94609d709c0f4f139fb5d480e8/packages/ra-data-localstorage/src/index.ts#L112
+export default (params) => {
+  return {
+    updateMany: (resource, params) => {
+      updateLocalStorage(() => {
+        params.ids.forEach((id) => {
+          const index = data.data.data.data.data[resource]?.findIndex(
+            (record) => record.id == id,
+          );
+          const index2 = data.data.data.data.data[resource].findIndex(
+            (record) => record.id == id,
+          );
+          data[resource][index] = {
+            ...data[resource][index],
+            ...params.data,
+          };
+        });
+      });
+      return baseDataProvider.updateMany(resource, params);
+    },
+  };
+};
+
+================================================================================
+`;
diff --git a/tests/format/js/chain-expression/issue-15785-1.js b/tests/format/js/chain-expression/issue-15785-1.js
new file mode 100644
--- /dev/null
+++ b/tests/format/js/chain-expression/issue-15785-1.js
@@ -0,0 +1,8 @@
+function someFunctionName(
+  someLongBreakingParameterName,
+  anotherLongParameterName,
+) {
+  return isEqual(a?.map(([t, _]) => t?.id), b?.map(([t, _]) => t?.id));
+  return isEqual(a.map(([t, _]) => t?.id), b.map(([t, _]) => t?.id));
+}
+
diff --git a/tests/format/js/chain-expression/issue-15785-2.js b/tests/format/js/chain-expression/issue-15785-2.js
new file mode 100644
--- /dev/null
+++ b/tests/format/js/chain-expression/issue-15785-2.js
@@ -0,0 +1,6 @@
+const theValue = Object.entries(someLongObjectName).filter(
+  ([listingId]) => someListToCompareToHere?.includes(listingId),
+);
+const theValue2 = Object.entries(someLongObjectName).filter(
+  ([listingId]) => someListToCompareToHere.includes(listingId),
+);
diff --git a/tests/format/js/chain-expression/issue-15785-3.js b/tests/format/js/chain-expression/issue-15785-3.js
new file mode 100644
--- /dev/null
+++ b/tests/format/js/chain-expression/issue-15785-3.js
@@ -0,0 +1,6 @@
+logger.log(
+  `A long template string with a conditional: ${channel?.id}, and then some more content that continues until ${JSON.stringify(location)}`
+);
+logger.log(
+  `A long template string with a conditional: ${channel.id}, and then some more content that continues until ${JSON.stringify(location)}`
+);
diff --git a/tests/format/js/chain-expression/test-2.js b/tests/format/js/chain-expression/test-2.js
new file mode 100644
--- /dev/null
+++ b/tests/format/js/chain-expression/test-2.js
@@ -0,0 +1,4 @@
+{{{{
+        const [lastCommittedX, lastCommittedY] =
+          multiElement?.lastCommittedPoint ?? [0, 0];
+}}}}
diff --git a/tests/format/js/chain-expression/test-3.js b/tests/format/js/chain-expression/test-3.js
new file mode 100644
--- /dev/null
+++ b/tests/format/js/chain-expression/test-3.js
@@ -0,0 +1,6 @@
+function getLanguageByInterpreter(languages, file) {
+  return languages.find((language) => language.interpreters?.includes(interpreter),
+  );
+  return languages.find((language) =>language.interpreters.includes(interpreter),
+  );
+}
diff --git a/tests/format/js/chain-expression/test-4.js b/tests/format/js/chain-expression/test-4.js
new file mode 100644
--- /dev/null
+++ b/tests/format/js/chain-expression/test-4.js
@@ -0,0 +1,22 @@
+// https://github.com/marmelab/react-admin/blob/8c1ddfeb4e011a94609d709c0f4f139fb5d480e8/packages/ra-data-localstorage/src/index.ts#L112
+export default (params) => {
+    return {
+        updateMany: (resource, params) => {
+            updateLocalStorage(() => {
+                params.ids.forEach(id => {
+                    const index = data.data.data.data.data[resource]?.findIndex(
+                        record => record.id == id
+                    );
+                    const index2 = data.data.data.data.data[resource].findIndex(
+                        record => record.id == id
+                    );
+                    data[resource][index] = {
+                        ...data[resource][index],
+                        ...params.data,
+                    };
+                });
+            });
+            return baseDataProvider.updateMany(resource, params);
+        },
+    }
+}
diff --git a/tests/format/typescript/chain-expression/__snapshots__/jsfmt.spec.js.snap b/tests/format/typescript/chain-expression/__snapshots__/jsfmt.spec.js.snap
--- a/tests/format/typescript/chain-expression/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/format/typescript/chain-expression/__snapshots__/jsfmt.spec.js.snap
@@ -19,3 +19,42 @@ printWidth: 80
 
 ================================================================================
 `;
+
+exports[`test2.ts format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+{{
+    const rotation1 = getTransformHandles(arrow, h.state.zoom, 'mouse')
+      .rotation!;
+    const rotation2 = getTransformHandles(arrow, h.state.zoom, 'mouse')
+      .rotation;
+    const rotation3 = getTransformHandles(arrow, h.state.zoom, 'mouse')
+      ?.rotation;
+}}
+
+=====================================output=====================================
+{
+  {
+    const rotation1 = getTransformHandles(
+      arrow,
+      h.state.zoom,
+      "mouse",
+    ).rotation!;
+    const rotation2 = getTransformHandles(
+      arrow,
+      h.state.zoom,
+      "mouse",
+    ).rotation;
+    const rotation3 = getTransformHandles(
+      arrow,
+      h.state.zoom,
+      "mouse",
+    )?.rotation;
+  }
+}
+
+================================================================================
+`;
diff --git a/tests/format/typescript/chain-expression/test2.ts b/tests/format/typescript/chain-expression/test2.ts
new file mode 100644
--- /dev/null
+++ b/tests/format/typescript/chain-expression/test2.ts
@@ -0,0 +1,8 @@
+{{
+    const rotation1 = getTransformHandles(arrow, h.state.zoom, 'mouse')
+      .rotation!;
+    const rotation2 = getTransformHandles(arrow, h.state.zoom, 'mouse')
+      .rotation;
+    const rotation3 = getTransformHandles(arrow, h.state.zoom, 'mouse')
+      ?.rotation;
+}}