mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-29 22:02:46 -07:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user