Skip to content

Commit c8f51be

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 9aa183d commit c8f51be

File tree

8 files changed

+162
-24
lines changed

8 files changed

+162
-24
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

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# CHANGELOG
22

3+
## 2.4.0
4+
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+
310
## 2.3.0
411

512
- [BC BREAK] The `data-action="live#update"` attribute must now be

src/LiveComponent/assets/dist/live_controller.js

+36-12
Original file line numberDiff line numberDiff line change
@@ -1084,8 +1084,8 @@ function getModelDirectiveFromInput(element, throwOnMissing = true) {
10841084
}
10851085
if (element.getAttribute('name')) {
10861086
const formElement = element.closest('form');
1087-
if (formElement && formElement.dataset.model) {
1088-
const directives = parseDirectives(formElement.dataset.model);
1087+
if (formElement && ('model' in formElement.dataset)) {
1088+
const directives = parseDirectives(formElement.dataset.model || '*');
10891089
const directive = directives[0];
10901090
if (directive.args.length > 0 || directive.named.length > 0) {
10911091
throw new Error(`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`);
@@ -1222,9 +1222,7 @@ class default_1 extends Controller {
12221222
if (!(this.element instanceof HTMLElement)) {
12231223
throw new Error('Invalid Element Type');
12241224
}
1225-
if (this.element.dataset.poll !== undefined) {
1226-
this._initiatePolling(this.element.dataset.poll);
1227-
}
1225+
this._initiatePolling();
12281226
window.addEventListener('beforeunload', this.markAsWindowUnloaded);
12291227
this._startAttributesMutationObserver();
12301228
this.element.addEventListener('live:update-model', this.handleUpdateModelEvent);
@@ -1234,9 +1232,7 @@ class default_1 extends Controller {
12341232
this._dispatchEvent('live:connect', { controller: this });
12351233
}
12361234
disconnect() {
1237-
this.pollingIntervals.forEach((interval) => {
1238-
clearInterval(interval);
1239-
});
1235+
this._stopAllPolling();
12401236
window.removeEventListener('beforeunload', this.markAsWindowUnloaded);
12411237
this.element.removeEventListener('live:update-model', this.handleUpdateModelEvent);
12421238
this.element.removeEventListener('input', this.handleInputEvent);
@@ -1598,6 +1594,12 @@ class default_1 extends Controller {
15981594
_executeMorphdom(newHtml, modifiedElements) {
15991595
const newElement = htmlToElement(newHtml);
16001596
morphdom(this.element, newElement, {
1597+
getNodeKey: (node) => {
1598+
if (!(node instanceof HTMLElement)) {
1599+
return;
1600+
}
1601+
return node.dataset.liveId;
1602+
},
16011603
onBeforeElUpdated: (fromEl, toEl) => {
16021604
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
16031605
return false;
@@ -1625,6 +1627,15 @@ class default_1 extends Controller {
16251627
return false;
16261628
}
16271629
return true;
1630+
},
1631+
onBeforeNodeDiscarded(node) {
1632+
if (!(node instanceof HTMLElement)) {
1633+
return true;
1634+
}
1635+
if (node.hasAttribute('data-live-ignore')) {
1636+
return false;
1637+
}
1638+
return true;
16281639
}
16291640
});
16301641
this._exposeOriginalData();
@@ -1665,7 +1676,12 @@ class default_1 extends Controller {
16651676
}
16661677
this._updateModelFromElement(target, 'change');
16671678
}
1668-
_initiatePolling(rawPollConfig) {
1679+
_initiatePolling() {
1680+
this._stopAllPolling();
1681+
if (this.element.dataset.poll === undefined) {
1682+
return;
1683+
}
1684+
const rawPollConfig = this.element.dataset.poll;
16691685
const directives = parseDirectives(rawPollConfig || '$render');
16701686
directives.forEach((directive) => {
16711687
let duration = 2000;
@@ -1790,9 +1806,12 @@ class default_1 extends Controller {
17901806
const element = this.element;
17911807
this.mutationObserver = new MutationObserver((mutations) => {
17921808
mutations.forEach((mutation) => {
1793-
if (mutation.type === 'attributes' && !element.dataset.originalData) {
1794-
this.originalDataJSON = this.valueStore.asJson();
1795-
this._exposeOriginalData();
1809+
if (mutation.type === 'attributes') {
1810+
if (!element.dataset.originalData) {
1811+
this.originalDataJSON = this.valueStore.asJson();
1812+
this._exposeOriginalData();
1813+
}
1814+
this._initiatePolling();
17961815
}
17971816
});
17981817
});
@@ -1803,6 +1822,11 @@ class default_1 extends Controller {
18031822
getDefaultDebounce() {
18041823
return this.hasDebounceValue ? this.debounceValue : DEFAULT_DEBOUNCE;
18051824
}
1825+
_stopAllPolling() {
1826+
this.pollingIntervals.forEach((interval) => {
1827+
clearInterval(interval);
1828+
});
1829+
}
18061830
}
18071831
default_1.values = {
18081832
url: String,

src/LiveComponent/assets/src/live_controller.ts

+19
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,13 @@ export default class extends Controller implements LiveController {
685685
_executeMorphdom(newHtml: string, modifiedElements: Array<HTMLElement>) {
686686
const newElement = htmlToElement(newHtml);
687687
morphdom(this.element, newElement, {
688+
getNodeKey: (node: Node) => {
689+
if (!(node instanceof HTMLElement)) {
690+
return;
691+
}
692+
693+
return node.dataset.liveId;
694+
},
688695
onBeforeElUpdated: (fromEl, toEl) => {
689696
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
690697
return false;
@@ -726,6 +733,18 @@ export default class extends Controller implements LiveController {
726733
return false;
727734
}
728735

736+
return true;
737+
},
738+
739+
onBeforeNodeDiscarded(node) {
740+
if (!(node instanceof HTMLElement)) {
741+
// text element
742+
return true;
743+
}
744+
745+
if (node.hasAttribute('data-live-ignore')) {
746+
return false;
747+
}
729748
return true;
730749
}
731750
});

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)