Skip to content

Commit c4791fc

Browse files
committed
[LiveComponent][Autocomplete] Adding support for libraries to play nicely together
This includes several important points: A) In autocomplete, data-live-ignore was added so that, when a "live component" re-renders, the element is not affected. B) For morphdom, the node key was changed from "id" to "data-live-id". This was due to a very complex "bad situation" that arised when autocomplete+live components. TomSelect finds the label for the element and give it an id attribute, which helps with the aria- attributes. However, this caused, on re-render, morphdom to think the "new label" did not match the old label. This small difference caused the elements in the parent div to be re-added one-by-one. Most importantly, a very innocent fromEl.appendChild(selectEl) is called deep in morphdom. In reality, selectEl already exists in fromEl... and the purpose of this call is actually just to help order things in the correct way. However, appendChild() caused the select element to momentarily be removed from the DOM then re-added. Stimulus noticed this and disconnected and then reconnected the autocomplete controller, causing the element to re-initialize (this, at the very least, would cause the element to lose focus). Anyways, this whole bad situation (which, is a bit poorly handled by morphdom), was caused by morphdom being confused by the "id" attribute on the label, which is present in the DOM but not in the re-rendered HTML. To avoid side effects like this, the nodeKey was changed to data-live-id. C) In live components, we also fixed a bug where if an element had data-live-ignore, it could be, incorrectly, removed on re-render if not present.
1 parent 16d8629 commit c4791fc

File tree

8 files changed

+139
-12
lines changed

8 files changed

+139
-12
lines changed

src/Autocomplete/assets/dist/controller.js

