yii2-twig icon indicating copy to clipboard operation
yii2-twig copied to clipboard

The $context arg in render don't preserve the "internal context" in a double call of render

Open ivan-redooc opened this issue 4 years ago • 6 comments

Using a "render" inside a model break the ability of Twig to include afterwards includes

What steps will reproduce the problem?

File structure

models
  User.php
  ViewContext.php
controllers
  AlfaController.php
views
  alfa
    index.twig
    footer.twig
  templates
    profile.twig

Files

AlfaController.php

class AlfaController{
  funtion action index(){
     $user=new User();
     return $this->render("index.twig",['user'=>$user])
  }

}

user.php

class User{
  public function getProfile(){
        $context = new ViewContext([
            'viewPath' => "@app/views/templates",
        ]);

      return \Yii::$app->getView()->render("profile.twig",[],$context);
  }
}

ViewContext.php

class ViewContext extends BaseObject implements ViewContextInterface
{
    public $viewPath;
    public function getViewPath ()
    {
        return \Yii::getAlias($this->viewPath);
    }
}

index.twig

<html>
<body>

   {# here the second render #}
   {{user.profile}}

   {# the include will fail #}
   {{include "footer.twig"}}

</body>
</html>

profile.twig

{# nothing special here #}
<div>
I'm a user
</div>

footer.twig

{# nothing special here #}
<div>
<hr>
</div>

What's expected?

A render like this

<html>
<body>

<div>
I'm a user
</div>

<div>
<hr>
</div>

</body>
</html>

What do you get instead?

Error: \Twig\Error\LoaderError Message: Unable to find template "footer.twig" (looked into: frontend/views/templates, frontend/views). Throwing point: file: vendor/twig/twig/src/Loader/FilesystemLoader.php line: 227

Additional info

In funtion render of vendor/yiisoft/yii2-twig/src/ViewRenderer.php a new FilesystemLoader was set inside the $this->twig object, so the next call (by the include) can't find the TWIG in a different path ( in this case frontend/views/templates):

    public function render($view, $file, $params)
    {
        $this->twig->addGlobal('this', $view);
        $loader = new FilesystemLoader(dirname($file));
        if ($view instanceof View) {
            $this->addFallbackPaths($loader, $view->theme);
        }

        $this->addAliases($loader, Yii::$aliases);
        $this->twig->setLoader($loader);

        // Change lexer syntax (must be set after other settings)
        if (!empty($this->lexerOptions)) {
            $this->setLexerOptions($this->lexerOptions);
        }

        return $this->twig->render(pathinfo($file, PATHINFO_BASENAME), $params);
    }

The $context is not involved in this business-logic.

A possible solution (I'm adopting) is to define in main.php a second view with twig render in $app

Q A
Yii version 2.0.38
Yii Twig version 2.4.0
Twig version v3.0.5
PHP version 7.3
Operating system Ubuntu

ivan-redooc avatar Jan 29 '21 08:01 ivan-redooc

@samdark I think I can help. Already have an idea, I can share if you like.

ivan-redooc avatar Feb 03 '21 15:02 ivan-redooc

Yes, please.

samdark avatar Feb 03 '21 16:02 samdark

Because the core of ViewContextInterface is the function getViewPath() we can use it as index of array of FilesystemLoader.

So my idea (still to test) is:

    /**
     * @var FilesystemLoader[]
     * @since
     */
    protected $loaders=[];
//....
    public function render($view, $file, $params)
    {
        $this->twig->addGlobal('this', $view);
        
        if(isset($this->loaders[$view->context->getViewPath()])) {
            // I reuse if already created
            $loader = $this->loaders[$view->context->getViewPath()];
        } else {
            // just one time
            $loader = new FilesystemLoader(dirname($file));
            if ($view instanceof View) {
                $this->addFallbackPaths($loader, $view->theme);
            }

            $this->addAliases($loader, Yii::$aliases);
        }
        $this->twig->setLoader($loader);

        // Change lexer syntax (must be set after other settings)
        if (!empty($this->lexerOptions)) {
            $this->setLexerOptions($this->lexerOptions);
        }

        return $this->twig->render(pathinfo($file, PATHINFO_BASENAME), $params);
    }

ivan-redooc avatar Feb 04 '21 07:02 ivan-redooc

I am having the exact same issue. Did you ever find a solution?

developedsoftware avatar Jul 28 '22 13:07 developedsoftware

I think I may have solved this by altering https://github.com/twigphp/Twig/tree/3.x/src/Template.php

Whenever we call loadTemplate() I am adding the path of the currently loading template to the list of paths to check

Before

protected function loadTemplate($template, $templateName = null, $line = null, $index = null)
    {
        
        try {

            if (\is_array($template)) {
                return $this->env->resolveTemplate($template);
            }

            if ($template instanceof self || $template instanceof TemplateWrapper) {
                return $template;
            }

            if ($template === $this->getTemplateName()) {
                $class = static::class;
                if (false !== $pos = strrpos($class, '___', -1)) {
                    $class = substr($class, 0, $pos);
                }
            } else {
                $class = $this->env->getTemplateClass($template);
            }

            return $this->env->loadTemplate($class, $template, $index);
        } catch (Error $e) {
            if (!$e->getSourceContext()) {
                $e->setSourceContext($templateName ? new Source('', $templateName) : $this->getSourceContext());
            }

            if ($e->getTemplateLine() > 0) {
                throw $e;
            }

            if (!$line) {
                $e->guess();
            } else {
                $e->setTemplateLine($line);
            }

            throw $e;
        }
    }

After (added 2 lines of code after the try block)

protected function loadTemplate($template, $templateName = null, $line = null, $index = null)
    {
        
        try {
            
            $source = $this->getSourceContext();
            $this->env->getLoader()->addPath(dirname($source->getPath()));  
            
            if (\is_array($template)) {
                return $this->env->resolveTemplate($template);
            }

            if ($template instanceof self || $template instanceof TemplateWrapper) {
                return $template;
            }

            if ($template === $this->getTemplateName()) {
                $class = static::class;
                if (false !== $pos = strrpos($class, '___', -1)) {
                    $class = substr($class, 0, $pos);
                }
            } else {
                $class = $this->env->getTemplateClass($template);
            }

            return $this->env->loadTemplate($class, $template, $index);
        } catch (Error $e) {
            if (!$e->getSourceContext()) {
                $e->setSourceContext($templateName ? new Source('', $templateName) : $this->getSourceContext());
            }

            if ($e->getTemplateLine() > 0) {
                throw $e;
            }

            if (!$line) {
                $e->guess();
            } else {
                $e->setTemplateLine($line);
            }

            throw $e;
        }
    }

Should I open an issue upstream? Or can we override that functionality from within the yii2-twig implementation of twig?

developedsoftware avatar Jul 28 '22 13:07 developedsoftware

$this->env->getLoader()->addPath(dirname($this->getSourceContext()->getPath()));

This one line inside loadTemplate() seems to do the trick ;)

developedsoftware avatar Jul 28 '22 14:07 developedsoftware