-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Block bindings: Don't use useEffect
in the block bindings editor hook
#59443
Changes from all commits
5928c69
6dd46e8
1ac627f
1b91609
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,7 +4,7 @@ | |
import { getBlockType, store as blocksStore } from '@wordpress/blocks'; | ||
import { createHigherOrderComponent } from '@wordpress/compose'; | ||
import { useSelect } from '@wordpress/data'; | ||
import { useLayoutEffect, useCallback, useState } from '@wordpress/element'; | ||
import { useCallback, useReducer } from '@wordpress/element'; | ||
import { addFilter } from '@wordpress/hooks'; | ||
import { RichTextData } from '@wordpress/rich-text'; | ||
|
||
|
@@ -89,11 +89,16 @@ const BindingConnector = ( { | |
* If the attribute is a RichTextData instance, | ||
* (core/paragraph, core/heading, core/button, etc.) | ||
* compare its HTML representation with the new value. | ||
* | ||
* To do: it looks like a workaround. | ||
* Consider improving the attribute and metadata fields types. | ||
*/ | ||
if ( prevAttrValue instanceof RichTextData ) { | ||
// If the new value is also a RichTextData instance and the value is the same, bail early. | ||
if ( | ||
newAttrValue instanceof RichTextData && | ||
prevAttrValue.toHTMLString() === newAttrValue.toHTMLString() | ||
) { | ||
return; | ||
} | ||
|
||
// Bail early if the Rich Text value is the same. | ||
if ( prevAttrValue.toHTMLString() === newAttrValue ) { | ||
return; | ||
|
@@ -106,6 +111,7 @@ const BindingConnector = ( { | |
newAttrValue = RichTextData.fromHTMLString( newAttrValue ); | ||
} | ||
|
||
// Bail early if the attribute value is the same. | ||
if ( prevAttrValue === newAttrValue ) { | ||
return; | ||
} | ||
|
@@ -115,35 +121,26 @@ const BindingConnector = ( { | |
[ attrName, onPropValueChange ] | ||
); | ||
|
||
useLayoutEffect( () => { | ||
if ( typeof propValue !== 'undefined' ) { | ||
updateBoundAttibute( propValue, attrValue ); | ||
} else if ( placeholder ) { | ||
/* | ||
* Placeholder fallback. | ||
* If the attribute is `src` or `href`, | ||
* a placeholder can't be used because it is not a valid url. | ||
* Adding this workaround until | ||
* attributes and metadata fields types are improved and include `url`. | ||
*/ | ||
const htmlAttribute = | ||
getBlockType( blockName ).attributes[ attrName ].attribute; | ||
|
||
if ( htmlAttribute === 'src' || htmlAttribute === 'href' ) { | ||
updateBoundAttibute( null ); | ||
return; | ||
} | ||
if ( typeof propValue !== 'undefined' ) { | ||
updateBoundAttibute( propValue, attrValue ); | ||
} else if ( placeholder ) { | ||
/* | ||
* Placeholder fallback. | ||
* If the attribute is `src` or `href`, | ||
* a placeholder can't be used because it is not a valid url. | ||
* Adding this workaround until | ||
* attributes and metadata fields types are improved and include `url`. | ||
*/ | ||
const htmlAttribute = | ||
getBlockType( blockName ).attributes[ attrName ].attribute; | ||
|
||
updateBoundAttibute( placeholder ); | ||
if ( htmlAttribute === 'src' || htmlAttribute === 'href' ) { | ||
updateBoundAttibute( null, attrValue ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unlike before, we pass the second parameter ( |
||
return null; | ||
} | ||
}, [ | ||
updateBoundAttibute, | ||
propValue, | ||
attrValue, | ||
placeholder, | ||
blockName, | ||
attrName, | ||
] ); | ||
|
||
updateBoundAttibute( placeholder, attrValue ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as the other comment: Unlike before, we pass the second parameter ( |
||
} | ||
|
||
return null; | ||
}; | ||
|
@@ -194,18 +191,17 @@ function BlockBindingBridge( { blockProps, bindings, onPropValueChange } ) { | |
|
||
const withBlockBindingSupport = createHigherOrderComponent( | ||
( BlockEdit ) => ( props ) => { | ||
function attributeReducer( state, newAttibutes ) { | ||
return { ...state, ...newAttibutes }; | ||
} | ||
|
||
/* | ||
* Collect and update the bound attributes | ||
* in a separate state. | ||
*/ | ||
const [ boundAttributes, setBoundAttributes ] = useState( {} ); | ||
const updateBoundAttributes = useCallback( | ||
( newAttributes ) => | ||
setBoundAttributes( ( prev ) => ( { | ||
...prev, | ||
...newAttributes, | ||
} ) ), | ||
[] | ||
const [ boundAttributes, updateBoundAttributes ] = useReducer( | ||
attributeReducer, | ||
props.attributes | ||
); | ||
|
||
/* | ||
|
@@ -222,16 +218,13 @@ const withBlockBindingSupport = createHigherOrderComponent( | |
<> | ||
{ Object.keys( bindings ).length > 0 && ( | ||
<BlockBindingBridge | ||
blockProps={ props } | ||
blockProps={ { ...props, attributes: boundAttributes } } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of passing the block |
||
bindings={ bindings } | ||
onPropValueChange={ updateBoundAttributes } | ||
/> | ||
) } | ||
|
||
<BlockEdit | ||
{ ...props } | ||
attributes={ { ...props.attributes, ...boundAttributes } } | ||
/> | ||
<BlockEdit { ...props } attributes={ boundAttributes } /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a dangerous change. By doing this, we would be harcoding the assignment of attributes for all blocks. It works only in the first renders because the reducer takes the block attributes value when initialized. If, for example, you try to update the attributes of any block by dispatching the updateBlockAttributes action, it will fail. Once the block is rendered, the new changes in the store will not be reflected in the component. Keep in mind that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right. This pattern won't work and should not be used. Thanks for pointing it out 🙂 I'll have to think about whether avoiding There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So, guess that we'd need something like this: diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js
index b2bc7bdc9f..e5a757d674 100644
--- a/packages/block-editor/src/hooks/use-bindings-attributes.js
+++ b/packages/block-editor/src/hooks/use-bindings-attributes.js
@@ -204,6 +208,11 @@ const withBlockBindingSupport = createHigherOrderComponent(
props.attributes
);
+ // Update the boundAttributes variable when the block attributes change.
+ useEffect( () => {
+ updateBoundAttributes( props.attributes );
+ }, [ props.attributes ] );
+
/*
* Create binding object filtering
* only the attributes that can be bound.
But that would defeat the purpose of this PR 🙂 Alternatively, we could technically use the same pattern of "adjust the state directly during rendering" like: diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js
index b2bc7bdc9f..3732a799d9 100644
--- a/packages/block-editor/src/hooks/use-bindings-attributes.js
+++ b/packages/block-editor/src/hooks/use-bindings-attributes.js
@@ -204,6 +204,10 @@ const withBlockBindingSupport = createHigherOrderComponent(
props.attributes
);
+ if ( props.attributes !== boundAttributes ) {
+ updateBoundAttributes( props.attributes );
+ }
+
/*
* Create binding object filtering
* only the attributes that can be bound. but I'm not 100% sure if this is safe either. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As mentioned before, handling the data propagation relying on the component makes the approach fragile. It'll get tougher when we introduce the ability to edit the property value of the external source according to attribute changes. The riskiest is getting out of sync and/or also especially falling into an infinite loop ♾️. |
||
</> | ||
); | ||
}, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it authorized in React to call "setState" in a render function? Isn't that something that can break easily or something. cc @jsnajdr
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it's possible and legal, you just need to be careful about not triggering infinite loops.
setState
in a render function is how you can implementgetDerivedStateFromProps
in a functional component. If such asetState
call happens, the output of the render function is guaranteed to be never committed to DOM (it's using props and state that are out of sync) and a second rerender is scheduled to be done immediately.React FAQ mentions this technique for functional
getDerivedStateFromProps
here: https://legacy.reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops. Note how thesetState
functions are called directly in theScrollView
function, without anyuseEffect
.There's also a link to a "you probably don't need derived state" article that describes some alternatives. But I think none of them match our case, because we're not working with local component state, but with a state in an external data store. Data store, when updated, also triggers a
setState
-ish operation being done on your component, so the process is similar, but not identical.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think here we're working with normal
setState
it's just that it's stored in one component that is one level up in the tree (because of the need to respect the rules of hooks). So it seems this solution is legit. Thanks Jarda.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just to add to this: yes this is a legit react pattern for exactly the reason that Jarda explained: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes:~:text=adjust%20the%20state%20directly%20during%20rendering%3A
While we are indeed using an external value (from a custom field) here, we're not setting it in store in any way. So this approach should be fine.
That said, once we start implementing the ability to edit values (after 6.5), we'd have to use
useEffect()
somewhere because we'd be setting a value in an external store.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see the Warning about rendering update the component state when rendering others:
Could we ignore it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this something that we do not want to do at all?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By the way, I have some doubts whether the data bindings should be implemented in React at all, with effects synchronizing stuff back and forth. Maybe it belongs somewhere to the
core/block-editor
store instead.I just wrote down an issue (#59592) about how Gutenberg blocks implement a lot of functionality in the "view" layer, instead of doing it in the "model" layer (in MVC terminology). Inspired by the obstacles I hit when trying to lazy load the edit UI.
I know very little about how exactly block bindings work, but on the surface the React code is "suspicious" 🙂 Let me know if it makes sense for you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that's a grat discussion to have. Thank you for opening the issue and flagging it here 👍🏻
@kevin940726 and @talldan had several prototypes for Pattern Overrides that were trying to use the local copy of the
core/block-editor
store with some actions and selectors patched so everything works out of the box in React components. The biggest challenge when replicating the same idea on Block Bindings would be that for every selector run that returns the block attributes, there would be need to cross-check if themetadata.bindings
is set and fetching the data from different sources. It is doable, but the concern would be the performance. I would be very happy to see that prototyped directly incore/block-editor
store so we could measure the impact.