Skip to content

Commit e856c62

Browse files
committed
[Live] Fixing a bug where data-original-data was lost on child components
1 parent 485fc09 commit e856c62

File tree

4 files changed

+108
-0
lines changed

4 files changed

+108
-0
lines changed

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,7 @@ class default_1 extends Controller {
10241024
this.pollingIntervals = [];
10251025
this.isWindowUnloaded = false;
10261026
this.originalDataJSON = '{}';
1027+
this.mutationObserver = null;
10271028
this.markAsWindowUnloaded = () => {
10281029
this.isWindowUnloaded = true;
10291030
};
@@ -1042,6 +1043,7 @@ class default_1 extends Controller {
10421043
this._initiatePolling(this.element.dataset.poll);
10431044
}
10441045
window.addEventListener('beforeunload', this.markAsWindowUnloaded);
1046+
this._startAttributesMutationObserver();
10451047
this.element.addEventListener('live:update-model', (event) => {
10461048
if (event.target === this.element) {
10471049
return;
@@ -1055,6 +1057,9 @@ class default_1 extends Controller {
10551057
clearInterval(interval);
10561058
});
10571059
window.removeEventListener('beforeunload', this.markAsWindowUnloaded);
1060+
if (this.mutationObserver) {
1061+
this.mutationObserver.disconnect();
1062+
}
10581063
}
10591064
update(event) {
10601065
this._updateModelFromElement(event.target, this._getValueFromElement(event.target), true);
@@ -1517,6 +1522,19 @@ class default_1 extends Controller {
15171522
}
15181523
this.element.dataset.originalData = this.originalDataJSON;
15191524
}
1525+
_startAttributesMutationObserver() {
1526+
this.mutationObserver = new MutationObserver((mutations) => {
1527+
mutations.forEach((mutation) => {
1528+
if (mutation.type === 'attributes' && !this.element.dataset.originalData) {
1529+
this.originalDataJSON = JSON.stringify(this.dataValue);
1530+
this._exposeOriginalData();
1531+
}
1532+
});
1533+
});
1534+
this.mutationObserver.observe(this.element, {
1535+
attributes: true
1536+
});
1537+
}
15201538
}
15211539
default_1.values = {
15221540
url: String,

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export default class extends Controller {
6060

6161
originalDataJSON = '{}';
6262

63+
mutationObserver: MutationObserver|null = null;
64+
6365
initialize() {
6466
this.markAsWindowUnloaded = this.markAsWindowUnloaded.bind(this);
6567
this.originalDataJSON = JSON.stringify(this.dataValue);
@@ -82,6 +84,8 @@ export default class extends Controller {
8284

8385
window.addEventListener('beforeunload', this.markAsWindowUnloaded);
8486

87+
this._startAttributesMutationObserver();
88+
8589
this.element.addEventListener('live:update-model', (event) => {
8690
// ignore events that we dispatched
8791
if (event.target === this.element) {
@@ -100,6 +104,10 @@ export default class extends Controller {
100104
});
101105

102106
window.removeEventListener('beforeunload', this.markAsWindowUnloaded);
107+
108+
if (this.mutationObserver) {
109+
this.mutationObserver.disconnect();
110+
}
103111
}
104112

105113
/**
@@ -790,6 +798,34 @@ export default class extends Controller {
790798

791799
this.element.dataset.originalData = this.originalDataJSON;
792800
}
801+
802+
/**
803+
* Re-establishes the data-original-data attribute if missing.
804+
*
805+
* This happens if a parent component re-renders a child component
806+
* and morphdom *updates* child. This commonly happens if a parent
807+
* component is around a list of child components, and changing
808+
* something in the parent causes the list to change. In that case,
809+
* the a child component might be removed while another is added.
810+
* But to morphdom, this sometimes looks like an "update". The result
811+
* is that the child component is re-rendered, but the child component
812+
* is not re-initialized. And so, the data-original-data attribute
813+
* is missing and never re-established.
814+
*/
815+
_startAttributesMutationObserver() {
816+
this.mutationObserver = new MutationObserver((mutations) => {
817+
mutations.forEach((mutation) => {
818+
if (mutation.type === 'attributes' && !this.element.dataset.originalData) {
819+
this.originalDataJSON = JSON.stringify(this.dataValue);
820+
this._exposeOriginalData();
821+
}
822+
});
823+
});
824+
825+
this.mutationObserver.observe(this.element, {
826+
attributes: true
827+
});
828+
}
793829
}
794830

795831
/**

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,44 @@ describe('LiveController parent -> child component tests', () => {
193193

194194
expect(controller.dataValue).toEqual({ textareaContent: 'changed content' });
195195
});
196+
197+
it('updates child data-original-data on parent re-render', async () => {
198+
const parentTemplateListOfChildren = (data) => `
199+
<div
200+
${initLiveComponent('/_components/parent', data)}
201+
>
202+
<ul>
203+
${data.children.map((child) => {
204+
return `
205+
<li ${initLiveComponent('/_components/child', child)}>
206+
${child.name}
207+
</li>
208+
`
209+
})}
210+
</ul>
211+
212+
<button
213+
data-action="live#$render"
214+
>Parent Re-render</button>
215+
</div>
216+
`;
217+
218+
const data = { children: [{name: 'child1'}, {name: 'child2'}, {name: 'child3'}] };
219+
const { element, controller } = await startStimulus(parentTemplateListOfChildren(data));
220+
221+
// mock a re-render where "child2" disappears
222+
mockRerender(data, parentTemplateListOfChildren, (returnedData) => {
223+
returnedData.children = [{name: 'child1'}, {name: 'child3'}];
224+
});
225+
getByText(element, 'Parent Re-render').click();
226+
227+
await waitFor(() => expect(element).not.toHaveTextContent('child2'));
228+
const secondLi = element.querySelectorAll('li').item(1);
229+
expect(secondLi).not.toBeNull();
230+
// the 2nd li was just "updated" by the parent component, which
231+
// would have eliminated its data-original-data attribute. Check
232+
// that it was re-established to the 3rd child's data.
233+
// see MutationObserver in live_controller for more details.
234+
expect(secondLi.dataset.originalData).toEqual(JSON.stringify({name: 'child3'}));
235+
});
196236
});

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1655,6 +1655,20 @@ form. But it also makes sure that when the ``textarea`` changes, both
16551655
the ``value`` model in ``MarkdownTextareaComponent`` *and* the
16561656
``post.content`` model in ``EditPostcomponent`` will be updated.
16571657

1658+
Rendering Quirks with List of Embedded Components
1659+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1660+
1661+
Imagine your component renders a list of embedded components and
1662+
that list is updated as the user types into a search box. Most of the
1663+
time, this works *fine*. But in some cases, as the list of items
1664+
changes, a child component will re-render even though it was there
1665+
before *and* after the list changed. This can cause that child component
1666+
to lose some state (i.e. it re-renders with its original live props data).
1667+
1668+
To fix this, add a unique ``id`` attribute to the root component of each
1669+
child element. This will helps LiveComponent identify each item in the
1670+
list and correctly determine if a re-render is necessary, or not.
1671+
16581672
Skipping Updating Certain Elements
16591673
----------------------------------
16601674

0 commit comments

Comments
 (0)