+14-3
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,16 @@ class default_1 extends Controller {
2828
super(...arguments);
2929
_instances.add(this);
3030
}
31-
connect() {
32-
if (this.tomSelect) {
33-
return;
31+
initialize() {
32+
this.element.setAttribute('data-live-ignore', '');
33+
if (this.element.id) {
34+
const label = document.querySelector(`label[for="${this.element.id}"]`);
35+
if (label) {
36+
label.setAttribute('data-live-ignore', '');
37+
}
3438
}
39+
}
40+
connect() {
3541
if (this.urlValue) {
3642
this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocompleteWithRemoteData).call(this, this.urlValue);
3743
return;
@@ -43,6 +49,7 @@ class default_1 extends Controller {
4349
this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocomplete).call(this);
4450
}
4551
disconnect() {
52+
this.tomSelect.revertSettings.innerHTML = this.element.innerHTML;
4653
this.tomSelect.destroy();
4754
}
4855
get selectElement() {
@@ -80,6 +87,10 @@ _instances = new WeakSet(), _getCommonConfig = function _getCommonConfig() {
8087
onItemAdd: () => {
8188
this.tomSelect.setTextboxValue('');
8289
},
90+
onInitialize: function () {
91+
const tomSelect = this;
92+
tomSelect.wrapper.setAttribute('data-live-ignore', '');
93+
},
8394
closeAfterSelect: true,
8495
};
8596
if (!this.selectElement && !this.urlValue) {

src/Autocomplete/assets/src/controller.ts

+16-7
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,17 @@ export default class extends Controller {
1818
readonly tomSelectOptionsValue: object;
1919
tomSelect: TomSelect;
2020

21-
connect() {
22-
// this avoids initializing the same field twice (TomSelect shows an error otherwise)
23-
if (this.tomSelect) {
24-
return;
21+
initialize() {
22+
this.element.setAttribute('data-live-ignore', '');
23+
if (this.element.id) {
24+
const label = document.querySelector(`label[for="${this.element.id}"]`);
25+
if (label) {
26+
label.setAttribute('data-live-ignore', '');
27+
}
2528
}
29+
}
2630

31+
connect() {
2732
if (this.urlValue) {
2833
this.tomSelect = this.#createAutocompleteWithRemoteData(this.urlValue);
2934

@@ -40,9 +45,9 @@ export default class extends Controller {
4045
}
4146

4247
disconnect() {
48+
// make sure it will "revert" to the latest innerHTML
49+
this.tomSelect.revertSettings.innerHTML = this.element.innerHTML;
4350
this.tomSelect.destroy();
44-
// Fixes https://github.com/symfony/ux/issues/407
45-
this.tomSelect = undefined;
4651
}
4752

4853
#getCommonConfig(): Partial<TomSettings> {
@@ -73,6 +78,10 @@ export default class extends Controller {
7378
onItemAdd: () => {
7479
this.tomSelect.setTextboxValue('');
7580
},
81+
onInitialize: function() {
82+
const tomSelect = this as any;
83+
tomSelect.wrapper.setAttribute('data-live-ignore', '');
84+
},
7685
closeAfterSelect: true,
7786
};
7887

@@ -124,7 +133,7 @@ export default class extends Controller {
124133
},
125134
// VERY IMPORTANT: use 'function (query, callback) { ... }' instead of the
126135
// '(query, callback) => { ... }' syntax because, otherwise,
127-
// the 'this.XXX' calls inside of this method fail
136+
// the 'this.XXX' calls inside this method fail
128137
load: function (query: string, callback: (results?: any) => void) {
129138
const url = this.getUrl(query);
130139
fetch(url)

src/Autocomplete/assets/test/controller.test.ts

+29-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
'use strict';
1111

1212
import { Application, Controller } from '@hotwired/stimulus';
13-
import { getByTestId, waitFor, getAllByLabelText } from '@testing-library/dom';
13+
import { getByTestId, waitFor } from '@testing-library/dom';
1414
import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
1515
import AutocompleteController from '../src/controller';
1616
import fetchMock from 'fetch-mock-jest';
@@ -137,4 +137,32 @@ describe('AutocompleteController', () => {
137137
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
138138
});
139139
});
140+
141+
it('adds live-component support', async () => {
142+
const container = mountDOM(`
143+
<div>
144+
<label for="the-select" data-testid="main-element-label">Select something</label>
145+
<select
146+
id="the-select"
147+
data-testid="main-element"
148+
data-controller="check autocomplete"
149+
></select>
150+
</div>
151+
`);
152+
153+
application = startStimulus();
154+
155+
await waitFor(() => {
156+
expect(getByTestId(container, 'main-element')).toHaveClass('connected');
157+
});
158+
159+
expect(getByTestId(container, 'main-element')).toHaveAttribute('data-live-ignore');
160+
expect(getByTestId(container, 'main-element-label')).toHaveAttribute('data-live-ignore');
161+
const tsDropdown = container.querySelector('.ts-wrapper');
162+
163+
await waitFor(() => {
164+
expect(tsDropdown).not.toBeNull();
165+
});
166+
expect(tsDropdown).toHaveAttribute('data-live-ignore');
167+
});
140168
});

src/LiveComponent/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## 2.3.1
44

5+
- [BC BREAK] Previously, the `id` attribute was used with `morphdom` as the
6+
"node id" when updating the DOM after a render. This has changed to
7+
`data-live-id`. This is useful when maintaining the correct order of a list
8+
of elements.
9+
510
- [BEHAVIOR CHANGE] If an action Ajax call is still processing and a
611
model update occurs, the component will _no_ longer re-render. The
712
model will be updated internally, but not re-rendered (so, any

src/LiveComponent/assets/dist/live_controller.js

+15
Original file line numberDiff line numberDiff line change
@@ -1599,6 +1599,12 @@ class default_1 extends Controller {
15991599
_executeMorphdom(newHtml, modifiedElements) {
16001600
const newElement = htmlToElement(newHtml);
16011601
morphdom(this.element, newElement, {
1602+
getNodeKey: (node) => {
1603+
if (!(node instanceof HTMLElement)) {
1604+
return;
1605+
}
1606+
return node.dataset.liveId;
1607+
},
16021608
onBeforeElUpdated: (fromEl, toEl) => {
16031609
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
16041610
return false;
@@ -1626,6 +1632,15 @@ class default_1 extends Controller {
16261632
return false;
16271633
}
16281634
return true;
1635+
},
1636+
onBeforeNodeDiscarded(node) {
1637+
if (!(node instanceof HTMLElement)) {
1638+
return true;
1639+
}
1640+
if (node.hasAttribute('data-live-ignore')) {
1641+
return false;
1642+
}
1643+
return true;
16291644
}
16301645
});
16311646
this._exposeOriginalData();

src/LiveComponent/assets/src/live_controller.ts

+19
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,13 @@ export default class extends Controller implements LiveController {
694694
_executeMorphdom(newHtml: string, modifiedElements: Array<HTMLElement>) {
695695
const newElement = htmlToElement(newHtml);
696696
morphdom(this.element, newElement, {
697+
getNodeKey: (node: Node) => {
698+
if (!(node instanceof HTMLElement)) {
699+
return;
700+
}
701+
702+
return node.dataset.liveId;
703+
},
697704
onBeforeElUpdated: (fromEl, toEl) => {
698705
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
699706
return false;
@@ -735,6 +742,18 @@ export default class extends Controller implements LiveController {
735742
return false;
736743
}
737744

745+
return true;
746+
},
747+
748+
onBeforeNodeDiscarded(node) {
749+
if (!(node instanceof HTMLElement)) {
750+
// text element
751+
return true;
752+
}
753+
754+
if (node.hasAttribute('data-live-ignore')) {
755+
return false;
756+
}
738757
return true;
739758
}
740759
});

src/LiveComponent/assets/test/controller/render.test.ts

+34
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { shutdownTest, createTest, initComponent } from '../tools';
1313
import { createEvent, fireEvent, getByText, waitFor } from '@testing-library/dom';
1414
import userEvent from '@testing-library/user-event';
1515
import fetchMock from 'fetch-mock-jest';
16+
import { htmlToElement } from '../../src/dom_utils';
1617

1718
describe('LiveController rendering Tests', () => {
1819
afterEach(() => {
@@ -166,6 +167,7 @@ describe('LiveController rendering Tests', () => {
166167

167168
// imitate some JavaScript changing this element
168169
test.element.querySelector('span')?.setAttribute('data-foo', 'bar');
170+
test.element.appendChild(htmlToElement('<div data-live-ignore>I should not be removed</div>'));
169171

170172
test.expectsAjaxCall('get')
171173
.expectSentData(test.initialData)
@@ -181,6 +183,38 @@ describe('LiveController rendering Tests', () => {
181183
const ignoreElement = test.element.querySelector('div[data-live-ignore]');
182184
expect(ignoreElement).not.toBeNull();
183185
expect(ignoreElement?.outerHTML).toEqual('<div data-live-ignore="">Inside Ignore Name: <span data-foo="bar">Ryan</span></div>');
186+
expect(test.element.innerHTML).toContain('I should not be removed');
187+
});
188+
189+
it('if data-live-id changes, data-live-ignore elements ARE re-rendered', async () => {
190+
const test = await createTest({ firstName: 'Ryan', containerId: 'original' }, (data: any) => `
191+
<div ${initComponent(data)}>
192+
<div data-live-id="${data.containerId}">
193+
<div data-live-ignore>Inside Ignore Name: <span>${data.firstName}</span></div>
194+
</div>
195+
196+
Outside Ignore Name: ${data.firstName}
197+
198+
<button data-action="live#$render">Reload</button>
199+
</div>
200+
`);
201+
202+
test.expectsAjaxCall('get')
203+
.expectSentData(test.initialData)
204+
.serverWillChangeData((data: any) => {
205+
// change the data on the server so the template renders differently
206+
data.firstName = 'Kevin';
207+
data.containerId = 'updated';
208+
})
209+
.init();
210+
211+
getByText(test.element, 'Reload').click();
212+
213+
await waitFor(() => expect(test.element).toHaveTextContent('Outside Ignore Name: Kevin'));
214+
const ignoreElement = test.element.querySelector('div[data-live-ignore]');
215+
expect(ignoreElement).not.toBeNull();
216+
// check that even the ignored element re-rendered
217+
expect(ignoreElement?.outerHTML).toEqual('<div data-live-ignore="">Inside Ignore Name: <span>Kevin</span></div>');
184218
});
185219

186220
it('cancels a re-render if the page is navigating away', async () => {

src/LiveComponent/src/Resources/doc/index.rst

+7-1
Original file line numberDiff line numberDiff line change
@@ -1730,7 +1730,7 @@ changes, a child component will re-render even though it was there
17301730
before *and* after the list changed. This can cause that child component
17311731
to lose some state (i.e. it re-renders with its original live props data).
17321732

1733-
To fix this, add a unique ``id`` attribute to the root component of each
1733+
To fix this, add a unique ``data-live-id`` attribute to the root component of each
17341734
child element. This will helps LiveComponent identify each item in the
17351735
list and correctly determine if a re-render is necessary, or not.
17361736

@@ -1748,6 +1748,12 @@ To handle this, add the ``data-live-ignore`` attribute to the element:
17481748

17491749
<input name="favorite_color" data-live-ignore>
17501750

1751+
.. note::
1752+
1753+
To *force* an ignored element to re-render, give its parent element a
1754+
``data-live-id`` attribute. During a re-render, if this value changes, all
1755+
of the children of the element will be re-rendered, even those with ``data-live-ignore``.
1756+
17511757
Backward Compatibility promise
17521758
------------------------------
17531759

0 commit comments

Comments
 (0)