Problem/Motivation

This issue originally surfaced in Entity Browser where, in certain configurations used with Inline Entity Form or Layout Builder, there can be unexpected results:

#2764889: Entity Browser widget loses selected images in inline entity form
#3046416: Remove button conflict wrong triggering element
#3104901: Entity Browser used in a entity referenced field of a layout builder custom block is not working

But it seems the underlying issue may actually be a core FormBuilder bug.

Comment #7 in #2764889: Entity Browser widget loses selected images in inline entity form documents what's happening:

I have investigated further what makes inconsistent form state -> and I have ended in IEF module.

So what is going on (in "short"): for example when remove button is pressed without IEF

  1. in processing of action (FormBuilder::processForm()), rebuild is triggered -> FormBuilder::rebuildForm() and then FormBuilder::retrieveForm() -> that will execute EntityReferenceBrowserWidget::formElement()
  2. setting of new state is done in EntityReferenceBrowserWidget::formElement()
  3. and after FormBuilder::retrieveForm() in FormBuilder::rebuildForm() form state will be saved (cached)
  4. so we end with correct form state (when I say form state I mean form state relevant for Entity Browser)

Other case: for example when remove button is pressed within IEF

  1. in processing of action (FormBuilder::processForm()), rebuild is triggered -> FormBuilder::rebuildForm() and then FormBuilder::retrieveForm() -> that will execute creating of IEF form and -> it will generate #process callback for inner form
  2. and after FormBuilder::retrieveForm() in FormBuilder::rebuildForm() form state will be saved (cached)
  3. after saving of form state inside FormBuilder::rebuildForm() -> FormBuilder::doBuildForm() will be triggered. That will pick up all defined #process callbacks and execute them
  4. execution of defined #process callback will execute EntityReferenceBrowserWidget::formElement() and new form state will be set
  5. But!!! Problem is that form state is already saved (cached) and form state set during execution of #process callback will not be saved and actions after that will have wrong form state

Inline block forms in Layout Builder work similarly to IEF, in that the entity form is built in a #process callback (see \Drupal\layout_builder\Plugin\Block\InlineBlock::blockForm()

The root issue seems to be that in \Drupal\Core\Form\FormBuilder::rebuildForm(), the form and form state is cached before the call to doBuildForm, where the #process callbacks are run. If we compare how the form cache is set in rebuildform() to processForm, we can see that in the latter method, caching is done after the doBuildForm() call, using a copy of the form array created before the doBuildForm().

While the identified issues are specific to Entity Browser, this could affect any code where form state storage is updated in #process callbacks.

Steps to reproduce

See #13

My setup is Drupal 10.1.7 with PHP 8.1.

The patch solved this bug:
1. Create a custom block with a required reference field, e.g. for nodes
2. Configure its form display to use an entity browser to display the field. Display the Remove button.
3. Configure the entity browser to use modal windows.
4. Edit the layout of a basic page and add the custom block.
5. Use the entity browser to add a couple of nodes.
6. Remove one of the nodes.
7. Remove another one. Instead of removing the node, the modal window to add more nodes opens.

It also solves this bug:
1. Create a custom block with a required reference field, e.g. for nodes
2. Configure its form display to use an entity browser to display the field.
3. Configure the entity browser to use modal windows.
4. Edit the layout of a basic page and add the custom block.
5. Use the entity browser to add a couple of nodes.
6. Try to save the block, but provoke a validation error, e.g. by saving the block without a title. The Layout Builder is reloaded, but the previously selected nodes are not displayed anymore. They are back again if you add another node or if you simply save the block without seeing them.

Proposed resolution

Use similar form caching code in rebuildForm() to what is done in processForm():

  1. Save a copy of the form array before doBuildForm() call
  2. Call doBuildForm()
  3. Cache if necessary with unprocessed copy of form array saved before doBuildForm()
  4. return built and processed form array

Remaining tasks

Write patch and tests.

User interface changes

API changes

Data model changes

Release notes snippet

Issue fork drupal-3177977

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

godotislate created an issue. See original summary.

godotislate’s picture

Status: Active » Needs review
Issue tags: +Needs tests
StatusFileSize
new1.9 KB

Here's a patch - running tests to see if anything breaks.

@todo Add test to demonstrate bug.

Version: 9.2.x-dev » 9.3.x-dev

Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

tlo405’s picture

Status: Needs review » Reviewed & tested by the community

I was having issues with a required entity reference field in layout builder, and this patch fixed the problem for me. What the patch is doing makes sense to me and the code looks good. I know this still needs tests, but I would like to get some more feedback on this approach.

