Room list: add robustness to custom section loading (#33475)

* refactor: move all access to custom sections settings into `section.ts`

* fix: add robustness when getting the order list of custom sections

* fix: add robustness when getting the custom section data

* fix: ignore malformed section but don't erase them

* fix: remove useless await operator

* test: add more tests
This commit is contained in:
Florian Duros
2026-05-13 15:50:33 +00:00
committed by GitHub
parent c4c32b8334
commit 47f8012691
9 changed files with 178 additions and 34 deletions
@@ -833,6 +833,7 @@ describe("RoomListStoreV3", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [];
if (setting === "RoomList.CustomSectionData") return {};
return false;
});
}
@@ -1093,6 +1094,7 @@ describe("RoomListStoreV3", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [];
if (setting === "RoomList.CustomSectionData") return {};
return false;
});
@@ -1107,11 +1109,13 @@ describe("RoomListStoreV3", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [customTag];
if (setting === "RoomList.CustomSectionData")
return { [customTag]: { tag: customTag, name: "Custom" } };
return false;
});
// Trigger the settings watcher
settingsWatcher("RoomList.OrderedCustomSections");
await Promise.resolve(settingsWatcher("RoomList.OrderedCustomSections"));
// Now there should be 4 sections (Favourite, custom, Chats, LowPriority)
expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(4);
@@ -8,12 +8,14 @@
import Modal from "../../../../src/Modal";
import SettingsStore from "../../../../src/settings/SettingsStore";
import {
CHATS_TAG,
CUSTOM_SECTION_TAG_PREFIX,
createSection,
editSection,
deleteSection,
getCustomSectionData,
getOrderedCustomSections,
isDefaultSectionTag,
CHATS_TAG,
CUSTOM_SECTION_TAG_PREFIX,
isSectionTag,
} from "../../../../src/stores/room-list-v3/section";
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
@@ -25,6 +27,68 @@ describe("section", () => {
jest.restoreAllMocks();
});
describe("getCustomSectionData", () => {
const validTag = "element.io.section.valid";
const invalidTag = "element.io.section.invalid";
const validEntry = { tag: validTag, name: "Valid" };
beforeEach(() => {
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it.each([null, false, 42, "string", []] as const)("returns an empty object when the raw value is %p", (raw) => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(raw as any);
expect(getCustomSectionData()).toEqual({});
});
it("returns valid entries and drops invalid ones", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({
[validTag]: validEntry,
[invalidTag]: { tag: "element.io.section.mismatch", name: "Bad" },
});
expect(getCustomSectionData()).toEqual({ [validTag]: validEntry });
});
it("drops entries that fail the isValidCustomSection check", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({
"element.io.section.null-val": null,
"element.io.section.str-val": "not-an-object",
"element.io.section.bad-tag": { tag: "not-a-custom-tag", name: "Bad" },
"element.io.section.bad-name": { tag: "element.io.section.bad-name", name: 42 },
});
expect(getCustomSectionData()).toEqual({});
});
});
describe("getOrderedCustomSections", () => {
const tag = "element.io.section.abc";
beforeEach(() => {
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("returns an empty array when the raw value is not an array", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return "not-an-array";
return null;
});
const result = getOrderedCustomSections();
expect(result).toEqual([]);
});
it("removes unknown sections and saves the cleaned list", () => {
const knownTag = "element.io.section.known";
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.CustomSectionData") return { [knownTag]: { tag: knownTag, name: "Known" } };
if (setting === "RoomList.OrderedCustomSections") return [knownTag, tag];
return null;
});
expect(getOrderedCustomSections()).toEqual([knownTag]);
});
});
describe("createSection", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
@@ -69,6 +133,8 @@ describe("section", () => {
const existingTag = "element.io.section.existing";
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return [existingTag];
if (setting === "RoomList.CustomSectionData")
return { [existingTag]: { tag: existingTag, name: "Existing" } };
return null;
});
jest.spyOn(Modal, "createDialog").mockReturnValue({
@@ -79,15 +145,16 @@ describe("section", () => {
await createSection();
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
const savedSection = Object.values(customDataCall![3] as Record<string, { tag: string; name: string }>)[0];
expect(savedSection.name).toBe("My Section");
expect(savedSection.tag).toMatch(/^element\.io\.section\./);
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
const savedOrder = orderedCall![3] as string[];
expect(savedOrder[0]).toBe(existingTag);
expect(savedOrder[1]).toMatch(/^element\.io\.section\./);
const newTag = savedOrder[1];
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
const savedSection = (customDataCall![3] as Record<string, { tag: string; name: string }>)[newTag];
expect(savedSection.name).toBe("My Section");
expect(savedSection.tag).toBe(newTag);
});
});
@@ -100,6 +167,12 @@ describe("section", () => {
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("does nothing if the tag is not a custom section tag", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog");
await editSection("m.favourite");
expect(createDialogSpy).not.toHaveBeenCalled();
});
it("does nothing if the section does not exist", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
const createDialogSpy = jest.spyOn(Modal, "createDialog");
@@ -157,13 +230,20 @@ describe("section", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.CustomSectionData") return { [tag]: { tag, name: "My Section" } };
if (setting === "RoomList.CustomSectionData")
return { [tag]: { tag, name: "My Section" }, [otherTag]: { tag: otherTag, name: "Other Section" } };
if (setting === "RoomList.OrderedCustomSections") return [otherTag, tag];
return null;
});
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("does nothing if the tag is not a custom section tag", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog");
await deleteSection("m.favourite", false);
expect(createDialogSpy).not.toHaveBeenCalled();
});
it("does nothing if the section does not exist", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
const createDialogSpy = jest.spyOn(Modal, "createDialog");