mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-25 12:52:47 -07:00
fix: old data kept on new route during transition (#730)
Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
This commit is contained in:
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user