yiisoft/yii2-twig

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

ivan-redooc opened this issue · 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

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

Yes, please.

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);
    }

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

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?

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

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