From 33aa37ed7d5846f2ef441b504a5fd6e54ef90e23 Mon Sep 17 00:00:00 2001 From: Valerio Ageno <51341197+Valerioageno@users.noreply.github.com> Date: Sun, 19 Jan 2025 16:23:30 +0100 Subject: [PATCH] fix: create a component tree that matches between client and server to avoid `useId` output mismatch (#371) Co-authored-by: Marco Pasqualetti --- examples/tuono-app/src/routes/__layout.tsx | 2 + examples/tuono-app/src/routes/index.tsx | 76 ++++++++-------- .../tuono-tutorial/src/routes/__layout.tsx | 2 + examples/with-mdx/src/routes/__layout.tsx | 2 + .../with-tailwind/src/routes/__layout.tsx | 2 + .../src/components/RouterContext.tsx | 15 ++-- .../src/components/RouterProvider.tsx | 5 +- packages/tuono-router/src/globals.ts | 5 +- packages/tuono-router/src/index.ts | 1 + packages/tuono-router/src/types.ts | 3 + packages/tuono/src/hydration/index.tsx | 6 +- packages/tuono/src/index.ts | 8 +- .../components => shared}/DevResources.tsx | 14 ++- packages/tuono/src/shared/ProdResources.tsx | 24 +++++ packages/tuono/src/shared/TuonoScripts.tsx | 18 ++++ .../src/shared/dynamic/RouteLazyLoading.tsx | 24 +++++ packages/tuono/src/shared/dynamic/dynamic.tsx | 88 +++++++++++++++++++ packages/tuono/src/shared/dynamic/index.ts | 2 + .../src/ssr/components/ProdResources.tsx | 26 ------ packages/tuono/src/ssr/server.tsx | 18 +--- 20 files changed, 242 insertions(+), 99 deletions(-) rename packages/tuono/src/{ssr/components => shared}/DevResources.tsx (70%) create mode 100644 packages/tuono/src/shared/ProdResources.tsx create mode 100644 packages/tuono/src/shared/TuonoScripts.tsx create mode 100644 packages/tuono/src/shared/dynamic/RouteLazyLoading.tsx create mode 100644 packages/tuono/src/shared/dynamic/dynamic.tsx create mode 100644 packages/tuono/src/shared/dynamic/index.ts delete mode 100644 packages/tuono/src/ssr/components/ProdResources.tsx diff --git a/examples/tuono-app/src/routes/__layout.tsx b/examples/tuono-app/src/routes/__layout.tsx index 660ce461..8ef43630 100644 --- a/examples/tuono-app/src/routes/__layout.tsx +++ b/examples/tuono-app/src/routes/__layout.tsx @@ -1,4 +1,5 @@ import type { ReactNode, JSX } from 'react' +import { TuonoScripts } from 'tuono' interface RootLayoutProps { children: ReactNode @@ -9,6 +10,7 @@ export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
{children}
+ ) diff --git a/examples/tuono-app/src/routes/index.tsx b/examples/tuono-app/src/routes/index.tsx index 719f8ea6..13d1439b 100644 --- a/examples/tuono-app/src/routes/index.tsx +++ b/examples/tuono-app/src/routes/index.tsx @@ -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): JSX.Element { - if (isLoading) { - return

Loading...

- } + if (isLoading) { + return

Loading...

+ } - return ( - <> -
- - Crates - - - Npm - -
-
-

- TUONO -

-
- - -
-
-
-

{data?.subtitle}

- - Github - -
- - ) + return ( + <> +
+ + Crates + + + Npm + +
+
+

+ TUONO +

+
+ + +
+
+
+

{data?.subtitle}

+ + Github + +
+ + ) } diff --git a/examples/tuono-tutorial/src/routes/__layout.tsx b/examples/tuono-tutorial/src/routes/__layout.tsx index 660ce461..8ef43630 100644 --- a/examples/tuono-tutorial/src/routes/__layout.tsx +++ b/examples/tuono-tutorial/src/routes/__layout.tsx @@ -1,4 +1,5 @@ import type { ReactNode, JSX } from 'react' +import { TuonoScripts } from 'tuono' interface RootLayoutProps { children: ReactNode @@ -9,6 +10,7 @@ export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
{children}
+ ) diff --git a/examples/with-mdx/src/routes/__layout.tsx b/examples/with-mdx/src/routes/__layout.tsx index e25fcb2b..f4bf5b50 100644 --- a/examples/with-mdx/src/routes/__layout.tsx +++ b/examples/with-mdx/src/routes/__layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode, JSX } from 'react' import { MDXProvider } from '@mdx-js/react' +import { TuonoScripts } from 'tuono' interface RootLayoutProps { children: ReactNode @@ -12,6 +13,7 @@ export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
{children}
+ ) diff --git a/examples/with-tailwind/src/routes/__layout.tsx b/examples/with-tailwind/src/routes/__layout.tsx index c8d467b2..cdb20498 100644 --- a/examples/with-tailwind/src/routes/__layout.tsx +++ b/examples/with-tailwind/src/routes/__layout.tsx @@ -1,4 +1,5 @@ import type { ReactNode, JSX } from 'react' +import { TuonoScripts } from 'tuono' interface RootLayoutProps { children: ReactNode @@ -12,6 +13,7 @@ export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
{children}
+ ) diff --git a/packages/tuono-router/src/components/RouterContext.tsx b/packages/tuono-router/src/components/RouterContext.tsx index abdcffcf..b4bdfaf4 100644 --- a/packages/tuono-router/src/components/RouterContext.tsx +++ b/packages/tuono-router/src/components/RouterContext.tsx @@ -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[0]) const [location, setLocation] = useState(() => - 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) } diff --git a/packages/tuono-router/src/components/RouterProvider.tsx b/packages/tuono-router/src/components/RouterProvider.tsx index eb9fa46f..5ae11d65 100644 --- a/packages/tuono-router/src/components/RouterProvider.tsx +++ b/packages/tuono-router/src/components/RouterProvider.tsx @@ -19,10 +19,7 @@ export function RouterProvider({ serverProps, }: RouterProviderProps): JSX.Element { return ( - + ) diff --git a/packages/tuono-router/src/globals.ts b/packages/tuono-router/src/globals.ts index faf284fa..cb66ce24 100644 --- a/packages/tuono-router/src/globals.ts +++ b/packages/tuono-router/src/globals.ts @@ -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 } } diff --git a/packages/tuono-router/src/index.ts b/packages/tuono-router/src/index.ts index 8a76e565..c152b7e7 100644 --- a/packages/tuono-router/src/index.ts +++ b/packages/tuono-router/src/index.ts @@ -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' diff --git a/packages/tuono-router/src/types.ts b/packages/tuono-router/src/types.ts index 40fc5c63..ad1c5667 100644 --- a/packages/tuono-router/src/types.ts +++ b/packages/tuono-router/src/types.ts @@ -14,6 +14,9 @@ export interface ServerRouterInfo { export interface ServerProps { router: ServerRouterInfo props: TProps + jsBundles: Array + cssBundles: Array + mode: 'Dev' | 'Prod' } export interface RouteProps { diff --git a/packages/tuono/src/hydration/index.tsx b/packages/tuono/src/hydration/index.tsx index ba1fa8a3..d13e6482 100644 --- a/packages/tuono/src/hydration/index.tsx +++ b/packages/tuono/src/hydration/index.tsx @@ -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, - + - , + , ) } diff --git a/packages/tuono/src/index.ts b/packages/tuono/src/index.ts index 93525d99..905c6a47 100644 --- a/packages/tuono/src/index.ts +++ b/packages/tuono/src/index.ts @@ -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' diff --git a/packages/tuono/src/ssr/components/DevResources.tsx b/packages/tuono/src/shared/DevResources.tsx similarity index 70% rename from packages/tuono/src/ssr/components/DevResources.tsx rename to packages/tuono/src/shared/DevResources.tsx index c5a7f485..327cd65f 100644 --- a/packages/tuono/src/ssr/components/DevResources.tsx +++ b/packages/tuono/src/shared/DevResources.tsx @@ -6,7 +6,7 @@ const SCRIPT_BASE_URL = `http://localhost:${TUONO_DEV_SERVER_PORT}${VITE_PROXY_P export const DevResources = (): JSX.Element => ( <> - - - + + ) diff --git a/packages/tuono/src/shared/ProdResources.tsx b/packages/tuono/src/shared/ProdResources.tsx new file mode 100644 index 00000000..11328f7c --- /dev/null +++ b/packages/tuono/src/shared/ProdResources.tsx @@ -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) => ( + + ))} + + {serverSideProps?.jsBundles.map((scriptSrc) => ( + + ))} + + ) +} diff --git a/packages/tuono/src/shared/TuonoScripts.tsx b/packages/tuono/src/shared/TuonoScripts.tsx new file mode 100644 index 00000000..e5109e55 --- /dev/null +++ b/packages/tuono/src/shared/TuonoScripts.tsx @@ -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 ( + <> + + {serverSideProps?.mode === 'Dev' && } + {serverSideProps?.mode === 'Prod' && } + + ) +} diff --git a/packages/tuono/src/shared/dynamic/RouteLazyLoading.tsx b/packages/tuono/src/shared/dynamic/RouteLazyLoading.tsx new file mode 100644 index 00000000..3fb82a60 --- /dev/null +++ b/packages/tuono/src/shared/dynamic/RouteLazyLoading.tsx @@ -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(factory) + + const loadComponent = (): Promise => + factory().then((module) => { + LoadedComponent = module.default + }) + + const Component = ( + props: React.ComponentProps, + ): ReactElement => createElement(LoadedComponent || LazyComponent, props) + + Component.preload = loadComponent + + return Component +} diff --git a/packages/tuono/src/shared/dynamic/dynamic.tsx b/packages/tuono/src/shared/dynamic/dynamic.tsx new file mode 100644 index 00000000..022f0b49 --- /dev/null +++ b/packages/tuono/src/shared/dynamic/dynamic.tsx @@ -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 { + default: React.ComponentType +} + +interface DynamicOptions { + ssr?: boolean + loading?: React.ComponentType | null +} + +type Loader = () => Promise< + React.ComponentType | ComponentModule +> + +interface LoadableOptions extends DynamicOptions { + loader: Loader +} + +type LoadableFn = (options: LoadableOptions) => ComponentType + +const defaultLoaderOptions: LoadableOptions = { + ssr: true, + loading: null, + loader: () => Promise.resolve(() => null), +} + +function noSSR( + LoadableInitializer: LoadableFn, + loadableOptions: LoadableOptions, +): React.ComponentType { + 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 + } + return NoSSRLoading +} + +function Loadable(options: LoadableOptions): ComponentType { + const opts = { ...defaultLoaderOptions, ...options } + const Lazy = lazy(() => opts.loader().then()) + const Loading = opts.loading + + function LoadableComponent(props: T): React.JSX.Element { + const fallbackElement = Loading ? : null + + const Wrap = Loading ? Suspense : Fragment + const wrapProps = Loading ? { fallback: fallbackElement } : {} + + // TODO: In case ssr = false handle also the assets preloading + return ( + + + + ) + } + 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( + importFn: Loader, + opts?: DynamicOptions, +): ComponentType { + if (typeof opts?.ssr === 'boolean' && !opts.ssr) { + return noSSR(Loadable, { ...opts, loader: importFn }) + } + return Loadable({ ...opts, loader: importFn }) +} diff --git a/packages/tuono/src/shared/dynamic/index.ts b/packages/tuono/src/shared/dynamic/index.ts new file mode 100644 index 00000000..2d008941 --- /dev/null +++ b/packages/tuono/src/shared/dynamic/index.ts @@ -0,0 +1,2 @@ +export { dynamic } from './dynamic' +export { RouteLazyLoading } from './RouteLazyLoading' diff --git a/packages/tuono/src/ssr/components/ProdResources.tsx b/packages/tuono/src/ssr/components/ProdResources.tsx deleted file mode 100644 index fdb788b8..00000000 --- a/packages/tuono/src/ssr/components/ProdResources.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { JSX } from 'react' - -interface ProdResourcesProps { - cssBundles: Array - jsBundles: Array -} - -export const ProdResources = ({ - cssBundles, - jsBundles, -}: ProdResourcesProps): JSX.Element => ( - <> - {cssBundles.map((cssHref) => ( - - ))} - - {jsBundles.map((scriptSrc) => ( - - ))} - -) diff --git a/packages/tuono/src/ssr/server.tsx b/packages/tuono/src/ssr/server.tsx index 253ed004..fb087d05 100644 --- a/packages/tuono/src/ssr/server.tsx +++ b/packages/tuono/src/ssr/server.tsx @@ -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 @@ -58,22 +56,12 @@ export function serverSideRendering(routeTree: RouteTree) { unknown > - const mode = serverProps.mode as Mode - const jsBundles = serverProps.jsBundles as Array - const cssBundles = serverProps.cssBundles as Array const router = createRouter({ routeTree }) // Render the app const stream = await renderToReadableStream( - <> + - - {mode === 'Dev' && } - {mode === 'Prod' && ( - - )} - - - , + , ) await stream.allReady