Skip to content

Commit 84a3bf0

Browse files
committed
Adding logic to "smartly" re-render child components
1 parent 065aa9e commit 84a3bf0

File tree

6 files changed

+331
-59
lines changed

6 files changed

+331
-59
lines changed

src/LiveComponent/README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,9 +1151,33 @@ Here are a few helpful things to know:
11511151

11521152
### Each component re-renders independent of one another
11531153

1154-
If a parent component re-renders, the child component will *not* be updated,
1155-
even though it lives inside the parent. Each component is its own,
1156-
isolated universe.
1154+
If a parent component re-renders, the child component will *not* (most
1155+
of the time) be updated, even though it lives inside the parent. Each
1156+
component is its own, isolated universe.
1157+
1158+
But... this is not always what you want. For example, suppose a
1159+
parent component holds a form and a child component holds one field.
1160+
When you click a "Save" button on the parent component, it validates
1161+
the form and re-renders with errors - including a new `error` value
1162+
that it passes into the child:
1163+
1164+
```twig
1165+
{# templates/components/post_form.html.twig #}
1166+
1167+
{{ component('textarea_field', {
1168+
value: this.content,
1169+
error: this.getError('content')
1170+
}) }}
1171+
```
1172+
1173+
In this situation, when the parent component re-renders after clicking
1174+
"Save", you *do* want the updated child component (with the validation
1175+
error) to be rendered. And, this *will* happen because the system detects
1176+
that the parent component has *changed* how it's rendering the child.
1177+
1178+
This may not always be perfect, and if your child component has its own
1179+
`LiveProp` that has changed since it was first rendered, that value will
1180+
be lost when the parent component causes the child to re-render.
11571181

11581182
### Actions, methods and model updates in a child do not affect the parent
11591183

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
export function haveRenderedValuesChanged(originalDataJson, currentDataJson, newDataJson) {
2+
/*
3+
* Right now, if the "data" on the new value is different than
4+
* the "original data" on the child element, then we force re-render
5+
* the child component. There may be some other cases that we
6+
* add later if they come up. Either way, this is probably the
7+
* behavior we want most of the time, but it's not perfect. For
8+
* example, if the child component has some a writable prop that
9+
* has changed since the initial render, re-rendering the child
10+
* component from the parent component will "eliminate" that
11+
* change.
12+
*/
13+
14+
// if the original data matches the new data, then the parent
15+
// hasn't changed how they render the child.
16+
if (originalDataJson === newDataJson) {
17+
return false;
18+
}
19+
20+
// The child component IS now being rendered in a "new way".
21+
// This means that at least one of the "data" pieces used
22+
// to render the child component has changed.
23+
// However, the piece of data that changed might simply
24+
// match the "current data" of that child component. In that case,
25+
// there is no point to re-rendering.
26+
// And, to be safe (in case the child has some "private LiveProp"
27+
// that has been modified), we want to avoid rendering.
28+
29+
30+
// if the current data exactly matches the new data, then definitely
31+
// do not re-render.
32+
if (currentDataJson === newDataJson) {
33+
return false;
34+
}
35+
36+
// here, we will compare the original data for the child component
37+
// with the new data. What we're looking for are they keys that
38+
// have changed between the original "child rendering" and the
39+
// new "child rendering".
40+
const originalData = JSON.parse(originalDataJson);
41+
const newData = JSON.parse(newDataJson);
42+
const changedKeys = Object.keys(newData);
43+
Object.entries(originalData).forEach(([key, value]) => {
44+
// if any key in the new data is different than a key in the
45+
// current data, then we *should* re-render. But if all the
46+
// keys in the new data equal
47+
if (value === newData[key]) {
48+
// value is equal, remove from changedKeys
49+
changedKeys.splice(changedKeys.indexOf(key), 1);
50+
}
51+
});
52+
53+
// now that we know which keys have changed between originally
54+
// rendering the child component and this latest render, we
55+
// can check to see if the the child component *already* has
56+
// the latest value for those keys.
57+
58+
const currentData = JSON.parse(currentDataJson)
59+
let keyHasChanged = false;
60+
changedKeys.forEach((key) => {
61+
// if any key in the new data is different than a key in the
62+
// current data, then we *should* re-render. But if all the
63+
// keys in the new data equal
64+
if (currentData[key] !== newData[key]) {
65+
keyHasChanged = true;
66+
}
67+
});
68+
69+
return keyHasChanged;
70+
}

src/LiveComponent/assets/src/live_controller.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { combineSpacedArray } from './string_utils';
55
import { buildFormData, buildSearchParams } from './http_data_helper';
66
import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data';
77
import './polyfills';
8+
import { haveRenderedValuesChanged } from './have_rendered_values_changed';
89

910
const DEFAULT_DEBOUNCE = '150';
1011

