Overview

Discovered at #3532514: Gracefully handle components in active development: ensure great DX:

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: Gracefully handle components in active development: ensure great DX
+

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: Gracefully handle components in active development: ensure great DX

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

Proposed resolution

User interface changes

None.

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

wim leers created an issue. See original summary.

wim leers’s picture

Issue tags: -Needs tests

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 👍

wim leers’s picture

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 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().

Now, that wouldn't have been a problem if it would've been "corrected" subsequently, which doesn't happen because …

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 …

in combination with

\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]);
    }

👆 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.

wim leers’s picture

Assigned: wim leers » larowlan
Status: Active » Reviewed & tested by the community
Parent issue: » #3520449: [META] Production-ready data storage

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 😅

larowlan made their first commit to this issue’s fork.

  • larowlan committed fcc71ab3 on 0.x authored by wim leers
    Issue #3536277 by wim leers: Data loss: `ComponentTreeItem::setValue()`...
larowlan’s picture

Assigned: larowlan » Unassigned
Status: Reviewed & tested by the community » Fixed

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.

Status: Fixed » Closed (fixed)

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