Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 42ac873

Browse files
authored
Reset power selector on API failure to prevent state mismatch (#12319)
* Reset power selector on API failure to prevent state mismatch Signed-off-by: Michael Telatynski <[email protected]> * Allow onChange to be sync or async Signed-off-by: Michael Telatynski <[email protected]> * Add unmounted check Signed-off-by: Michael Telatynski <[email protected]> * Improve coverage Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> --------- Signed-off-by: Michael Telatynski <[email protected]>
1 parent ddbc643 commit 42ac873

File tree

4 files changed

+96
-13
lines changed

4 files changed

+96
-13
lines changed

Diff for: src/components/views/elements/PowerSelector.tsx

+22-5
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ interface Props<K extends undefined | string> {
3939
// The name to annotate the selector with
4040
label?: string;
4141

42-
onChange(value: number, powerLevelKey: K extends undefined ? void : K): void;
42+
onChange(value: number, powerLevelKey: K extends undefined ? void : K): void | Promise<void>;
4343

4444
// Optional key to pass as the second argument to `onChange`
4545
powerLevelKey: K extends undefined ? void : K;
@@ -60,6 +60,7 @@ export default class PowerSelector<K extends undefined | string> extends React.C
6060
maxValue: Infinity,
6161
usersDefault: 0,
6262
};
63+
private unmounted = false;
6364

6465
public constructor(props: Props<K>) {
6566
super(props);
@@ -84,6 +85,10 @@ export default class PowerSelector<K extends undefined | string> extends React.C
8485
}
8586
}
8687

88+
public componentWillUnmount(): void {
89+
this.unmounted = true;
90+
}
91+
8792
private initStateFromProps(): void {
8893
// This needs to be done now because levelRoleMap has translated strings
8994
const levelRoleMap = Roles.levelRoleMap(this.props.usersDefault);
@@ -106,27 +111,39 @@ export default class PowerSelector<K extends undefined | string> extends React.C
106111
});
107112
}
108113

109-
private onSelectChange = (event: React.ChangeEvent<HTMLSelectElement>): void => {
114+
private onSelectChange = async (event: React.ChangeEvent<HTMLSelectElement>): Promise<void> => {
110115
const isCustom = event.target.value === CUSTOM_VALUE;
111116
if (isCustom) {
112117
this.setState({ custom: true });
113118
} else {
114119
const powerLevel = parseInt(event.target.value);
115-
this.props.onChange(powerLevel, this.props.powerLevelKey);
116120
this.setState({ selectValue: powerLevel });
121+
try {
122+
await this.props.onChange(powerLevel, this.props.powerLevelKey);
123+
} catch {
124+
if (this.unmounted) return;
125+
// If the request failed, roll back the state of the selector.
126+
this.initStateFromProps();
127+
}
117128
}
118129
};
119130

120131
private onCustomChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
121132
this.setState({ customValue: parseInt(event.target.value) });
122133
};
123134

124-
private onCustomBlur = (event: React.FocusEvent): void => {
135+
private onCustomBlur = async (event: React.FocusEvent): Promise<void> => {
125136
event.preventDefault();
126137
event.stopPropagation();
127138

128139
if (Number.isFinite(this.state.customValue)) {
129-
this.props.onChange(this.state.customValue, this.props.powerLevelKey);
140+
try {
141+
await this.props.onChange(this.state.customValue, this.props.powerLevelKey);
142+
} catch {
143+
if (this.unmounted) return;
144+
// If the request failed, roll back the state of the selector.
145+
this.initStateFromProps();
146+
}
130147
} else {
131148
this.initStateFromProps(); // reset, invalid input
132149
}

Diff for: src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx

+16-6
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
174174
}
175175
}
176176

177-
private onPowerLevelsChanged = (value: number, powerLevelKey: string): void => {
177+
private onPowerLevelsChanged = async (value: number, powerLevelKey: string): Promise<void> => {
178178
const client = this.context;
179179
const room = this.props.room;
180180
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
@@ -203,17 +203,22 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
203203
parentObj[keyPath[keyPath.length - 1]] = value;
204204
}
205205

206-
client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent).catch((e) => {
206+
try {
207+
await client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent);
208+
} catch (e) {
207209
logger.error(e);
208210

209211
Modal.createDialog(ErrorDialog, {
210212
title: _t("room_settings|permissions|error_changing_pl_reqs_title"),
211213
description: _t("room_settings|permissions|error_changing_pl_reqs_description"),
212214
});
213-
});
215+
216+
// Rethrow so that the PowerSelector can roll back
217+
throw e;
218+
}
214219
};
215220

