Repository: sphinx-doc/sphinx

Issue:
HTML5/Latex/Text writers: Trailing comma in multi-line signatures
### Describe the bug

Multi-line signatures have a comma separator even for the last parameter when outputting HTML5 (edit: and LaTeX and Text).

The offender seems to be [writers/html5.py#L232](https://github.com/sphinx-doc/sphinx/blob/76110c3ea05e4eef02bc1689c4b9253aca743392/sphinx/writers/html5.py#L232)
```python
 if opt_param_left_at_level or is_required and (is_last_group or next_is_required):
```
<del>which I think should be</del>
```python
if opt_param_left_at_level or is_required and (not is_last_group or next_is_required):
```
Edit: Nm, that would break the closing dd

### How to Reproduce

```rst
.. cpp:function:: Foo(int bar, float baz)
```
with `maximum_signature_line_length = 1`
outputs

```
Foo(
    int bar,
    float baz,
)
```

### Environment Information

```text
Sphinx 8.0.2
```


### Sphinx extensions

_No response_

### Additional context

_No response_

Discussion:
@Level3Manatee: Upon further investigation, Latex and text writers are affected as well@Level3Manatee: I am pretty sure the html5 writer code should read
```diff
@@ -229,9 +229,9 @@
-            if opt_param_left_at_level or is_required and (is_last_group or next_is_required):
+            if opt_param_left_at_level or is_required and (not is_last_group or next_is_required):
                 self.body.append(self.param_separator)
-                self.body.append('</dd>\n')
+            self.body.append('</dd>\n')
```
(and similar changes to LaTeX and Text writers), but a number of tests seem to expect a trailing comma and I don't feel comfortable touching those, as I am not very familiar with the code base.@picnixz: We decided to keep the trailing comma actually because it was easier at that time to do it that way I think. 

What we could do is probably enable/disable this feature via a config value though (personally I'd prefer no trailing commas so we could just remove the trailing comma). cc @TLouf as the original author (I will take care of PEP 695 output depending on the decision)@AA-Turner: This doesn't seem to be a bug. @Level3Manatee can you confirm this is purely a stylistic request on your part?

A@TLouf: Thanks for the ping. Yes, it's slightly easier to do it that way, but more than anything this was a choice based on personal preference (see https://github.com/sphinx-doc/sphinx/pull/11011#issuecomment-1510429111). So this behaviour could be controlled by a config value, if there's agreement on adding one.@jakobandersen: Note, the original report was about a C++ declaration, where a trailing comma is not valid, as opposed to in Python. The choice of a trailing comma should therefore be the choice of the domain and not the writers.@Level3Manatee: > This doesn't seem to be a bug. @Level3Manatee can you confirm this is purely a stylistic request on your part?

As @jakobandersen has already pointed out, a trailing comma is not really a stylistic choice in the C/C++ domain. I am currently documenting C++ code, which is why I tripped over this. I assumed it was a general bug because there is no trailing comma for single-line signatures.

I would personally prefer there not being a trailing comma for Python either, but I do see why one might want it there. So maybe this should be controllable through a config value that applies to domains where it would/could be valid, i.e. std and Python?@picnixz: I think it's a sound argument that this is a domain and not a writer responsibility to decide whether a trailing comma should or shouldn't be part of the signature. 

As such, I suggest we have a configuration value which enables or disables the trailing comma assuming that the language supports it. The fix shouldn't be that hard since we can simply add a node attribute (the node being altered would be the paramlist node).

In addition, I may at some point have a similar logic as for PEP 695 but for C++ template functions (namely multi-lines template parameters).@TLouf: Oh I didn't know that, my bad then! If there's agreement on having a config for that, I can propose a PR some time this week. As far as I can see, domains for which a trailing comma could make sense are Python, std, and Javascript.

Solution patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 616492687fb..139ce579af0 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -55,6 +55,11 @@ Features added
 * #7630, #4824: autodoc: Use :file:`.pyi` type stub files
   to auto-document native modules.
   Patch by Adam Turner, partially based on work by Allie Fitter.
+* #12975: Enable configuration of trailing commas in multi-line signatures
+  in the Python and Javascript domains, via the new
+  :confval:`python_trailing_comma_in_multi_line_signatures` and
+  :confval:`javascript_trailing_comma_in_multi_line_signatures`
+  configuration options.
 
 Bugs fixed
 ----------
@@ -86,6 +91,7 @@ Bugs fixed
   before static methods, which themselves are rendered before regular
   methods and attributes.
   Patch by Bénédikt Tran.
+* #12975: Avoid rendering a trailing comma in C and C++ multi-line signatures.
 
 Testing
 -------
diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst
index 6e526ef1173..cbfc4075f03 100644
--- a/doc/usage/configuration.rst
+++ b/doc/usage/configuration.rst
@@ -4092,6 +4092,14 @@ Options for the Javascript domain
 
    .. versionadded:: 7.1
 
+.. confval:: javascript_trailing_comma_in_multi_line_signatures
+   :type: :code-py:`bool`
+   :default: :code-py:`True`
+
+   Use a trailing comma in parameter lists spanning multiple lines, if true.
+
+   .. versionadded:: 8.2
+
 
 Options for the Python domain
 -----------------------------
@@ -4181,6 +4189,14 @@ Options for the Python domain
 
    .. versionadded:: 7.1
 
+.. confval:: python_trailing_comma_in_multi_line_signatures
+   :type: :code-py:`bool`
+   :default: :code-py:`True`
+
+   Use a trailing comma in parameter lists spanning multiple lines, if true.
+
+   .. versionadded:: 8.2
+
 .. confval:: python_use_unqualified_type_names
    :type: :code-py:`bool`
    :default: :code-py:`False`
diff --git a/sphinx/addnodes.py b/sphinx/addnodes.py
index 1a58495df38..f84d1e1069e 100644
--- a/sphinx/addnodes.py
+++ b/sphinx/addnodes.py
@@ -259,6 +259,8 @@ class desc_parameterlist(nodes.Part, nodes.Inline, nodes.FixedTextElement):
     As default the parameter list is written in line with the rest of the signature.
     Set ``multi_line_parameter_list = True`` to describe a multi-line parameter list.
     In that case each parameter will then be written on its own, indented line.
+    A trailing comma will be added on the last line
+    if ``multi_line_trailing_comma`` is True.
     """
 
     child_text_separator = ', '
@@ -273,6 +275,8 @@ class desc_type_parameter_list(nodes.Part, nodes.Inline, nodes.FixedTextElement)
     As default the type parameters list is written in line with the rest of the signature.
     Set ``multi_line_parameter_list = True`` to describe a multi-line type parameters list.
     In that case each type parameter will then be written on its own, indented line.
+    A trailing comma will be added on the last line
+    if ``multi_line_trailing_comma`` is True.
     """
 
     child_text_separator = ', '
diff --git a/sphinx/domains/javascript.py b/sphinx/domains/javascript.py
index 58e35e1ad6c..cd9e4ea8348 100644
--- a/sphinx/domains/javascript.py
+++ b/sphinx/domains/javascript.py
@@ -109,6 +109,10 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
             and (len(sig) > max_len > 0)
         )
 
+        trailing_comma = (
+            self.env.config.javascript_trailing_comma_in_multi_line_signatures
+        )
+
         display_prefix = self.get_display_prefix()
         if display_prefix:
             signode += addnodes.desc_annotation('', '', *display_prefix)
@@ -129,7 +133,12 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
             if not arglist:
                 signode += addnodes.desc_parameterlist()
             else:
-                _pseudo_parse_arglist(signode, arglist, multi_line_parameter_list)
+                _pseudo_parse_arglist(
+                    signode,
+                    arglist,
+                    multi_line_parameter_list,
+                    trailing_comma,
+                )
         return fullname, prefix
 
     def _object_hierarchy_parts(self, sig_node: desc_signature) -> tuple[str, ...]:
@@ -564,6 +573,12 @@ def setup(app: Sphinx) -> ExtensionMetadata:
         'env',
         types=frozenset({int, type(None)}),
     )
