Skip to content

Commit 7ebbc3a

Browse files
committed
bug #3155 [Autocomplete] Fix issue where TomSelect could already been initialized on an element (Marcus Stöhr)
This PR was merged into the 2.x branch. Discussion ---------- [Autocomplete] Fix issue where TomSelect could already been initialized on an element | Q | A | -------------- | --- | Bug fix? | yes | New feature? | no <!-- please update src/**/CHANGELOG.md files --> | Deprecations? | no <!-- if yes, also update UPGRADE-*.md and src/**/CHANGELOG.md --> | Documentation? | no <!-- required for new features, or documentation updates --> | Issues | Fix #2623 <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT **Problem** When Autocomplete elements are removed and re-added to the DOM (e.g., with Turbo navigation), a race condition causes "Tom Select already initialized on this element" error. **Root Cause** `disconnect()` destroys TomSelect but leaves `this.tomSelect` pointing to the destroyed instance. If `urlValueChanged()` fires before `connect()` (during re-attachment), it calls `resetTomSelect()` on the stale reference, triggering double-initialization. **Impact** - Affects: Users with Turbo navigation, especially on mobile (Safari iOS) - Symptoms: "Tom Select already initialized" console errors during navigation - Fix: Single line addition prevents the race condition - Risk: None - only affects the disconnect cleanup path Commits ------- 6e44ad9 [Autocomplete] Fix issue where TomSelect could already been initialized on an element
2 parents 0933778 + 6e44ad9 commit 7ebbc3a

21 files changed

+707
-24
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
import { getComponent } from '@symfony/ux-live-component';
3+
4+
export default class extends Controller {
5+
async connect() {
6+
this.component = await getComponent(this.element.closest('[data-controller*="live"]'));
7+
8+
this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this));
9+
this.element.addEventListener('autocomplete:connect', this._onConnect.bind(this));
10+
}
11+
12+
disconnect() {
13+
this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this));
14+
this.element.removeEventListener('autocomplete:connect', this._onConnect.bind(this));
15+
}
16+
17+
_onPreConnect(event) {
18+
const options = event.detail.options;
19+
options.render = {
20+
...options.render,
21+
option: (item) => {
22+
return `<div data-test-id="autocomplete-option" data-title="${item.title || item.text}">${item.text}</div>`;
23+
},
24+
};
25+
}
26+
27+
_onConnect(event) {
28+
const tomSelect = event.detail.tomSelect;
29+
30+
tomSelect.on('item_add', (value, item) => {
31+
const title = item.getAttribute('data-title') || item.textContent;
32+
this.component.emit('movie-selected', { title });
33+
});
34+
}
35+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
import { getComponent } from '@symfony/ux-live-component';
3+
4+
export default class extends Controller {
5+
async connect() {
6+
this.component = await getComponent(this.element.closest('[data-controller*="live"]'));
7+
8+
this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this));
9+
this.element.addEventListener('autocomplete:connect', this._onConnect.bind(this));
10+
}
11+
12+
disconnect() {
13+
this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect.bind(this));
14+
this.element.removeEventListener('autocomplete:connect', this._onConnect.bind(this));
15+
}
16+
17+
_onPreConnect(event) {
18+
const options = event.detail.options;
19+
options.render = {
20+
...options.render,
21+
option: (item) => {
22+
return `<div data-test-id="autocomplete-option" data-title="${item.title || item.text}">${item.text}</div>`;
23+
},
24+
};
25+
}
26+
27+
_onConnect(event) {
28+
const tomSelect = event.detail.tomSelect;
29+
30+
tomSelect.on('item_add', (value, item) => {
31+
const title = item.getAttribute('data-title') || item.textContent;
32+
this.component.emit('videogame-selected', { title });
33+
});
34+
}
35+
}

apps/e2e/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"symfony/ux-typed": "^2.29.1",
6262
"symfony/ux-vue": "^2.29.1",
6363
"symfony/yaml": "6.4.*|7.3.*",
64+
"symfonycasts/dynamic-forms": "^0.2",
6465
"twig/extra-bundle": "^3.21",
6566
"twig/twig": "^3.21.1"
6667
},

apps/e2e/src/Controller/AutocompleteController.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,27 @@
22

33
namespace App\Controller;
44

5-
use Psr\Log\LoggerInterface;
5+
use App\Form\Type\AutocompleteSelectType;
66
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
7-
use Symfony\Component\HttpFoundation\Response;
87
use Symfony\Component\Routing\Attribute\Route;
9-
use Symfony\UX\Chartjs\Builder\ChartBuilderInterface;
10-
use Symfony\UX\Chartjs\Model\Chart;
118