effulgentsia’s picture

Nice find! I had to do some archaeology to see why we have the inconsistency to begin with.

I think part of it is that initially (2009 and earlier), we did not intend for #process callbacks to put things into $form_state['storage'] (back then we had explicit $form_state['storage'], whereas now it's all stored except for $form_state->setTemporaryValue()). So, the setCache() in buildForm() did not include $form_state, and only the one in rebuildForm() did, and that was cached prior to invoking #process callbacks.

#302240-25: button broken due to fix various problems when using form storage started including $form_state in the pre-rebuilt setCache(), and then #634440-33: Remove auto-rebuilding magic for $form_state['storage'] changed rebuilds to not be auto-triggered whenever there's new $form_state to store. Between those two changes (and maybe others as well), we got into the situation of divergent code paths between caching the initial form build and caching the rebuild.

I want to do some more research on why #process callbacks should (or shouldn't) change $form_state in a way that requires persistence. But if we accept the idea that they do (and have good reason to), then I think what #2 proposes is correct.

+++ b/core/lib/Drupal/Core/Form/FormBuilder.php
@@ -429,20 +429,29 @@ public function rebuildForm($form_id, FormStateInterface &$form_state, $old_form
-    // @todo For Drupal 8, find a way to avoid this code duplication.
+    // @todo Find a way to avoid caching code duplication.

More than a decade after I first wrote that @todo line, I wonder if it's feasible to fully resolve it by centralizing the call to setCache() into one place, so that we don't in the future again run into this kind of divergence of when it's called relative to other steps.

effulgentsia’s picture

Status: Reviewed & tested by the community » Needs work

I'm conflicted between setting this to Needs work (for tests) or Needs review (for more discussion on what are legitimate use cases for #process callbacks to persist $form_state changes). Especially considering that #process callbacks get run again after the form is retrieved from cache.

However, the test itself could be a good way to elucidate the use case, so setting to Needs work for that.

tlo405’s picture

This is already covered in great detail above, however I figured I would give my exact use case where I am seeing this issue just in case it helps anyone..

So I have a few block types, each one with its own media field. The media field is an entity reference field which opens up a modal with all the available images (this modal is a custom entity browser). In layout builder, when I go into an existing block (by clicking 'configure') a modal pops open with all the block fields (this modal is provided by the layout_builder_modal module). if I go down to the media field, I am able to remove the existing media item without any issue. However, clicking 'Select media' again does not open up the modal, therefore I can't select a new image. This is happening only when the media field is required. If the field is not required, I can add and remove as many times as I want. If I create a new block, I can add and remove as many times as I want. I only see the error when modifying an existing block that already has a media item selected.

swentel’s picture

Been bitten with something alike.

Setup

- custom block type, with a paragraph (multiple), which contains an image field using the entity browser widget
- go to layout on a node, add a block
- upload one image
- click add another
- uploaded image is gone

With the patch, everything is fine.

This did work though on latest D8 and entity browser 2.5, so not exactly sure what happened between latest D8 and D9.2.9 - and where exactly the problem might be.

Version: 9.3.x-dev » 9.4.x-dev

Drupal 9.3.0-rc1 was released on November 26, 2021, which means new developments and disruptive changes should now be targeted for the 9.4.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.4.x-dev » 9.5.x-dev

Drupal 9.4.0-alpha1 was released on May 6, 2022, which means new developments and disruptive changes should now be targeted for the 9.5.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.5.x-dev » 10.1.x-dev

Drupal 9.5.0-beta2 and Drupal 10.0.0-beta2 were released on September 29, 2022, which means new developments and disruptive changes should now be targeted for the 10.1.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 10.1.x-dev » 11.x-dev

Drupal core is moving towards using a “main” branch. As an interim step, a new 11.x branch has been opened, as Drupal.org infrastructure cannot currently fully support a branch named main. New developments and disruptive changes should now be targeted for the 11.x branch, which currently accepts only minor-version allowed changes. For more information, see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

ammaletu’s picture

I just tested the patch from #2 and all kinds of bugs went away. It's hard to imagine how much headscratching and confusion could have been avoided with this being included in core three years ago!

My setup is Drupal 10.1.7 with PHP 8.1.

The patch solved this bug:
1. Create a custom block with a required reference field, e.g. for nodes
2. Configure its form display to use an entity browser to display the field. Display the Remove button.
3. Configure the entity browser to use modal windows.
4. Edit the layout of a basic page and add the custom block.
5. Use the entity browser to add a couple of nodes.
6. Remove one of the nodes.
7. Remove another one. Instead of removing the node, the modal window to add more nodes opens.

It also solves this bug:
1. Create a custom block with a required reference field, e.g. for nodes
2. Configure its form display to use an entity browser to display the field.
3. Configure the entity browser to use modal windows.
4. Edit the layout of a basic page and add the custom block.
5. Use the entity browser to add a couple of nodes.
6. Try to save the block, but provoke a validation error, e.g. by saving the block without a title. The Layout Builder is reloaded, but the previously selected nodes are not displayed anymore. They are back again if you add another node or if you simply save the block without seeing them.

It also apparently solves a bug we had where a cached form_state was reused when different anonymous users opened the form at the same time. Only the fastest of these users then could save the form, the other's got a "duplicate UUID" error.

So, how do we add a test for this in a generic way, independent of the entity browser module? And are we sure that the approach in the patch is the right way? Is there a way to get a core committer to have a look at this? To me, this seems like quite a fundamental issue, causing numerous seemingly unrelated and hard to debug bugs. Not some minor issue that can easily be patched by community members.

godotislate’s picture

effulgentsia is a Form API subsystem maintainer, and from his comments a couple years ago, he was hoping to see a test to determine next steps.

I was in the process of creating an automated test for this back then, but the test I wrote was not working as expected, and then I moved to other project. I happened to re-visit this a couple weeks ago, but the challenge is that there are no forms in core that I can find that sets form storage after the cache happens, and it's non-trivial trying to devise a test form that does.

This issue only has 10 followers, so I'm guessing there isn't a lot of awareness of how it affects entity browser or similar other contrib, but perhaps if there were more eyes on this issue, things could move forward.

wim leers’s picture

Priority: Normal » Major

godotislate’s picture

Status: Needs work » Needs review
Issue tags: -Layout Builder, -forms

Added a test and created an MR. Test doesn't really demonstrate a use case, but it does demonstrate that form state changes in #process callbacks aren't cached during rebuild.

godotislate’s picture

Issue tags: -Needs tests +Layout Builder, +forms
godotislate’s picture

Issue summary: View changes

It also apparently solves a bug we had where a cached form_state was reused when different anonymous users opened the form at the same time. Only the fastest of these users then could save the form, the other's got a "duplicate UUID" error.

@Ammaletu Is the bug reproducible without contrib or custom code? If so, can you provide steps to reproduce?

godotislate’s picture

wim leers’s picture

Very interesting change! 🧐 👏

This indeed needs sign-off from a Form API maintainer, because it has significant potential repercussions. Pinged @tim.plunkett.

ammaletu’s picture

@Ammaletu Is the bug reproducible without contrib or custom code? If so, can you provide steps to reproduce?

I'm afraid not. I had another look, and we got the "duplicate UUID" problem because we are caching the form state (to solve yet another bug). Invoking the cache kill switch on the pages with the form solved this for guest users, except for one form which is displayed via AJAX. For this last form with the "duplicate UUID" problem, the patch from this issue helped. I'am afraid I don't have the time and expertise to boil this down into a generic use case.

godotislate’s picture

I'm afraid not. I had another look, and we got the "duplicate UUID" problem because we are caching the form state (to solve yet another bug).

OK, thanks for checking. Mostly I was asking because there wasn't context on what type of form/use case was surfacing the "duplicate UUID" issue, but I'm assuming from your last comment that anonymous users have access to an entity form that has an entity browser widget.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new1.86 KB

The Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

godotislate’s picture

Status: Needs work » Needs review

Rebased and addressed new code sniff issue.

smustgrave’s picture

Going to bump this to framework manager.

smustgrave’s picture

Status: Needs review » Reviewed & tested by the community

Going to take a leap on this one.

1) Drupal\Tests\system\Functional\Form\FormStatePersistTest::testFormStatePersistence
Element matching xpath "//div[@data-drupal-messages]//div[contains(., "Rebuild state cached.")]" not found.
/builds/issue/drupal-3177977/core/tests/Drupal/Tests/WebAssert.php:856
/builds/issue/drupal-3177977/core/modules/system/tests/src/Functional/Form/FormStatePersistTest.php:53
/builds/issue/drupal-3177977/vendor/phpunit/phpunit/src/Framework/TestResult.php:729
FAILURES!
Tests: 1, Assertions: 5, Failures: 1.

