diff --git a/packages/tuono-router/src/components/RouteMatch.spec.tsx b/packages/tuono-router/src/components/RouteMatch.spec.tsx index a2236f19..6dba91f3 100644 --- a/packages/tuono-router/src/components/RouteMatch.spec.tsx +++ b/packages/tuono-router/src/components/RouteMatch.spec.tsx @@ -4,6 +4,7 @@ import { cleanup, render, screen } from '@testing-library/react' import { Route } from '../route' import type { RouteComponent, RouteProps } from '../types' import { useServerPayloadData } from '../hooks/useServerPayloadData' +import { useRouterContext } from '../components/RouterContext' import { RouteMatch } from './RouteMatch' @@ -47,16 +48,21 @@ vi.mock('../hooks/useServerPayloadData', () => ({ useServerPayloadData: vi.fn(), })) +vi.mock('../components/RouterContext', () => ({ + useRouterContext: vi.fn(), +})) + const useServerPayloadDataMock = vi.mocked(useServerPayloadData) +const useRouterContextMock = vi.mocked(useRouterContext) describe('', () => { afterEach(cleanup) it('should correctly render nested routes', () => { - useServerPayloadDataMock.mockReturnValue({ - data: { some: 'data' }, - isLoading: false, - }) + useServerPayloadDataMock.mockReturnValue({ data: { some: 'data' } }) + + // @ts-expect-error only isTransitioning is used by RouteMatch + useRouterContextMock.mockReturnValue({ isTransitioning: false }) render() @@ -81,13 +87,15 @@ describe('', () => { ) }) - it('should return null data when while loading', () => { - useServerPayloadDataMock.mockReturnValue({ - data: { some: 'data' }, - isLoading: true, - }) + it('should correctly handle loading transition', () => { + useServerPayloadDataMock.mockReturnValue({ data: { some: 'data' } }) - render() + // @ts-expect-error only isTransitioning is used by RouteMatch + useRouterContextMock.mockReturnValue({ isTransitioning: true }) + + const { rerender } = render( + , + ) expect(screen.getByTestId('root')).toMatchInlineSnapshot( ` @@ -106,5 +114,30 @@ describe('', () => { `, ) + + // @ts-expect-error only isTransitioning is used by RouteMatch + useRouterContextMock.mockReturnValue({ isTransitioning: false }) + + rerender() + + expect(screen.getByTestId('root')).toMatchInlineSnapshot( + ` +
+ root route +
+ parent route +
+ {"some":"data"} +
+
+
+ `, + ) }) }) diff --git a/packages/tuono-router/src/components/RouteMatch.tsx b/packages/tuono-router/src/components/RouteMatch.tsx index 40519c68..1db74771 100644 --- a/packages/tuono-router/src/components/RouteMatch.tsx +++ b/packages/tuono-router/src/components/RouteMatch.tsx @@ -2,8 +2,11 @@ import type { JSX } from 'react' import { memo, Suspense, useMemo } from 'react' import type { Route } from '../route' + import { useServerPayloadData } from '../hooks/useServerPayloadData' +import { useRouterContext } from './RouterContext' + interface RouteMatchProps { route: Route // User defined server side props @@ -19,21 +22,22 @@ export const RouteMatch = ({ route, serverInitialData, }: RouteMatchProps): JSX.Element => { - const { data, isLoading } = useServerPayloadData(route, serverInitialData) + const { data } = useServerPayloadData(route, serverInitialData) + const { isTransitioning } = useRouterContext() // eslint-disable-next-line react-hooks/exhaustive-deps const routes = useMemo(() => loadParentComponents(route), [route.id]) - const routeData = isLoading ? null : data + const routeData = isTransitioning ? null : data return ( - + ) diff --git a/packages/tuono-router/src/components/RouterContext.tsx b/packages/tuono-router/src/components/RouterContext.tsx index 101c10df..0bf9df88 100644 --- a/packages/tuono-router/src/components/RouterContext.tsx +++ b/packages/tuono-router/src/components/RouterContext.tsx @@ -1,4 +1,11 @@ -import { createContext, useState, useEffect, useContext, useMemo } from 'react' +import { + createContext, + useState, + useEffect, + useContext, + useMemo, + useCallback, +} from 'react' import type { ReactNode } from 'react' import type { Router } from '../router' @@ -17,7 +24,9 @@ export interface ParsedLocation { interface RouterContextValue { router: Router location: ParsedLocation + isTransitioning: boolean updateLocation: (loc: ParsedLocation) => void + stopTransitioning: () => void } const RouterContext = createContext({} as RouterContextValue) @@ -64,6 +73,9 @@ export function RouterContextProvider({ const [location, setLocation] = useState(() => getInitialLocation(serverInitialLocation), ) + // Global state to track whether a page transition is in progress. + // Set to `false` once the page is fully loaded, including server-side data. + const [isTransitioning, setIsTransitioning] = useState(false) /** * Listen browser navigation events @@ -91,13 +103,24 @@ export function RouterContextProvider({ } }, []) + const updateLocation = useCallback((newLocation: ParsedLocation): void => { + setIsTransitioning(true) + setLocation(newLocation) + }, []) + + const stopTransitioning = useCallback((): void => { + setIsTransitioning(false) + }, []) + const contextValue: RouterContextValue = useMemo( () => ({ router, location, - updateLocation: setLocation, + isTransitioning, + updateLocation, + stopTransitioning, }), - [location, router], + [location, router, isTransitioning, updateLocation, stopTransitioning], ) return ( diff --git a/packages/tuono-router/src/hooks/useServerPayloadData.ts b/packages/tuono-router/src/hooks/useServerPayloadData.ts index 74aa50e4..28684b14 100644 --- a/packages/tuono-router/src/hooks/useServerPayloadData.ts +++ b/packages/tuono-router/src/hooks/useServerPayloadData.ts @@ -5,8 +5,6 @@ import { fromUrlToParsedLocation } from '../utils/from-url-to-parsed-location' import { useRouterContext } from '../components/RouterContext' -const isServer = typeof document === 'undefined' - interface TuonoApi { data?: unknown info: { @@ -22,7 +20,6 @@ const fetchClientSideData = async (): Promise => { interface UseServerPayloadDataResult { data: TData - isLoading: boolean } /* @@ -37,15 +34,7 @@ export function useServerPayloadData( serverInitialData: TServerPayloadData, ): UseServerPayloadDataResult { const isFirstRendering = useRef(true) - const { location, updateLocation } = useRouterContext() - const [isLoading, setIsLoading] = useState( - // Force loading if has handler - !!route.options.hasHandler && - // Avoid loading on the server - !isServer && - // Avoid loading if first rendering - !isFirstRendering.current, - ) + const { location, updateLocation, stopTransitioning } = useRouterContext() const [data, setData] = useState( serverInitialData, @@ -56,6 +45,7 @@ export function useServerPayloadData( // props are already bundled by the SSR if (isFirstRendering.current) { isFirstRendering.current = false + stopTransitioning() return } // After client side routing load again the remote data @@ -63,7 +53,6 @@ export function useServerPayloadData( // The error management is already handled inside the IIFE // eslint-disable-next-line @typescript-eslint/no-floating-promises ;(async (): Promise => { - setIsLoading(true) try { const response = await fetchClientSideData() if (response.info.redirect_destination) { @@ -84,16 +73,23 @@ export function useServerPayloadData( } catch (error) { throw Error('Failed loading Server Side Data', { cause: error }) } finally { - setIsLoading(false) + stopTransitioning() } })() + } else { + stopTransitioning() } // Clean up the data when changing route return (): void => { setData(undefined) } - }, [location.pathname, route.options.hasHandler, updateLocation]) + }, [ + location.pathname, + route.options.hasHandler, + updateLocation, + stopTransitioning, + ]) - return { isLoading, data: data as TServerPayloadData } + return { data: data as TServerPayloadData } }