refactor: create TuonoContext (#413)

This commit is contained in:
Marco Pasqualetti
2025-01-25 15:56:10 +01:00
committed by GitHub
parent d5cc65983e
commit dcedb1c6c0
26 changed files with 253 additions and 110 deletions
+8 -8
View File
@@ -16,8 +16,8 @@ fn has_dynamic_path(route: &str) -> bool {
#[derive(SerdeSerialize)]
/// This is the payload sent to the client for hydration
pub struct Payload<'a> {
router: Location,
props: &'a dyn Serialize,
location: Location,
data: &'a dyn Serialize,
mode: Mode,
#[serde(rename(serialize = "jsBundles"))]
js_bundles: Option<Vec<&'a String>>,
@@ -28,7 +28,7 @@ pub struct Payload<'a> {
}
impl<'a> Payload<'a> {
pub fn new(req: &'a Request, props: &'a dyn Serialize) -> Payload<'a> {
pub fn new(req: &'a Request, data: &'a dyn Serialize) -> Payload<'a> {
let config = GLOBAL_CONFIG
.get()
.expect("Failed to load the current config");
@@ -42,8 +42,8 @@ impl<'a> Payload<'a> {
};
Payload {
router: req.location(),
props,
location: req.location(),
data,
mode,
js_bundles: None,
css_bundles: None,
@@ -79,7 +79,7 @@ impl<'a> Payload<'a> {
let mut js_bundles_sources = vec![&main_bundle.file];
let mut css_bundles_sources = main_bundle.css.iter().collect::<Vec<&String>>();
let pathname = &self.router.pathname();
let pathname = &self.location.pathname();
let bundle_data = manifest.get(*pathname);
@@ -233,8 +233,8 @@ mod tests {
let location = Location::from(uri);
Payload {
router: location,
props: &None::<Option<()>>,
location,
data: &None::<Option<()>>,
mode,
js_bundles: None,
css_bundles: None,
@@ -2,7 +2,7 @@ import type * as React from 'react'
import { useInView } from 'react-intersection-observer'
import { useRouter } from '../hooks/useRouter'
import useRoute from '../hooks/useRoute'
import { useRoute } from '../hooks/useRoute'
interface TuonoLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
/**
@@ -1,17 +1,19 @@
import type * as React from 'react'
import useRoute from '../hooks/useRoute'
import { useRoute } from '../hooks/useRoute'
import { RouteMatch } from './RouteMatch'
import NotFound from './NotFound'
import { useRouterContext } from './RouterContext'
interface MatchesProps<TServerSideProps = unknown> {
interface MatchesProps<TServerPayloadData = unknown> {
// user defined props
serverSideProps: TServerSideProps
serverInitialData: TServerPayloadData
}
export function Matches({ serverSideProps }: MatchesProps): React.JSX.Element {
export function Matches({
serverInitialData,
}: MatchesProps): React.JSX.Element {
const { location } = useRouterContext()
const route = useRoute(location.pathname)
@@ -20,5 +22,5 @@ export function Matches({ serverSideProps }: MatchesProps): React.JSX.Element {
return <NotFound />
}
return <RouteMatch route={route} serverSideProps={serverSideProps} />
return <RouteMatch route={route} serverInitialData={serverInitialData} />
}
@@ -12,7 +12,7 @@ export default function NotFound(): React.JSX.Element {
// Check if exists a custom 404 error page
if (custom404Route) {
return <RouteMatch route={custom404Route} serverSideProps={{}} />
return <RouteMatch route={custom404Route} serverInitialData={{}} />
}
return (
@@ -6,6 +6,7 @@ import { cleanup, render, screen } from '@testing-library/react'
import type { Route } from '../route'
import { RouteMatch } from './RouteMatch'
import '@testing-library/jest-dom'
interface Props {
@@ -41,8 +42,8 @@ describe('Test RouteMatch component', () => {
})
test('It should correctly render nested routes', () => {
vi.mock('../hooks/useServerSideProps.tsx', () => ({
useServerSideProps: (): { data: unknown; isLoading: boolean } => {
vi.mock('../hooks/useServerPayloadData.ts', () => ({
useServerPayloadData: (): { data: unknown; isLoading: boolean } => {
return {
data: undefined,
isLoading: false,
@@ -50,7 +51,7 @@ describe('Test RouteMatch component', () => {
},
}))
render(<RouteMatch route={route} serverSideProps={{}} />)
render(<RouteMatch route={route} serverInitialData={{}} />)
expect(screen.getByTestId('root')).toHaveTextContent(
'root route parent route current route',
)
@@ -1,12 +1,12 @@
import * as React from 'react'
import type { Route } from '../route'
import { useServerSideProps } from '../hooks/useServerSideProps'
import { useServerPayloadData } from '../hooks/useServerPayloadData'
interface RouteMatchProps<TServerSideProps = unknown> {
interface RouteMatchProps<TServerPayloadData = unknown> {
route: Route
// User defined server side props
serverSideProps: TServerSideProps
serverInitialData: TServerPayloadData
}
/**
@@ -16,9 +16,9 @@ interface RouteMatchProps<TServerSideProps = unknown> {
*/
export const RouteMatch = ({
route,
serverSideProps,
serverInitialData: serverInitialData,
}: RouteMatchProps): React.JSX.Element => {
const { data, isLoading } = useServerSideProps(route, serverSideProps)
const { data, isLoading } = useServerPayloadData(route, serverInitialData)
// eslint-disable-next-line react-hooks/exhaustive-deps
const routes = React.useMemo(() => loadParentComponents(route), [route.id])
@@ -2,7 +2,7 @@ import { createContext, useState, useEffect, useContext, useMemo } from 'react'
import type { ReactNode } from 'react'
import type { Router } from '../router'
import type { ServerRouterInfo, ServerProps } from '../types'
import type { ServerInitialLocation } from '../types'
const isServerSide = typeof window === 'undefined'
@@ -17,53 +17,51 @@ export interface ParsedLocation {
interface RouterContextValue {
router: Router
location: ParsedLocation
serverSideProps?: ServerProps
updateLocation: (loc: ParsedLocation) => void
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const RouterContext = createContext<RouterContextValue>(null!)
const RouterContext = createContext({} as RouterContextValue)
function getInitialLocation(
serverSideProps?: ServerRouterInfo,
serverPayloadLocation: ServerInitialLocation,
): ParsedLocation {
if (typeof document === 'undefined') {
if (isServerSide) {
return {
pathname: serverSideProps?.pathname || '',
pathname: serverPayloadLocation.pathname || '',
hash: '',
href: serverSideProps?.href || '',
searchStr: serverSideProps?.searchStr || '',
href: serverPayloadLocation.href || '',
searchStr: serverPayloadLocation.searchStr || '',
// TODO: Polyfill URLSearchParams
search: {},
}
}
const { location } = window
const { pathname, hash, href, search } = window.location
return {
pathname: location.pathname,
hash: location.hash,
href: location.href,
searchStr: location.search,
search: Object.fromEntries(new URLSearchParams(location.search)),
pathname,
hash,
href,
searchStr: search,
search: Object.fromEntries(new URLSearchParams(search)),
}
}
interface RouterContextProviderProps {
router: Router
serverInitialLocation: ServerInitialLocation
children: ReactNode
serverSideProps?: ServerProps
}
export function RouterContextProvider({
router,
serverInitialLocation,
children,
serverSideProps,
}: RouterContextProviderProps): ReactNode {
// Allow the router to update options on the router instance
router.update({ ...router.options } as Parameters<typeof router.update>[0])
const [location, setLocation] = useState<ParsedLocation>(() =>
getInitialLocation(serverSideProps?.router),
getInitialLocation(serverInitialLocation),
)
/**
@@ -94,14 +92,11 @@ export function RouterContextProvider({
const contextValue: RouterContextValue = useMemo(
() => ({
serverSideProps: isServerSide
? serverSideProps
: window.__TUONO_SSR_PROPS__,
router,
location,
updateLocation: setLocation,
}),
[location, router, serverSideProps],
[location, router],
)
return (
@@ -111,6 +106,9 @@ export function RouterContextProvider({
)
}
/**
* @warning THIS SHOULD NOT BE EXPOSED TO USERLAND
*/
export function useRouterContext(): RouterContextValue {
return useContext(RouterContext)
}
@@ -1,6 +1,6 @@
import type { JSX } from 'react'
import type { ServerProps } from '../types'
import type { ServerInitialLocation } from '../types'
import type { Router } from '../router'
import { RouterContextProvider } from './RouterContext'
@@ -8,19 +8,21 @@ import { Matches } from './Matches'
interface RouterProviderProps {
router: Router
serverProps?: ServerProps
serverInitialLocation: ServerInitialLocation
serverInitialData: unknown
}
/**
* This component is used in the tuono app entry point
*/
export function RouterProvider({
router,
serverProps,
serverInitialLocation,
serverInitialData,
}: RouterProviderProps): JSX.Element {
return (
<RouterContextProvider router={router} serverSideProps={serverProps}>
<Matches serverSideProps={serverProps?.props} />
<RouterContextProvider
router={router}
serverInitialLocation={serverInitialLocation}
>
<Matches serverInitialData={serverInitialData} />
</RouterContextProvider>
)
}
@@ -1,9 +1,7 @@
import type { Router } from './router'
import type { ServerProps } from './types'
declare global {
interface Window {
__TUONO__ROUTER__: Router
__TUONO_SSR_PROPS__?: ServerProps
}
}
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import useRoute from './useRoute'
import { useRoute } from './useRoute'
describe('useRoute', () => {
afterEach(cleanup)
@@ -22,9 +22,9 @@ export function sanitizePathname(pathname: string): string {
*
* File: crates/tuono_lib/src/payload.rs
*
* Optimizations should occour on both
* Optimizations should occur on both
*/
export default function useRoute(pathname?: string): Route | undefined {
export function useRoute(pathname?: string): Route | undefined {
const {
router: { routesById },
} = useRouterContext()
@@ -7,11 +7,6 @@ import { useRouterContext } from '../components/RouterContext'
const isServer = typeof document === 'undefined'
interface UseServerSidePropsReturn<TData> {
data: TData
isLoading: boolean
}
interface TuonoApi {
data?: unknown
info: {
@@ -25,17 +20,22 @@ const fetchClientSideData = async (): Promise<TuonoApi> => {
return data
}
interface UseServerPayloadDataResult<TData> {
data: TData
isLoading: boolean
}
/*
* Use the props provided by the SSR and dehydrate the
* props for client side usage.
*
* In case is a client fetch the remote data API
*/
export function useServerSideProps<T>(
export function useServerPayloadData<TServerPayloadData>(
route: Route,
// User defined props
serverSideProps: T,
): UseServerSidePropsReturn<T> {
// User defined data
serverInitialData: TServerPayloadData,
): UseServerPayloadDataResult<TServerPayloadData> {
const isFirstRendering = useRef<boolean>(true)
const { location, updateLocation } = useRouterContext()
const [isLoading, setIsLoading] = useState<boolean>(
@@ -47,8 +47,8 @@ export function useServerSideProps<T>(
!isFirstRendering.current,
)
const [data, setData] = useState<T | undefined>(
(serverSideProps ?? window.__TUONO_SSR_PROPS__?.props) as T,
const [data, setData] = useState<TServerPayloadData | undefined>(
serverInitialData,
)
useEffect(() => {
@@ -80,7 +80,7 @@ export function useServerSideProps<T>(
updateLocation(parsedLocation)
return
}
setData(response.data as T)
setData(response.data as TServerPayloadData)
} catch (error) {
throw Error('Failed loading Server Side Data', { cause: error })
} finally {
@@ -95,5 +95,5 @@ export function useServerSideProps<T>(
}
}, [location.pathname, route.options.hasHandler, updateLocation])
return { isLoading, data: data as T }
return { isLoading, data: data as TServerPayloadData }
}
+3 -3
View File
@@ -1,7 +1,7 @@
export { RouterProvider } from './components/RouterProvider'
export { useRouterContext } from './components/RouterContext'
export { default as Link } from './components/Link'
export { createRouter, type RouterType } from './router'
export { createRouter } from './router'
export type { RouterInstanceType } from './router'
export { createRoute, createRootRoute } from './route'
export { useRouter } from './hooks/useRouter'
export type { RouteProps, RouteComponent, ServerProps } from './types'
export type { RouteProps, RouteComponent } from './types'
+2 -1
View File
@@ -21,7 +21,8 @@ export function createRouter(options: CreateRouterOptions): Router {
return new Router(options)
}
export type RouterType = InstanceType<typeof Router>
export type RouterInstanceType = InstanceType<typeof Router>
export class Router {
options?: RouterOptions
basePath = '/'
+6 -13
View File
@@ -5,26 +5,19 @@ export interface Segment {
value: string
}
export interface ServerRouterInfo {
/**
* Provided by the rust server and used in the ssr env
* @see tuono {@link ServerPayloadLocation}
*/
export interface ServerInitialLocation {
href: string
pathname: string
searchStr: string
}
export interface ServerProps<TProps = unknown> {
router: ServerRouterInfo
props: TProps
jsBundles: Array<string>
cssBundles: Array<string>
mode: 'Dev' | 'Prod'
devServerConfig: {
port: number
host: string
}
}
export interface RouteProps<TData = unknown> {
data: TData
isLoading: boolean
children?: ReactNode
+1
View File
@@ -0,0 +1 @@
export const SERVER_PAYLOAD_VARIABLE_NAME = '__TUONO_SERVER_PAYLOAD__'
+8
View File
@@ -0,0 +1,8 @@
import type { ServerPayload } from './types'
import type { SERVER_PAYLOAD_VARIABLE_NAME } from './constants'
declare global {
interface Window {
[SERVER_PAYLOAD_VARIABLE_NAME]?: ServerPayload
}
}
+4 -4
View File
@@ -1,13 +1,13 @@
import type { JSX } from 'react'
import { useRouterContext } from 'tuono-router'
import { useTuonoContextServerPayload } from './TuonoContext'
const VITE_PROXY_PATH = '/vite-server'
const DEFAULT_SERVER_CONFIG = { host: 'localhost', port: 3000 }
export const DevResources = (): JSX.Element => {
const { serverSideProps } = useRouterContext()
const { host, port } =
serverSideProps?.devServerConfig ?? DEFAULT_SERVER_CONFIG
const { devServerConfig } = useTuonoContextServerPayload()
const { host, port } = devServerConfig ?? DEFAULT_SERVER_CONFIG
const viteBaseUrl = `http://${host}:${port}${VITE_PROXY_PATH}`
+5 -4
View File
@@ -1,12 +1,13 @@
import type { JSX } from 'react'
import { useRouterContext } from 'tuono-router'
import { useTuonoContextServerPayload } from './TuonoContext'
export const ProdResources = (): JSX.Element => {
const { serverSideProps } = useRouterContext()
const { cssBundles, jsBundles } = useTuonoContextServerPayload()
return (
<>
{serverSideProps?.cssBundles.map((cssHref) => (
{cssBundles?.map((cssHref) => (
<link
key={cssHref}
rel="stylesheet"
@@ -16,7 +17,7 @@ export const ProdResources = (): JSX.Element => {
/>
))}
{serverSideProps?.jsBundles.map((scriptSrc) => (
{jsBundles?.map((scriptSrc) => (
<script key={scriptSrc} type="module" src={`/${scriptSrc}`}></script>
))}
</>
@@ -0,0 +1,30 @@
import type { JSX } from 'react'
import { RouterProvider } from 'tuono-router'
import type { RouterInstanceType } from 'tuono-router'
import { useTuonoContextServerPayload } from './TuonoContext'
interface RouterContextProviderWrapperProps {
router: RouterInstanceType
}
/**
* This component is needed to get the data from {@link TuonoContext}
* since the provider is also located in {@link TuonoEntryPoint}
* hence the context cannot be accessed directly there
*
* @see https://github.com/tuono-labs/tuono/issues/410
*/
export function RouterContextProviderWrapper({
router,
}: RouterContextProviderWrapperProps): JSX.Element {
const serverPayload = useTuonoContextServerPayload()
return (
<RouterProvider
router={router}
serverInitialLocation={serverPayload.location}
serverInitialData={serverPayload.data}
/>
)
}
@@ -0,0 +1,50 @@
import type { JSX, ReactNode } from 'react'
import { createContext, useContext, useMemo } from 'react'
import type { ServerPayload } from '../types'
import { SERVER_PAYLOAD_VARIABLE_NAME } from '../constants'
const isServerSide = typeof window === 'undefined'
interface TuonoContextValue {
serverPayload: ServerPayload
}
const TuonoContext = createContext({} as TuonoContextValue)
interface TuonoContextProviderProps {
serverPayload?: ServerPayload
children: ReactNode
}
/**
* @warning THIS SHOULD NOT BE EXPOSED TO USERLAND
*
* @see https://github.com/tuono-labs/tuono/issues/410
*/
export function TuonoContextProvider({
serverPayload,
children,
}: TuonoContextProviderProps): JSX.Element {
const contextValue: TuonoContextValue = useMemo(() => {
// At least one of these two should be defined
const _serverPayload = (
isServerSide ? serverPayload : window[SERVER_PAYLOAD_VARIABLE_NAME]
) as ServerPayload
return {
// Maybe this logic should be integrated using defaults
serverPayload: _serverPayload,
}
}, [serverPayload])
return <TuonoContext value={contextValue}>{children}</TuonoContext>
}
/**
* @warning THIS SHOULD NOT BE EXPOSED TO USERLAND
*/
export function useTuonoContextServerPayload(): TuonoContextValue['serverPayload'] {
return useContext(TuonoContext).serverPayload
}
+14 -6
View File
@@ -1,18 +1,26 @@
import { type JSX, StrictMode } from 'react'
import { type RouterType, type ServerProps, RouterProvider } from 'tuono-router'
import { StrictMode } from 'react'
import type { JSX } from 'react'
import type { RouterInstanceType } from 'tuono-router'
import type { ServerPayload } from '../types'
import { TuonoContextProvider } from './TuonoContext'
import { RouterContextProviderWrapper } from './RouterContextProviderWrapper'
interface TuonoEntryPointProps {
router: RouterType
serverProps?: ServerProps
router: RouterInstanceType
serverPayload?: ServerPayload
}
export function TuonoEntryPoint({
router,
serverProps,
serverPayload,
}: TuonoEntryPointProps): JSX.Element {
return (
<StrictMode>
<RouterProvider router={router} serverProps={serverProps} />
<TuonoContextProvider serverPayload={serverPayload}>
<RouterContextProviderWrapper router={router} />
</TuonoContextProvider>
</StrictMode>
)
}
+6 -5
View File
@@ -1,18 +1,19 @@
import type { JSX } from 'react'
import { useRouterContext } from 'tuono-router'
import { SERVER_PAYLOAD_VARIABLE_NAME } from '../constants'
import { DevResources } from './DevResources'
import { ProdResources } from './ProdResources'
import { useTuonoContextServerPayload } from './TuonoContext'
export function TuonoScripts(): JSX.Element {
const { serverSideProps } = useRouterContext()
const serverPayload = useTuonoContextServerPayload()
return (
<>
<script>{`window.__TUONO_SSR_PROPS__=${JSON.stringify(serverSideProps)}`}</script>
{serverSideProps?.mode === 'Dev' && <DevResources />}
{serverSideProps?.mode === 'Prod' && <ProdResources />}
<script>{`window['${SERVER_PAYLOAD_VARIABLE_NAME}']=${JSON.stringify(serverPayload)}`}</script>
{serverPayload.mode === 'Dev' && <DevResources />}
{serverPayload.mode === 'Prod' && <ProdResources />}
</>
)
}
+4 -3
View File
@@ -42,9 +42,10 @@ import type { ReadableStream } from 'node:stream/web'
import { renderToReadableStream } from 'react-dom/server'
import { createRouter } from 'tuono-router'
import type { createRoute, ServerProps } from 'tuono-router'
import type { createRoute } from 'tuono-router'
import { TuonoEntryPoint } from '../shared/TuonoEntryPoint'
import type { ServerPayload } from '../types'
import { streamToString } from './utils'
@@ -52,12 +53,12 @@ type RouteTree = ReturnType<typeof createRoute>
export function serverSideRendering(routeTree: RouteTree) {
return async function render(payload: string | undefined): Promise<string> {
const serverProps = (payload ? JSON.parse(payload) : {}) as ServerProps
const serverPayload = (payload ? JSON.parse(payload) : {}) as ServerPayload
const router = createRouter({ routeTree }) // Render the app
const stream = await renderToReadableStream(
<TuonoEntryPoint router={router} serverProps={serverProps} />,
<TuonoEntryPoint router={router} serverPayload={serverPayload} />,
)
await stream.allReady
+48
View File
@@ -1,3 +1,51 @@
/**
* Provided by the rust server and used in the ssr env
* @see tuono-router {@link ServerInitialLocation}
*/
export interface ServerPayloadLocation {
href: string
pathname: string
searchStr: string
}
/**
* @see crates/tuono_lib/src/payload.rs
*/
export interface ServerPayload<TData = unknown> {
mode: 'Prod' | 'Dev'
location: ServerPayloadLocation
data: TData
/** Available only on 'Prod' mode */
jsBundles: Array<string> | null
cssBundles: Array<string> | null
/** Available only on 'Dev' mode */
devServerConfig?: {
port: number
host: string
}
}
/* the above type could be refined with an union like this
(
| {
mode: 'Prod'
jsBundles: Array<string>
cssBundles: Array<string>
}
| {
mode: 'Dev'
devServerConfig: {
port: number
host: string
}
}
)
*/
export interface TuonoProps<T> {
data?: T
isLoading: boolean