Shows the test coverage for the change.

Reviewing the code I don't see anything glaring wrong.

Issue summary appears complete.

Fingers crossed.

quietone’s picture

@godotislate, thanks for the issue summary updated and the test.

I read the IS, comments and MR (but I have a headache - so really a skim). Of note is the analysis in #5. Leaving at RTBC

catch’s picture

I'm adding back needs subsytem maintainer review. Just because a subsystem maintainer hasn't reviewed an issue yet doesn't mean they never will. The escalation to framework manager from subsystem maintainer is as a fallback. Framework managers are hugely overstretched so it does not make things happen faster artificially taking subsystem maintainers out of the loop.

fwiw this looks sensible to me and the comments/logic are as straightforward as I could imagine them getting considering it's a complex area.

larowlan’s picture

Status: Reviewed & tested by the community » Needs review

Took a look at the code and agree with catch, seems reasonable - I could only find some nits, I've left them, but I don't think addressing them will advance the issue.

What we need here is some input from the Form API maintainers that there's no downsides to this change.

@effulgentsia is one of those and said this earlier

Especially considering that #process callbacks get run again after the form is retrieved from cache

, but I don't think that helps alleviate the issue. There are some genuine places where the only tool you have in your toolbox to alter a form is a process callback - examples of this would be e.g. as a plugin where you're building a plugin settings form but need to alter the larger form.

