docs(changeset): feat(GlobalStateStore): add initialization state and update actions

This commit is contained in:
2026-02-07 23:08:52 +02:00
parent c2113357f2
commit 210a1d44e7
6 changed files with 189 additions and 109 deletions

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/changelog-git", "changelog": "@changesets/changelog-git",
"commit": false, "commit": true,
"fixed": [], "fixed": [],
"linked": [], "linked": [],
"access": "public", "access": "public",

View File

@@ -0,0 +1,5 @@
---
'@warkypublic/oranguru': patch
---
feat(GlobalStateStore): add initialization state and update actions

View File

@@ -20,6 +20,7 @@ import type {
import { loadStorage, saveStorage } from './GlobalStateStore.utils'; import { loadStorage, saveStorage } from './GlobalStateStore.utils';
const initialState: GlobalState = { const initialState: GlobalState = {
initialized: false,
layout: { layout: {
bottomBar: { open: false }, bottomBar: { open: false },
leftBar: { open: false }, leftBar: { open: false },
@@ -141,13 +142,14 @@ const createNavigationSlice = (set: SetState) => ({
})), })),
}); });
const createComplexActions = (set: SetState, get: GetState) => ({ const createComplexActions = (set: SetState, get: GetState) => {
fetchData: async (url?: string) => { // Internal implementation without lock
const fetchDataInternal = async (url?: string) => {
try { try {
set((state: GlobalState) => ({ set((state: GlobalState) => ({
session: { session: {
...state.session, ...state.session,
apiURL: url ?? state.session.apiURL, apiURL: url || state.session.apiURL,
loading: true, 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 => { isLoggedIn: (): boolean => {
const session = get().session; const session = get().session;
@@ -203,92 +214,146 @@ const createComplexActions = (set: SetState, get: GetState) => ({
return true; return true;
}, },
login: async (authToken?: string) => { login: async (authToken?: string, user?: Partial<UserState>) => {
try { // Wait for initialization to complete
set((state: GlobalState) => ({ await waitForInitialization();
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,
},
}));
}
},
logout: async () => { // Use lock to prevent concurrent auth operations
try { return withOperationLock(async () => {
set((state: GlobalState) => ({ try {
...initialState, set((state: GlobalState) => ({
session: { session: {
...initialState.session, ...state.session,
apiURL: state.session.apiURL, authToken: authToken ?? '',
expiryDate: undefined, expiryDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
loading: true, loading: true,
loggedIn: false, loggedIn: true,
}, },
})); user: {
await get().fetchData(); ...state.user,
const currentState = get(); ...user,
const result = await currentState.onLogout?.(currentState); },
if (result) { }));
set((state: GlobalState) => ({
...state, const currentState = get();
owner: result.owner ? { ...state.owner, ...result.owner } : state.owner, const result = await currentState.onLogin?.(currentState);
program: result.program ? { ...state.program, ...result.program } : state.program, if (result) {
session: result.session ? { ...state.session, ...result.session } : state.session, set((state: GlobalState) => ({
user: result.user ? { ...state.user, ...result.user } : state.user, ...state,
})); owner: result.owner ? { ...state.owner, ...result.owner } : state.owner,
} program: result.program ? { ...state.program, ...result.program } : state.program,
} catch (e) { session: result.session ? { ...state.session, ...result.session } : state.session,
set((state: GlobalState) => ({ user: result.user ? { ...state.user, ...result.user } : state.user,
session: { }));
...state.session, }
connected: false, // Call internal version to avoid nested lock
error: `Logout Exception: ${String(e)}`, await fetchDataInternal();
loading: false, } catch (e) {
}, set((state: GlobalState) => ({
})); session: {
} finally { ...state.session,
set((state: GlobalState) => ({ connected: false,
session: { error: `Login Exception: ${String(e)}`,
...state.session, loading: false,
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<void> = null;
let operationLock: Promise<void> = Promise.resolve();
// Helper to wait for initialization - must be defined before store creation
const waitForInitialization = async (): Promise<void> => {
if (initializationPromise) {
await initializationPromise;
}
};
// Helper to ensure async operations run sequentially
const withOperationLock = async <T>(operation: () => Promise<T>): Promise<T> => {
const currentLock = operationLock;
let releaseLock: () => void;
// Create new lock promise
operationLock = new Promise<void>((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<GlobalStateStoreType>((set, get) => ({ const GlobalStateStore = createStore<GlobalStateStoreType>((set, get) => ({
...initialState, ...initialState,
@@ -301,33 +366,40 @@ const GlobalStateStore = createStore<GlobalStateStoreType>((set, get) => ({
...createComplexActions(set, get), ...createComplexActions(set, get),
})); }));
// Flag to prevent saving during initial load // Initialize storage and load saved state
let isInitialLoad = true; initializationPromise = loadStorage()
loadStorage()
.then((state) => { .then((state) => {
GlobalStateStore.setState((current) => ({ // Merge loaded state with initial state
...current, GlobalStateStore.setState(
...state, (current) => ({
session: { ...current,
...current.session, ...state,
...state.session, initialized: true,
connected: true, session: {
loading: false, ...current.session,
}, ...state.session,
})); connected: true,
loading: false,
},
}),
true // Replace state completely to avoid triggering subscription during init
);
}) })
.catch((e) => { .catch((e) => {
console.error('Error loading storage:', e); console.error('Error loading storage:', e);
// Mark as initialized even on error so app doesn't hang
GlobalStateStore.setState({ initialized: true });
}) })
.finally(() => { .finally(() => {
// Enable saving after initial load completes // Mark initialization as complete
isInitialLoad = false; isStorageInitialized = true;
initializationPromise = null;
}); });
// Subscribe to state changes and persist to storage
// Only saves after initialization is complete
GlobalStateStore.subscribe((state) => { GlobalStateStore.subscribe((state) => {
// Skip saving during initial load if (!isStorageInitialized) {
if (isInitialLoad) {
return; return;
} }

View File

@@ -18,6 +18,7 @@ type DatabaseDetail = {
type ExtractState<S> = S extends { getState: () => infer X } ? X : never; type ExtractState<S> = S extends { getState: () => infer X } ? X : never;
interface GlobalState { interface GlobalState {
initialized: boolean;
layout: LayoutState; layout: LayoutState;
navigation: NavigationState; navigation: NavigationState;
owner: OwnerState; owner: OwnerState;
@@ -31,7 +32,7 @@ interface GlobalStateActions {
fetchData: (url?: string) => Promise<void>; fetchData: (url?: string) => Promise<void>;
isLoggedIn: () => boolean; isLoggedIn: () => boolean;
login: (authToken?: string) => Promise<void>; login: (authToken?: string, user?: Partial<UserState>) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
// Callbacks for custom logic // Callbacks for custom logic
onFetchSession?: (state: Partial<GlobalState>) => Promise<Partial<GlobalState>>; onFetchSession?: (state: Partial<GlobalState>) => Promise<Partial<GlobalState>>;

View File

@@ -14,6 +14,7 @@ import { get, set } from 'idb-keyval';
describe('GlobalStateStore.utils', () => { describe('GlobalStateStore.utils', () => {
const mockState: GlobalState = { const mockState: GlobalState = {
initialized: false,
layout: { layout: {
bottomBar: { open: false }, bottomBar: { open: false },
leftBar: { open: false }, leftBar: { open: false },

View File

@@ -6,6 +6,7 @@ const STORAGE_KEY = 'APP_GLO';
const SKIP_PATHS = new Set([ const SKIP_PATHS = new Set([
'app.controls', 'app.controls',
'initialized',
'session.connected', 'session.connected',
'session.error', 'session.error',
'session.loading', 'session.loading',