diff --git a/reflex/.templates/web/generate-shell.mjs b/reflex/.templates/web/generate-shell.mjs new file mode 100644 index 00000000000..2e60cc33392 --- /dev/null +++ b/reflex/.templates/web/generate-shell.mjs @@ -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"); diff --git a/reflex/.templates/web/ssr-serve.js b/reflex/.templates/web/ssr-serve.js new file mode 100644 index 00000000000..17249281a39 --- /dev/null +++ b/reflex/.templates/web/ssr-serve.js @@ -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); + } + + // .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}`); +}); diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 9e937ed62cd..b5ced0bd943 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -842,6 +842,7 @@ export const useEventLoop = ( dispatch, initial_events = () => [], client_storage = {}, + ssrHydrated = false, ) => { const socket = useRef(null); const location = useLocation(); @@ -948,13 +949,27 @@ export const useEventLoop = ( const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode useEffect(() => { if (!sentHydrate.current) { - queueEvents( - initial_events(), - socket, - true, - navigate, - () => params.current, - ); + if (ssrHydrated) { + // SSR state was applied via StateProvider's initial reducer values. + // Just send hydrate to establish WebSocket session, skip on_load_internal + // since data is already loaded from the server-side render. + queueEvents( + [ReflexEvent(state_name + ".hydrate")], + socket, + true, + navigate, + () => params.current, + ); + } else { + // No SSR state — fall back to normal WebSocket hydration. + queueEvents( + initial_events(), + socket, + true, + navigate, + () => params.current, + ); + } sentHydrate.current = true; } }, []); diff --git a/reflex/app.py b/reflex/app.py index 54682543a7d..d89143eb6ca 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -86,6 +86,7 @@ from reflex.istate.proxy import StateProxy from reflex.page import DECORATED_PAGES from reflex.route import ( + extract_route_params, get_route_args, replace_brackets_with_keywords, verify_route_validity, @@ -689,6 +690,12 @@ def _add_default_endpoints(self): health, methods=["GET"], ) + if get_config().runtime_ssr: + self._api.add_route( + str(constants.Endpoint.SSR_DATA), + ssr_data(self), + methods=["POST"], + ) def _add_optional_endpoints(self): """Add optional api endpoints (_upload).""" @@ -1461,8 +1468,9 @@ def _submit_work_without_advancing( progress.advance(task) # Compile the contexts. + runtime_ssr = get_config().runtime_ssr compile_results.append( - compiler.compile_contexts(self._state, self.theme), + compiler.compile_contexts(self._state, self.theme, runtime_ssr=runtime_ssr), ) if self.theme is not None: # Fix #2992 by removing the top-level appearance prop @@ -1471,7 +1479,7 @@ def _submit_work_without_advancing( # Compile the app root. compile_results.append( - compiler.compile_app(app_root), + compiler.compile_app(app_root, runtime_ssr=runtime_ssr), ) progress.advance(task) @@ -1484,10 +1492,15 @@ def _submit_work_without_advancing( with console.timing("Install Frontend Packages"): self._get_frontend_packages(all_imports) - # Setup the react-router.config.js + # Setup the react-router.config.js and package.json. frontend_skeleton.update_react_router_config( prerender_routes=prerender_routes, ) + frontend_skeleton.initialize_package_json() + + # Copy SSR scripts when runtime SSR is enabled. + if runtime_ssr: + frontend_skeleton.copy_ssr_scripts() if is_prod_mode(): # Empty the .web pages directory. @@ -1806,6 +1819,21 @@ async def process( if (path := router_data.get(constants.RouteVar.PATH)) else "404" ).removeprefix("/") + # Server-side extraction of dynamic route params as a fallback. + # When the SPA shell is served for a direct page visit, the client + # may not have route params yet (React Router lazy route discovery + # hasn't completed), so extract them from the pathname to ensure + # on_load handlers receive correct params like slug, id, etc. + if path: + server_params = extract_route_params( + path, router_data[constants.RouteVar.PATH] + ) + if server_params: + query = router_data.get(constants.RouteVar.QUERY, {}) + for key, value in server_params.items(): + if not query.get(key): + query[key] = value + router_data[constants.RouteVar.QUERY] = query # re-assign only when the value is different if state.router_data != router_data: # assignment will recurse into substates and force recalculation of @@ -1891,6 +1919,135 @@ async def health(_request: Request) -> JSONResponse: return JSONResponse(content=health_status, status_code=status_code) +async def _run_ssr_on_load(state: BaseState, load_event: Any, path: str) -> None: + """Execute a single on_load handler on the ephemeral SSR state. + + Args: + state: The ephemeral root state instance. + load_event: The on_load event handler to execute. + path: The URL path (for error logging). + """ + try: + handler_fn = ( + load_event.handler if hasattr(load_event, "handler") else load_event + ) + # Get the full handler name. + if hasattr(handler_fn, "fn") and hasattr(handler_fn, "state_full_name"): + handler_name = f"{handler_fn.state_full_name}.{handler_fn.fn.__name__}" + else: + handler_name = str(handler_fn) + + # Find the target substate and event handler. + target_state, event_handler = state._get_event_handler(handler_name) + + # Execute the handler on the target substate. + result = event_handler.fn(target_state) + if asyncio.iscoroutine(result): + result = await result + # For generators (event chains), consume them but ignore returned events. + if inspect.isgenerator(result): + for _ in result: + pass + if inspect.isasyncgen(result): + async for _ in result: + pass + except Exception: + console.warn(f"SSR on_load handler failed for {path}: {traceback.format_exc()}") + + +def ssr_data(app: App): + """SSR data loader endpoint. + + Creates an ephemeral state, sets route params, runs on_load handlers, + and returns the serialized state for server-side rendering. + + Args: + app: The app to get SSR data for. + + Returns: + The SSR data handler function. + """ + + async def ssr_data_handler(request: Request) -> Response: + """Handle an SSR data request. + + Args: + request: The Starlette request object. + + Returns: + Response with the serialized state as JSON. + """ + body = await request.json() + + path = body.get("path", "/") + headers = body.get("headers", {}) + + if not app._state: + return Response( + content='{"state": null}', + media_type="application/json", + ) + + # Create an ephemeral state instance (no persistent session). + # Use State (root) rather than app._state which may be a subclass + # with inherited vars that can't be set without a parent. + state = State(_reflex_internal_init=True) # pyright: ignore[reportCallIssue] + + # Resolve the route pattern from the concrete path. + resolved_route = app.router(path) or "404" + + # Extract route params from the path by matching against the route pattern. + # e.g. route="blog/[slug]", path="/blog/hello-world" => {"slug": "hello-world"} + params = extract_route_params(path, resolved_route) + + # Build router_data dict (same structure as process() uses). + router_data = { + constants.RouteVar.PATH: "/" + resolved_route.removeprefix("/"), + constants.RouteVar.ORIGIN: path, + constants.RouteVar.QUERY: { + **params, + }, + constants.RouteVar.CLIENT_TOKEN: "__ssr__", + constants.RouteVar.SESSION_ID: "__ssr__", + constants.RouteVar.HEADERS: { + "origin": headers.get( + "origin", headers.get("host", "http://localhost") + ), + **headers, + }, + constants.RouteVar.CLIENT_IP: ( + request.client.host if request.client else "0.0.0.0" + ), + } + + # Set router data on the state — this triggers DynamicRouteVar recomputation. + state.router_data = router_data + state.router = RouterData.from_router_data(router_data) + + # Get on_load event handlers for this route. + load_events = app.get_load_events(path) + + # Execute each on_load handler directly on the ephemeral state. + for load_event in load_events: + await _run_ssr_on_load(state, load_event, path) + + # Serialize the full state tree for the frontend. + full_state = state.dict() + + # Use Reflex's json serializer to handle custom types (RouterData, etc.) + json_str = format.json_dumps({"state": full_state}) + + return Response( + content=json_str, + media_type="application/json", + headers={ + "Cache-Control": "no-cache", + }, + ) + + return ssr_data_handler + + class _UploadStreamingResponse(StreamingResponse): """Streaming response that always releases upload form resources.""" diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 0c8ee62c1a1..9aec8ac8934 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -73,11 +73,12 @@ def _normalize_library_name(lib: str) -> str: return lib.replace("$/", "").replace("@", "").replace("/", "_").replace("-", "_") -def _compile_app(app_root: Component) -> str: +def _compile_app(app_root: Component, runtime_ssr: bool = False) -> str: """Compile the app template component. Args: app_root: The app root to compile. + runtime_ssr: Whether runtime SSR is enabled. Returns: The compiled app. @@ -100,6 +101,7 @@ def _compile_app(app_root: Component) -> str: window_libraries=window_libraries_deduped, render=app_root.render(), dynamic_imports=app_root._get_all_dynamic_imports(), + runtime_ssr=runtime_ssr, ) @@ -115,12 +117,17 @@ def _compile_theme(theme: str) -> str: return templates.theme_template(theme=theme) -def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> str: +def _compile_contexts( + state: type[BaseState] | None, + theme: Component | None, + runtime_ssr: bool = False, +) -> str: """Compile the initial state and contexts. Args: state: The app state. theme: The top-level app theme. + runtime_ssr: Whether runtime SSR is enabled. Returns: The compiled context file. @@ -136,11 +143,13 @@ def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> client_storage=utils.compile_client_storage(state), is_dev_mode=not is_prod_mode(), default_color_mode=str(appearance), + runtime_ssr=runtime_ssr, ) if state else templates.context_template( is_dev_mode=not is_prod_mode(), default_color_mode=str(appearance), + runtime_ssr=runtime_ssr, ) ) @@ -491,11 +500,12 @@ def compile_document_root( return output_path, code -def compile_app(app_root: Component) -> tuple[str, str]: +def compile_app(app_root: Component, runtime_ssr: bool = False) -> tuple[str, str]: """Compile the app root. Args: app_root: The app root component to compile. + runtime_ssr: Whether runtime SSR is enabled. Returns: The path and code of the compiled app wrapper. @@ -506,7 +516,7 @@ def compile_app(app_root: Component) -> tuple[str, str]: ) # Compile the document root. - code = _compile_app(app_root) + code = _compile_app(app_root, runtime_ssr=runtime_ssr) return output_path, code @@ -532,12 +542,14 @@ def compile_theme(style: ComponentStyle) -> tuple[str, str]: def compile_contexts( state: type[BaseState] | None, theme: Component | None, + runtime_ssr: bool = False, ) -> tuple[str, str]: """Compile the initial state / context. Args: state: The app state. theme: The top-level app theme. + runtime_ssr: Whether runtime SSR is enabled. Returns: The path and code of the compiled context. @@ -545,7 +557,7 @@ def compile_contexts( # Get the path for the output file. output_path = utils.get_context_path() - return output_path, _compile_contexts(state, theme) + return output_path, _compile_contexts(state, theme, runtime_ssr=runtime_ssr) def compile_page(path: str, component: BaseComponent) -> tuple[str, str]: @@ -899,7 +911,11 @@ def compile_unevaluated_page( component = compile_unevaluated_page( route, cls.UNCOMPILED_PAGES[route], style, theme ) - return route, component, compile_page(route, component) + return ( + route, + component, + compile_page(route, component), + ) @classmethod def compile_theme(cls, style: ComponentStyle | None) -> tuple[str, str]: diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index a8a7dbe4ec2..329d3ffe316 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -170,6 +170,7 @@ def app_root_template( window_libraries: list[tuple[str, str]], render: dict[str, Any], dynamic_imports: set[str], + runtime_ssr: bool = False, ): """Template for the App root. @@ -180,6 +181,7 @@ def app_root_template( window_libraries: The list of window libraries. render: The dictionary of render functions. dynamic_imports: The set of dynamic imports. + runtime_ssr: Whether runtime SSR is enabled. Returns: Rendered App root component as string. @@ -198,17 +200,67 @@ def app_root_template( f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries ]) + if runtime_ssr: + ssr_imports = ( + 'import { Outlet, useLoaderData } from "react-router";\n' + 'import { getBackendURL } from "$/utils/state";\n' + 'import 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); + 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: new URL(request.url).pathname, + headers: Object.fromEntries(request.headers), + }), + }); + if (res.ok) { + return res.json(); + } + } catch (e) { + console.error("SSR data fetch failed:", e); + } + return { state: null }; +} +""" + ssr_layout_head = ( + " const loaderData = useLoaderData();\n" + " const ssrState = loaderData?.state || null;\n" + ) + ssr_state_provider_props = "{ssrState}" + else: + ssr_imports = 'import { Outlet } from "react-router";' + ssr_loader = "" + ssr_layout_head = "" + ssr_state_provider_props = "{}" + return f""" {imports_str} {dynamic_imports_str} import {{ EventLoopProvider, StateProvider, defaultColorMode }} from "$/utils/context"; import {{ ThemeProvider }} from '$/utils/react-theme'; import {{ Layout as AppLayout }} from './_document'; -import {{ Outlet }} from 'react-router'; +{ssr_imports} {import_window_libraries} {custom_code_str} - +{ssr_loader} function AppWrap({{children}}) {{ {_render_hooks(hooks)} return ({_RenderUtils.render(render)}) @@ -216,6 +268,7 @@ def app_root_template( export function Layout({{children}}) {{ +{ssr_layout_head} useEffect(() => {{ // Make contexts and state objects available globally for dynamic eval'd components let windowImports = {{ @@ -226,7 +279,7 @@ def app_root_template( return jsx(AppLayout, {{}}, jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}}, - jsx(StateProvider, {{}}, + jsx(StateProvider, {ssr_state_provider_props}, jsx(EventLoopProvider, {{}}, jsx(AppWrap, {{}}, children) ) @@ -261,6 +314,7 @@ def context_template( initial_state: dict[str, Any] | None = None, state_name: str | None = None, client_storage: dict[str, dict[str, dict[str, Any]]] | None = None, + runtime_ssr: bool = False, ): """Template for the context file. @@ -270,6 +324,7 @@ def context_template( client_storage: The client storage for the context. is_dev_mode: Whether the app is in development mode. default_color_mode: The default color mode for the context. + runtime_ssr: Whether runtime SSR is enabled. Returns: Rendered context file content as string. @@ -327,10 +382,16 @@ def context_template( """ ) - state_reducer_str = "\n".join( - rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, initialState["{state_name}"])' - for state_name in initial_state - ) + if runtime_ssr: + state_reducer_str = "\n".join( + rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, ssrState !== null && ssrState["{state_name}"] != null ? ssrState["{state_name}"] : initialState["{state_name}"])' + for state_name in initial_state + ) + else: + state_reducer_str = "\n".join( + rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, initialState["{state_name}"])' + for state_name in initial_state + ) create_state_contexts_str = "\n".join( rf"createElement(StateContexts.{format_state_name(state_name)},{{value: {format_state_name(state_name)}}}," @@ -353,8 +414,15 @@ def context_template( export const UploadFilesContext = createContext(null); export const DispatchContext = createContext(null); export const StateContexts = {{{state_contexts_str}}}; -export const EventLoopContext = createContext(null); -export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)} +export const EventLoopContext = createContext(null);{ + ''' +export const SSRContext = createContext(false);''' + if runtime_ssr + else "" + } +export const clientStorage = { + "{}" if client_storage is None else json.dumps(client_storage) + } {state_str} @@ -389,11 +457,21 @@ def context_template( }} export function EventLoopProvider({{ children }}) {{ - const dispatch = useContext(DispatchContext) + const dispatch = useContext(DispatchContext){ + ''' + const ssrHydrated = useContext(SSRContext)''' + if runtime_ssr + else "" + } const [addEvents, connectErrors] = useEventLoop( dispatch, initialEvents, - clientStorage, + clientStorage,{ + ''' + ssrHydrated,''' + if runtime_ssr + else "" + } ) return createElement( EventLoopContext.Provider, @@ -402,7 +480,9 @@ def context_template( ); }} -export function StateProvider({{ children }}) {{ +export function StateProvider({{ children{ + ", ssrState = null" if runtime_ssr else "" + } }}) {{ {state_reducer_str} const dispatchers = useMemo(() => {{ return {{ @@ -411,9 +491,11 @@ def context_template( }}, []) return ( + {"createElement(SSRContext.Provider, {value: !!ssrState}," if runtime_ssr else ""} {create_state_contexts_str} createElement(DispatchContext, {{value: dispatchers}}, children) {")" * len(initial_state)} + {")" if runtime_ssr else ""} ) }}""" @@ -454,6 +536,7 @@ def page_template( dynamic_imports_str = "\n".join(dynamic_imports) hooks_str = _render_hooks(hooks) + return f"""{imports_str} {dynamic_imports_str} diff --git a/reflex/config.py b/reflex/config.py index 47fd969ac49..6a9c38d8312 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -265,6 +265,9 @@ class BaseConfig: # The transport method for client-server communication. transport: Literal["websocket", "polling"] = "websocket" + # Enable runtime server-side rendering for search engine crawlers on dynamic routes. + runtime_ssr: bool = False + # Whether to skip plugin checks. _skip_plugins_checks: bool = dataclasses.field(default=False, repr=False) diff --git a/reflex/constants/base.py b/reflex/constants/base.py index c8a9a1dfde5..bbc39afcd6e 100644 --- a/reflex/constants/base.py +++ b/reflex/constants/base.py @@ -169,9 +169,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})(.*)" diff --git a/reflex/constants/event.py b/reflex/constants/event.py index 6a0f71ec161..00f9fb2c8d1 100644 --- a/reflex/constants/event.py +++ b/reflex/constants/event.py @@ -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. diff --git a/reflex/constants/installer.py b/reflex/constants/installer.py index 84e23e51b58..84801fede16 100644 --- a/reflex/constants/installer.py +++ b/reflex/constants/installer.py @@ -106,6 +106,7 @@ class Commands(SimpleNamespace): DEV = "react-router dev --host" EXPORT = "react-router build" + PROD_SSR = "node ssr-serve.js" @staticmethod def get_prod_command(frontend_path: str = "") -> str: @@ -149,6 +150,21 @@ def DEPENDENCIES(cls) -> dict[str, str]: "universal-cookie": "7.2.2", } + @classproperty + @classmethod + def SSR_DEPENDENCIES(cls) -> dict[str, str]: + """Additional dependencies required when runtime_ssr is enabled. + + Returns: + A dictionary of SSR-specific dependencies with their versions. + """ + return { + "@react-router/serve": cls._react_router_version, + "@react-router/express": cls._react_router_version, + "express": "4.21.2", + "compression": "1.8.0", + } + DEV_DEPENDENCIES = { "@emotion/react": "11.14.0", "autoprefixer": "10.4.24", diff --git a/reflex/route.py b/reflex/route.py index 761ec8f974d..5fdc5ccdd19 100644 --- a/reflex/route.py +++ b/reflex/route.py @@ -96,6 +96,39 @@ def _add_route_arg(arg_name: str, type_: str): return args +def extract_route_params(path: str, route: str) -> dict[str, str]: + """Extract dynamic route parameter values from a concrete path. + + Given a concrete path (e.g. "/blog/hello-world") and a route pattern + (e.g. "blog/[slug]"), extract the parameter values by positional matching. + + Args: + path: The concrete URL path (e.g. "/blog/hello-world"). + route: The route pattern with brackets (e.g. "blog/[slug]"). + + Returns: + Dict mapping parameter names to their values, + e.g. {"slug": "hello-world"}. + """ + route_args = get_route_args(route) + if not route_args: + return {} + + params: dict[str, str] = {} + route_parts = route.strip("/").split("/") + path_parts = path.strip("/").split("/") + for i, route_part in enumerate(route_parts): + if i < len(path_parts): + arg_match = re.match( + r"^\[{1,2}(?:\.\.\.)?([a-zA-Z_]\w*)\]{1,2}$", route_part + ) + if arg_match: + param_name = arg_match.group(1) + if param_name in route_args: + params[param_name] = path_parts[i] + return params + + def replace_brackets_with_keywords(input_string: str) -> str: """Replace brackets and everything inside it in a string with a keyword. diff --git a/reflex/testing.py b/reflex/testing.py index 4ab72602334..fefb522cf8f 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -1134,3 +1134,119 @@ def stop(self): self.frontend_server.shutdown() if self.frontend_thread is not None: self.frontend_thread.join() + + +class AppHarnessSSR(AppHarnessProd): + """AppHarnessSSR runs a Reflex app with runtime SSR via ssr-serve.js. + + Instead of serving static files with Python's http.server, this harness + runs the Node.js ``ssr-serve.js`` Express server that provides bot-aware + server-side rendering. Regular (non-bot) users still receive the SPA shell + for fast hydration, while crawlers get fully rendered HTML. + + Use this harness with ``runtime_ssr=True`` in ``rxconfig.py``. + """ + + def _initialize_app(self): + """Initialize the app and patch the config for runtime SSR. + + The base ``_initialize_app`` scaffolds the project (``_init``), reloads + the config from disk, then creates the app instance (which registers + endpoints). We need ``runtime_ssr=True`` in the config **before** the + app instance is created so that the ``/_ssr_data`` endpoint is + registered and the ``package.json`` includes SSR dependencies. + + Strategy: call ``super()`` which does everything, then: + 1. Patch ``rxconfig.py`` on disk so future reloads pick it up. + 2. Set the live config to ``runtime_ssr=True``. + 3. Manually register the ``/_ssr_data`` endpoint on the already-created + ASGI app (since it was skipped during initial creation). + """ + super()._initialize_app() + + # 1. Patch the on-disk rxconfig.py for future reinit / export. + rxconfig_path = self.app_path / reflex.constants.Config.FILE + content = rxconfig_path.read_text() + content = content.replace("rx.Config(", "rx.Config(\n runtime_ssr=True,") + rxconfig_path.write_text(content) + + # 2. Enable runtime_ssr on the live config singleton. + get_config().runtime_ssr = True + + # 3. Register the /_ssr_data endpoint that was skipped during init. + if self.app_instance is not None and self.app_instance._api is not None: + from reflex.app import ssr_data + + self.app_instance._api.add_route( + str(reflex.constants.Endpoint.SSR_DATA), + ssr_data(self.app_instance), + methods=["POST"], + ) + + def _start_frontend(self): + """Export the app and launch ssr-serve.js as the frontend process. + + After export, an extra ``bun install`` is run to pick up SSR + dependencies that were written to ``package.json`` by ``_compile()`` + *after* the initial package install (a sequencing nuance: in normal + usage the package.json is correct from the start, but here the test + harness flipped ``runtime_ssr`` after the first ``_init``). + """ + with chdir(self.app_path): + config = reflex.config.get_config() + print("Polling for servers...") # for pytest diagnosis # noqa: T201 + config.api_url = "http://{}:{}".format( + *self._poll_for_servers(timeout=30).getsockname(), + ) + print("Building frontend (SSR)...") # for pytest diagnosis # noqa: T201 + + get_config().loglevel = reflex.constants.LogLevel.INFO + + reflex.utils.prerequisites.assert_in_reflex_dir() + + if reflex.utils.prerequisites.needs_reinit(): + reflex.reflex._init(name=get_config().app_name) + + export( + zipping=False, + frontend=True, + backend=False, + loglevel=reflex.constants.LogLevel.INFO, + env=reflex.constants.Env.PROD, + ) + + # export() regenerated package.json with SSR deps but the initial + # bun install ran before that. Run install once more so that + # @react-router/express, express, compression are available. + web_dir = self.app_path / reflex.utils.prerequisites.get_web_dir() + print("Installing SSR dependencies...") # for pytest diagnosis # noqa: T201 + install_proc = reflex.utils.processes.new_process( + [ + *js_runtimes.get_js_package_executor(raise_on_none=True)[0], + "install", + "--legacy-peer-deps", + ], + cwd=web_dir, + ) + install_proc.communicate() + + print("Frontend starting (SSR)...") # for pytest diagnosis # noqa: T201 + + from reflex.utils.path_ops import get_node_path + + node = str(get_node_path() or "node") + self.frontend_process = reflex.utils.processes.new_process( + [node, "ssr-serve.js"], + cwd=web_dir, + env={"PORT": "0", "NO_COLOR": "1"}, + **FRONTEND_POPEN_ARGS, + ) + + def _wait_frontend(self): + """Wait for ssr-serve.js to emit its listening URL on stdout. + + Reuses the base ``AppHarness._wait_frontend`` which parses + ``FRONTEND_LISTENING_REGEX`` — that regex already matches the + ``[ssr-serve] http://localhost:`` output format. + """ + AppHarness._wait_frontend(self) diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 7b7408f8b3b..45bff5e7865 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -141,13 +141,34 @@ def zip_app( } if frontend: - _zip( - component_name=constants.ComponentName.FRONTEND, - target=zip_dest_dir / constants.ComponentName.FRONTEND.zip(), - root_directory=prerequisites.get_web_dir() / constants.Dirs.STATIC, - files_to_exclude=files_to_exclude, - exclude_venv_directories=False, - ) + web_dir = prerequisites.get_web_dir() + if get_config().runtime_ssr: + # SSR mode: zip build/ (client + server) + ssr-serve.js + package.json. + # Use build/ as root and include the extra files via globs from web_dir. + _zip( + component_name=constants.ComponentName.FRONTEND, + target=zip_dest_dir / constants.ComponentName.FRONTEND.zip(), + root_directory=web_dir, + files_to_exclude=files_to_exclude, + exclude_venv_directories=False, + directory_names_to_exclude={ + "node_modules", + "app", + "utils", + "styles", + "components", + "backend", + "public", + }, + ) + else: + _zip( + component_name=constants.ComponentName.FRONTEND, + target=zip_dest_dir / constants.ComponentName.FRONTEND.zip(), + root_directory=web_dir / constants.Dirs.STATIC, + files_to_exclude=files_to_exclude, + exclude_venv_directories=False, + ) if backend: _zip( @@ -187,6 +208,38 @@ def _duplicate_index_html_to_parent_directory(directory: Path): _duplicate_index_html_to_parent_directory(child) +def _generate_ssr_shell(wdir: Path): + """Generate a static SPA shell (build/client/index.html) after SSR build. + + Runs the generate-shell.mjs script which renders the app with a normal + user-agent (so the loader returns empty state) and writes the resulting + HTML to build/client/index.html. The production server (ssr-serve.js) + serves this file to non-bot users for zero SSR overhead. + + Args: + wdir: The web directory (.web/). + """ + shell_script = wdir / "generate-shell.mjs" + if not shell_script.exists(): + console.warn("generate-shell.mjs not found, skipping SPA shell generation.") + return + + console.info("Generating SPA shell for non-bot users...") + node_path = str(path_ops.get_node_path() or "node") + shell_process = processes.new_process( + [node_path, "generate-shell.mjs"], + cwd=wdir, + shell=constants.IS_WINDOWS, + ) + shell_process.wait() + if shell_process.returncode != 0: + console.warn( + "SPA shell generation failed. Non-bot users will fall back to SSR." + ) + else: + console.info("SPA shell generated successfully.") + + def build(): """Build the app for deployment. @@ -226,6 +279,11 @@ def build(): "Failed to build the frontend. Please run with --loglevel debug for more information.", ) raise SystemExit(1) + + # When runtime SSR is enabled, generate a static SPA shell for non-bot users. + if get_config().runtime_ssr: + _generate_ssr_shell(wdir) + _duplicate_index_html_to_parent_directory(wdir / constants.Dirs.STATIC) spa_fallback = wdir / constants.Dirs.STATIC / constants.ReactRouter.SPA_FALLBACK diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 37d66fac7db..afcbe00a282 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -147,6 +147,21 @@ def update_react_router_config(prerender_routes: bool = False): react_router_config_file_path.write_text(new_react_router_config) +def copy_ssr_scripts(): + """Copy SSR-related scripts from the web template to the .web directory. + + Copies ssr-serve.js (production server) and generate-shell.mjs + (post-build static shell generator) when runtime_ssr is enabled. + """ + import shutil + + web_dir = get_web_dir() + for filename in ("ssr-serve.js", "generate-shell.mjs"): + src = constants.Templates.Dirs.WEB_TEMPLATE / filename + if src.exists(): + shutil.copy2(str(src), str(web_dir / filename)) + + def _update_react_router_config(config: Config, prerender_routes: bool = False): basename = "/" + (config.frontend_path or "").strip("/") if not basename.endswith("/"): @@ -157,7 +172,7 @@ def _update_react_router_config(config: Config, prerender_routes: bool = False): "future": { "unstable_optimizeDeps": True, }, - "ssr": False, + "ssr": config.runtime_ssr, } if prerender_routes: @@ -169,15 +184,21 @@ def _update_react_router_config(config: Config, prerender_routes: bool = False): def _compile_package_json(): config = get_config() + prod_command = ( + constants.PackageJson.Commands.PROD_SSR + if config.runtime_ssr + else constants.PackageJson.Commands.get_prod_command(config.frontend_path) + ) + deps = dict(constants.PackageJson.DEPENDENCIES) + if config.runtime_ssr: + deps.update(constants.PackageJson.SSR_DEPENDENCIES) return templates.package_json_template( scripts={ "dev": constants.PackageJson.Commands.DEV, "export": constants.PackageJson.Commands.EXPORT, - "prod": constants.PackageJson.Commands.get_prod_command( - config.frontend_path - ), + "prod": prod_command, }, - dependencies=constants.PackageJson.DEPENDENCIES, + dependencies=deps, dev_dependencies=constants.PackageJson.DEV_DEPENDENCIES, overrides=constants.PackageJson.OVERRIDES, ) diff --git a/tests/integration/tests_playwright/test_ssr.py b/tests/integration/tests_playwright/test_ssr.py new file mode 100644 index 00000000000..ab9205129d0 --- /dev/null +++ b/tests/integration/tests_playwright/test_ssr.py @@ -0,0 +1,314 @@ +"""Integration tests for runtime SSR (server-side rendering). + +Spins up a blog app with ``runtime_ssr=True`` via ``AppHarnessSSR``, then +verifies: + - Bots receive fully rendered HTML with blog data. + - Normal users receive the SPA shell (no blog content in raw HTML). + - Playwright (a real browser) can navigate to dynamic routes and hydrate. + - The ``/_ssr_data`` backend endpoint returns serialized state. +""" + +from __future__ import annotations + +from collections.abc import Generator + +import httpx +import pytest +from playwright.sync_api import Page, expect + +import reflex as rx +from reflex.testing import AppHarnessSSR + +# --------------------------------------------------------------------------- +# App source - a minimal blog with a dynamic /blog/[slug] route. +# --------------------------------------------------------------------------- + + +def SSRBlogApp(): + """A blog app with dynamic routes for SSR testing.""" + import reflex as rx + + POSTS = { + "hello-world": { + "title": "Hello World", + "content": "First post content for SSR testing.", + "author": "Test Author", + }, + "second-post": { + "title": "Second Post", + "content": "Another post for navigation tests.", + "author": "Test Author", + }, + } + + class BlogState(rx.State): + title: rx.Field[str] = rx.field("") + content: rx.Field[str] = rx.field("") + author: rx.Field[str] = rx.field("") + not_found: rx.Field[bool] = rx.field(False) + + @rx.event + def on_load_post(self): + slug: str = self.slug # pyright: ignore[reportAttributeAccessIssue] + post = POSTS.get(slug) + if post: + self.title = post["title"] + self.content = post["content"] + self.author = post["author"] + self.not_found = False + else: + self.title = "Not Found" + self.content = f"No post with slug '{slug}'" + self.author = "" + self.not_found = True + + def index() -> rx.Component: + return rx.container( + rx.heading("SSR Blog", size="8", data_testid="index-heading"), + rx.vstack( + rx.link( + "Hello World", + href="/blog/hello-world", + data_testid="link-hello-world", + ), + rx.link( + "Second Post", + href="/blog/second-post", + data_testid="link-second-post", + ), + spacing="3", + ), + ) + + @rx.page( + route="/blog/[slug]", + title="Blog Post", + on_load=BlogState.on_load_post, + ) + def blog_post() -> rx.Component: + return rx.container( + rx.link("Back", href="/", data_testid="back-link"), + rx.cond( + BlogState.not_found, + rx.text("Post Not Found", data_testid="not-found"), + rx.vstack( + rx.heading(BlogState.title, size="7", data_testid="post-title"), + rx.text(BlogState.author, data_testid="post-author"), + rx.text(BlogState.content, data_testid="post-content"), + spacing="4", + ), + ), + ) + + app = rx.App() + app.add_page(index) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +GOOGLEBOT_UA = ( + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" +) + +CHROME_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + + +@pytest.fixture(scope="module") +def ssr_blog_app(tmp_path_factory) -> Generator[AppHarnessSSR, None, None]: + """Create and start the SSR blog app. + + Args: + tmp_path_factory: pytest fixture for creating temporary directories. + + Yields: + AppHarnessSSR: A running harness for the SSR blog app. + """ + with AppHarnessSSR.create( + root=tmp_path_factory.mktemp("ssr_blog"), + app_source=SSRBlogApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_bot_gets_ssr_html(ssr_blog_app: AppHarnessSSR): + """Bots (Googlebot) should receive fully rendered HTML with blog data. + + Args: + ssr_blog_app: The running SSR blog app harness. + """ + assert ssr_blog_app.frontend_url is not None + url = f"{ssr_blog_app.frontend_url}/blog/hello-world" + resp = httpx.get(url, headers={"User-Agent": GOOGLEBOT_UA}, follow_redirects=True) + assert resp.status_code == 200 + body = resp.text + # The HTML should contain the actual blog post data (server-rendered). + assert "Hello World" in body + assert "First post content for SSR testing." in body + assert "Test Author" in body + + +def test_normal_user_gets_spa_shell(ssr_blog_app: AppHarnessSSR): + """Normal users should receive the SPA shell without blog-specific content. + + Args: + ssr_blog_app: The running SSR blog app harness. + """ + assert ssr_blog_app.frontend_url is not None + url = f"{ssr_blog_app.frontend_url}/blog/hello-world" + resp = httpx.get( + url, + headers={ + "User-Agent": CHROME_UA, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + follow_redirects=True, + ) + assert resp.status_code == 200 + body = resp.text + # The SPA shell should NOT contain post-specific rendered content. + assert "First post content for SSR testing." not in body + # But it should be valid HTML with the app shell structure. + assert " home -> Post B to verify multi-step navigation. + + Args: + ssr_blog_app: The running SSR blog app harness. + page: A Playwright page. + """ + assert ssr_blog_app.frontend_url is not None + + # Start at the first post. + page.goto(f"{ssr_blog_app.frontend_url}/blog/hello-world") + title = page.get_by_test_id("post-title") + expect(title).to_be_visible(timeout=15000) + expect(title).to_have_text("Hello World") + + # Go back to index. + back = page.get_by_test_id("back-link") + back.click() + heading = page.get_by_test_id("index-heading") + expect(heading).to_be_visible(timeout=15000) + + # Navigate to the second post. + link = page.get_by_test_id("link-second-post") + expect(link).to_be_visible() + link.click() + + title2 = page.get_by_test_id("post-title") + expect(title2).to_be_visible(timeout=15000) + expect(title2).to_have_text("Second Post") + + content2 = page.get_by_test_id("post-content") + expect(content2).to_have_text("Another post for navigation tests.") + + +def test_ssr_data_endpoint(ssr_blog_app: AppHarnessSSR): + """The /_ssr_data backend endpoint should return serialized state. + + Args: + ssr_blog_app: The running SSR blog app harness. + """ + api_url = rx.config.get_config().api_url + assert api_url is not None + + resp = httpx.post( + f"{api_url}/{rx.constants.Endpoint.SSR_DATA.value}", + json={"path": "/blog/hello-world", "headers": {}}, + ) + assert resp.status_code == 200 + + data = resp.json() + assert "state" in data + state = data["state"] + # The state is a nested dict: top-level keys are substate paths like + # "reflex___state____state.ssrblogapp___ssrblogapp____blog_state". + # Var names are mangled with a suffix (e.g. "title" → "title_rx_state_"). + # Find the BlogState substate and verify blog data is present. + blog_state = None + for key, value in state.items(): + if "blog_state" in key and isinstance(value, dict): + blog_state = value + break + assert blog_state is not None, ( + f"BlogState substate not found in: {list(state.keys())}" + ) + # Check that the title field is set (may be mangled with a suffix). + title_values = [v for k, v in blog_state.items() if k.startswith("title")] + assert any(v == "Hello World" for v in title_values), ( + f"Expected 'Hello World' in title fields: {blog_state}" + ) + + +def test_not_found_post(ssr_blog_app: AppHarnessSSR, page: Page): + """Navigating to a non-existent slug should show the "Post Not Found" text. + + Args: + ssr_blog_app: The running SSR blog app harness. + page: A Playwright page. + """ + assert ssr_blog_app.frontend_url is not None + page.goto(f"{ssr_blog_app.frontend_url}/blog/does-not-exist") + + not_found = page.get_by_test_id("not-found") + expect(not_found).to_be_visible(timeout=15000) + expect(not_found).to_have_text("Post Not Found") diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index cf22404bd69..58ea416e8d0 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -54,6 +54,22 @@ True, 'export default {"basename": "/", "future": {"unstable_optimizeDeps": true}, "ssr": false, "prerender": true, "build": "build"};', ), + ( + Config( + app_name="test", + runtime_ssr=True, + ), + False, + 'export default {"basename": "/", "future": {"unstable_optimizeDeps": true}, "ssr": true};', + ), + ( + Config( + app_name="test", + runtime_ssr=True, + ), + True, + 'export default {"basename": "/", "future": {"unstable_optimizeDeps": true}, "ssr": true, "prerender": true, "build": "build"};', + ), ], ) def test_update_react_router_config(config, export, expected_output): diff --git a/tests/units/test_ssr_compile.py b/tests/units/test_ssr_compile.py new file mode 100644 index 00000000000..888dd229f0f --- /dev/null +++ b/tests/units/test_ssr_compile.py @@ -0,0 +1,284 @@ +"""Unit tests for SSR compile output and configuration.""" + +from __future__ import annotations + +import json + +from pytest_mock import MockerFixture + +import reflex as rx +from reflex.utils.frontend_skeleton import ( + _compile_package_json, + _update_react_router_config, +) + + +class TestPackageJsonProdCommand: + """Tests for the package.json prod command based on runtime_ssr config.""" + + def test_prod_command_ssr(self, mocker: MockerFixture): + """With runtime_ssr=True, prod command is 'node ssr-serve.js'.""" + conf = rx.Config(app_name="test", runtime_ssr=True) + mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) + + result = _compile_package_json() + pkg = json.loads(result) + + assert pkg["scripts"]["prod"] == "node ssr-serve.js" + + def test_prod_command_static(self, mocker: MockerFixture): + """With runtime_ssr=False, prod command is sirv static server.""" + conf = rx.Config(app_name="test", runtime_ssr=False) + mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) + + result = _compile_package_json() + pkg = json.loads(result) + + assert pkg["scripts"]["prod"].startswith("sirv") + assert "node ssr-serve.js" not in pkg["scripts"]["prod"] + + def test_ssr_deps_only_when_enabled(self, mocker: MockerFixture): + """SSR-specific deps are only included when runtime_ssr=True.""" + ssr_only_deps = ("@react-router/express", "express", "compression") + + for ssr in (True, False): + conf = rx.Config(app_name="test", runtime_ssr=ssr) + mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) + pkg = json.loads(_compile_package_json()) + deps = pkg["dependencies"] + for dep in ssr_only_deps: + if ssr: + assert dep in deps, f"{dep} should be present when runtime_ssr=True" + else: + assert dep not in deps, ( + f"{dep} should NOT be present when runtime_ssr=False" + ) + + def test_dev_and_export_commands_unchanged(self, mocker: MockerFixture): + """Dev and export commands are the same regardless of runtime_ssr.""" + results = {} + for ssr in (True, False): + conf = rx.Config(app_name="test", runtime_ssr=ssr) + mocker.patch("reflex.utils.frontend_skeleton.get_config", return_value=conf) + results[ssr] = json.loads(_compile_package_json()) + + assert results[True]["scripts"]["dev"] == results[False]["scripts"]["dev"] + assert results[True]["scripts"]["export"] == results[False]["scripts"]["export"] + + +class TestReactRouterConfig: + """Tests for react-router.config.js based on runtime_ssr config.""" + + def test_ssr_true_in_config(self): + """With runtime_ssr=True, config has ssr: true.""" + conf = rx.Config(app_name="test", runtime_ssr=True) + result = _update_react_router_config(conf) + + parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) + assert parsed["ssr"] is True + + def test_ssr_false_in_config(self): + """With runtime_ssr=False, config has ssr: false.""" + conf = rx.Config(app_name="test", runtime_ssr=False) + result = _update_react_router_config(conf) + + parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) + assert parsed["ssr"] is False + + def test_ssr_with_prerender(self): + """runtime_ssr and prerender can coexist.""" + conf = rx.Config(app_name="test", runtime_ssr=True) + result = _update_react_router_config(conf, prerender_routes=True) + + parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) + assert parsed["ssr"] is True + assert parsed["prerender"] is True + + def test_default_ssr_is_false(self): + """Default config has ssr: false.""" + conf = rx.Config(app_name="test") + result = _update_react_router_config(conf) + + parsed = json.loads(result.removeprefix("export default ").removesuffix(";")) + assert parsed["ssr"] is False + + +class TestTemplateOutput: + """Tests for the generated template content based on runtime_ssr.""" + + @staticmethod + def _render_root(runtime_ssr: bool) -> str: + """Render root template with minimal valid params. + + Args: + runtime_ssr: Whether runtime SSR is enabled. + + Returns: + Rendered template string. + """ + from reflex.compiler.templates import app_root_template + + return app_root_template( + imports=[], + custom_codes=[], + hooks={}, + window_libraries=[], + render={"contents": "children"}, + dynamic_imports=set(), + runtime_ssr=runtime_ssr, + ) + + def test_root_template_has_loader_when_ssr_true(self): + """With runtime_ssr=True, root.jsx template contains the SSR loader.""" + result = self._render_root(runtime_ssr=True) + + assert "export async function loader" in result + assert "useLoaderData" in result + assert "getBackendURL" in result + assert "ssrState" in result + assert "SSR_DATA" in result + + def test_root_template_loader_checks_shell_gen_header(self): + """The SSR loader short-circuits for shell generation requests.""" + result = self._render_root(runtime_ssr=True) + + assert "x-reflex-shell-gen" in result + assert "state: null" in result + # isbot check should NOT be in the loader — bot routing is in ssr-serve.js. + assert "isbot" not in result + + def test_root_template_no_loader_when_ssr_false(self): + """With runtime_ssr=False, root.jsx template has no SSR loader.""" + result = self._render_root(runtime_ssr=False) + + assert "export async function loader" not in result + assert "useLoaderData" not in result + assert "getBackendURL" not in result + assert "ssrState" not in result + + def test_context_template_has_ssr_context_when_ssr_true(self): + """With runtime_ssr=True, context.js has SSRContext and ssrHydrated.""" + from reflex.compiler.templates import context_template + + result = context_template( + initial_state={"test_state": {"field_rx_state_": "value"}}, + state_name="test_state", + client_storage=None, + is_dev_mode=False, + default_color_mode='"system"', + runtime_ssr=True, + ) + + assert "SSRContext" in result + assert "ssrHydrated" in result + assert "ssrState = null" in result + assert "SSRContext.Provider" in result + + def test_context_template_no_ssr_context_when_ssr_false(self): + """With runtime_ssr=False, context.js has no SSR-related code.""" + from reflex.compiler.templates import context_template + + result = context_template( + initial_state={"test_state": {"field_rx_state_": "value"}}, + state_name="test_state", + client_storage=None, + is_dev_mode=False, + default_color_mode='"system"', + runtime_ssr=False, + ) + + assert "SSRContext" not in result + assert "ssrHydrated" not in result + assert "ssrState" not in result + + def test_context_template_ssr_reducer_uses_ssr_state(self): + """With runtime_ssr=True, useReducer initializers check ssrState.""" + from reflex.compiler.templates import context_template + + result = context_template( + initial_state={"my_app.state": {"count_rx_state_": 0}}, + state_name="my_app.state", + client_storage=None, + is_dev_mode=False, + default_color_mode='"system"', + runtime_ssr=True, + ) + + # The SSR-aware reducer initialization pattern. + assert 'ssrState !== null && ssrState["my_app.state"]' in result + + def test_context_template_static_reducer_no_ssr_state(self): + """With runtime_ssr=False, useReducer uses initialState directly.""" + from reflex.compiler.templates import context_template + + result = context_template( + initial_state={"my_app.state": {"count_rx_state_": 0}}, + state_name="my_app.state", + client_storage=None, + is_dev_mode=False, + default_color_mode='"system"', + runtime_ssr=False, + ) + + assert 'useReducer(applyDelta, initialState["my_app.state"]' in result + assert "ssrState" not in result + + +class TestExtractRouteParams: + """Tests for the extract_route_params utility function.""" + + def test_simple_dynamic_route(self): + """Single dynamic segment is extracted correctly.""" + from reflex.route import extract_route_params + + result = extract_route_params("/blog/hello-world", "blog/[slug]") + assert result == {"slug": "hello-world"} + + def test_multiple_dynamic_segments(self): + """Multiple dynamic segments are extracted correctly.""" + from reflex.route import extract_route_params + + result = extract_route_params("/users/42/posts/99", "users/[id]/posts/[pid]") + assert result == {"id": "42", "pid": "99"} + + def test_no_dynamic_segments(self): + """Static route returns empty dict.""" + from reflex.route import extract_route_params + + result = extract_route_params("/about", "about") + assert result == {} + + def test_root_path(self): + """Root path with no segments returns empty dict.""" + from reflex.route import extract_route_params + + result = extract_route_params("/", "/") + assert result == {} + + def test_leading_slash_handling(self): + """Leading slashes on both path and route are handled.""" + from reflex.route import extract_route_params + + result = extract_route_params("/blog/my-post", "/blog/[slug]") + assert result == {"slug": "my-post"} + + def test_optional_segment(self): + """Optional dynamic segment ([[param]]) is extracted.""" + from reflex.route import extract_route_params + + result = extract_route_params("/docs/intro", "docs/[[section]]") + assert result == {"section": "intro"} + + def test_no_match_shorter_path(self): + """When path has fewer segments than route, missing params are skipped.""" + from reflex.route import extract_route_params + + result = extract_route_params("/blog", "blog/[slug]") + assert result == {} + + def test_preserves_special_characters_in_value(self): + """Values with hyphens and other URL-safe chars are preserved.""" + from reflex.route import extract_route_params + + result = extract_route_params("/blog/my-great-post-2024", "blog/[slug]") + assert result == {"slug": "my-great-post-2024"} diff --git a/tests/units/test_ssr_data.py b/tests/units/test_ssr_data.py new file mode 100644 index 00000000000..1d4788b2a78 --- /dev/null +++ b/tests/units/test_ssr_data.py @@ -0,0 +1,306 @@ +"""Unit tests for the /_ssr_data endpoint handler.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest +from starlette.responses import Response + +import reflex as rx +from reflex.app import App, ssr_data +from reflex.state import State, all_base_state_classes + + +@pytest.fixture(autouse=True, scope="module") +def _clean_state_subclasses(): + """Snapshot and restore State subclass registrations after all tests. + + Tests in this module define rx.State subclasses inside test functions, + which permanently registers them in the global class hierarchy. Without + cleanup, these leak into later test modules (e.g. test_state.py) and + cause failures. + """ + orig_subclasses = State.class_subclasses.copy() + orig_all = all_base_state_classes.copy() + orig_dirty = State._potentially_dirty_states.copy() + orig_always_dirty = State._always_dirty_substates.copy() + orig_var_deps = State._var_dependencies.copy() + + yield + + State.class_subclasses = orig_subclasses + State._potentially_dirty_states = orig_dirty + State._always_dirty_substates = orig_always_dirty + State._var_dependencies = orig_var_deps + all_base_state_classes.clear() + all_base_state_classes.update(orig_all) + State.get_class_substate.cache_clear() + + +def _make_request(path: str = "/", headers: dict | None = None) -> Mock: + """Create a mock Starlette Request with the given path and headers. + + Args: + path: The URL path to include in the request body. + headers: Optional headers dict to include in the request body. + + Returns: + A mock Request object. + """ + body = {"path": path, "headers": headers or {}} + request = Mock() + request.json = AsyncMock(return_value=body) + request.client = Mock() + request.client.host = "127.0.0.1" + return request + + +def _parse_response(response: Response) -> dict[str, Any]: + """Parse a Starlette Response body as JSON. + + Args: + response: The response to parse. + + Returns: + Parsed JSON as a dict. + """ + assert isinstance(response.body, bytes) + return json.loads(response.body) + + +@pytest.mark.asyncio +async def test_ssr_data_no_state(): + """When the app has no state, the endpoint returns null state.""" + app = App(enable_state=False) + app.add_page(lambda: rx.text("hello"), route="/") + handler = ssr_data(app) + + response = await handler(_make_request("/")) + + assert response.status_code == 200 + data = _parse_response(response) + assert data["state"] is None + + +@pytest.mark.asyncio +async def test_ssr_data_basic_state(): + """The endpoint returns serialized state for a basic stateful app.""" + + class BasicState(rx.State): + title: str = "default" + + app = App() + app._state = BasicState + app.add_page(lambda: rx.text(BasicState.title), route="/") + + handler = ssr_data(app) + response = await handler(_make_request("/")) + + assert response.status_code == 200 + data = _parse_response(response) + assert data["state"] is not None + + # The root State should be present. + root_name = rx.State.get_full_name() + assert root_name in data["state"] + + # The user's substate should also be present with the default value. + substate_name = BasicState.get_full_name() + assert substate_name in data["state"] + assert data["state"][substate_name]["title_rx_state_"] == "default" + + +@pytest.mark.asyncio +async def test_ssr_data_dynamic_route_params(): + """Route params are extracted from the URL path and set on the state.""" + + class PostState(rx.State): + pass + + app = App() + app._state = PostState + app.add_page(lambda: rx.text("post"), route="/blog/[slug]") + + handler = ssr_data(app) + response = await handler(_make_request("/blog/hello-world")) + + assert response.status_code == 200 + data = _parse_response(response) + root_name = rx.State.get_full_name() + router = data["state"][root_name]["router_rx_state_"] + assert router["page"]["params"] == {"slug": "hello-world"} + assert router["page"]["raw_path"] == "/blog/hello-world" + + +@pytest.mark.asyncio +async def test_ssr_data_on_load_runs(): + """The on_load handler runs and mutates state before serialization.""" + + class LoadState(rx.State): + title: str = "" + + @rx.event + def on_load_post(self): + self.title = "loaded" + + app = App() + app._state = LoadState + app.add_page( + lambda: rx.text(LoadState.title), + route="/page", + on_load=LoadState.on_load_post, + ) + + handler = ssr_data(app) + response = await handler(_make_request("/page")) + + assert response.status_code == 200 + data = _parse_response(response) + substate_name = LoadState.get_full_name() + assert data["state"][substate_name]["title_rx_state_"] == "loaded" + + +@pytest.mark.asyncio +async def test_ssr_data_async_on_load(): + """An async on_load handler is properly awaited.""" + + class AsyncLoadState(rx.State): + message: str = "" + + @rx.event + async def load_data(self): + self.message = "async-loaded" + + app = App() + app._state = AsyncLoadState + app.add_page( + lambda: rx.text(AsyncLoadState.message), + route="/async", + on_load=AsyncLoadState.load_data, + ) + + handler = ssr_data(app) + response = await handler(_make_request("/async")) + + assert response.status_code == 200 + data = _parse_response(response) + substate_name = AsyncLoadState.get_full_name() + assert data["state"][substate_name]["message_rx_state_"] == "async-loaded" + + +@pytest.mark.asyncio +async def test_ssr_data_on_load_error_graceful(): + """If on_load raises, the endpoint returns state with defaults (no crash).""" + + class ErrorState(rx.State): + value: str = "untouched" + + @rx.event + def bad_handler(self): + msg = "boom" + raise RuntimeError(msg) + + app = App() + app._state = ErrorState + app.add_page( + lambda: rx.text(ErrorState.value), + route="/error", + on_load=ErrorState.bad_handler, + ) + + handler = ssr_data(app) + response = await handler(_make_request("/error")) + + assert response.status_code == 200 + data = _parse_response(response) + substate_name = ErrorState.get_full_name() + # State should still be returned with original defaults. + assert data["state"][substate_name]["value_rx_state_"] == "untouched" + + +@pytest.mark.asyncio +async def test_ssr_data_headers_forwarded(): + """Request headers are set on the state's router headers.""" + + class HeaderState(rx.State): + pass + + app = App() + app._state = HeaderState + app.add_page(lambda: rx.text("h"), route="/") + + handler = ssr_data(app) + response = await handler( + _make_request( + "/", headers={"user-agent": "Googlebot", "origin": "https://example.com"} + ) + ) + + assert response.status_code == 200 + data = _parse_response(response) + root_name = rx.State.get_full_name() + router = data["state"][root_name]["router_rx_state_"] + assert router["headers"]["user_agent"] == "Googlebot" + + +@pytest.mark.asyncio +async def test_ssr_data_unknown_route(): + """An unknown path resolves to the 404 route.""" + + class NotFoundState(rx.State): + pass + + app = App() + app._state = NotFoundState + app.add_page(lambda: rx.text("home"), route="/") + + handler = ssr_data(app) + response = await handler(_make_request("/this/does/not/exist")) + + assert response.status_code == 200 + data = _parse_response(response) + # Should still return valid state (the 404 handler path). + assert data["state"] is not None + + +@pytest.mark.asyncio +async def test_ssr_data_cache_control_header(): + """The response includes Cache-Control: no-cache.""" + + class CacheState(rx.State): + pass + + app = App() + app._state = CacheState + app.add_page(lambda: rx.text("c"), route="/") + + handler = ssr_data(app) + response = await handler(_make_request("/")) + + assert response.headers["cache-control"] == "no-cache" + + +@pytest.mark.asyncio +async def test_ssr_data_client_ip(): + """The client IP from the request is set in the state.""" + + class IpState(rx.State): + pass + + app = App() + app._state = IpState + app.add_page(lambda: rx.text("ip"), route="/") + + handler = ssr_data(app) + request = _make_request("/") + request.client.host = "10.0.0.42" + response = await handler(request) + + assert response.status_code == 200 + data = _parse_response(response) + root_name = rx.State.get_full_name() + router = data["state"][root_name]["router_rx_state_"] + assert router["session"]["client_ip"] == "10.0.0.42"