@@ -44,8 +45,12 @@ export default class extends Controller {
4445

4546
isWindowUnloaded = false;
4647

48+
originalDataJSON;
49+
4750
initialize() {
4851
this.markAsWindowUnloaded = this.markAsWindowUnloaded.bind(this);
52+
this.originalDataJSON = JSON.stringify(this.dataValue);
53+
this._exposeOriginalData();
4954
}
5055

5156
connect() {
@@ -530,13 +535,16 @@ export default class extends Controller {
530535
if (fromEl.hasAttribute('data-controller')
531536
&& fromEl.getAttribute('data-controller').split(' ').indexOf('live') !== -1
532537
&& fromEl !== this.element
538+
&& !this._shouldChildLiveElementUpdate(fromEl, toEl)
533539
) {
534540
return false;
535541
}
536542

537543
return true;
538544
}
539545
});
546+
// restore the data-original-data attribute
547+
this._exposeOriginalData();
540548
}
541549

542550
markAsWindowUnloaded = () => {
@@ -616,6 +624,31 @@ export default class extends Controller {
616624
}
617625
);
618626
}
627+
628+
/**
629+
* Determines of a child live element should be re-rendered.
630+
*
631+
* This is called when this element re-renders and detects that
632+
* a child element is inside. Normally, in that case, we do not
633+
* re-render the child element. However, if we detect that the
634+
* "data" on the child element has changed from its initial data,
635+
* then this will trigger a re-render.
636+
*
637+
* @param {Element} fromEl
638+
* @param {Element} toEl
639+
* @return {boolean}
640+
*/
641+
_shouldChildLiveElementUpdate(fromEl, toEl) {
642+
return haveRenderedValuesChanged(
643+
fromEl.dataset.originalData,
644+
fromEl.dataset.liveDataValue,
645+
toEl.dataset.liveDataValue
646+
);
647+
}
648+
649+
_exposeOriginalData() {
650+
this.element.dataset.originalData = this.originalDataJSON;
651+
}
619652
}
620653

