Repository: symfony/ux (php)

Issue:
[LiveComponent] Undefined array key "placeholder" after upgrading to 2.22.x
<!-- ======================== Guidelines ============================

Thank you for taking the time to report a bug! 🙏

Please follow these guidelines to help us understand & fix the issue:

Describe Your Problem 🎯
- Clearly explain the problem you're facing;
- Describe what you expected to happen versus what actually occurred;
- List the steps to reproduce the bug.
	
Provide Detailed Information 📋
- Share relevant details: Component version, errors, screenshots;
- If possible, provide a minimal reproducer in a GitHub repository.

Be Kind and Respectful 🙂
- Stay patient & open to feedback, and act with kindness and respect;
- Remember that this is a volunteer-driven project.

============================= Guidelines ======================== -->

Hello,

recently after upgrading symfony/ux-live-components to 2.22.x I've encountered this exception:

**An exception has been thrown during the rendering of a template ("Warning: Undefined array key "placeholder"").**

Here's the reproducer: https://github.com/maciazek/symfony-ux-live-components-choices-reproducer

I have a custom DatalistType form field, which extends TextType to add HTML datalist into it. Inside DatalistType I'm using 'choices' option to add choices to datalist.
https://github.com/maciazek/symfony-ux-live-components-choices-reproducer/blob/master/src/Form/DatalistType.php#L11-L29
https://github.com/maciazek/symfony-ux-live-components-choices-reproducer/blob/master/templates/form/custom_types.html.twig

I have a form inside live component which is using DatalistType as a child field.
https://github.com/maciazek/symfony-ux-live-components-choices-reproducer/blob/ebe9ff77a7181ed6e0ca4dd5fca7483f38e8ed94/src/Form/ExampleType.php#L9-L30
https://github.com/maciazek/symfony-ux-live-components-choices-reproducer/blob/ebe9ff77a7181ed6e0ca4dd5fca7483f38e8ed94/src/Twig/Components/ExampleForm.php#L13-L23

Everything worked well until updating symfony/ux-live-components to 2.22.x - then I've encountered previously mentioned error.

After some investigation I've found that problem is caused by recent change in ComponentWithFormTrait:
https://github.com/symfony/ux-live-component/blob/5adf780aac0ccbd5cb1dddea683db9aa99b91d4a/src/ComponentWithFormTrait.php#L264-L271

I have 'choices' option in my DatalistType, but it's extending TextType which, as I guess, has no default values for placeholder, multiple and expanded options so they're missing in vars array.

Possible solutions:
1. I can rename option 'choices' to something different, but I still haven't found a better name :)
2. I can set default values for placeholder, multiple and expanded and assign them to vars inside buildView function in my DatalistType - that's my temporary workaround;
3. Maybe these recently added vars tests could be null-coalesced or `array_key_exists`-ed in ComponentWithFormTrait?

Thank you for your great work on the whole UX ecosystem!

Pull request description:
[LiveComponent] Fix `ComponentWithFormTrait::extractFormValues()` with edge cases
| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | no <!-- please update src/**/CHANGELOG.md files -->
| Issues        | Fix #2487 
| License       | MIT

#2403 fixed an old bug to simulate browser behaviour when a select field has no option selected,  no placeholder, and is required (it then uses the first option)
#2425 and #2426 fixed the way we handled empty strings as placeholders

In #2487 @maciazek explained us how a custom type with a "choices" option became unusable since the last changes.

Also.. while I was reading all this I realized we returned wrong values here when "option groups" were used.. leading to other problems.

I updated the code of `extractFormValues()` method to:
* check if most of the caracteristic keys added in `ChoiceType::buildView` were defined in the `$view->vars`, to ensure we are dealing with a `<select>` field built by a `ChoiceType`
* fetch recursively the value of the first displayed option (even with groups)





Patch:
diff --git a/src/LazyImage/doc/index.rst b/src/LazyImage/doc/index.rst
index 2454ab94..b8369e0f 100644
--- a/src/LazyImage/doc/index.rst
+++ b/src/LazyImage/doc/index.rst
@@ -1,12 +1,6 @@
 Symfony UX LazyImage
 ====================
 
-.. warning::
-
-    **This package is no longer recommended.** Instead, use the
-    `modern techniques to improve image loading performance`_ natively supported
-    by all major browsers.
-
 Symfony UX LazyImage is a Symfony bundle providing utilities to improve
 image loading performance. It is part of `the Symfony UX initiative`_.
 
@@ -251,7 +245,6 @@ This bundle aims at following the same Backward Compatibility promise as
 the Symfony framework:
 https://symfony.com/doc/current/contributing/code/bc.html
 
