WordPress / gutenberg

The Block Editor project for WordPress and beyond. Plugin is available from the official repository.
https://wordpress.org/gutenberg/
Other
10.52k stars 4.2k forks source link

Block sequential deprecations result in attribute data loss #59694

Closed andreg closed 7 months ago

andreg commented 8 months ago

Description

I'm having trouble with block deprecations being run sequentially on a dynamic block.

I have created the following example: a block with two initial attributes (name and surname), which get renamed one after the other (to firstName and lastName, respectively) in two separate deprecation functions. What happens is that the second one being run, overrides the outcome of the first, resulting in the first updated attribute being lost.

In our case, being the surname the first one being run in the deprecated.js exported array, only the name -> firstName attribute is correctly kept.

Here's a link to the repo where the issue is happening: https://github.com/andreg/BlockDeprecations

Specifically, here's a link to the deprecated.js file: https://github.com/andreg/BlockDeprecations/blob/main/src/deprecated.js

Step-by-step reproduction instructions

  1. Compile the block
  2. Create a new page with the following content: <!-- wp:test/test {"name":"John", "surname": "Doe"} /-->
  3. Refresh the page and check the block data wp.data.select( 'core/block-editor' ).getBlocks()[0].attributes

You'll see thatthe firstName attribute is present and with the correct value, while the lastName attribute is missing, infact it's still present the previous surname attribute.

Screenshots, screen recording, code snippet

No response

Environment info

Please confirm that you have searched existing issues in the repo.

Yes

Please confirm that you have tested with all plugins deactivated except Gutenberg.

Yes

aaronrobertshaw commented 8 months ago

Thanks for taking the time to submit this issue @andreg 👍

If I understand the problem correctly, I believe there's a misunderstanding of the way deprecations work in Gutenberg. This is completely understandable as I found them very unintuitive and misunderstood them myself when first working with them.

The key point here is that deprecations are not sequential. The deprecation docs state:

Deprecations do not operate as a chain of updates in the way other software data updates, like database migrations, do.

The documentation goes into much further detail so I'd strongly recommend giving that a read, maybe even a few times 😅.

How this relates to your situation depends on how many old versions of your block there are.

If you have a single deprecated version of the block with the name and surname attributes, you'd need only a single deprecation that migrates both attributes to their current naming.

If the original block was updated multiple times e.g. first updating one attribute name only, then later the second attribute as well, then you'd need two deprecations. Each deprecation needs to migrate all the attributes all the way to the very latest version. Same goes for any markup changes etc.

Deprecations work by finding the first deprecation which has a save function that produces valid content. As that deprecation should migrate that version of the block all the way to the latest current version, there is no sequential progression from say v1 to v2.

This is why the deprecation array should be ordered in terms of the most recent version to oldest as that reflects the probability the appropriate deprecation is hit first. I believe the intent behind this approach is performance.

What happens is that the second one being run, overrides the outcome of the first, resulting in the first updated attribute being lost.

This explains the behaviour you're noticing. Your second deprecation is matched first and migrates only the surname attribute to lastName. So rather than the outcome of the first update being lost, it is never happening.

I hope that helps 🤞

It's a tricky topic so please let me know if this comment or the docs aren't clear. If it is, I think we can close this issue.

andreg commented 8 months ago

Hi @aaronrobertshaw,

thank you for taking the time to reply to my question. I'm not exactly sure I'm folllowing the reasoning behind migrations, the way they have been explained in the documentation.

I have modified the example provided (https://github.com/andreg/BlockDeprecations/blob/main/src/deprecated.js), by making each deprecation to migrate all the attributes all the way to the current version of the data, however, I'm not getting why the v1 migration is still run after v2, since the sequence should stop (all attributes are updated in the v2 migration callback). I've left a couple of console.log calls for that.

Hope you can shed a bit more light on that!

Thank you!

aaronrobertshaw commented 8 months ago

I'm not exactly sure I'm folllowing the reasoning behind migrations, the way they have been explained in the documentation.

If you have any ideas or suggestions on how to improve the documentation, that sounds like a great follow-up. I get the feeling a visual diagram in addition to the textual explanation might help but we need to make sure improvements there are accessible as well (not just diagrams).

By "reasoning", if you mean why they are the way they are and require migrating a block to the latest version in a single step. It really is performance, I think, to avoid repeatedly parsing blocks, comparing, etc.

If you mean it how deprecations work still, I'll expand a little further below: (by the looks of your updated example, you're across most of this so please bare with me 🙏 )

