Skip to content

Commit 9219cdf

Browse files
committed
bug symfony#2328 [TwigComponent] Ignore "nested" for Alpine & Vue attributes (smnandre)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [TwigComponent] Ignore "nested" for Alpine & Vue attributes | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no <!-- please update src/**/CHANGELOG.md files --> | Issues | Fix symfony#1839 | License | MIT Have lost time on Twig & the website 😓 Alternative implementation of symfony#2325 Update: Improved `__toString` performance following `@Kocal` comments --- Now all these attributes are directly rendered | Framework | Prefix | Code Example | Documentation | |-----------------|---------|-------------------------------------------------------------------------------------------------------|----------------------------------------------------------------| | **Alpine.js** | `x-` | `<div x-data="{ open: false }" x-show="open"></div>` | [Documentation Alpine.js](https://alpinejs.dev/) | | **Vue.js** | `v-` | `<input v-model="message" v-if="show">` | [Documentation Vue.js](https://vuejs.org/guide/) | | **Stencil** | `@` | `<my-component `@onClick`="handleClick"></my-component>` | [Documentation Stencil](https://stenciljs.com/docs/) | | **Lit** | `@` | `<button `@click`="${this.handleClick}">Click me</button>` | [Documentation Lit](https://lit.dev/docs/) | Commits ------- 7bd2854 Improve __toString performance cb7809f Add str_contains in nested method 9f7c9c8 Performance 3452436 fabbot 1ed1db7 [TwigComponent] Ignore "nested" for Alpine & Vue attributes
2 parents e95c0cd + 7bd2854 commit 9219cdf

File tree

4 files changed

+109
-38
lines changed

4 files changed

+109
-38
lines changed