216-
private onUserPowerLevelChanged = (value: number, powerLevelKey: string): void => {
221+
private onUserPowerLevelChanged = async (value: number, powerLevelKey: string): Promise<void> => {
217222
const client = this.context;
218223
const room = this.props.room;
219224
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
@@ -226,14 +231,19 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
226231
if (!plContent["users"]) plContent["users"] = {};
227232
plContent["users"][powerLevelKey] = value;
228233

229-
client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent).catch((e) => {
234+
try {
235+
await client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent);
236+
} catch (e) {
230237
logger.error(e);
231238

232239
Modal.createDialog(ErrorDialog, {
233240
title: _t("room_settings|permissions|error_changing_pl_title"),
234241
description: _t("room_settings|permissions|error_changing_pl_description"),
235242
});
236-
});
243+
244+
// Rethrow so that the PowerSelector can roll back
245+
throw e;
246+
}
237247
};
238248

239249
public render(): React.ReactNode {

Diff for: test/components/views/elements/PowerSelector-test.tsx

+22
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ limitations under the License.
1616

1717
import React from "react";
1818
import { fireEvent, render, screen } from "@testing-library/react";
19+
import { defer } from "matrix-js-sdk/src/utils";
1920

2021
import PowerSelector from "../../../../src/components/views/elements/PowerSelector";
2122

@@ -75,4 +76,25 @@ describe("<PowerSelector />", () => {
7576
expect(option.selected).toBeTruthy();
7677
expect(fn).not.toHaveBeenCalled();
7778
});
79+
80+
it("should reset when onChange promise rejects", async () => {
81+
const deferred = defer<void>();
82+
render(
83+
<PowerSelector
84+
value={25}
85+
maxValue={100}
86+
usersDefault={0}
87+
onChange={() => deferred.promise}
88+
powerLevelKey="key"
89+
/>,
90+
);
91+
92+
const input = screen.getByLabelText("Power level");
93+
fireEvent.change(input, { target: { value: 40 } });
94+
fireEvent.blur(input);
95+
96+
await screen.findByDisplayValue(40);
97+
deferred.reject("Some error");
98+
await screen.findByDisplayValue(25);
99+
});
78100
});

Diff for: test/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx

+36-2
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ limitations under the License.
1515
*/
1616

1717
import React from "react";
18-
import { fireEvent, render, RenderResult, screen } from "@testing-library/react";
19-
import { MatrixClient, EventType, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
18+
import { fireEvent, render, RenderResult, screen, waitFor } from "@testing-library/react";
19+
import { MatrixClient, EventType, MatrixEvent, Room, RoomMember, ISendEventResponse } from "matrix-js-sdk/src/matrix";
2020
import { mocked } from "jest-mock";
21+
import { defer } from "matrix-js-sdk/src/utils";
2122

2223
import RolesRoomSettingsTab from "../../../../../../src/components/views/settings/tabs/room/RolesRoomSettingsTab";
2324
import { mkStubRoom, withClientContextRenderOptions, stubClient } from "../../../../../test-utils";
@@ -231,4 +232,37 @@ describe("RolesRoomSettingsTab", () => {
231232
expect(screen.getByTitle("Banned by Alice")).toBeInTheDocument();
232233
});
233234
});
235+
236+
it("should roll back power level change on error", async () => {
237+
const deferred = defer<ISendEventResponse>();
238+
mocked(cli.sendStateEvent).mockReturnValue(deferred.promise);
239+
mocked(cli.getRoom).mockReturnValue(room);
240+
// @ts-ignore - mocked doesn't support overloads properly
241+
mocked(room.currentState.getStateEvents).mockImplementation((type, key) => {
242+
if (key === undefined) return [] as MatrixEvent[];
243+
if (type === "m.room.power_levels") {
244+
return new MatrixEvent({
245+
sender: "@sender:server",
246+
room_id: roomId,
247+
type: "m.room.power_levels",
248+
state_key: "",
249+
content: {
250+
users: {
251+
[cli.getUserId()!]: 100,
252+
},
253+
},
254+
});
255+
}
256+
return null;
257+
});
258+
mocked(room.currentState.mayClientSendStateEvent).mockReturnValue(true);
259+
const { container } = renderTab();
260+
261+
const selector = container.querySelector(`[placeholder="${cli.getUserId()}"]`)!;
262+
fireEvent.change(selector, { target: { value: "50" } });
263+
expect(selector).toHaveValue("50");
264+
265+
deferred.reject("Error");
266+
await waitFor(() => expect(selector).toHaveValue("100"));
267+
});
234268
});

0 commit comments

Comments
 (0)