Problem/Motivation

In a standard Drupal page, a form render array's #prefix and #suffix render outside the <form> tag: they prepend and append the rendered form, not sit inside it. Modules rely on this. For example, openid_connect_form_user_login_form_alter() pre-renders the OpenID login form to HTML and writes it to $form['#suffix'], expecting that HTML (which contains its own <form> element) to appear as a sibling form below the standard login form.

CustomElementsFormControllerTrait::getCustomElementsContentResult() strips #theme_wrappers so the decoupled frontend can add its own <form> wrapper, but it keeps #prefix and #suffix in the default slot. When the frontend wraps the default-slot markup in <form>...</form>, content that was supposed to be outside the form ends up nested inside it. For openid_connect this is particularly harmful: the nested <form> start tag is silently dropped by the HTML5 parser (nested forms are invalid), but its orphaned children (including the hidden inputs form_id=openid_connect_login_form and its form_build_id) remain as children of the outer form, contaminating the submission.

Steps to reproduce

  1. Install drupal/openid_connect 3.0.0-alpha6 or newer. (alpha6 fixed the LogicException previously raised from this same code path via href="https://www.drupal.org/project/openid_connect/issues/3504763">#3504763: render() switched to renderRoot(), so the backend now succeeds and emits the problematic markup.)
  2. Configure openid_connect with user_login_display: below and at least one OAuth client.
  3. In a decoupled frontend consuming the CE-API /user/login endpoint, render the login form and attempt to log in with Drupal credentials.
  4. Inspect the DOM: duplicate input[name="form_id"] and input[name="form_build_id"] inside the outer <form>.
  5. Submit: Drupal processes openid_connect_login_form instead of user_login_form; login silently fails.

Expected behaviour

#prefix and #suffix render outside the frontend's <form> wrapper, matching the semantics every other Drupal renderer preserves. The OpenID login form appears as a sibling
<form> below (or above) the standard login form, as it does in a standard Drupal theme.

Proposed resolution

In CustomElementsFormControllerTrait::getCustomElementsContentResult(), before CustomElement::createFromRenderArray($form) runs:

  • Detach top-level $form['#prefix'] and $form['#suffix'] from the render array.
  • Expose each as a named slot (prefix, suffix) on the resulting CustomElement:
    • string / MarkupInterface values via setSlot('prefix', $value).
    • render-array values via setSlotFromRenderArray('prefix', $value), so cacheability bubbles correctly.

This matches the custom_elements module's own slot model (see custom_elements_prepare_slots_as_web_component() in custom_elements.module, which renders each non-default slot wrapped in <div slot="NAME">). It also maps directly to the W3C Web Components <slot name="..."> convention and Vue's v-slot:name, the established cross-framework way to expose multi-region content.

Frontend components can opt in by placing <slot name="prefix" /> and <slot name="suffix" /> around their form wrapper, exactly the position the markup occupies in standard Drupal theme rendering. Components that don't add those slots simply won't render the prefix/suffix content, which is safer than today's silent contamination.

Remaining tasks

User interface changes

None on the backend. On the frontend, consumers who were implicitly relying on #prefix and #suffix being inlined in the default slot will no longer see that markup unless they add slot consumers; but those
consumers were already experiencing the HTML5 nested-form contamination described above whenever the content contained a <form>, so this is a bug fix rather than an API break.

API changes

Data model changes

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

petar_basic created an issue. See original summary.

petar_basic’s picture

StatusFileSize
new5.53 KB
petar_basic’s picture

Assigned: petar_basic » Unassigned
Status: Needs work » Needs review

Extracts top-level #prefix/#suffix off the form render array in CustomElementsFormControllerTrait::getCustomElementsContentResult() and exposes each as a named slot on the custom element. Render arrays go through setSlotFromRenderArray() so cacheability bubbles, scalar/MarkupInterface through setSlot(). Kernel test covers both shapes plus the default-slot-stays-clean contract.

Verified against a decoupled Drupal + Nuxt setup with openid_connect's user_login_display = below. Before: /ce-api/user/login returned slots.default with the nested openid_connect inside. The frontend's wrapper absorbed its duplicate form_id hidden inputs (HTML5 flattens the inner start tag but keeps its children), so the outer POST silently targeted the wrong form handler — login just failed with no error visible to the user. After: slots.default is clean, slots.suffix holds the nested form, the frontend renders it as a sibling via a named slot, login works.

fago’s picture

Status: Needs review » Needs work

thank you. I think adding the dedicated slots is the right solution to the problem, so the form-frontend can control whether it wants to add it to the output or not.

However, the MR itself needs some work though. Be sure to avoid lengthy AI comments that convolute the general problem solved with the specific fix. We need brief, relevant comments that address the culprit of what we are doing and why on a general way.

Minor, but also code-wise it seems weird to split it on two sections, cannot we just conditionally remove+set those values in one go? Could this be simplified?

petar_basic’s picture

Status: Needs work » Needs review

I've reduced the comments to something meaningfull. Improved the code a bit as well, but I am not completely sure what you mean by split it on two sections?

  • fago committed 00af5f4c on 1.x authored by petar_basic
    fix: #3586210: Form #prefix/#suffix can break forms, extract them into...
fago’s picture

Status: Needs review » Fixed

fix is good and makes sense, tested successfully!

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.