Skip to content

Commit 061c5cb

Browse files
committed
Fixing bug where a parent component's model is not updated if shared inside a child
1 parent 593c01a commit 061c5cb

File tree

3 files changed

+119
-43
lines changed

3 files changed

+119
-43
lines changed

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ var _default = /*#__PURE__*/function (_Controller) {
108108
}, {
109109
key: "connect",
110110
value: function connect() {
111+
var _this2 = this;
112+
111113
// hide "loading" elements to begin with
112114
// This is done with CSS, but only for the most basic cases
113115
this._onLoadingFinish();
@@ -117,6 +119,14 @@ var _default = /*#__PURE__*/function (_Controller) {
117119
}
118120

119121
window.addEventListener('beforeunload', this.markAsWindowUnloaded);
122+
this.element.addEventListener('live:update-model', function (event) {
123+
// ignore events that we dispatched
124+
if (event.target === _this2.element) {
125+
return;
126+
}
127+
128+
_this2._handleChildComponentUpdateModel(event);
129+
});
120130

121131
this._dispatchEvent('live:connect');
122132
}
@@ -149,7 +159,7 @@ var _default = /*#__PURE__*/function (_Controller) {
149159
}, {
150160
key: "action",
151161
value: function action(event) {
152-
var _this2 = this;
162+
var _this3 = this;
153163

154164
// using currentTarget means that the data-action and data-action-name
155165
// must live on the same element: you can't add
@@ -169,9 +179,9 @@ var _default = /*#__PURE__*/function (_Controller) {
169179
// then the action Ajax will start. We want to avoid the
170180
// re-render request from starting after the debounce and
171181
// taking precedence
172-
_this2._clearWaitingDebouncedRenders();
182+
_this3._clearWaitingDebouncedRenders();
173183

174-
_this2._makeRequest(directive.action);
184+
_this3._makeRequest(directive.action);
175185
};
176186

177187
var handled = false;
@@ -196,13 +206,13 @@ var _default = /*#__PURE__*/function (_Controller) {
196206
{
197207
var length = modifier.value ? modifier.value : DEFAULT_DEBOUNCE; // clear any pending renders
198208

199-
if (_this2.actionDebounceTimeout) {
200-
clearTimeout(_this2.actionDebounceTimeout);
201-
_this2.actionDebounceTimeout = null;
209+
if (_this3.actionDebounceTimeout) {
210+
clearTimeout(_this3.actionDebounceTimeout);
211+
_this3.actionDebounceTimeout = null;
202212
}
203213

204-
_this2.actionDebounceTimeout = setTimeout(function () {
205-
_this2.actionDebounceTimeout = null;
214+
_this3.actionDebounceTimeout = setTimeout(function () {
215+
_this3.actionDebounceTimeout = null;
206216

207217
_executeAction();
208218
}, length);
@@ -236,14 +246,14 @@ var _default = /*#__PURE__*/function (_Controller) {
236246
throw new Error("The update() method could not be called for \"".concat(clonedElement.outerHTML, "\": the element must either have a \"data-model\" or \"name\" attribute set to the model name."));
237247
}
238248

239-
this.$updateModel(model, value, element, shouldRender);
249+
this.$updateModel(model, value, shouldRender);
240250
}
241251
}, {
242252
key: "$updateModel",
243-
value: function $updateModel(model, value, element) {
244-
var _this3 = this;
253+
value: function $updateModel(model, value) {
254+
var _this4 = this;
245255

246-
var shouldRender = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true;
256+
var shouldRender = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true;
247257
var directives = (0, _directives_parser.parseDirectives)(model);
248258

249259
if (directives.length > 1) {
@@ -272,7 +282,12 @@ var _default = /*#__PURE__*/function (_Controller) {
272282

273283
if (!(0, _set_deep_data.doesDeepPropertyExist)(this.dataValue, modelName)) {
274284
console.warn("Model \"".concat(modelName, "\" is not a valid data-model value"));
275-
} // we do not send old and new data to the server
285+
}
286+
287+
this._dispatchEvent('live:update-model', {
288+
model: modelName,
289+
value: value
290+
}); // we do not send old and new data to the server
276291
// we merge in the new data now
277292
// TODO: handle edge case for top-level of a model with "exposed" props
278293
// For example, suppose there is a "post" field but "post.title" is exposed.
@@ -301,16 +316,16 @@ var _default = /*#__PURE__*/function (_Controller) {
301316
this._clearWaitingDebouncedRenders();
302317

303318
this.renderDebounceTimeout = setTimeout(function () {
304-
_this3.renderDebounceTimeout = null;
319+
_this4.renderDebounceTimeout = null;
305320

306-
_this3.$render();
321+
_this4.$render();
307322
}, this.debounceValue || DEFAULT_DEBOUNCE);
308323
}
309324
}
310325
}, {
311326
key: "_makeRequest",
312327
value: function _makeRequest(action) {
313-
var _this4 = this;
328+
var _this5 = this;
314329

315330
var _this$urlValue$split = this.urlValue.split('?'),
316331
_this$urlValue$split2 = _slicedToArray(_this$urlValue$split, 2),
@@ -347,15 +362,15 @@ var _default = /*#__PURE__*/function (_Controller) {
347362
this.renderPromiseStack.addPromise(thisPromise);
348363
thisPromise.then(function (response) {
349364
// if another re-render is scheduled, do not "run it over"
350-
if (_this4.renderDebounceTimeout) {
365+
if (_this5.renderDebounceTimeout) {
351366
return;
352367
}
353368

354-
var isMostRecent = _this4.renderPromiseStack.removePromise(thisPromise);
369+
var isMostRecent = _this5.renderPromiseStack.removePromise(thisPromise);
355370

356371
if (isMostRecent) {
357372
response.json().then(function (data) {
358-
_this4._processRerender(data);
373+
_this5._processRerender(data);
359374
});
360375
}
361376
});
@@ -424,21 +439,21 @@ var _default = /*#__PURE__*/function (_Controller) {
424439
}, {
425440
key: "_handleLoadingToggle",
426441
value: function _handleLoadingToggle(isLoading) {
427-
var _this5 = this;
442+
var _this6 = this;
428443

429444
this._getLoadingDirectives().forEach(function (_ref) {
430445
var element = _ref.element,
431446
directives = _ref.directives;
432447

433448
// so we can track, at any point, if an element is in a "loading" state
434449
if (isLoading) {
435-
_this5._addAttributes(element, ['data-live-is-loading']);
450+
_this6._addAttributes(element, ['data-live-is-loading']);
436451
} else {
437-
_this5._removeAttributes(element, ['data-live-is-loading']);
452+
_this6._removeAttributes(element, ['data-live-is-loading']);
438453
}
439454

440455
directives.forEach(function (directive) {
441-
_this5._handleLoadingDirective(element, isLoading, directive);
456+
_this6._handleLoadingDirective(element, isLoading, directive);
442457
});
443458
});
444459
}
@@ -452,50 +467,50 @@ var _default = /*#__PURE__*/function (_Controller) {
452467
}, {
453468
key: "_handleLoadingDirective",
454469
value: function _handleLoadingDirective(element, isLoading, directive) {
455-
var _this6 = this;
470+
var _this7 = this;
456471

457472
var finalAction = parseLoadingAction(directive.action, isLoading);
458473
var loadingDirective = null;
459474

460475
switch (finalAction) {
461476
case 'show':
462477
loadingDirective = function loadingDirective() {
463-
_this6._showElement(element);
478+
_this7._showElement(element);
464479
};
465480

466481
break;
467482

468483
case 'hide':
469484
loadingDirective = function loadingDirective() {
470-
return _this6._hideElement(element);
485+
return _this7._hideElement(element);
471486
};
472487

473488
break;
474489

475490
case 'addClass':
476491
loadingDirective = function loadingDirective() {
477-
return _this6._addClass(element, directive.args);
492+
return _this7._addClass(element, directive.args);
478493
};
479494

480495
break;
481496

482497
case 'removeClass':
483498
loadingDirective = function loadingDirective() {
484-
return _this6._removeClass(element, directive.args);
499+
return _this7._removeClass(element, directive.args);
485500
};
486501

487502
break;
488503

489504
case 'addAttribute':
490505
loadingDirective = function loadingDirective() {
491-
return _this6._addAttributes(element, directive.args);
506+
return _this7._addAttributes(element, directive.args);
492507
};
493508

494509
break;
495510

496511
case 'removeAttribute':
497512
loadingDirective = function loadingDirective() {
498-
return _this6._removeAttributes(element, directive.args);
513+
return _this7._removeAttributes(element, directive.args);
499514
};
500515

501516
break;
@@ -599,7 +614,7 @@ var _default = /*#__PURE__*/function (_Controller) {
599614
}, {
600615
key: "_executeMorphdom",
601616
value: function _executeMorphdom(newHtml) {
602-
var _this7 = this;
617+
var _this8 = this;
603618

604619
// https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro#answer-35385518
605620
function htmlToElement(html) {
@@ -618,7 +633,7 @@ var _default = /*#__PURE__*/function (_Controller) {
618633
} // avoid updating child components: they will handle themselves
619634

620635

621-
if (fromEl.hasAttribute('data-controller') && fromEl.getAttribute('data-controller').split(' ').indexOf('live') !== -1 && fromEl !== _this7.element) {
636+
if (fromEl.hasAttribute('data-controller') && fromEl.getAttribute('data-controller').split(' ').indexOf('live') !== -1 && fromEl !== _this8.element) {
622637
return false;
623638
}
624639

@@ -629,7 +644,7 @@ var _default = /*#__PURE__*/function (_Controller) {
629644
}, {
630645
key: "_initiatePolling",
631646
value: function _initiatePolling(rawPollConfig) {
632-
var _this8 = this;
647+
var _this9 = this;
633648

634649
var directives = (0, _directives_parser.parseDirectives)(rawPollConfig || '$render');
635650
directives.forEach(function (directive) {
@@ -648,23 +663,23 @@ var _default = /*#__PURE__*/function (_Controller) {
648663
}
649664
});
650665

651-
_this8.startPoll(directive.action, duration);
666+
_this9._startPoll(directive.action, duration);
652667
});
653668
}
654669
}, {
655-
key: "startPoll",
656-
value: function startPoll(actionName, duration) {
657-
var _this9 = this;
670+
key: "_startPoll",
671+
value: function _startPoll(actionName, duration) {
672+
var _this10 = this;
658673

659674
var callback;
660675

661676
if (actionName.charAt(0) === '$') {
662677
callback = function callback() {
663-
_this9[actionName]();
678+
_this10[actionName]();
664679
};
665680
} else {
666681
callback = function callback() {
667-
_this9._makeRequest(actionName);
682+
_this10._makeRequest(actionName);
668683
};
669684
}
670685

@@ -685,6 +700,15 @@ var _default = /*#__PURE__*/function (_Controller) {
685700
});
686701
return this.element.dispatchEvent(userEvent);
687702
}
703+
}, {
704+
key: "_handleChildComponentUpdateModel",
705+
value: function _handleChildComponentUpdateModel(event) {
706+
if (!(0, _set_deep_data.doesDeepPropertyExist)(this.dataValue, event.detail.model)) {
707+
return;
708+
}
709+
710+
this.$updateModel(event.detail.model, event.detail.value, false);
711+
}
688712
}]);
689713

690714
return _default;

src/LiveComponent/assets/src/live_controller.js

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ export default class extends Controller {
5959

6060
window.addEventListener('beforeunload', this.markAsWindowUnloaded);
6161

62+
this.element.addEventListener('live:update-model', (event) => {
63+
// ignore events that we dispatched
64+
if (event.target === this.element) {
65+
return;
66+
}
67+
68+
this._handleChildComponentUpdateModel(event);
69+
});
70+
6271
this._dispatchEvent('live:connect');
6372
}
6473

@@ -169,10 +178,10 @@ export default class extends Controller {
169178
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.`);
170179
}
171180

172-
this.$updateModel(model, value, element, shouldRender);
181+
this.$updateModel(model, value, shouldRender);
173182
}
174183

175-
$updateModel(model, value, element, shouldRender = true) {
184+
$updateModel(model, value, shouldRender = true) {
176185
const directives = parseDirectives(model);
177186
if (directives.length > 1) {
178187
throw new Error(`The data-model="${model}" format is invalid: it does not support multiple directives (i.e. remove any spaces).`);
@@ -201,6 +210,11 @@ export default class extends Controller {
201210
console.warn(`Model "${modelName}" is not a valid data-model value`);
202211
}
203212

213+
this._dispatchEvent('live:update-model', {
214+
model: modelName,
215+
value,
216+
})
217+
204218
// we do not send old and new data to the server
205219
// we merge in the new data now
206220
// TODO: handle edge case for top-level of a model with "exposed" props
@@ -531,11 +545,11 @@ export default class extends Controller {
531545
}
532546
});
533547

534-
this.startPoll(directive.action, duration);
548+
this._startPoll(directive.action, duration);
535549
})
536550
}
537551

538-
startPoll(actionName, duration) {
552+
_startPoll(actionName, duration) {
539553
let callback;
540554
if (actionName.charAt(0) === '$') {
541555
callback = () => {
@@ -561,6 +575,14 @@ export default class extends Controller {
561575

562576
return this.element.dispatchEvent(userEvent);
563577
}
578+
579+
_handleChildComponentUpdateModel(event) {
580+
if (!doesDeepPropertyExist(this.dataValue, event.detail.model)) {
581+
return;
582+
}
583+
584+
this.$updateModel(event.detail.model, event.detail.value, false);
585+
}
564586
}
565587

566588
/**

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,34 @@ describe('LiveController parent -> child component tests', () => {
103103
// the child component should *not* have updated
104104
expect(element).toHaveTextContent('Description in child: i love popcorn');
105105
});
106+
107+
it('updates child model and parent model in a deferred way', async () => {
108+
const data = { title: 'Parent component', description: 'i love' };
109+
const { element, controller } = await startStimulusForParentChild(
110+
parentTemplate(data),
111+
data,
112+
{ description: data.description }
113+
);
114+
115+
// verify the child request contains the correct description & re-render
116+
fetchMock.get('http://localhost/_components/my_component?description=i+love+turtles', {
117+
html: childTemplate({ description: 'i love turtles' }),
118+
data: { description: 'i love turtles' }
119+
});
120+
121+
// change the description in the child
122+
const inputElement = getByLabelText(element, 'Description:');
123+
await userEvent.type(inputElement, ' turtles');
124+
125+
// wait for the render to complete
126+
await waitFor(() => expect(element).toHaveTextContent('Description in child: i love turtles'));
127+
128+
// the parent should not re-render
129+
expect(element).not.toHaveTextContent('Description in parent: i love turtles');
130+
// but the value DID update
131+
expect(controller.dataValue.description).toEqual('i love turtles');
132+
});
133+
134+
// TODO - what if a child component re-renders and comes down with
135+
// a changed set of data? Should that update the parent's data?
106136
});

0 commit comments

Comments
 (0)