From 210a1d44e7c8627dba2ba69a799d1cff7e45e8d5 Mon Sep 17 00:00:00 2001 From: Hein Date: Sat, 7 Feb 2026 23:08:52 +0200 Subject: [PATCH] docs(changeset): feat(GlobalStateStore): add initialization state and update actions --- .changeset/config.json | 2 +- .changeset/puny-pots-crash.md | 5 + src/GlobalStateStore/GlobalStateStore.ts | 286 +++++++++++------- .../GlobalStateStore.types.ts | 3 +- .../GlobalStateStore.utils.test.ts | 1 + .../GlobalStateStore.utils.ts | 1 + 6 files changed, 189 insertions(+), 109 deletions(-) create mode 100644 .changeset/puny-pots-crash.md diff --git a/.changeset/config.json b/.changeset/config.json index 8e5e411..0bbe107 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "changelog": "@changesets/changelog-git", - "commit": false, + "commit": true, "fixed": [], "linked": [], "access": "public", diff --git a/.changeset/puny-pots-crash.md b/.changeset/puny-pots-crash.md new file mode 100644 index 0000000..18c0381 --- /dev/null +++ b/.changeset/puny-pots-crash.md @@ -0,0 +1,5 @@ +--- +'@warkypublic/oranguru': patch +--- + +feat(GlobalStateStore): add initialization state and update actions diff --git a/src/GlobalStateStore/GlobalStateStore.ts b/src/GlobalStateStore/GlobalStateStore.ts index ea440aa..6cbaff3 100644 --- a/src/GlobalStateStore/GlobalStateStore.ts +++ b/src/GlobalStateStore/GlobalStateStore.ts @@ -20,6 +20,7 @@ import type { import { loadStorage, saveStorage } from './GlobalStateStore.utils'; const initialState: GlobalState = { + initialized: false, layout: { bottomBar: { open: false }, leftBar: { open: false }, @@ -141,13 +142,14 @@ const createNavigationSlice = (set: SetState) => ({ })), }); -const createComplexActions = (set: SetState, get: GetState) => ({ - fetchData: async (url?: string) => { +const createComplexActions = (set: SetState, get: GetState) => { + // Internal implementation without lock + const fetchDataInternal = async (url?: string) => { try { set((state: GlobalState) => ({ session: { ...state.session, - apiURL: url ?? state.session.apiURL, + apiURL: url || state.session.apiURL, loading: true, }, })); @@ -190,7 +192,16 @@ const createComplexActions = (set: SetState, get: GetState) => ({ }, })); } - }, + }; + + return { + fetchData: async (url?: string) => { + // Wait for initialization to complete + await waitForInitialization(); + + // Use lock to prevent concurrent fetchData calls + return withOperationLock(() => fetchDataInternal(url)); + }, isLoggedIn: (): boolean => { const session = get().session; @@ -203,92 +214,146 @@ const createComplexActions = (set: SetState, get: GetState) => ({ return true; }, - login: async (authToken?: string) => { - try { - set((state: GlobalState) => ({ - session: { - ...state.session, - authToken: authToken ?? '', - expiryDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - loading: true, - loggedIn: true, - }, - })); - await get().fetchData(); - const currentState = get(); - const result = await currentState.onLogin?.(currentState); - if (result) { - set((state: GlobalState) => ({ - ...state, - owner: result.owner ? { ...state.owner, ...result.owner } : state.owner, - program: result.program ? { ...state.program, ...result.program } : state.program, - session: result.session ? { ...state.session, ...result.session } : state.session, - user: result.user ? { ...state.user, ...result.user } : state.user, - })); - } - } catch (e) { - set((state: GlobalState) => ({ - session: { - ...state.session, - connected: false, - error: `Login Exception: ${String(e)}`, - loading: false, - loggedIn: false, - }, - })); - } finally { - set((state: GlobalState) => ({ - session: { - ...state.session, - loading: false, - }, - })); - } - }, + login: async (authToken?: string, user?: Partial) => { + // Wait for initialization to complete + await waitForInitialization(); - logout: async () => { - try { - set((state: GlobalState) => ({ - ...initialState, - session: { - ...initialState.session, - apiURL: state.session.apiURL, - expiryDate: undefined, - loading: true, - loggedIn: false, - }, - })); - await get().fetchData(); - const currentState = get(); - const result = await currentState.onLogout?.(currentState); - if (result) { - set((state: GlobalState) => ({ - ...state, - owner: result.owner ? { ...state.owner, ...result.owner } : state.owner, - program: result.program ? { ...state.program, ...result.program } : state.program, - session: result.session ? { ...state.session, ...result.session } : state.session, - user: result.user ? { ...state.user, ...result.user } : state.user, - })); - } - } catch (e) { - set((state: GlobalState) => ({ - session: { - ...state.session, - connected: false, - error: `Logout Exception: ${String(e)}`, - loading: false, - }, - })); - } finally { - set((state: GlobalState) => ({ - session: { - ...state.session, - loading: false, - }, - })); - } - }, -}); + // Use lock to prevent concurrent auth operations + return withOperationLock(async () => { + try { + set((state: GlobalState) => ({ + session: { + ...state.session, + authToken: authToken ?? '', + expiryDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + loading: true, + loggedIn: true, + }, + user: { + ...state.user, + ...user, + }, + })); + + const currentState = get(); + const result = await currentState.onLogin?.(currentState); + if (result) { + set((state: GlobalState) => ({ + ...state, + owner: result.owner ? { ...state.owner, ...result.owner } : state.owner, + program: result.program ? { ...state.program, ...result.program } : state.program, + session: result.session ? { ...state.session, ...result.session } : state.session, + user: result.user ? { ...state.user, ...result.user } : state.user, + })); + } + // Call internal version to avoid nested lock + await fetchDataInternal(); + } catch (e) { + set((state: GlobalState) => ({ + session: { + ...state.session, + connected: false, + error: `Login Exception: ${String(e)}`, + loading: false, + loggedIn: false, + }, + })); + } finally { + set((state: GlobalState) => ({ + session: { + ...state.session, + loading: false, + }, + })); + } + }); + }, + + logout: async () => { + // Wait for initialization to complete + await waitForInitialization(); + + // Use lock to prevent concurrent auth operations + return withOperationLock(async () => { + try { + set((state: GlobalState) => ({ + ...initialState, + session: { + ...initialState.session, + apiURL: state.session.apiURL, + expiryDate: undefined, + loading: true, + loggedIn: false, + }, + })); + + const currentState = get(); + const result = await currentState.onLogout?.(currentState); + if (result) { + set((state: GlobalState) => ({ + ...state, + owner: result.owner ? { ...state.owner, ...result.owner } : state.owner, + program: result.program ? { ...state.program, ...result.program } : state.program, + session: result.session ? { ...state.session, ...result.session } : state.session, + user: result.user ? { ...state.user, ...result.user } : state.user, + })); + } + // Call internal version to avoid nested lock + await fetchDataInternal(); + } catch (e) { + set((state: GlobalState) => ({ + session: { + ...state.session, + connected: false, + error: `Logout Exception: ${String(e)}`, + loading: false, + }, + })); + } finally { + set((state: GlobalState) => ({ + session: { + ...state.session, + loading: false, + }, + })); + } + }); + }, + }; +}; + +// State management flags and locks - must be defined before store creation +let isStorageInitialized = false; +let initializationPromise: null | Promise = null; +let operationLock: Promise = Promise.resolve(); + +// Helper to wait for initialization - must be defined before store creation +const waitForInitialization = async (): Promise => { + if (initializationPromise) { + await initializationPromise; + } +}; + +// Helper to ensure async operations run sequentially +const withOperationLock = async (operation: () => Promise): Promise => { + const currentLock = operationLock; + let releaseLock: () => void; + + // Create new lock promise + operationLock = new Promise((resolve) => { + releaseLock = resolve; + }); + + try { + // Wait for previous operation to complete + await currentLock; + // Run the operation + return await operation(); + } finally { + // Release the lock + releaseLock!(); + } +}; const GlobalStateStore = createStore((set, get) => ({ ...initialState, @@ -301,33 +366,40 @@ const GlobalStateStore = createStore((set, get) => ({ ...createComplexActions(set, get), })); -// Flag to prevent saving during initial load -let isInitialLoad = true; - -loadStorage() +// Initialize storage and load saved state +initializationPromise = loadStorage() .then((state) => { - GlobalStateStore.setState((current) => ({ - ...current, - ...state, - session: { - ...current.session, - ...state.session, - connected: true, - loading: false, - }, - })); + // Merge loaded state with initial state + GlobalStateStore.setState( + (current) => ({ + ...current, + ...state, + initialized: true, + session: { + ...current.session, + ...state.session, + connected: true, + loading: false, + }, + }), + true // Replace state completely to avoid triggering subscription during init + ); }) .catch((e) => { console.error('Error loading storage:', e); + // Mark as initialized even on error so app doesn't hang + GlobalStateStore.setState({ initialized: true }); }) .finally(() => { - // Enable saving after initial load completes - isInitialLoad = false; + // Mark initialization as complete + isStorageInitialized = true; + initializationPromise = null; }); +// Subscribe to state changes and persist to storage +// Only saves after initialization is complete GlobalStateStore.subscribe((state) => { - // Skip saving during initial load - if (isInitialLoad) { + if (!isStorageInitialized) { return; } diff --git a/src/GlobalStateStore/GlobalStateStore.types.ts b/src/GlobalStateStore/GlobalStateStore.types.ts index e1ec381..c63c22d 100644 --- a/src/GlobalStateStore/GlobalStateStore.types.ts +++ b/src/GlobalStateStore/GlobalStateStore.types.ts @@ -18,6 +18,7 @@ type DatabaseDetail = { type ExtractState = S extends { getState: () => infer X } ? X : never; interface GlobalState { + initialized: boolean; layout: LayoutState; navigation: NavigationState; owner: OwnerState; @@ -31,7 +32,7 @@ interface GlobalStateActions { fetchData: (url?: string) => Promise; isLoggedIn: () => boolean; - login: (authToken?: string) => Promise; + login: (authToken?: string, user?: Partial) => Promise; logout: () => Promise; // Callbacks for custom logic onFetchSession?: (state: Partial) => Promise>; diff --git a/src/GlobalStateStore/GlobalStateStore.utils.test.ts b/src/GlobalStateStore/GlobalStateStore.utils.test.ts index 5dd38b0..059f0c4 100644 --- a/src/GlobalStateStore/GlobalStateStore.utils.test.ts +++ b/src/GlobalStateStore/GlobalStateStore.utils.test.ts @@ -14,6 +14,7 @@ import { get, set } from 'idb-keyval'; describe('GlobalStateStore.utils', () => { const mockState: GlobalState = { + initialized: false, layout: { bottomBar: { open: false }, leftBar: { open: false }, diff --git a/src/GlobalStateStore/GlobalStateStore.utils.ts b/src/GlobalStateStore/GlobalStateStore.utils.ts index 37d67d4..b27dac0 100644 --- a/src/GlobalStateStore/GlobalStateStore.utils.ts +++ b/src/GlobalStateStore/GlobalStateStore.utils.ts @@ -6,6 +6,7 @@ const STORAGE_KEY = 'APP_GLO'; const SKIP_PATHS = new Set([ 'app.controls', + 'initialized', 'session.connected', 'session.error', 'session.loading',