({
+ LocalStorageUtility: {
+ getEntryObject: jest.fn(),
+ setEntryObject: jest.fn(),
+ },
+ StorageKey: {
+ AppState: "AppState",
+ },
+}));
+
+describe("AppStatePersistenceUtility", () => {
+ const storePath = {
+ componentName: "a",
+ subComponentName: "b",
+ globalAccountName: "c",
+ databaseName: "d",
+ containerName: "e",
+ };
+ const key = createKeyFromPath(storePath);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ beforeEach(() => {
+ (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
+ key0: {
+ schemaVersion: 1,
+ timestamp: 0,
+ data: {},
+ },
+ });
+ });
+
+ describe("saveState()", () => {
+ const testState = { aa: 1, bb: "2", cc: [3, 4] };
+
+ it("should save state", () => {
+ saveState(storePath, testState);
+ expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1);
+ expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledWith(StorageKey.AppState, expect.any(Object));
+
+ const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
+ expect(passedState[key].data).toHaveProperty("aa", 1);
+ });
+
+ it("should save state with timestamp", () => {
+ saveState(storePath, testState);
+ const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
+ expect(passedState[key]).toHaveProperty("timestamp");
+ expect(passedState[key].timestamp).toBeGreaterThan(0);
+ });
+
+ it("should add state to existing state", () => {
+ (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
+ key0: {
+ schemaVersion: 1,
+ timestamp: 0,
+ data: { dd: 5 },
+ },
+ });
+
+ saveState(storePath, testState);
+ const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
+ expect(passedState["key0"].data).toHaveProperty("dd", 5);
+ });
+
+ it("should remove the oldest entry when the number of entries exceeds the limit", () => {
+ // Fill up storage with MAX entries
+ const currentAppState = {};
+ for (let i = 0; i < MAX_ENTRY_NB; i++) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (currentAppState as any)[`key${i}`] = {
+ schemaVersion: 1,
+ timestamp: i,
+ data: {},
+ };
+ }
+ (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(currentAppState);
+
+ saveState(storePath, testState);
+
+ // Verify that the new entry is saved
+ const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
+ expect(passedState[key].data).toHaveProperty("aa", 1);
+
+ // Verify that the oldest entry is removed (smallest timestamp)
+ const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
+ expect(Object.keys(passedAppState).length).toBe(MAX_ENTRY_NB);
+ expect(passedAppState).not.toHaveProperty("key0");
+ });
+
+ it("should not remove the oldest entry when the number of entries does not exceed the limit", () => {
+ (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
+ key0: {
+ schemaVersion: 1,
+ timestamp: 0,
+ data: {},
+ },
+ key1: {
+ schemaVersion: 1,
+ timestamp: 1,
+ data: {},
+ },
+ });
+ saveState(storePath, testState);
+ const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
+ expect(Object.keys(passedAppState).length).toBe(3);
+ });
+ });
+
+ describe("loadState()", () => {
+ it("should load state", () => {
+ const data = { aa: 1, bb: "2", cc: [3, 4] };
+ const testState = {
+ [key]: {
+ schemaVersion: 1,
+ timestamp: 0,
+ data,
+ },
+ };
+ (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(testState);
+ const state = loadState(storePath);
+ expect(state).toEqual(data);
+ });
+
+ it("should return undefined if the state is not found", () => {
+ (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(null);
+ const state = loadState(storePath);
+ expect(state).toBeUndefined();
+ });
+ });
+
+ describe("deleteState()", () => {
+ it("should delete state", () => {
+ const key = createKeyFromPath(storePath);
+ (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
+ [key]: {
+ schemaVersion: 1,
+ timestamp: 0,
+ data: {},
+ },
+ otherKey: {
+ schemaVersion: 2,
+ timestamp: 0,
+ data: {},
+ },
+ });
+
+ deleteState(storePath);
+ expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1);
+ const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
+ expect(passedAppState).not.toHaveProperty(key);
+ expect(passedAppState).toHaveProperty("otherKey");
+ });
+ });
+ describe("createKeyFromPath()", () => {
+ it("should create path that contains all components", () => {
+ const key = createKeyFromPath(storePath);
+ expect(key).toContain(storePath.componentName);
+ expect(key).toContain(storePath.subComponentName);
+ expect(key).toContain(storePath.globalAccountName);
+ expect(key).toContain(storePath.databaseName);
+ expect(key).toContain(storePath.containerName);
+ });
+ });
+});
diff --git a/src/Shared/AppStatePersistenceUtility.ts b/src/Shared/AppStatePersistenceUtility.ts
new file mode 100644
index 000000000..bcf5ad7f3
--- /dev/null
+++ b/src/Shared/AppStatePersistenceUtility.ts
@@ -0,0 +1,109 @@
+import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
+
+// The component name whose state is being saved. Component name must not include special characters.
+export type ComponentName = "DocumentsTab";
+
+const SCHEMA_VERSION = 1;
+
+// Export for testing purposes
+export const MAX_ENTRY_NB = 100_000; // Limit number of entries to 100k
+
+export interface StateData {
+ schemaVersion: number;
+ timestamp: number;
+ data: unknown;
+}
+
+type StorePath = {
+ componentName: string;
+ subComponentName?: string;
+ globalAccountName?: string;
+ databaseName?: string;
+ containerName?: string;
+};
+
+// Load and save state data
+export const loadState = (path: StorePath): unknown => {
+ const appState =
+ LocalStorageUtility.getEntryObject
(StorageKey.AppState) || ({} as ApplicationState);
+ const key = createKeyFromPath(path);
+ return appState[key]?.data;
+};
+export const saveState = (path: StorePath, state: unknown): void => {
+ // Retrieve state object
+ const appState =
+ LocalStorageUtility.getEntryObject(StorageKey.AppState) || ({} as ApplicationState);
+ const key = createKeyFromPath(path);
+ appState[key] = {
+ schemaVersion: SCHEMA_VERSION,
+ timestamp: Date.now(),
+ data: state,
+ };
+
+ if (Object.keys(appState).length > MAX_ENTRY_NB) {
+ // Remove the oldest entry
+ const oldestKey = Object.keys(appState).reduce((oldest, current) =>
+ appState[current].timestamp < appState[oldest].timestamp ? current : oldest,
+ );
+ delete appState[oldestKey];
+ }
+
+ LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
+};
+
+export const deleteState = (path: StorePath): void => {
+ // Retrieve state object
+ const appState =
+ LocalStorageUtility.getEntryObject(StorageKey.AppState) || ({} as ApplicationState);
+ const key = createKeyFromPath(path);
+ delete appState[key];
+ LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
+};
+
+// This is for high-frequency state changes
+let timeoutId: NodeJS.Timeout | undefined;
+export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ timeoutId = setTimeout(() => saveState(path, state), debounceDelayMs);
+};
+
+interface ApplicationState {
+ [statePath: string]: StateData;
+}
+
+const orderedPathSegments: (keyof StorePath)[] = [
+ "subComponentName",
+ "globalAccountName",
+ "databaseName",
+ "containerName",
+];
+
+/**
+ * /componentName/subComponentName/globalAccountName/databaseName/containerName/
+ * Any of the path segments can be "" except componentName
+ * Export for testing purposes
+ * @param path
+ */
+export const createKeyFromPath = (path: StorePath): string => {
+ if (path.componentName.includes("/")) {
+ throw new Error(`Invalid component name: ${path.componentName}`);
+ }
+ let key = `/${path.componentName}`; // ComponentName is always there
+ orderedPathSegments.forEach((segment) => {
+ const segmentValue = path[segment as keyof StorePath];
+ if (segmentValue.includes("/")) {
+ throw new Error(`Invalid setting path segment: ${segment}`);
+ }
+ key += `/${segmentValue !== undefined ? segmentValue : ""}`;
+ });
+ return key;
+};
+
+/**
+ * Remove the entire app state key from local storage
+ */
+export const deleteAllStates = (): void => {
+ LocalStorageUtility.removeEntry(StorageKey.AppState);
+};
diff --git a/src/Shared/LocalStorageUtility.ts b/src/Shared/LocalStorageUtility.ts
index 9fc2f4f7c..097f45877 100644
--- a/src/Shared/LocalStorageUtility.ts
+++ b/src/Shared/LocalStorageUtility.ts
@@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void =>
export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
localStorage.setItem(StorageKey[key], value.toString());
+
+export const setEntryObject = (key: StorageKey, value: unknown): void => {
+ localStorage.setItem(StorageKey[key], JSON.stringify(value));
+};
+export const getEntryObject = (key: StorageKey): T | null => {
+ const item = localStorage.getItem(StorageKey[key]);
+ if (item) {
+ return JSON.parse(item) as T;
+ }
+ return null;
+};
diff --git a/src/Shared/StorageUtility.ts b/src/Shared/StorageUtility.ts
index f2ca1f20b..952bcd9ac 100644
--- a/src/Shared/StorageUtility.ts
+++ b/src/Shared/StorageUtility.ts
@@ -30,6 +30,7 @@ export enum StorageKey {
VisitedAccounts,
PriorityLevel,
DefaultQueryResultsView,
+ AppState,
}
export const hasRUThresholdBeenConfigured = (): boolean => {
diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts
index 3b4892990..1fae132ad 100644
--- a/src/Shared/Telemetry/TelemetryConstants.ts
+++ b/src/Shared/Telemetry/TelemetryConstants.ts
@@ -139,6 +139,9 @@ export enum Action {
QueryEdited,
ExecuteQueryGeneratedFromQueryCopilot,
DeleteDocuments,
+ ReadPersistedTabState,
+ SavePersistedTabState,
+ DeletePersistedTabState,
}
export const ActionModifiers = {