fix(ui): get rid of mobx-stored

This commit is contained in:
Lukasz Mierzwa
2026-03-09 15:41:44 +00:00
committed by Łukasz Mierzwa
parent 1cee18465d
commit ec1b05cd37
8 changed files with 552 additions and 215 deletions
+188 -92
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -27,7 +27,6 @@
"lodash.uniqueid": "4.0.1",
"mobx": "6.15.0",
"mobx-react-lite": "4.1.1",
"mobx-stored": "1.1.0",
"promise-retry": "2.0.1",
"react": "19.2.4",
"react-cool-dimensions": "3.0.1",
+53
View File
@@ -0,0 +1,53 @@
import { observable, autorun, set, toJS } from "mobx";
interface LocalStoreResult<T> {
value: T;
destroy: () => void;
}
function localStored<T extends object>(
key: string,
defaultValue: T,
): LocalStoreResult<T> {
const initial: T = { ...defaultValue };
const fromStorage = localStorage.getItem(key);
if (fromStorage) {
try {
Object.assign(initial, JSON.parse(fromStorage));
} catch {
// ignore malformed JSON, use defaults
}
}
const obsVal = observable(initial);
const disposeAutorun = autorun(
() => {
localStorage.setItem(key, JSON.stringify(toJS(obsVal)));
},
{ delay: 0 },
);
const onStorageEvent = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try {
set(obsVal, JSON.parse(e.newValue));
} catch {
// ignore malformed JSON from other tabs
}
}
};
window.addEventListener("storage", onStorageEvent);
return {
value: obsVal,
destroy() {
disposeAutorun();
window.removeEventListener("storage", onStorageEvent);
},
};
}
export { localStored };
export type { LocalStoreResult };
@@ -1,4 +1,10 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import {
act,
render,
screen,
fireEvent,
waitFor,
} from "@testing-library/react";
import { Settings } from "Stores/Settings";
import { AnimationsConfiguration } from "./AnimationsConfiguration";
@@ -26,7 +32,9 @@ describe("<AnimationsConfiguration />", () => {
renderConfiguration();
const checkbox = screen.getByRole("checkbox");
settingsStore.themeConfig.setAnimations(true);
act(() => {
settingsStore.themeConfig.setAnimations(true);
});
expect(settingsStore.themeConfig.config.animations).toBe(true);
fireEvent.click(checkbox);
await waitFor(() => {
@@ -38,7 +46,9 @@ describe("<AnimationsConfiguration />", () => {
renderConfiguration();
const checkbox = screen.getByRole("checkbox");
settingsStore.themeConfig.setAnimations(false);
act(() => {
settingsStore.themeConfig.setAnimations(false);
});
expect(settingsStore.themeConfig.config.animations).toBe(false);
fireEvent.click(checkbox);
await waitFor(() => {
@@ -19,10 +19,6 @@ beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
localStorage.setItem("history.filters", "");
});
const renderHistory = () => {
return render(
<History alertStore={alertStore} settingsStore={settingsStore} />,
@@ -322,3 +318,205 @@ describe("<HistoryMenu />", () => {
});
});
});
describe("History localStorage", () => {
// Verifies that history entries persisted in localStorage by a previous
// session are loaded and displayed when the component mounts.
it("loads pre-populated localStorage on mount", async () => {
const promise = Promise.resolve();
const savedFilters = [
[{ raw: "cluster=prod", name: "cluster", matcher: "=", value: "prod" }],
[{ raw: "env=staging", name: "env", matcher: "=", value: "staging" }],
];
localStorage.setItem("filters", JSON.stringify({ filters: savedFilters }));
const { container } = renderHistory();
const toggle = container.querySelector("button.cursor-pointer");
fireEvent.click(toggle!);
expect(screen.getByText("cluster=prod")).toBeInTheDocument();
expect(screen.getByText("env=staging")).toBeInTheDocument();
expect(container.querySelectorAll("button.dropdown-item")).toHaveLength(2);
await act(() => promise);
});
// Verifies that localStored persists observable changes to localStorage
// via a delayed reaction (setTimeout 0).
it("localStored persists observable changes to localStorage", () => {
const { localStored } = require("Common/LocalStore");
const { runInAction } = require("mobx");
const store = localStored("test_persist_key", { filters: [] as any[] });
// Flush the initial write (reaction fires via setTimeout 0)
jest.runAllTimers();
const afterInit = JSON.parse(
localStorage.getItem("test_persist_key") || "{}",
);
expect(afterInit.filters).toEqual([]);
runInAction(() => {
store.value.filters = [
[{ raw: "foo=bar", name: "foo", matcher: "=", value: "bar" }],
];
});
// Flush the reaction that persists to localStorage
jest.runAllTimers();
const afterSet = JSON.parse(
localStorage.getItem("test_persist_key") || "{}",
);
expect(afterSet.filters).toHaveLength(1);
expect(afterSet.filters[0]).toEqual([
{ raw: "foo=bar", name: "foo", matcher: "=", value: "bar" },
]);
store.destroy();
localStorage.removeItem("test_persist_key");
});
// Verifies that all rapid sequential filter changes are captured in
// history and none are lost due to timing.
it("preserves all sequential filter changes in history", async () => {
const promise = Promise.resolve();
const { container } = renderHistory();
act(() => {
alertStore.filters.setFilterValues([
AppliedFilter("cluster", "=", "prod"),
]);
jest.runOnlyPendingTimers();
});
act(() => {
alertStore.filters.setFilterValues([
AppliedFilter("env", "=", "staging"),
]);
jest.runOnlyPendingTimers();
});
act(() => {
alertStore.filters.setFilterValues([
AppliedFilter("region", "=", "us-east"),
]);
jest.runOnlyPendingTimers();
});
const toggle = container.querySelector("button.cursor-pointer");
fireEvent.click(toggle!);
expect(container.querySelectorAll("button.dropdown-item")).toHaveLength(3);
expect(screen.getByText("region=us-east")).toBeInTheDocument();
expect(screen.getByText("env=staging")).toBeInTheDocument();
expect(screen.getByText("cluster=prod")).toBeInTheDocument();
await act(() => promise);
});
// Simulates a second browser tab writing to localStorage and firing a
// StorageEvent. Verifies the component picks up the cross-tab change.
// mobx-stored's propagateChangesToMemory handler calls set(obsVal, newValue)
// which replaces the observable state and re-establishes the persistence
// autorun, so multiple timer flushes are needed.
it("picks up StorageEvent from another tab", async () => {
const promise = Promise.resolve();
const { container } = renderHistory();
act(() => {
alertStore.filters.setFilterValues([AppliedFilter("foo", "=", "bar")]);
jest.runOnlyPendingTimers();
jest.runOnlyPendingTimers();
});
const externalFilters = {
filters: [
[
{
raw: "external=filter",
name: "external",
matcher: "=",
value: "filter",
},
],
],
};
const newValue = JSON.stringify(externalFilters);
localStorage.setItem("filters", newValue);
act(() => {
window.dispatchEvent(
new StorageEvent("storage", {
key: "filters",
newValue: newValue,
storageArea: localStorage,
}),
);
jest.runOnlyPendingTimers();
jest.runOnlyPendingTimers();
});
const toggle = container.querySelector("button.cursor-pointer");
fireEvent.click(toggle!);
expect(screen.getByText("external=filter")).toBeInTheDocument();
await act(() => promise);
});
// Demonstrates the cross-tab race condition. When tab B receives a
// StorageEvent with tab A's history and then tab B's own alertStore
// filters change, the History component's autorun rebuilds history
// from only what's in its own alertStore. Entries from tab A that
// are not in tab B's alertStore are preserved only if they were
// already in the in-memory history.config.filters. If the
// StorageEvent arrived after the autorun already ran, tab A's
// entries could be lost.
it("StorageEvent followed by filter change preserves cross-tab history", async () => {
const promise = Promise.resolve();
const { container } = renderHistory();
// Simulate tab A writing history with "cluster=prod" to localStorage
// and firing a StorageEvent that tab B receives
const tabAHistory = {
filters: [
[
{
raw: "cluster=prod",
name: "cluster",
matcher: "=",
value: "prod",
},
],
],
};
const tabAValue = JSON.stringify(tabAHistory);
localStorage.setItem("filters", tabAValue);
act(() => {
window.dispatchEvent(
new StorageEvent("storage", {
key: "filters",
newValue: tabAValue,
storageArea: localStorage,
}),
);
jest.runOnlyPendingTimers();
jest.runOnlyPendingTimers();
});
// Now tab B's own alertStore gets a different filter applied.
// The History autorun should merge tab A's "cluster=prod" with
// tab B's new "env=staging" in history.
act(() => {
alertStore.filters.setFilterValues([
AppliedFilter("env", "=", "staging"),
]);
jest.runOnlyPendingTimers();
});
const toggle = container.querySelector("button.cursor-pointer");
fireEvent.click(toggle!);
// Both tab A's and tab B's filter sets should appear in history.
// If "cluster=prod" is missing, the autorun overwrote tab A's data.
expect(screen.getByText("env=staging")).toBeInTheDocument();
expect(screen.getByText("cluster=prod")).toBeInTheDocument();
expect(container.querySelectorAll("button.dropdown-item")).toHaveLength(2);
await act(() => promise);
});
});
@@ -11,7 +11,6 @@ import {
import { action, autorun } from "mobx";
import { observer } from "mobx-react-lite";
import { localStored } from "mobx-stored";
import { useFloating, shift, flip, offset, size } from "@floating-ui/react-dom";
@@ -26,6 +25,7 @@ import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash";
import type { AlertStore, FilterT } from "Stores/AlertStore";
import type { Settings } from "Stores/Settings";
import { IsMobile } from "Common/Device";
import { localStored } from "Common/LocalStore";
import { DropdownSlide } from "Components/Animations/DropdownSlide";
import HistoryLabel from "Components/Labels/HistoryLabel";
import { useOnClickOutside } from "Hooks/useOnClickOutside";
@@ -170,26 +170,27 @@ interface HistoryStorageT {
}
class HistoryStorage {
config: HistoryStorageT = localStored(
"filters",
{
filters: [] as ReduceFilterT[][],
},
{
delay: 100,
},
);
private store = localStored<HistoryStorageT>("filters", {
filters: [] as ReduceFilterT[][],
});
get config(): HistoryStorageT {
return this.store.value;
}
setFilters = action((newFilters: ReduceFilterT[][]) => {
this.config.filters = newFilters;
this.store.value.filters = newFilters;
});
destroy = () => {
this.store.destroy();
};
}
const History: FC<{
alertStore: AlertStore;
settingsStore: Settings;
}> = observer(({ alertStore, settingsStore }) => {
// this will be dumped to local storage via mobx-stored
const [history] = useState<HistoryStorage>(new HistoryStorage());
const [isVisible, setIsVisible] = useState<boolean>(false);
const [maxHeight, setMaxHeight] = useState<number | null>(null);
@@ -212,38 +213,40 @@ const History: FC<{
// every time this component updates we will rewrite history
// (if there are changes)
useEffect(
() =>
autorun(() => {
// we don't store unapplied (we only have raw text for those, we need
// name & value for coloring) or invalid filters
// also check for value, name might be missing for fuzzy filters, but
// the value should always be set
const validAppliedFilters = alertStore.filters.values
.filter((f) => f.applied && f.isValid && f.value)
.map((f) => ReduceFilter(f));
useEffect(() => {
const disposeAutorun = autorun(() => {
// we don't store unapplied (we only have raw text for those, we need
// name & value for coloring) or invalid filters
// also check for value, name might be missing for fuzzy filters, but
// the value should always be set
const validAppliedFilters = alertStore.filters.values
.filter((f) => f.applied && f.isValid && f.value)
.map((f) => ReduceFilter(f));
// don't store empty filters in history
if (validAppliedFilters.length === 0) return;
// make a JSON dump for comparing later with what's already stored
const filtersJSON = JSON.stringify(validAppliedFilters);
// don't store empty filters in history
if (validAppliedFilters.length === 0) return;
// make a JSON dump for comparing later with what's already stored
const filtersJSON = JSON.stringify(validAppliedFilters);
// rewrite history putting current filter set on top, this will move
// it up if user selects a filter set that was already in history
const newHistory = [
...[validAppliedFilters],
...history.config.filters.filter(
(f) => JSON.stringify(f) !== filtersJSON,
),
].slice(0, 8);
if (
JSON.stringify(newHistory) !== JSON.stringify(history.config.filters)
) {
history.setFilters(newHistory);
}
}),
[], // eslint-disable-line react-hooks/exhaustive-deps
);
// rewrite history putting current filter set on top, this will move
// it up if user selects a filter set that was already in history
const newHistory = [
...[validAppliedFilters],
...history.config.filters.filter(
(f) => JSON.stringify(f) !== filtersJSON,
),
].slice(0, 8);
if (
JSON.stringify(newHistory) !== JSON.stringify(history.config.filters)
) {
history.setFilters(newHistory);
}
});
return () => {
disposeAutorun();
history.destroy();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const ref = useRef<HTMLSpanElement | null>(null);
useOnClickOutside(ref, hide, isVisible);
+50 -73
View File
@@ -1,5 +1,6 @@
import { action } from "mobx";
import { localStored } from "mobx-stored";
import { localStored } from "Common/LocalStore";
import type { UIDefaults } from "Models/UI";
import type { OptionT } from "Common/Select";
@@ -9,25 +10,23 @@ interface SavedFiltersStorage {
present: boolean;
}
class SavedFilters {
config: SavedFiltersStorage = localStored(
"savedFilters",
{
filters: [],
present: false,
},
{
delay: 100,
},
);
private store = localStored<SavedFiltersStorage>("savedFilters", {
filters: [],
present: false,
});
get config(): SavedFiltersStorage {
return this.store.value;
}
save = action((newFilters: string[]) => {
this.config.filters = newFilters;
this.config.present = true;
this.store.value.filters = newFilters;
this.store.value.present = true;
});
clear = action(() => {
this.config.filters = [];
this.config.present = false;
this.store.value.filters = [];
this.store.value.present = false;
});
}
@@ -35,15 +34,15 @@ interface FetchConfigStorage {
interval: number;
}
class FetchConfig {
private store;
config: FetchConfigStorage;
setInterval: (newInterval: number) => void;
constructor(refresh: number) {
this.config = localStored(
"fetchConfig",
{ interval: refresh },
{ delay: 100 },
);
this.store = localStored<FetchConfigStorage>("fetchConfig", {
interval: refresh,
});
this.config = this.store.value;
this.setInterval = action((newInterval) => {
this.config.interval = newInterval;
@@ -86,15 +85,12 @@ class AlertGroupConfig {
collapseState: CollapseStateT,
colorTitleBar: boolean,
) {
this.config = localStored(
"alertGroupConfig",
{
defaultRenderCount: renderCount,
defaultCollapseState: collapseState,
colorTitleBar: colorTitleBar,
},
{ delay: 100 },
);
const store = localStored<AlertGroupConfigStorage>("alertGroupConfig", {
defaultRenderCount: renderCount,
defaultCollapseState: collapseState,
colorTitleBar: colorTitleBar,
});
this.config = store.value;
this.setDefaultRenderCount = action((val: number) => {
this.config.defaultRenderCount = val;
@@ -116,11 +112,10 @@ class SilenceFormConfig {
saveAuthor: (newAuthor: string) => void;
constructor() {
this.config = localStored(
"silenceFormConfig",
{ author: "" },
{ delay: 100 },
);
const store = localStored<SilenceFormConfigStorage>("silenceFormConfig", {
author: "",
});
this.config = store.value;
this.saveAuthor = action((newAuthor: string) => {
this.config.author = newAuthor;
@@ -158,16 +153,13 @@ class GridConfig {
setGroupWidth: (w: number) => void;
constructor(groupWidth: number) {
this.config = localStored(
"alertGridConfig",
{
sortOrder: this.options.default.value,
sortLabel: null,
reverseSort: null,
groupWidth: groupWidth,
},
{ delay: 100 },
);
const store = localStored<GridConfigStorage>("alertGridConfig", {
sortOrder: this.options.default.value as SortOrderT,
sortLabel: null,
reverseSort: null,
groupWidth: groupWidth,
});
this.config = store.value;
this.setSortOrder = action((o: SortOrderT) => {
this.config.sortOrder = o;
@@ -192,15 +184,10 @@ class FilterBarConfig {
setAutohide: (v: boolean) => void;
constructor(autohide: boolean) {
this.config = localStored(
"filterBarConfig",
{
autohide: autohide,
},
{
delay: 100,
},
);
const store = localStored<FilterBarConfigStorage>("filterBarConfig", {
autohide: autohide,
});
this.config = store.value;
this.setAutohide = action((v: boolean) => {
this.config.autohide = v;
});
@@ -229,16 +216,11 @@ class ThemeConfig {
dark: { label: "Dark theme", value: "dark", wasCreated: false },
});
this.config = localStored(
"themeConfig",
{
theme: defaultTheme,
animations: animations,
},
{
delay: 0,
},
);
const store = localStored<ThemeConfigStorage>("themeConfig", {
theme: defaultTheme,
animations: animations,
});
this.config = store.value;
this.setTheme = action((v: ThemeT) => {
this.config.theme = v;
});
@@ -258,16 +240,11 @@ class MultiGridConfig {
setGridSortReverse: (v: boolean) => void;
constructor(gridLabel: string, gridSortReverse: boolean) {
this.config = localStored(
"multiGridConfig",
{
gridLabel: gridLabel,
gridSortReverse: gridSortReverse,
},
{
delay: 100,
},
);
const store = localStored<MultiGridConfigStorage>("multiGridConfig", {
gridLabel: gridLabel,
gridSortReverse: gridSortReverse,
});
this.config = store.value;
this.setGridLabel = action((l: string) => {
this.config.gridLabel = l;
+1
View File
@@ -51,6 +51,7 @@ console.error = (...args: unknown[]) => {
};
beforeEach(() => {
localStorage.clear();
useFetchGetMock.fetch.reset();
(useFetchGet as jest.MockedFunction<typeof useFetchGetMock>).mockRestore();
(