Repository navigation
ssr wip #6173
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
base: main
Are you sure you want to change the base?
ssr wip #6173
Changes from all commits
2272cc0
f120b60
0606e2b
32bd4a7
1158c3c
0287832
263d3eb
fe877a6
9369532
2feca15
0274b8e
d3d9c74
fd3aa17
5128cfe
9ff5773
451c79d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add an experimental, default-off `runtime_ssr` config option that renders pages on the server, with the state their `on_load` handlers produce, for search engine crawlers, while browsers keep receiving the static app shell. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add the experimental, default-off `runtime_ssr` config option and the frontend templates that serve server-rendered pages to search engine crawlers. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| /** | ||
| * Post-build script: generates a static SPA shell (build/client/index.html). | ||
| * | ||
| * With ssr:true, `react-router build` does not emit index.html because all | ||
| * HTML is rendered at request time. The production server (ssr-serve.js) | ||
| * serves this pre-built shell to regular users for instant load with zero | ||
| * SSR overhead; only bots go through the SSR path. | ||
| * | ||
| * The X-Reflex-Shell-Gen header tells the root loader to short-circuit and | ||
| * return { state: null } without contacting the Python backend. | ||
| */ | ||
| import { createRequestHandler } from "react-router"; | ||
| import { writeFileSync } from "node:fs"; | ||
| import { dirname, join } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| // Resolve paths relative to this file, not process.cwd(). | ||
| const __dirname = | ||
| import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); | ||
|
|
||
| const build = await import(join(__dirname, "build", "server", "index.js")); | ||
| const handler = createRequestHandler(build, "production"); | ||
|
|
||
| const request = new Request("http://localhost/", { | ||
| headers: { | ||
| "User-Agent": "Mozilla/5.0 Chrome/120 (Shell Generator)", | ||
| "X-Reflex-Shell-Gen": "1", | ||
| }, | ||
| }); | ||
|
|
||
| const response = await handler(request); | ||
| const html = await response.text(); | ||
|
|
||
| const outPath = join(__dirname, "build", "client", "index.html"); | ||
| writeFileSync(outPath, html); | ||
| console.log("Generated build/client/index.html"); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| /** | ||
| * Bot-aware SSR production server for Reflex apps. | ||
| * | ||
| * - Crawlers/bots receive fully server-side rendered HTML (SEO). | ||
| * - Regular users receive the static SPA shell (fast, zero SSR overhead). | ||
| * | ||
| * Used when `runtime_ssr=True` is set in the Reflex config. | ||
| */ | ||
| import { createRequestHandler } from "@react-router/express"; | ||
| import express from "express"; | ||
| import compression from "compression"; | ||
| import { isbot } from "isbot"; | ||
| import { existsSync, readFileSync } from "node:fs"; | ||
| import { dirname, join } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| // Resolve all paths relative to *this file*, not process.cwd(). | ||
| const __dirname = | ||
| import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); | ||
|
|
||
| const clientDir = join(__dirname, "build", "client"); | ||
| const serverEntry = join(__dirname, "build", "server", "index.js"); | ||
|
|
||
| const buildModule = await import(serverEntry); | ||
|
|
||
| const app = express(); | ||
| app.disable("x-powered-by"); | ||
| app.use(compression()); | ||
|
|
||
| // Static assets with content-hash filenames — cache immutably. | ||
| app.use( | ||
| "/assets", | ||
| express.static(join(clientDir, "assets"), { immutable: true, maxAge: "1y" }), | ||
| ); | ||
|
|
||
| // Other static files (favicon, sitemap, etc.) — short cache. | ||
| app.use(express.static(clientDir, { maxAge: "1h" })); | ||
|
|
||
| // SSR request handler (React Router). | ||
| const ssrHandler = createRequestHandler({ build: buildModule }); | ||
|
|
||
| // Read the static SPA shell into memory at startup (generated by generate-shell.mjs). | ||
| // Serving from memory avoids per-request filesystem access. | ||
| const shellPath = join(clientDir, "index.html"); | ||
| const shellHtml = existsSync(shellPath) | ||
| ? readFileSync(shellPath, "utf-8") | ||
| : null; | ||
| if (!shellHtml) { | ||
| console.warn( | ||
| "[ssr-serve] build/client/index.html not found — all requests will use SSR.", | ||
| ); | ||
| } | ||
|
|
||
| app.all("*", (req, res, next) => { | ||
| const ua = req.headers["user-agent"] || ""; | ||
|
|
||
| // Bots always get full server-side rendered HTML with state data. | ||
| // Also used as fallback when the static shell is unavailable. | ||
| if (isbot(ua) || !shellHtml) { | ||
| return ssrHandler(req, res, next); | ||
| } | ||
|
|
||
| // For regular users: only serve the SPA shell for initial document requests | ||
| // (browser navigating to a URL). React Router's .data requests (used for | ||
| // client-side navigations) and other non-document fetches must go through | ||
| // the SSR handler so the root loader can run and return JSON state data. | ||
| const accept = req.headers["accept"] || ""; | ||
| if (accept.includes("text/html") && !req.url.endsWith(".data")) { | ||
| return res | ||
| .setHeader("Content-Type", "text/html; charset=utf-8") | ||
| .send(shellHtml); | ||
|
Comment on lines
+68
to
+71
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
|
|
||
| // .data requests, API calls, etc. → SSR handler (runs loaders, returns JSON). | ||
| return ssrHandler(req, res, next); | ||
| }); | ||
|
|
||
|
|
||
| const requestedPort = parseInt(process.env.PORT || "3000", 10); | ||
| const server = app.listen(requestedPort, () => { | ||
| // Emit the actual port (important when PORT=0 for auto-assignment). | ||
| // Message format matches Reflex's PROD_FRONTEND_LISTENING_REGEX. | ||
| const actualPort = server.address().port; | ||
| console.log(`[ssr-serve] http://localhost:${actualPort}`); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -880,6 +880,7 @@ const handleStateMismatch = (info) => { | |
| * @param client_storage The client storage object from context.js | ||
| * @param navigate The navigate function from React Router | ||
| * @param params The params object from React Router | ||
| * @param ssrHydrated Whether the page already holds state rendered by the backend | ||
| */ | ||
| export const connect = async ( | ||
| socket, | ||
|
|
@@ -889,6 +890,7 @@ export const connect = async ( | |
| client_storage = {}, | ||
| navigate, | ||
| params, | ||
| ssrHydrated = false, | ||
| ) => { | ||
| // Socket already allocated, just reconnect it if needed. | ||
| if (socket.current) { | ||
|
|
@@ -908,7 +910,14 @@ export const connect = async ( | |
| // the state schema goes along on every (re)connect, since a reconnect can | ||
| // land on another backend version. | ||
| const bootAuth = (first) => { | ||
| const boot_event = withRouterData(app.initialEvents(first)[0], params); | ||
| // A server-rendered page already ran its on_load handlers and holds no | ||
| // compiled defaults to diff against, so its first connect only hydrates. | ||
| const boot_event = withRouterData( | ||
| first && ssrHydrated | ||
| ? ReflexEvent(`${app.state_name}.hydrate`) | ||
| : app.initialEvents(first)[0], | ||
|
Comment on lines
+915
to
+918
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For browsers receiving SSR HTML, this branch sends Keep the browser's backend session in sync before skipping |
||
| params, | ||
| ); | ||
| recordSentStorageValues(boot_event); | ||
| // Instrumentation hook (installed by reflex-otel): may add a traceparent. | ||
| window.__reflex_otel?.onEventSend?.(boot_event); | ||
|
|
@@ -1364,6 +1373,7 @@ const applyClientStorageDelta = (client_storage, delta) => { | |
| * @param dispatch The reducer dispatch function to update state. | ||
| * @param initial_events The initial app events. | ||
| * @param client_storage The client storage object from context.js | ||
| * @param ssrHydrated Whether the page already holds state rendered by the backend | ||
| * | ||
| * @returns [addEvents, connectErrors] - | ||
| * addEvents is used to queue an event, and | ||
|
|
@@ -1373,6 +1383,7 @@ export const useEventLoop = ( | |
| dispatch, | ||
| initial_events = () => [], | ||
| client_storage = {}, | ||
| ssrHydrated = false, | ||
| ) => { | ||
| const socket = useRef(null); | ||
| const location = useLocation(); | ||
|
|
@@ -1418,6 +1429,7 @@ export const useEventLoop = ( | |
| client_storage, | ||
| navigate, | ||
| params, | ||
| ssrHydrated, | ||
| ); | ||
| } | ||
| }, [ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -174,6 +174,7 @@ def app_root_template( | |
| dynamic_imports: set[str], | ||
| hydrate_fallback_export: str | None = None, | ||
| lazy_window_libraries: list[tuple[str, str]] | None = None, | ||
| runtime_ssr: bool = False, | ||
| ): | ||
| """Template for the App root. | ||
|
|
||
|
|
@@ -186,6 +187,7 @@ def app_root_template( | |
| dynamic_imports: The set of dynamic imports. | ||
| hydrate_fallback_export: The exported name of the hydrate-fallback memo module to re-export as ``HydrateFallback``, or None for no fallback. | ||
| lazy_window_libraries: Optional libraries loaded before evaluating a dynamic component. | ||
| runtime_ssr: Whether runtime SSR is enabled. | ||
|
|
||
| Returns: | ||
| Rendered App root component as string. | ||
|
|
@@ -249,17 +251,60 @@ def app_root_template( | |
| }} | ||
| """ | ||
|
|
||
| if runtime_ssr: | ||
| ssr_imports = ( | ||
| '\nimport { getBackendURL } from "$/utils/state";' | ||
| '\nimport env from "$/env.json";' | ||
| ) | ||
| ssr_loader = """ | ||
| export async function loader({ request }) { | ||
| // Short-circuit during static shell generation (no backend available). | ||
| if (request.headers.get("x-reflex-shell-gen") === "1") { | ||
| return { state: null }; | ||
| } | ||
| // Fetch state data from the Python backend. This loader runs in two cases: | ||
| // (a) Full SSR render for bots — ssr-serve.js routes bot requests here. | ||
| // (b) .data requests for client-side navigation — React Router calls the | ||
| // loader to fetch route data as JSON for the next page. | ||
| // Both cases need real state data from the backend. | ||
| const backendUrl = getBackendURL(env.SSR_DATA); | ||
| const { pathname, search } = new URL(request.url); | ||
| try { | ||
| const res = await fetch(backendUrl, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| ...(request.headers.get("cookie") ? { "Cookie": request.headers.get("cookie") } : {}), | ||
| }, | ||
| body: JSON.stringify({ | ||
| path: pathname + search, | ||
| headers: Object.fromEntries(request.headers), | ||
| }), | ||
| }); | ||
| if (res.ok) { | ||
| return res.json(); | ||
| } | ||
| } catch (e) { | ||
| console.error("SSR data fetch failed:", e); | ||
| } | ||
| return { state: null }; | ||
|
Comment on lines
+266
to
+290
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| """ | ||
| else: | ||
| ssr_imports = "" | ||
| ssr_loader = "" | ||
|
|
||
| return f""" | ||
| {imports_str} | ||
| {dynamic_imports_str} | ||
| import {{ defaultColorMode }} from "$/utils/context"; | ||
| import {{ ThemeProvider }} from '$/utils/react-theme'; | ||
| import {{ Layout as AppLayout }} from './_document'; | ||
| import {{ Outlet }} from 'react-router'; | ||
| import {{ Outlet }} from 'react-router';{ssr_imports} | ||
| {import_window_libraries} | ||
| {lazy_imports_setup} | ||
|
|
||
| {custom_code_str} | ||
| {custom_code_str}{ssr_loader} | ||
|
|
||
| function ReflexProviders({{children}}) {{ | ||
| {window_imports_effect} | ||
|
|
@@ -316,6 +361,7 @@ def context_template( | |
| state_schema: str | None = None, | ||
| client_storage: dict[str, dict[str, dict[str, Any]]] | None = None, | ||
| disable_react_owner_stacks: bool = False, | ||
| runtime_ssr: bool = False, | ||
| ): | ||
| """Template for the context file. | ||
|
|
||
|
|
@@ -336,6 +382,7 @@ def context_template( | |
| disable_react_owner_stacks: Whether to emit the snippet that disables | ||
| React's dev-build owner-stack capture (an Error() constructed per | ||
| created element, whose cost grows with render depth). | ||
| runtime_ssr: Whether runtime SSR is enabled. | ||
|
|
||
| Returns: | ||
| Rendered context file content as string. | ||
|
|
@@ -437,11 +484,34 @@ def context_template( | |
| else "" | ||
| ) | ||
|
|
||
| if runtime_ssr: | ||
| ssr_import_str = '\nimport { useRouteLoaderData } from "react-router"' | ||
| ssr_hook_str = """ | ||
| // State the backend rendered for this request (see the root loader), or null | ||
| // when the static SPA shell was served. | ||
| const useSSRState = () => useRouteLoaderData("root")?.state ?? null; | ||
| """ | ||
| ssr_hydrated_str = "\n const ssrHydrated = useSSRState() !== null" | ||
| ssr_hydrated_arg = "\n ssrHydrated," | ||
| ssr_state_str = "\n const ssrState = useSSRState();" | ||
| # The client store reads it only on mount, so loader revalidations on | ||
| # later navigations leave the live state alone. | ||
| client_initial_state = "{ ...initialState, ...ssrState }" | ||
| server_state_store = ( | ||
| "{ ...serverStateStore, getSnapshot: (substateName) => " | ||
| "ssrState?.[substateName] ?? initialState[substateName] }" | ||
| ) | ||
| else: | ||
| ssr_import_str = ssr_hook_str = ssr_hydrated_str = ssr_hydrated_arg = "" | ||
| ssr_state_str = "" | ||
| client_initial_state = "initialState" | ||
| server_state_store = "serverStateStore" | ||
|
|
||
| return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useState, createElement, useEffect }} from "react" | ||
| import {{ applyDelta, ReflexEvent, hydrateClientStorage, useEventLoop, refs }} from "$/utils/state" | ||
| import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext, registerApp, eventLoop }} from "$/utils/context-registry" | ||
| import {{ jsx }} from "@emotion/react"; | ||
| {disable_owner_stacks_str} | ||
| import {{ jsx }} from "@emotion/react";{ssr_import_str} | ||
| {disable_owner_stacks_str}{ssr_hook_str} | ||
| export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext }}; | ||
| export const initialState = {initial_state_json} | ||
| export const initialStateHashes = {"[]" if not initial_state_hashes else json_dumps(initial_state_hashes)} | ||
|
|
@@ -509,11 +579,11 @@ def context_template( | |
| }} | ||
|
|
||
| export function EventLoopProvider({{ children }}) {{ | ||
| const dispatch = useContext(DispatchContext) | ||
| const dispatch = useContext(DispatchContext){ssr_hydrated_str} | ||
| const [addEventsLocal, connectErrors] = useEventLoop( | ||
| dispatch, | ||
| initialEvents, | ||
| clientStorage, | ||
| clientStorage,{ssr_hydrated_arg} | ||
| ) | ||
| // Publish the dispatchers so JSX literals constructed outside the | ||
| // React-tree path (e.g. ``ErrorBoundary.onError``) can call ``addEvents``. | ||
|
|
@@ -588,8 +658,8 @@ def context_template( | |
| ); | ||
| }} | ||
|
|
||
| function ClientStateProvider({{ children }}) {{ | ||
| const [store] = useState(() => createStateStore(initialState)); | ||
| function ClientStateProvider({{ children }}) {{{ssr_state_str} | ||
| const [store] = useState(() => createStateStore({client_initial_state})); | ||
| useEffect(() => store.mergeInitialState(initialState), [store, initialState]); | ||
| return createElement(StateStoreProviders, {{ children, store }}); | ||
| }} | ||
|
|
@@ -602,8 +672,8 @@ def context_template( | |
| subscribe: () => () => {{}}, | ||
| }}; | ||
|
|
||
| function ServerStateProvider({{ children }}) {{ | ||
| return createElement(StateStoreProviders, {{ children, store: serverStateStore }}); | ||
| function ServerStateProvider({{ children }}) {{{ssr_state_str} | ||
| return createElement(StateStoreProviders, {{ children, store: {server_state_store} }}); | ||
| }} | ||
|
|
||
| export const StateProvider = | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/reaches this static middleware before the bot check. Express servesbuild/client/index.htmlas the directory index, so the crawler gets the unrendered SPA shell instead of the server-rendered home page.