Skip to content
Draft

ssr wip #6173

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/+runtime-ssr.feature.md
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.
1 change: 1 addition & 0 deletions packages/reflex-base/news/+runtime-ssr.feature.md
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" }));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Home-page bots bypass SSR A crawler requesting / reaches this static middleware before the bot check. Express serves build/client/index.html as the directory index, so the crawler gets the unrendered SPA shell instead of the server-rendered home page.


// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Direct visits get wrong markup The shell is generated by rendering /, but this branch sends that same HTML for every document URL. Someone opening /blog/hello-world directly therefore receives the index page’s markup and hydration data, causing an initially incorrect page or a hydration mismatch.

}

// .data requests, API calls, etc. → SSR handler (runs loaders, returns JSON).
return ssrHandler(req, res, next);
});
Comment thread Fixed

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
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 SSR data disappears on connection

For browsers receiving SSR HTML, this branch sends hydrate against the browser's separate backend session. But ssr_data ran the load handlers on a temporary state, not that session. hydrate then sends the session's full state, replacing the loaded values. It also resets client storage and leaves is_hydrated false without queuing load handlers.

Keep the browser's backend session in sync before skipping hydrate_and_load.

params,
);
recordSentStorageValues(boot_event);
// Instrumentation hook (installed by reflex-otel): may add a traceparent.
window.__reflex_otel?.onEventSend?.(boot_event);
Expand Down Expand Up @@ -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
Expand All @@ -1373,6 +1383,7 @@ export const useEventLoop = (
dispatch,
initial_events = () => [],
client_storage = {},
ssrHydrated = false,
) => {
const socket = useRef(null);
const location = useLocation();
Expand Down Expand Up @@ -1418,6 +1429,7 @@ export const useEventLoop = (
client_storage,
navigate,
params,
ssrHydrated,
);
}
}, [
Expand Down
90 changes: 80 additions & 10 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Navigation runs load handlers twice On a client-side navigation, the .data request reaches this loader and runs the page’s on_load handlers through /_ssr_data. The existing navigation effect also sends on_load_internal over the WebSocket. A handler with an external side effect, such as a write or notification, can therefore perform it twice for one navigation.

}
"""
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}
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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``.
Expand Down Expand Up @@ -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 }});
}}
Expand All @@ -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 =
Expand Down
3 changes: 3 additions & 0 deletions packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ class BaseConfig:
plugins: List of plugins to use in the app.
disable_plugins: List of plugin types to disable in the app.
transport: The transport method for client-server communication.
runtime_ssr: Enable runtime server-side rendering for search engine crawlers on dynamic routes.
"""

app_name: str
Expand Down Expand Up @@ -282,6 +283,8 @@ class BaseConfig:

transport: Literal["websocket", "polling"] = "websocket"

runtime_ssr: bool = False
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

# Whether to skip plugin checks.
_skip_plugins_checks: bool = dataclasses.field(default=False, repr=False)

Expand Down
5 changes: 2 additions & 3 deletions packages/reflex-base/src/reflex_base/constants/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,8 @@ class ReactRouter(Javascript):
# Regex to check for message displayed when frontend comes up
DEV_FRONTEND_LISTENING_REGEX = r"Local:[\s]+"

# Regex to pattern the route path in the config file
# INFO Accepting connections at http://localhost:3000
PROD_FRONTEND_LISTENING_REGEX = r"Accepting connections at[\s]+"
# Matches output from sirv ("Accepting connections at") or ssr-serve ("[ssr-serve]").
PROD_FRONTEND_LISTENING_REGEX = r"(?:Accepting connections at|\[ssr-serve\])[\s]+"

FRONTEND_LISTENING_REGEX = (
rf"(?:{DEV_FRONTEND_LISTENING_REGEX}|{PROD_FRONTEND_LISTENING_REGEX})(.*)"
Expand Down
1 change: 1 addition & 0 deletions packages/reflex-base/src/reflex_base/constants/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Endpoint(Enum):
AUTH_CODESPACE = "auth-codespace"
HEALTH = "_health"
ALL_ROUTES = "_all_routes"
SSR_DATA = "_ssr_data"

def __str__(self) -> str:
"""Get the string representation of the endpoint.
Expand Down
Loading
Loading