src/TwigComponent/src/ComponentAttributes.php

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
final class ComponentAttributes implements \Stringable, \IteratorAggregate, \Countable
2323
{
2424
private const NESTED_REGEX = '#^([\w-]+):(.+)$#';
25+
private const ALPINE_REGEX = '#^x-([a-z]+):[^:]+$#';
26+
private const VUE_REGEX = '#^v-([a-z]+):[^:]+$#';
2527

2628
/** @var array<string,true> */
2729
private array $rendered = [];
@@ -35,43 +37,43 @@ public function __construct(private array $attributes)
3537

3638
public function __toString(): string
3739
{
38-
return array_reduce(
39-
array_filter(
40-
array_keys($this->attributes),
41-
fn (string $key) => !isset($this->rendered[$key])
42-
),
43-
function (string $carry, string $key) {
44-
if (preg_match(self::NESTED_REGEX, $key)) {
45-
return $carry;
46-
}
47-
48-
$value = $this->attributes[$key];
49-
50-
if ($value instanceof \Stringable) {
51-
$value = (string) $value;
52-
}
53-
54-
if (!\is_scalar($value) && null !== $value) {
55-
throw new \LogicException(\sprintf('A "%s" prop was passed when creating the component. No matching "%s" property or mount() argument was found, so we attempted to use this as an HTML attribute. But, the value is not a scalar (it\'s a "%s"). Did you mean to pass this to your component or is there a typo on its name?', $key, $key, get_debug_type($value)));
56-
}
57-
58-
if (null === $value) {
59-
trigger_deprecation('symfony/ux-twig-component', '2.8.0', 'Passing "null" as an attribute value is deprecated and will throw an exception in 3.0.');
60-
$value = true;
61-
}
62-
63-
if (true === $value && str_starts_with($key, 'aria-')) {
64-
$value = 'true';
65-
}
66-
67-
return match ($value) {
68-
true => "{$carry} {$key}",
69-
false => $carry,
70-
default => \sprintf('%s %s="%s"', $carry, $key, $value),
71-
};
72-
},
73-
''
74-
);
40+
$attributes = '';
41+
42+
foreach ($this->attributes as $key => $value) {
43+
if (isset($this->rendered[$key])) {
44+
continue;
45+
}
46+
47+
if (
48+
str_contains($key, ':')
49+
&& preg_match(self::NESTED_REGEX, $key)
50+
&& !preg_match(self::ALPINE_REGEX, $key)
51+
&& !preg_match(self::VUE_REGEX, $key)
52+
) {
53+
continue;
54+
}
55+
56+
if (null === $value) {
57+
trigger_deprecation('symfony/ux-twig-component', '2.8.0', 'Passing "null" as an attribute value is deprecated and will throw an exception in 3.0.');
58+
$value = true;
59+
}
60+
61+
if (!\is_scalar($value) && !($value instanceof \Stringable)) {
62+
throw new \LogicException(\sprintf('A "%s" prop was passed when creating the component. No matching "%s" property or mount() argument was found, so we attempted to use this as an HTML attribute. But, the value is not a scalar (it\'s a "%s"). Did you mean to pass this to your component or is there a typo on its name?', $key, $key, get_debug_type($value)));
63+
}
64+
65+
if (true === $value && str_starts_with($key, 'aria-')) {
66+
$value = 'true';
67+
}
68+
69+
$attributes .= match ($value) {
70+
true => ' '.$key,
71+
false => '',
72+
default => \sprintf(' %s="%s"', $key, $value),
73+
};
74+
}
75+
76+
return $attributes;
7577
}
7678

7779
public function __clone(): void
@@ -215,7 +217,10 @@ public function nested(string $namespace): self
215217
$attributes = [];
216218

217219
foreach ($this->attributes as $key => $value) {
218-
if (preg_match(self::NESTED_REGEX, $key, $matches) && $namespace === $matches[1]) {
220+
if (
221+
str_contains($key, ':')
222+
&& preg_match(self::NESTED_REGEX, $key, $matches) && $namespace === $matches[1]
223+
) {
219224
$attributes[$matches[2]] = $value;
220225
}
221226
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div{{ attributes }}></div>

src/TwigComponent/tests/Integration/ComponentExtensionTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,58 @@ public function testRenderingComponentWithNestedAttributes(): void
318318
);
319319
}
320320

321+
/**
322+
* @dataProvider providePrefixedAttributesCases
323+
*/
324+
public function testRenderPrefixedAttributes(string $attributes, bool $expectContains): void
325+
{
326+
/** @var Environment $twig */
327+
$twig = self::getContainer()->get(Environment::class);
328+
$template = $twig->createTemplate(\sprintf('<twig:PrefixedAttributes %s/>', $attributes));
329+
330+
if ($expectContains) {
331+
self::assertStringContainsString($attributes, trim($template->render()));
332+
333+
return;
334+
}
335+
336+
self::assertStringNotContainsString($attributes, trim($template->render()));
337+
}
338+
339+
/**
340+
* @return iterable<array{0: string, 1: bool}>
341+
*/
342+
public static function providePrefixedAttributesCases(): iterable
343+
{
344+
// General
345+
yield ['x:men', false]; // Nested
346+
yield ['x:men="u"', false]; // Nested
347+
yield ['x-men', true];
348+
yield ['x-men="u"', true];
349+
350+
// AlpineJS
351+
yield ['x-click="count++"', true];
352+
yield ['x-on:click="count++"', true];
353+
yield ['@click="open"', true];
354+
// Not AlpineJS
355+
yield ['z-click="count++"', true];
356+
yield ['z-on:click="count++"', false]; // Nested
357+
358+
// Stencil
359+
yield ['onClick="count++"', true];
360+
yield ['@onClick="count++"', true];
361+
362+
// VueJs
363+
yield ['v-model="message"', true];
364+
yield ['v-bind:id="dynamicId"', true];
365+
yield ['v-bind:id', true];
366+
yield ['@submit.prevent="onSubmit"', true];
367+
// Not VueJs
368+
yield ['z-model="message"', true];
369+
yield ['z-bind:id="dynamicId"', false]; // Nested
370+
yield ['z-bind:id', false]; // Nested
371+
}
372+
321373
public function testRenderingHtmlSyntaxComponentWithNestedAttributes(): void
322374
{
323375
$output = self::getContainer()

src/TwigComponent/tests/Unit/ComponentAttributesTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,19 @@ public function testNestedAttributes(): void
259259
$this->assertSame('', (string) $attributes->nested('invalid'));
260260
}
261261

262+
public function testPrefixedAttributes(): void
263+
{
264+
$attributes = new ComponentAttributes([
265+
'x-click' => 'x+',
266+
'title:x-click' => 'title:x+',
267+
]);
268+
269+
$this->assertSame(' x-click="x+"', (string) $attributes);
270+
$this->assertSame(' x-click="title:x+"', (string) $attributes->nested('title'));
271+
$this->assertSame('', (string) $attributes->nested('title')->nested('span'));
272+
$this->assertSame('', (string) $attributes->nested('invalid'));
273+
}
274+
262275
public function testConvertTrueAriaAttributeValue(): void
263276
{
264277
$attributes = new ComponentAttributes([

0 commit comments

Comments
 (0)