Skip to content

Conversation

@talldan
Copy link
Contributor

@talldan talldan commented Oct 22, 2018

Description

This is a small fix to make sure that the default attributes for a block type cannot be mutated within a block instance's edit method.

This issue originated from a message in the #core-editor channel in slack: https://wordpress.slack.com/archives/C02QB2JS7/p1540130491000100

After some investigation I discovered that createBlock passes default objects and arrays by reference instead of copying/cloning them when createBlock is called, leading to unexpected results if those references are mutated. To make things easier for block implementors, the solution in this PR is to copy those defaults in createBlock.

How has this been tested?

  • Added unit tests
  • Manual testing using a custom block that mutates its attribute (there's a fuller example in the slack chat). ie:
onClick={ () => {
  testAttribute.newProperty = 'test';
  setAttribute( { testAttribute } );
} }

Then to test this custom block:

  1. Add a new block of the custom type
  2. Trigger the mutation of the attribute
  3. Add another new block of the custom type
  4. Observe that in the second block the default should not have been mutated

Screenshots

This issue can result in the behaviour where the default attribute is mutated in the first block, and then successive new blocks when added also use the mutated default:
2018-10-21 14 20 11

Types of changes

Bug fix (non-breaking change which fixes an issue)

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • My code has proper inline documentation.

@talldan talldan added [Type] Bug An existing feature does not function as intended [Feature] Block API API that allows to express the block paradigm. labels Oct 22, 2018
@talldan talldan self-assigned this Oct 22, 2018
@talldan talldan requested a review from youknowriad October 23, 2018 01:32
// If the type of the attribute's default is a reference type,
// make a copy so that implementors cannot easily mutate the
// block type's default attributes.
if ( Array.isArray( defaultValue ) ) {
Copy link
Contributor Author

@talldan talldan Oct 23, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we should consider deep cloning? Having said that, everything worked ok in my tests.

Copy link
Member

@noisysocks noisysocks Oct 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if deep cloning would be worth the slight performance penalty.

To simplify this if, could we use _.clone?

https://lodash.com/docs/4.17.10#clone

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I was sure I searched for a clone in the lodash docs and couldn't find anything, but there it is, clear as day.

@desrosj desrosj added this to the WordPress 5.0 milestone Oct 25, 2018
@talldan talldan requested review from a team and removed request for youknowriad October 26, 2018 04:02
// Mutate the array attribute and assert the original
// default wasn't also mutated.
block.attributes.content[ 0 ] = 'test';
expect( block.attributes.content ).not.toEqual( arrayDefault );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically speaking, you could also verify before mutation happens the following:

expect( block.attributes.content ).not.toBe( arrayDefault );

which check whether they are equal and share the same reference.

// make a copy so that implementors cannot easily mutate the
// block type's default attributes.
if ( Array.isArray( defaultValue ) ) {
defaultValue = defaultValue.slice();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also use ES6 syntax, not sure if it is faster though:

defaultValue = [ ...defaultValue ];
defaultValue = { ...defaultValue }; 

but it's shorter for sure :)

Copy link
Member

@gziolo gziolo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like a legit fix. I would appreciate having confirmation from @aduth, @youknowriad or @iseulde that it doesn't bite us in other aspects. Do you think there might be other methods which need to be verified?

Copy link
Member

@aduth aduth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preemptively blocking the merge to allow discussion.

My initial impression: This code is "doing it wrong" for more reason than one:

onClick={ () => {
  testAttribute.newProperty = 'test';
  setAttribute( { testAttribute } );
} }

// block type's default attributes.
if ( Array.isArray( defaultValue ) ) {
defaultValue = defaultValue.slice();
} else if ( typeof defaultValue === 'object' ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typeof null === 'object'

@aduth
Copy link
Member

aduth commented Oct 26, 2018

I don't think we want this. Block implementers should not be mutating attributes directly, as it will have very unpredictable and buggy behavior in how attributes are referenced. It's much the same (in fact, literally the same, since it is in state) as Redux's read-only state principle:

https://redux.js.org/introduction/threeprinciples#state-is-read-only

If we want a developer experience improvement, I think we should do so by (deep-)freezing the attribute value so that it never gives any impression of working at all.

@talldan
Copy link
Contributor Author

talldan commented Oct 26, 2018

@aduth - I recommended a non-mutating fix to the block implementor in slack, and I agree with that concept. I did pause before creating this PR, considering whether it was necessary, and after some thought my opinion was that this is a bug.

That's purely because I don't think any actions within a created block should cause the original definition to change. I think this could be considered synonymous to a created React component somehow causing the initial declaration of the component to be modified—even though those APIs encourage non-mutation there's still a level of protection. When working within a component or a block, I'd have an expectation that changes do not leak out—a created block should potentially be its own sandbox—I could screw up that one instance by triggering a mutation, but it shouldn't affect others that haven't undergone the mutation.

I think there's also a dev experience aspect. I'm sure a lot of block builders are learning both React and Gutenberg (and possibly functional concepts) at the same time, so it might be good to offer a bit more implicit safety.

@aduth
Copy link
Member

aduth commented Oct 26, 2018

I think this could be considered synonymous to a created React component somehow causing the initial declaration of the component to be modified—even though those APIs encourage non-mutation there's still a level of protection.

Can you elaborate on this? What protection are you referring to?

By contrast, if you mutate a prop in a React component which had been provided through a defaultProps definition, it mutates the default.

https://codepen.io/aduth/pen/PyVXZZ

I'm sure a lot of block builders are learning both React and Gutenberg (and possibly functional concepts) at the same time, so it might be good to offer a bit more implicit safety.

But the example from the original comment still wrong and error-prone in its state-mutating nature, even with the clone behavior.

@talldan
Copy link
Contributor Author

talldan commented Oct 30, 2018

Can you elaborate on this? What protection are you referring to?

That potentially buggy code is contained/localized and doesn't cause issues more widespread than needed.

By contrast, if you mutate a prop in a React component which had been provided through a defaultProps definition, it mutates the default.

I'm surprised by that.

But the example from the original comment still wrong and error-prone in its state-mutating nature, even with the clone behavior.

I worry that it's not clear enough to block implementors why mutation is a problem—there's no developer feedback that the mutation is an error. Bugs caused by mutations could be quite subtle and hard to spot. I also couldn't find much (or anything) in the handbook to explain why attributes shouldn't be mutated, but I could've missed it. Maybe there's an assumption that implementors understand the paradigm from using React/Redux.

Perhaps at the very least this PR can be replaced with another one that adds more information to the docs?

@aduth
Copy link
Member

aduth commented Oct 30, 2018

Perhaps at the very least this PR can be replaced with another one that adds more information to the docs?

I'm fine with that. I also don't mind the idea of applying deepFreeze on attributes of created blocks to more loudly error when a mutation is attempted. It should only be applied in an environment we can guarantee to be for development (like SCRIPT_DEBUG) and unfortunately we've found that many developers don't run with these constants.

@aduth
Copy link
Member

aduth commented Oct 30, 2018

Also worth noting that conveniences don't make things simpler when in the broader sense we don't apply the conveniences consistently. If I receive positive or negative feedback in mutating attributes, will it alter my expectations in cases where I may be tempted to try the same (e.g. adding a new property to the return value of getPostEdits selector?). In these instances, it could be argued that we're setting people up for failure.

@noisysocks
Copy link
Member

unfortunately we've found that many developers don't run with these constants

Getting off topic here, but I keep thinking that WordPress needs a Developer Mode switch in WP Admin which enables extra warnings.

@gziolo gziolo modified the milestones: WordPress 5.0, 4.3 Nov 5, 2018
@talldan talldan removed this from the 4.3 milestone Nov 6, 2018
@bfintal
Copy link
Contributor

bfintal commented Dec 2, 2018

I haven't tested, but this may fix #11705

@talldan
Copy link
Contributor Author

talldan commented Dec 11, 2018

Just to update on this:

@talldan talldan closed this Dec 12, 2018
@talldan talldan deleted the fix/copy-reference-type-default-attributes branch December 12, 2018 06:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Block API API that allows to express the block paradigm. [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants