Skip to content

Commit

Permalink
[FEATURE] Proper support of local variables in ViewHelpers (#828)
Browse files Browse the repository at this point in the history
In its current implementation, the `f:alias` ViewHelper has side effects:
It adds variables to the global variable container, which are not restored
properly after the ViewHelper call. If a variable name is used as alias
which already exists, that variable is unset after the ViewHelper call.

To resolve this, a new `ScopedVariableProvider` is introduced to handle
local variables inside ViewHelper calls properly. After the local scope,
global variables are restored to their original value in case they existed.
At the same time, other modifications to the variable provider inside
the `f:alias` call, such as a call to `f:variable`, should still be persistent
after the ViewHelper call. This ensures that the change is non-breaking
for the vast majority of usages. The only situation where this could be
breaking is when you relied on the arguably broken behavior that `f:alias`
unsets the variables afterwards.

This is also the reason why we can't simple store and reset the whole
variable container. This would be a breaking change and would also be
a diversion from other ViewHelpers, such as `f:if`, where `f:variable` also
leaks out. To change this behavior for select ViewHelpers would be
inconsistent and hard to understand for users of Fluid.

before:

```xml
<f:variable name="myVariable" value="initialValue" />
<f:alias map="{myVariable: \'myValue\'}">...</f:alias>
{myVariable} <!-- outputs empty string -->
```

after:

```xml
<f:variable name="myVariable" value="initialValue" />
<f:alias map="{myVariable: \'myValue\'}">...</f:alias>
{myVariable} <!-- outputs "initialValue" -->
```

This behavior also applies to other ViewHelpers that provide variables
to their child nodes, such as `f:for`, `f:render`, `f:groupedFor`, `f:cycle`.
These will be modified accordingly in separate PRs.

Resolves #666
  • Loading branch information
s2b committed Nov 21, 2023
1 parent c18f300 commit cdd96af
Show file tree
Hide file tree
Showing 5 changed files with 476 additions and 8 deletions.
49 changes: 49 additions & 0 deletions Documentation/Usage/Syntax.rst
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,52 @@ types. To be able to cast a variable in this case, simply wrap it with quotes:
cast the variable and finally remove the quotations and use the variable
directly. Semantically, the quotes mean you create a new `TextNode` that
contains a variable converted to the specified type.

Variable Scopes
===============

Each Fluid template, partial and section has its own variable scope. For templates,
these variables are set via the PHP API, for partials and sections the `<f:render>`
ViewHelper has a `arguments` argument to provide variables.

Inside templates, partials and sections there are two variable scopes: global
variables and local variables. Local variables are created by ViewHelpers that
provide additional variables to their child nodes. Local variables are only valid
in their appropriate context and don't leak out to the whole template.

For example, `<f:alias>` and `<f:for>` create local variables:

.. code-block:: xml
<f:for each="{items}" as="item">
<!-- {item} is valid here -->
</f:for>
<!-- {item} is no longer valid here -->
<f:alias map="{item: myObject.sub.item}">
<!-- {item} is valid here -->
</f:for>
<!-- {item} is no longer valid here -->
If a global variable uses the same name as a local value, the state of the global
value will be restored when the local variable is invalidated:

.. code-block:: xml
<f:variable name="item" value="global item" />
<!-- {item} is "global item" -->
<f:for each="{0: 'local item'}" as="item">
<!-- {item} is "local item" -->
</f:for>
<!-- {item} is "global item" -->
If a variable is created in a local block, for example by using the `<f:variable>`
ViewHelper, that variable is treated as a global variable, so it will leak out of
the scope:

.. code-block:: xml
<f:for each="{0: 'first', 1: 'second'}" as="item">
<f:variable name="lastItem" value="{item}" />
</f:for>
<!-- {lastItem} is "second" -->
119 changes: 119 additions & 0 deletions src/Core/Variables/ScopedVariableProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

/*
* This file belongs to the package "TYPO3 Fluid".
* See LICENSE.txt that was shipped with this package.
*/

namespace TYPO3Fluid\Fluid\Core\Variables;

/**
* Variable provider to be used in cases where a specific
* set of variables are only valid in a local context, while
* another set of global variables should remain valid after
* that context. This is used for example for AliasViewHelper
* or ForViewHelper to differentiate the variables provided
* for child elements from global variables that should still
* be valid afterwards.
*/
final class ScopedVariableProvider extends StandardVariableProvider implements VariableProviderInterface
{
public function __construct(
protected VariableProviderInterface $globalVariables,
protected VariableProviderInterface $localVariables,
) {}

public function getGlobalVariableProvider(): VariableProviderInterface
{
return $this->globalVariables;
}

public function getLocalVariableProvider(): VariableProviderInterface
{
return $this->localVariables;
}

/**
* @param string $identifier Identifier of the variable to add
* @param mixed $value The variable's value
*/
public function add($identifier, $value): void
{
$this->globalVariables->add($identifier, $value);
}

/**
* @param string $identifier The identifier to remove
*/
public function remove($identifier): void
{
$this->globalVariables->remove($identifier);
$this->localVariables->remove($identifier);
}

/**
* @param mixed $source
*/
public function setSource($source): void
{
$this->globalVariables->setSource($source);
}

public function getSource(): array
{
return $this->getAll();
}

public function getAll(): array
{
return array_merge(
$this->globalVariables->getAll(),
$this->localVariables->getAll(),
);
}

/**
* @param string $identifier
*/
public function exists($identifier): bool
{
return $this->localVariables->exists($identifier) || $this->globalVariables->exists($identifier);
}

/**
* @param string $identifier
*/
public function get($identifier): mixed
{
return $this->localVariables->get($identifier) ?? $this->globalVariables->get($identifier);
}

/**
* @param string $path
*/
public function getByPath($path): mixed
{
return $this->localVariables->getByPath($path) ?? $this->globalVariables->getByPath($path);
}

public function getAllIdentifiers(): array
{
return array_unique(array_merge(
$this->globalVariables->getAllIdentifiers(),
$this->localVariables->getAllIdentifiers(),
));
}

/**
* @param array|\ArrayAccess $variables
*/
public function getScopeCopy($variables): ScopedVariableProvider
{
return new ScopedVariableProvider(
$this->globalVariables->getScopeCopy($variables),
clone $this->localVariables
);
}
}
18 changes: 10 additions & 8 deletions src/ViewHelpers/AliasViewHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
namespace TYPO3Fluid\Fluid\ViewHelpers;

use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
use TYPO3Fluid\Fluid\Core\Variables\ScopedVariableProvider;
use TYPO3Fluid\Fluid\Core\Variables\StandardVariableProvider;
use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
use TYPO3Fluid\Fluid\Core\ViewHelper\Traits\CompileWithRenderStatic;

Expand Down Expand Up @@ -77,15 +79,15 @@ public function initializeArguments()
*/
public static function renderStatic(array $arguments, \Closure $renderChildrenClosure, RenderingContextInterface $renderingContext)
{
$templateVariableContainer = $renderingContext->getVariableProvider();
$map = $arguments['map'];
foreach ($map as $aliasName => $value) {
$templateVariableContainer->add($aliasName, $value);
}
$globalVariableProvider = $renderingContext->getVariableProvider();
$localVariableProvider = new StandardVariableProvider($arguments['map']);
$scopedVariableProvider = new ScopedVariableProvider($globalVariableProvider, $localVariableProvider);
$renderingContext->setVariableProvider($scopedVariableProvider);

$output = $renderChildrenClosure();
foreach ($map as $aliasName => $value) {
$templateVariableContainer->remove($aliasName);
}

$renderingContext->setVariableProvider($globalVariableProvider);

return $output;
}
}
24 changes: 24 additions & 0 deletions tests/Functional/ViewHelpers/AliasViewHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@ public static function renderDataProvider(): \Generator
[],
'',
];

yield 'variables are restored correctly' => [
'<f:alias map="{x: \'foo\'}"></f:alias>{x}',
['x' => 'bar'],
'bar',
];

yield 'variables are restored correctly if overwritten in alias' => [
'<f:alias map="{x: \'foo\'}"><f:variable name="x" value="foo2" /></f:alias>{x}',
['x' => 'bar'],
'foo2',
];

yield 'variables set inside alias can be used afterwards' => [
'<f:alias map="{x: \'foo\'}"><f:variable name="foo" value="bar" /></f:alias>{foo}',
[],
'bar',
];

yield 'existing variables can be modified in alias and retain the value set in the alias' => [
'<f:alias map="{x: \'foo\'}"><f:variable name="foo" value="bar" /></f:alias>{foo}',
['foo' => 'fallback'],
'bar',
];
}

/**
Expand Down

0 comments on commit cdd96af

Please sign in to comment.