Skip to content

Commit 0c6423c

Browse files
committed
feature #250 [Live] Fixing bug where inputs would not re-render if the value changed (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Live] Fixing bug where inputs would not re-render if the value changed | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | Fix #144 and Bug E on #102. | License | MIT ~~NOTE to self: I'm considering reversing this PR as it has some side effects where we lose input values on re-render. That can be solved with `data-live-ignore`, but it may be better to reverse this, and "fix" the original problem by telling the user to add a `data-live-update` attribute (or something like that) where we opt INTO re-rendering. There is basically a situation where we don't know if the user will want a form element to re-render (and update the value) or not. And so, the user needs to choose. The question is, which way should the "default" be.~~ For example, if an input rendered initially empty, then you typed into it, and, on re-render, the value was set BACK to the original, the input would *not* update on re-render, and it would be stuck with the old value. This was because the old input node and new input node were seen as identical... and so morphdom didn't bother to update that node. However, in reality, the value property of the "old" input had been changed and would no longer match the value "attribute" of the incoming input. Effectively, the 2 inputs had different values, but this difference was not caught by the system. Commits ------- a27fdd3 [Live] Fixing bug where inputs would not re-render if the value changed
2 parents db75d11 + a27fdd3 commit 0c6423c

11 files changed

+176
-41
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
## 2.1.0
44

5+
- Added `data-live-ignore` attribute. If included in an element, that element
6+
will not be updated on re-render.
7+
58
- The Live Component AJAX endpoints now return HTML in all situations
69
instead of JSON.
710

8-
- Send live action arguments to backend
11+
- Ability to send live action arguments to backend
912