621654
/**

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

Lines changed: 81 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,33 @@
1010
'use strict';
1111

1212
import { clearDOM } from '@symfony/stimulus-testing';
13-
import { startStimulus } from '../tools';
14-
import { createEvent, fireEvent, getByLabelText, getByText, waitFor } from '@testing-library/dom';
13+
import { initLiveComponent, mockRerender, startStimulus } from '../tools';
14+
import { getByLabelText, getByText, waitFor } from '@testing-library/dom';
1515
import userEvent from '@testing-library/user-event';
1616
import fetchMock from 'fetch-mock-jest';
1717

1818
describe('LiveController parent -> child component tests', () => {
1919
const parentTemplate = (data) => {
20+
const errors = data.errors || { post: {} };
21+
2022
return `
2123
<div
22-
data-controller="live"
23-
data-live-url-value="http://localhost/_components/parent"
24+
${initLiveComponent('/_components/parent', data)}
2425
>
2526
<span>Title: ${data.post.title}</span>
2627
<span>Description in Parent: ${data.post.content}</span>
2728
28-
<input
29-
type="text"
30-
name="post[title]"
31-
value="${data.post.title}"
32-
>
29+
<label>
30+
Title:
31+
<input
32+
type="text"
33+
name="post[title]"
34+
value="${data.post.title}"
35+
data-action="live#update"
36+
>
37+
</label>
3338
34-
${childTemplate({ value: data.post.content })}
39+
${childTemplate({ value: data.post.content, error: errors.post.content })}
3540
3641
<button
3742
data-action="live#$render"
@@ -42,82 +47,66 @@ describe('LiveController parent -> child component tests', () => {
4247

4348
const childTemplate = (data) => `
4449
<div
45-
data-controller="live"
46-
data-live-url-value="http://localhost/_components/child"
50+
${initLiveComponent('/_components/child', data)}
4751
>
48-
<!-- form field not mapped with data-model -->
4952
<label>
5053
Content:
51-
<input
52-
value="${data.value}"
54+
<textarea
5355
data-model="value"
5456
name="post[content]"
5557
data-action="live#update"
56-
>
58+
rows="${data.rows ? data.rows : '3'}"
59+
>${data.value}</textarea>
5760
</label>
5861
5962
<div>Value in child: ${data.value}</div>
63+
<div>Error in child: ${data.error ? data.error : 'none'}</div>
64+
{# Rows represents a writable prop that's private to the child component #}
65+
<div>Rows in child: ${data.rows ? data.rows : 'not set'}</div>
6066
6167
<button
6268
data-action="live#$render"
6369
>Child Re-render</button>
6470
</div>
6571
`;
6672

67-
const startStimulusForParentChild = async (html, data, childData) => {
68-
const testData = await startStimulus(
69-
parentTemplate(data),
70-
data
71-
);
72-
73-
// setup the values on the child element
74-
testData.element.querySelector('[data-controller="live"]').dataset.liveDataValue = JSON.stringify(childData);
75-
76-
return testData;
77-
}
78-
7973
afterEach(() => {
8074
clearDOM();
8175
fetchMock.reset();
8276
});
8377

8478
it('renders parent component without affecting child component', async () => {
85-
const data = { post: { title: 'Parent component', content: 'i love components' } };
86-
const { element } = await startStimulusForParentChild(
87-
parentTemplate(data),
88-
data,
89-
{ value: data.post.content }
90-
);
91-
92-
// on child re-render, use a new content
93-
fetchMock.get('http://localhost/_components/child?value=i+love+components', {
94-
html: childTemplate({ value: 'i love popcorn' }),
95-
data: { value: 'i love popcorn' }
79+
const data = { post: { title: 'Parent component', content: 'i love' } };
80+
const { element } = await startStimulus(parentTemplate(data));
81+
82+
// on child re-render, expect the new value, change rows on the server
83+
mockRerender({ value: 'i love popcorn' }, childTemplate, (data) => {
84+
// change the "rows" data on the "server"
85+
data.rows = 5;
9686
});
87+
await userEvent.type(getByLabelText(element, 'Content:'), ' popcorn');
9788

98-
// reload the child template
99-
getByText(element, 'Child Re-render').click();
10089
await waitFor(() => expect(element).toHaveTextContent('Value in child: i love popcorn'));
101-
102-
// on parent re-render, render the child template differently
103-
fetchMock.get('begin:http://localhost/_components/parent', {
104-
html: parentTemplate({ post: { title: 'Changed title', content: 'changed content' } }),
105-
data: { post: { title: 'Changed title', content: 'changed content' } }
106-
});
107-
getByText(element, 'Parent Re-render').click();
108-
await waitFor(() => expect(element).toHaveTextContent('Title: Changed title'));
90+
expect(element).toHaveTextContent('Rows in child: 5');
91+
92+
// when the parent re-renders, expect the changed title AND content (from child)
93+
// but, importantly, the only "changed" data that will be passed into
94+
// the child component will be "content", which will match what the
95+
// child already has. This will NOT trigger a re-render.
96+
mockRerender(
97+
{ post: { title: 'Parent component changed', content: 'i love popcorn' } },
98+
parentTemplate
99+
)
100+
await userEvent.type(getByLabelText(element, 'Title:'), ' changed');
101+
await waitFor(() => expect(element).toHaveTextContent('Title: Parent component changed'));
109102

110103
// the child component should *not* have updated
111-
expect(element).toHaveTextContent('Value in child: i love popcorn');
104+
expect(element).toHaveTextContent('Rows in child: 5');
112105
});
113106

114107
it('updates child model and parent model in a deferred way', async () => {
115108
const data = { post: { title: 'Parent component', content: 'i love' } };
116-
const { element, controller } = await startStimulusForParentChild(
117-
parentTemplate(data),
118-
data,
119-
{ value: data.post.content }
120-
);
109+
const { element, controller } = await startStimulus(parentTemplate(data));
121110

122111
// verify the child request contains the correct description & re-render
123112
fetchMock.get('http://localhost/_components/child?value=i+love+turtles', {
@@ -139,6 +128,43 @@ describe('LiveController parent -> child component tests', () => {
139128
expect(controller.dataValue.post.content).toEqual('i love turtles');
140129
});
141130

131+
it('updates re-renders a child component if data has changed from initial', async () => {
132+
const data = { post: { title: 'Parent component', content: 'initial content' } };
133+
const { element } = await startStimulus(parentTemplate(data));
134+
135+
// allow the child to re-render, but change the "rows" value
136+
const inputElement = getByLabelText(element, 'Content:');
137+
await userEvent.clear(inputElement);
138+
await userEvent.type(inputElement, 'changed content');
139+
fetchMock.get('http://localhost/_components/child?value=changed+content', {
140+
html: childTemplate({ value: 'changed content', rows: 5 }),
141+
data: { value: 'changed content', rows: 5 }
142+
});
143+
144+
// reload, which will give us rows=5
145+
getByText(element, 'Child Re-render').click();
146+
await waitFor(() => expect(element).toHaveTextContent('Rows in child: 5'));
147+
148+
// simulate an action in the parent component where "errors" changes
149+
const newData = {...data};
150+
newData.post.title = 'Changed title';
151+
newData.post.content = 'changed content';
152+
newData.errors = { post: { content: 'the content is not interesting enough' }};
153+
fetchMock.get('http://localhost/_components/parent?post%5Btitle%5D=Parent+component&post%5Bcontent%5D=changed+content', {
154+
html: parentTemplate(newData),
155+
data: newData
156+
});
157+
158+
getByText(element, 'Parent Re-render').click();
159+
await waitFor(() => expect(element).toHaveTextContent('Title: Changed title'));
160+
// the child, of course, still has the "changed content" value
161+
expect(element).toHaveTextContent('Value in child: changed content');
162+
// but because some child data *changed* from its original value, the child DOES re-render
163+
expect(element).toHaveTextContent('Error in child: the content is not interesting enough');
164+
// however, this means that the updated "rows" data on the child is lost
165+
expect(element).toHaveTextContent('Rows in child: not set');
166+
});
167+
142168
// TODO - what if a child component re-renders and comes down with
143169
// a changed set of data? Should that update the parent's data?
144170
});

0 commit comments

Comments
 (0)