+    app.add_config_value(
+        'javascript_trailing_comma_in_multi_line_signatures',
+        True,
+        'env',
+        types=frozenset({bool}),
+    )
     return {
         'version': 'builtin',
         'env_version': 3,
diff --git a/sphinx/domains/python/__init__.py b/sphinx/domains/python/__init__.py
index 5ee59fc4c63..2fa15260212 100644
--- a/sphinx/domains/python/__init__.py
+++ b/sphinx/domains/python/__init__.py
@@ -1078,6 +1078,12 @@ def setup(app: Sphinx) -> ExtensionMetadata:
         'env',
         types=frozenset({int, type(None)}),
     )
+    app.add_config_value(
+        'python_trailing_comma_in_multi_line_signatures',
+        True,
+        'env',
+        types=frozenset({bool}),
+    )
     app.add_config_value('python_display_short_literal_types', False, 'env')
     app.connect('object-description-transform', filter_meta_fields)
     app.connect('missing-reference', builtin_resolver, priority=900)
diff --git a/sphinx/domains/python/_annotations.py b/sphinx/domains/python/_annotations.py
index 96373759d7d..432e240a5dd 100644
--- a/sphinx/domains/python/_annotations.py
+++ b/sphinx/domains/python/_annotations.py
@@ -400,11 +400,15 @@ def _pformat_token(self, tok: Token, native: bool = False) -> str:
 
 
 def _parse_type_list(
-    tp_list: str, env: BuildEnvironment, multi_line_parameter_list: bool = False
+    tp_list: str,
+    env: BuildEnvironment,
+    multi_line_parameter_list: bool = False,
+    trailing_comma: bool = True,
 ) -> addnodes.desc_type_parameter_list:
     """Parse a list of type parameters according to PEP 695."""
     type_params = addnodes.desc_type_parameter_list(tp_list)
     type_params['multi_line_parameter_list'] = multi_line_parameter_list
