Persist sidebar collapse state and stabilize Metro port

This commit is contained in:
Mohamed Boudra
2026-04-21 14:01:16 +07:00
parent 32a20cbeea
commit 515ba62be6
6 changed files with 162 additions and 29 deletions
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(),
removeItem: vi.fn(),
},
}));
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
describe("sidebar-collapsed-sections-store", () => {
beforeEach(() => {
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(),
});
});
it("tracks collapsed project keys as a Set", () => {
const store = useSidebarCollapsedSectionsStore.getState();
store.setProjectCollapsed("project-a", true);
store.toggleProjectCollapsed("project-b");
store.toggleProjectCollapsed("project-a");
expect(Array.from(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys)).toEqual([
"project-b",
]);
});
it("serializes collapsed project keys for preference storage", () => {
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(["project-a", "project-b"]),
});
const partialize = useSidebarCollapsedSectionsStore.persist.getOptions().partialize;
expect(partialize?.(useSidebarCollapsedSectionsStore.getState())).toEqual({
collapsedProjectKeys: ["project-a", "project-b"],
});
});
it("restores collapsed project keys from persisted preferences", () => {
const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge;
const restored = merge?.(
{
collapsedProjectKeys: ["project-a", "project-b", 42],
},
useSidebarCollapsedSectionsStore.getState(),
);
expect(Array.from(restored?.collapsedProjectKeys ?? [])).toEqual(["project-a", "project-b"]);
});
it("keeps the existing state object when persisted preferences do not change collapsed keys", () => {
const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge;
const currentState = useSidebarCollapsedSectionsStore.getState();
expect(merge?.({}, currentState)).toBe(currentState);
expect(merge?.({ collapsedProjectKeys: [] }, currentState)).toBe(currentState);
});
});
@@ -1,4 +1,6 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
interface SidebarCollapsedSectionsState {
collapsedProjectKeys: Set<string>;
@@ -6,26 +8,84 @@ interface SidebarCollapsedSectionsState {
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
}
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>((set) => ({
collapsedProjectKeys: new Set(),
toggleProjectCollapsed: (projectKey) =>
set((state) => {
const next = new Set(state.collapsedProjectKeys);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
return { collapsedProjectKeys: next };
type PersistedSidebarCollapsedSectionsState = {
collapsedProjectKeys?: string[];
};
function serializeCollapsedProjectKeys(keys: Set<string>): string[] {
return Array.from(keys);
}
function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
if (!Array.isArray(value)) {
return new Set();
}
return new Set(value.filter((key): key is string => typeof key === "string"));
}
function areSetsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const key of left) {
if (!right.has(key)) {
return false;
}
}
return true;
}
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()(
persist(
(set) => ({
collapsedProjectKeys: new Set(),
toggleProjectCollapsed: (projectKey) =>
set((state) => {
const next = new Set(state.collapsedProjectKeys);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
return { collapsedProjectKeys: next };
}),
setProjectCollapsed: (projectKey, collapsed) =>
set((state) => {
const next = new Set(state.collapsedProjectKeys);
if (collapsed) {
next.add(projectKey);
} else {
next.delete(projectKey);
}
return { collapsedProjectKeys: next };
}),
}),
setProjectCollapsed: (projectKey, collapsed) =>
set((state) => {
const next = new Set(state.collapsedProjectKeys);
if (collapsed) {
next.add(projectKey);
} else {
next.delete(projectKey);
}
return { collapsedProjectKeys: next };
}),
}));
{
name: "sidebar-collapsed-sections",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
collapsedProjectKeys: serializeCollapsedProjectKeys(state.collapsedProjectKeys),
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as PersistedSidebarCollapsedSectionsState | undefined;
if (!persisted?.collapsedProjectKeys) {
return currentState;
}
const collapsedProjectKeys = deserializeCollapsedProjectKeys(
persisted.collapsedProjectKeys,
);
if (areSetsEqual(currentState.collapsedProjectKeys, collapsedProjectKeys)) {
return currentState;
}
return {
...currentState,
collapsedProjectKeys,
};
},
},
),
);