Skip to content

Commit 2a49974

Browse files
committed
bug #523 [Live] Fixed bug with "unsynced" inputs and <form data-model> (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Live] Fixed bug with "unsynced" inputs and <form data-model> | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | none | License | MIT This fixes an unreleased bug. In a somewhat edge-case use with forms, as you typed into the fields, the values you were typing would get run-over/replaced by the old values from the server - i.e. some of what you are typing gets deleted as you type. That ends up being 0️⃣ fun! The technical details: > If a field is modified during an Ajax request, previously, for model fields, after re-rendering, the modified values from the ValueStore were set back onto those fields via SetValueOntoModelFieldsPlugin. Effectively, morphdom would use the new value from the server (which is not correct) momentarily, and then we would reset it back to the modified value. > However, that didn't work for fields inside of a <form data-model> that use the name attribute, as the "auto-set model field values" functionality purposely doesn't apply to those. > So, the system was made smarter. It now keeps track of *all* field elements that have been modified (i.e. are unsynced). Then, only when those values are actually *sent* to the server on an Ajax request, those are removed as unsynced elements. This means that morphdom will now see all unsynced fields during re-rendering and will prefer the browser values over the server values. Cheers! Commits ------- ecf124f [Live] Fixed bug with "unsynced" inputs and <form data-model>
2 parents 866cbdc + ecf124f commit 2a49974

File tree

6 files changed

+168
-67
lines changed

6 files changed

+168
-67
lines changed

src/LiveComponent/assets/dist/live_controller.js

+33-46
Original file line numberDiff line numberDiff line change
@@ -1184,7 +1184,7 @@ function normalizeAttributesForComparison(element) {
11841184
});
11851185
}
11861186

