Skip to content

Commit 5dcd76a

Browse files
committed
bug #418 [LiveComponent][Autocomplete] Adding support for libraries to play nicely (weaverryan)
This PR was merged into the 2.x branch. Discussion ---------- [LiveComponent][Autocomplete] Adding support for libraries to play nicely | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no (but yes to BC break) | Tickets | Fixes #407 and helps with #354 | License | MIT See the commit for more information - this was a VERY pesky bug. Cheers! Commits ------- c4791fc [LiveComponent][Autocomplete] Adding support for libraries to play nicely together
2 parents 569acef + c4791fc commit 5dcd76a

File tree

8 files changed

+139
-12
lines changed

8 files changed

+139
-12
lines changed

src/Autocomplete/assets/dist/controller.js

Lines changed: 14 additions & 3 deletions
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

Lines changed: 16 additions & 7 deletions
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

Lines changed: 29 additions & 1 deletion
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 15 additions & 0 deletions
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

Lines changed: 19 additions & 0 deletions
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

Lines changed: 34 additions & 0 deletions
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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1842,7 +1842,7 @@ changes, a child component will re-render even though it was there
18421842
before *and* after the list changed. This can cause that child component
18431843
to lose some state (i.e. it re-renders with its original live props data).
18441844

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

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

18611861
<input name="favorite_color" data-live-ignore>
18621862

1863+
.. note::
1864+
1865+
To *force* an ignored element to re-render, give its parent element a
1866+
``data-live-id`` attribute. During a re-render, if this value changes, all
1867+
of the children of the element will be re-rendered, even those with ``data-live-ignore``.
1868+
18631869
Backward Compatibility promise
18641870
------------------------------
18651871

0 commit comments

Comments
 (0)