I'll ping Alex and Tim. Leaving at Needs review to reflect the actual status.

godotislate’s picture

Rebased and applied suggestions from #30. Leaving in Needs Review for subsystem maintainer review.

bkosborne’s picture

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new90 bytes

The Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

godotislate’s picture

Status: Needs work » Needs review

Rebased for merge conflict.

larowlan’s picture

Status: Needs review » Needs work
Issue tags: -Needs subsystem maintainer review, -Needs framework manager review

This looks good to me. In #5 @effulgentsia asked if we could address the todo here, so that we only set form cache in one spot and don't have divergent paths.
Can we explore to see if that is possible here?

godotislate’s picture

Took a shot at consolidating the cacheing code, but it broke other tests, so I reverted. Might try again tomorrow.

godotislate’s picture

Status: Needs work » Needs review

OK, after a couple failed attempts, I'm not sure that consolidating the cache set code is possible without a larger refactor of form processing.
The challenge is that between processForm() and rebuildForm(), in each we want to cache the form array as it is before the call to doBuildForm() with the $form_state object after the call to doBuildForm.

The flow looks like this:

processForm
  - save form array here
  - doBuildForm
  - process input
  - rebuildForm (if necessary)
  - cache form array + form state if not already cached in rebuildForm

rebuildForm
  - save form array here
  - doBuildForm
  - cache form array + form state

If the caching step is removed from rebuildForm is removed, and caching is done at the end of processForm regardless of whether the form is rebuilding, this works in most cases, except in batch processing.

Because for batch processing, there's this in buildForm():

    if ($request->getSession()->has('batch_form_state')) {
      // We've been redirected here after a batch processing. The form has
      // already been processed, but needs to be rebuilt. See _batch_finished().
      $session = $request->getSession();
      $form_state = $session->get('batch_form_state');
      $session->remove('batch_form_state');
      return $this->rebuildForm($form_id, $form_state);
    }

So rebuildForm() is called directly without a call to processForm(), and the form/form state don't get cached.

If all the caching is done within doBuildForm(), with the form array being saved at the beginning of the method, and the caching being done right before the return statement, the form state that gets cached does not include the changes made from input processing after the doBuildForm call in processForm, if the form is not rebuilding.

I think the MR is probably left as is, though I can remove this: @todo Find a way to avoid caching code duplication.

godotislate’s picture

Removed the @todo about code duplication since it was investigated in 37.

Also, since @larowlan has looked at the MR, and he's a Form API maintainer now, I think this is ready.

godotislate’s picture

Refactored from a functional test to a kernel test.

godotislate changed the visibility of the branch 3177977-form-state-storage-test-only-test to hidden.

smustgrave’s picture

Status: Needs review » Reviewed & tested by the community

Believe feedback from @larowlan has been addressed, test-only still fails the same #27 so believe we are good. Going to take a swing.

godotislate’s picture

Added to the new kernel test and rebased. Nothing else has changed, so I think it's fair to leave in RTBC.

  • catch committed f4b4015c on 11.x
    fix: #3177977 Form state storage changes in #process callbacks are not...

  • catch committed 1645a699 on main
    fix: #3177977 Form state storage changes in #process callbacks are not...
catch’s picture

Status: Reviewed & tested by the community » Fixed

Now that @larowlan has reviewed (covers both framework and subsystem maintainer), and there's been more than enough time for @effulgentsia and @timplunkett to object I'm happy to go ahead here. Given this is very, very low level, going to commit to main/11.x but not backport to 11.3.x though.

Committed/pushed to main and 11.x, thanks!

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.