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
- Install
drupal/openid_connect3.0.0-alpha6 or newer. (alpha6 fixed theLogicExceptionpreviously raised from this same code path via href="https://www.drupal.org/project/openid_connect/issues/3504763">#3504763:render()switched torenderRoot(), so the backend now succeeds and emits the problematic markup.) - Configure openid_connect with
user_login_display: belowand at least one OAuth client. - In a decoupled frontend consuming the CE-API
/user/loginendpoint, render the login form and attempt to log in with Drupal credentials. - Inspect the DOM: duplicate
input[name="form_id"]andinput[name="form_build_id"]inside the outer<form>. - Submit: Drupal processes
openid_connect_login_forminstead ofuser_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 resultingCustomElement:- string /
MarkupInterfacevalues viasetSlot('prefix', $value). - render-array values via
setSlotFromRenderArray('prefix', $value), so cacheability bubbles correctly.
- string /
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
Issue fork lupus_decoupled-3586210
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
Comment #3
petar_basic commentedComment #4
petar_basic commentedExtracts 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.
Comment #5
fagothank 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?
Comment #6
petar_basic commentedI'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?
Comment #8
fagofix is good and makes sense, tested successfully!