-.. _`modern techniques to improve image loading performance`: https://web.dev/learn/images/performance-issues
 .. _`the Symfony UX initiative`: https://ux.symfony.com/
 .. _`BlurHash implementation`: https://blurha.sh
 .. _`StimulusBundle`: https://symfony.com/bundles/StimulusBundle/current/index.html
diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst
index 8baa3fc4..4c7b5fc2 100644
--- a/src/LiveComponent/doc/index.rst
+++ b/src/LiveComponent/doc/index.rst
@@ -3732,9 +3732,9 @@ uses Symfony's test client to render and make requests to your components::
                 ->set('count', 99)
             ;
 
-            // Submit form data ('my_form' for your MyFormType form)
+            // Submit form data
             $testComponent
-                ->submitForm(['my_form' => ['input' => 'value']], 'save');
+                ->submitForm(['form' => ['input' => 'value']], 'save');
 
             $this->assertStringContainsString('Count: 99', $testComponent->render());
 
diff --git a/src/LiveComponent/src/ComponentWithFormTrait.php b/src/LiveComponent/src/ComponentWithFormTrait.php
index c8ab525d..8f2aceab 100644
--- a/src/LiveComponent/src/ComponentWithFormTrait.php
+++ b/src/LiveComponent/src/ComponentWithFormTrait.php
@@ -11,6 +11,7 @@
 
 namespace Symfony\UX\LiveComponent;
 
+use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView;
 use Symfony\Component\Form\ClearableErrorsInterface;
 use Symfony\Component\Form\FormInterface;
 use Symfony\Component\Form\FormView;
@@ -255,25 +256,39 @@ trait ComponentWithFormTrait
                 continue;
             }
 
+            // <input type="checkbox">
             if (\array_key_exists('checked', $child->vars)) {
-                // special handling for check boxes
                 $values[$name] = $child->vars['checked'] ? $child->vars['value'] : null;
                 continue;
             }
 