+    type_params['multi_line_trailing_comma'] = trailing_comma
     # formal parameter names are interpreted as type parameter names and
     # type annotations are interpreted as type parameter bound or constraints
     parser = _TypeParameterListParser(tp_list)
@@ -462,11 +466,15 @@ def _parse_type_list(
 
 
 def _parse_arglist(
-    arglist: str, env: BuildEnvironment, multi_line_parameter_list: bool = False
+    arglist: str,
+    env: BuildEnvironment,
+    multi_line_parameter_list: bool = False,
+    trailing_comma: bool = True,
 ) -> addnodes.desc_parameterlist:
     """Parse a list of arguments using AST parser"""
     params = addnodes.desc_parameterlist(arglist)
     params['multi_line_parameter_list'] = multi_line_parameter_list
+    params['multi_line_trailing_comma'] = trailing_comma
     sig = signature_from_str('(%s)' % arglist)
     last_kind = None
     for param in sig.parameters.values():
@@ -522,7 +530,10 @@ def _parse_arglist(
 
 
 def _pseudo_parse_arglist(
-    signode: desc_signature, arglist: str, multi_line_parameter_list: bool = False
+    signode: desc_signature,
+    arglist: str,
+    multi_line_parameter_list: bool = False,
+    trailing_comma: bool = True,
 ) -> None:
     """'Parse' a list of arguments separated by commas.
 
@@ -532,6 +543,7 @@ def _pseudo_parse_arglist(
     """
     paramlist = addnodes.desc_parameterlist()
     paramlist['multi_line_parameter_list'] = multi_line_parameter_list
+    paramlist['multi_line_trailing_comma'] = trailing_comma
     stack: list[Element] = [paramlist]
     try:
         for argument in arglist.split(','):
diff --git a/sphinx/domains/python/_object.py b/sphinx/domains/python/_object.py
index 90cfd935de3..b72f35a6fcd 100644
--- a/sphinx/domains/python/_object.py
+++ b/sphinx/domains/python/_object.py
@@ -310,6 +310,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
             and (sig_len - (arglist_span[1] - arglist_span[0])) > max_len > 0
         )
 
+        trailing_comma = self.env.config.python_trailing_comma_in_multi_line_signatures
         sig_prefix = self.get_signature_prefix(sig)
         if sig_prefix:
             if type(sig_prefix) is str:
@@ -332,7 +333,10 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
         if tp_list:
             try:
                 signode += _parse_type_list(
-                    tp_list, self.env, multi_line_type_parameter_list
+                    tp_list,
+                    self.env,
+                    multi_line_type_parameter_list,
+                    trailing_comma,
                 )
             except Exception as exc:
                 logger.warning(
@@ -341,19 +345,34 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
 
         if arglist:
             try:
-                signode += _parse_arglist(arglist, self.env, multi_line_parameter_list)
+                signode += _parse_arglist(
+                    arglist,
+                    self.env,
+                    multi_line_parameter_list,
+                    trailing_comma,
+                )
             except SyntaxError:
                 # fallback to parse arglist original parser
                 # (this may happen if the argument list is incorrectly used
                 # as a list of bases when documenting a class)
                 # it supports to represent optional arguments (ex. "func(foo [, bar])")
-                _pseudo_parse_arglist(signode, arglist, multi_line_parameter_list)
+                _pseudo_parse_arglist(
+                    signode,
+                    arglist,
+                    multi_line_parameter_list,
+                    trailing_comma,
+                )
             except (NotImplementedError, ValueError) as exc:
                 # duplicated parameter names raise ValueError and not a SyntaxError
                 logger.warning(
                     'could not parse arglist (%r): %s', arglist, exc, location=signode
                 )
-                _pseudo_parse_arglist(signode, arglist, multi_line_parameter_list)
+                _pseudo_parse_arglist(
+                    signode,
+                    arglist,
+                    multi_line_parameter_list,
+                    trailing_comma,
+                )
         else:
             if self.needs_arglist():
                 # for callables, add an empty parameter list
diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py
index d6582302602..402175a8420 100644
--- a/sphinx/writers/html5.py
+++ b/sphinx/writers/html5.py
@@ -174,6 +174,7 @@ def _visit_sig_parameter_list(
         self.required_params_left = sum(self.list_is_required_param)
         self.param_separator = node.child_text_separator
         self.multi_line_parameter_list = node.get('multi_line_parameter_list', False)
+        self.trailing_comma = node.get('multi_line_trailing_comma', False)
         if self.multi_line_parameter_list:
             self.body.append('\n\n')
             self.body.append(self.starttag(node, 'dl'))
@@ -238,7 +239,8 @@ def depart_desc_parameter(self, node: Element) -> None:
                 or is_required
                 and (is_last_group or next_is_required)
             ):
-                self.body.append(self.param_separator)
+                if not is_last_group or opt_param_left_at_level or self.trailing_comma:
+                    self.body.append(self.param_separator)
                 self.body.append('</dd>\n')
 
         elif self.required_params_left:
@@ -281,19 +283,26 @@ def visit_desc_optional(self, node: Element) -> None:
 
     def depart_desc_optional(self, node: Element) -> None:
         self.optional_param_level -= 1
+        level = self.optional_param_level
         if self.multi_line_parameter_list:
-            # If it's the first time we go down one level, add the separator
-            # before the bracket.
-            if self.optional_param_level == self.max_optional_param_level - 1:
+            max_level = self.max_optional_param_level
+            len_lirp = len(self.list_is_required_param)
+            is_last_group = self.param_group_index + 1 == len_lirp
+            # If it's the first time we go down one level, add the separator before the
+            # bracket, except if this is the last parameter and the parameter list
+            # should not feature a trailing comma.
+            if level == max_level - 1 and (
+                not is_last_group or level > 0 or self.trailing_comma
+            ):
                 self.body.append(self.param_separator)
             self.body.append('<span class="optional">]</span>')
             # End the line if we have just closed the last bracket of this
             # optional parameter group.
-            if self.optional_param_level == 0:
+            if level == 0:
                 self.body.append('</dd>\n')
         else:
             self.body.append('<span class="optional">]</span>')
-        if self.optional_param_level == 0:
+        if level == 0:
             self.param_group_index += 1
 
     def visit_desc_annotation(self, node: Element) -> None:
diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py
index 1004b716ed8..a4f80c8bbfa 100644
--- a/sphinx/writers/latex.py
+++ b/sphinx/writers/latex.py
@@ -954,6 +954,7 @@ def _visit_sig_parameter_list(
         self.required_params_left = sum(self.list_is_required_param)
         self.param_separator = r'\sphinxparamcomma '
         self.multi_line_parameter_list = node.get('multi_line_parameter_list', False)
+        self.trailing_comma = node.get('multi_line_trailing_comma', False)
 
     def visit_desc_parameterlist(self, node: Element) -> None:
         if self.has_tp_list:
@@ -1013,7 +1014,7 @@ def _depart_sig_parameter(self, node: Element) -> None:
             if (
                 opt_param_left_at_level
                 or is_required
-                and (is_last_group or next_is_required)
+                and (next_is_required or self.trailing_comma)
             ):
                 self.body.append(self.param_separator)
 
@@ -1055,13 +1056,20 @@ def visit_desc_optional(self, node: Element) -> None:
 
     def depart_desc_optional(self, node: Element) -> None:
         self.optional_param_level -= 1
+        level = self.optional_param_level
         if self.multi_line_parameter_list:
+            max_level = self.max_optional_param_level
+            len_lirp = len(self.list_is_required_param)
+            is_last_group = self.param_group_index + 1 == len_lirp
             # If it's the first time we go down one level, add the separator before the
-            # bracket.
-            if self.optional_param_level == self.max_optional_param_level - 1:
+            # bracket, except if this is the last parameter and the parameter list
+            # should not feature a trailing comma.
+            if level == max_level - 1 and (
+                not is_last_group or level > 0 or self.trailing_comma
+            ):
                 self.body.append(self.param_separator)
         self.body.append('}')
-        if self.optional_param_level == 0:
+        if level == 0:
             self.param_group_index += 1
 
     def visit_desc_annotation(self, node: Element) -> None:
diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py
index 748603d71a2..6190a64f53c 100644
--- a/sphinx/writers/text.py
+++ b/sphinx/writers/text.py
@@ -648,6 +648,7 @@ def _visit_sig_parameter_list(
         self.required_params_left = sum(self.list_is_required_param)
         self.param_separator = ', '
         self.multi_line_parameter_list = node.get('multi_line_parameter_list', False)
+        self.trailing_comma = node.get('multi_line_trailing_comma', False)
         if self.multi_line_parameter_list:
             self.param_separator = self.param_separator.rstrip()
         self.context.append(sig_close_paren)
@@ -699,7 +700,8 @@ def visit_desc_parameter(self, node: Element) -> None:
                 or is_required
                 and (is_last_group or next_is_required)
             ):
-                self.add_text(self.param_separator)
+                if not is_last_group or opt_param_left_at_level or self.trailing_comma:
+                    self.add_text(self.param_separator)
                 self.end_state(wrap=False, end=None)
 
         elif self.required_params_left:
@@ -740,20 +742,27 @@ def visit_desc_optional(self, node: Element) -> None:
 
     def depart_desc_optional(self, node: Element) -> None:
         self.optional_param_level -= 1
+        level = self.optional_param_level
         if self.multi_line_parameter_list:
+            max_level = self.max_optional_param_level
+            len_lirp = len(self.list_is_required_param)
+            is_last_group = self.param_group_index + 1 == len_lirp
             # If it's the first time we go down one level, add the separator before the
-            # bracket.
-            if self.optional_param_level == self.max_optional_param_level - 1:
+            # bracket, except if this is the last parameter and the parameter list
+            # should not feature a trailing comma.
+            if level == max_level - 1 and (
+                not is_last_group or level > 0 or self.trailing_comma
+            ):
                 self.add_text(self.param_separator)
             self.add_text(']')
             # End the line if we have just closed the last bracket of this group of
             # optional parameters.
-            if self.optional_param_level == 0:
+            if level == 0:
                 self.end_state(wrap=False, end=None)
 
         else:
             self.add_text(']')
-        if self.optional_param_level == 0:
+        if level == 0:
             self.param_group_index += 1
 
     def visit_desc_annotation(self, node: Element) -> None:


Test patch:
diff --git a/tests/test_builders/test_build_html.py b/tests/test_builders/test_build_html.py
index b82db80f1fd..035ee90baa1 100644
--- a/tests/test_builders/test_build_html.py
+++ b/tests/test_builders/test_build_html.py
@@ -12,6 +12,7 @@
 from sphinx._cli.util.errors import strip_escape_sequences
 from sphinx.builders.html import validate_html_extra_path, validate_html_static_path
 from sphinx.errors import ConfigError
+from sphinx.testing.util import etree_parse
 from sphinx.util.inventory import InventoryFile, _InventoryItem
 
 from tests.test_builders.xpath_data import FIGURE_CAPTION
@@ -657,3 +658,60 @@ def __call__(self, nodes):
         r'.//dt[@id="MyList"][1]',
         chk('class MyList[\nT,\n](list[T])'),
     )
+
+
+@pytest.mark.sphinx(
+    'html',
+    testroot='domain-py-python_maximum_signature_line_length',
+    confoverrides={
+        'python_maximum_signature_line_length': 1,
+        'python_trailing_comma_in_multi_line_signatures': False,
+    },
+)
+def test_html_pep_695_trailing_comma_in_multi_line_signatures(app):
+    app.build()
+    fname = app.outdir / 'index.html'
+    etree = etree_parse(fname)
+
+    class chk:
+        def __init__(self, expect: str) -> None:
+            self.expect = expect
+
+        def __call__(self, nodes):
+            assert len(nodes) == 1, nodes
+            objnode = ''.join(nodes[0].itertext()).replace('\n\n', '')
+            objnode = objnode.rstrip(chr(182))  # remove '¶' symbol
+            objnode = objnode.strip('\n')  # remove surrounding new lines
+            assert objnode == self.expect
+
+    # each signature has a dangling ',' at the end of its parameters lists
+    check_xpath(
+        etree,
+        fname,
+        r'.//dt[@id="generic_foo"][1]',
+        chk('generic_foo[\nT\n]()'),
+    )
+    check_xpath(
+        etree,
+        fname,
+        r'.//dt[@id="generic_bar"][1]',
+        chk('generic_bar[\nT\n](\nx: list[T]\n)'),
+    )
+    check_xpath(
+        etree,
+        fname,
+        r'.//dt[@id="generic_ret"][1]',
+        chk('generic_ret[\nR\n]() → R'),
+    )
+    check_xpath(
+        etree,
+        fname,
+        r'.//dt[@id="MyGenericClass"][1]',
+        chk('class MyGenericClass[\nX\n]'),
+    )
+    check_xpath(
+        etree,
+        fname,
+        r'.//dt[@id="MyList"][1]',
+        chk('class MyList[\nT\n](list[T])'),
+    )
diff --git a/tests/test_builders/test_build_latex.py b/tests/test_builders/test_build_latex.py
index 49c4bb5fd01..0a67e55bc4f 100644
--- a/tests/test_builders/test_build_latex.py
+++ b/tests/test_builders/test_build_latex.py
@@ -2214,7 +2214,6 @@ def test_one_parameter_per_line(app):
     app.build(force_all=True)
     result = (app.outdir / 'projectnamenotset.tex').read_text(encoding='utf8')
 
-    # TODO: should these asserts check presence or absence of a final \sphinxparamcomma?
     # signature of 23 characters is too short to trigger one-param-per-line mark-up
     assert (
         '\\pysiglinewithargsret\n'
@@ -2227,6 +2226,7 @@ def test_one_parameter_per_line(app):
         '{\\sphinxbfcode{\\sphinxupquote{foo}}}\n'
         '{\\sphinxoptional{\\sphinxparam{' in result
     )
+    assert r'\sphinxparam{\DUrole{n}{f}}\sphinxparamcomma' in result
 
     # generic_arg[T]
     assert (
@@ -2283,6 +2283,22 @@ def test_one_parameter_per_line(app):
     )
 
 
+@pytest.mark.sphinx(
+    'latex',
+    testroot='domain-py-python_maximum_signature_line_length',
+    confoverrides={
+        'python_maximum_signature_line_length': 23,
+        'python_trailing_comma_in_multi_line_signatures': False,
+    },
+)
+def test_one_parameter_per_line_without_trailing_comma(app):
+    app.build(force_all=True)
+    result = (app.outdir / 'projectnamenotset.tex').read_text(encoding='utf8')
+
+    assert r'\sphinxparam{\DUrole{n}{f}}\sphinxparamcomma' not in result
+    assert r'\sphinxparam{\DUrole{n}{f}}}}' in result
+
+
 @pytest.mark.sphinx('latex', testroot='markup-rubric')
 def test_latex_rubric(app):
     app.build()
diff --git a/tests/test_domains/test_domain_c.py b/tests/test_domains/test_domain_c.py
index 5416f6ec81d..6a5de744faa 100644
--- a/tests/test_domains/test_domain_c.py
+++ b/tests/test_domains/test_domain_c.py
@@ -1374,7 +1374,7 @@ def test_domain_c_c_maximum_signature_line_length_in_html(app):
 <dd>\
 <span class="n"><span class="pre">str</span></span>\
 <span class="w"> </span>\
-<span class="n"><span class="pre">name</span></span>,\
+<span class="n"><span class="pre">name</span></span>\
 </dd>
 </dl>
 
@@ -1395,6 +1395,6 @@ def test_domain_c_c_maximum_signature_line_length_in_text(app):
     content = (app.outdir / 'index.txt').read_text(encoding='utf8')
     param_line_fmt = STDINDENT * ' ' + '{}\n'
 
-    expected_parameter_list_hello = '(\n{})'.format(param_line_fmt.format('str name,'))
+    expected_parameter_list_hello = '(\n{})'.format(param_line_fmt.format('str name'))
 
     assert expected_parameter_list_hello in content
diff --git a/tests/test_domains/test_domain_cpp.py b/tests/test_domains/test_domain_cpp.py
index 19e5e07523d..f0de741e925 100644
--- a/tests/test_domains/test_domain_cpp.py
+++ b/tests/test_domains/test_domain_cpp.py
@@ -2426,7 +2426,7 @@ def test_domain_cpp_cpp_maximum_signature_line_length_in_html(app):
 <dd>\
 <span class="n"><span class="pre">str</span></span>\
 <span class="w"> </span>\
-<span class="n sig-param"><span class="pre">name</span></span>,\
+<span class="n sig-param"><span class="pre">name</span></span>\
 </dd>
 </dl>
 
@@ -2445,6 +2445,6 @@ def test_domain_cpp_cpp_maximum_signature_line_length_in_text(app):
     content = (app.outdir / 'index.txt').read_text(encoding='utf8')
     param_line_fmt = STDINDENT * ' ' + '{}\n'
 
-    expected_parameter_list_hello = '(\n{})'.format(param_line_fmt.format('str name,'))
+    expected_parameter_list_hello = '(\n{})'.format(param_line_fmt.format('str name'))
 
     assert expected_parameter_list_hello in content
diff --git a/tests/test_domains/test_domain_js.py b/tests/test_domains/test_domain_js.py
index ab3ed26c6cb..fad3907fb5b 100644
--- a/tests/test_domains/test_domain_js.py
+++ b/tests/test_domains/test_domain_js.py
@@ -755,3 +755,121 @@ def test_domain_js_javascript_maximum_signature_line_length_in_text(app):
         expected_f,
     )
     assert expected_parameter_list_foo in content
+
+
+@pytest.mark.sphinx(
+    'html',
+    testroot='domain-js-javascript_maximum_signature_line_length',
+    confoverrides={'javascript_trailing_comma_in_multi_line_signatures': False},
+)
+def test_domain_js_javascript_trailing_comma_in_multi_line_signatures_in_html(app):
+    app.build()
+    content = (app.outdir / 'index.html').read_text(encoding='utf8')
+    expected_parameter_list_hello = """\
+
+<dl>
+<dd>\
+<em class="sig-param">\
+<span class="n"><span class="pre">name</span></span>\
+</em>\
+</dd>
+</dl>
+
+<span class="sig-paren">)</span>\
+<a class="headerlink" href="#hello" title="Link to this definition">¶</a>\
+</dt>\
+"""
+    assert expected_parameter_list_hello in content
+
+    param_line_fmt = '<dd>{}</dd>\n'
+    param_name_fmt = (
+        '<em class="sig-param"><span class="n"><span class="pre">{}</span></span></em>'
+    )
+    optional_fmt = '<span class="optional">{}</span>'
+
+    expected_a = param_line_fmt.format(
+        optional_fmt.format('[')
+        + param_name_fmt.format('a')
+        + ','
+        + optional_fmt.format('['),
+    )
+    assert expected_a in content
+
+    expected_b = param_line_fmt.format(
+        param_name_fmt.format('b')
+        + ','
+        + optional_fmt.format(']')
+        + optional_fmt.format(']'),
+    )
+    assert expected_b in content
+
+    expected_c = param_line_fmt.format(param_name_fmt.format('c') + ',')
+    assert expected_c in content
+
+    expected_d = param_line_fmt.format(
+        param_name_fmt.format('d') + optional_fmt.format('[') + ','
+    )
+    assert expected_d in content
+
+    expected_e = param_line_fmt.format(param_name_fmt.format('e') + ',')
+    assert expected_e in content
+
+    expected_f = param_line_fmt.format(
+        param_name_fmt.format('f') + optional_fmt.format(']')
+    )
+    assert expected_f in content
+
+    expected_parameter_list_foo = """\
+
+<dl>
+{}{}{}{}{}{}</dl>
+
+<span class="sig-paren">)</span>\
+<a class="headerlink" href="#foo" title="Link to this definition">¶</a>\
+</dt>\
+""".format(expected_a, expected_b, expected_c, expected_d, expected_e, expected_f)
+    assert expected_parameter_list_foo in content
+
+
+@pytest.mark.sphinx(
+    'text',
+    testroot='domain-js-javascript_maximum_signature_line_length',
+    freshenv=True,
+    confoverrides={'javascript_trailing_comma_in_multi_line_signatures': False},
+)
+def test_domain_js_javascript_trailing_comma_in_multi_line_signatures_in_text(app):
+    app.build()
+    content = (app.outdir / 'index.txt').read_text(encoding='utf8')
+    param_line_fmt = STDINDENT * ' ' + '{}\n'
+
+    expected_parameter_list_hello = '(\n{})'.format(param_line_fmt.format('name'))
+
+    assert expected_parameter_list_hello in content
+
+    expected_a = param_line_fmt.format('[a,[')
+    assert expected_a in content
+
+    expected_b = param_line_fmt.format('b,]]')
+    assert expected_b in content
+
+    expected_c = param_line_fmt.format('c,')
+    assert expected_c in content
+
+    expected_d = param_line_fmt.format('d[,')
+    assert expected_d in content
+
+    expected_e = param_line_fmt.format('e,')
+    assert expected_e in content
+
+    expected_f = param_line_fmt.format('f]')
+    assert expected_f in content
+
+    expected_parameter_list_foo = '(\n{}{}{}{}{}{})'.format(
+        expected_a,
+        expected_b,
+        expected_c,
+        expected_d,
+        expected_e,
+        expected_f,
+    )
+    assert expected_parameter_list_foo in content
diff --git a/tests/test_domains/test_domain_py.py b/tests/test_domains/test_domain_py.py
index 9f19f1da34c..c67a1f09d2d 100644
--- a/tests/test_domains/test_domain_py.py
+++ b/tests/test_domains/test_domain_py.py
@@ -1073,6 +1073,133 @@ def test_domain_py_python_maximum_signature_line_length_in_text(app):
     assert expected_parameter_list_foo in content
 
 
+@pytest.mark.sphinx(
+    'html',
+    testroot='domain-py-python_maximum_signature_line_length',
+    confoverrides={'python_trailing_comma_in_multi_line_signatures': False},
+)
+def test_domain_py_python_trailing_comma_in_multi_line_signatures_in_html(app):
+    app.build()
+    content = (app.outdir / 'index.html').read_text(encoding='utf8')
+    expected_parameter_list_hello = """\
+
+<dl>
+<dd>\
+<em class="sig-param">\
+<span class="n"><span class="pre">name</span></span>\
+<span class="p"><span class="pre">:</span></span>\
+<span class="w"> </span>\
+<span class="n"><span class="pre">str</span></span>\
+</em>\
+</dd>
+</dl>
+
+<span class="sig-paren">)</span> \
+<span class="sig-return">\
+<span class="sig-return-icon">&#x2192;</span> \
+<span class="sig-return-typehint"><span class="pre">str</span></span>\
+</span>\
+<a class="headerlink" href="#hello" title="Link to this definition">¶</a>\
+</dt>\
+"""
+    assert expected_parameter_list_hello in content
+
+    param_line_fmt = '<dd>{}</dd>\n'
+    param_name_fmt = (
+        '<em class="sig-param"><span class="n"><span class="pre">{}</span></span></em>'
+    )
+    optional_fmt = '<span class="optional">{}</span>'
+
+    expected_a = param_line_fmt.format(
+        optional_fmt.format('[')
+        + param_name_fmt.format('a')
+        + ','
+        + optional_fmt.format('['),
+    )
+    assert expected_a in content
+
+    expected_b = param_line_fmt.format(
+        param_name_fmt.format('b')
+        + ','
+        + optional_fmt.format(']')
+        + optional_fmt.format(']'),
+    )
+    assert expected_b in content
+
+    expected_c = param_line_fmt.format(param_name_fmt.format('c') + ',')
+    assert expected_c in content
+
+    expected_d = param_line_fmt.format(
+        param_name_fmt.format('d') + optional_fmt.format('[') + ','
+    )
+    assert expected_d in content
+
+    expected_e = param_line_fmt.format(param_name_fmt.format('e') + ',')
+    assert expected_e in content
+
+    expected_f = param_line_fmt.format(
+        param_name_fmt.format('f') + optional_fmt.format(']')
+    )
+    assert expected_f in content
+
+    expected_parameter_list_foo = """\
+
+<dl>
+{}{}{}{}{}{}</dl>
+
+<span class="sig-paren">)</span>\
+<a class="headerlink" href="#foo" title="Link to this definition">¶</a>\
+</dt>\
+""".format(expected_a, expected_b, expected_c, expected_d, expected_e, expected_f)
+    assert expected_parameter_list_foo in content
+
+
+@pytest.mark.sphinx(
+    'text',
+    testroot='domain-py-python_maximum_signature_line_length',
+    freshenv=True,
+    confoverrides={'python_trailing_comma_in_multi_line_signatures': False},
+)
+def test_domain_py_python_trailing_comma_in_multi_line_signatures_in_text(app):
+    app.build()
+    content = (app.outdir / 'index.txt').read_text(encoding='utf8')
+    param_line_fmt = STDINDENT * ' ' + '{}\n'
+
+    expected_parameter_list_hello = '(\n{}) -> str'.format(
+        param_line_fmt.format('name: str')
+    )
+
+    assert expected_parameter_list_hello in content
+
+    expected_a = param_line_fmt.format('[a,[')
+    assert expected_a in content
+
+    expected_b = param_line_fmt.format('b,]]')
+    assert expected_b in content
+
+    expected_c = param_line_fmt.format('c,')
+    assert expected_c in content
+
+    expected_d = param_line_fmt.format('d[,')
+    assert expected_d in content
+
+    expected_e = param_line_fmt.format('e,')
+    assert expected_e in content
+
+    expected_f = param_line_fmt.format('f]')
+    assert expected_f in content
+
+    expected_parameter_list_foo = '(\n{}{}{}{}{}{})'.format(
+        expected_a,
+        expected_b,
+        expected_c,
+        expected_d,
+        expected_e,
+        expected_f,
+    )
+    assert expected_parameter_list_foo in content
+
+
 @pytest.mark.sphinx('html', testroot='root')
 def test_module_content_line_number(app):
     text = '.. py:module:: foo\n\n   Some link here: :ref:`abc`\n'