Skip to content

Commit a27fdd3

Browse files
committed
[Live] Fixing bug where inputs would not re-render if the value changed
1 parent 112ed03 commit a27fdd3

File tree

11 files changed

+176
-41
lines changed

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
## 2.0.0
1114

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)