mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-25 12:52:47 -07:00
fix: create a component tree that matches between client and server to avoid useId output mismatch (#371)
Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
This commit is contained in:
@@ -2,7 +2,9 @@ import { createContext, useState, useEffect, useContext, useMemo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { Router } from '../router'
|
||||
import type { ServerRouterInfo } from '../types'
|
||||
import type { ServerRouterInfo, ServerProps } from '../types'
|
||||
|
||||
const isServerSide = typeof window === 'undefined'
|
||||
|
||||
export interface ParsedLocation {
|
||||
href: string
|
||||
@@ -15,6 +17,7 @@ export interface ParsedLocation {
|
||||
interface RouterContextValue {
|
||||
router: Router
|
||||
location: ParsedLocation
|
||||
serverSideProps?: ServerProps
|
||||
updateLocation: (loc: ParsedLocation) => void
|
||||
}
|
||||
|
||||
@@ -48,7 +51,7 @@ function getInitialLocation(
|
||||
interface RouterContextProviderProps {
|
||||
router: Router
|
||||
children: ReactNode
|
||||
serverSideProps?: ServerRouterInfo
|
||||
serverSideProps?: ServerProps
|
||||
}
|
||||
|
||||
export function RouterContextProvider({
|
||||
@@ -60,7 +63,7 @@ export function RouterContextProvider({
|
||||
router.update({ ...router.options } as Parameters<typeof router.update>[0])
|
||||
|
||||
const [location, setLocation] = useState<ParsedLocation>(() =>
|
||||
getInitialLocation(serverSideProps),
|
||||
getInitialLocation(serverSideProps?.router),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -91,11 +94,14 @@ export function RouterContextProvider({
|
||||
|
||||
const contextValue: RouterContextValue = useMemo(
|
||||
() => ({
|
||||
serverSideProps: isServerSide
|
||||
? serverSideProps
|
||||
: window.__TUONO_SSR_PROPS__,
|
||||
router,
|
||||
location,
|
||||
updateLocation: setLocation,
|
||||
}),
|
||||
[location, router],
|
||||
[location, router, serverSideProps],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -105,7 +111,6 @@ export function RouterContextProvider({
|
||||
)
|
||||
}
|
||||
|
||||
/** @warning DO NOT EXPORT THIS TO USER LAND */
|
||||
export function useRouterContext(): RouterContextValue {
|
||||
return useContext(RouterContext)
|
||||
}
|
||||
|
||||
@@ -19,10 +19,7 @@ export function RouterProvider({
|
||||
serverProps,
|
||||
}: RouterProviderProps): JSX.Element {
|
||||
return (
|
||||
<RouterContextProvider
|
||||
router={router}
|
||||
serverSideProps={serverProps?.router}
|
||||
>
|
||||
<RouterContextProvider router={router} serverSideProps={serverProps}>
|
||||
<Matches serverSideProps={serverProps?.props} />
|
||||
</RouterContextProvider>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Router } from './router'
|
||||
import type { ServerProps } from './types'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TUONO__ROUTER__: Router
|
||||
__TUONO_SSR_PROPS__?: {
|
||||
props?: unknown
|
||||
}
|
||||
__TUONO_SSR_PROPS__?: ServerProps
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { RouterProvider } from './components/RouterProvider'
|
||||
export { useRouterContext } from './components/RouterContext'
|
||||
export { default as Link } from './components/Link'
|
||||
export { createRouter } from './router'
|
||||
export { createRoute, createRootRoute } from './route'
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface ServerRouterInfo {
|
||||
export interface ServerProps<TProps = unknown> {
|
||||
router: ServerRouterInfo
|
||||
props: TProps
|
||||
jsBundles: Array<string>
|
||||
cssBundles: Array<string>
|
||||
mode: 'Dev' | 'Prod'
|
||||
}
|
||||
|
||||
export interface RouteProps<TData = unknown> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import { StrictMode } from 'react'
|
||||
import { hydrateRoot } from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from 'tuono-router'
|
||||
import type { createRoute } from 'tuono-router'
|
||||
@@ -11,8 +11,8 @@ export function hydrate(routeTree: RouteTree): void {
|
||||
|
||||
hydrateRoot(
|
||||
document,
|
||||
<React.StrictMode>
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</React.StrictMode>,
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ export {
|
||||
useRouter,
|
||||
} from 'tuono-router'
|
||||
|
||||
export { RouteLazyLoading as __tuono__internal__lazyLoadRoute } from './dynamic/RouteLazyLoading'
|
||||
export { dynamic } from './dynamic/dynamic'
|
||||
export {
|
||||
dynamic,
|
||||
RouteLazyLoading as __tuono__internal__lazyLoadRoute,
|
||||
} from './shared/dynamic'
|
||||
|
||||
export { TuonoScripts } from './shared/TuonoScripts'
|
||||
|
||||
export type { TuonoProps } from './types'
|
||||
|
||||
+11
-3
@@ -6,7 +6,7 @@ const SCRIPT_BASE_URL = `http://localhost:${TUONO_DEV_SERVER_PORT}${VITE_PROXY_P
|
||||
|
||||
export const DevResources = (): JSX.Element => (
|
||||
<>
|
||||
<script type="module">
|
||||
<script type="module" async>
|
||||
{[
|
||||
`import RefreshRuntime from '${SCRIPT_BASE_URL}/@react-refresh'`,
|
||||
'RefreshRuntime.injectIntoGlobalHook(window)',
|
||||
@@ -15,7 +15,15 @@ export const DevResources = (): JSX.Element => (
|
||||
'window.__vite_plugin_react_preamble_installed__ = true',
|
||||
].join('\n')}
|
||||
</script>
|
||||
<script type="module" src={`${SCRIPT_BASE_URL}/@vite/client`}></script>
|
||||
<script type="module" src={`${SCRIPT_BASE_URL}/client-main.tsx`}></script>
|
||||
<script
|
||||
type="module"
|
||||
async
|
||||
src={`${SCRIPT_BASE_URL}/@vite/client`}
|
||||
></script>
|
||||
<script
|
||||
type="module"
|
||||
async
|
||||
src={`${SCRIPT_BASE_URL}/client-main.tsx`}
|
||||
></script>
|
||||
</>
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { JSX } from 'react'
|
||||
import { useRouterContext } from 'tuono-router'
|
||||
|
||||
export const ProdResources = (): JSX.Element => {
|
||||
const { serverSideProps } = useRouterContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
{serverSideProps?.cssBundles.map((cssHref) => (
|
||||
<link
|
||||
key={cssHref}
|
||||
rel="stylesheet"
|
||||
precedence="high"
|
||||
type="text/css"
|
||||
href={`/${cssHref}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
{serverSideProps?.jsBundles.map((scriptSrc) => (
|
||||
<script key={scriptSrc} type="module" src={`/${scriptSrc}`}></script>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { JSX } from 'react'
|
||||
|
||||
import { useRouterContext } from 'tuono-router'
|
||||
|
||||
import { DevResources } from './DevResources'
|
||||
import { ProdResources } from './ProdResources'
|
||||
|
||||
export function TuonoScripts(): JSX.Element {
|
||||
const { serverSideProps } = useRouterContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
<script>{`window.__TUONO_SSR_PROPS__=${JSON.stringify(serverSideProps)}`}</script>
|
||||
{serverSideProps?.mode === 'Dev' && <DevResources />}
|
||||
{serverSideProps?.mode === 'Prod' && <ProdResources />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { lazy, createElement } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
import type { RouteComponent } from 'tuono-router'
|
||||
|
||||
type ImportFn = () => Promise<{ default: RouteComponent }>
|
||||
|
||||
export const RouteLazyLoading = (factory: ImportFn): RouteComponent => {
|
||||
let LoadedComponent: RouteComponent | undefined
|
||||
const LazyComponent = lazy<RouteComponent>(factory)
|
||||
|
||||
const loadComponent = (): Promise<void> =>
|
||||
factory().then((module) => {
|
||||
LoadedComponent = module.default
|
||||
})
|
||||
|
||||
const Component = (
|
||||
props: React.ComponentProps<RouteComponent>,
|
||||
): ReactElement => createElement(LoadedComponent || LazyComponent, props)
|
||||
|
||||
Component.preload = loadComponent
|
||||
|
||||
return Component
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* This component is heavily inspired by Next.js dynamic function
|
||||
* Link: https://github.com/vercel/next.js/blob/1df81bcea62800198884438a2bb27ba14c9d506a/packages/next/src/shared/lib/dynamic.tsx
|
||||
*/
|
||||
import { lazy, Suspense, Fragment } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
const isServerSide = typeof window === 'undefined'
|
||||
|
||||
interface ComponentModule<T> {
|
||||
default: React.ComponentType<T>
|
||||
}
|
||||
|
||||
interface DynamicOptions {
|
||||
ssr?: boolean
|
||||
loading?: React.ComponentType<unknown> | null
|
||||
}
|
||||
|
||||
type Loader<T = object> = () => Promise<
|
||||
React.ComponentType<T> | ComponentModule<T>
|
||||
>
|
||||
|
||||
interface LoadableOptions<T> extends DynamicOptions {
|
||||
loader: Loader<T>
|
||||
}
|
||||
|
||||
type LoadableFn = <T = object>(options: LoadableOptions<T>) => ComponentType<T>
|
||||
|
||||
const defaultLoaderOptions: LoadableOptions<object> = {
|
||||
ssr: true,
|
||||
loading: null,
|
||||
loader: () => Promise.resolve(() => null),
|
||||
}
|
||||
|
||||
function noSSR<T = object>(
|
||||
LoadableInitializer: LoadableFn,
|
||||
loadableOptions: LoadableOptions<T>,
|
||||
): React.ComponentType<T> {
|
||||
if (!isServerSide) {
|
||||
return LoadableInitializer(loadableOptions)
|
||||
}
|
||||
|
||||
if (!loadableOptions.loading) return () => null
|
||||
|
||||
const Loading = loadableOptions.loading
|
||||
// This will only be rendered on the server side
|
||||
function NoSSRLoading(): React.JSX.Element {
|
||||
return <Loading />
|
||||
}
|
||||
return NoSSRLoading
|
||||
}
|
||||
|
||||
function Loadable<T = object>(options: LoadableOptions<T>): ComponentType<T> {
|
||||
const opts = { ...defaultLoaderOptions, ...options }
|
||||
const Lazy = lazy(() => opts.loader().then())
|
||||
const Loading = opts.loading
|
||||
|
||||
function LoadableComponent(props: T): React.JSX.Element {
|
||||
const fallbackElement = Loading ? <Loading /> : null
|
||||
|
||||
const Wrap = Loading ? Suspense : Fragment
|
||||
const wrapProps = Loading ? { fallback: fallbackElement } : {}
|
||||
|
||||
// TODO: In case ssr = false handle also the assets preloading
|
||||
return (
|
||||
<Wrap {...wrapProps}>
|
||||
<Lazy {...props} />
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
LoadableComponent.displayName = 'LoadableComponent'
|
||||
|
||||
return LoadableComponent
|
||||
}
|
||||
|
||||
/**
|
||||
* This function lets you dynamically import a component.
|
||||
* It uses [React.lazy()](https://react.dev/reference/react/lazy) with [Suspense](https://react.dev/reference/react/Suspense) under the hood.
|
||||
*/
|
||||
export function dynamic<T = object>(
|
||||
importFn: Loader<T>,
|
||||
opts?: DynamicOptions,
|
||||
): ComponentType<T> {
|
||||
if (typeof opts?.ssr === 'boolean' && !opts.ssr) {
|
||||
return noSSR<T>(Loadable, { ...opts, loader: importFn })
|
||||
}
|
||||
return Loadable<T>({ ...opts, loader: importFn })
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { dynamic } from './dynamic'
|
||||
export { RouteLazyLoading } from './RouteLazyLoading'
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { JSX } from 'react'
|
||||
|
||||
interface ProdResourcesProps {
|
||||
cssBundles: Array<string>
|
||||
jsBundles: Array<string>
|
||||
}
|
||||
|
||||
export const ProdResources = ({
|
||||
cssBundles,
|
||||
jsBundles,
|
||||
}: ProdResourcesProps): JSX.Element => (
|
||||
<>
|
||||
{cssBundles.map((cssHref) => (
|
||||
<link
|
||||
key={cssHref}
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href={`/${cssHref}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
{jsBundles.map((scriptSrc) => (
|
||||
<script key={scriptSrc} type="module" src={`/${scriptSrc}`}></script>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
@@ -40,13 +40,11 @@ import { MessageChannelPolyfill } from './polyfills/MessageChannel'
|
||||
|
||||
import type { ReadableStream } from 'node:stream/web'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { renderToReadableStream } from 'react-dom/server'
|
||||
import { RouterProvider, createRouter } from 'tuono-router'
|
||||
import type { createRoute } from 'tuono-router'
|
||||
|
||||
import { DevResources } from './components/DevResources'
|
||||
import { ProdResources } from './components/ProdResources'
|
||||
import type { Mode } from './types'
|
||||
import { streamToString } from './utils'
|
||||
|
||||
type RouteTree = ReturnType<typeof createRoute>
|
||||
@@ -58,22 +56,12 @@ export function serverSideRendering(routeTree: RouteTree) {
|
||||
unknown
|
||||
>
|
||||
|
||||
const mode = serverProps.mode as Mode
|
||||
const jsBundles = serverProps.jsBundles as Array<string>
|
||||
const cssBundles = serverProps.cssBundles as Array<string>
|
||||
const router = createRouter({ routeTree }) // Render the app
|
||||
|
||||
const stream = await renderToReadableStream(
|
||||
<>
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} serverProps={serverProps as never} />
|
||||
|
||||
{mode === 'Dev' && <DevResources />}
|
||||
{mode === 'Prod' && (
|
||||
<ProdResources cssBundles={cssBundles} jsBundles={jsBundles} />
|
||||
)}
|
||||
|
||||
<script>{`window.__TUONO_SSR_PROPS__=${payload as string}`}</script>
|
||||
</>,
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
await stream.allReady
|
||||
|
||||
Reference in New Issue
Block a user