Repository: prettier/prettier (JavaScript)
Date: 2021-04-16 13:48:14+00:00
Task type: Feature

Issue:
Suboptimal additional indentation for decorated functions
**Prettier 2.2.1**
[Playground link](https://prettier.io/playground/#N4Igxg9gdgLgprEAuEB6VACAvDjcAeAhgLYAOANnBgIzY70ONPMtMYA6U6GMAFlQDMIAJ2KEY8ACYYIAVxil5MgT14BLAM4yoVAO5ry5beQCeGchAgBrDAHNLkztzUrSwuBLVxhGAG4AmADog2k0MWQ04aQxCLTUYHndxLQFZKDAYNWgtPnEnTEJ3DCgSKI4QSTgBNR12EAxJFwFvBATcqBk+by1JWSoYCAwAI1lbDABiagAGABZ-fwwAWnyMSKpeCVINJHRbeN5ZIcDIYlQ3D0zvM-dPK80NPo1Uabn-Tk5IKA0EgGE5WG82AaVRqcAAFAByYgmRaQNLwYQQgA0GDBwE4GAwNXiakI5D+8KBUyRGPMhCGcCMWAwEIJAMRnAAvkgMHSEQAFYQQLYASmwAD4MOiOhhPt8MABtOGwFGRGBsgC6QIicAAyjBxODsZk8WyeaSxQloD9yGowDZqSqfnjyENCOawWC+VhBXK2WD4gKsQkANQ0HkoiUK-Ui0k3WTCDpg0mYgA8Iwk0G0YFN5qwwGNqasjP5McxQvI5MpzKF0pgjLzsdQCYGUFzIr5mKZIZW9DwRDIlAwC1Yvb7-Y4UBWi0FXRikkamSTNUqsHEWQ6NVUghEYgkZTkCnkK3zYXJGgg5HklLMUAgCV0hAB0n47ne6Wyv3+CKBJ1I0FakOhsOf3mRqOFTFtVxfFnyJEkRULCkqRpNk-yZFk4OETluQ0Z1BUA0VH0lMtZQ8RVlUidVNQ9KAcV1Z8Q0xQ1tBNM0LXCSJrUMO0HSdL03WfD0Ehdb0MD9agA0lYNSTDDwIyjSsayTaAU3o9NM3onM80xYAoOLFlgDLCsRTjat5FretMUbQdGRDEAkRAbkpy+ZBQEKLldHZQoEA0ZAQDxS8TDcyyhmEe0rA8VVSHtGpbGQGBhD6Sy4GICkJyiAAZK9bFkQhbDgAAxVdxEyKBwpQQh5AgCyQA2YhyAAdXUeANBCsA1VcnFfHiEx3LADQfJAGpImEGBOXSsRkAEPFIksgArDR8AAIX880gtKRLQWG0a4AmqbVTCygAEVZHPOAVvIMaQBC4RevcmATFIOANDAYQ1FIGBSrcGoYEqtRJD4ZAAA5iROrlIkq-zSHc85et8A7LIARz2+AUK2dzYkWHQoiiUr3BhtR3AG2whqQEajrWkBImINRDuOjQtrgXb9oiqKiY1IZ3s+3hkH8SzIsIAwwr+Yg8ZAM8dFKlUABV93JonfD6ABJKBZxgVU7oemAAEE5fVExKAlxlGSAA)
<!-- prettier-ignore -->
```sh
--parser typescript
--no-semi
--single-quote
--trailing-comma none
```

**Input:**
<!-- prettier-ignore -->
```tsx
// === example 1 ========================================== 
// the formatted output of this one will only look good
// if prettier v2.2.1 is used  as it treats functions that
// are named "define" different than others due to bug #10422 -
// see https://github.com/prettier/prettier/issues/10422

const Counter = define('my-counter', ({
  initialCount = 0,
  label = 'Counter'
}: CounterProps) => {
  const [count, setCount] = useState(initialCount)
  const onClick = useCallback(() => setCount(it => it + 1), [])
  
  return (
    <button onclick={onClick}>
      {label}: {count}
    </button>
  )   
})

// === example 2 ========================================== 

// -> the addition indentation in the formatted output
//    is absolutely not wanted here

const Counter = component('my-counter', ({
  initialCount = 0,
  label = 'Counter'
}: CounterProps) => {
  const [count, setCount] = useState(initialCount)
  const onClick = useCallback(() => setCount(it => it + 1), [])
  
  return (
    <button onclick={onClick}>
      {label}: {count}
    </button>
  )   
})

```

**Output:**
<!-- prettier-ignore -->
```tsx
// === example 1 ==========================================
// the formatted output of this one will only look good
// if prettier v2.2.1 is used  as it treats functions that
// are named "define" different than others due to bug #10422 -
// see https://github.com/prettier/prettier/issues/10422

const Counter = define('my-counter', ({
  initialCount = 0,
  label = 'Counter'
}: CounterProps) => {
  const [count, setCount] = useState(initialCount)
  const onClick = useCallback(() => setCount((it) => it + 1), [])

  return (
    <button onclick={onClick}>
      {label}: {count}
    </button>
  )
})

// === example 2 ==========================================

// -> the addition indentation in the formatted output
//    is absolutely not wanted here

const Counter = component(
  'my-counter',
  ({ initialCount = 0, label = 'Counter' }: CounterProps) => {
    const [count, setCount] = useState(initialCount)
    const onClick = useCallback(() => setCount((it) => it + 1), [])

    return (
      <button onclick={onClick}>
        {label}: {count}
      </button>
    )
  }
)

```

**Expected behavior:**
Would be perfect if the function indentation stays as is (no additional two space indentation) - the  formatting of the argument list is not that important.
Please ignore that the examples above look a bit like React or Preact - it's not about those libraries, it's just about the pattern.
Very often I have the case that special functions have the purpose to transform other functions, often including a bit of meta information (like a string or whatever), like in the examples above, but then the formatting by prettier will be quite ugly.
You may say for the examples above just define first a named function and then call the `component` function afterwards with the function variable as argument ... but in general this may explicitly not wanted and be more complicated (for example if you have `const Counter = component('my-counter', counterOptions, func)` where the type of `func` depends heavily on the type of `counterOptions`). Of course with some aditional lines of code and some utility types this is doable, but will be syntactically ugly and less readable.
Frankly, I have no concrete suggestion what could be a good solution regarding what format should be applied in what concrete cases - but I hoped you may have some ideas.

[Edit] One possibility could be: If all arguments except for the last one are literals or variables and the last argument is a closure and the literals and variables match one line then format as suggested above otherwise format as usual.


Issue discussion:
Closely related: https://github.com/prettier/prettier/issues/6921
I don't believe there is a solution. In situations like this, the developer is willing to sacrifice the readability of a call to get less indentation. How can an algorithm guess this intention?

For `it`, `describe`, and co this was solved by hardcoding well-known function names, but that obviously doesn't scale.

Moreover, I suspect many people from #6921 would say that the current formatting is good because this is the best formatting for function composition.

Let's keep the issue open for discussion, but don't really expect this to lead anywhere.

Solution patch:
diff --git a/changelog_unreleased/javascript/10714.md b/changelog_unreleased/javascript/10714.md
new file mode 100644
index 000000000000..e186f02f989d
--- /dev/null
+++ b/changelog_unreleased/javascript/10714.md
@@ -0,0 +1,21 @@
+#### Support the "decorated function" pattern (#10714 by @thorn0)
+
+In this case the developer is usually willing to sacrifice the readability of the arrow function's signature to get less indentation in its body. Prettier now recognizes this pattern and keeps the arrow function hugged even if the signature breaks.
+
+<!-- prettier-ignore -->
+```ts
+// Prettier stable
+const Counter = decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    // ...
+  }
+);
+
+// Prettier main
+const Counter = decorator("my-counter")((props: {
+  initialCount?: number;
+  label?: string;
+}) => {
+  // ...
+});
+```
diff --git a/src/language-js/print/function-parameters.js b/src/language-js/print/function-parameters.js
index d7ac5872c1dd..ae13bdf7534b 100644
--- a/src/language-js/print/function-parameters.js
+++ b/src/language-js/print/function-parameters.js
@@ -26,6 +26,8 @@ import { locEnd } from "../loc.js";
 import { ArgExpansionBailout } from "../../common/errors.js";
 import { printFunctionTypeParameters } from "./misc.js";
 
+/** @typedef {import("../../common/ast-path").default} AstPath */
+
 function printFunctionParameters(
   path,
   print,
@@ -91,7 +93,7 @@ function printFunctionParameters(
   //     }                     b,
   //   )                     ) => {
   //                         })
-  if (expandArg) {
+  if (expandArg && !isDecoratedFunction(path)) {
     if (willBreak(typeParams) || willBreak(printed)) {
       // Removing lines in this case leads to broken or ugly output
       throw new ArgExpansionBailout();
@@ -226,6 +228,59 @@ function shouldGroupFunctionParameters(functionNode, returnTypeDoc) {
   );
 }
 
+/**
+ * The "decorated function" pattern.
+ * The arrow function should be kept hugged even if its signature breaks.
+ *
+ * ```
+ * const decoratedFn = decorator(param1, param2)((
+ *   ...
+ * ) => {
+ *   ...
+ * });
+ * ```
+ * @param {AstPath} path
+ */
+function isDecoratedFunction(path) {
+  return path.match(
+    (node) =>
+      node.type === "ArrowFunctionExpression" &&
+      node.body.type === "BlockStatement",
+    (node, name) => {
+      if (
+        node.type === "CallExpression" &&
+        name === "arguments" &&
+        node.arguments.length === 1 &&
+        node.callee.type === "CallExpression"
+      ) {
+        const decorator = node.callee.callee;
+        return (
+          decorator.type === "Identifier" ||
+          (decorator.type === "MemberExpression" &&
+            !decorator.computed &&
+            decorator.object.type === "Identifier" &&
+            decorator.property.type === "Identifier")
+        );
+      }
+      return false;
+    },
+    (node, name) =>
+      (node.type === "VariableDeclarator" && name === "init") ||
+      (node.type === "ExportDefaultDeclaration" && name === "declaration") ||
+      (node.type === "TSExportAssignment" && name === "expression") ||
+      (node.type === "AssignmentExpression" &&
+        name === "right" &&
+        node.left.type === "MemberExpression" &&
+        node.left.object.type === "Identifier" &&
+        node.left.object.name === "module" &&
+        node.left.property.type === "Identifier" &&
+        node.left.property.name === "exports"),
+    (node) =>
+      node.type !== "VariableDeclaration" ||
+      (node.kind === "const" && node.declarations.length === 1)
+  );
+}
+
 export {
   printFunctionParameters,
   shouldHugFunctionParameters,


Test patch:
diff --git a/tests/format/flow/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap b/tests/format/flow/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap
new file mode 100644
index 000000000000..5563f0bf751b
--- /dev/null
+++ b/tests/format/flow/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap
@@ -0,0 +1,152 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`break.js format 1`] = `
+====================================options=====================================
+parsers: ["flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+export default class AddAssetHtmlPlugin {
+  apply(compiler: WebpackCompilerType) {
+    compiler.plugin('compilation', (compilation: WebpackCompilationType) => {
+      compilation.plugin('html-webpack-plugin-before-html', (callback: Callback<any>) => {
+        addAllAssetsToCompilation(this.assets, compilation, htmlPluginData, callback);
+      });
+    });
+  }
+}
+
+=====================================output=====================================
+export default class AddAssetHtmlPlugin {
+  apply(compiler: WebpackCompilerType) {
+    compiler.plugin("compilation", (compilation: WebpackCompilationType) => {
+      compilation.plugin(
+        "html-webpack-plugin-before-html",
+        (callback: Callback<any>) => {
+          addAllAssetsToCompilation(
+            this.assets,
+            compilation,
+            htmlPluginData,
+            callback,
+          );
+        },
+      );
+    });
+  }
+}
+
+================================================================================
+`;
+
+exports[`decorated-function.js format 1`] = `
+====================================options=====================================
+parsers: ["flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const Counter = decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    const p = useDefault(props, {
+      initialCount: 0,
+      label: "Counter",
+    });
+
+    const [s, set] = useState({ count: p.initialCount });
+    const onClick = () => set("count", (it) => it + 1);
+
+    return () => (
+      <button onclick={onClick}>
+        {p.label}: {s.count}
+      </button>
+    );
+  }
+);
+
+const Counter2 = decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return () => (
+      <button onclick={onClick}>
+        {p.label}: {s.count}
+      </button>
+    );
+  }
+);
+
+export default decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return foo;
+  }
+);
+
+=====================================output=====================================
+const Counter = decorator("my-counter")((props: {
+  initialCount?: number,
+  label?: string,
+}) => {
+  const p = useDefault(props, {
+    initialCount: 0,
+    label: "Counter",
+  });
+
+  const [s, set] = useState({ count: p.initialCount });
+  const onClick = () => set("count", (it) => it + 1);
+
+  return () => (
+    <button onclick={onClick}>
+      {p.label}: {s.count}
+    </button>
+  );
+});
+
+const Counter2 = decorators.decorator("my-counter")((props: {
+  initialCount?: number,
+  label?: string,
+}) => {
+  return () => (
+    <button onclick={onClick}>
+      {p.label}: {s.count}
+    </button>
+  );
+});
+
+export default decorators.decorator("my-counter")((props: {
+  initialCount?: number,
+  label?: string,
+}) => {
+  return foo;
+});
+
+================================================================================
+`;
+
+exports[`edge_case.js format 1`] = `
+====================================options=====================================
+parsers: ["flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+var listener = DOM.listen(
+  introCard,
+  'click',
+  sigil,
+  (event: JavelinEvent): void =>
+    BanzaiLogger.log(
+      config,
+      {...logData, ...DataStore.get(event.getNode(sigil))},
+    ),
+);
+
+=====================================output=====================================
+var listener = DOM.listen(
+  introCard,
+  "click",
+  sigil,
+  (event: JavelinEvent): void =>
+    BanzaiLogger.log(config, {
+      ...logData,
+      ...DataStore.get(event.getNode(sigil)),
+    }),
+);
+
+================================================================================
+`;
diff --git a/tests/format/flow/last-argument-expansion/break.js b/tests/format/flow/last-argument-expansion/break.js
new file mode 100644
index 000000000000..cc736b0c3966
--- /dev/null
+++ b/tests/format/flow/last-argument-expansion/break.js
@@ -0,0 +1,9 @@
+export default class AddAssetHtmlPlugin {
+  apply(compiler: WebpackCompilerType) {
+    compiler.plugin('compilation', (compilation: WebpackCompilationType) => {
+      compilation.plugin('html-webpack-plugin-before-html', (callback: Callback<any>) => {
+        addAllAssetsToCompilation(this.assets, compilation, htmlPluginData, callback);
+      });
+    });
+  }
+}
diff --git a/tests/format/flow/last-argument-expansion/decorated-function.js b/tests/format/flow/last-argument-expansion/decorated-function.js
new file mode 100644
index 000000000000..676319d276cb
--- /dev/null
+++ b/tests/format/flow/last-argument-expansion/decorated-function.js
@@ -0,0 +1,33 @@
+const Counter = decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    const p = useDefault(props, {
+      initialCount: 0,
+      label: "Counter",
+    });
+
+    const [s, set] = useState({ count: p.initialCount });
+    const onClick = () => set("count", (it) => it + 1);
+
+    return () => (
+      <button onclick={onClick}>
+        {p.label}: {s.count}
+      </button>
+    );
+  }
+);
+
+const Counter2 = decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return () => (
+      <button onclick={onClick}>
+        {p.label}: {s.count}
+      </button>
+    );
+  }
+);
+
+export default decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return foo;
+  }
+);
diff --git a/tests/format/flow/last-argument-expansion/edge_case.js b/tests/format/flow/last-argument-expansion/edge_case.js
new file mode 100644
index 000000000000..7a495e838c2e
--- /dev/null
+++ b/tests/format/flow/last-argument-expansion/edge_case.js
@@ -0,0 +1,10 @@
+var listener = DOM.listen(
+  introCard,
+  'click',
+  sigil,
+  (event: JavelinEvent): void =>
+    BanzaiLogger.log(
+      config,
+      {...logData, ...DataStore.get(event.getNode(sigil))},
+    ),
+);
diff --git a/tests/format/flow/last-argument-expansion/jsfmt.spec.js b/tests/format/flow/last-argument-expansion/jsfmt.spec.js
new file mode 100644
index 000000000000..c89f3cc86abe
--- /dev/null
+++ b/tests/format/flow/last-argument-expansion/jsfmt.spec.js
@@ -0,0 +1,1 @@
+run_spec(import.meta, ["flow"]);
diff --git a/tests/format/typescript/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap b/tests/format/typescript/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap
index b62aad2d85ad..5aa644ffbdc9 100644
--- a/tests/format/typescript/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/format/typescript/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap
@@ -2,7 +2,7 @@
 
 exports[`break.ts format 1`] = `
 ====================================options=====================================
-parsers: ["typescript", "babel", "flow"]
+parsers: ["typescript"]
 printWidth: 80
                                                                                 | printWidth
 =====================================input======================================
@@ -38,9 +38,137 @@ export default class AddAssetHtmlPlugin {
 ================================================================================
 `;
 
