Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions src/TwigComponent/src/Twig/PropsNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,36 +30,40 @@ public function __construct(array $propsNames, array $values, $lineno = 0)

public function compile(Compiler $compiler): void
{
$compiler
->addDebugInfo($this)
->write('$propsNames = [];')
;
$compiler->addDebugInfo($this);

if (!$propsNames = $this->getAttribute('names')) {
$compiler->write('$propsNames = [];');

if (!$this->getAttribute('names')) {
return;
}

$compiler
->write('$propsNames = [\''.implode("', '", $propsNames).'\'];')
->raw("\n")
->write('$context[\'attributes\'] = $context[\'attributes\']->without(...$propsNames);')
->raw("\n")
;

foreach ($this->getAttribute('names') as $name) {
$compiler
->write('if (isset($context[\'__props\'][\''.$name.'\'])) {')
->raw("\n")
->indent()
->write('$componentClass = isset($context[\'this\']) ? get_debug_type($context[\'this\']) : "";')
->raw("\n")
->write('throw new \Twig\Error\RuntimeError(\'Cannot define prop "'.$name.'" in template "'.$this->getTemplateName().'". Property already defined in component class "\'.$componentClass.\'".\');')
->raw("\n")
->outdent()
->write('}')
->raw("\n")
;

$compiler
->write('$propsNames[] = \''.$name.'\';')
->write("\n")
->write('$context[\'attributes\'] = $context[\'attributes\']->remove(\''.$name.'\');')
->write("\n")
->write('if (!isset($context[\''.$name.'\'])) {');
$compiler->write('if (!isset($context[\''.$name.'\'])) {');

if (!$this->hasNode($name)) {
$compiler
->write("\n")
->indent()
->write('throw new \Twig\Error\RuntimeError("'.$name.' should be defined for component '.$this->getTemplateName().'.");')
->write("\n")
Expand Down Expand Up @@ -97,22 +101,14 @@ public function compile(Compiler $compiler): void
}

$compiler
->write('$attributesKeys = array_keys($context[\'attributes\']->all());')
->raw("\n")
->write('foreach ($context as $key => $value) {')
->raw("\n")
->indent()
->write('if (in_array($key, $attributesKeys) && !in_array($key, $propsNames)) {')
->write('foreach ($context[\'attributes\']->all() as $key => $value) {')
->raw("\n")
->indent()
->raw('unset($context[$key]);')
->raw("\n")
->outdent()
->write('}')
->raw("\n")
->outdent()
->write('}')
->raw("\n")
;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% props name %}

<div {{ attributes }}>
<p>name={{ name }}</p>
<p>class-var-defined={{ class is defined ? 'yes' : 'no' }}</p>
<p>style-var-defined={{ style is defined ? 'yes' : 'no' }}</p>
<p>data_foo-var-defined={{ data_foo is defined ? 'yes' : 'no' }}</p>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% props propA, propB, propC = 'default' %}

<div {{ attributes }}>
<p>propA={{ propA }}</p>
<p>propB={{ propB }}</p>
<p>propC={{ propC }}</p>
<p>has-propA-attr={{ attributes.has('propA') ? 'yes' : 'no' }}</p>
<p>has-propB-attr={{ attributes.has('propB') ? 'yes' : 'no' }}</p>
<p>has-propC-attr={{ attributes.has('propC') ? 'yes' : 'no' }}</p>
<p>has-class-attr={{ attributes.has('class') ? 'yes' : 'no' }}</p>
<p>has-extra-attr={{ attributes.has('data-extra') ? 'yes' : 'no' }}</p>
<p>propA-in-context={{ propA is defined ? 'yes' : 'no' }}</p>
<p>propB-in-context={{ propB is defined ? 'yes' : 'no' }}</p>
<p>propC-in-context={{ propC is defined ? 'yes' : 'no' }}</p>
</div>
111 changes: 111 additions & 0 deletions src/TwigComponent/tests/Integration/ComponentExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,117 @@ public function testHigherOrderComponentWithAttributeDefaults()
$this->assertStringContainsString('Confirm deletion?', $output);
}

