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:
Valerio Ageno
2025-01-19 16:23:30 +01:00
committed by GitHub
parent 43910fd75d
commit 33aa37ed7d
20 changed files with 242 additions and 99 deletions
@@ -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>
)
+2 -3
View File
@@ -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
View File
@@ -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'
+3
View File
@@ -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> {
+3 -3
View File
@@ -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 -2
View File
@@ -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'
@@ -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>
))}
</>
)
+3 -15
View File
@@ -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