diff --git a/apps/e2e/README.md b/apps/e2e/README.md
index 4795b30333d..db1d43cdec9 100644
--- a/apps/e2e/README.md
+++ b/apps/e2e/README.md
@@ -16,7 +16,7 @@ to ensure they work as expected for multiple Symfony versions and various browse
```shell
docker compose up -d
-symfony php ../.github/build-packages.php
+symfony php ../../.github/build-packages.php
SYMFONY_REQUIRE=6.4.* symfony composer update
# or...
diff --git a/apps/e2e/assets/controllers/movie-autocomplete_controller.js b/apps/e2e/assets/controllers/movie-autocomplete_controller.js
new file mode 100644
index 00000000000..be8682958c3
--- /dev/null
+++ b/apps/e2e/assets/controllers/movie-autocomplete_controller.js
@@ -0,0 +1,35 @@
+import { Controller } from '@hotwired/stimulus';
+import { getComponent } from '@symfony/ux-live-component';
+
+export default class extends Controller {
+ async connect() {
+ this.component = await getComponent(this.element.closest('[data-controller*="live"]'));
+
+ this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this));
+ this.element.addEventListener('autocomplete:connect', this._onConnect.bind(this));
+ }
+
+ disconnect() {
+ this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this));
+ this.element.removeEventListener('autocomplete:connect', this._onConnect.bind(this));
+ }
+
+ _onPreConnect(event) {
+ const options = event.detail.options;
+ options.render = {
+ ...options.render,
+ option: (item) => {
+ return `
${item.text}
`;
+ },
+ };
+ }
+
+ _onConnect(event) {
+ const tomSelect = event.detail.tomSelect;
+
+ tomSelect.on('item_add', (value, item) => {
+ const title = item.getAttribute('data-title') || item.textContent;
+ this.component.emit('movie-selected', { title });
+ });
+ }
+}
diff --git a/apps/e2e/assets/controllers/videogame-autocomplete_controller.js b/apps/e2e/assets/controllers/videogame-autocomplete_controller.js
new file mode 100644
index 00000000000..0b30e1c1eba
--- /dev/null
+++ b/apps/e2e/assets/controllers/videogame-autocomplete_controller.js
@@ -0,0 +1,35 @@
+import { Controller } from '@hotwired/stimulus';
+import { getComponent } from '@symfony/ux-live-component';
+
+export default class extends Controller {
+ async connect() {
+ this.component = await getComponent(this.element.closest('[data-controller*="live"]'));
+
+ this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this));
+ this.element.addEventListener('autocomplete:connect', this._onConnect.bind(this));
+ }
+
+ disconnect() {
+ this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this));
+ this.element.removeEventListener('autocomplete:connect', this._onConnect.bind(this));
+ }
+
+ _onPreConnect(event) {
+ const options = event.detail.options;
+ options.render = {
+ ...options.render,
+ option: (item) => {
+ return `${item.text}
`;
+ },
+ };
+ }
+
+ _onConnect(event) {
+ const tomSelect = event.detail.tomSelect;
+
+ tomSelect.on('item_add', (value, item) => {
+ const title = item.getAttribute('data-title') || item.textContent;
+ this.component.emit('videogame-selected', { title });
+ });
+ }
+}
diff --git a/apps/e2e/composer.json b/apps/e2e/composer.json
index b22dc2bc498..f0fa6f37408 100644
--- a/apps/e2e/composer.json
+++ b/apps/e2e/composer.json
@@ -61,6 +61,7 @@
"symfony/ux-typed": "^2.29.1",
"symfony/ux-vue": "^2.29.1",
"symfony/yaml": "6.4.*|7.3.*",
+ "symfonycasts/dynamic-forms": "^0.2",
"twig/extra-bundle": "^3.21",
"twig/twig": "^3.21.1"
},
diff --git a/apps/e2e/src/Controller/AutocompleteController.php b/apps/e2e/src/Controller/AutocompleteController.php
index 42d875fe685..eb338ebd02a 100644
--- a/apps/e2e/src/Controller/AutocompleteController.php
+++ b/apps/e2e/src/Controller/AutocompleteController.php
@@ -2,14 +2,27 @@
namespace App\Controller;
-use Psr\Log\LoggerInterface;
+use App\Form\Type\AutocompleteSelectType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
-use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
-use Symfony\UX\Chartjs\Builder\ChartBuilderInterface;
-use Symfony\UX\Chartjs\Model\Chart;
#[Route('/ux-autocomplete')]
final class AutocompleteController extends AbstractController
{
+ #[Route('/non-ajax')]
+ public function index()
+ {
+ $form = $this->createForm(AutocompleteSelectType::class);
+
+ return $this->render(
+ 'ux_autocomplete/index.html.twig',
+ ['form' => $form->createView()]
+ );
+ }
+
+ #[Route('/custom-controller')]
+ public function customController()
+ {
+ return $this->render('ux_autocomplete/custom_controller.html.twig');
+ }
}
diff --git a/apps/e2e/src/Controller/TestAutocompleteController.php b/apps/e2e/src/Controller/TestAutocompleteController.php
new file mode 100644
index 00000000000..72168a638e8
--- /dev/null
+++ b/apps/e2e/src/Controller/TestAutocompleteController.php
@@ -0,0 +1,63 @@
+render('test/autocomplete_dynamic_form.html.twig');
+ }
+
+ #[Route('/autocomplete/movie', name: 'test_autocomplete_movie')]
+ public function movieAutocomplete(Request $request): JsonResponse
+ {
+ $query = $request->query->get('query', '');
+
+ $movies = [
+ ['value' => 'movie_1', 'text' => 'The Matrix (1999)', 'title' => 'movie Movie #1'],
+ ['value' => 'movie_2', 'text' => 'Inception (2010)', 'title' => 'movie Movie #2'],
+ ['value' => 'movie_3', 'text' => 'The Dark Knight (2008)', 'title' => 'movie Movie #3'],
+ ['value' => 'movie_4', 'text' => 'Interstellar (2014)', 'title' => 'movie Movie #4'],
+ ['value' => 'movie_5', 'text' => 'Pulp Fiction (1994)', 'title' => 'movie Movie #5'],
+ ];
+
+ $results = array_filter($movies, function ($movie) use ($query) {
+ return '' === $query || false !== stripos($movie['text'], $query);
+ });
+
+ return $this->json([
+ 'results' => array_values($results),
+ ]);
+ }
+
+ #[Route('/autocomplete/videogame', name: 'test_autocomplete_videogame')]
+ public function videogameAutocomplete(Request $request): JsonResponse
+ {
+ $query = $request->query->get('query', '');
+
+ $games = [
+ ['value' => 'videogame_1', 'text' => 'Halo: Combat Evolved (2001)', 'title' => 'videogame Game #1'],
+ ['value' => 'videogame_2', 'text' => 'The Legend of Zelda (1986)', 'title' => 'videogame Game #2'],
+ ['value' => 'videogame_3', 'text' => 'Half-Life 2 (2004)', 'title' => 'videogame Game #3'],
+ ['value' => 'videogame_4', 'text' => 'Portal (2007)', 'title' => 'videogame Game #4'],
+ ['value' => 'videogame_5', 'text' => 'Mass Effect 2 (2010)', 'title' => 'videogame Game #5'],
+ ];
+
+ $results = array_filter($games, function ($game) use ($query) {
+ return '' === $query || false !== stripos($game['text'], $query);
+ });
+
+ return $this->json([
+ 'results' => array_values($results),
+ ]);
+ }
+}
diff --git a/apps/e2e/src/Form/Model/ProductionDto.php b/apps/e2e/src/Form/Model/ProductionDto.php
new file mode 100644
index 00000000000..8c6f2c028d2
--- /dev/null
+++ b/apps/e2e/src/Form/Model/ProductionDto.php
@@ -0,0 +1,14 @@
+add(
+ 'favorite_fruit',
+ ChoiceType::class,
+ [
+ 'choices' => [
+ 'Apple' => 'apple',
+ 'Banana' => 'banana',
+ 'Cherry' => 'cherry',
+ 'Coconut' => 'coconut',
+ 'Grape' => 'grape',
+ 'Kiwi' => 'kiwi',
+ 'Lemon' => 'lemon',
+ 'Mango' => 'mango',
+ 'Orange' => 'orange',
+ 'Papaya' => 'papaya',
+ 'Peach' => 'peach',
+ 'Pineapple' => 'pineapple',
+ 'Pear' => 'pear',
+ 'Pomegranate' => 'pomegranate',
+ 'Pomelo' => 'pomelo',
+ 'Raspberry' => 'raspberry',
+ 'Strawberry' => 'strawberry',
+ 'Watermelon' => 'watermelon',
+ ],
+ 'autocomplete' => true,
+ 'label' => 'Your favorite fruit:'
+ ]
+ );
+ }
+}
diff --git a/apps/e2e/src/Form/Type/MovieAutocompleteType.php b/apps/e2e/src/Form/Type/MovieAutocompleteType.php
new file mode 100644
index 00000000000..e3326948fde
--- /dev/null
+++ b/apps/e2e/src/Form/Type/MovieAutocompleteType.php
@@ -0,0 +1,36 @@
+setDefaults([
+ 'autocomplete' => true,
+ 'autocomplete_url' => $this->urlGenerator->generate('test_autocomplete_movie'),
+ 'tom_select_options' => [
+ 'maxOptions' => null,
+ ],
+ 'attr' => [
+ 'data-test-id' => 'movie-autocomplete',
+ 'data-controller' => 'movie-autocomplete',
+ ],
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return TextType::class;
+ }
+}
diff --git a/apps/e2e/src/Form/Type/ProductionType.php b/apps/e2e/src/Form/Type/ProductionType.php
new file mode 100644
index 00000000000..137148e56df
--- /dev/null
+++ b/apps/e2e/src/Form/Type/ProductionType.php
@@ -0,0 +1,66 @@
+add('type', ChoiceType::class, [
+ 'choices' => [
+ 'Movie' => 'movie',
+ 'Videogame' => 'videogame',
+ ],
+ 'placeholder' => 'Select a type',
+ 'attr' => [
+ 'data-test-id' => 'production-type',
+ ],
+ ])
+ ->addDependent('movieSearch', ['type'], function (DependentField $field, ?string $type) {
+ if ('movie' !== $type) {
+ return;
+ }
+
+ $field->add(MovieAutocompleteType::class, [
+ 'label' => 'Search Movies',
+ 'required' => false,
+ ]);
+ })
+ ->addDependent('videogameSearch', ['type'], function (DependentField $field, ?string $type) {
+ if ('videogame' !== $type) {
+ return;
+ }
+
+ $field->add(VideogameAutocompleteType::class, [
+ 'label' => 'Search Videogames',
+ 'required' => false,
+ ]);
+ })
+ ->add('title', TextType::class, [
+ 'required' => false,
+ 'attr' => [
+ 'data-test-id' => 'production-title',
+ ],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => ProductionDto::class,
+ ]);
+ }
+}
diff --git a/apps/e2e/src/Form/Type/VideogameAutocompleteType.php b/apps/e2e/src/Form/Type/VideogameAutocompleteType.php
new file mode 100644
index 00000000000..3d7a2cb445d
--- /dev/null
+++ b/apps/e2e/src/Form/Type/VideogameAutocompleteType.php
@@ -0,0 +1,36 @@
+setDefaults([
+ 'autocomplete' => true,
+ 'autocomplete_url' => $this->urlGenerator->generate('test_autocomplete_videogame'),
+ 'tom_select_options' => [
+ 'maxOptions' => null,
+ ],
+ 'attr' => [
+ 'data-test-id' => 'videogame-autocomplete',
+ 'data-controller' => 'videogame-autocomplete',
+ ],
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return TextType::class;
+ }
+}
diff --git a/apps/e2e/src/Repository/ExampleRepository.php b/apps/e2e/src/Repository/ExampleRepository.php
index 51f3e2a3f9b..97565ee076d 100644
--- a/apps/e2e/src/Repository/ExampleRepository.php
+++ b/apps/e2e/src/Repository/ExampleRepository.php
@@ -21,8 +21,11 @@ class ExampleRepository
*/
private array $examples;
- public function __construct() {
+ public function __construct()
+ {
$this->examples = [
+ new Example(UxPackage::Autocomplete, 'Autocomplete (non-ajax)', 'An autocomplete component to enhance a simple choice field.', '/ux-autocomplete/non-ajax'),
+ new Example(UxPackage::Autocomplete, 'Autocomplete (custom controller)', 'An autocomplete component with a custom Stimulus controller for AJAX results.', '/ux-autocomplete/custom-controller'),
new Example(UxPackage::Map, 'Basic map (Leaflet)', 'A basic map centered on Paris with zoom level 12', '/ux-map/basic?renderer=leaflet'),
new Example(UxPackage::Map, 'Basic map (Google)', 'A basic map centered on Paris with zoom level 12', '/ux-map/basic?renderer=google'),
new Example(UxPackage::Map, 'With markers, fit bounds (Leaflet)', 'A map with 2 markers, and the bounds are automatically adjusted to fit both markers', '/ux-map/with-markers-and-fit-bounds-to-markers?renderer=leaflet'),
@@ -43,14 +46,14 @@ public function __construct() {
new Example(UxPackage::Map, 'With rectangles (Google)', 'A map with two rectangles: one from Paris to Lille, the other from Lyon to Bordeaux', '/ux-map/with-rectangles?renderer=google'),
new Example(UxPackage::React, 'Basic React Component', 'A basic React component that displays a welcoming message', '/ux-react/'),
new Example(UxPackage::Svelte, 'Basic Svelte Component', 'A basic Svelte component that displays a welcoming message', '/ux-svelte/'),
- new Example(UxPackage::Translator, 'Basic translation', 'A simple translation example using the Translator component', '/ux-translator/basic'),
- new Example(UxPackage::Translator, 'Translation with parameter', 'Translation example with dynamic parameters', '/ux-translator/with-parameter'),
- new Example(UxPackage::Translator, 'ICU Translation with `select` argument', 'ICU message format example using the `select` argument', '/ux-translator/icu-select'),
- new Example(UxPackage::Translator, 'ICU Translation with `plural` argument', 'ICU message format example using the `plural` argument', '/ux-translator/icu-plural'),
- new Example(UxPackage::Translator, 'ICU Translation with `selectordinal` argument', 'ICU message format example using the `selectordinal` argument', '/ux-translator/icu-selectordinal'),
- new Example(UxPackage::Translator, 'ICU Translation with `date` and `time` arguments', 'ICU message format example using `date` and `time` arguments', '/ux-translator/icu-date-time'),
- new Example(UxPackage::Translator, 'ICU Translation with `number` and `percent` arguments', 'ICU message format example using `number` and `percent` arguments', '/ux-translator/icu-number-percent'),
- new Example(UxPackage::Translator, 'ICU Translation with `number` and `currency` arguments', 'ICU message format example using `number` and `currency` arguments', '/ux-translator/icu-number-currency'),
+ new Example(UxPackage::Translator, 'Basic translation', 'A simple translation example using the Translator component', '/ux-translator/basic'),
+ new Example(UxPackage::Translator, 'Translation with parameter', 'Translation example with dynamic parameters', '/ux-translator/with-parameter'),
+ new Example(UxPackage::Translator, 'ICU Translation with `select` argument', 'ICU message format example using the `select` argument', '/ux-translator/icu-select'),
+ new Example(UxPackage::Translator, 'ICU Translation with `plural` argument', 'ICU message format example using the `plural` argument', '/ux-translator/icu-plural'),
+ new Example(UxPackage::Translator, 'ICU Translation with `selectordinal` argument', 'ICU message format example using the `selectordinal` argument', '/ux-translator/icu-selectordinal'),
+ new Example(UxPackage::Translator, 'ICU Translation with `date` and `time` arguments', 'ICU message format example using `date` and `time` arguments', '/ux-translator/icu-date-time'),
+ new Example(UxPackage::Translator, 'ICU Translation with `number` and `percent` arguments', 'ICU message format example using `number` and `percent` arguments', '/ux-translator/icu-number-percent'),
+ new Example(UxPackage::Translator, 'ICU Translation with `number` and `currency` arguments', 'ICU message format example using `number` and `currency` arguments', '/ux-translator/icu-number-currency'),
new Example(UxPackage::Vue, 'Basic Vue Component', 'A basic Vue component that displays a welcoming message', '/ux-vue/'),
];
}
diff --git a/apps/e2e/src/Twig/Components/ProductionForm.php b/apps/e2e/src/Twig/Components/ProductionForm.php
new file mode 100644
index 00000000000..3c67094744f
--- /dev/null
+++ b/apps/e2e/src/Twig/Components/ProductionForm.php
@@ -0,0 +1,43 @@
+createForm(ProductionType::class, $this->initialFormData ?? new ProductionDto());
+ }
+
+ #[LiveListener('movie-selected')]
+ public function onMovieSelected(#[LiveArg] string $title): void
+ {
+ $this->formValues['title'] = $title;
+ }
+
+ #[LiveListener('videogame-selected')]
+ public function onVideogameSelected(#[LiveArg] string $title): void
+ {
+ $this->formValues['title'] = $title;
+ }
+}
diff --git a/apps/e2e/templates/components/ProductionForm.html.twig b/apps/e2e/templates/components/ProductionForm.html.twig
new file mode 100644
index 00000000000..09d4fc18916
--- /dev/null
+++ b/apps/e2e/templates/components/ProductionForm.html.twig
@@ -0,0 +1,33 @@
+
+ {{ form_start(form) }}
+
+ {{ form_label(form.type) }}
+ {{ form_widget(form.type) }}
+ {{ form_errors(form.type) }}
+
+
+ {% if form.movieSearch is defined %}
+
+ {{ form_label(form.movieSearch) }}
+ {{ form_widget(form.movieSearch) }}
+ {{ form_errors(form.movieSearch) }}
+
+ {% endif %}
+
+ {% if form.videogameSearch is defined %}
+
+ {{ form_label(form.videogameSearch) }}
+ {{ form_widget(form.videogameSearch) }}
+ {{ form_errors(form.videogameSearch) }}
+
+ {% endif %}
+
+
+ {{ form_label(form.title) }}
+ {{ form_widget(form.title) }}
+ {{ form_errors(form.title) }}
+
+
+
+ {{ form_end(form) }}
+
diff --git a/apps/e2e/templates/test/autocomplete_dynamic_form.html.twig b/apps/e2e/templates/test/autocomplete_dynamic_form.html.twig
new file mode 100644
index 00000000000..d46786dd6a6
--- /dev/null
+++ b/apps/e2e/templates/test/autocomplete_dynamic_form.html.twig
@@ -0,0 +1,12 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Autocomplete Dynamic Form Test{% endblock %}
+
+{% block main %}
+
+
Autocomplete with Dynamic Forms
+
This test page demonstrates dynamic autocomplete fields within a LiveComponent form.
+
+ {{ component('ProductionForm') }}
+
+{% endblock %}
diff --git a/apps/e2e/templates/ux_autocomplete/custom_controller.html.twig b/apps/e2e/templates/ux_autocomplete/custom_controller.html.twig
new file mode 100644
index 00000000000..7793a3d8d94
--- /dev/null
+++ b/apps/e2e/templates/ux_autocomplete/custom_controller.html.twig
@@ -0,0 +1,5 @@
+{% extends 'example.html.twig' %}
+
+{% block example %}
+ {{ component('ProductionForm') }}
+{% endblock %}
diff --git a/apps/e2e/templates/ux_autocomplete/index.html.twig b/apps/e2e/templates/ux_autocomplete/index.html.twig
index 78c01e96007..bc4d51d6b78 100644
--- a/apps/e2e/templates/ux_autocomplete/index.html.twig
+++ b/apps/e2e/templates/ux_autocomplete/index.html.twig
@@ -1,3 +1,8 @@
{% extends 'example.html.twig' %}
-{% block example %}{% endblock %}
+{% block example %}
+Autocomplete:
+ {{ form_start(form) }}
+ {{ form_widget(form.favorite_fruit) }}
+ {{ form_end(form) }}
+{% endblock %}
diff --git a/src/Autocomplete/assets/dist/controller.d.ts b/src/Autocomplete/assets/dist/controller.d.ts
index e03b841b16d..199f31b0414 100644
--- a/src/Autocomplete/assets/dist/controller.d.ts
+++ b/src/Autocomplete/assets/dist/controller.d.ts
@@ -32,7 +32,7 @@ declare class export_default extends Controller {
readonly tomSelectOptionsValue: object;
readonly hasPreloadValue: boolean;
readonly preloadValue: string;
- tomSelect: TomSelect;
+ tomSelect: TomSelect | undefined;
private mutationObserver;
private isObserving;
private hasLoadedChoicesPreviously;
diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js
index 6a60498e682..e061fca8cce 100644
--- a/src/Autocomplete/assets/dist/controller.js
+++ b/src/Autocomplete/assets/dist/controller.js
@@ -65,6 +65,9 @@ var controller_default = class extends Controller {
}
disconnect() {
this.stopMutationObserver();
+ if (!this.tomSelect) {
+ return;
+ }
let currentSelectedValues = [];
if (this.selectElement) {
if (this.selectElement.multiple) {
@@ -74,6 +77,7 @@ var controller_default = class extends Controller {
}
}
this.tomSelect.destroy();
+ this.tomSelect = void 0;
if (this.selectElement) {
if (this.selectElement.multiple) {
Array.from(this.selectElement.options).forEach((option) => {
@@ -136,6 +140,9 @@ var controller_default = class extends Controller {
}
}
changeTomSelectDisabledState(isDisabled) {
+ if (!this.tomSelect) {
+ return;
+ }
this.stopMutationObserver();
if (isDisabled) {
this.tomSelect.disable();
@@ -244,11 +251,14 @@ getCommonConfig_fn = function() {
plugins,
// clear the text input after selecting a value
onItemAdd: () => {
- this.tomSelect.setTextboxValue("");
+ this.tomSelect?.setTextboxValue("");
},
closeAfterSelect: true,
// fix positioning (in the dropdown) of options added through addOption()
onOptionAdd: (value, data) => {
+ if (!this.tomSelect) {
+ return;
+ }
let parentElement = this.tomSelect.input;
let optgroupData = null;
const optgroup = data[this.tomSelect.settings.optgroupField];
@@ -300,9 +310,9 @@ createAutocompleteWithHtmlContents_fn = function() {
const config = __privateMethod(this, _instances, mergeConfigs_fn).call(this, commonConfig, {
maxOptions: this.getMaxOptions(),
score: (search) => {
- const scoringFunction = this.tomSelect.getScoreFunction(search);
+ const scoringFunction = this.tomSelect?.getScoreFunction(search);
return (item) => {
- return scoringFunction({ ...item, text: __privateMethod(this, _instances, stripTags_fn).call(this, item[labelField]) });
+ return scoringFunction?.({ ...item, text: __privateMethod(this, _instances, stripTags_fn).call(this, item[labelField]) });
};
},
render: {
@@ -345,7 +355,7 @@ createAutocompleteWithRemoteData_fn = function(autocompleteEndpointUrl, minChara
},
optgroupField: "group_by",
// avoid extra filtering after results are returned
- score: (search) => (item) => 1,
+ score: (_search) => (_item) => 1,
render: {
option: (item) => `${item[labelField]}
`,
item: (item) => `${item[labelField]}
`,
diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts
index 593fba9994b..646e084bb1e 100644
--- a/src/Autocomplete/assets/src/controller.ts
+++ b/src/Autocomplete/assets/src/controller.ts
@@ -46,7 +46,7 @@ export default class extends Controller {
declare readonly tomSelectOptionsValue: object;
declare readonly hasPreloadValue: boolean;
declare readonly preloadValue: string;
- tomSelect: TomSelect;
+ tomSelect: TomSelect | undefined;
private mutationObserver: MutationObserver;
private isObserving = false;
@@ -98,6 +98,10 @@ export default class extends Controller {
disconnect() {
this.stopMutationObserver();
+ if (!this.tomSelect) {
+ return;
+ }
+
// TomSelect.destroy() resets the element to its original HTML. This
// causes the selected value to be lost. We store it.
let currentSelectedValues: string[] = [];
@@ -114,6 +118,7 @@ export default class extends Controller {
}
this.tomSelect.destroy();
+ this.tomSelect = undefined;
if (this.selectElement) {
if (this.selectElement.multiple) {
@@ -163,11 +168,15 @@ export default class extends Controller {
plugins,
// clear the text input after selecting a value
onItemAdd: () => {
- this.tomSelect.setTextboxValue('');
+ this.tomSelect?.setTextboxValue('');
},
closeAfterSelect: true,
// fix positioning (in the dropdown) of options added through addOption()
onOptionAdd: (value: string, data: { [key: string]: any }) => {
+ if (!this.tomSelect) {
+ return;
+ }
+
let parentElement = this.tomSelect.input as Element;
let optgroupData = null;
@@ -232,10 +241,10 @@ export default class extends Controller {
const config = this.#mergeConfigs(commonConfig, {
maxOptions: this.getMaxOptions(),
score: (search: string) => {
- const scoringFunction = this.tomSelect.getScoreFunction(search);
+ const scoringFunction = this.tomSelect?.getScoreFunction(search);
return (item: any) => {
// strip HTML tags from each option's searchable text
- return scoringFunction({ ...item, text: this.#stripTags(item[labelField]) });
+ return scoringFunction?.({ ...item, text: this.#stripTags(item[labelField]) });
};
},
render: {
@@ -295,7 +304,7 @@ export default class extends Controller {
},
optgroupField: 'group_by',
// avoid extra filtering after results are returned
- score: (search: string) => (item: any) => 1,
+ score: (_search: string) => (_item: any) => 1,
render: {
option: (item: any) => `${item[labelField]}
`,
item: (item: any) => `${item[labelField]}
`,
@@ -439,6 +448,10 @@ export default class extends Controller {
}
private changeTomSelectDisabledState(isDisabled: boolean): void {
+ if (!this.tomSelect) {
+ return;
+ }
+
this.stopMutationObserver();
if (isDisabled) {
this.tomSelect.disable();
diff --git a/src/Autocomplete/assets/test/browser/dynamic-form.test.ts b/src/Autocomplete/assets/test/browser/dynamic-form.test.ts
new file mode 100644
index 00000000000..ece6333021d
--- /dev/null
+++ b/src/Autocomplete/assets/test/browser/dynamic-form.test.ts
@@ -0,0 +1,125 @@
+import { expect, type Page, test } from '@playwright/test';
+
+async function typeInTomSelect(page: Page, testId: string, text: string) {
+ const wrapper = page.locator(`[data-test-id="${testId}"]`).locator('..');
+ const tsControl = wrapper.locator('.ts-control');
+ await tsControl.waitFor({ state: 'visible', timeout: 10000 });
+ await tsControl.click();
+ await tsControl.locator('input').fill(text);
+}
+
+async function waitForAutocomplete(page: Page, testId: string) {
+ const element = page.locator(`[data-test-id="${testId}"]`);
+ await element.waitFor({ state: 'attached', timeout: 10000 });
+ const wrapper = element.locator('..');
+ await wrapper.locator('.ts-control').waitFor({ state: 'visible', timeout: 10000 });
+}
+
+test.describe('Autocomplete with Dynamic Forms', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/test/autocomplete-dynamic-form');
+ await expect(page.locator('[data-test-id="test-page"]')).toBeVisible();
+ });
+
+ test('should not throw "Tom Select already initialized" error when switching between dynamic autocomplete fields', async ({
+ page,
+ }) => {
+ const consoleErrors: string[] = [];
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ consoleErrors.push(msg.text());
+ }
+ });
+
+ await page.selectOption('[data-test-id="production-type"]', 'movie');
+ await waitForAutocomplete(page, 'movie-autocomplete');
+
+ await typeInTomSelect(page, 'movie-autocomplete', 'Matrix');
+ await page.waitForTimeout(500);
+
+ const optionsAfterFirstFill = page.locator('[data-test-id="autocomplete-option"]');
+ if ((await optionsAfterFirstFill.count()) > 0) {
+ await optionsAfterFirstFill.first().click();
+ await page.waitForTimeout(1000);
+ }
+
+ await page.selectOption('[data-test-id="production-type"]', 'videogame');
+ await waitForAutocomplete(page, 'videogame-autocomplete');
+
+ await typeInTomSelect(page, 'videogame-autocomplete', 'Halo');
+ await page.waitForTimeout(500);
+
+ const optionsAfterSecondFill = page.locator('[data-test-id="autocomplete-option"]');
+ if ((await optionsAfterSecondFill.count()) > 0) {
+ await optionsAfterSecondFill.first().click();
+ }
+
+ await page.selectOption('[data-test-id="production-type"]', 'movie');
+ await waitForAutocomplete(page, 'movie-autocomplete');
+
+ await typeInTomSelect(page, 'movie-autocomplete', 'Inception');
+ await page.waitForTimeout(500);
+
+ const tomSelectError = consoleErrors.find((error) => error.includes('Tom Select already initialized'));
+
+ expect(tomSelectError).toBeUndefined();
+
+ await expect(page.locator('[data-test-id="autocomplete-option"]')).toHaveCount(1);
+ });
+
+ test('should properly disconnect and reconnect Tom Select on rapid type changes', async ({ page }) => {
+ const consoleErrors: string[] = [];
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ consoleErrors.push(msg.text());
+ }
+ });
+
+ for (let i = 0; i < 5; i++) {
+ await page.selectOption('[data-test-id="production-type"]', 'movie');
+ await page.waitForTimeout(100);
+ await page.selectOption('[data-test-id="production-type"]', 'videogame');
+ await page.waitForTimeout(100);
+ }
+
+ await page.selectOption('[data-test-id="production-type"]', 'movie');
+ await waitForAutocomplete(page, 'movie-autocomplete');
+
+ await typeInTomSelect(page, 'movie-autocomplete', 'Test');
+
+ expect(consoleErrors).toHaveLength(0);
+ });
+
+ test('should handle autocomplete in morphed LiveComponent without errors', async ({ page }) => {
+ const consoleErrors: string[] = [];
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ consoleErrors.push(msg.text());
+ }
+ });
+
+ await page.selectOption('[data-test-id="production-type"]', 'movie');
+ await waitForAutocomplete(page, 'movie-autocomplete');
+
+ await typeInTomSelect(page, 'movie-autocomplete', 'Matrix');
+ await page.waitForTimeout(500);
+
+ const dropdown = page.locator('.ts-dropdown');
+ await dropdown.waitFor({ state: 'visible', timeout: 5000 });
+
+ const firstOption = dropdown.locator('.option').first();
+ if (await firstOption.isVisible()) {
+ await firstOption.click();
+ }
+
+ await page.waitForTimeout(1000);
+
+ await typeInTomSelect(page, 'movie-autocomplete', 'Inception');
+ await page.waitForTimeout(1000);
+
+ expect(consoleErrors).toHaveLength(0);
+
+ const dropdownVisible = await dropdown.isVisible();
+ expect(dropdownVisible).toBe(true);
+ });
+});
diff --git a/src/Autocomplete/assets/test/unit/controller.test.ts b/src/Autocomplete/assets/test/unit/controller.test.ts
index 4d91b94b61a..aaada5e44b5 100644
--- a/src/Autocomplete/assets/test/unit/controller.test.ts
+++ b/src/Autocomplete/assets/test/unit/controller.test.ts
@@ -1113,4 +1113,97 @@ describe('AutocompleteController', () => {
'input_autogrow',
]);
});
+
+ it('disconnect() should clear the tomSelect reference to prevent double-initialization (issue #2623)', async () => {
+ // This test verifies the fix for issue #2623:
+ // When disconnect() is called, it MUST clear the this.tomSelect reference
+ // to prevent a subsequent urlValueChanged() from triggering resetTomSelect()
+ // on a stale (destroyed) TomSelect instance.
+ //
+ // In real-world scenarios with Turbo, this race condition manifests as:
+ // "Error: Tom Select already initialized on this element"
+ //
+ // The fix: this.tomSelect = undefined; after this.tomSelect.destroy();
+
+ const { container, tomSelect: firstTomSelect } = await startAutocompleteTest(`
+
+
+ `);
+
+ expect(firstTomSelect).not.toBeNull();
+ const selectElement = getByTestId(container, 'main-element') as HTMLSelectElement;
+
+ // Verify TomSelect is initialized
+ expect(container.querySelector('.ts-wrapper')).toBeInTheDocument();
+
+ // Simulate what happens during Turbo navigation:
+ // 1. Element is removed from DOM → disconnect() is called
+ selectElement.remove();
+ await shortDelay(50);
+
+ // At this point, with the fix: controller.tomSelect should be undefined
+ // Without the fix: controller.tomSelect still references destroyed instance
+ // If urlValueChanged() is called now, it would:
+ // - With fix: Exit early because if (!this.tomSelect) is true
+ // - Without fix: Try to reinitialize, causing double-initialization error
+
+ // 2. Element is re-added with changed attribute
+ // This is what Turbo does when restoring from cache with modified HTML
+ const newSelect = document.createElement('select');
+ newSelect.id = 'the-select';
+ newSelect.setAttribute('data-testid', 'main-element');
+ newSelect.setAttribute('data-controller', 'autocomplete');
+ // Changed URL triggers urlValueChanged() callback
+ newSelect.setAttribute('data-autocomplete-url-value', '/path/to/autocomplete-v2');
+ container.appendChild(newSelect);
+
+ // Setup for potential reconnection
+ fetchMock.mockResponseOnce(
+ JSON.stringify({
+ results: [{ value: 1, text: 'item' }],
+ })
+ );
+
+ let reconnectFailed = false;
+ let failureReason = '';
+
+ container.addEventListener('autocomplete:connect', () => {
+ // Reconnect event fired successfully
+ });
+
+ try {
+ // Wait for successful reconnection
+ await waitFor(
+ () => {
+ expect(newSelect).toBeInTheDocument();
+ },
+ { timeout: 2000 }
+ );
+ } catch (error: any) {
+ if (error.message?.includes('already initialized')) {
+ reconnectFailed = true;
+ failureReason = error.message;
+ }
+ }
+
+ // The critical assertion: reconnection must succeed
+ // If this fails with "already initialized", the fix is missing
+ expect(reconnectFailed).toBe(false);
+ if (reconnectFailed) {
+ throw new Error(
+ `Issue #2623 reproduced: ${failureReason}\n` +
+ 'The fix is missing: disconnect() must set this.tomSelect = undefined;'
+ );
+ }
+
+ // Verify reconnection completed
+ // (Note: In test environment, this may not always happen due to Stimulus lifecycle,
+ // but the absence of "already initialized" error is the key indicator)
+ expect(newSelect).toBeInTheDocument();
+ });
});