diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js index a559b224749..73449a1bafa 100644 --- a/src/Autocomplete/assets/dist/controller.js +++ b/src/Autocomplete/assets/dist/controller.js @@ -28,10 +28,16 @@ class default_1 extends Controller { super(...arguments); _instances.add(this); } - connect() { - if (this.tomSelect) { - return; + initialize() { + this.element.setAttribute('data-live-ignore', ''); + if (this.element.id) { + const label = document.querySelector(`label[for="${this.element.id}"]`); + if (label) { + label.setAttribute('data-live-ignore', ''); + } } + } + connect() { if (this.urlValue) { this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocompleteWithRemoteData).call(this, this.urlValue); return; @@ -43,6 +49,7 @@ class default_1 extends Controller { this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocomplete).call(this); } disconnect() { + this.tomSelect.revertSettings.innerHTML = this.element.innerHTML; this.tomSelect.destroy(); } get selectElement() { @@ -80,6 +87,10 @@ _instances = new WeakSet(), _getCommonConfig = function _getCommonConfig() { onItemAdd: () => { this.tomSelect.setTextboxValue(''); }, + onInitialize: function () { + const tomSelect = this; + tomSelect.wrapper.setAttribute('data-live-ignore', ''); + }, closeAfterSelect: true, }; if (!this.selectElement && !this.urlValue) { diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts index e140be2dcf2..d6ca0bf1363 100644 --- a/src/Autocomplete/assets/src/controller.ts +++ b/src/Autocomplete/assets/src/controller.ts @@ -18,12 +18,17 @@ export default class extends Controller { readonly tomSelectOptionsValue: object; tomSelect: TomSelect; - connect() { - // this avoids initializing the same field twice (TomSelect shows an error otherwise) - if (this.tomSelect) { - return; + initialize() { + this.element.setAttribute('data-live-ignore', ''); + if (this.element.id) { + const label = document.querySelector(`label[for="${this.element.id}"]`); + if (label) { + label.setAttribute('data-live-ignore', ''); + } } + } + connect() { if (this.urlValue) { this.tomSelect = this.#createAutocompleteWithRemoteData(this.urlValue); @@ -40,9 +45,9 @@ export default class extends Controller { } disconnect() { + // make sure it will "revert" to the latest innerHTML + this.tomSelect.revertSettings.innerHTML = this.element.innerHTML; this.tomSelect.destroy(); - // Fixes https://github.com/symfony/ux/issues/407 - this.tomSelect = undefined; } #getCommonConfig(): Partial { @@ -73,6 +78,10 @@ export default class extends Controller { onItemAdd: () => { this.tomSelect.setTextboxValue(''); }, + onInitialize: function() { + const tomSelect = this as any; + tomSelect.wrapper.setAttribute('data-live-ignore', ''); + }, closeAfterSelect: true, }; @@ -124,7 +133,7 @@ export default class extends Controller { }, // VERY IMPORTANT: use 'function (query, callback) { ... }' instead of the // '(query, callback) => { ... }' syntax because, otherwise, - // the 'this.XXX' calls inside of this method fail + // the 'this.XXX' calls inside this method fail load: function (query: string, callback: (results?: any) => void) { const url = this.getUrl(query); fetch(url) diff --git a/src/Autocomplete/assets/test/controller.test.ts b/src/Autocomplete/assets/test/controller.test.ts index 4627df6f6d9..a0a4ad0643c 100644 --- a/src/Autocomplete/assets/test/controller.test.ts +++ b/src/Autocomplete/assets/test/controller.test.ts @@ -10,7 +10,7 @@ 'use strict'; import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor, getAllByLabelText } from '@testing-library/dom'; +import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; import AutocompleteController from '../src/controller'; import fetchMock from 'fetch-mock-jest'; @@ -137,4 +137,32 @@ describe('AutocompleteController', () => { expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2); }); }); + + it('adds live-component support', async () => { + const container = mountDOM(` +
+ + +
+ `); + + application = startStimulus(); + + await waitFor(() => { + expect(getByTestId(container, 'main-element')).toHaveClass('connected'); + }); + + expect(getByTestId(container, 'main-element')).toHaveAttribute('data-live-ignore'); + expect(getByTestId(container, 'main-element-label')).toHaveAttribute('data-live-ignore'); + const tsDropdown = container.querySelector('.ts-wrapper'); + + await waitFor(() => { + expect(tsDropdown).not.toBeNull(); + }); + expect(tsDropdown).toHaveAttribute('data-live-ignore'); + }); }); diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 90b15d9c7ec..4d5d66c3288 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -2,6 +2,11 @@ ## 2.3.1 +- [BC BREAK] Previously, the `id` attribute was used with `morphdom` as the + "node id" when updating the DOM after a render. This has changed to + `data-live-id`. This is useful when maintaining the correct order of a list + of elements. + - [BEHAVIOR CHANGE] If an action Ajax call is still processing and a model update occurs, the component will _no_ longer re-render. The model will be updated internally, but not re-rendered (so, any diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 0de0762ee6a..8bf88a4f134 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1599,6 +1599,12 @@ class default_1 extends Controller { _executeMorphdom(newHtml, modifiedElements) { const newElement = htmlToElement(newHtml); morphdom(this.element, newElement, { + getNodeKey: (node) => { + if (!(node instanceof HTMLElement)) { + return; + } + return node.dataset.liveId; + }, onBeforeElUpdated: (fromEl, toEl) => { if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) { return false; @@ -1626,6 +1632,15 @@ class default_1 extends Controller { return false; } return true; + }, + onBeforeNodeDiscarded(node) { + if (!(node instanceof HTMLElement)) { + return true; + } + if (node.hasAttribute('data-live-ignore')) { + return false; + } + return true; } }); this._exposeOriginalData(); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index e33c9447571..ad7008bba8c 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -694,6 +694,13 @@ export default class extends Controller implements LiveController { _executeMorphdom(newHtml: string, modifiedElements: Array) { const newElement = htmlToElement(newHtml); morphdom(this.element, newElement, { + getNodeKey: (node: Node) => { + if (!(node instanceof HTMLElement)) { + return; + } + + return node.dataset.liveId; + }, onBeforeElUpdated: (fromEl, toEl) => { if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) { return false; @@ -735,6 +742,18 @@ export default class extends Controller implements LiveController { return false; } + return true; + }, + + onBeforeNodeDiscarded(node) { + if (!(node instanceof HTMLElement)) { + // text element + return true; + } + + if (node.hasAttribute('data-live-ignore')) { + return false; + } return true; } }); diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts index 3fd4bb1086c..dcefc6704b2 100644 --- a/src/LiveComponent/assets/test/controller/render.test.ts +++ b/src/LiveComponent/assets/test/controller/render.test.ts @@ -13,6 +13,7 @@ import { shutdownTest, createTest, initComponent } from '../tools'; import { createEvent, fireEvent, getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock-jest'; +import { htmlToElement } from '../../src/dom_utils'; describe('LiveController rendering Tests', () => { afterEach(() => { @@ -166,6 +167,7 @@ describe('LiveController rendering Tests', () => { // imitate some JavaScript changing this element test.element.querySelector('span')?.setAttribute('data-foo', 'bar'); + test.element.appendChild(htmlToElement('
I should not be removed
')); test.expectsAjaxCall('get') .expectSentData(test.initialData) @@ -181,6 +183,38 @@ describe('LiveController rendering Tests', () => { const ignoreElement = test.element.querySelector('div[data-live-ignore]'); expect(ignoreElement).not.toBeNull(); expect(ignoreElement?.outerHTML).toEqual('
Inside Ignore Name: Ryan
'); + expect(test.element.innerHTML).toContain('I should not be removed'); + }); + + it('if data-live-id changes, data-live-ignore elements ARE re-rendered', async () => { + const test = await createTest({ firstName: 'Ryan', containerId: 'original' }, (data: any) => ` +
+
+
Inside Ignore Name: ${data.firstName}
+
+ + Outside Ignore Name: ${data.firstName} + + +
+ `); + + test.expectsAjaxCall('get') + .expectSentData(test.initialData) + .serverWillChangeData((data: any) => { + // change the data on the server so the template renders differently + data.firstName = 'Kevin'; + data.containerId = 'updated'; + }) + .init(); + + getByText(test.element, 'Reload').click(); + + await waitFor(() => expect(test.element).toHaveTextContent('Outside Ignore Name: Kevin')); + const ignoreElement = test.element.querySelector('div[data-live-ignore]'); + expect(ignoreElement).not.toBeNull(); + // check that even the ignored element re-rendered + expect(ignoreElement?.outerHTML).toEqual('
Inside Ignore Name: Kevin
'); }); it('cancels a re-render if the page is navigating away', async () => { diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst index e11ce06a38e..8fa8e576119 100644 --- a/src/LiveComponent/src/Resources/doc/index.rst +++ b/src/LiveComponent/src/Resources/doc/index.rst @@ -1730,7 +1730,7 @@ changes, a child component will re-render even though it was there before *and* after the list changed. This can cause that child component to lose some state (i.e. it re-renders with its original live props data). -To fix this, add a unique ``id`` attribute to the root component of each +To fix this, add a unique ``data-live-id`` attribute to the root component of each child element. This will helps LiveComponent identify each item in the list and correctly determine if a re-render is necessary, or not. @@ -1748,6 +1748,12 @@ To handle this, add the ``data-live-ignore`` attribute to the element: +.. note:: + + To *force* an ignored element to re-render, give its parent element a + ``data-live-id`` attribute. During a re-render, if this value changes, all + of the children of the element will be re-rendered, even those with ``data-live-ignore``. + Backward Compatibility promise ------------------------------