1187-
function executeMorphdom(rootFromElement, rootToElement, modifiedElements, getElementValue, childComponents, findChildComponent, getKeyFromElement) {
1187+
function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, getElementValue, childComponents, findChildComponent, getKeyFromElement) {
11881188
const childComponentMap = new Map();
11891189
childComponents.forEach((childComponent) => {
11901190
childComponentMap.set(childComponent.element, childComponent);
@@ -1215,7 +1215,7 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedElements, getEl
12151215
if (childComponent) {
12161216
return childComponent.updateFromNewElement(toEl);
12171217
}
1218-
if (modifiedElements.includes(fromEl)) {
1218+
if (modifiedFieldElements.includes(fromEl)) {
12191219
setValueOnElement(toEl, getElementValue(fromEl));
12201220
}
12211221
if (fromEl.isEqualNode(toEl)) {
@@ -1238,35 +1238,6 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedElements, getEl
12381238
});
12391239
}
12401240

1241-
/******************************************************************************
1242-
Copyright (c) Microsoft Corporation.
1243-
1244-
Permission to use, copy, modify, and/or distribute this software for any
1245-
purpose with or without fee is hereby granted.
1246-
1247-
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
1248-
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
1249-
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
1250-
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
1251-
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
1252-
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
1253-
PERFORMANCE OF THIS SOFTWARE.
1254-
***************************************************************************** */
1255-
1256-
function __classPrivateFieldGet(receiver, state, kind, f) {
1257-
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
1258-
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
1259-
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
1260-
}
1261-
1262-
function __classPrivateFieldSet(receiver, state, value, kind, f) {
1263-
if (kind === "m") throw new TypeError("Private method is not writable");
1264-
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
1265-
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
1266-
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
1267-
}
1268-
1269-
var _UnsyncedInputContainer_mappedFields, _UnsyncedInputContainer_unmappedFields;
12701241
class UnsyncedInputsTracker {
12711242
constructor(component, modelElementResolver) {
12721243
this.elementEventListeners = [
@@ -1307,36 +1278,51 @@ class UnsyncedInputsTracker {
13071278
this.unsyncedInputs.add(element, modelName);
13081279
}
13091280
getUnsyncedInputs() {
1310-
return this.unsyncedInputs.all();
1281+
return this.unsyncedInputs.allUnsyncedInputs();
1282+
}
1283+
getUnsyncedModels() {
1284+
return Array.from(this.unsyncedInputs.getUnsyncedModelNames());
13111285
}
1312-
getModifiedModels() {
1313-
return Array.from(this.unsyncedInputs.getModifiedModels());
1286+
resetUnsyncedFields() {
1287+
this.unsyncedInputs.resetUnsyncedFields();
13141288
}
13151289
}
13161290
class UnsyncedInputContainer {
13171291
constructor() {
1318-
_UnsyncedInputContainer_mappedFields.set(this, void 0);
1319-
_UnsyncedInputContainer_unmappedFields.set(this, []);
1320-
__classPrivateFieldSet(this, _UnsyncedInputContainer_mappedFields, new Map(), "f");
1292+
this.unsyncedNonModelFields = [];
1293+
this.unsyncedModelNames = [];
1294+
this.unsyncedModelFields = new Map();
13211295
}
13221296
add(element, modelName = null) {
13231297
if (modelName) {
1324-
__classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").set(modelName, element);
1298+
this.unsyncedModelFields.set(modelName, element);
1299+
if (!this.unsyncedModelNames.includes(modelName)) {
1300+
this.unsyncedModelNames.push(modelName);
1301+
}
13251302
return;
13261303
}
1327-
__classPrivateFieldGet(this, _UnsyncedInputContainer_unmappedFields, "f").push(element);
1304+
this.unsyncedNonModelFields.push(element);
13281305
}
1329-
all() {
1330-
return [...__classPrivateFieldGet(this, _UnsyncedInputContainer_unmappedFields, "f"), ...__classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").values()];
1306+
resetUnsyncedFields() {
1307+
this.unsyncedModelFields.forEach((value, key) => {
1308+
if (!this.unsyncedModelNames.includes(key)) {
1309+
this.unsyncedModelFields.delete(key);
1310+
}
1311+
});
1312+
}
1313+
allUnsyncedInputs() {
1314+
return [...this.unsyncedNonModelFields, ...this.unsyncedModelFields.values()];
13311315
}
13321316
markModelAsSynced(modelName) {
1333-
__classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").delete(modelName);
1317+
const index = this.unsyncedModelNames.indexOf(modelName);
1318+
if (index !== -1) {
1319+
this.unsyncedModelNames.splice(index, 1);
1320+
}
13341321
}
1335-
getModifiedModels() {
1336-
return Array.from(__classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").keys());
1322+
getUnsyncedModelNames() {
1323+
return this.unsyncedModelNames;
13371324
}
13381325
}
1339-
_UnsyncedInputContainer_mappedFields = new WeakMap(), _UnsyncedInputContainer_unmappedFields = new WeakMap();
13401326

13411327
class HookManager {
13421328
constructor() {
@@ -1452,7 +1438,7 @@ class Component {
14521438
return promise;
14531439
}
14541440
getUnsyncedModels() {
1455-
return this.unsyncedInputsTracker.getModifiedModels();
1441+
return this.unsyncedInputsTracker.getUnsyncedModels();
14561442
}
14571443
addChild(child, modelBindings = []) {
14581444
if (!child.id) {
@@ -1521,6 +1507,7 @@ class Component {
15211507
performRequest() {
15221508
const thisPromiseResolve = this.nextRequestPromiseResolve;
15231509
this.resetPromise();
1510+
this.unsyncedInputsTracker.resetUnsyncedFields();
15241511
this.backendRequest = this.backend.makeRequest(this.valueStore.all(), this.pendingActions, this.valueStore.updatedModels, this.getChildrenFingerprints());
15251512
this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest);
15261513
this.pendingActions = [];

src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts

+52-14
Original file line numberDiff line numberDiff line change
@@ -58,49 +58,87 @@ export default class {
5858
}
5959

6060
getUnsyncedInputs(): HTMLElement[] {
61-
return this.unsyncedInputs.all();
61+
return this.unsyncedInputs.allUnsyncedInputs();
6262
}
6363

64-
getModifiedModels(): string[] {
65-
return Array.from(this.unsyncedInputs.getModifiedModels());
64+
getUnsyncedModels(): string[] {
65+
return Array.from(this.unsyncedInputs.getUnsyncedModelNames());
66+
}
67+
68+
resetUnsyncedFields(): void {
69+
this.unsyncedInputs.resetUnsyncedFields();
6670
}
6771
}
6872

6973
/**
7074
* Tracks field & models whose values are "unsynced".
7175
*
72-
* Unsynced means that the value has been updated inside of
76+
* For a model, unsynced means that the value has been updated inside of
7377
* a field (e.g. an input), but that this new value hasn't
7478
* yet been set onto the actual model data. It is "unsynced"
7579
* from the underlying model data.
80+
*
81+
* For a field, unsynced means that it is "modified on the client side". In
82+
* other words, the field's value in the browser would be different than the
83+
* one returned from the server. This can happen because a field has no model
84+
* (and so it is permanently unsynced once changed) or the field has been changed
85+
* and the corresponding model has not yet been sent to the server.
86+
*
87+
* Note: a "model" can become synced when that value is set back
88+
* onto the data store. But the corresponding field will
89+
* remain unsynced until the next Ajax call starts.
7690
*/
7791
export class UnsyncedInputContainer {
78-
#mappedFields: Map<string, HTMLElement>;
79-
#unmappedFields: Array<HTMLElement> = [];
92+
private unsyncedModelFields: Map<string, HTMLElement>;
93+
private unsyncedNonModelFields: Array<HTMLElement> = [];
94+
private unsyncedModelNames: Array<string> = [];
8095

8196
constructor() {
82-
this.#mappedFields = new Map();
97+
this.unsyncedModelFields = new Map();
8398
}
8499

85100
add(element: HTMLElement, modelName: string|null = null) {
86101
if (modelName) {
87-
this.#mappedFields.set(modelName, element);
102+
this.unsyncedModelFields.set(modelName, element);
103+
if (!this.unsyncedModelNames.includes(modelName)) {
104+
this.unsyncedModelNames.push(modelName);
105+
}
88106

89107
return;
90108
}
91109

92-
this.#unmappedFields.push(element);
110+
this.unsyncedNonModelFields.push(element);
93111
}
94112

95-
all(): HTMLElement[] {
96-
return [...this.#unmappedFields, ...this.#mappedFields.values()]
113+
/**
114+
* Mark all fields as synced, except for those not bound to a model or whose
115+
* values are still dirty.
116+
*/
117+
resetUnsyncedFields(): void {
118+
// clear out all unsynced fields, except those where the value is still unsynced
119+
this.unsyncedModelFields.forEach((value, key) => {
120+
if (!this.unsyncedModelNames.includes(key)) {
121+
this.unsyncedModelFields.delete(key);
122+
}
123+
});
124+
}
125+
126+
allUnsyncedInputs(): HTMLElement[] {
127+
return [...this.unsyncedNonModelFields, ...this.unsyncedModelFields.values()]
97128
}
98129

99130
markModelAsSynced(modelName: string): void {
100-
this.#mappedFields.delete(modelName);
131+
const index = this.unsyncedModelNames.indexOf(modelName);
132+
if (index !== -1) {
133+
this.unsyncedModelNames.splice(index, 1);
134+
}
101135
}
102136

103-
getModifiedModels(): string[] {
104-
return Array.from(this.#mappedFields.keys());
137+
/**
138+
* Returns a list of models whose fields have been modified, but whose values
139+
* have not yet been set onto the data store.
140+
*/
141+
getUnsyncedModelNames(): string[] {
142+
return this.unsyncedModelNames;
105143
}
106144
}

src/LiveComponent/assets/src/Component/index.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,12 @@ export default class Component {
164164
return promise;
165165
}
166166

167+
/**
168+
* Returns an array of models the user has modified, but whose model has not
169+
* yet been updated.
170+
*/
167171
getUnsyncedModels(): string[] {
168-
return this.unsyncedInputsTracker.getModifiedModels();
172+
return this.unsyncedInputsTracker.getUnsyncedModels();
169173
}
170174

171175
addChild(child: Component, modelBindings: ModelBinding[] = []): void {
@@ -277,6 +281,10 @@ export default class Component {
277281
// then create a fresh Promise, so any future .then() apply to it
278282
this.resetPromise();
279283

284+
// any fields that were modified will now be sent on this request:
285+
// they are now "in sync" (with some exceptions noted inside)
286+
this.unsyncedInputsTracker.resetUnsyncedFields();
287+
280288
this.backendRequest = this.backend.makeRequest(
281289
this.valueStore.all(),
282290
this.pendingActions,

src/LiveComponent/assets/src/morphdom.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Component from './Component';
1212
export function executeMorphdom(
1313
rootFromElement: HTMLElement,
1414
rootToElement: HTMLElement,
15-
modifiedElements: Array<HTMLElement>,
15+
modifiedFieldElements: Array<HTMLElement>,
1616
getElementValue: (element: HTMLElement) => any,
1717
childComponents: Component[],
1818
findChildComponent: (id: string, element: HTMLElement) => HTMLElement|null,
@@ -57,7 +57,7 @@ export function executeMorphdom(
5757

5858
// if this field's value has been modified since this HTML was
5959
// requested, set the toEl's value to match the fromEl
60-
if (modifiedElements.includes(fromEl)) {
60+
if (modifiedFieldElements.includes(fromEl)) {
6161
setValueOnElement(toEl, getElementValue(fromEl))
6262
}
6363

src/LiveComponent/assets/test/UnsyncedInputContainer.test.ts

+19-4
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ describe('UnsyncedInputContainer', () => {
99
container.add(element1);
1010
container.add(element2, 'some_model');
1111

12-
expect(container.all()).toEqual([element1, element2]);
12+
expect(container.allUnsyncedInputs()).toEqual([element1, element2]);
1313
});
1414

15-
it('markModelAsSynced removes items added to it', () => {
15+
it('markModelAsSynced removes unsynced models but not fields', () => {
1616
const container = new UnsyncedInputContainer();
1717
const element1 = htmlToElement('<span>element1</span');
1818
const element2 = htmlToElement('<span>element2</span');
@@ -23,7 +23,7 @@ describe('UnsyncedInputContainer', () => {
2323

2424
container.markModelAsSynced('some_model2');
2525

26-
expect(container.all()).toEqual([element1, element3]);
26+
expect(container.allUnsyncedInputs()).toEqual([element1, element2, element3]);
2727
});
2828

2929
it('returns modified models via getModifiedModels()', () => {
@@ -36,6 +36,21 @@ describe('UnsyncedInputContainer', () => {
3636
container.add(element3, 'some_model3');
3737

3838
container.markModelAsSynced('some_model2');
39-
expect(container.getModifiedModels()).toEqual(['some_model3'])
39+
expect(container.getUnsyncedModelNames()).toEqual(['some_model3'])
40+
});
41+
42+
it('resetUnsyncedFields removes all model fields except those unsynced', () => {
43+
const container = new UnsyncedInputContainer();
44+
const element1 = htmlToElement('<span>element1</span');
45+
const element2 = htmlToElement('<span>element2</span');
46+
const element3 = htmlToElement('<span>element3</span');
47+
container.add(element1);
48+
container.add(element2, 'some_model2');
49+
container.add(element3, 'some_model3');
50+
51+
container.markModelAsSynced('some_model2');
52+
53+
container.resetUnsyncedFields();
54+
expect(container.allUnsyncedInputs()).toEqual([element1, element3]);
4055
});
4156
});

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

+53
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,59 @@ describe('LiveController data-model Tests', () => {
687687
expect(unmappedTextarea.getAttribute('class')).toEqual('changed-class');
688688
});
689689

690+
it('keeps the unsynced value of a model field mapped via a form', async () => {
691+
const test = await createTest({
692+
comment: 'Live components',
693+
}, (data: any) => `
694+
<div ${initComponent(data)}>
695+
<form data-model>
696+
<textarea name="comment" data-testid="comment">${data.comment}</textarea>
697+
</form>
698+
699+
<button data-action="live#$render">Reload</button>
700+
</div>
701+
`);
702+
703+
test.expectsAjaxCall('get')
704+
.expectSentData(test.initialData)
705+
.serverWillChangeData((data) => {
706+
data.comment = 'server tries to change comment, but it will be modified client side';
707+
})
708+
// delay slightly so we can type in the textarea
709+
.delayResponse(10)
710+
.init();
711+
712+
getByText(test.element, 'Reload').click();
713+
// mimic changing the field, but without (yet) triggering the change event
714+
const commentField = getByTestId(test.element, 'comment');
715+
if (!(commentField instanceof HTMLTextAreaElement)) {
716+
throw new Error('wrong type');
717+
}
718+
userEvent.type(commentField, ' ftw!');
719+
720+
// wait for loading start and end
721+
await waitFor(() => expect(test.element).toHaveAttribute('busy'));
722+
await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
723+
724+
expect(commentField).toHaveValue('Live components ftw!');
725+
726+
// refresh again, the value should now be in sync and accept the changed
727+
// value from the server
728+
test.expectsAjaxCall('get')
729+
.expectSentData({ comment: 'Live components ftw!' })
730+
.serverWillChangeData((data) => {
731+
data.comment = 'server changed comment';
732+
})
733+
.init();
734+
735+
getByText(test.element, 'Reload').click();
736+
// wait for loading start and end
737+
await waitFor(() => expect(test.element).toHaveAttribute('busy'));
738+
await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
739+
740+
expect(commentField).toHaveValue('server changed comment');
741+
});
742+
690743
it('allows model fields to be manually set as long as change event is dispatched', async () => {
691744
const test = await createTest({ food: '' }, (data: any) => `
692745
<div ${initComponent(data)}>

0 commit comments

Comments
 (0)