From 2f5342ff0a8197e433763fa24ed72935332aa7c0 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Tue, 19 Nov 2024 22:40:49 +0200 Subject: [PATCH] feat(hydration): implement guard for store hydration management --- src/providers/HydrationGuard.tsx | 38 ++++++++++ src/providers/index.tsx | 11 +-- src/stores/HydrationService.ts | 74 +++++++++++++++++++ src/stores/direction/direction.store.ts | 57 +++++---------- src/stores/generator/generator.store.ts | 14 +++- src/stores/history/core/history.store.ts | 15 +++- src/stores/startup/startup.store.ts | 14 +++- src/stores/theme/theme.store.ts | 92 +++++++++--------------- src/stores/types.ts | 11 +++ 9 files changed, 224 insertions(+), 102 deletions(-) create mode 100644 src/providers/HydrationGuard.tsx create mode 100644 src/stores/HydrationService.ts create mode 100644 src/stores/types.ts diff --git a/src/providers/HydrationGuard.tsx b/src/providers/HydrationGuard.tsx new file mode 100644 index 0000000..9513f4e --- /dev/null +++ b/src/providers/HydrationGuard.tsx @@ -0,0 +1,38 @@ +import type { ReactNode, FC } from "react"; + +import { useGeneratorStore, useHistoryStore, useStartupStore } from "@/stores"; + +/** + * Props for the HydrationGuard component + * @interface HydrationGuardProps + * @property {ReactNode} children - Child components to render once stores are hydrated + */ +interface HydrationGuardProps { + children: ReactNode; +} + +/** + * HydrationGuard ensures that all required Zustand stores are hydrated before rendering children. + * This prevents hydration mismatches and ensures consistent state management across the application. + * + * @component + * @example + * ```tsx + * + * + * + * ``` + * + * If any store is not yet hydrated, it returns null to prevent premature rendering. + */ +export const HydrationGuard: FC = ({ children }) => { + const isGeneratorReady = useGeneratorStore((state) => state._hasHydrated); + const isHistoryReady = useHistoryStore((state) => state._hasHydrated); + const isStartupReady = useStartupStore((state) => state._hasHydrated); + + if (!isGeneratorReady || !isHistoryReady || !isStartupReady) { + return null; + } + + return <>{children}; +}; diff --git a/src/providers/index.tsx b/src/providers/index.tsx index 8f72a97..3448dc6 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -1,5 +1,6 @@ -import { type ReactNode, type FC } from "react"; +import type { ReactNode, FC } from "react"; +import { HydrationGuard } from "./HydrationGuard"; import { ThemeProvider } from "./theme"; import { CacheProvider } from "./cache"; @@ -34,7 +35,9 @@ interface AppProvidersProps { * ``` */ export const AppProviders: FC = ({ children }) => ( - - {children} - + + + {children} + + ); diff --git a/src/stores/HydrationService.ts b/src/stores/HydrationService.ts new file mode 100644 index 0000000..bd792cd --- /dev/null +++ b/src/stores/HydrationService.ts @@ -0,0 +1,74 @@ +import type { HydrationState, WithHydration } from "./types"; + +/** + * Service class for managing hydration state in Zustand stores + * Provides utilities for handling store rehydration from persistent storage + */ +export class HydrationService { + /** + * Returns the hydration configuration for persist middleware + * Includes callback to mark store as hydrated after rehydration + * + * @template T - Type of the store state + * @returns Configuration object with onRehydrateStorage callback + * + * @example + * ```typescript + * export const useStore = create()( + * persist( + * (set) => ({ + * // Store state + * }), + * { + * ...HydrationService.getHydrationConfig(), + * storage: customStorage, + * } + * ) + * ); + * ``` + */ + static getHydrationConfig() { + return { + onRehydrateStorage: (state: WithHydration) => { + return () => state.setHasHydrated(true); + }, + }; + } + + /** + * Returns state object with hydration flag set to true + * Used to mark store as hydrated + * + * @returns Hydration state object with _hasHydrated set to true + * + * @example + * ```typescript + * setHasHydrated: () => set(() => HydrationService.setStateAsHydrated()) + * ``` + */ + static setStateAsHydrated(): HydrationState { + return { + _hasHydrated: true, + }; + } + + /** + * Returns initial hydration state + * Used when initializing store + * + * @returns Initial hydration state with _hasHydrated set to false + * + * @example + * ```typescript + * export const useStore = create()((set) => ({ + * ...HydrationService.getInitialState(), + * // Other initial state + * })); + * ``` + */ + static getInitialState(): HydrationState { + return { + _hasHydrated: false, + }; + } +} diff --git a/src/stores/direction/direction.store.ts b/src/stores/direction/direction.store.ts index db6e9fa..3267fbe 100644 --- a/src/stores/direction/direction.store.ts +++ b/src/stores/direction/direction.store.ts @@ -1,9 +1,7 @@ -import { persist } from "zustand/middleware"; import { create } from "zustand"; -import type { DirectionState, DirectionStore } from "./direction.types"; +import type { DirectionStore } from "./direction.types"; -import { createWxtStorage } from "../persistMiddleware"; import { DirectionService } from "./direction.service"; /** @@ -23,44 +21,27 @@ import { DirectionService } from "./direction.service"; * useDirectionStore.getState().initializeDirection(); * ``` */ -export const useDirectionStore = create()( - persist( - (set) => ({ - // Initialize with default direction state - ...DirectionService.getInitialState(), +export const useDirectionStore = create()((set) => ({ + // Initialize with default direction state + ...DirectionService.getInitialState(), - /** - * Initializes direction based on browser language - */ - initializeDirection: async () => { - const direction = await DirectionService.detectDirection(); + /** + * Initializes direction based on browser language + */ + initializeDirection: async () => { + const direction = await DirectionService.detectDirection(); - set(() => DirectionService.setDirection(direction)); - }, + set(() => DirectionService.setDirection(direction)); + }, - /** - * Sets the current direction and updates HTML dir attribute - * @param direction - Direction to set ('ltr' or 'rtl') - */ - setDirection: (direction) => { - set(() => DirectionService.setDirection(direction)); - }, - }), - { - /** - * Specifies which parts of the state should be persisted - * @param state - Current direction state - * @returns Partial state containing only persistent values - */ - partialize: (state) => ({ - direction: state.direction, - }), - // Use Wxt storage for persistence - storage: createWxtStorage(), - name: "direction-storage", - }, - ), -); + /** + * Sets the current direction and updates HTML dir attribute + * @param direction - Direction to set ('ltr' or 'rtl') + */ + setDirection: (direction) => { + set(() => DirectionService.setDirection(direction)); + }, +})); /** * Initialize browser language direction detection when the store is created diff --git a/src/stores/generator/generator.store.ts b/src/stores/generator/generator.store.ts index f3b63b6..8c0f18c 100644 --- a/src/stores/generator/generator.store.ts +++ b/src/stores/generator/generator.store.ts @@ -6,9 +6,11 @@ import type { GeneratorState, GeneratorStore, } from "./generator.types"; +import type { WithHydration } from "../types"; import { createWxtStorage } from "../persistMiddleware"; import { GeneratorService } from "./generator.service"; +import { HydrationService } from "../HydrationService"; /** * Zustand store for managing password/passphrase generation state and options @@ -27,11 +29,12 @@ import { GeneratorService } from "./generator.service"; * useGeneratorStore.getState().updatePasswordOptions({ length: 16 }); * ``` */ -export const useGeneratorStore = create()( +export const useGeneratorStore = create>()( persist( (set, get) => ({ // Initialize with default generator state ...GeneratorService.getInitialState(), + ...HydrationService.getInitialState(), /** * Updates passphrase generation options @@ -61,6 +64,14 @@ export const useGeneratorStore = create()( set((state) => GeneratorService.updatePasswordOptions(state, updates)); }, + /** + * Sets the hydration status to true + * Called automatically by persist middleware after rehydration + */ + setHasHydrated: () => { + set(() => HydrationService.setStateAsHydrated()); + }, + /** * Sets the currently displayed password/passphrase * @param value - Password or passphrase to set @@ -91,6 +102,7 @@ export const useGeneratorStore = create()( // Use Wxt storage for persistence storage: createWxtStorage>(), name: "generator-storage", + ...HydrationService.getHydrationConfig(), }, ), ); diff --git a/src/stores/history/core/history.store.ts b/src/stores/history/core/history.store.ts index 1ddf887..de861e1 100644 --- a/src/stores/history/core/history.store.ts +++ b/src/stores/history/core/history.store.ts @@ -1,3 +1,6 @@ +import type { WithHydration } from "@/stores/types"; + +import { HydrationService } from "@/stores/HydrationService"; import { persist } from "zustand/middleware"; import { create } from "zustand"; @@ -29,11 +32,12 @@ import { HistoryService } from "./history.service"; * console.log(passwords); * ``` */ -export const useHistoryStore = create()( +export const useHistoryStore = create>()( persist( (set) => ({ // Initialize with default history state ...HistoryService.getInitialState(), + ...HydrationService.getInitialState(), /** * Adds a new password to the history @@ -51,6 +55,14 @@ export const useHistoryStore = create()( set((state) => HistoryService.removePassword(state, id)); }, + /** + * Sets the hydration status to true + * Called automatically by persist middleware after rehydration + */ + setHasHydrated: () => { + set(() => HydrationService.setStateAsHydrated()); + }, + /** * Clears entire password history * Resets to initial empty state @@ -71,6 +83,7 @@ export const useHistoryStore = create()( // Use Wxt storage for persistence storage: createWxtStorage(), name: "history-storage", + ...HydrationService.getHydrationConfig(), }, ), ); diff --git a/src/stores/startup/startup.store.ts b/src/stores/startup/startup.store.ts index 9b7893a..834c73a 100644 --- a/src/stores/startup/startup.store.ts +++ b/src/stores/startup/startup.store.ts @@ -2,8 +2,10 @@ import { persist } from "zustand/middleware"; import { create } from "zustand"; import type { StartupState, StartupStore } from "./startup.types"; +import type { WithHydration } from "../types"; import { createWxtStorage } from "../persistMiddleware"; +import { HydrationService } from "../HydrationService"; import { StartupService } from "./startup.service"; /** @@ -25,11 +27,12 @@ import { StartupService } from "./startup.service"; * } * ``` */ -export const useStartupStore = create()( +export const useStartupStore = create>()( persist( (set) => ({ // Initialize with default startup state ...StartupService.getInitialState(), + ...HydrationService.getInitialState(), /** * Updates the welcome screen viewed status @@ -38,6 +41,14 @@ export const useStartupStore = create()( setHasSeenWelcome: (value) => { set(() => StartupService.updateStatus(value)); }, + + /** + * Sets the hydration status to true + * Called automatically by persist middleware after rehydration + */ + setHasHydrated: () => { + set(() => HydrationService.setStateAsHydrated()); + }, }), { /** @@ -51,6 +62,7 @@ export const useStartupStore = create()( // Use Wxt storage for persistence storage: createWxtStorage(), name: "startup-storage", + ...HydrationService.getHydrationConfig(), }, ), ); diff --git a/src/stores/theme/theme.store.ts b/src/stores/theme/theme.store.ts index d6fe9d0..d533086 100644 --- a/src/stores/theme/theme.store.ts +++ b/src/stores/theme/theme.store.ts @@ -1,9 +1,7 @@ -import { persist } from "zustand/middleware"; import { create } from "zustand"; -import type { ThemeStore, ThemeState } from "./theme.types"; +import type { ThemeStore } from "./theme.types"; -import { createWxtStorage } from "../persistMiddleware"; import { ThemeService } from "./theme.service"; /** @@ -23,65 +21,45 @@ import { ThemeService } from "./theme.service"; * useThemeStore.getState().toggleTheme(); * ``` */ -export const useThemeStore = create()( - persist( - (set, get) => ({ - // Initialize with default theme state - ...ThemeService.getInitialState(), +export const useThemeStore = create()((set, get) => ({ + // Initialize with default theme state + ...ThemeService.getInitialState(), - /** - * Synchronizes theme with system preferences and sets up theme change listener - * @returns {() => void} Cleanup function to remove system theme listener - */ - syncWithSystem: () => { - // Update theme to match current system preference - set(() => - ThemeService.updateTheme(ThemeService.getSystemTheme(), true), - ); + /** + * Synchronizes theme with system preferences and sets up theme change listener + * @returns {() => void} Cleanup function to remove system theme listener + */ + syncWithSystem: () => { + // Update theme to match current system preference + set(() => ThemeService.updateTheme(ThemeService.getSystemTheme(), true)); - // Set up listener for system theme changes - return ThemeService.setupSystemThemeListener(() => { - const state = get(); - const update = ThemeService.handleSystemThemeChange(state); + // Set up listener for system theme changes + return ThemeService.setupSystemThemeListener(() => { + const state = get(); + const update = ThemeService.handleSystemThemeChange(state); - if (update) { - set(update); - } - }); - }, + if (update) { + set(update); + } + }); + }, - /** - * Toggles between light and dark theme - * If system theme is enabled, this will disable it - */ - toggleTheme: () => { - set((state) => ThemeService.toggleTheme(state)); - }, + /** + * Toggles between light and dark theme + * If system theme is enabled, this will disable it + */ + toggleTheme: () => { + set((state) => ThemeService.toggleTheme(state)); + }, - /** - * Sets the theme to a specific value - * @param theme - Theme to set (usually 'light' or 'dark') - */ - setTheme: (theme) => { - set(() => ThemeService.setTheme(theme)); - }, - }), - { - /** - * Specifies which parts of the state should be persisted - * @param state - Current theme state - * @returns Partial state containing only persistent values - */ - partialize: (state) => ({ - isSystemTheme: state.isSystemTheme, - theme: state.theme, - }), - // Use Wxt storage for persistence - storage: createWxtStorage(), - name: "theme-storage", - }, - ), -); + /** + * Sets the theme to a specific value + * @param theme - Theme to set (usually 'light' or 'dark') + */ + setTheme: (theme) => { + set(() => ThemeService.setTheme(theme)); + }, +})); /** * Initialize system theme synchronization when the store is created diff --git a/src/stores/types.ts b/src/stores/types.ts new file mode 100644 index 0000000..3b80644 --- /dev/null +++ b/src/stores/types.ts @@ -0,0 +1,11 @@ +export interface HydrationActions { + setHasHydrated: (state: boolean) => void; +} + +export type HydrationStore = HydrationActions & HydrationState; + +export interface HydrationState { + _hasHydrated: boolean; +} + +export type WithHydration = HydrationStore & T;