Problem/Motivation
Found in #3086374: Make Drupal 8 & 9 compatible with PHP 7.4.
Usage of a mix of arrays and Attribute objects for $variable['attributes']
for rendering has some problems.
1. Sometimes $variables['attributes']
is an Attribute object, and array functions such as array_key_exists
do not operate properly on them.
@alexpott
+++ b/core/includes/theme.inc @@ -817,9 +817,9 @@ function template_preprocess_image(&$variables) { - // If the property has already been defined in the attributes, - // do not override, including NULL. - if (array_key_exists($key, $variables['attributes'])) { + // If the property has already been defined in the attributes, do not + // override, including NULL. Attributes can be an array or an object. + if ((is_array($variables['attributes']) && array_key_exists($key, $variables['attributes'])) || (is_object($variables['attributes']) && property_exists($variables['attributes'], $key))) { continue;
This fix isn't right unfortunately.
>>> $a = new \Drupal\Core\Template\Attribute(['foo' => NULL, 'bar' => 'bar']); => Drupal\Core\Template\Attribute {#4861 markup: " bar="bar"", } >>> $a->toArray(); => [ "foo" => null, "bar" => "bar", ] >>> property_exists($a, 'foo'); => false >>> property_exists($a, 'bar'); => false >>> isset($a['foo']); => true >>> isset($a['bar']); => true >>> isset($a['width']); => false >>> isset($a->toArray()['foo']); => false
So I think this should be we need to detect if we dealing with an array() or an Attribute object. And if we're dealing with an attribute object use isset() - which is super funky because it works different from arrays. Anyhow I think this is a good candidate for splitting out into a separate issue and getting test coverage as the current code in HEAD is broken too...
>>> array_key_exists('foo', $a) => false >>> array_key_exists('bar', $a) => false
lolz.
2. Merging attributes is tricky e.g. $variables['attributes'] = NestedArray::mergeDeep($variables['attributes'], $variables[$key]['#attributes']);
@Charlie ChX Negyesi:
mergeDeep is a special kind of trouble. It passes arguments to mergeDeepArrays which doesn't fatal on as long as the "array" is iterable but if the iterable contains ArrayAccess objects instead of arrays then the recursion breaks! [...]. I mean... https://3v4l.org/X5Cje
Proposed resolution
Add new methods to the Attribute object for merging and determining if it has an attribute.
Add a helper for themers to use in preprocess functions where you don't know if you're dealing with an attributes object or an array.
Remaining tasks
User interface changes
None
API changes
See the change record: https://www.drupal.org/node/3110716
Data model changes
None
Release notes snippet
In theme preprocess functions the attributes property is sometimes an array and sometimes an Attribute object. The \Drupal\Core\Template\AttributeHelper
class has been added to help deal with this situation. It has methods to check for the existence of an attribute in either an object or an array and to merge two attribute collections together irrespective of type. See https://www.drupal.org/node/3110716 for more information.
Comment | File | Size | Author |
---|---|---|---|
#32 | interdiff_22-32.txt | 11.93 KB | mondrake |
#32 | 3090145-32.patch | 12.23 KB | mondrake |
#28 | 3090145-22.patch | 7.34 KB | mondrake |
Comments
Comment #2
mondrakeComment #3
mondrakeHere's a proposal for point 1, building on @alexpott's suggestion in #3086374-110: Make Drupal 8 & 9 compatible with PHP 7.4.
Comment #4
mondrakeWorking on point 2.
Comment #5
mondrakeAddressed point 2 with a bespoke copy of NestedArray::mergeDeep and ::mergeDeepArray. Will always return attributes as an array.
Comment #6
mondrakeComment #7
sjerdoInstead of only supporting 'real'(non-object) arrays and instances of Attribute, shouldn't we additionally allow all instances of ArrayAcces?
Comment #8
alexpottThis is an odd method.
We support varidics now... see https://www.php.net/manual/en/migration56.new-features.php
How can this return an Attribute object?
This returning an array feels quite odd when its on the Attributes object.
Comment #9
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedInstead of the two new methods on the
Attribute
class, I think we should do a simple ternary assignment like$attributes = $variables['attributes'] instanceof Attribute ? $variables['attributes']->toArray() : $variables['attributes'];
in those three places.Comment #10
mondrakeThanks for the reviews.
Reshuffled.
1) introduced
getNames
in place ofgetAsArray
, will only return the list of attributes' names2) the
merge
method does the merging of two attributes A and B (either arrays or objects), preserving the type of attribute AInterdiff not very useful.
Comment #11
mondrakeComment #12
andypostLooks ready to go, could use follow-up to deprecate array argument types and to find a way to clean-up usage
Comment #13
andralex CreditAttribution: andralex at EPAM Systems commentedTested the patch #10. It works for me. The only concern is about coding standards.
According to it, methods should use lowerCamel naming.
Comment #14
longwaveThis is perhaps bikeshedding but why not getKeys instead of getNames?
Or, this could become a keyExists() helper?
Comment #15
alexpott+1 on renaming this. Or even changing the functionality to be hasKey or keyExists().
It'd be good to have something other then arrays of classes in the tests to test this a bit more. For
href
orid
orname
- actually the name attribute is another reason why getnames() is badly named.This tests should be broken into separate tests. I'm not sure the second test is even running. Checked locally and I can change the second exception message to whatever I want and the test still passes.
Comment #16
andralex CreditAttribution: andralex at EPAM Systems commentedI've updated the patch from #10 according to the last comments.
Comment #17
andralex CreditAttribution: andralex at EPAM Systems commentedFixed coding standards issues in last patch.
Comment #18
alexpottin_array() is prone to strange results plus we're doing unnecessary work here. We can do this instead:
Let's call these $a and $b - the prefix makes this harder to read because so much of the variable name is the same. Also we have precedence for this in core - see \Drupal\jsonapi\JsonApiResource\Link::merge() and \Drupal\Core\Render\BubbleableMetadata::mergeAttachments()
I don't think we should be duplicating logic from NestedArray here. We could do
Because everything we're passing is an array we don;t run into the problem @chx mentions in the issue summary.
For similar reasons let's move this to a separate method for exception testing.
It would be good to not only test class => array here... something like name or id.
Comment #19
mondrakeThe more I think about this the more I am inclined to move away from static methods - just load the array into an Attribute object and use
offsetExists
for the existence check, and have amerge
method with one argument only (the attribute object/array to be merged to currentstorage
).I'll work on a patch in that sense.
FWIW I was suggesting
getNames
because several methods on the class already use$name
for the arguments, and according to RFC1866, para 3.2.4.,In fact, IMHO the
Attribute
object naming is not fully correct - it's more anAttributeCollection
.But yeah, that's more semantics.
Comment #20
alexpott@mondrake I'm not sure that #19 will work. The problem is these methods should not change the type of the variable during template preprocessing.
Re getNames vs getKeys - your point is a very good one. If the documentation of the gertNames method made it clear that it was this "name" it was getting then it'd be better. Also - very very good point on Attribute vs Attribute collection. I''d love to see that re-name as part of #2664570: Move Attribute classes under Drupal\Component
Comment #21
mondrakeComment #22
mondrakeThanks all for reviews and comments. Here's #19 in practice.
No interdiff, it won't make sense.
Comment #23
alexpottWhy do we need to cast to an array?
Comment #24
mondrake#23 dunno, was just moving that piece without questioning
EDIT - the typecasting is there since when that piece of code was introduced in #2214241: Field default markup - removing the divitis
Comment #25
mondrakeLet's see what happens if we remove the typecasting.
Comment #26
andralex CreditAttribution: andralex at EPAM Systems commentedLooks good to go for me.
Comment #27
alexpottI'm sorry I was wrong. By removing the typecasting we're introducing a very subtle API change. Let's not do that. Especially when this issue is all about not making subtle API changes by converting between arrays and attribute objects.
Comment #28
mondrakeReuploading #22 then.
Comment #29
alexpottSo I was really surprised by the change from #17 to #22. I preferred the static method helpers that dealt with type for the developer. Here's why:
1. Looking at the above code that's a lot a theme developer to know about in order to use this method.
2. As we've seen preprocess functions can change an array to an attributes object and vice versa so less thinking anyone has to do the better.
Can we go back to #17 + the fixes asked for in #18?
Comment #30
mondrakeIf we really want static helpers here, I'd rather add them to #22 then, so that they just deal with the type conversions, while we leave logic in the instance methods.
Comment #31
alexpott@mondrake but then we’re going to have even more naming fun
Comment #32
mondrakeHow about this, adding a
AttributeHelper
class for the static methods only. I see precendents of this in core. When attributes will no longer be arrays but only object instances, then the helper will no longer be needed.Comment #33
alexpott@mondrake it's not pretty :) but then again having to deal with either an array or an attribute object is not pretty either so I think the compromise of adding an AttributeHelper is a good one. I think we need a change record to tell developers that this helper exists and why it is good to use it.
This looks great from a developer experience POV.
Having these methods here will be great if we ever get to the place where all #attributes properties in themes are Attribute objects. Nice.
Comment #34
mondrakeAdded draft CR, https://www.drupal.org/node/3110716
Comment #35
alexpott@mondrake the CR looks good thanks.
I've updated the issue summary with the solution and other required information.
This fix should target 8.8.x as we'd like to support PHP 7.4 on 8.8.x
Comment #38
catchCommitted f90ca9b and pushed to 9.0.x. Thanks!
Cherry-picked to 8.9.x. But leaving at 'to be ported' against 8.8.x for after packaging is fixed. I think adding the new method and class to a patch release is OK given this is a critical bug fix although would be nice to commit it just after rather than just before a patch release.
Comment #39
catch8.8.2 is out now. Cherry-picked to 8.8.x