129
#[Route('/ux-autocomplete')]
1310
final class AutocompleteController extends AbstractController
1411
{
12+
#[Route('/without-ajax')]
13+
public function index()
14+
{
15+
$form = $this->createForm(AutocompleteSelectType::class);
16+
17+
return $this->render(
18+
'ux_autocomplete/index.html.twig',
19+
['form' => $form->createView()]
20+
);
21+
}
22+
23+
#[Route('/custom-controller')]
24+
public function customController()
25+
{
26+
return $this->render('ux_autocomplete/custom_controller.html.twig');
27+
}
1528
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
namespace App\Controller;
4+
5+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
6+
use Symfony\Component\HttpFoundation\JsonResponse;
7+
use Symfony\Component\HttpFoundation\Request;
8+
use Symfony\Component\HttpFoundation\Response;
9+
use Symfony\Component\Routing\Attribute\Route;
10+
11+
#[Route('/test')]
12+
final class TestAutocompleteController extends AbstractController
13+
{
14+
#[Route('/autocomplete-dynamic-form', name: 'test_autocomplete_dynamic_form')]
15+
public function dynamicForm(): Response
16+
{
17+
return $this->render('test/autocomplete_dynamic_form.html.twig');
18+
}
19+
20+
#[Route('/autocomplete/movie', name: 'test_autocomplete_movie')]
21+
public function movieAutocomplete(Request $request): JsonResponse
22+
{
23+
$query = $request->query->get('query', '');
24+
25+
$movies = [
26+
['value' => 'movie_1', 'text' => 'The Matrix (1999)', 'title' => 'movie Movie #1'],
27+
['value' => 'movie_2', 'text' => 'Inception (2010)', 'title' => 'movie Movie #2'],
28+
['value' => 'movie_3', 'text' => 'The Dark Knight (2008)', 'title' => 'movie Movie #3'],
29+
['value' => 'movie_4', 'text' => 'Interstellar (2014)', 'title' => 'movie Movie #4'],
30+
['value' => 'movie_5', 'text' => 'Pulp Fiction (1994)', 'title' => 'movie Movie #5'],
31+
];
32+
33+
$results = array_filter($movies, function ($movie) use ($query) {
34+
return '' === $query || false !== stripos($movie['text'], $query);
35+
});
36+
37+
return $this->json([
38+
'results' => array_values($results),
39+
]);
40+
}
41+
42+
#[Route('/autocomplete/videogame', name: 'test_autocomplete_videogame')]
43+
public function videogameAutocomplete(Request $request): JsonResponse
44+
{
45+
$query = $request->query->get('query', '');
46+
47+
$games = [
48+
['value' => 'videogame_1', 'text' => 'Halo: Combat Evolved (2001)', 'title' => 'videogame Game #1'],
49+
['value' => 'videogame_2', 'text' => 'The Legend of Zelda (1986)', 'title' => 'videogame Game #2'],
50+
['value' => 'videogame_3', 'text' => 'Half-Life 2 (2004)', 'title' => 'videogame Game #3'],
51+
['value' => 'videogame_4', 'text' => 'Portal (2007)', 'title' => 'videogame Game #4'],
52+
['value' => 'videogame_5', 'text' => 'Mass Effect 2 (2010)', 'title' => 'videogame Game #5'],
53+
];
54+
55+
$results = array_filter($games, function ($game) use ($query) {
56+
return '' === $query || false !== stripos($game['text'], $query);
57+
});
58+
59+
return $this->json([
60+
'results' => array_values($results),
61+
]);
62+
}
63+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Form\Model;
4+
5+
class ProductionDto
6+
{
7+
public ?string $type = null;
8+
9+
public ?string $movieSearch = null;
10+
11+
public ?string $videogameSearch = null;
12+
13+
public ?string $title = null;
14+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace App\Form\Type;
4+
5+
use Symfony\Component\Form\AbstractType;
6+
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
7+
use Symfony\Component\Form\FormBuilderInterface;
8+
9+
class AutocompleteSelectType extends AbstractType
10+
{
11+
public function buildForm(FormBuilderInterface $builder, array $options): void
12+
{
13+
$builder->add(
14+
'favorite_fruit',
15+
ChoiceType::class,
16+
[
17+
'choices' => [
18+
'Apple' => 'apple',
19+
'Banana' => 'banana',
20+
'Cherry' => 'cherry',
21+
'Coconut' => 'coconut',
22+
'Grape' => 'grape',
23+
'Kiwi' => 'kiwi',
24+
'Lemon' => 'lemon',
25+
'Mango' => 'mango',
26+
'Orange' => 'orange',
27+
'Papaya' => 'papaya',
28+
'Peach' => 'peach',
29+
'Pineapple' => 'pineapple',
30+
'Pear' => 'pear',
31+
'Pomegranate' => 'pomegranate',
32+
'Pomelo' => 'pomelo',
33+
'Raspberry' => 'raspberry',
34+
'Strawberry' => 'strawberry',
35+
'Watermelon' => 'watermelon',
36+
],
37+
'autocomplete' => true,
38+
'label' => 'Your favorite fruit:'
39+
]
40+
);
41+
}
42+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace App\Form\Type;
4+
5+
use Symfony\Component\Form\AbstractType;
6+
use Symfony\Component\Form\Extension\Core\Type\TextType;
7+
use Symfony\Component\OptionsResolver\OptionsResolver;
8+
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
9+
10+
class MovieAutocompleteType extends AbstractType
11+
{
12+
public function __construct(
13+
private UrlGeneratorInterface $urlGenerator
14+
) {
15+
}
16+
17+
public function configureOptions(OptionsResolver $resolver): void
18+
{
19+
$resolver->setDefaults([
20+
'autocomplete' => true,
21+
'autocomplete_url' => $this->urlGenerator->generate('test_autocomplete_movie'),
22+
'tom_select_options' => [
23+
'maxOptions' => null,
24+
],
25+
'attr' => [
26+
'data-test-id' => 'movie-autocomplete',
27+
'data-controller' => 'movie-autocomplete',
28+
],
29+
]);
30+
}
31+
32+
public function getParent(): string
33+
{
34+
return TextType::class;
35+
}
36+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
namespace App\Form\Type;
4+
5+
use App\Form\Model\ProductionDto;
6+
use Symfony\Component\Form\AbstractType;
7+
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
8+
use Symfony\Component\Form\Extension\Core\Type\TextType;
9+
use Symfony\Component\Form\FormBuilderInterface;
10+
use Symfony\Component\OptionsResolver\OptionsResolver;
11+
use Symfonycasts\DynamicForms\DependentField;
12+
use Symfonycasts\DynamicForms\DynamicFormBuilder;
13+
14+
class ProductionType extends AbstractType
15+
{
16+
public function buildForm(FormBuilderInterface $builder, array $options): void
17+
{
18+
$builder = new DynamicFormBuilder($builder);
19+
20+
$builder
21+
->add('type', ChoiceType::class, [
22+
'choices' => [
23+
'Movie' => 'movie',
24+
'Videogame' => 'videogame',
25+
],
26+
'placeholder' => 'Select a type',
27+
'attr' => [
28+
'data-test-id' => 'production-type',
29+
],
30+
])
31+
->addDependent('movieSearch', ['type'], function (DependentField $field, ?string $type) {
32+
if ('movie' !== $type) {
33+
return;
34+
}
35+
36+
$field->add(MovieAutocompleteType::class, [
37+
'label' => 'Search Movies',
38+
'required' => false,
39+
]);
40+
})
41+
->addDependent('videogameSearch', ['type'], function (DependentField $field, ?string $type) {
42+
if ('videogame' !== $type) {
43+
return;
44+
}
45+
46+
$field->add(VideogameAutocompleteType::class, [
47+
'label' => 'Search Videogames',
48+
'required' => false,
49+
]);
50+
})
51+
->add('title', TextType::class, [
52+
'required' => false,
53+
'attr' => [
54+
'data-test-id' => 'production-title',
55+
],
56+
])
57+
;
58+
}
59+
60+
public function configureOptions(OptionsResolver $resolver): void
61+
{
62+
$resolver->setDefaults([
63+
'data_class' => ProductionDto::class,
64+
]);
65+
}
66+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace App\Form\Type;
4+
5+
use Symfony\Component\Form\AbstractType;
6+
use Symfony\Component\Form\Extension\Core\Type\TextType;
7+
use Symfony\Component\OptionsResolver\OptionsResolver;
8+
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
9+
10+
class VideogameAutocompleteType extends AbstractType
11+
{
12+
public function __construct(
13+
private UrlGeneratorInterface $urlGenerator
14+
) {
15+
}
16+
17+
public function configureOptions(OptionsResolver $resolver): void
18+
{
19+
$resolver->setDefaults([
20+
'autocomplete' => true,
21+
'autocomplete_url' => $this->urlGenerator->generate('test_autocomplete_videogame'),
22+
'tom_select_options' => [
23+
'maxOptions' => null,
24+
],
25+
'attr' => [
26+
'data-test-id' => 'videogame-autocomplete',
27+
'data-controller' => 'videogame-autocomplete',
28+
],
29+
]);
30+
}
31+
32+
public function getParent(): string
33+
{
34+
return TextType::class;
35+
}
36+
}

0 commit comments

Comments
 (0)