1013
- [BC BREAK] Remove `init_live_component()` twig function, use `{{ attributes }}` instead:
1114
```diff
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function cloneHTMLElement(element: HTMLElement): HTMLElement {
2+
const newElement = element.cloneNode(true);
3+
if (!(newElement instanceof HTMLElement)) {
4+
throw new Error('Could not clone element');
5+
}
6+
7+
return newElement;
8+
}

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { combineSpacedArray } from './string_utils';
55
import { buildFormData, buildSearchParams } from './http_data_helper';
66
import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data';
77
import { haveRenderedValuesChanged } from './have_rendered_values_changed';
8+
import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison';
9+
import { cloneHTMLElement } from './clone_html_element';
810

911
interface ElementLoadingDirectives {
1012
element: HTMLElement,
@@ -197,11 +199,7 @@ export default class extends Controller {
197199
const model = element.dataset.model || element.getAttribute('name');
198200

199201
if (!model) {
200-
const clonedElement = (element.cloneNode());
201-
// helps typescript know this is an HTMLElement
202-
if (!(clonedElement instanceof HTMLElement)) {
203-
throw new Error('cloneNode() produced incorrect type');
204-
}
202+
const clonedElement = cloneHTMLElement(element);
205203

206204
throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-model" or "name" attribute set to the model name.`);
207205
}
@@ -558,7 +556,18 @@ export default class extends Controller {
558556
onBeforeElUpdated: (fromEl, toEl) => {
559557
// https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes
560558
if (fromEl.isEqualNode(toEl)) {
561-
return false
559+
// the nodes are equal, but the "value" on some might differ
560+
// lets try to quickly compare a bit more deeply
561+
const normalizedFromEl = cloneHTMLElement(fromEl);
562+
normalizeAttributesForComparison(normalizedFromEl);
563+
564+
const normalizedToEl = cloneHTMLElement(toEl);
565+
normalizeAttributesForComparison(normalizedToEl);
566+
567+
if (normalizedFromEl.isEqualNode(normalizedToEl)) {
568+
// don't bother updating
569+
return false;
570+
}
562571
}
563572

564573
// avoid updating child components: they will handle themselves
@@ -571,6 +580,11 @@ export default class extends Controller {
571580
return false;
572581
}
573582

583+
// look for data-live-ignore, and don't update
584+
if (fromEl.hasAttribute('data-live-ignore')) {
585+
return false;
586+
}
587+
574588
return true;
575589
}
576590
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Updates an HTML node to represent its underlying data.
3+
*
4+
* For example, this finds the value property of each underlying node
5+
* and sets that onto the value attribute. This is useful to compare
6+
* if two nodes are identical.
7+
*/
8+
export function normalizeAttributesForComparison(element: HTMLElement): void {
9+
if (element.value) {
10+
element.setAttribute('value', element.value);
11+
} else if (element.hasAttribute('value')) {
12+
element.setAttribute('value', '');
13+
}
14+
15+
Array.from(element.children).forEach((child: HTMLElement) => {
16+
normalizeAttributesForComparison(child);
17+
});
18+
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ describe('LiveController Action Tests', () => {
4242

4343
afterEach(() => {
4444
clearDOM();
45+
if (!fetchMock.done()) {
46+
throw new Error('Mocked requests did not match');
47+
}
4548
fetchMock.reset();
4649
});
4750

@@ -62,16 +65,14 @@ describe('LiveController Action Tests', () => {
6265
await waitFor(() => expect(element).toHaveTextContent('Comment Saved!'));
6366
expect(getByLabelText(element, 'Comments:')).toHaveValue('hi weaver');
6467

65-
fetchMock.done();
66-
6768
expect(postMock.lastOptions().body.get('comments')).toEqual('hi WEAVER');
6869
});
6970

7071
it('Sends action named args', async () => {
7172
const data = { comments: 'hi' };
7273
const { element } = await startStimulus(template(data));
7374

74-
fetchMock.postOnce('http://localhost/_components/my_component/sendNamedArgs?values=a%3D1%26b%3D2%26c%3D3', {
75+
fetchMock.postOnce('http://localhost/_components/my_component/sendNamedArgs?args=a%3D1%26b%3D2%26c%3D3', {
7576
html: template({ comments: 'hi' }),
7677
});
7778

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ describe('LiveController parent -> child component tests', () => {
7272

7373
afterEach(() => {
7474
clearDOM();
75+
if (!fetchMock.done()) {
76+
throw new Error('Mocked requests did not match');
77+
}
7578
fetchMock.reset();
7679
});
7780

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ describe('LiveController CSRF Tests', () => {
4040

4141
afterEach(() => {
4242
clearDOM();
43+
if (!fetchMock.done()) {
44+
throw new Error('Mocked requests did not match');
45+
}
4346
fetchMock.reset();
4447
});
4548

@@ -56,7 +59,5 @@ describe('LiveController CSRF Tests', () => {
5659
await waitFor(() => expect(element).toHaveTextContent('Comment Saved!'));
5760

5861
expect(postMock.lastOptions().headers['X-CSRF-TOKEN']).toEqual('123TOKEN');
59-
60-
fetchMock.done();
6162
});
6263
});

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

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ describe('LiveController data-model Tests', () => {
3434

3535
afterEach(() => {
3636
clearDOM();
37+
if (!fetchMock.done()) {
38+
throw new Error('Mocked requests did not match');
39+
}
3740
fetchMock.reset();
3841
});
3942

@@ -52,9 +55,6 @@ describe('LiveController data-model Tests', () => {
5255
await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Ryan Weaver'));
5356
expect(controller.dataValue).toEqual({name: 'Ryan Weaver'});
5457

55-
// assert all calls were done the correct number of times
56-
fetchMock.done();
57-
5858
// assert the input is still focused after rendering
5959
expect(document.activeElement.dataset.model).toEqual('name');
6060
});
@@ -69,9 +69,6 @@ describe('LiveController data-model Tests', () => {
6969

7070
await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Jan'));
7171
expect(controller.dataValue).toEqual({name: 'Jan'});
72-
73-
// assert all calls were done the correct number of times
74-
fetchMock.done();
7572
});
7673

7774

@@ -111,9 +108,6 @@ describe('LiveController data-model Tests', () => {
111108
await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Ryanguy_'));
112109
expect(controller.dataValue).toEqual({name: 'Ryanguy_'});
113110

114-
// assert all calls were done the correct number of times
115-
fetchMock.done();
116-
117111
// only 1 render should have ultimately occurred
118112
expect(renderCount).toEqual(1);
119113
});
@@ -135,9 +129,6 @@ describe('LiveController data-model Tests', () => {
135129

136130
await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver'));
137131
expect(controller.dataValue).toEqual({name: 'Ryan Weaver'});
138-
139-
// assert all calls were done the correct number of times
140-
fetchMock.done();
141132
});
142133

143134
it('uses data-model when both name and data-model is present', async () => {
@@ -157,8 +148,6 @@ describe('LiveController data-model Tests', () => {
157148

158149
await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver'));
159150
expect(controller.dataValue).toEqual({name: 'Ryan Weaver'});
160-
161-
fetchMock.done();
162151
});
163152

164153
it('uses data-value when both value and data-value is present', async () => {
@@ -178,8 +167,6 @@ describe('LiveController data-model Tests', () => {
178167

179168
await waitFor(() => expect(inputElement).toHaveValue('first_name'));
180169
expect(controller.dataValue).toEqual({name: 'first_name'});
181-
182-
fetchMock.done();
183170
});
184171

185172
it('standardizes user[firstName] style models into post.name', async () => {
@@ -211,9 +198,6 @@ describe('LiveController data-model Tests', () => {
211198

212199
await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver'));
213200
expect(controller.dataValue).toEqual({ user: { firstName: 'Ryan Weaver' } });
214-
215-
// assert all calls were done the correct number of times
216-
fetchMock.done();
217201
});
218202

219203
it('updates correctly when live#update is on a parent element', async () => {
@@ -245,8 +229,6 @@ describe('LiveController data-model Tests', () => {
245229

246230
await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver'));
247231

248-
fetchMock.done();
249-
250232
// assert the input is still focused after rendering
251233
expect(document.activeElement.getAttribute('name')).toEqual('firstName');
252234
});
@@ -264,9 +246,6 @@ describe('LiveController data-model Tests', () => {
264246
await userEvent.type(inputElement, ' WEAVER');
265247

266248
await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver'));
267-
268-
// assert all calls were done the correct number of times
269-
fetchMock.done();
270249
});
271250

272251
it('data changed on server should be noticed by controller', async () => {
@@ -283,7 +262,5 @@ describe('LiveController data-model Tests', () => {
283262

284263
await waitFor(() => expect(inputElement).toHaveValue('Kevin Bond'));
285264
expect(controller.dataValue).toEqual({name: 'Kevin Bond'});
286-
287-
fetchMock.done();
288265
});
289266
});

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ describe('LiveController rendering Tests', () => {
4040

4141
afterEach(() => {
4242
clearDOM();
43+
if (!fetchMock.done()) {
44+
throw new Error('Mocked requests did not match');
45+
}
4346
fetchMock.reset();
4447
});
4548

@@ -57,19 +60,46 @@ describe('LiveController rendering Tests', () => {
5760
expect(controller.dataValue).toEqual({name: 'Kevin'});
5861
});
5962

60-
it('conserves values of fields modified after a render request', async () => {
63+
it('renders over local value in input', async () => {
6164
const data = { name: 'Ryan' };
6265
const { element } = await startStimulus(template(data));
6366

6467
fetchMock.get(
68+
// name=Ryan is sent to the server
6569
'http://localhost/_components/my_component?name=Ryan',
70+
// server changes that data
6671
template({ name: 'Kevin' }),
6772
{ delay: 100 }
6873
);
69-
getByText(element, 'Reload').click();
74+
// type into the input that is not bound to a model
7075
userEvent.type(getByLabelText(element, 'Comments:'), '!!');
76+
getByText(element, 'Reload').click();
7177

7278
await waitFor(() => expect(element).toHaveTextContent('Name: Kevin'));
79+
// value if unmapped input is reset
80+
expect(getByLabelText(element, 'Comments:')).toHaveValue('i like pizza');
81+
expect(document.activeElement.name).toEqual('comments');
82+
});
83+
84+
it('conserves values of fields modified after a render request IF data-live-ignore', async () => {
85+
const data = { name: 'Ryan' };
86+
const { element } = await startStimulus(template(data));
87+
88+
fetchMock.get(
89+
// name=Ryan is sent to the server
90+
'http://localhost/_components/my_component?name=Ryan',
91+
// server changes that data
92+
template({ name: 'Kevin' }),
93+
{ delay: 100 }
94+
);
95+
// type into the input that is not bound to a model
96+
const input = getByLabelText(element, 'Comments:');
97+
input.setAttribute('data-live-ignore', '');
98+
userEvent.type(input, '!!');
99+
getByText(element, 'Reload').click();
100+
101+
await waitFor(() => expect(element).toHaveTextContent('Name: Kevin'));
102+
// value of unmapped input is NOT reset because of data-live-ignore
73103
expect(getByLabelText(element, 'Comments:')).toHaveValue('i like pizza!!');
74104
expect(document.activeElement.name).toEqual('comments');
75105
});
@@ -88,8 +118,10 @@ describe('LiveController rendering Tests', () => {
88118
template({ name: 'Kevin' }, true),
89119
{ delay: 100 }
90120
);
121+
const input = getByLabelText(element, 'Comments:');
122+
input.setAttribute('data-live-ignore', '');
123+
userEvent.type(input, '!!');
91124
getByText(element, 'Reload').click();
92-
userEvent.type(getByLabelText(element, 'Comments:'), '!!');
93125

94126
await waitFor(() => expect(element).toHaveTextContent('Name: Kevin'));
95127
expect(getByLabelText(element, 'Comments:')).toHaveValue('i like pizza!!');
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { normalizeAttributesForComparison } from '../src/normalize_attributes_for_comparison';
2+
3+
const createElement = function(html: string): HTMLElement {
4+
const template = document.createElement('template');
5+
html = html.trim();
6+
template.innerHTML = html;
7+
8+
const child = template.content.firstChild;
9+
if (!child || !(child instanceof HTMLElement)) {
10+
throw new Error('Child not found');
11+
}
12+
13+
return child;
14+
}
15+
16+
describe('normalizeAttributesForComparison', () => {
17+
it('makes no changes if value and attribute not set', () => {
18+
const element = createElement('<div class="foo"></div>');
19+
normalizeAttributesForComparison(element);
20+
expect(element.outerHTML)
21+
.toEqual('<div class="foo"></div>');
22+
});
23+
24+
it('sets the attribute if the value is present', () => {
25+
const element = createElement('<input class="foo">');
26+
element.value = 'set value';
27+
normalizeAttributesForComparison(element);
28+
expect(element.outerHTML)
29+
.toEqual('<input class="foo" value="set value">');
30+
});
31+
32+
it('sets the attribute to empty if the value is empty', () => {
33+
const element = createElement('<input class="foo" value="starting">');
34+
element.value = '';
35+
normalizeAttributesForComparison(element);
36+
expect(element.outerHTML)
37+
.toEqual('<input class="foo" value="">');
38+
});
39+
40+
it('changes the value attribute if value is different', () => {
41+
const element = createElement('<input class="foo" value="starting">');
42+
element.value = 'changed';
43+
normalizeAttributesForComparison(element);
44+
expect(element.outerHTML)
45+
.toEqual('<input class="foo" value="changed">');
46+
});
47+
48+
it('changes the value attribute on a child', () => {
49+
const element = createElement('<div><input id="child" value="original"></div>');
50+
element.querySelector('#child').value = 'changed';
51+
normalizeAttributesForComparison(element);
52+
expect(element.outerHTML)
53+
.toEqual('<div><input id="child" value="changed"></div>');
54+
});
55+
56+
it('changes the value on multiple levels', () => {
57+
const element = createElement('<div><input id="child" value="original"><div><input id="grand_child"></div></div>');
58+
element.querySelector('#child').value = 'changed';
59+
element.querySelector('#grand_child').value = 'changed grand child';
60+
normalizeAttributesForComparison(element);
61+
expect(element.outerHTML)
62+
.toEqual('<div><input id="child" value="changed"><div><input id="grand_child" value="changed grand child"></div></div>');
63+
});
64+
});

0 commit comments

Comments
 (0)