refactor: create standalone router package (#22)

* refactor: create standalone router package

* fix: add jsdom to tuono-router package

* fix: remove test in tuono vite.config

* update turbo to latest

* fix: wait build before lint

* fix: router types
This commit is contained in:
Valerio Ageno
2024-08-11 17:14:54 +02:00
committed by GitHub
parent 8604858a61
commit 75c790dd6e
32 changed files with 112 additions and 25 deletions
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { useRouter } from '../hooks/useRouter'
import type { AnchorHTMLAttributes, MouseEvent } from 'react'
export default function Link(
props: AnchorHTMLAttributes<HTMLAnchorElement>,
): JSX.Element {
const router = useRouter()
const handleTransition = (e: MouseEvent<HTMLAnchorElement>): void => {
e.preventDefault()
props.onClick?.(e)
router.push(props.href || '')
}
return (
<a {...props} onClick={handleTransition}>
{props.children}
</a>
)
}
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { getRouteByPathname } from './Matches'
import { cleanup } from '@testing-library/react'
describe('Test getRouteByPathname fn', () => {
afterEach(() => {
cleanup()
})
test('match routes by ids', () => {
vi.mock('../hooks/useInternalRouter.tsx', () => ({
useInternalRouter: (): { routesById: Record<string, any> } => {
return {
routesById: {
'/': { id: '/' },
'/about': { id: '/about' },
'/posts/': { id: '/posts/' }, // posts/index
'/posts/[post]': { id: '/posts/[post]' },
'/posts/defined-post': { id: '/posts/defined-post' },
'/posts/[post]/[comment]': { id: '/posts/[post]/[comment]' },
},
}
},
}))
expect(getRouteByPathname('/')?.id).toBe('/')
expect(getRouteByPathname('/not-found')?.id).toBe(undefined)
expect(getRouteByPathname('/about')?.id).toBe('/about')
expect(getRouteByPathname('/posts/')?.id).toBe('/posts/')
expect(getRouteByPathname('/posts/dynamic-post')?.id).toBe('/posts/[post]')
expect(getRouteByPathname('/posts/defined-post')?.id).toBe(
'/posts/defined-post',
)
expect(getRouteByPathname('/posts/dynamic-post/dynamic-comment')?.id).toBe(
'/posts/[post]/[comment]',
)
})
})
@@ -0,0 +1,75 @@
import * as React from 'react'
import { useInternalRouter } from '../hooks/useInternalRouter'
import { useRouterStore } from '../hooks/useRouterStore'
import type { Route } from '../route'
import { RouteMatch } from './RouteMatch'
import NotFound from './NotFound'
interface MatchesProps {
// user defined props
serverSideProps: any
}
const DYNAMIC_PATH_REGEX = /\[(.*?)\]/
/*
* This function is also implemented on server side to match the bundle
* file to load at the first rendering.
*
* File: crates/tuono_lib/src/payload.rs
*
* Optimizations should occour on both
*/
export function getRouteByPathname(pathname: string): Route | undefined {
const { routesById } = useInternalRouter()
if (routesById[pathname]) return routesById[pathname]
const dynamicRoutes = Object.keys(routesById).filter((route) =>
DYNAMIC_PATH_REGEX.test(route),
)
if (!dynamicRoutes.length) return
const pathSegments = pathname.split('/').filter(Boolean)
let match = undefined
// TODO: Check algo efficiency
for (const dynamicRoute of dynamicRoutes) {
const dynamicRouteSegments = dynamicRoute.split('/').filter(Boolean)
const routeSegmentsCollector: string[] = []
for (let i = 0; i < dynamicRouteSegments.length; i++) {
if (
dynamicRouteSegments[i] === pathSegments[i] ||
DYNAMIC_PATH_REGEX.test(dynamicRouteSegments[i] || '')
) {
routeSegmentsCollector.push(dynamicRouteSegments[i] ?? '')
} else {
break
}
}
if (routeSegmentsCollector.length === pathSegments.length) {
match = `/${routeSegmentsCollector.join('/')}`
break
}
}
if (!match) return
return routesById[match]
}
export function Matches({ serverSideProps }: MatchesProps): JSX.Element {
const location = useRouterStore((st) => st.location)
const route = getRouteByPathname(location.pathname)
if (!route) {
return <NotFound />
}
return <RouteMatch route={route} serverSideProps={serverSideProps} />
}
@@ -0,0 +1,22 @@
import * as React from 'react'
import { RouteMatch } from './RouteMatch'
import Link from './Link'
import { useInternalRouter } from '../hooks/useInternalRouter'
export default function NotFound(): JSX.Element {
const router = useInternalRouter()
const custom404Route = router.routesById['/404']
// Check if exists a custom 404 error page
if (custom404Route) {
return <RouteMatch route={custom404Route} serverSideProps={{}} />
}
return (
<>
<h1>404 Not found</h1>
<Link href="/">Return home</Link>
</>
)
}
@@ -0,0 +1,56 @@
import * as React from 'react'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { RouteMatch } from './RouteMatch'
import { cleanup, render, screen } from '@testing-library/react'
import type { Route } from '../route'
import '@testing-library/jest-dom'
interface Props {
children: React.ReactNode
}
const root = {
isRoot: true,
component: ({ children }: Props) => (
<div data-testid="root">root route {children}</div>
),
} as Route
const parent = {
component: ({ children }: Props) => (
<div data-testid="parent">parent route {children}</div>
),
options: {
getParentRoute: () => root,
},
} as Route
const route = {
component: () => <p data-testid="route">current route</p>,
options: {
getParentRoute: () => parent,
},
} as Route
describe('Test RouteMatch component', () => {
afterEach(() => {
cleanup()
})
test('It should correctly render nested routes', () => {
vi.mock('../hooks/useServerSideProps.tsx', () => ({
useServerSideProps: (): { data: any; isLoading: boolean } => {
return {
data: undefined,
isLoading: false,
}
},
}))
render(<RouteMatch route={route} serverSideProps={{}} />)
expect(screen.getByTestId('root')).toHaveTextContent(
'root route parent route current route',
)
expect(screen.getByTestId('route')).toHaveTextContent('current route')
})
})
@@ -0,0 +1,81 @@
import * as React from 'react'
import type { Route } from '../route'
import { useServerSideProps } from '../hooks/useServerSideProps'
interface MatchProps {
route: Route
// User defined server side props
serverSideProps: any
}
/**
* Returns the route match with the root element if exists
*
* It handles the fetch of the client side resources
*/
export const RouteMatch = ({
route,
serverSideProps,
}: MatchProps): JSX.Element => {
const { data, isLoading } = useServerSideProps(route, serverSideProps)
const routes = React.useMemo(() => {
const components = loadParentComponents(route)
components.push(route)
return components
}, [route.id])
return (
<TraverseRootComponents routes={routes} data={data} isLoading={isLoading} />
)
}
interface TraverseRootComponentsProps {
routes: Route[]
data: any
isLoading: boolean
children?: React.ReactNode
index?: number
}
interface ParentProps {
children: React.ReactNode
data: any
isLoading: boolean
}
const TraverseRootComponents = ({
routes,
data,
isLoading,
index = 0,
}: TraverseRootComponentsProps): JSX.Element => {
const Parent = React.memo(
routes[index]?.component as unknown as (props: ParentProps) => JSX.Element,
)
return (
<Parent data={data} isLoading={isLoading}>
{Boolean(routes.length > index) && (
<TraverseRootComponents
routes={routes}
data={data}
isLoading={isLoading}
index={index + 1}
/>
)}
</Parent>
)
}
const loadParentComponents = (route: Route, loader: Route[] = []): Route[] => {
const parentComponent = route.options?.getParentRoute?.()
loader.push(parentComponent)
if (!parentComponent.isRoot) {
return loadParentComponents(parentComponent, loader)
}
return loader.reverse()
}
@@ -0,0 +1,21 @@
import * as React from 'react'
import type { Router } from '../router'
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const routerContext = React.createContext<Router>(null!)
const TUONO_CONTEXT = '__TUONO_CONTEXT__'
export function getRouterContext(): any {
if (typeof document === 'undefined') {
return routerContext
}
if (window[TUONO_CONTEXT as any]) {
return window[TUONO_CONTEXT as any]
}
window[TUONO_CONTEXT as any] = routerContext as any
return routerContext
}
@@ -0,0 +1,60 @@
import { getRouterContext } from './RouterContext'
import { Matches } from './Matches'
import { useListenBrowserUrlUpdates } from '../hooks/useListenBrowserUrlUpdates'
import React, { type ReactNode } from 'react'
import { initRouterStore } from '../hooks/useRouterStore'
import type { ServerProps } from '../types'
type Router = any
interface RouterContextProviderProps {
router: Router
children: ReactNode
}
interface RouterProviderProps {
router: Router
serverProps?: ServerProps
}
function RouterContextProvider({
router,
children,
...rest
}: RouterContextProviderProps): JSX.Element {
// Allow the router to update options on the router instance
router.update({
...router.options,
...rest,
context: {
...router.options.context,
},
})
const routerContext = getRouterContext()
const pendingElement = router.options.defaultPendingComponent ? (
<router.options.defaultPendingComponent />
) : null
return (
<React.Suspense fallback={pendingElement}>
<routerContext.Provider value={router}>{children}</routerContext.Provider>
</React.Suspense>
)
}
export function RouterProvider({
router,
serverProps,
}: RouterProviderProps): JSX.Element {
initRouterStore(serverProps)
useListenBrowserUrlUpdates()
return (
<RouterContextProvider router={router}>
<Matches serverSideProps={serverProps?.props} />
</RouterContextProvider>
)
}