Skip to content

Commit b57f68b

Browse files
[Live] Fixed bug with "unsynced" inputs and <form data-model>
1 parent 41383ee commit b57f68b

File tree

6 files changed

+168
-67
lines changed

6 files changed

+168
-67
lines changed

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 33 additions & 46 deletions
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

Lines changed: 52 additions & 14 deletions
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

Lines changed: 9 additions & 1 deletion
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

Lines changed: 2 additions & 2 deletions
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

Lines changed: 19 additions & 4 deletions
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

Lines changed: 53 additions & 0 deletions
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)