implement ssr
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
|
||||
import Home from "./pages/home";
|
||||
import LogIn from "./pages/login";
|
||||
import Post from "./pages/post";
|
||||
import Editor from "./pages/editor";
|
||||
|
||||
/** The route tree, shared by the browser and server entry points. */
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<LogIn />} />
|
||||
<Route path="/post/:slug" element={<Post />} />
|
||||
<Route path="/editor" element={<Editor />} />
|
||||
<Route path="/editor/:slug" element={<Editor />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useHydrated } from "../ssr/hydrated";
|
||||
|
||||
// The server render and the hydration pass use a fixed locale and timezone so
|
||||
// both produce identical markup. Once hydrated we switch to the visitor's own
|
||||
// locale and timezone, which is what the page showed before it was
|
||||
// server-rendered.
|
||||
const stableFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "UTC",
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
});
|
||||
|
||||
export default function FormattedDate({ value }: { value: string }) {
|
||||
const hydrated = useHydrated();
|
||||
const date = new Date(value);
|
||||
|
||||
return (
|
||||
<time dateTime={date.toISOString()}>
|
||||
{hydrated ? date.toLocaleString() : stableFormatter.format(date)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Post } from "../utils/types";
|
||||
import FormattedDate from "./formattedDate";
|
||||
|
||||
export default function PostRow({ post }: { post: Post }) {
|
||||
return (
|
||||
@@ -8,7 +9,7 @@ export default function PostRow({ post }: { post: Post }) {
|
||||
<h1>{post.title}</h1>
|
||||
<p>{post.description}</p>
|
||||
<p className="text-xs opacity-50">
|
||||
Posted on {new Date(post.createdAt).toLocaleString()}
|
||||
Posted on <FormattedDate value={post.createdAt} />
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createRoot, hydrateRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { AuthProvider } from "./providers/AuthProvider";
|
||||
import { SsrDataProvider } from "./ssr/context";
|
||||
import type { SsrData } from "./ssr/types";
|
||||
import App from "./app";
|
||||
import "./assets/index.css";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SSR_DATA__?: SsrData;
|
||||
}
|
||||
}
|
||||
|
||||
const ssrData = window.__SSR_DATA__ ?? null;
|
||||
delete window.__SSR_DATA__;
|
||||
|
||||
const tree = (
|
||||
<SsrDataProvider data={ssrData}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</SsrDataProvider>
|
||||
);
|
||||
|
||||
const container = document.getElementById("root")!;
|
||||
|
||||
// Server-rendered pages ship markup to hydrate; every other route is still a
|
||||
// plain client-side render into an empty root.
|
||||
if (ssrData) {
|
||||
hydrateRoot(container, tree);
|
||||
} else {
|
||||
createRoot(container).render(tree);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { StaticRouter } from "react-router-dom";
|
||||
import { AuthProvider } from "./providers/AuthProvider";
|
||||
import { SsrDataProvider } from "./ssr/context";
|
||||
import { matchSsrRoute, type ApiFetch } from "./ssr/routes";
|
||||
import type { SsrData, SsrHead } from "./ssr/types";
|
||||
import App from "./app";
|
||||
|
||||
type RenderResult =
|
||||
/** Not a server-rendered route - serve the client-only shell. */
|
||||
| { kind: "spa" }
|
||||
/** Server-rendered route whose content is missing - shell, with a 404. */
|
||||
| { kind: "not-found" }
|
||||
| { kind: "ssr"; html: string; data: SsrData; head: SsrHead };
|
||||
|
||||
async function render(url: string, api: ApiFetch): Promise<RenderResult> {
|
||||
const pathname = new URL(url, "http://ssr.invalid").pathname;
|
||||
const matched = matchSsrRoute(pathname);
|
||||
if (!matched) return { kind: "spa" };
|
||||
|
||||
const loaded = await matched.route.load(api, matched.params);
|
||||
if (!loaded) return { kind: "not-found" };
|
||||
|
||||
// The visitor's session is deliberately not resolved here: auth-dependent
|
||||
// chrome renders logged-out on the server and AuthProvider fills it in after
|
||||
// hydration, which keeps these pages cacheable and hydration-safe.
|
||||
const html = renderToString(
|
||||
<SsrDataProvider data={loaded.data}>
|
||||
<AuthProvider>
|
||||
<StaticRouter location={url}>
|
||||
<App />
|
||||
</StaticRouter>
|
||||
</AuthProvider>
|
||||
</SsrDataProvider>,
|
||||
);
|
||||
|
||||
return { kind: "ssr", html, data: loaded.data, head: loaded.head };
|
||||
}
|
||||
|
||||
export { render };
|
||||
export { defaultHead } from "./ssr/routes";
|
||||
export type { RenderResult };
|
||||
@@ -1,38 +0,0 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import { AuthProvider } from "./providers/AuthProvider";
|
||||
import "./assets/index.css";
|
||||
|
||||
import Home from "./pages/home";
|
||||
import LogIn from "./pages/login";
|
||||
import Post from "./pages/post";
|
||||
import Editor from "./pages/editor";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
element: <LogIn />,
|
||||
},
|
||||
{
|
||||
path: "/post/:slug",
|
||||
element: <Post />,
|
||||
},
|
||||
{
|
||||
path: "/editor",
|
||||
element: <Editor />,
|
||||
},
|
||||
{
|
||||
path: "/editor/:slug",
|
||||
element: <Editor />,
|
||||
},
|
||||
]);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>,
|
||||
);
|
||||
@@ -3,15 +3,21 @@ import PostRow from "../components/postRow";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../providers/AuthProvider";
|
||||
import { useSsrData } from "../ssr/context";
|
||||
import type { Post } from "../utils/types";
|
||||
|
||||
export default function Home() {
|
||||
const [posts, setPosts] = useState<Post[] | null>(null);
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const ssr = useSsrData("home");
|
||||
const [seededByServer] = useState<boolean>(() => ssr !== null);
|
||||
const [posts, setPosts] = useState<Post[] | null>(ssr?.posts ?? null);
|
||||
const [loaded, setLoaded] = useState<boolean>(seededByServer);
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Already rendered on the server with the post list baked in.
|
||||
if (seededByServer) return;
|
||||
|
||||
fetch("/api/posts", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
@@ -25,7 +31,7 @@ export default function Home() {
|
||||
setLoaded(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}, [seededByServer]);
|
||||
|
||||
const PostList = () => {
|
||||
const mappedPosts = posts?.map((v, k) => {
|
||||
|
||||
@@ -2,16 +2,23 @@ import Layout from "../components/layout";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../providers/AuthProvider";
|
||||
import { useSsrData } from "../ssr/context";
|
||||
import FormattedDate from "../components/formattedDate";
|
||||
import type { Post } from "../utils/types";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export default function Post() {
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const ssr = useSsrData("post");
|
||||
const [seededByServer] = useState<boolean>(() => ssr !== null);
|
||||
const [post, setPost] = useState<Post | null>(ssr?.post ?? null);
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// Already rendered on the server with the post baked in.
|
||||
if (seededByServer) return;
|
||||
|
||||
if (params.slug) {
|
||||
fetch("/api/posts/" + params.slug, {
|
||||
method: "GET",
|
||||
@@ -25,7 +32,7 @@ export default function Post() {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [seededByServer, params.slug]);
|
||||
|
||||
function deletePost() {
|
||||
if (!auth.user || !post) return;
|
||||
@@ -63,9 +70,12 @@ export default function Post() {
|
||||
</div>
|
||||
|
||||
<p className="text-sm opacity-50">
|
||||
Posted {new Date(post.createdAt).toLocaleString()}{" "}
|
||||
{post.updatedAt !== post.createdAt &&
|
||||
"- updated on " + new Date(post.updatedAt).toLocaleString()}
|
||||
Posted <FormattedDate value={post.createdAt} />{" "}
|
||||
{post.updatedAt !== post.createdAt && (
|
||||
<>
|
||||
- updated on <FormattedDate value={post.updatedAt} />
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{auth.user && (
|
||||
<div className="inline-block space-x-2">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useHydrated } from "./hydrated";
|
||||
import type { SsrData } from "./types";
|
||||
|
||||
const SsrDataContext = createContext<SsrData | null>(null);
|
||||
|
||||
export function SsrDataProvider({
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
data: SsrData | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// The prefetched payload seeds the server render and the hydration pass that
|
||||
// has to match it, then goes away: from there on, client-side navigation
|
||||
// fetches fresh data rather than replaying a payload from page load.
|
||||
const hydrated = useHydrated();
|
||||
|
||||
return (
|
||||
<SsrDataContext value={hydrated ? null : data}>{children}</SsrDataContext>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the server-prefetched data for a page, or null when the page wasn't
|
||||
* server-rendered (or is being mounted by a client-side navigation).
|
||||
*/
|
||||
export function useSsrData<T extends SsrData["route"]>(
|
||||
route: T,
|
||||
): Extract<SsrData, { route: T }> | null {
|
||||
const data = useContext(SsrDataContext);
|
||||
if (data?.route !== route) return null;
|
||||
return data as Extract<SsrData, { route: T }>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const subscribe = () => () => {};
|
||||
const onClient = () => true;
|
||||
const onServer = () => false;
|
||||
|
||||
/**
|
||||
* False while rendering on the server and during the hydration pass, true
|
||||
* afterwards. Lets a component render server-safe markup first and switch to
|
||||
* browser-only output once hydration has matched.
|
||||
*/
|
||||
export function useHydrated() {
|
||||
return useSyncExternalStore(subscribe, onClient, onServer);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { matchPath } from "react-router-dom";
|
||||
import type { Post } from "../utils/types";
|
||||
import type { SsrData, SsrHead } from "./types";
|
||||
|
||||
/** Fetches a path against the backend API, carrying the visitor's cookies. */
|
||||
type ApiFetch = (path: string) => Promise<Response>;
|
||||
|
||||
type SsrRoute = {
|
||||
path: string;
|
||||
load: (
|
||||
api: ApiFetch,
|
||||
params: Record<string, string | undefined>,
|
||||
) => Promise<{ data: SsrData; head: SsrHead } | null>;
|
||||
};
|
||||
|
||||
const SITE_NAME = "Alex / dev + QA";
|
||||
const SITE_DESCRIPTION =
|
||||
"random thoughts, sometimes LinkedIn slop, sometimes just about my interests.";
|
||||
|
||||
const defaultHead: SsrHead = {
|
||||
title: SITE_NAME,
|
||||
description: SITE_DESCRIPTION,
|
||||
};
|
||||
|
||||
/**
|
||||
* The only routes that get server-rendered. Anything not listed here is served
|
||||
* as the plain client-only SPA shell, so /login and /editor keep behaving
|
||||
* exactly as they did before.
|
||||
*
|
||||
* A loader returning null means "this page has no content" and the server
|
||||
* answers 404 with the shell.
|
||||
*/
|
||||
const ssrRoutes: SsrRoute[] = [
|
||||
{
|
||||
path: "/",
|
||||
load: async (api) => {
|
||||
const res = await api("/posts");
|
||||
if (!res.ok) return null;
|
||||
|
||||
const posts: Post[] = await res.json();
|
||||
return {
|
||||
data: { route: "home", posts },
|
||||
head: defaultHead,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/post/:slug",
|
||||
load: async (api, params) => {
|
||||
if (!params.slug) return null;
|
||||
|
||||
const res = await api("/posts/" + encodeURIComponent(params.slug));
|
||||
if (!res.ok) return null;
|
||||
|
||||
const post: Post = await res.json();
|
||||
return {
|
||||
data: { route: "post", post },
|
||||
head: {
|
||||
title: `${post.title} - ${SITE_NAME}`,
|
||||
description: post.description,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function matchSsrRoute(pathname: string) {
|
||||
for (const route of ssrRoutes) {
|
||||
const match = matchPath(route.path, pathname);
|
||||
if (match) return { route, params: match.params };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export { matchSsrRoute, defaultHead };
|
||||
export type { ApiFetch };
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Post } from "../utils/types";
|
||||
|
||||
/**
|
||||
* Data the server prefetched for a server-rendered page. It is embedded in the
|
||||
* HTML response so hydration starts from exactly what was rendered instead of
|
||||
* re-fetching on mount.
|
||||
*/
|
||||
type SsrData =
|
||||
| { route: "home"; posts: Post[] }
|
||||
| { route: "post"; post: Post };
|
||||
|
||||
/** Page-specific <head> content. Server-side only, never sent to the client. */
|
||||
type SsrHead = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type { SsrData, SsrHead };
|
||||
Reference in New Issue
Block a user