fix: old data kept on new route during transition (#730)

Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
This commit is contained in:
Valerio Ageno
2025-04-10 21:50:16 +02:00
committed by GitHub
parent eb4966e88e
commit e368cc5e23
4 changed files with 89 additions and 33 deletions
@@ -4,6 +4,7 @@ import { cleanup, render, screen } from '@testing-library/react'
import { Route } from '../route' import { Route } from '../route'
import type { RouteComponent, RouteProps } from '../types' import type { RouteComponent, RouteProps } from '../types'
import { useServerPayloadData } from '../hooks/useServerPayloadData' import { useServerPayloadData } from '../hooks/useServerPayloadData'
import { useRouterContext } from '../components/RouterContext'
import { RouteMatch } from './RouteMatch' import { RouteMatch } from './RouteMatch'
@@ -47,16 +48,21 @@ vi.mock('../hooks/useServerPayloadData', () => ({
useServerPayloadData: vi.fn(), useServerPayloadData: vi.fn(),
})) }))
vi.mock('../components/RouterContext', () => ({
useRouterContext: vi.fn(),
}))
const useServerPayloadDataMock = vi.mocked(useServerPayloadData) const useServerPayloadDataMock = vi.mocked(useServerPayloadData)
const useRouterContextMock = vi.mocked(useRouterContext)
describe('<RouteMatch />', () => { describe('<RouteMatch />', () => {
afterEach(cleanup) afterEach(cleanup)
it('should correctly render nested routes', () => { it('should correctly render nested routes', () => {
useServerPayloadDataMock.mockReturnValue({ useServerPayloadDataMock.mockReturnValue({ data: { some: 'data' } })
data: { some: 'data' },
isLoading: false, // @ts-expect-error only isTransitioning is used by RouteMatch
}) useRouterContextMock.mockReturnValue({ isTransitioning: false })
render(<RouteMatch route={route} serverInitialData={{}} />) render(<RouteMatch route={route} serverInitialData={{}} />)
@@ -81,13 +87,15 @@ describe('<RouteMatch />', () => {
) )
}) })
it('should return null data when while loading', () => { it('should correctly handle loading transition', () => {
useServerPayloadDataMock.mockReturnValue({ useServerPayloadDataMock.mockReturnValue({ data: { some: 'data' } })
data: { some: 'data' },
isLoading: true,
})
render(<RouteMatch route={route} serverInitialData={{}} />) // @ts-expect-error only isTransitioning is used by RouteMatch
useRouterContextMock.mockReturnValue({ isTransitioning: true })
const { rerender } = render(
<RouteMatch route={route} serverInitialData={{}} />,
)
expect(screen.getByTestId('root')).toMatchInlineSnapshot( expect(screen.getByTestId('root')).toMatchInlineSnapshot(
` `
@@ -106,5 +114,30 @@ describe('<RouteMatch />', () => {
</div> </div>
`, `,
) )
// @ts-expect-error only isTransitioning is used by RouteMatch
useRouterContextMock.mockReturnValue({ isTransitioning: false })
rerender(<RouteMatch route={route} serverInitialData={{}} />)
expect(screen.getByTestId('root')).toMatchInlineSnapshot(
`
<div
data-testid="root"
>
root route
<div
data-testid="parent"
>
parent route
<div
data-testid="current"
>
{"some":"data"}
</div>
</div>
</div>
`,
)
}) })
}) })
@@ -2,8 +2,11 @@ import type { JSX } from 'react'
import { memo, Suspense, useMemo } from 'react' import { memo, Suspense, useMemo } from 'react'
import type { Route } from '../route' import type { Route } from '../route'
import { useServerPayloadData } from '../hooks/useServerPayloadData' import { useServerPayloadData } from '../hooks/useServerPayloadData'
import { useRouterContext } from './RouterContext'
interface RouteMatchProps<TServerPayloadData = unknown> { interface RouteMatchProps<TServerPayloadData = unknown> {
route: Route route: Route
// User defined server side props // User defined server side props
@@ -19,21 +22,22 @@ export const RouteMatch = ({
route, route,
serverInitialData, serverInitialData,
}: RouteMatchProps): JSX.Element => { }: 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 // eslint-disable-next-line react-hooks/exhaustive-deps
const routes = useMemo(() => loadParentComponents(route), [route.id]) const routes = useMemo(() => loadParentComponents(route), [route.id])
const routeData = isLoading ? null : data const routeData = isTransitioning ? null : data
return ( return (
<TraverseRootComponents <TraverseRootComponents
routes={routes} routes={routes}
data={routeData} data={routeData}
isLoading={isLoading} isLoading={isTransitioning}
> >
<Suspense> <Suspense>
<route.component data={routeData} isLoading={isLoading} /> <route.component data={routeData} isLoading={isTransitioning} />
</Suspense> </Suspense>
</TraverseRootComponents> </TraverseRootComponents>
) )
@@ -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 { ReactNode } from 'react'
import type { Router } from '../router' import type { Router } from '../router'
@@ -17,7 +24,9 @@ export interface ParsedLocation {
interface RouterContextValue { interface RouterContextValue {
router: Router router: Router
location: ParsedLocation location: ParsedLocation
isTransitioning: boolean
updateLocation: (loc: ParsedLocation) => void updateLocation: (loc: ParsedLocation) => void
stopTransitioning: () => void
} }
const RouterContext = createContext({} as RouterContextValue) const RouterContext = createContext({} as RouterContextValue)
@@ -64,6 +73,9 @@ export function RouterContextProvider({
const [location, setLocation] = useState<ParsedLocation>(() => const [location, setLocation] = useState<ParsedLocation>(() =>
getInitialLocation(serverInitialLocation), 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<boolean>(false)
/** /**
* Listen browser navigation events * 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( const contextValue: RouterContextValue = useMemo(
() => ({ () => ({
router, router,
location, location,
updateLocation: setLocation, isTransitioning,
updateLocation,
stopTransitioning,
}), }),
[location, router], [location, router, isTransitioning, updateLocation, stopTransitioning],
) )
return ( return (
@@ -5,8 +5,6 @@ import { fromUrlToParsedLocation } from '../utils/from-url-to-parsed-location'
import { useRouterContext } from '../components/RouterContext' import { useRouterContext } from '../components/RouterContext'
const isServer = typeof document === 'undefined'
interface TuonoApi { interface TuonoApi {
data?: unknown data?: unknown
info: { info: {
@@ -22,7 +20,6 @@ const fetchClientSideData = async (): Promise<TuonoApi> => {
interface UseServerPayloadDataResult<TData> { interface UseServerPayloadDataResult<TData> {
data: TData data: TData
isLoading: boolean
} }
/* /*
@@ -37,15 +34,7 @@ export function useServerPayloadData<TServerPayloadData>(
serverInitialData: TServerPayloadData, serverInitialData: TServerPayloadData,
): UseServerPayloadDataResult<TServerPayloadData> { ): UseServerPayloadDataResult<TServerPayloadData> {
const isFirstRendering = useRef<boolean>(true) const isFirstRendering = useRef<boolean>(true)
const { location, updateLocation } = useRouterContext() const { location, updateLocation, stopTransitioning } = useRouterContext()
const [isLoading, setIsLoading] = useState<boolean>(
// Force loading if has handler
!!route.options.hasHandler &&
// Avoid loading on the server
!isServer &&
// Avoid loading if first rendering
!isFirstRendering.current,
)
const [data, setData] = useState<TServerPayloadData | undefined>( const [data, setData] = useState<TServerPayloadData | undefined>(
serverInitialData, serverInitialData,
@@ -56,6 +45,7 @@ export function useServerPayloadData<TServerPayloadData>(
// props are already bundled by the SSR // props are already bundled by the SSR
if (isFirstRendering.current) { if (isFirstRendering.current) {
isFirstRendering.current = false isFirstRendering.current = false
stopTransitioning()
return return
} }
// After client side routing load again the remote data // After client side routing load again the remote data
@@ -63,7 +53,6 @@ export function useServerPayloadData<TServerPayloadData>(
// The error management is already handled inside the IIFE // The error management is already handled inside the IIFE
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
;(async (): Promise<void> => { ;(async (): Promise<void> => {
setIsLoading(true)
try { try {
const response = await fetchClientSideData() const response = await fetchClientSideData()
if (response.info.redirect_destination) { if (response.info.redirect_destination) {
@@ -84,16 +73,23 @@ export function useServerPayloadData<TServerPayloadData>(
} catch (error) { } catch (error) {
throw Error('Failed loading Server Side Data', { cause: error }) throw Error('Failed loading Server Side Data', { cause: error })
} finally { } finally {
setIsLoading(false) stopTransitioning()
} }
})() })()
} else {
stopTransitioning()
} }
// Clean up the data when changing route // Clean up the data when changing route
return (): void => { return (): void => {
setData(undefined) 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 }
} }