-            if (\array_key_exists('choices', $child->vars)
-                && $child->vars['required']
-                && !$child->vars['disabled']
-                && !$child->vars['value']
-                && (false === $child->vars['placeholder'] || null === $child->vars['placeholder'])
-                && !$child->vars['multiple']
-                && !$child->vars['expanded']
-                && $child->vars['choices']
+            // <select> - Simulate browser behavior
+            // When no option is selected, browsers send the value of the first
+            // option, when the following conditions are met:
+            if (
+                \array_key_exists('choices', $child->vars)
+                && $child->vars['choices']                  // has defined choices
+                && $child->vars['required']                 // is required
+                && !$child->vars['disabled']                // is not disabled
+                && '' === $child->vars['value']             // has no value set  ("0" can be a value)
+                && !array_diff_key(
+                    /* @see \Symfony\Component\Form\Extension\Core\Type\ChoiceType::buildView() */
+                    array_flip(['choices', 'expanded', 'multiple', 'placeholder', 'placeholder_in_choices', 'preferred_choices']),
+                    $child->vars,
+                )
+                && !$child->vars['expanded']                // is a <select>     (not a radio/checkbox)
+                && !$child->vars['multiple']                // is not multiple
+                && !\is_string($child->vars['placeholder'])  // has no placeholder (empty string is valid)
             ) {
-                if (null !== $firstKey = array_key_first($child->vars['choices'])) {
-                    $values[$name] = $child->vars['choices'][$firstKey]->value ?? null;
-                }
-
+                $choices = $child->vars['choices'];
+                do {
+                    $choice = $choices[array_key_first($choices)];
+                    if (!$choice instanceof ChoiceGroupView) {
+                        break;
+                    }
+                } while ($choices = $choice->choices);
+
+                $values[$name] = $choice?->value;
                 continue;
             }
 
diff --git a/src/StimulusBundle/assets/dist/loader.js b/src/StimulusBundle/assets/dist/loader.js
index 3f70fed3..1d1ce80d 100644
--- a/src/StimulusBundle/assets/dist/loader.js
+++ b/src/StimulusBundle/assets/dist/loader.js
@@ -25,50 +25,30 @@ class StimulusLazyControllerHandler {
         this.lazyLoadNewControllers(document.documentElement);
     }
     lazyLoadExistingControllers(element) {
-        Array.from(element.querySelectorAll(`[${controllerAttribute}]`))
-            .flatMap(extractControllerNamesFrom)
-            .forEach((controllerName) => this.loadLazyController(controllerName));
+        this.queryControllerNamesWithin(element).forEach((controllerName) => this.loadLazyController(controllerName));
     }
-    loadLazyController(name) {
-        if (!this.lazyControllers[name]) {
-            return;
-        }
-        const controllerLoader = this.lazyControllers[name];
-        delete this.lazyControllers[name];
-        if (!canRegisterController(name, this.application)) {
-            return;
-        }
-        this.application.logDebugActivity(name, 'lazy:loading');
-        controllerLoader()
-            .then((controllerModule) => {
-            this.application.logDebugActivity(name, 'lazy:loaded');
+    async loadLazyController(name) {
+        if (canRegisterController(name, this.application)) {
+            if (this.lazyControllers[name] === undefined) {
+                return;
+            }
+            const controllerModule = await this.lazyControllers[name]();
             registerController(name, controllerModule.default, this.application);
-        })
-            .catch((error) => {
-            console.error(`Error loading controller "${name}":`, error);
-        });
+        }
     }
     lazyLoadNewControllers(element) {
-        if (Object.keys(this.lazyControllers).length === 0) {
-            return;
-        }
         new MutationObserver((mutationsList) => {
-            for (const mutation of mutationsList) {
-                switch (mutation.type) {
-                    case 'childList': {
-                        for (const node of mutation.addedNodes) {
-                            if (node instanceof Element) {
-                                extractControllerNamesFrom(node).forEach((controllerName) => {
-                                    this.loadLazyController(controllerName);
-                                });
-                            }
+            for (const { attributeName, target, type } of mutationsList) {
+                switch (type) {
+                    case 'attributes': {
+                        if (attributeName === controllerAttribute &&
+                            target.getAttribute(controllerAttribute)) {
+                            extractControllerNamesFrom(target).forEach((controllerName) => this.loadLazyController(controllerName));
                         }
                         break;
                     }
-                    case 'attributes': {
-                        if (mutation.attributeName === controllerAttribute) {
-                            extractControllerNamesFrom(mutation.target).forEach((controllerName) => this.loadLazyController(controllerName));
-                        }
+                    case 'childList': {
+                        this.lazyLoadExistingControllers(target);
                     }
                 }
             }
@@ -78,6 +58,9 @@ class StimulusLazyControllerHandler {
             childList: true,
         });
     }
+    queryControllerNamesWithin(element) {
+        return Array.from(element.querySelectorAll(`[${controllerAttribute}]`)).flatMap(extractControllerNamesFrom);
+    }
 }
 function registerController(name, controller, application) {
     if (canRegisterController(name, application)) {
diff --git a/src/StimulusBundle/assets/src/loader.ts b/src/StimulusBundle/assets/src/loader.ts
index 393ded69..5b5009ae 100644
--- a/src/StimulusBundle/assets/src/loader.ts
+++ b/src/StimulusBundle/assets/src/loader.ts
@@ -64,61 +64,40 @@ class StimulusLazyControllerHandler {
     }
 
     private lazyLoadExistingControllers(element: Element) {
-        Array.from(element.querySelectorAll(`[${controllerAttribute}]`))
-            .flatMap(extractControllerNamesFrom)
-            .forEach((controllerName) => this.loadLazyController(controllerName));
+        this.queryControllerNamesWithin(element).forEach((controllerName) => this.loadLazyController(controllerName));
     }
 
-    private loadLazyController(name: string) {
-        if (!this.lazyControllers[name]) {
-            return;
-        }
+    private async loadLazyController(name: string) {
+        if (canRegisterController(name, this.application)) {
+            if (this.lazyControllers[name] === undefined) {
+                return;
+            }
 
-        // Delete the loader to avoid loading it twice
-        const controllerLoader = this.lazyControllers[name];
-        delete this.lazyControllers[name];
+            const controllerModule = await this.lazyControllers[name]();
 
-        if (!canRegisterController(name, this.application)) {
-            return;
+            registerController(name, controllerModule.default, this.application);
         }
-
-        this.application.logDebugActivity(name, 'lazy:loading');
-
-        controllerLoader()
-            .then((controllerModule) => {
-                this.application.logDebugActivity(name, 'lazy:loaded');
-                registerController(name, controllerModule.default, this.application);
-            })
-            .catch((error) => {
-                console.error(`Error loading controller "${name}":`, error);
-            });
     }
 
     private lazyLoadNewControllers(element: Element) {
-        if (Object.keys(this.lazyControllers).length === 0) {
-            return;
-        }
         new MutationObserver((mutationsList) => {
-            for (const mutation of mutationsList) {
-                switch (mutation.type) {
-                    case 'childList': {
-                        // @ts-ignore
-                        for (const node of mutation.addedNodes) {
-                            if (node instanceof Element) {
-                                extractControllerNamesFrom(node).forEach((controllerName) => {
-                                    this.loadLazyController(controllerName);
-                                });
-                            }
-                        }
-                        break;
-                    }
-
+            for (const { attributeName, target, type } of mutationsList) {
+                switch (type) {
                     case 'attributes': {
-                        if (mutation.attributeName === controllerAttribute) {
-                            extractControllerNamesFrom(mutation.target as Element).forEach((controllerName) =>
+                        if (
+                            attributeName === controllerAttribute &&
+                            (target as Element).getAttribute(controllerAttribute)
+                        ) {
+                            extractControllerNamesFrom(target as Element).forEach((controllerName) =>
                                 this.loadLazyController(controllerName)
                             );
                         }
+
+                        break;
+                    }
+
+                    case 'childList': {
+                        this.lazyLoadExistingControllers(target as Element);
                     }
                 }
             }
@@ -128,6 +107,10 @@ class StimulusLazyControllerHandler {
             childList: true,
         });
     }
+
+    private queryControllerNamesWithin(element: Element): string[] {
+        return Array.from(element.querySelectorAll(`[${controllerAttribute}]`)).flatMap(extractControllerNamesFrom);
+    }
 }
 
 function registerController(name: string, controller: ControllerConstructor, application: Application) {
diff --git a/src/StimulusBundle/doc/index.rst b/src/StimulusBundle/doc/index.rst
index 286f3871..62521ff2 100644
--- a/src/StimulusBundle/doc/index.rst
+++ b/src/StimulusBundle/doc/index.rst
@@ -574,7 +574,7 @@ it will normalize it:
 .. _`Typed`: https://github.com/mattboldt/typed.js/
 .. _`Vue`: https://vuejs.org/
 .. _`stimulus-use`: https://stimulus-use.github.io/stimulus-use
-.. _`stimulus-components`: https://www.stimulus-components.com/
+.. _`stimulus-components`: https://stimulus-components.netlify.app/
 .. _`TypeScript`: https://www.typescriptlang.org/
 .. _`sensiolabs/typescript-bundle`: https://github.com/sensiolabs/AssetMapperTypeScriptBundle
 .. _`Stimulus plugin`: https://plugins.jetbrains.com/plugin/24562-stimulus
diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst
index efccbcc6..fb0a38d9 100644
--- a/src/TwigComponent/doc/index.rst
+++ b/src/TwigComponent/doc/index.rst
@@ -405,8 +405,11 @@ normal. This example assumes you have a ``Product`` Doctrine entity and
     #[AsTwigComponent]
     class FeaturedProducts
     {
-        public function __construct(private ProductRepository $productRepository)
+        private ProductRepository $productRepository;
+
+        public function __construct(ProductRepository $productRepository)
         {
+            $this->productRepository = $productRepository;
         }
 
         public function getProducts(): array
@@ -1342,7 +1345,7 @@ You can test how your component is mounted and rendered using the
                 data: ['foo' => 'bar'],
             );
 
-            $this->assertStringContainsString('bar', (string) $rendered);
+            $this->assertStringContainsString('bar', $rendered);
 
             // use the crawler
             $this->assertCount(5, $rendered->crawler()->filter('ul li'));
@@ -1360,7 +1363,7 @@ You can test how your component is mounted and rendered using the
                 ],
             );
 
-            $this->assertStringContainsString('bar', (string) $rendered);
+            $this->assertStringContainsString('bar', $rendered);
         }
     }
 
diff --git a/ux.symfony.com/.platform.app.yaml b/ux.symfony.com/.platform.app.yaml
index 5681b6a4..e195bba0 100644
--- a/ux.symfony.com/.platform.app.yaml
+++ b/ux.symfony.com/.platform.app.yaml
@@ -4,7 +4,7 @@ type: php:8.3
 
 dependencies:
     php:
-        composer/composer: "^2.8"
+        composer/composer: "^2"
 
 build:
     flavor: none
diff --git a/ux.symfony.com/assets/styles/components/_Banner.scss b/ux.symfony.com/assets/styles/components/_Banner.scss
index aef2d0c2..7e6f4564 100644
--- a/ux.symfony.com/assets/styles/components/_Banner.scss
+++ b/ux.symfony.com/assets/styles/components/_Banner.scss
@@ -3,26 +3,10 @@
 // -----------------------------------------------------------------
 
 .Banner {
-  --color-front: #fff;
-  --color-back: #0A0A0A;
-  --color-text: color-mix(in oklab, var(--color-front) 80%, var(--color-back) 20%);
-  --color-light: color-mix(in oklab, var(--color-front) 50%, var(--color-back) 50%);
-  
-  --space-block: .5rem;
-  --space-inline: 2rem;
-  --font-size: .8rem;
-
-  @media screen and (min-width: 768px) {
-    --font-size: .9rem;
-    --space-inline: 1rem;
-  }
-
-  background: var(--color-back);
-  color: var(--color-front);
-  z-index: 10;
-  position: relative;
+  background: #0A0A0A;
+  color: #fff;
+  transition: transform 250ms linear;
 }
-
 .Banner + .App {
   position: relative;
 }
@@ -31,102 +15,84 @@
   transform: translateY(-100%);
 }
 
-.Banner_inner {
+.BannerInner {
   display: flex;
   flex-direction: row;
   flex-wrap: nowrap;
-  padding-block: var(--space-block);
-  gap: var(--space-inline);
+  padding-block: .5rem;
+  gap: 1rem;
   position: relative;
 }
 
-.Banner::after {
-  height: 2px;
-  backdrop-filter: blur(4px);
-  width: 100%;
-  content: "";
-  background: rgb(255 255 255 / .1);
-}
-
-.Banner_content {
+.BannerContent {
   flex: 1;
   display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
+  flex-direction: column;
+  place-content: center;
   gap: 0.25rem;
   text-wrap: balance;
 }
 
-.Banner_text {
+.BannerText {
+  text-align: center;
   margin: 0;
-  font-size: var(--font-size);
+  font-size: 0.8rem;
   line-height: 1.3;
-  font-weight: 300;
-  color: var(--color-text, var(--color-front));
-  .Icon {
-    transform: translateY(-12%);
-    margin-inline: calc(var(--space-inline) / 2);
-  }
+  font-weight: 400;
+  color: #fffd;
+}
+.BannerText em {
+  text-decoration: underline;
+  text-underline-offset: .25rem;
+  font-weight: 600;
+  font-style: normal;
 }
 
-.Banner_link {
-  color: var(--color-front);
-  font-weight: 400;
-  transition: all 300ms ease-out;
-  transform: none;
-  display: inline-flex;
-  text-decoration: underline 1px;
-  text-underline-offset: 0.2em;
-  text-decoration-color: var(--color-back);
-  
-  &:hover {
-    transition: all 150ms ease-out;
-    transform: translateY(-6%);
-    text-decoration-color: var(--color-text);
-    text-underline-offset: 0.3em;
-  }
-  
-  strong {
-    font-weight: 600;
-  }
+.BannerAction {
+  display: grid;
+  place-content: center;
+  justify-content: center;
 }
 
-.Banner_cta {
+.BannerButton {
   display: flex;
-  flex-direction: row;
-  gap: .75em;
   align-items: center;
-  flex-wrap: nowrap;
-  flex-shrink: 0;
-
-  span {
-    transition: all 350ms ease-out;
-  }
-
-  &:hover {
-    span {
-      transition: all 150ms linear;
-      transform: translateY(-0.1em);
-    }
-  }
-
-  .Icon {
-    width: 1.5em;
-    height: 1.5em;
-  }
+  padding: .15em .5em;
+  align-self: center;
+  color: #fffc;
+  font-size: .9em;
+  font-family: var(--font-family-title);
+  gap: .35em;
+  justify-content: center;
+  background: linear-gradient(#000e, #000d) padding-box,
+  linear-gradient(90deg, #D65831 0%, #D2D631 36.52%, #31D673 71.83%, #3aa3ff 100%) border-box;
+  border-radius: 2em;
+  border: 2px solid transparent;
+  transition: all 250ms linear;
+}
+.BannerButton:hover {
+  color: #fff;
+}
+.BannerButton:after {
+  content: "";
+  position: absolute;
+  inset: 0;
 }
 
-@media screen and (max-width: 768px) {
-  .Banner_text .Icon,
-  .Banner_text .more, 
-  .Banner_cta span {
-    display: none;
+.BannerButton svg {
+  width: 1em;
+  height: 1em;
+  color: currentColor;
+  transition: all 250ms linear;
+}
+.BannerButton:hover svg {
+  transform: scale(1.1);
+}
+@media screen and (min-width: 768px) {
+  .BannerInner {
+    padding: .5rem 2rem;
   }
-  .Banner_cta:after {
-    content: '';
-    position: absolute;
-    inset: 0;
-    z-index: 100;
+  .BannerText {
+    font-size: .9rem;
   }
 }
diff --git a/ux.symfony.com/assets/styles/sections/_nav.scss b/ux.symfony.com/assets/styles/sections/_nav.scss
index d7e2040b..f0a59f06 100644
--- a/ux.symfony.com/assets/styles/sections/_nav.scss
+++ b/ux.symfony.com/assets/styles/sections/_nav.scss
@@ -61,7 +61,7 @@
 
 @media (max-width: 859px) {
   .AppNav_menu {
-    transform: translateY(-40%);
+    transform: translateY(-20%);
     opacity: 0;
     z-index: -1;
     transition: transform 350ms ease-out;
diff --git a/ux.symfony.com/templates/_banner.html.twig b/ux.symfony.com/templates/_banner.html.twig
index e54a457e..eb02f25b 100644
--- a/ux.symfony.com/templates/_banner.html.twig
+++ b/ux.symfony.com/templates/_banner.html.twig
@@ -1,22 +1,15 @@
-<aside class="Banner" style="--color-back: {{ color_back ?? '#D5054EFF' }}; --color-front: #ffffffdd;">
-    <div class="container-xxl px-4 px-md-5">
-        <div class="Banner_inner">
-            <div class="Banner_content">
-                <p class="Banner_text">
-                    <span>
-                        Get ready for an unforgettable
-                        <a href="https://live.symfony.com/2025-chicago/" class="Banner_link"><strong>SymfonyDay Chicago 2025</strong></a>!
-                        <twig:ux:icon name="si:heart-fill" />
-                    </span>
-                    <span class="more">
-                        Join us for a full day dedicated to
-                        <a href="https://gofund.me/44ecdba2" rel="external" class="Banner_link">Ryan Weaver</a>
-                        & Symfony UX!
-                    </span>
+<aside class="Banner">
+    <div class="container-xxl">
+        <div class="BannerInner">
+            <div class="BannerContent">
+                <p class="BannerText">
+                    Ryan is the lead maintainer of Symfony UX. Now he <em>needs your help</em>.
                 </p>
-                <a href="https://live.symfony.com/2025-chicago/" class="Banner_cta" rel="external">
-                    <twig:ux:icon name="ion:ticket" />
-                    <span>Get Your Tickets!</span>
+            </div>
+            <div class="BannerAction">
+                <a href="https://gofund.me/44ecdba2" class="BannerButton" rel="external">
+                    <twig:ux:icon name="heart" aria-hidden="true"/>
+                    Donate
                 </a>
             </div>
         </div>
diff --git a/ux.symfony.com/templates/icons/index.html.twig b/ux.symfony.com/templates/icons/index.html.twig
index d4580f39..eb0bbad4 100644
--- a/ux.symfony.com/templates/icons/index.html.twig
+++ b/ux.symfony.com/templates/icons/index.html.twig
@@ -48,11 +48,6 @@
         <p class="eyebrows" style="margin: 4rem 0 0;">Iconic Packages</p>
         <div class="d-md-flex justify-content-md-between text-center align-items-center text-md-start">
             <h2 class="ubuntu pt-2 component-headlines">Popular <em class="rainbow-emphasis">Icon Sets</em></h2>
-            <a style="height: 100%;" class="btn btn-md btn-outline-primary my-3 my-md-0"
-               href="https://symfony.com/bundles/ux-icons/current/index.html" rel="external" target="_blank">
-                Read Documentation
-                <twig:ux:icon name="arrow-right" style="transform: rotate(-45deg);"/>
-            </a>
         </div>
         <div class="mt-3 mt-md-5">
             <div style="display:grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 2rem;">
@@ -66,41 +61,6 @@
         </div>
     </section>
 
-    <section class="section">
-        <div class="container-fluid container-xxl px-4 px-md-5 py-3">
-
-            <div class="d-md-flex justify-content-md-between py-5 text-center align-items-center text-md-start">
-                <h2 class="ubuntu pt-2 component-headlines">Documentation</h2>
-                <a href="https://symfony.com/bundles/ux-icons/current/index.html" class="btn btn-md btn-outline-primary my-3 my-md-0"
-                   rel="external" target="_blank">
-                    Read Full Documentation
-                    <twig:ux:icon name="arrow-right" style="transform: rotate(-45deg);"/>
-                </a>
-            </div>
-
-            <div class="row">
-                <div class="col-4">
-                    <h3 class="ubuntu mb-4" style="font-weight: 400;" title="Install UX Icons">Installation</h3>
-                    <twig:Terminal bottomPadding="20">
-                        composer require {{ package.composerName }}
-                    </twig:Terminal>
-                </div>
-                <div class="col-4">
-                    <h3 class="ubuntu mb-4" style="font-weight: 400;" title="Search UX Icons">Search Icons</h3>
-                    <twig:Terminal bottomPadding="20">
-                        php bin/console ux:icons:search lucide
-                    </twig:Terminal>
-                </div>
-                <div class="col-4">
-                   <h3 class="ubuntu mb-4" style="font-weight: 400;" title="Symfony UX Bundle Configuration">Configuration</h3>
-                    <twig:Terminal bottomPadding="20">
-                        php bin/console debug:config ux_icons
-                    </twig:Terminal>
-                </div>
-            </div>
-        </div>
-    </section>
-
 {% endblock %}
 
 {% block aside %}
diff --git a/ux.symfony.com/templates/ux_packages/chartjs.html.twig b/ux.symfony.com/templates/ux_packages/chartjs.html.twig
index b13f1e56..746bde80 100644
--- a/ux.symfony.com/templates/ux_packages/chartjs.html.twig
+++ b/ux.symfony.com/templates/ux_packages/chartjs.html.twig
@@ -1,9 +1,5 @@
 {% extends 'ux_packages/package.html.twig' %}
 
-{% block banner %}
-    {{ include('_banner.html.twig', {color_back: '#165514'}) }}
-{% endblock %}
-
 {% block package_header %}
     {% component PackageHeader with {
         package: 'chartjs',
diff --git a/ux.symfony.com/templates/ux_packages/live_component.html.twig b/ux.symfony.com/templates/ux_packages/live_component.html.twig
index 20b2cfbc..b8eb5a28 100644
--- a/ux.symfony.com/templates/ux_packages/live_component.html.twig
+++ b/ux.symfony.com/templates/ux_packages/live_component.html.twig
@@ -1,9 +1,5 @@
 {% extends 'ux_packages/package.html.twig' %}
 
-{% block banner %}
-    {{ include('_banner.html.twig', {color_back: '#854e0d'}) }}
-{% endblock %}
-
 {% block package_header %}
     {% component PackageHeader with {
         package: 'live-component',
diff --git a/ux.symfony.com/templates/ux_packages/map.html.twig b/ux.symfony.com/templates/ux_packages/map.html.twig
index 7273ea33..8f7afe5e 100644
--- a/ux.symfony.com/templates/ux_packages/map.html.twig
+++ b/ux.symfony.com/templates/ux_packages/map.html.twig
@@ -1,9 +1,5 @@
 {% extends 'ux_packages/package.html.twig' %}
 
-{% block banner %}
-    {{ include('_banner.html.twig', {color_back: '#165514'}) }}
-{% endblock %}
-
 {% set package = 'map'|ux_package %}
 
 {% block package_header %}
diff --git a/ux.symfony.com/templates/ux_packages/package.html.twig b/ux.symfony.com/templates/ux_packages/package.html.twig
index 7b1c4b79..709debc3 100644
--- a/ux.symfony.com/templates/ux_packages/package.html.twig
+++ b/ux.symfony.com/templates/ux_packages/package.html.twig
@@ -17,10 +17,6 @@
     }
 } %}
 
-{% block banner %}
-    {{ include('_banner.html.twig', {color_back: '#222'}) }}
-{% endblock %}
-
 {% block header %}
     {{ include('_header.html.twig', {
         theme: 'white'
diff --git a/ux.symfony.com/templates/ux_packages/stimulus.html.twig b/ux.symfony.com/templates/ux_packages/stimulus.html.twig
index 0d3b9c65..7b11dc69 100644
--- a/ux.symfony.com/templates/ux_packages/stimulus.html.twig
+++ b/ux.symfony.com/templates/ux_packages/stimulus.html.twig
@@ -1,9 +1,5 @@
 {% extends 'ux_packages/package.html.twig' %}
 
-{% block banner %}
-    {{ include('_banner.html.twig', {color_back: '#18543e'}) }}
-{% endblock %}
-
 {% block package_header %}
     <twig:Package:PackageHeader
         package="{{ 'stimulus'|ux_package }}"
diff --git a/ux.symfony.com/templates/ux_packages/turbo.html.twig b/ux.symfony.com/templates/ux_packages/turbo.html.twig
index d16863ca..f54cacf1 100644
--- a/ux.symfony.com/templates/ux_packages/turbo.html.twig
+++ b/ux.symfony.com/templates/ux_packages/turbo.html.twig
@@ -1,9 +1,5 @@
 {% extends 'ux_packages/package.html.twig' %}
 
-{% block banner %}
-    {{ include('_banner.html.twig', {color_back: '#331C50'}) }}
-{% endblock %}
-
 {% block package_header %}
     {% component PackageHeader with {
         package: 'turbo',
diff --git a/ux.symfony.com/templates/ux_packages/twig_component.html.twig b/ux.symfony.com/templates/ux_packages/twig_component.html.twig
index bae2a5a4..91da8abb 100644
--- a/ux.symfony.com/templates/ux_packages/twig_component.html.twig
+++ b/ux.symfony.com/templates/ux_packages/twig_component.html.twig
@@ -1,9 +1,5 @@
 {% extends 'ux_packages/package.html.twig' %}
 
-{% block banner %}
-    {{ include('_banner.html.twig', {color_back: '#4a5d20'}) }}
-{% endblock %}
-
 {% block package_header %}
     {% component PackageHeader with {
         package: 'twig-component',


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index d0efd220..24dd1d62 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -17,9 +17,16 @@ jobs:
         steps:
             - uses: actions/checkout@v4
             - run: corepack enable
-            - uses: actions/setup-node@v4
+            - name: Get yarn cache directory path
+              id: yarn-cache-dir-path
+              run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
+            - uses: actions/cache@v4
+              id: yarn-cache
               with:
-                cache: 'yarn'
+                  path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+                  key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }}
+                  restore-keys: |
+                      ${{ runner.os }}-yarn-
             - run: yarn --immutable
             - run: yarn ci
 
@@ -29,24 +36,32 @@ jobs:
         steps:
             - uses: actions/checkout@v4
             - run: corepack enable
-            - uses: actions/setup-node@v4
+            - name: Get yarn cache directory path
+              id: yarn-cache-dir-path
+              run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
+            - uses: actions/cache@v4
+              id: yarn-cache
               with:
-                cache: 'yarn'
-                cache-dependency-path: |
-                  yarn.lock
-                  **/package.json
+                  path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+                  key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }}
+                  restore-keys: |
+                      ${{ runner.os }}-yarn-
             - run: yarn --immutable && yarn build
+            - name: Check if js dist files are current
+              id: changes
+              run: |
+                echo "STATUS=$(git status --porcelain)" >> $GITHUB_OUTPUT
 
-            - name: Check if JS dist files are current
+            - name: No changes found
+              if: steps.changes.outputs.STATUS == ''
+              run: |
+                echo "git status is clean"
+            - name: Changes were found
+              if: steps.changes.outputs.STATUS != ''
               run: |
-                if [[ -n $(git status --porcelain) ]]; then
-                  echo "The Git workspace is unclean! Changes detected:"
-                  git status --porcelain
-                  git diff
-                  exit 1
-                else
-                  echo "The Git workspace is clean. No changes detected."
-                fi
+                echo "JS dist files need to be rebuilt"
+                echo "${{ steps.changes.outputs.STATUS }}"
+                exit 1
 
     tests-php-components:
         runs-on: ubuntu-latest
@@ -119,12 +134,16 @@ jobs:
         steps:
             - uses: actions/checkout@v4
             - run: corepack enable
-            - uses: actions/setup-node@v4
+            - name: Get yarn cache directory path
+              id: yarn-cache-dir-path
+              run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
+            - uses: actions/cache@v4
+              id: yarn-cache
               with:
-                cache: 'yarn'
-                cache-dependency-path: |
-                  yarn.lock
-                  **/package.json
+                  path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+                  key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }}
+                  restore-keys: |
+                      ${{ runner.os }}-yarn-
             - run: yarn --immutable
             - run: yarn playwright install
             - run: yarn test
diff --git a/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php b/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php
index f2a0e322..59537991 100644
--- a/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php
+++ b/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php
@@ -65,6 +65,15 @@ class FormWithManyDifferentFieldsType extends AbstractType
                     'foo' => 1,
                 ],
             ])
+            ->add('choice_required_without_placeholder_and_choice_group', ChoiceType::class, [
+                'choices' => [
+                    'Bar Group' => [
+                        'Bar Label' => 'ok',
+                        'Foo Label' => 'foo_value',
+                    ],
+                    'foo' => 1,
+                ],
+            ])
             ->add('choice_expanded', ChoiceType::class, [
                 'choices' => [
                     'foo' => 1,
diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
index a3ed0f7c..bfbe477c 100644
--- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
+++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
@@ -181,6 +181,7 @@ class ComponentWithFormTest extends KernelTestCase
             'choice_required_with_placeholder' => '',
             'choice_required_with_empty_placeholder' => '',
             'choice_required_without_placeholder' => '2',
+            'choice_required_without_placeholder_and_choice_group' => 'ok',
             'choice_expanded' => '',
             'choice_multiple' => ['2'],
             'select_multiple' => ['2'],
diff --git a/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php
index 931430d8..a7062605 100644
--- a/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php
+++ b/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php
@@ -46,6 +46,7 @@ class ComponentWithFormTest extends KernelTestCase
                 'choice_required_with_placeholder' => '',
                 'choice_required_with_empty_placeholder' => '',
                 'choice_required_without_placeholder' => '2',
+                'choice_required_without_placeholder_and_choice_group' => 'ok',
                 'choice_expanded' => '',
                 'choice_multiple' => ['2'],
                 'select_multiple' => ['2'],