diff --git a/rsconcept/frontend/src/app/application-layout.tsx b/rsconcept/frontend/src/app/application-layout.tsx index 277095f2..f1c3e34a 100644 --- a/rsconcept/frontend/src/app/application-layout.tsx +++ b/rsconcept/frontend/src/app/application-layout.tsx @@ -3,6 +3,7 @@ import { Outlet } from 'react-router'; import clsx from 'clsx'; import { ModalLoader } from '@/components/modal'; +import { useBrowserNavigation } from '@/hooks/use-browser-navigation'; import { useAppLayoutStore, useMainHeight, useViewportHeight } from '@/stores/app-layout'; import { useDialogsStore } from '@/stores/dialogs'; @@ -16,6 +17,8 @@ import { MutationErrors } from './mutation-errors'; import { Navigation } from './navigation'; export function ApplicationLayout() { + useBrowserNavigation(); + const mainHeight = useMainHeight(); const viewportHeight = useViewportHeight(); const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation); diff --git a/rsconcept/frontend/src/app/global-loader.tsx b/rsconcept/frontend/src/app/global-loader.tsx index f91eab4a..dd56d1b8 100644 --- a/rsconcept/frontend/src/app/global-loader.tsx +++ b/rsconcept/frontend/src/app/global-loader.tsx @@ -3,13 +3,21 @@ import { useDebounce } from 'use-debounce'; import { Loader } from '@/components/loader'; import { ModalBackdrop } from '@/components/modal/modal-backdrop'; +import { useTransitionTracker } from '@/hooks/use-transition-delay'; +import { useAppTransitionStore } from '@/stores/app-transition'; import { PARAMETER } from '@/utils/constants'; export function GlobalLoader() { const navigation = useNavigation(); - const isLoading = navigation.state === 'loading'; - const [loadingDebounced] = useDebounce(isLoading, PARAMETER.navigationPopupDelay); + const isTransitioning = useTransitionTracker(); + const isManualNav = useAppTransitionStore(state => state.isNavigating); + const isRouterLoading = navigation.state === 'loading'; + + const [loadingDebounced] = useDebounce( + isRouterLoading || isTransitioning || isManualNav, + PARAMETER.navigationPopupDelay + ); if (!loadingDebounced) { return null; diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx index 049033d9..63ba778e 100644 --- a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx +++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx @@ -26,7 +26,7 @@ export function PromptTemplatesPage() { active: query.get('active') }); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); useBlockNavigation(isModified); return ( diff --git a/rsconcept/frontend/src/features/library/components/editor-library-item.tsx b/rsconcept/frontend/src/features/library/components/editor-library-item.tsx index e9bb2d1b..8f1c1964 100644 --- a/rsconcept/frontend/src/features/library/components/editor-library-item.tsx +++ b/rsconcept/frontend/src/features/library/components/editor-library-item.tsx @@ -43,7 +43,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem const setGlobalLocation = useLibrarySearchStore(state => state.setLocation); const isProcessing = useMutatingLibrary(); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); const { setOwner } = useSetOwner(); const { setLocation } = useSetLocation(); diff --git a/rsconcept/frontend/src/features/library/components/toolbar-item-card.tsx b/rsconcept/frontend/src/features/library/components/toolbar-item-card.tsx index e1720670..492dba47 100644 --- a/rsconcept/frontend/src/features/library/components/toolbar-item-card.tsx +++ b/rsconcept/frontend/src/features/library/components/toolbar-item-card.tsx @@ -39,7 +39,7 @@ export function ToolbarItemCard({ }: ToolbarItemCardProps) { const role = useRoleStore(state => state.role); const router = useConceptNavigation(); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); const isProcessing = useMutatingLibrary(); const canSave = isModified && !isProcessing; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/editor-oss-card.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/editor-oss-card.tsx index 6807aaba..55ac620d 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/editor-oss-card.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/editor-oss-card.tsx @@ -19,7 +19,7 @@ const SIDELIST_LAYOUT_THRESHOLD = 768; // px export function EditorOssCard() { const { schema, isMutable, deleteSchema } = useOssEdit(); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); const showOSSStats = usePreferencesStore(state => state.showOSSStats); const windowSize = useWindowSize(); const isNarrow = !!windowSize.width && windowSize.width <= SIDELIST_LAYOUT_THRESHOLD; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/form-oss.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/form-oss.tsx index 8b6c8de9..212b78a9 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/form-oss.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/form-oss.tsx @@ -20,7 +20,8 @@ import { useOssEdit } from '../oss-edit-context'; export function FormOSS() { const { updateItem: updateOss } = useUpdateItem(); - const { isModified, setIsModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); + const setIsModified = useModificationStore(state => state.setIsModified); const isProcessing = useMutatingOss(); const { schema, isMutable } = useOssEdit(); diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-page.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-page.tsx index 47316163..b9603c92 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-page.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-page.tsx @@ -37,7 +37,7 @@ export function OssPage() { tab: query.get('tab') }); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); useBlockNavigation(isModified); if (!urlData.id) { diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/form-constituenta.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/form-constituenta.tsx index 063f8e55..486de8b6 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/form-constituenta.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/form-constituenta.tsx @@ -42,7 +42,8 @@ interface FormConstituentaProps { } export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst, onOpenEdit }: FormConstituentaProps) { - const { isModified, setIsModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); + const setIsModified = useModificationStore(state => state.setIsModified); const isProcessing = useMutatingRSForm(); const { updateConstituenta: cstUpdate } = useUpdateConstituenta(); diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/toolbar-constituenta.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/toolbar-constituenta.tsx index ff4d809d..5fc8efad 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/toolbar-constituenta.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/toolbar-constituenta.tsx @@ -64,7 +64,7 @@ export function ToolbarConstituenta({ const showList = usePreferencesStore(state => state.showCstSideList); const toggleList = usePreferencesStore(state => state.toggleShowCstSideList); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); const isProcessing = useMutatingRSForm(); function viewPredecessor(target: number) { diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/editor-rsform-card.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/editor-rsform-card.tsx index ab3030d1..4f5d73c9 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/editor-rsform-card.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/editor-rsform-card.tsx @@ -19,7 +19,7 @@ const SIDELIST_LAYOUT_THRESHOLD = 768; // px export function EditorRSFormCard() { const { schema, isMutable, deleteSchema, isAttachedToOSS } = useRSEdit(); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); const showRSFormStats = usePreferencesStore(state => state.showRSFormStats); const windowSize = useWindowSize(); const isNarrow = !!windowSize.width && windowSize.width <= SIDELIST_LAYOUT_THRESHOLD; diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/form-rsform.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/form-rsform.tsx index ef3f874c..52c04914 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/form-rsform.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/form-rsform.tsx @@ -30,7 +30,7 @@ import { ToolbarVersioning } from './toolbar-versioning'; export function FormRSForm() { const router = useConceptNavigation(); const { updateItem: updateSchema } = useUpdateItem(); - const { setIsModified } = useModificationStore(); + const setIsModified = useModificationStore(state => state.setIsModified); const isProcessing = useMutatingRSForm(); const { schema, isAttachedToOSS, isContentEditable } = useRSEdit(); diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/toolbar-versioning.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/toolbar-versioning.tsx index f76397bc..34eabe66 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/toolbar-versioning.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/toolbar-versioning.tsx @@ -20,7 +20,7 @@ interface ToolbarVersioningProps { } export function ToolbarVersioning({ blockReload, className }: ToolbarVersioningProps) { - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); const { restoreVersion: versionRestore } = useRestoreVersion(); const { schema, isMutable, isContentEditable, navigateVersion, activeVersion, selected } = useRSEdit(); diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/menu-edit-schema.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/menu-edit-schema.tsx index 868ad495..23e92820 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/menu-edit-schema.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/menu-edit-schema.tsx @@ -29,7 +29,7 @@ import { useRSEdit } from './rsedit-context'; export function MenuEditSchema() { const { isAnonymous } = useAuthSuspense(); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); const router = useConceptNavigation(); const menu = useDropdown(); const { schema, activeCst, setSelected, isArchive, isContentEditable, promptTemplate, deselectAll } = useRSEdit(); diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/menu-main.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/menu-main.tsx index a8d0c030..ca07bc4c 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/menu-main.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/menu-main.tsx @@ -40,7 +40,7 @@ export function MenuMain() { const { user, isAnonymous } = useAuthSuspense(); const role = useRoleStore(state => state.role); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); const { download } = useDownloadRSForm(); diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx index 69cc95a4..dc211817 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx @@ -47,7 +47,7 @@ export const RSEditState = ({ const { user } = useAuthSuspense(); const { schema } = useRSFormSuspense({ itemID: itemID, version: activeVersion }); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); const isOwned = !!user.id && user.id === schema.owner; const isArchive = !!activeVersion; diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsform-page.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsform-page.tsx index f22db465..521681ed 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsform-page.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsform-page.tsx @@ -47,7 +47,7 @@ export function RSFormPage() { activeID: query.get('active') }); - const { isModified } = useModificationStore(); + const isModified = useModificationStore(state => state.isModified); useBlockNavigation(isModified); if (!urlData.id) { diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rstabs.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rstabs.tsx index c2723537..0abfbd42 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rstabs.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rstabs.tsx @@ -24,7 +24,7 @@ export function RSTabs({ activeID, activeTab }: RSTabsProps) { const router = useConceptNavigation(); const hideFooter = useAppLayoutStore(state => state.hideFooter); - const { setIsModified } = useModificationStore(); + const setIsModified = useModificationStore(state => state.setIsModified); const { schema, selected, setSelected, deselectAll, navigateRSForm } = useRSEdit(); useLayoutEffect(() => { diff --git a/rsconcept/frontend/src/hooks/use-browser-navigation.ts b/rsconcept/frontend/src/hooks/use-browser-navigation.ts new file mode 100644 index 00000000..59ccf925 --- /dev/null +++ b/rsconcept/frontend/src/hooks/use-browser-navigation.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; + +import { useAppTransitionStore } from '@/stores/app-transition'; + +const DELAY_CACHE_CHECK = 100; // ms + +export function useBrowserNavigation() { + const start = useAppTransitionStore(state => state.startNavigation); + const end = useAppTransitionStore(state => state.endNavigation); + + useEffect(() => { + const onPopState = () => { + start(); + + // Fallback to end the navigation in case route completes with cache + setTimeout(() => { + end(); + }, DELAY_CACHE_CHECK); // or cancel after Suspense/loader finishes + }; + + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, [start, end]); +} diff --git a/rsconcept/frontend/src/hooks/use-reset-modification.ts b/rsconcept/frontend/src/hooks/use-reset-modification.ts index 1246ce48..29daee9b 100644 --- a/rsconcept/frontend/src/hooks/use-reset-modification.ts +++ b/rsconcept/frontend/src/hooks/use-reset-modification.ts @@ -1,13 +1,11 @@ -import { useRef } from 'react'; +import { useEffect } from 'react'; import { useModificationStore } from '@/stores/modification'; export function useResetModification() { - const { setIsModified } = useModificationStore(); - const initialized = useRef(false); + const setIsModified = useModificationStore(state => state.setIsModified); - if (!initialized.current) { - initialized.current = true; + useEffect(() => { setIsModified(false); - } + }, [setIsModified]); } diff --git a/rsconcept/frontend/src/hooks/use-transition-delay.ts b/rsconcept/frontend/src/hooks/use-transition-delay.ts new file mode 100644 index 00000000..6f5f5b99 --- /dev/null +++ b/rsconcept/frontend/src/hooks/use-transition-delay.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { useNavigation } from 'react-router'; + +const DEFAULT_DEBOUNCE_DELAY = 100; // ms + +/** + * Tracks whether a route transition is in progress, even if data is cached or rendering takes time. + * Adds an optional debounce to avoid flashing the loader. + * + * @param delay Optional debounce delay in milliseconds before showing the loader. + * @returns `true` if a transition is in progress (after debounce), `false` otherwise. + */ +export function useTransitionTracker(delay: number = DEFAULT_DEBOUNCE_DELAY): boolean { + const navigation = useNavigation(); + const [showPending, setShowPending] = useState(false); + + useEffect(() => { + let timeout: ReturnType | null = null; + + if (navigation.location) { + timeout = setTimeout(() => setShowPending(true), delay); + } else { + setShowPending(false); + if (timeout) { + clearTimeout(timeout); + } + } + + return () => { + if (timeout) { + clearTimeout(timeout); + } + }; + }, [navigation.location, delay]); + + return showPending; +} diff --git a/rsconcept/frontend/src/stores/app-transition.ts b/rsconcept/frontend/src/stores/app-transition.ts new file mode 100644 index 00000000..f49bc609 --- /dev/null +++ b/rsconcept/frontend/src/stores/app-transition.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface TransitionState { + isNavigating: boolean; + startNavigation: () => void; + endNavigation: () => void; +} + +export const useAppTransitionStore = create(set => ({ + isNavigating: false, + startNavigation: () => set({ isNavigating: true }), + endNavigation: () => set({ isNavigating: false }) +}));