Skip to content

Commit ba3d740

Browse files
committed
Fixing bug where a parent component's model is not updated if shared inside a child
1 parent 763e630 commit ba3d740

File tree

3 files changed

+119
-43
lines changed

3 files changed

+119
-43
lines changed

src/LiveComponent/assets/dist/live-controller.esm.js

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,8 @@ var _default = /*#__PURE__*/function (_Controller) {
12501250
}, {
12511251
key: "connect",
12521252
value: function connect() {
1253+
var _this2 = this;
1254+
12531255
// hide "loading" elements to begin with
12541256
// This is done with CSS, but only for the most basic cases
12551257
this._onLoadingFinish();
@@ -1259,6 +1261,14 @@ var _default = /*#__PURE__*/function (_Controller) {
12591261
}
12601262

12611263
window.addEventListener('beforeunload', this.markAsWindowUnloaded);
1264+
this.element.addEventListener('live:update-model', function (event) {
1265+
// ignore events that we dispatched
1266+
if (event.target === _this2.element) {
1267+
return;
1268+
}
1269+
1270+
_this2._handleChildComponentUpdateModel(event);
1271+
});
12621272

12631273
this._dispatchEvent('live:connect');
12641274
}
@@ -1291,7 +1301,7 @@ var _default = /*#__PURE__*/function (_Controller) {
12911301
}, {
12921302
key: "action",
12931303
value: function action(event) {
1294-
var _this2 = this;
1304+
var _this3 = this;
12951305

12961306
// using currentTarget means that the data-action and data-action-name
12971307
// must live on the same element: you can't add
@@ -1311,9 +1321,9 @@ var _default = /*#__PURE__*/function (_Controller) {
13111321
// then the action Ajax will start. We want to avoid the
13121322
// re-render request from starting after the debounce and
13131323
// taking precedence
1314-
_this2._clearWaitingDebouncedRenders();
1324+
_this3._clearWaitingDebouncedRenders();
13151325

1316-
_this2._makeRequest(directive.action);
1326+
_this3._makeRequest(directive.action);
13171327
};
13181328

13191329
var handled = false;
@@ -1338,13 +1348,13 @@ var _default = /*#__PURE__*/function (_Controller) {
13381348
{
13391349
var length = modifier.value ? modifier.value : DEFAULT_DEBOUNCE; // clear any pending renders
13401350

1341-
if (_this2.actionDebounceTimeout) {
1342-
clearTimeout(_this2.actionDebounceTimeout);
1343-
_this2.actionDebounceTimeout = null;
1351+
if (_this3.actionDebounceTimeout) {
1352+
clearTimeout(_this3.actionDebounceTimeout);
1353+
_this3.actionDebounceTimeout = null;
13441354
}
13451355

1346-
_this2.actionDebounceTimeout = setTimeout(function () {
1347-
_this2.actionDebounceTimeout = null;
1356+
_this3.actionDebounceTimeout = setTimeout(function () {
1357+
_this3.actionDebounceTimeout = null;
13481358

13491359
_executeAction();
13501360
}, length);
@@ -1378,14 +1388,14 @@ var _default = /*#__PURE__*/function (_Controller) {
13781388
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."));
13791389
}
13801390

1381-
this.$updateModel(model, value, element, shouldRender);
1391+
this.$updateModel(model, value, shouldRender);
13821392
}
13831393
}, {
13841394
key: "$updateModel",
1385-
value: function $updateModel(model, value, element) {
1386-
var _this3 = this;
1395+
value: function $updateModel(model, value) {
1396+
var _this4 = this;
13871397

1388-
var shouldRender = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true;
1398+
var shouldRender = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true;
13891399
var directives = parseDirectives(model);
13901400

13911401
if (directives.length > 1) {
@@ -1414,7 +1424,12 @@ var _default = /*#__PURE__*/function (_Controller) {
14141424

14151425
if (!doesDeepPropertyExist(this.dataValue, modelName)) {
14161426
console.warn("Model \"".concat(modelName, "\" is not a valid data-model value"));
1417-
} // we do not send old and new data to the server
1427+
}
1428+
1429+
this._dispatchEvent('live:update-model', {
1430+
model: modelName,
1431+
value: value
1432+
}); // we do not send old and new data to the server
14181433
// we merge in the new data now
14191434
// TODO: handle edge case for top-level of a model with "exposed" props
14201435
// For example, suppose there is a "post" field but "post.title" is exposed.
@@ -1443,16 +1458,16 @@ var _default = /*#__PURE__*/function (_Controller) {
14431458
this._clearWaitingDebouncedRenders();
14441459

14451460
this.renderDebounceTimeout = setTimeout(function () {
1446-
_this3.renderDebounceTimeout = null;
1461+
_this4.renderDebounceTimeout = null;
14471462

1448-
_this3.$render();
1463+
_this4.$render();
14491464
}, this.debounceValue || DEFAULT_DEBOUNCE);
14501465
}
14511466
}
14521467
}, {
14531468
key: "_makeRequest",
14541469
value: function _makeRequest(action) {
1455-
var _this4 = this;
1470+
var _this5 = this;
14561471

14571472
var _this$urlValue$split = this.urlValue.split('?'),
14581473
_this$urlValue$split2 = _slicedToArray(_this$urlValue$split, 2),
@@ -1489,15 +1504,15 @@ var _default = /*#__PURE__*/function (_Controller) {
14891504
this.renderPromiseStack.addPromise(thisPromise);
14901505
thisPromise.then(function (response) {
14911506
// if another re-render is scheduled, do not "run it over"
1492-
if (_this4.renderDebounceTimeout) {
1507+
if (_this5.renderDebounceTimeout) {
14931508
return;
14941509
}
14951510

1496-
var isMostRecent = _this4.renderPromiseStack.removePromise(thisPromise);
1511+
var isMostRecent = _this5.renderPromiseStack.removePromise(thisPromise);
14971512

14981513
if (isMostRecent) {
14991514
response.json().then(function (data) {
1500-
_this4._processRerender(data);
1515+
_this5._processRerender(data);
15011516
});
15021517
}
15031518
});
@@ -1566,21 +1581,21 @@ var _default = /*#__PURE__*/function (_Controller) {
15661581
}, {
15671582
key: "_handleLoadingToggle",
15681583
value: function _handleLoadingToggle(isLoading) {
1569-
var _this5 = this;
1584+
var _this6 = this;
15701585

15711586
this._getLoadingDirectives().forEach(function (_ref) {
15721587
var element = _ref.element,
15731588
directives = _ref.directives;
15741589

15751590
// so we can track, at any point, if an element is in a "loading" state
15761591
if (isLoading) {
1577-
_this5._addAttributes(element, ['data-live-is-loading']);
1592+
_this6._addAttributes(element, ['data-live-is-loading']);
15781593
} else {
1579-
_this5._removeAttributes(element, ['data-live-is-loading']);
1594+
_this6._removeAttributes(element, ['data-live-is-loading']);
15801595
}
15811596

15821597
directives.forEach(function (directive) {
1583-
_this5._handleLoadingDirective(element, isLoading, directive);
1598+
_this6._handleLoadingDirective(element, isLoading, directive);
15841599
});
15851600
});
15861601
}
@@ -1594,50 +1609,50 @@ var _default = /*#__PURE__*/function (_Controller) {
15941609
}, {
15951610
key: "_handleLoadingDirective",
15961611
value: function _handleLoadingDirective(element, isLoading, directive) {
1597-
var _this6 = this;
1612+
var _this7 = this;
15981613

15991614
var finalAction = parseLoadingAction(directive.action, isLoading);
16001615
var loadingDirective = null;
16011616

16021617
switch (finalAction) {
16031618
case 'show':
16041619
loadingDirective = function loadingDirective() {
1605-
_this6._showElement(element);
1620+
_this7._showElement(element);
16061621
};
16071622

16081623
break;
16091624

16101625
case 'hide':
16111626
loadingDirective = function loadingDirective() {
1612-
return _this6._hideElement(element);
1627+
return _this7._hideElement(element);
16131628
};
16141629

16151630
break;
16161631

16171632
case 'addClass':
16181633
loadingDirective = function loadingDirective() {
1619-
return _this6._addClass(element, directive.args);
1634+
return _this7._addClass(element, directive.args);
16201635
};
16211636

16221637
break;
16231638

16241639
case 'removeClass':
16251640
loadingDirective = function loadingDirective() {
1626-
return _this6._removeClass(element, directive.args);
1641+
return _this7._removeClass(element, directive.args);
16271642
};
16281643

16291644
break;
16301645

16311646
case 'addAttribute':
16321647
loadingDirective = function loadingDirective() {
1633-
return _this6._addAttributes(element, directive.args);
1648+
return _this7._addAttributes(element, directive.args);
16341649
};
16351650

16361651
break;
16371652

16381653
case 'removeAttribute':
16391654
loadingDirective = function loadingDirective() {
1640-
return _this6._removeAttributes(element, directive.args);
1655+
return _this7._removeAttributes(element, directive.args);
16411656
};
16421657

16431658
break;
@@ -1741,7 +1756,7 @@ var _default = /*#__PURE__*/function (_Controller) {
17411756
}, {
17421757
key: "_executeMorphdom",
17431758
value: function _executeMorphdom(newHtml) {
1744-
var _this7 = this;
1759+
var _this8 = this;
17451760

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

17621777

1763-
if (fromEl.hasAttribute('data-controller') && fromEl.getAttribute('data-controller').split(' ').indexOf('live') !== -1 && fromEl !== _this7.element) {
1778+
if (fromEl.hasAttribute('data-controller') && fromEl.getAttribute('data-controller').split(' ').indexOf('live') !== -1 && fromEl !== _this8.element) {
17641779
return false;
17651780
}
17661781

@@ -1771,7 +1786,7 @@ var _default = /*#__PURE__*/function (_Controller) {
17711786
}, {
17721787
key: "_initiatePolling",
17731788
value: function _initiatePolling(rawPollConfig) {
1774-
var _this8 = this;
1789+
var _this9 = this;
17751790

17761791
var directives = parseDirectives(rawPollConfig || '$render');
17771792
directives.forEach(function (directive) {
@@ -1790,23 +1805,23 @@ var _default = /*#__PURE__*/function (_Controller) {
17901805
}
17911806
});
17921807

1793-
_this8.startPoll(directive.action, duration);
1808+
_this9._startPoll(directive.action, duration);
17941809
});
17951810
}
17961811
}, {
1797-
key: "startPoll",
1798-
value: function startPoll(actionName, duration) {
1799-
var _this9 = this;
1812+
key: "_startPoll",
1813+
value: function _startPoll(actionName, duration) {
1814+
var _this10 = this;
18001815

18011816
var callback;
18021817

18031818
if (actionName.charAt(0) === '$') {
18041819
callback = function callback() {
1805-
_this9[actionName]();
1820+
_this10[actionName]();
18061821
};
18071822
} else {
18081823
callback = function callback() {
1809-
_this9._makeRequest(actionName);
1824+
_this10._makeRequest(actionName);
18101825
};
18111826
}
18121827

@@ -1827,6 +1842,15 @@ var _default = /*#__PURE__*/function (_Controller) {
18271842
});
18281843
return this.element.dispatchEvent(userEvent);
18291844
}
1845+
}, {
1846+
key: "_handleChildComponentUpdateModel",
1847+
value: function _handleChildComponentUpdateModel(event) {
1848+
if (!doesDeepPropertyExist(this.dataValue, event.detail.model)) {
1849+
return;
1850+
}
1851+
1852+
this.$updateModel(event.detail.model, event.detail.value, false);
1853+
}
18301854
}]);
18311855

18321856
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)