Data loss: `ComponentTreeItem::setValue()` and `::onChange()` may inappropriately upgrade `component_version` to the latest version

Created on 16 July 2025, 30 days ago

Overview

Discovered at πŸ› Deleting or renaming SDC prop will break xb pages using old version of the component Active :

While manually testing the fix for the reported bug (see @heyyo's #7), I couldn't get to the point where I had 2 component instances of different version in the component tree. First I thought "PEBKAC". And it was. While editing, I got 2 different versions πŸ‘ But then upon saving, they both changed to the same version! 😱

IOW: the component version is always overwritten to the active component version, instead of retaining the original component version.

This appears to be a bug in the Auto-save functionality because the client sends the correct Component versions:

but the corresponding auto-save entry contains this:


                    [field_xb_demo] => Array
                        (
                            [0] => Array
                                (
                                    [uuid] => 89b1e8ac-ac5d-459a-94b9-f7a6d2f0b257
                                    [inputs] => {"text":"The og heading element","style":"primary","element":"h1"}
                                    [component_id] => sdc.experience_builder.heading
                                    [parent_uuid] => 
                                    [component_version] => acbd38ffd9d42d04
                                    [slot] => 
                                    [label] => 
                                )

                            [1] => Array
                                (
                                    [uuid] => c0d4e9e1-23c0-46ee-9a71-a75d6a5494cb
                                    [inputs] => {"style":"primary","text":"Dramatic title!","element":"h1"}
                                    [component_id] => sdc.experience_builder.heading
                                    [parent_uuid] => 
                                    [component_version] => acbd38ffd9d42d04
                                    [slot] => 
                                    [label] => 
                                )

                            [2] => Array
                                (
                                    [uuid] => 770f670f-5526-4704-9c55-8ac28b022751
                                    [component_id] => sdc.experience_builder.heading
                                    [component_version] => acbd38ffd9d42d04
                                    [inputs] => {"text":"drama","style":"primary","element":"h1"}
                                    [parent_uuid] => 
                                    [slot] => 
                                    [label] => 
                                )

                        )

… i.e. everything on acbd38ffd9d42d04 aka the latest version.

β€” #3532514-12: Deleting or renaming SDC prop breaks *editing* component instances using prior versions: harden `GeneratedFieldExplicitInputUxComponentSourceBase::buildConfigurationForm()` β†’
+

I've traced #12 to a bug in \Drupal\experience_builder\ClientDataToEntityConverter::convert(), specifically:

$item_list->setValue(self::convertClientToServer($layout['components'], $model, $entity, $validate));

… but not through a flaw in Auto-save functionality! It appears to be a bug in \Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem::setValue() and ::onChange() 🫠

β€” #3532514-13: Deleting or renaming SDC prop breaks *editing* component instances using prior versions: harden `GeneratedFieldExplicitInputUxComponentSourceBase::buildConfigurationForm()` β†’

See attached screencast of the data loss happening in real time while debugging.

Proposed resolution

User interface changes

None.

πŸ› Bug report
Status

Active

Version

0.0

Component

Data model

Created by

πŸ‡§πŸ‡ͺBelgium wim leers Ghent πŸ‡§πŸ‡ͺπŸ‡ͺπŸ‡Ί

Live updates comments and jobs are added and updated live.
  • Needs tests

    The change is currently missing an automated test that fails when run with the original code, and succeeds when the bug has been fixed.

Sign in to follow issues

Merge Requests

Comments & Activities

  • Issue created by @wim leers
  • πŸ‡§πŸ‡ͺBelgium wim leers Ghent πŸ‡§πŸ‡ͺπŸ‡ͺπŸ‡Ί

    This blocks fixing πŸ› Deleting or renaming SDC prop will break xb pages using old version of the component Active .

    This regression appears to have been introduced in πŸ“Œ Version component prop definitions for SDC and Code components Active .

  • πŸ‡§πŸ‡ͺBelgium wim leers Ghent πŸ‡§πŸ‡ͺπŸ‡ͺπŸ‡Ί

    So turns out this is literally doing what I was concerned about:

        // TRICKY: do this *only* when no version is specified, otherwise this would
        // unintentionally "upgrade" instances of older component versions to newer
        // ones!
    

    πŸ˜…

    Test coverage pushed πŸ‘

  • πŸ‡§πŸ‡ͺBelgium wim leers Ghent πŸ‡§πŸ‡ͺπŸ‡ͺπŸ‡Ί

    Test coverage is proving the problem. πŸ‘

    AFAICT the root cause is
    this bit in \Drupal\experience_builder\Plugin\DataType\ConfigEntityVersionReference::setValue():

        if (\is_string($value)) {
          // If only a single value is passed, this is a config entity ID.
          $this->id = $value;
          // Reset the version ID as none was passed we default to the active
          // version.
          $this->version = NULL;
          $target = $this->getTarget();
          \assert($target instanceof ConfigEntityVersionAdapter || $target === NULL);
          $this->version = $target?->getValue()->getActiveVersion();
          $this->doNotify($notify);
          return
    

    when it gets called with that string value from \Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem::onChange() where it does:

    $this->writePropertyValue($property2, $this->get($property1)->getValue());
    

    (at this point, $property2 == 'component' and $property1 == 'component_id', which of course does not include the component version, so the string ConfigEntityVersionReference::setValue() receives is indeed only a component ID)

    in combination with

        if ($property_name === 'component_version') {
          // Reset the component reference property.
          $component_id = $this->get('component')->getTargetIdentifier();
          $version_id = $this->get('component_version')->getValue();
          if ($component_id !== NULL && $version_id !== NULL) {
            $this->writePropertyValue('component', [
              'target_id' => $component_id,
              'version' => $version_id,
            ]);
          }
        }
    

    in \Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem::onChange().

    in combination with:

        // DX: if no version is specified, set it automatically to the active
        // version of the Component.
        // TRICKY: do this *only* when no version is specified, otherwise this would
        // unintentionally "upgrade" instances of older component versions to newer
        // ones!
        if (!array_key_exists('component_version', $this->values) && ($property_name === 'component_id' || $property_name === 'component')) {
          // Set the version ID based on the loaded component.
          $this->writePropertyValue('component_version', $this->getComponent()?->getLoadedVersion());
        }
    

    in \Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem::onChange() incorrectly concluding that no version is specified when in fact it was, but \Drupal\Core\TypedData\Plugin\DataType\Map::setValue()'s

        // Update any existing property objects.
        foreach ($this->properties as $name => $property) {
          $value = $values[$name] ?? NULL;
          $property->setValue($value, FALSE);
          // Remove the value from $this->values to ensure it does not contain any
          // value for computed properties.
          unset($this->values[$name]);
        }
    

    does that unset() leading ComponentTreeItem::onChange() to reasonably deduce that in fact no version was specified 🫠

    πŸ€ͺ What a crazy crazy combination of circumstances. Death by a thousand cuts.

  • Pipeline finished with Success
    30 days ago
    #548977
  • πŸ‡§πŸ‡ͺBelgium wim leers Ghent πŸ‡§πŸ‡ͺπŸ‡ͺπŸ‡Ί

    This is ready for final review β€” preferably by @larowlan, because he's the original author of these pieces, and I doubt we have anybody else on the team with the same level of Field API/Typed Data API expertise πŸ˜…

  • First commit to issue fork.
  • Pipeline finished with Skipped
    29 days ago
    #549601
  • Pipeline finished with Skipped
    29 days ago
    #549602
  • Pipeline finished with Skipped
    29 days ago
    #549604
  • Pipeline finished with Skipped
    29 days ago
    #549614
  • Pipeline finished with Canceled
    29 days ago
    Total: 92s
    #549609
  • πŸ‡¦πŸ‡ΊAustralia larowlan πŸ‡¦πŸ‡ΊπŸ.au GMT+10

    Awesome work @wim leers

    Merged to 0.x

    This was always going to be tricky, I remember how hard these were to get right for Dynamic Entity Reference and I think Entity Reference Revisions probably had similar challenges. I wonder if we should look to make version a required key when calling setValue - it would mean a lot of changes in tests but the 'automatically picking the latest version if not found' logic has bitten us a couple of times now.

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

Production build 0.71.5 2024