+exports[`decorated-function.ts format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const Counter = decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    const p = useDefault(props, {
+      initialCount: 0,
+      label: "Counter",
+    });
+
+    const [s, set] = useState({ count: p.initialCount });
+    const onClick = () => set("count", (it) => it + 1);
+
+    return () => (
+      <button onclick={onClick}>
+        {p.label}: {s.count}
+      </button>
+    );
+  }
+);
+
+const Counter2 = decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return () => (
+      <button onclick={onClick}>
+        {p.label}: {s.count}
+      </button>
+    );
+  }
+);
+
+export default decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return foo;
+  }
+);
+
+export = decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return foo;
+  }
+);
+
+module.exports = decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return foo;
+  }
+);
+
+const Counter =
+  decorator("foo")(
+  decorator("bar")(
+  (props: {
+    loremFoo1: Array<Promise<any>>,
+    ipsumBarr2: Promise<number>,
+  }) => {
+    return <div/>;
+  }));
+
+=====================================output=====================================
+const Counter = decorator("my-counter")((props: {
+  initialCount?: number;
+  label?: string;
+}) => {
+  const p = useDefault(props, {
+    initialCount: 0,
+    label: "Counter",
+  });
+
+  const [s, set] = useState({ count: p.initialCount });
+  const onClick = () => set("count", (it) => it + 1);
+
+  return () => (
+    <button onclick={onClick}>
+      {p.label}: {s.count}
+    </button>
+  );
+});
+
+const Counter2 = decorators.decorator("my-counter")((props: {
+  initialCount?: number;
+  label?: string;
+}) => {
+  return () => (
+    <button onclick={onClick}>
+      {p.label}: {s.count}
+    </button>
+  );
+});
+
+export default decorators.decorator("my-counter")((props: {
+  initialCount?: number;
+  label?: string;
+}) => {
+  return foo;
+});
+
+export = decorators.decorator("my-counter")((props: {
+  initialCount?: number;
+  label?: string;
+}) => {
+  return foo;
+});
+
+module.exports = decorators.decorator("my-counter")((props: {
+  initialCount?: number;
+  label?: string;
+}) => {
+  return foo;
+});
+
+const Counter = decorator("foo")(
+  decorator("bar")(
+    (props: {
+      loremFoo1: Array<Promise<any>>;
+      ipsumBarr2: Promise<number>;
+    }) => {
+      return <div />;
+    },
+  ),
+);
+
+================================================================================
+`;
+
 exports[`edge_case.ts format 1`] = `
 ====================================options=====================================
-parsers: ["typescript", "babel", "flow"]
+parsers: ["typescript"]
 printWidth: 80
                                                                                 | printWidth
 =====================================input======================================
diff --git a/tests/format/typescript/last-argument-expansion/decorated-function.ts b/tests/format/typescript/last-argument-expansion/decorated-function.ts
new file mode 100644
index 000000000000..125c2708cea4
--- /dev/null
+++ b/tests/format/typescript/last-argument-expansion/decorated-function.ts
@@ -0,0 +1,55 @@
+const Counter = decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    const p = useDefault(props, {
+      initialCount: 0,
+      label: "Counter",
+    });
+
+    const [s, set] = useState({ count: p.initialCount });
+    const onClick = () => set("count", (it) => it + 1);
+
+    return () => (
+      <button onclick={onClick}>
+        {p.label}: {s.count}
+      </button>
+    );
+  }
+);
+
+const Counter2 = decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return () => (
+      <button onclick={onClick}>
+        {p.label}: {s.count}
+      </button>
+    );
+  }
+);
+
+export default decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return foo;
+  }
+);
+
+export = decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return foo;
+  }
+);
+
+module.exports = decorators.decorator("my-counter")(
+  (props: { initialCount?: number; label?: string }) => {
+    return foo;
+  }
+);
+
+const Counter =
+  decorator("foo")(
+  decorator("bar")(
+  (props: {
+    loremFoo1: Array<Promise<any>>,
+    ipsumBarr2: Promise<number>,
+  }) => {
+    return <div/>;
+  }));
diff --git a/tests/format/typescript/last-argument-expansion/jsfmt.spec.js b/tests/format/typescript/last-argument-expansion/jsfmt.spec.js
index 4847182d5633..29084a9c8a10 100644
--- a/tests/format/typescript/last-argument-expansion/jsfmt.spec.js
+++ b/tests/format/typescript/last-argument-expansion/jsfmt.spec.js
@@ -1,1 +1,1 @@
-run_spec(import.meta, ["typescript", "babel", "flow"]);
+run_spec(import.meta, ["typescript"]);