Skip to content

Commit 16d8629

Browse files
committed
bug #419 [LiveComponent] Avoiding model re-renders during an action (weaverryan)
This PR was merged into the 2.x branch. Discussion ---------- [LiveComponent] Avoiding model re-renders during an action | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | Fixes issue reported on Slack by `@FrancoisPog` | License | MIT This is so that we can guarantee that an action's response WILL always be used. Previously, an action's response would be ignored if a model-update re-render was triggered while the action's Ajax call was completing. Cheers! Commits ------- 73a2a3c Avoiding model re-renders during an action
2 parents 5893aca + 73a2a3c commit 16d8629

File tree

4 files changed

+92
-14
lines changed

4 files changed

+92
-14
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# CHANGELOG
22

3+
## 2.3.1
4+
5+
- [BEHAVIOR CHANGE] If an action Ajax call is still processing and a
6+
model update occurs, the component will _no_ longer re-render. The
7+
model will be updated internally, but not re-rendered (so, any
8+
model updates would effectively have the `|norender` modifier). See #419.
9+
310
## 2.3.0
411

512
- [BC BREAK] The `data-action="live#update"` attribute must now be

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,8 +1084,8 @@ function getModelDirectiveFromInput(element, throwOnMissing = true) {
10841084
}
10851085
if (element.getAttribute('name')) {
10861086
const formElement = element.closest('form');
1087-
if (formElement && formElement.dataset.model) {
1088-
const directives = parseDirectives(formElement.dataset.model);
1087+
if (formElement && ('model' in formElement.dataset)) {
1088+
const directives = parseDirectives(formElement.dataset.model || '*');
10891089
const directive = directives[0];
10901090
if (directive.args.length > 0 || directive.named.length > 0) {
10911091
throw new Error(`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`);
@@ -1195,6 +1195,7 @@ class default_1 extends Controller {
11951195
this.renderDebounceTimeout = null;
11961196
this.actionDebounceTimeout = null;
11971197
this.renderPromiseStack = new PromiseStack();
1198+
this.isActionProcessing = false;
11981199
this.pollingIntervals = [];
11991200
this.isWindowUnloaded = false;
12001201
this.originalDataJSON = '{}';
@@ -1222,9 +1223,7 @@ class default_1 extends Controller {
12221223
if (!(this.element instanceof HTMLElement)) {
12231224
throw new Error('Invalid Element Type');
12241225
}
1225-
if (this.element.dataset.poll !== undefined) {
1226-
this._initiatePolling(this.element.dataset.poll);
1227-
}
1226+
this._initiatePolling();
12281227
window.addEventListener('beforeunload', this.markAsWindowUnloaded);
12291228
this._startAttributesMutationObserver();
12301229
this.element.addEventListener('live:update-model', this.handleUpdateModelEvent);
@@ -1234,9 +1233,7 @@ class default_1 extends Controller {
12341233
this._dispatchEvent('live:connect', { controller: this });
12351234
}
12361235
disconnect() {
1237-
this.pollingIntervals.forEach((interval) => {
1238-
clearInterval(interval);
1239-
});
1236+
this._stopAllPolling();
12401237
window.removeEventListener('beforeunload', this.markAsWindowUnloaded);
12411238
this.element.removeEventListener('live:update-model', this.handleUpdateModelEvent);
12421239
this.element.removeEventListener('input', this.handleInputEvent);
@@ -1387,7 +1384,7 @@ class default_1 extends Controller {
13871384
}
13881385
this.valueStore.set(modelName, value);
13891386
this.unsyncedInputs.remove(modelName);
1390-
if (shouldRender) {
1387+
if (shouldRender && !this.isActionProcessing) {
13911388
this._clearWaitingDebouncedRenders();
13921389
let debounce = this.getDefaultDebounce();
13931390
if (options.debounce !== undefined && options.debounce !== null) {
@@ -1412,6 +1409,7 @@ class default_1 extends Controller {
14121409
'Accept': 'application/vnd.live-component+html',
14131410
};
14141411
if (action) {
1412+
this.isActionProcessing = true;
14151413
url += `/${encodeURIComponent(action)}`;
14161414
if (this.csrfValue) {
14171415
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfValue;
@@ -1437,6 +1435,9 @@ class default_1 extends Controller {
14371435
const reRenderPromise = new ReRenderPromise(thisPromise, this.unsyncedInputs.clone());
14381436
this.renderPromiseStack.addPromise(reRenderPromise);
14391437
thisPromise.then((response) => {
1438+
if (action) {
1439+
this.isActionProcessing = false;
1440+
}
14401441
if (this.renderDebounceTimeout) {
14411442
return;
14421443
}
@@ -1665,7 +1666,12 @@ class default_1 extends Controller {
16651666
}
16661667
this._updateModelFromElement(target, 'change');
16671668
}
1668-
_initiatePolling(rawPollConfig) {
1669+
_initiatePolling() {
1670+
this._stopAllPolling();
1671+
if (this.element.dataset.poll === undefined) {
1672+
return;
1673+
}
1674+
const rawPollConfig = this.element.dataset.poll;
16691675
const directives = parseDirectives(rawPollConfig || '$render');
16701676
directives.forEach((directive) => {
16711677
let duration = 2000;
@@ -1790,9 +1796,12 @@ class default_1 extends Controller {
17901796
const element = this.element;
17911797
this.mutationObserver = new MutationObserver((mutations) => {
17921798
mutations.forEach((mutation) => {
1793-
if (mutation.type === 'attributes' && !element.dataset.originalData) {
1794-
this.originalDataJSON = this.valueStore.asJson();
1795-
this._exposeOriginalData();
1799+
if (mutation.type === 'attributes') {
1800+
if (!element.dataset.originalData) {
1801+
this.originalDataJSON = this.valueStore.asJson();
1802+
this._exposeOriginalData();
1803+
}
1804+
this._initiatePolling();
17961805
}
17971806
});
17981807
});
@@ -1803,6 +1812,11 @@ class default_1 extends Controller {
18031812
getDefaultDebounce() {
18041813
return this.hasDebounceValue ? this.debounceValue : DEFAULT_DEBOUNCE;
18051814
}
1815+
_stopAllPolling() {
1816+
this.pollingIntervals.forEach((interval) => {
1817+
clearInterval(interval);
1818+
});
1819+
}
18061820
}
18071821
default_1.values = {
18081822
url: String,

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export default class extends Controller implements LiveController {
6868
*/
6969
renderPromiseStack = new PromiseStack();
7070

71+
isActionProcessing = false;
72+
7173
pollingIntervals: NodeJS.Timer[] = [];
7274

7375
isWindowUnloaded = false;
@@ -393,7 +395,8 @@ export default class extends Controller implements LiveController {
393395
// any Ajax request that starts from this moment WILL include this
394396
this.unsyncedInputs.remove(modelName);
395397

396-
if (shouldRender) {
398+
// skip rendering if there is an action Ajax call processing
399+
if (shouldRender && !this.isActionProcessing) {
397400
// clear any pending renders
398401
this._clearWaitingDebouncedRenders();
399402

@@ -425,6 +428,8 @@ export default class extends Controller implements LiveController {
425428
};
426429

427430
if (action) {
431+
this.isActionProcessing = true;
432+
428433
url += `/${encodeURIComponent(action)}`;
429434

430435
if (this.csrfValue) {
@@ -455,6 +460,10 @@ export default class extends Controller implements LiveController {
455460
const reRenderPromise = new ReRenderPromise(thisPromise, this.unsyncedInputs.clone());
456461
this.renderPromiseStack.addPromise(reRenderPromise);
457462
thisPromise.then((response) => {
463+
if (action) {
464+
this.isActionProcessing = false;
465+
}
466+
458467
// if another re-render is scheduled, do not "run it over"
459468
if (this.renderDebounceTimeout) {
460469
return;

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,52 @@ describe('LiveController Action Tests', () => {
101101

102102
await waitFor(() => expect(test.element).toHaveTextContent('Food: pizza'));
103103
});
104+
105+
it('prevents re-render model updates while action Ajax is pending', async () => {
106+
const test = await createTest({ comment: 'donut', isSaved: false }, (data: any) => `
107+
<div ${initComponent(data)}>
108+
<input data-model="comment" value="${data.comment}">
109+
110+
${data.isSaved ? 'Comment Saved!' : ''}
111+
112+
<span>${data.comment}</span>
113+
114+
<button data-action="live#action" data-action-name="save">Save</button>
115+
<button data-action="live#$render">Reload</button>
116+
</div>
117+
`);
118+
119+
// ONLY a post is sent, not a re-render GET
120+
test.expectsAjaxCall('post')
121+
.expectSentData(test.initialData)
122+
.expectActionCalled('save')
123+
.delayResponse(1000) // longer than debounce, so updating comment could potentially send a request
124+
.serverWillChangeData((data: any) => {
125+
// server marks component as "saved"
126+
data.isSaved = true;
127+
})
128+
.init();
129+
130+
// save first, then type into the box
131+
getByText(test.element, 'Save').click();
132+
await userEvent.type(test.queryByDataModel('comment'), ' holes');
133+
134+
await waitFor(() => expect(test.element).toHaveTextContent('Comment Saved!'));
135+
// render has not happened yet
136+
expect(test.element).not.toHaveTextContent('donut holes');
137+
138+
// trigger a render, it should now reflect the changed value
139+
test.expectsAjaxCall('get')
140+
.expectSentData({comment: 'donut holes', isSaved: true})
141+
.init();
142+
getByText(test.element, 'Reload').click();
143+
await waitFor(() => expect(test.element).toHaveTextContent('donut holes'));
144+
145+
// now check that model updating works again
146+
test.expectsAjaxCall('get')
147+
.expectSentData({comment: 'donut holes are delicious', isSaved: true})
148+
.init();
149+
await userEvent.type(test.queryByDataModel('comment'), ' are delicious');
150+
await waitFor(() => expect(test.element).toHaveTextContent('donut holes are delicious'));
151+
});
104152
});

0 commit comments

Comments
 (0)