Imagine we have two versions of a block:

  1. Original
  2. Updated #1

This is the simplest case for deprecations as there is only a single deprecation required.

  1. Original --> Updated #1

Now the block is being updated again, to Updated #2, so we'd have three possible versions of the block;

  1. Original
  2. Updated #1
  3. Updated #2

This means another deprecation is required. However, because deprecations aren't applied sequentially, the first deprecation also needs updating. So we'd have deprecations to perform the following:

  1. Original --> Updated #2
  2. Updated #1 --> Updated #2

With these deprecations, if the saved content for a block matched the Original version, the first deprecation is applied to migrate the latest version of the block. If the block's saved content didn't match the Original version, but did match the Updated #1 version, then the second deprecation would be applied.

Things get murkier if the saved content for a block could actually be the same for both Original and Updated #1. The first deprecation to match will be applied however the deprecations will be checked each time the block is processed in the editor, so it's possible that a migrated block might end up being migrated again, incorrectly.

This is where isEligible comes in. If a deprecation matches the saved content, it is only run if isEligible returns true. Sometimes this check might need updating on past deprecations when new versions of a block are added. Generally speaking though, it just needs to reliably determine if the current block is the version that deprecation is targeting.

In the current example, if both Original and Updated #1 versions of the block saved the same markup (or nothing), the deprecations need isEligible checks. The Original --> Updated #2 deprecation would need a check that would only return true for an Original version of the block. Likewise, the Updated #1 --> Updated #2 deprecation would need a check that only returned true for an Updated #1 version of the block.

On that note, it bring us to your updated example.

I have modified the example provided ... however, I'm not getting why the v1 migration is still run after v2, since the sequence should stop

There's a problem with the isEligible checks in the example. They are checking if an attribute has been migrated rather than whether the current version of the block is eligible to be migrated by the deprecation.

For v1, it should migrate when the block has a name and surname attribute. v2 should only migrate when there is a firstName and surname attribute combo.

With the current checks, v2 is determined to be eligible because lastName isn't set in the attributes but because the v2 attributes also don't define name that value isn't parsed into the block attributes and so isn't migrated by the v2 deprecation.

Then as the block is next validated, it doesn't match v2 anymore because it had the surname attribute migrated to lastName already. It then hits the isEligible for v1 and because that returns true if firstName wasn't set, which it couldn't be given v2's configuration, that deprecation is run.

This might make more sense if you added debug logs to the migrate and isEligible functions for each deprecation logging out the attributes object at each stage.

The example where the block has no content at all, and is relying more heavily on isEligible is a bit of an edge case.

So to get the example deprecation working, the isEligible functions need fixing. Something like:

// v2
    isEligible(attributes) {
        return (
            attributes.hasOwnProperty("firstName") &&
            attributes.hasOwnProperty("surname")
        );
    }

// v1
    isEligible(attributes) {
        return (
            attributes.hasOwnProperty("name") && attributes.hasOwnProperty("surname")
        );
    },

I appreciate your patience and time reading through all that. I'm hoping that it not only helps you but might help others that are wrestling with deprecations now or in the future 🤞

github-actions[bot] commented 7 months ago

Hi, This issue has gone 30 days without any activity. This means it is time for a check-in to make sure it is still relevant. If you are still experiencing this issue with the latest versions, you can help the project by responding to confirm the problem and by providing any updated reproduction steps. Thanks for helping out.

aaronrobertshaw commented 7 months ago

@andreg I'm going to close this issue as the deprecations are working as intended. If you have any further questions, I'm happy to answer any I can.