Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: create a component tree that matches between client and server to avoid useId output mismatch #371

Merged
merged 11 commits into from
Jan 19, 2025
2 changes: 2 additions & 0 deletions examples/tuono-app/src/routes/__layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode, JSX } from 'react'
import { TuonoScripts } from 'tuono'

interface RootLayoutProps {
children: ReactNode
Expand All @@ -9,6 +10,7 @@ export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
<html>
<body>
<main>{children}</main>
<TuonoScripts />
</body>
</html>
)
Expand Down
76 changes: 38 additions & 38 deletions examples/tuono-app/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,47 @@ import type { JSX } from 'react'
import type { TuonoProps } from 'tuono'

interface IndexProps {
subtitle: string
subtitle: string
}

export default function IndexPage({
data,
isLoading,
data,
isLoading,
}: TuonoProps<IndexProps>): JSX.Element {
if (isLoading) {
return <h1>Loading...</h1>
}
if (isLoading) {
return <h1>Loading...</h1>
}

return (
<>
<header className="header">
<a href="https://crates.io/crates/tuono" target="_blank">
Crates
</a>
<a href="https://www.npmjs.com/package/tuono" target="_blank">
Npm
</a>
</header>
<div className="title-wrap">
<h1 className="title">
TU<span>O</span>NO
</h1>
<div className="logo">
<img src="rust.svg" className="rust" />
<img src="react.svg" className="react" />
</div>
</div>
<div className="subtitle-wrap">
<p className="subtitle">{data?.subtitle}</p>
<a
href="https://github.com/tuono-labs/tuono"
target="_blank"
className="button"
type="button"
>
Github
</a>
</div>
</>
)
return (
<>
<header className="header">
<a href="https://crates.io/crates/tuono" target="_blank">
Crates
</a>
<a href="https://www.npmjs.com/package/tuono" target="_blank">
Npm
</a>
</header>
<div className="title-wrap">
<h1 className="title">
TU<span>O</span>NO
</h1>
<div className="logo">
<img src="rust.svg" className="rust" />
<img src="react.svg" className="react" />
</div>
</div>
<div className="subtitle-wrap">
<p className="subtitle">{data?.subtitle}</p>
<a
href="https://github.com/tuono-labs/tuono"
target="_blank"
className="button"
type="button"
>
Github
</a>
</div>
</>
)
}
2 changes: 2 additions & 0 deletions examples/tuono-tutorial/src/routes/__layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode, JSX } from 'react'
import { TuonoScripts } from 'tuono'

interface RootLayoutProps {
children: ReactNode
Expand All @@ -9,6 +10,7 @@ export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
<html>
<body>
<main>{children}</main>
<TuonoScripts />
</body>
</html>
)
Expand Down
2 changes: 2 additions & 0 deletions examples/with-mdx/src/routes/__layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactNode, JSX } from 'react'
import { MDXProvider } from '@mdx-js/react'
import { TuonoScripts } from 'tuono'

interface RootLayoutProps {
children: ReactNode
Expand All @@ -12,6 +13,7 @@ export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
<main>
<MDXProvider components={{}}>{children}</MDXProvider>
</main>
<TuonoScripts />
</body>
</html>
)
Expand Down
2 changes: 2 additions & 0 deletions examples/with-tailwind/src/routes/__layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode, JSX } from 'react'
import { TuonoScripts } from 'tuono'

interface RootLayoutProps {
children: ReactNode
Expand All @@ -12,6 +13,7 @@ export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
</head>
<body>
<main>{children}</main>
<TuonoScripts />
</body>
</html>
)
Expand Down
15 changes: 10 additions & 5 deletions packages/tuono-router/src/components/RouterContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +17,7 @@ export interface ParsedLocation {
interface RouterContextValue {
router: Router
location: ParsedLocation
serverSideProps?: ServerProps
updateLocation: (loc: ParsedLocation) => void
}

Expand Down Expand Up @@ -48,7 +51,7 @@ function getInitialLocation(
interface RouterContextProviderProps {
router: Router
children: ReactNode
serverSideProps?: ServerRouterInfo
serverSideProps?: ServerProps
}

export function RouterContextProvider({
Expand All @@ -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),
)

/**
Expand Down Expand Up @@ -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 (
Expand All @@ -105,7 +111,6 @@ export function RouterContextProvider({
)
}

/** @warning DO NOT EXPORT THIS TO USER LAND */
export function useRouterContext(): RouterContextValue {
return useContext(RouterContext)
}
5 changes: 1 addition & 4 deletions packages/tuono-router/src/components/RouterProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)
Expand Down
5 changes: 2 additions & 3 deletions packages/tuono-router/src/globals.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/tuono-router/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
3 changes: 3 additions & 0 deletions packages/tuono-router/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
6 changes: 3 additions & 3 deletions packages/tuono/src/hydration/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,8 +11,8 @@ export function hydrate(routeTree: RouteTree): void {

hydrateRoot(
document,
<React.StrictMode>
<StrictMode>
Valerioageno marked this conversation as resolved.
Show resolved Hide resolved
<RouterProvider router={router} />
</React.StrictMode>,
</StrictMode>,
)
}
8 changes: 6 additions & 2 deletions packages/tuono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand All @@ -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>
</>
)
24 changes: 24 additions & 0 deletions packages/tuono/src/shared/ProdResources.tsx
Original file line number Diff line number Diff line change
@@ -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>
))}
</>
)
}
18 changes: 18 additions & 0 deletions packages/tuono/src/shared/TuonoScripts.tsx
Original file line number Diff line number Diff line change
@@ -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 />}
</>
)
}
24 changes: 24 additions & 0 deletions packages/tuono/src/shared/dynamic/RouteLazyLoading.tsx
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading