yii2 icon indicating copy to clipboard operation
yii2 copied to clipboard

Widget::end will crash if widget is configured by closure in di container

Open jrajamaki opened this issue 1 year ago • 6 comments

What steps will reproduce the problem?

Using basic app as an example, create your own widget, e.g.

class ActiveForm extends yii\bootstrap5\ActiveForm {}

and configure di container to replace the original implementation using closure:

'container' => [
    'definitions' => [
        yii\bootstrap5\ActiveForm::class => fn ($container, $params, $config) => new ActiveForm($config),
    ]
],

Now when accessing basic app's "Contact" page, then app crashes with error "Cannot use object of type Closure as array".

What is the expected result?

It would work.

What do you get instead?

"Cannot use object of type Closure as array" error.

Additional info

Similar to yiisoft/yii2-debug#234. Caused by commit 0dbc4d3f35b1bfa0c4217ae744e30a139c8ad2c8.

Q A
Yii version 2.0.51
PHP version 8.3.9
Operating system macOS 14.5 Sonoma

jrajamaki avatar Oct 17 '24 12:10 jrajamaki

Would you agree to take PR for this?

If so, how it should work?

  • skip check like how yiisoft/yii2-debug#234 was fixed?
  • try to check callable's return type if available?
  • something else, what?

jrajamaki avatar Oct 18 '24 09:10 jrajamaki

Yes. Sure. PR is very welcome. Skipping the check sounds alright.

samdark avatar Oct 18 '24 09:10 samdark

We could save class in begin() and use it in end() instead of getting definition from container.

rob006 avatar Oct 18 '24 10:10 rob006

We could save class in begin() and use it in end() instead of getting definition from container.

That's how it works already. end() just has some safeguards so that developer wouldn't do anything stupid like trigger end() without begin() or mix two widgets:

WidgetA::begin([...]);
WidgetB::begin([...]);
WidgetA::end();
WidgetB::end();

The initial problem is that WidgetA might not be WidgetA because begin() uses Yii::createObject() to create the widget and widget's class might change if configured so in container configuration. But get_called_class always returns WidgetA in end() and wouldn't match the widget's class created in begin() if reconfigured. https://github.com/yiisoft/yii2/commit/0dbc4d3f35b1bfa0c4217ae744e30a139c8ad2c8 tried to fix this but didn't consider scenario where container configuration is callable.

jrajamaki avatar Oct 18 '24 16:10 jrajamaki

I mean we could this in begin():

self::$resolvedClasses[get_called_class()] = get_class($widget);

and this in end():

$calledClass = self::$resolvedClasses[get_called_class()] ?? get_called_class();

then we could remove these lines completely:

https://github.com/yiisoft/yii2/blob/3c75ff1043cdfc3c0c78ad8a4b477b5894223a5a/framework/base/Widget.php#L108-L110

rob006 avatar Oct 18 '24 20:10 rob006

I mean we could this

Seems good, this is something what I had in my mind

public static function end()
{
    if (empty(self::$stack)) {
        throw new InvalidCallException('Unexpected ' . get_called_class() . '::end() call. A matching begin() is not found.');
    }

    $widget = array_pop(self::$stack);
    $calledClass = get_called_class();

    if (Yii::$container->has($calledClass)) {
        $definition = Yii::$container->getDefinitions()[$calledClass];
        if (is_callable($definition)) {
            $reflection = new \ReflectionFunction($definition);
            $returnType = $reflection->getReturnType();
            if ($returnType && $returnType instanceof \ReflectionNamedType) {
                $calledClass = $returnType->getName();
            } else {
                unset($calledClass);
            }
        } elseif (isset($definition['class'])) {
            $calledClass = $definition['class'];
        }
    }

    if (isset($calledClass) && get_class($widget) !== $calledClass) {
        throw new InvalidCallException('Expecting end() of ' . get_class($widget) . ', found ' . $calledClass);
    }

    /* @var $widget Widget */
    if ($widget->beforeRun()) {
        $result = $widget->run();
        $result = $widget->afterRun($result);
        echo $result;
    }

    return $widget;
}

but using self::$resolvedClasses seems more straightforward.

jrajamaki avatar Oct 19 '24 17:10 jrajamaki