Problem/Motivation

When editing a Lesson and adding Activities via the "Add references" modal (the `activities_selection` view rendered as a ModalSubform), closing the modal after selecting one or more activities causes the visual "Activities" list on the parent Lesson form to be wiped out (replaced with empty markup), even though the underlying field value is saved correctly if the Lesson is submitted immediately after.

This is a display/AJAX bug only — no data is lost — but it is very confusing for editors, since the form appears to have discarded their selections.

Steps to reproduce

  1. Edit a Lesson entity.
  2. In the Activities field widget, click the button that opens the "Add references" modal (`activities_selection` view).
  3. Select one or more activities and click "Add references."
  4. Observe: the modal closes, but the Activities list on the Lesson form is now empty. The value is still present in form state though — saving the Lesson at this point persists the selected activities correctly.

Reproduced on:
- drupal/lms 1.1.18 (stable)
- drupal/lms 1.2.0-rc2
- Confirmed independent of theme: reproduces with default Claro (admin) / Olivero, so it is not a theme/JS conflict.

Observed network traffic

Request payload (relevant fields) for the AJAX call fired when the modal closes:

plugin_id: view
dialog_operation: close
rebuild_parent: 1
type: lms_activity
parent[plugin_id]: entity
parent[type]: lms_lesson
parent[bundle]: lesson
parent[entity_id]: 1
parent[form_build_id]: form-o3NkbjAJ-3YntaS0DPccNyI2uTualm0pL-aSd2Lxorc
view_id: activities_selection
display_id: default
_wrapper_format: drupal_ajax

lms_select[1]: lms_activity:content:1
form_build_id: form--K_ZbFbjyInyQGLUUke-t-YIy3WQ9ndiUsjN8uQrwqg
_triggering_element_name: op
_triggering_element_value: Add references
_drupal_ajax: 1

Response (full):

json
[
    {
        "command": "update_build_id",
        "old": "form-o3NkbjAJ-3YntaS0DPccNyI2uTualm0pL-aSd2Lxorc",
        "new": "form-b40TGCKfABq0K7o0nfWN_5-tdpR0tIAnhwq9DheUhxA"
    },
    {
        "command": "insert",
        "method": "replaceWith",
        "selector": "[data-drupal-selector=\"edit-activities-wrapper\"]",
        "data": "",
        "settings": null
    },
    {
        "command": "closeDialog",
        "selector": "#modal-view-activities-selection",
        "persist": false
    }
]

Notice the `insert`/`replaceWith` command targets the correct selector (`edit-activities-wrapper`), but `data` is an empty string — the wrapper is being replaced with nothing rather than the rebuilt list of activities.

Also worth noting: no second/follow-up AJAX request fires after this one, so this isn't a race condition between two requests — a single request's rebuild step is producing an empty render.

Root cause analysis

Looking at `src/Controller/ModalSubformController.php`, the `rebuild_parent` branch of `endpoint()` rebuilds the parent (Lesson) form using **cached user input from when the dialog was originally opened** (`getCachedInput()`), not the current state of the form:

php
$parent_input = $this->getCachedInput($query['parent']['form_build_id']);
...
$parent_form = $parent_plugin->buildForm([
  'rebuild_info' => [
    'modal_form_data' => $plugin->getSubmissionData(),
  ],
  'skip_validation' => TRUE,
]);

The rebuilt `$parent_form`/`$parent_plugin->getFormState()` is then passed to `getAjaxResponse()`, which re-derives the "triggering element" from that cached input and invokes its `#ajax['callback']`:

php
$trigger = $form_state->getTriggeringElement();
if (
  \is_array($trigger) &&
  \array_key_exists('#ajax', $trigger) &&
  \array_key_exists('callback', $trigger['#ajax'])
) {
  $element = \implode('][', $trigger['#array_parents'] ?? [$trigger['#name'] ?? '?']);
  $callback = $form_state->prepareCallback($trigger['#ajax']['callback']);
  ...
  $form_response = \call_user_func_array($callback, [$form, $form_state, $this->requestStack->getCurrentRequest()]);
  ...
  foreach ($form_response as $command) {
    ...
    $response->addCommand($command);
  }
}

There is already a comment directly above this code acknowledging the exact fragility that appears to cause this bug:

> // Identify the element for logging: the rebuild re-derives the triggering
> // element from cached parent input, and a parent widget whose button
> // #value is not stable can resolve to the wrong element here.

Because the Activities field widget's add/remove buttons do not appear to have stable `#name`/`#value` pairs across rebuilds (adding a new activity necessarily changes the widget's item count/deltas between when the dialog opened and when it closes), Drupal's standard triggering-element matching (matching `_triggering_element_name`/`_triggering_element_value` from the cached input against elements in the freshly-built form) resolves to the wrong element, or to an element whose `#array_parents` no longer point at populated content in the newly built form.

The resolved callback still executes successfully and returns valid `CommandInterface` objects (which is why no JS error or AlertCommand appears), but the render it produces for the `edit-activities-wrapper` selector ends up empty — most likely because `NestedArray::getValue()` (or equivalent) against the mismatched `#array_parents` returns an empty/missing render array, which then renders to an empty string.

Meanwhile, the actual selected activity data is safely present in the parent form's cached/rebuilt form state (via `modal_form_data`/`getSubmissionData()`), which is why final submission of the Lesson form still saves correctly — this bug is isolated entirely to the AJAX re-render step, not the data persistence.

Proposed area to investigate / fix

- In `ModalSubformController::getAjaxResponse()`, avoid relying on `$form_state->getTriggeringElement()` re-derived from stale cached input to select which wrapper/callback to invoke for the parent rebuild. Consider having the ModalSubform plugin (or the field widget) directly specify a canonical wrapper selector/callback for the "parent rebuild after modal close" case, rather than depending on Drupal's generic triggering-element matching against form input that is known to be stale by design.
- Alternatively, ensure the Activities field widget's relevant buttons have stable, delta-independent `#name` values so the standard triggering-element matching against cached input remains reliable even when the widget's item count changes between dialog open and close.

Impact

Cosmetic/UX only as far as we can tell — data saves correctly — but it's highly confusing for content editors, who may believe their activity selections were lost and either re-add them (creating duplicates) or abandon editing.

Comments

oalejandroh created an issue. See original summary.

avpaderno’s picture

Version: 1.2.0-rc2 » 1.2.x-dev
graber’s picture

Status: Active » Postponed (maintainer needs more info)

Strange, just tried to reproduce this on a site without any customization to the widget and Claro as the admin theme and no luck. What’s more.. the widget has quite extensive FunctionalJavascript test coverage and all is green:
https://git.drupalcode.org/project/lms/-/blob/1.2.x/tests/src/Functional...

Could you maybe try to modify the test a bit by adding a failing case so we can actually pin it? I feel the provided steps to reproduce are missing something important.

graber’s picture

Status: Postponed (maintainer needs more info) » Needs review
Related issues: +#3617784: LMS reference widget disappears on save if in a field group

Please check if the field_group fix resolves the issue on your setup too: #3617784: LMS reference widget disappears on save if in a field group

graber’s picture

Status: Needs review » Closed (works as designed)

Closing with the un-existing "nobody cares anymore" status.

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.