public function testPropsAreRemovedFromAttributesInSingleCall()
{
$output = $this->renderComponent('PropsAndAttributesSeparation', [
'propA' => 'valueA',
'propB' => 'valueB',
'propC' => 'valueC',
'class' => 'my-class',
'data-extra' => 'extra-value',
]);

// Props should be available as template variables
$this->assertStringContainsString('propA=valueA', $output);
$this->assertStringContainsString('propB=valueB', $output);
$this->assertStringContainsString('propC=valueC', $output);

// Props should NOT be in attributes anymore
$this->assertStringContainsString('has-propA-attr=no', $output);
$this->assertStringContainsString('has-propB-attr=no', $output);
$this->assertStringContainsString('has-propC-attr=no', $output);

// Non-prop attributes should still be in attributes
$this->assertStringContainsString('has-class-attr=yes', $output);
$this->assertStringContainsString('has-extra-attr=yes', $output);

// Props should be defined in context
$this->assertStringContainsString('propA-in-context=yes', $output);
$this->assertStringContainsString('propB-in-context=yes', $output);
$this->assertStringContainsString('propC-in-context=yes', $output);

// Non-prop attributes should be rendered in the div
$this->assertStringContainsString('class="my-class"', $output);
$this->assertStringContainsString('data-extra="extra-value"', $output);
}

/**
* Test that extra attributes (non-props) are correctly cleaned from context.
*
* This is a regression test for the optimization that iterates directly over
* attributes->all() instead of iterating over the entire context.
*/
public function testExtraAttributesAreCleanedFromContext()
{
$output = $this->renderComponent('PropsAndAttributesSeparation', [
'propA' => 'A',
'propB' => 'B',
'class' => 'test-class',
'data-extra' => 'test-extra',
'id' => 'test-id',
]);

// Props should be available
$this->assertStringContainsString('propA=A', $output);
$this->assertStringContainsString('propB=B', $output);
$this->assertStringContainsString('propC=default', $output);

// All non-prop attributes should be in the attributes object
$this->assertStringContainsString('class="test-class"', $output);
$this->assertStringContainsString('data-extra="test-extra"', $output);
$this->assertStringContainsString('id="test-id"', $output);
}

/**
* Test that props with default values work correctly when not provided.
*/
public function testPropsWithDefaultValuesWhenNotProvided()
{
$output = $this->renderComponent('PropsAndAttributesSeparation', [
'propA' => 'A',
'propB' => 'B',
// propC is not provided, should use default
'class' => 'some-class',
]);

$this->assertStringContainsString('propA=A', $output);
$this->assertStringContainsString('propB=B', $output);
$this->assertStringContainsString('propC=default', $output);

// propC should not be in attributes
$this->assertStringContainsString('has-propC-attr=no', $output);
}

/**
* Test that attributes passed to a component do not leak into template context.
*
* This is a regression test for the optimization that directly iterates over
* attributes->all() to unset context variables, ensuring non-prop attributes
* are properly cleaned from the context.
*/
public function testAttributesDoNotLeakToTemplateContext()
{
$output = $this->renderComponent('AttributesNotLeakingToContext', [
'name' => 'test-name',
'class' => 'my-class',
'style' => 'color: red;',
'data-foo' => 'bar',
]);

// The prop should be available
$this->assertStringContainsString('name=test-name', $output);

// The attributes should be rendered in the HTML
$this->assertStringContainsString('class="my-class"', $output);
$this->assertStringContainsString('style="color: red;"', $output);
$this->assertStringContainsString('data-foo="bar"', $output);

// But the attribute names should NOT be defined as template variables
$this->assertStringContainsString('class-var-defined=no', $output);
$this->assertStringContainsString('style-var-defined=no', $output);
$this->assertStringContainsString('data_foo-var-defined=no', $output);
}

private function renderComponent(string $name, array $data = []): string
{
return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [
Expand Down
5 changes: 3 additions & 2 deletions src/TwigComponent/tests/Unit/ComponentAttributesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@ public function testCanGetOnly()

public function testCanGetWithout()
{
$attributes = new ComponentAttributes(['class' => 'foo', 'style' => 'color:black;'], new EscaperRuntime());
$attributes = new ComponentAttributes(['class' => 'foo', 'style' => 'color:black;', 'data-foo' => 'bar'], new EscaperRuntime());

$this->assertSame(['class' => 'foo'], $attributes->without('style')->all());
$this->assertSame(['class' => 'foo', 'data-foo' => 'bar'], $attributes->without('style')->all());
$this->assertSame(['class' => 'foo'], $attributes->without('style', 'data-foo')->all());
}

/**
Expand Down
Loading