diff --git a/news/+ssr-isr.feature.md b/news/+ssr-isr.feature.md new file mode 100644 index 00000000000..f26d1621207 --- /dev/null +++ b/news/+ssr-isr.feature.md @@ -0,0 +1 @@ +Add experimental, default-off server-side rendering: setting the new `ssr_mode` config option (`rx.SsrMode`) adds an `/_ssr_data` endpoint that runs a page's `on_load` handlers, so server-rendered pages contain their data. The new `reflex.isr` page server (`granian --factory reflex.isr:create_isr_app`) caches those pages in Redis with stale-while-revalidate, and `reflex.isr.revalidate_path`, `reflex.isr.revalidate_tag` or `POST /_isr/revalidate` invalidate them on demand. Pages are cached per path without the query string; a page's `on_load` fills in query-dependent content after hydration. diff --git a/packages/reflex-base/news/+ssr-isr.feature.md b/packages/reflex-base/news/+ssr-isr.feature.md new file mode 100644 index 00000000000..05cdd375d25 --- /dev/null +++ b/packages/reflex-base/news/+ssr-isr.feature.md @@ -0,0 +1 @@ +Add the experimental, default-off `ssr_mode` config option with the frontend templates that render pages on the server, and the `isr_render_url`, `isr_revalidate` and `isr_build_id` options that configure incremental static regeneration, which renders and caches pages per path without the query string. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs b/packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs new file mode 100644 index 00000000000..b7754d33801 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs @@ -0,0 +1,109 @@ +/** + * ISR render tier for Reflex. + * + * A small HTTP service that renders the react-router **server bundle** to HTML + * for a given route, so the Python ISR page-server (reflex.isr.HttpRenderer) + * can cache and serve it. This is the one component that must run JavaScript; + * it is deployed as its own service (off the request hot path, scale-to-zero + * friendly) rather than in every frontend pod. + * + * Contract (matches reflex.isr.HttpRenderer): + * POST / { "path": "/blog/hello" } + * 200 -> { "html": "…", "revalidate"?: number, "tags"?: string[] } + * non-200 -> ISR treats it as a render failure (serves stale/shell) + * + * Requirements: + * - An SSR build of the app (react-router build with `ssr: true`) producing + * `build/server/index.js`. The app root `loader()` fetches page state from + * the Python `/_ssr_data` endpoint, so this service needs network access to + * the backend (set SSR_DATA / the backend URL in the app env). + * - Node deps `react-router` and `express`, which the SSR build installs. + * + * Run: node isr-render.mjs (listens on $PORT, default 8600) + * + * Optional per-page cache hints: the app may set response headers + * `X-Reflex-Revalidate: ` and `X-Reflex-Tags: a,b,c` + * which are forwarded to the ISR cache; otherwise Python applies its defaults. + */ +import { createRequestHandler } from "react-router"; +import express from "express"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = + import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); + +const serverBuild = join(__dirname, "build", "server", "index.js"); +const build = await import(serverBuild); +const handler = createRequestHandler(build, "production"); + +const app = express(); +app.use(express.json({ limit: "1mb" })); + +// Health endpoint for k8s probes. +app.get("/_health", (_req, res) => res.status(200).send("OK")); + +app.post("/", async (req, res) => { + const path = + typeof req.body?.path === "string" && req.body.path ? req.body.path : "/"; + + try { + // Render the target route through react-router as a normal GET document + // request. The app's loader runs here and pulls state from /_ssr_data. + const url = new URL(path, "http://reflex-isr-render.local"); + const request = new Request(url, { + method: "GET", + headers: { + "User-Agent": "ReflexISRRenderer/1.0", + Accept: "text/html", + // Forward the caller's cookies so authenticated/personalized loaders + // resolve the same way they would for the original visitor, if the + // page-server chooses to pass them along. + ...(req.body?.cookie ? { Cookie: String(req.body.cookie) } : {}), + }, + }); + + const response = await handler(request); + const html = await response.text(); + + if (!response.ok || !html) { + return res + .status(502) + .json({ error: "render_failed", status: response.status }); + } + + const revalidate = headerNumber(response, "x-reflex-revalidate"); + const tags = headerList(response, "x-reflex-tags"); + + return res.json({ + html, + ...(revalidate != null ? { revalidate } : {}), + ...(tags.length ? { tags } : {}), + }); + } catch (err) { + console.error("[isr-render] render error for", path, err); + return res.status(500).json({ error: String(err) }); + } +}); + +function headerNumber(response, name) { + const raw = response.headers.get(name); + if (raw == null) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + +function headerList(response, name) { + const raw = response.headers.get(name); + return raw + ? raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; +} + +const port = parseInt(process.env.PORT || "8600", 10); +app.listen(port, () => { + console.log(`[isr-render] listening on http://localhost:${port}`); +}); diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 38072d97b53..14d8cf32ed2 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -7,7 +7,7 @@ from collections.abc import Iterable, Mapping from typing import TYPE_CHECKING, Any, Literal -from reflex_base import constants +from reflex_base import constants, ssr from reflex_base.utils import memo_paths from reflex_base.utils.format import json_dumps from reflex_base.vars.base import VarData @@ -177,6 +177,50 @@ def app_root_template( f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries ]) + if ssr.is_enabled(): + ssr_imports = ( + 'import { useLoaderData } from "react-router";\n' + 'import { getBackendURL } from "$/utils/state";\n' + 'import { SSRStateContext } from "$/utils/context";\n' + 'import env from "$/env.json";' + ) + ssr_loader = """ +export async function loader({ request }) { + // Fetch on_load-hydrated state from the Python /_ssr_data endpoint so the + // server-rendered HTML already contains real data. + try { + const url = new URL(request.url); + const res = await fetch(getBackendURL(env.SSR_DATA), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(request.headers.get("cookie") ? { Cookie: request.headers.get("cookie") } : {}), + }, + body: JSON.stringify({ + path: url.pathname, + search: url.search, + headers: Object.fromEntries(request.headers), + }), + }); + if (res.ok) return res.json(); + } catch (e) { + console.error("SSR data fetch failed:", e); + } + return { state: null }; +} +""" + layout_body = ( + "const loaderData = useLoaderData();\n" + " const ssrState = loaderData?.state ?? null;\n" + " return jsx(AppLayout, {}, " + "jsx(SSRStateContext.Provider, {value: ssrState}, " + "jsx(ReflexProviders, {}, children)));" + ) + else: + ssr_imports = "" + ssr_loader = "" + layout_body = "return jsx(AppLayout, {}, jsx(ReflexProviders, {}, children));" + window_imports_effect = f"""useEffect(() => {{ // Make contexts and state objects available globally for dynamic eval'd components window.__reflex = {{ @@ -222,11 +266,12 @@ def app_root_template( import {{ ThemeProvider }} from '$/utils/react-theme'; import {{ Layout as AppLayout }} from './_document'; import {{ Outlet }} from 'react-router'; +{ssr_imports} {import_window_libraries} {lazy_imports_setup} {custom_code_str} - +{ssr_loader} function ReflexProviders({{children}}) {{ {window_imports_effect} @@ -242,7 +287,7 @@ def app_root_template( }} export function Layout({{children}}) {{ - return jsx(AppLayout, {{}}, jsx(ReflexProviders, {{}}, children)); + {layout_body} }} // Used by entry.client.js when mount_target is configured: skips the document @@ -306,6 +351,7 @@ def context_template( Returns: Rendered context file content as string. """ + ssr_enabled = ssr.is_enabled() initial_state = initial_state or {} if initial_state_json is None: initial_state_json = json_dumps(initial_state) @@ -375,6 +421,26 @@ def context_template( """ ) + if ssr_enabled: + # The store may start from SSR state, so the first hydrate needs it all. + initial_state_hashes = None + ssr_context_str = "export const SSRStateContext = createContext(null);" + ssr_state_hook = "const ssrState = useContext(SSRStateContext);" + # Server-rendered substates take precedence over the compiled defaults. + client_initial_state = ( + "ssrState ? Object.fromEntries(Object.entries(initialState).map(" + "([name, value]) => [name, ssrState[name] ?? value])) : initialState" + ) + server_state_store = ( + "ssrState ? { ...serverStateStore, getSnapshot: " + "(name) => ssrState[name] ?? initialState[name] } : serverStateStore" + ) + else: + ssr_context_str = "" + ssr_state_hook = "" + client_initial_state = "initialState" + server_state_store = "serverStateStore" + disable_owner_stacks_str = ( r""" // Disable React dev-build owner-stack capture: the per-element Error() @@ -403,7 +469,7 @@ def context_template( else "" ) - return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useState, createElement, useEffect }} from "react" + return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ {"createContext, " if ssr_enabled 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"; @@ -414,6 +480,7 @@ def context_template( export const stateSchema = {json.dumps(state_schema)} export const defaultColorMode = {default_color_mode} +{ssr_context_str} export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)} {state_str} @@ -555,7 +622,8 @@ def context_template( }} function ClientStateProvider({{ children }}) {{ - const [store] = useState(() => createStateStore(initialState)); + {ssr_state_hook} + const [store] = useState(() => createStateStore({client_initial_state})); useEffect(() => store.mergeInitialState(initialState), [store, initialState]); return createElement(StateStoreProviders, {{ children, store }}); }} @@ -569,7 +637,8 @@ def context_template( }}; function ServerStateProvider({{ children }}) {{ - return createElement(StateStoreProviders, {{ children, store: serverStateStore }}); + {ssr_state_hook} + return createElement(StateStoreProviders, {{ children, store: {server_state_store} }}); }} export const StateProvider = diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index c3861dc4c3d..2a461c37e75 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -194,6 +194,11 @@ 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. + ssr_mode: Server-side rendering mode ("off" or "always"). + isr_render_url: ISR render-tier URL; when set, enables ISR page serving. + Pages are rendered and cached per path, without the query string. + isr_revalidate: Default seconds until an ISR-cached page goes stale. + isr_build_id: Identifier that rotates the ISR cache on each deploy. """ app_name: str @@ -285,6 +290,22 @@ class BaseConfig: transport: Literal["websocket", "polling"] = "websocket" + # Server-side rendering mode: "off" (default) or "always". + ssr_mode: constants.SsrMode = constants.SsrMode.OFF + + # ISR render-tier URL. When set, the ISR page-server regenerates pages by + # POSTing paths here; when None, ISR is disabled. Pages are rendered and + # cached per path without the query string; the page's on_load fills in + # query-dependent content after hydration. + isr_render_url: str | None = None + + # Default seconds until an ISR-cached page is considered stale. + isr_revalidate: int = 60 + + # Identifier that rotates the ISR cache on each deploy. Defaults at runtime + # to the REFLEX_BUILD_ID env var (see reflex.isr.get_build_id). + isr_build_id: str | None = None + # Whether to skip plugin checks. _skip_plugins_checks: bool = dataclasses.field(default=False, repr=False) diff --git a/packages/reflex-base/src/reflex_base/constants/__init__.py b/packages/reflex-base/src/reflex_base/constants/__init__.py index f2b12a4829c..3035704171b 100644 --- a/packages/reflex-base/src/reflex_base/constants/__init__.py +++ b/packages/reflex-base/src/reflex_base/constants/__init__.py @@ -25,6 +25,7 @@ Reflex, ReflexHostingCLI, RunningMode, + SsrMode, Templates, ) from .compiler import ( @@ -140,6 +141,7 @@ "RouteVar", "RunningMode", "SocketEvent", + "SsrMode", "StateManagerMode", "Templates", "UvLock", diff --git a/packages/reflex-base/src/reflex_base/constants/base.py b/packages/reflex-base/src/reflex_base/constants/base.py index c56e0c2be8e..dc28be634b6 100644 --- a/packages/reflex-base/src/reflex_base/constants/base.py +++ b/packages/reflex-base/src/reflex_base/constants/base.py @@ -171,8 +171,8 @@ class ReactRouter(Javascript): 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})(.*)" @@ -210,6 +210,17 @@ class Env(str, Enum): PROD = "prod" +class SsrMode(str, Enum): + """Server-side rendering modes. + + OFF: No SSR — static SPA (default, same as before). + ALWAYS: SSR for all users — better FCP/LCP, higher server load. + """ + + OFF = "off" + ALWAYS = "always" + + class RunningMode(str, Enum): """The running modes.""" diff --git a/packages/reflex-base/src/reflex_base/constants/event.py b/packages/reflex-base/src/reflex_base/constants/event.py index daa4a696128..50925849fa2 100644 --- a/packages/reflex-base/src/reflex_base/constants/event.py +++ b/packages/reflex-base/src/reflex_base/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/packages/reflex-base/src/reflex_base/constants/installer.py b/packages/reflex-base/src/reflex_base/constants/installer.py index f62fd3e7ebb..e42edd8befd 100644 --- a/packages/reflex-base/src/reflex_base/constants/installer.py +++ b/packages/reflex-base/src/reflex_base/constants/installer.py @@ -168,6 +168,11 @@ def DEPENDENCIES(cls) -> dict[str, str]: "babel-plugin-react-compiler": "1.0.0", } + # Imported by the ISR render service (isr-render.mjs); installed by SSR builds. + ISR_RENDER_DEPENDENCIES = { + "express": "5.3.0", + } + # Force specific transitive npm deps to a single resolved version when # needed. Prefer a `DEV_DEPENDENCIES`/`DEPENDENCIES` pin when the package is # one we depend on directly: a top-level pin already satisfies and dedupes diff --git a/packages/reflex-base/src/reflex_base/ssr.py b/packages/reflex-base/src/reflex_base/ssr.py new file mode 100644 index 00000000000..6875c3b8c0b --- /dev/null +++ b/packages/reflex-base/src/reflex_base/ssr.py @@ -0,0 +1,46 @@ +"""Server-side rendering (SSR) config gate for the base package. + +The request-time backend (the ``/_ssr_data`` endpoint) lives in ``reflex.ssr`` +because it depends on the state/app machinery that is not part of +``reflex_base``. Only the config gate lives here so ``reflex_base`` code can +check whether SSR is enabled without importing the top-level ``reflex`` package. + +SSR is a no-op unless ``config.ssr_mode`` is not ``OFF``. +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from reflex_base import constants +from reflex_base.config import get_config + +if TYPE_CHECKING: + from reflex_base.config import Config + + +def is_enabled(config: Config | None = None) -> bool: + """Whether SSR is enabled. + + Args: + config: The config to read from, or the global config when omitted. + + Returns: + True when ``config.ssr_mode`` is not ``OFF``. + """ + config = config if config is not None else get_config() + return config.ssr_mode != constants.SsrMode.OFF + + +def ssr_build_enabled() -> bool: + """Whether to emit an ``ssr: true`` react-router build. + + Controlled by the ``REFLEX_SSR_BUILD`` env var so the same app can produce + both the default static SPA build (served by nginx) and a server-render + build (consumed by the ISR render tier) from separate build invocations. + + Returns: + True when ``REFLEX_SSR_BUILD`` is set to ``1``. + """ + return os.environ.get("REFLEX_SSR_BUILD") == "1" diff --git a/pyi_hashes.json b/pyi_hashes.json index 9df583b7fa1..bf8a95c34c6 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "f2a9cf6db58d169289fbc76de6813300", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", - "reflex/__init__.pyi": "23c090b0294d3a91e59f447f36a7a37f", + "reflex/__init__.pyi": "71f87df2f8f217bea53c57b025bc675b", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", "reflex/experimental/memo.pyi": "87e485038dc79f66b2ab15144845ef1e" } diff --git a/reflex/__init__.py b/reflex/__init__.py index ea1775c275f..1bc00bdf72d 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -170,7 +170,7 @@ "app": ["App", "UploadFile"], "assets": ["asset"], "config": ["Config", "DBConfig"], - "constants": ["Env"], + "constants": ["Env", "SsrMode"], "constants.colors": ["Color"], "_upload": [ "UploadChunk", diff --git a/reflex/app.py b/reflex/app.py index 97d7d7472e5..881352430d6 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -80,6 +80,7 @@ from starlette.staticfiles import StaticFiles from typing_extensions import Unpack +from reflex import ssr from reflex._upload import UploadedFilesHeadersMiddleware, upload from reflex._upload import UploadFile as UploadFile from reflex.admin import AdminDash @@ -883,6 +884,12 @@ def _add_default_endpoints(self): health, methods=["GET"], ) + if ssr.is_enabled(): + self._api.add_route( + config.prepend_backend_path(str(constants.Endpoint.SSR_DATA)), + ssr.ssr_data(self), + methods=["POST"], + ) def _add_optional_endpoints(self): """Add optional api endpoints (_upload).""" diff --git a/reflex/constants/__init__.py b/reflex/constants/__init__.py index 2878a22fa73..bfae304ce8e 100644 --- a/reflex/constants/__init__.py +++ b/reflex/constants/__init__.py @@ -20,6 +20,7 @@ ReactRouter, Reflex, ReflexHostingCLI, + SsrMode, Templates, ) from .compiler import ( @@ -114,6 +115,7 @@ "RouteRegex", "RouteVar", "SocketEvent", + "SsrMode", "StateManagerMode", "Templates", "UvLock", diff --git a/reflex/isr.py b/reflex/isr.py new file mode 100644 index 00000000000..6cb6bdee357 --- /dev/null +++ b/reflex/isr.py @@ -0,0 +1,857 @@ +"""Incremental Static Regeneration (ISR) for Reflex. + +Serves prerendered HTML from a shared cache with stale-while-revalidate (SWR) +semantics and cross-worker single-flight regeneration, so multiple workers +(e.g. Kubernetes replicas) share one cache and never double-render a page. + +Responsibilities split: + +* This module owns **caching, coordination, and revalidation** — pure Python, + no JS runtime. It works across workers when backed by Redis. +* Producing the HTML is delegated to a pluggable :data:`Renderer` (e.g. a Node + render tier that runs the react-router server bundle and fetches state from + the ``/_ssr_data`` endpoint). ISR itself never renders React. + +Cache entries are keyed by ``{build_id}:{path}`` so a new deploy (new build id) +transparently rotates the cache and never serves HTML that references +content-hashed assets from a previous build. ``path`` is the raw request path, +percent-encoding included, without the query string: each page is rendered and +cached per path, and its ``on_load`` fills in query-dependent content after +hydration. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import dataclasses +import json +import os +import time +import traceback +import uuid +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +import httpx +from reflex_base import constants +from reflex_base.config import Config, get_config +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, Response +from starlette.routing import Route + +from reflex.utils import console, prerequisites + +if TYPE_CHECKING: + from redis.asyncio import Redis + + +@dataclasses.dataclass(frozen=True) +class RenderResult: + """The result of rendering a page. + + Attributes: + html: The rendered HTML document. + revalidate: Seconds until the entry goes stale (``None`` = use default). + tags: Revalidation tags for on-demand invalidation of related pages. + """ + + html: str + revalidate: float | None = None + tags: tuple[str, ...] = () + + +# A Renderer turns a raw request path (percent-encoded, no query string) into a +# RenderResult, or None on failure (ISR then serves stale/fallback rather than +# caching an error). +Renderer = Callable[[str], Awaitable["RenderResult | None"]] + + +@dataclasses.dataclass(frozen=True) +class CachedPage: + """A cached rendered page. + + Attributes: + html: The rendered HTML document. + generated_at: Unix timestamp when the page was rendered. + revalidate: Seconds until the page is considered stale (``<= 0`` never). + tags: Revalidation tags associated with the page. + """ + + html: str + generated_at: float + revalidate: float + tags: tuple[str, ...] = () + + def is_stale(self, now: float | None = None) -> bool: + """Whether the page is past its revalidation window. + + Args: + now: The current time (defaults to ``time.time()``). + + Returns: + True if the page should be revalidated. + """ + if self.revalidate <= 0: + return False + now = time.time() if now is None else now + return (now - self.generated_at) >= self.revalidate + + def to_json(self) -> str: + """Serialize the page for storage. + + Returns: + A JSON string. + """ + return json.dumps(dataclasses.asdict(self)) + + @classmethod + def from_json(cls, raw: str | bytes) -> CachedPage: + """Deserialize a page from storage. + + Args: + raw: The JSON string/bytes produced by :meth:`to_json`. + + Returns: + The reconstructed page. + """ + data = json.loads(raw) + return cls( + html=data["html"], + generated_at=data["generated_at"], + revalidate=data["revalidate"], + tags=tuple(data.get("tags", ())), + ) + + +@runtime_checkable +class ISRCache(Protocol): + """Shared cache + coordination backend for ISR. + + A Redis-backed implementation makes the cache, single-flight lock, and tag + index shared across all workers; the in-memory implementation is per-process + (dev/test only). + """ + + async def get(self, key: str) -> CachedPage | None: + """Get a cached page. + + Args: + key: The cache key. + + Returns: + The cached page, or None if absent. + """ + ... + + async def set(self, key: str, page: CachedPage) -> None: + """Store a page and index its tags. + + Args: + key: The cache key. + page: The page to store. + """ + ... + + async def delete(self, key: str) -> None: + """Remove a cached page. + + Args: + key: The cache key. + """ + ... + + async def acquire_lock(self, key: str, ttl: float) -> str | None: + """Try to acquire the single-flight render lock for ``key``. + + Args: + key: The cache key. + ttl: Lock expiry in seconds. + + Returns: + An owner token if this caller now holds the lock, else None. + """ + ... + + async def release_lock(self, key: str, token: str) -> None: + """Release the single-flight render lock for ``key`` if ``token`` owns it. + + Args: + key: The cache key. + token: The owner token returned by :meth:`acquire_lock`. + """ + ... + + async def invalidate_tag(self, tag: str) -> int: + """Delete every cached page carrying ``tag``. + + Args: + tag: The tag to invalidate. + + Returns: + The number of pages removed. + """ + ... + + +class MemoryISRCache: + """In-process ISR cache (single worker; dev/test only). + + Not shared across workers — use :class:`RedisISRCache` in production. + """ + + def __init__(self) -> None: + """Initialize empty stores.""" + self._pages: dict[str, CachedPage] = {} + self._tags: dict[str, set[str]] = {} + self._locks: dict[str, str] = {} + + async def get(self, key: str) -> CachedPage | None: + """Get a cached page. + + Args: + key: The cache key. + + Returns: + The cached page, or None. + """ + return self._pages.get(key) + + async def set(self, key: str, page: CachedPage) -> None: + """Store a page and index its tags. + + Args: + key: The cache key. + page: The page to store. + """ + self._pages[key] = page + for tag in page.tags: + self._tags.setdefault(tag, set()).add(key) + + async def delete(self, key: str) -> None: + """Remove a cached page. + + Args: + key: The cache key. + """ + self._pages.pop(key, None) + + async def acquire_lock(self, key: str, ttl: float) -> str | None: + """Acquire the render lock (no TTL needed in-process). + + Args: + key: The cache key. + ttl: Unused for the in-memory backend. + + Returns: + An owner token if the lock was acquired, else None. + """ + if key in self._locks: + return None + token = self._locks[key] = uuid.uuid4().hex + return token + + async def release_lock(self, key: str, token: str) -> None: + """Release the render lock if ``token`` owns it. + + Args: + key: The cache key. + token: The owner token returned by :meth:`acquire_lock`. + """ + if self._locks.get(key) == token: + del self._locks[key] + + async def invalidate_tag(self, tag: str) -> int: + """Delete all pages carrying ``tag``. + + Args: + tag: The tag to invalidate. + + Returns: + The number of pages removed. + """ + keys = self._tags.pop(tag, set()) + for key in keys: + self._pages.pop(key, None) + return len(keys) + + +# Deletes the lock in KEYS[1] only while ARGV[1] still owns it, so a worker +# whose lock expired cannot release the lock another worker acquired since. +_RELEASE_LOCK_SCRIPT = """ +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0 +""" + + +class RedisISRCache: + """Redis-backed ISR cache shared across all workers.""" + + _PAGE_PREFIX = "reflex_isr:page:" + _LOCK_PREFIX = "reflex_isr:lock:" + _TAG_PREFIX = "reflex_isr:tag:" + + def __init__(self, redis: Redis) -> None: + """Store the async Redis client. + + Args: + redis: An async Redis client. + """ + self._redis = redis + + async def get(self, key: str) -> CachedPage | None: + """Get a cached page. + + Args: + key: The cache key. + + Returns: + The cached page, or None. + """ + raw = await self._redis.get(self._PAGE_PREFIX + key) + return CachedPage.from_json(raw) if raw is not None else None + + async def set(self, key: str, page: CachedPage) -> None: + """Store a page and index its tags. + + The stored value gets a generous TTL (well past ``revalidate``) so stale + entries remain available for stale-while-revalidate but do not leak + forever. + + Args: + key: The cache key. + page: The page to store. + """ + # Keep stale entries around for SWR: TTL = revalidate window + 24h grace. + ttl = int(page.revalidate) + 86400 if page.revalidate > 0 else None + pipe = self._redis.pipeline() + pipe.set(self._PAGE_PREFIX + key, page.to_json(), ex=ttl) + for tag in page.tags: + pipe.sadd(self._TAG_PREFIX + tag, key) + await pipe.execute() + + async def delete(self, key: str) -> None: + """Remove a cached page. + + Args: + key: The cache key. + """ + await self._redis.delete(self._PAGE_PREFIX + key) + + async def acquire_lock(self, key: str, ttl: float) -> str | None: + """Acquire the single-flight lock via ``SET NX EX``. + + Args: + key: The cache key. + ttl: Lock expiry in seconds (guards against a crashed holder). + + Returns: + An owner token if the lock was acquired, else None. + """ + token = uuid.uuid4().hex + acquired = await self._redis.set( + self._LOCK_PREFIX + key, token, nx=True, ex=max(1, int(ttl)) + ) + return token if acquired else None + + async def release_lock(self, key: str, token: str) -> None: + """Release the single-flight lock if ``token`` still owns it. + + Args: + key: The cache key. + token: The owner token returned by :meth:`acquire_lock`. + """ + await self._redis.eval(_RELEASE_LOCK_SCRIPT, 1, self._LOCK_PREFIX + key, token) # pyright: ignore[reportGeneralTypeIssues] + + async def invalidate_tag(self, tag: str) -> int: + """Delete all pages carrying ``tag``. + + Args: + tag: The tag to invalidate. + + Returns: + The number of pages removed. + """ + tag_key = self._TAG_PREFIX + tag + # redis.asyncio stubs type set-commands with their sync (non-awaitable) + # return type; smembers is awaitable at runtime. + keys = await self._redis.smembers(tag_key) # pyright: ignore[reportGeneralTypeIssues] + if not keys: + return 0 + page_keys = [ + self._PAGE_PREFIX + (k.decode() if isinstance(k, bytes) else k) + for k in keys + ] + await self._redis.delete(*page_keys, tag_key) + return len(page_keys) + + +def cache_key(build_id: str, path: str) -> str: + """Build the cache key for a page. + + Args: + build_id: The current build identifier (rotates the cache per deploy). + path: The request path. + + Returns: + The cache key. + """ + return f"{build_id}:{path}" + + +class ISRManager: + """Coordinates ISR serving: cache lookup, SWR, and single-flight rendering. + + Args: + cache: The shared cache/coordination backend. + renderer: Produces HTML for a path (e.g. calls the Node render tier). + build_id: Identifier that rotates the cache on each deploy. + default_revalidate: Default seconds-until-stale when a render does not + specify its own. + lock_ttl: Max seconds a single-flight render may hold the lock. + wait_timeout: Max seconds a request waits for another worker's in-flight + render on a cache miss before falling back. + """ + + def __init__( + self, + cache: ISRCache, + renderer: Renderer, + *, + build_id: str = "dev", + default_revalidate: float = 60.0, + lock_ttl: float = 30.0, + wait_timeout: float = 5.0, + ) -> None: + """Store configuration.""" + self.cache = cache + self.renderer = renderer + self.build_id = build_id + self.default_revalidate = default_revalidate + self.lock_ttl = lock_ttl + self.wait_timeout = wait_timeout + self._background: set[asyncio.Task] = set() + + async def get_html(self, path: str) -> str | None: + """Return HTML for ``path`` using ISR semantics. + + * Fresh cache hit -> serve immediately. + * Stale hit -> serve stale immediately, revalidate in the background + (single-flight). + * Miss -> single-flight render; losing waiters briefly poll for the + winner's result, then fall back to ``None`` (caller serves the shell). + + Args: + path: The concrete request path. + + Returns: + The HTML string, or None if it could not be produced. + """ + key = cache_key(self.build_id, path) + page = await self.cache.get(key) + + if page is not None and not page.is_stale(): + return page.html + + if page is not None: + # Stale-while-revalidate: serve stale now, refresh in the background. + self._spawn_revalidate(key, path) + return page.html + + # Cache miss: block on a single-flight render. + return await self._render_single_flight(key, path) + + async def _render_single_flight(self, key: str, path: str) -> str | None: + """Render ``path`` once across workers, or wait for the winner. + + Args: + key: The cache key. + path: The request path. + + Returns: + The rendered HTML, or None on failure/timeout. + """ + if (token := await self.cache.acquire_lock(key, self.lock_ttl)) is not None: + try: + return await self._render_and_store(key, path) + finally: + await self.cache.release_lock(key, token) + + # Another worker is rendering; poll briefly for its result. + deadline = time.monotonic() + self.wait_timeout + while time.monotonic() < deadline: + await asyncio.sleep(0.05) + page = await self.cache.get(key) + if page is not None: + return page.html + return None + + async def _render_and_store(self, key: str, path: str) -> str | None: + """Render ``path`` and cache the result. + + Args: + key: The cache key. + path: The request path. + + Returns: + The rendered HTML, or None on failure. + """ + try: + result = await self.renderer(path) + except Exception: + console.warn(f"ISR render failed for {path}: {traceback.format_exc()}") + return None + if result is None: + return None + revalidate = ( + result.revalidate + if result.revalidate is not None + else self.default_revalidate + ) + await self.cache.set( + key, + CachedPage( + html=result.html, + generated_at=time.time(), + revalidate=revalidate, + tags=result.tags, + ), + ) + return result.html + + def _spawn_revalidate(self, key: str, path: str) -> None: + """Schedule a background, single-flight revalidation of ``key``. + + Args: + key: The cache key. + path: The request path. + """ + task = asyncio.create_task(self._revalidate(key, path)) + self._background.add(task) + task.add_done_callback(self._background.discard) + + async def _revalidate(self, key: str, path: str) -> None: + """Re-render and refresh a stale entry if no other worker is doing so. + + Args: + key: The cache key. + path: The request path. + """ + if (token := await self.cache.acquire_lock(key, self.lock_ttl)) is None: + return + try: + await self._render_and_store(key, path) + finally: + await self.cache.release_lock(key, token) + + async def revalidate_path(self, path: str) -> None: + """Invalidate the cached page for a specific path. + + Args: + path: The path to invalidate. + """ + await self.cache.delete(cache_key(self.build_id, path)) + + async def revalidate_tag(self, tag: str) -> int: + """Invalidate every cached page carrying ``tag``. + + Args: + tag: The tag to invalidate. + + Returns: + The number of pages invalidated. + """ + return await self.cache.invalidate_tag(tag) + + +class HttpRenderer: + """Renderer that delegates to a render-tier HTTP service. + + The service receives ``{"path": path}`` and returns + ``{"html": ..., "revalidate"?: number, "tags"?: string[]}``. Any error or + non-200 response yields ``None`` so ISR serves stale/shell instead of + caching a failure. + + Args: + render_url: The render-tier endpoint URL. + timeout: Per-request timeout in seconds. + """ + + def __init__(self, render_url: str, *, timeout: float = 10.0) -> None: + """Store the render endpoint and timeout.""" + self._url = render_url + self._timeout = timeout + # A single pooled client is created lazily on first use (inside the + # event loop) and reused, so page-server -> render-tier connections stay + # keep-alive instead of a fresh TCP/TLS handshake per render. + self._client: httpx.AsyncClient | None = None + + def _get_client(self) -> httpx.AsyncClient: + """Return the shared client, creating it on first use. + + Returns: + The pooled async HTTP client. + """ + if self._client is None: + self._client = httpx.AsyncClient(timeout=self._timeout) + return self._client + + async def aclose(self) -> None: + """Close the pooled client (call on page-server shutdown).""" + if self._client is not None: + await self._client.aclose() + self._client = None + + async def __call__(self, path: str) -> RenderResult | None: + """Render ``path`` via the render tier. + + Args: + path: The concrete request path. + + Returns: + The render result, or None on failure. + """ + try: + resp = await self._get_client().post(self._url, json={"path": path}) + if resp.status_code != 200: + console.warn(f"ISR render tier returned {resp.status_code} for {path}") + return None + data = resp.json() + except Exception as exc: + console.warn(f"ISR render tier request failed for {path}: {exc}") + return None + + html = data.get("html") + if not html: + return None + return RenderResult( + html=html, + revalidate=data.get("revalidate"), + tags=tuple(data.get("tags", ())), + ) + + +def is_enabled(config: Config | None = None) -> bool: + """Whether ISR is configured (a render tier URL is set). + + Args: + config: The config to read (defaults to the global config). + + Returns: + True when ``config.isr_render_url`` is set. + """ + config = config if config is not None else get_config() + return config.isr_render_url is not None + + +def get_build_id(config: Config | None = None) -> str: + """Resolve the cache-rotating build id. + + Precedence: ``config.isr_build_id`` -> ``REFLEX_BUILD_ID`` env -> ``"dev"``. + + Args: + config: The config to read (defaults to the global config). + + Returns: + The build id. + """ + config = config if config is not None else get_config() + return config.isr_build_id or os.environ.get("REFLEX_BUILD_ID") or "dev" + + +def get_cache() -> ISRCache: + """Build the ISR cache backend: Redis when available, else in-memory. + + Returns: + A shared Redis-backed cache, or an in-process fallback. + """ + redis = prerequisites.get_redis() + if redis is not None: + return RedisISRCache(redis) + console.warn( + "ISR: no redis_url configured; using an in-memory cache that is NOT " + "shared across workers. Set redis_url for multi-worker deployments." + ) + return MemoryISRCache() + + +async def revalidate_path(path: str, *, config: Config | None = None) -> None: + """Invalidate the ISR cache entry for ``path`` from application code. + + Call this from an event handler after content changes so the next request + re-renders the page. It operates on the shared Redis cache, so a single + call propagates to every page-server worker. No-op when no ``redis_url`` + is configured (nothing is shared to invalidate). + + Args: + path: The percent-encoded path to invalidate (e.g. ``/blog/hello-world``). + config: The config to read the build id from (defaults to global). + """ + redis = prerequisites.get_redis() + if redis is not None: + await RedisISRCache(redis).delete(cache_key(get_build_id(config), path)) + + +async def revalidate_tag(tag: str) -> int: + """Invalidate every ISR page carrying ``tag`` from application code. + + Propagates to all page-server workers via the shared Redis cache. + + Args: + tag: The revalidation tag (e.g. ``post-123``). + + Returns: + The number of pages invalidated (0 when no ``redis_url`` is configured). + """ + redis = prerequisites.get_redis() + if redis is None: + return 0 + return await RedisISRCache(redis).invalidate_tag(tag) + + +def create_manager( + config: Config | None = None, + *, + cache: ISRCache | None = None, + renderer: Renderer | None = None, +) -> ISRManager: + """Assemble an :class:`ISRManager` from config (and optional overrides). + + Args: + config: The config to read (defaults to the global config). + cache: Cache backend override (defaults to :func:`get_cache`). + renderer: Renderer override (defaults to an :class:`HttpRenderer`). + + Returns: + A configured ISR manager. + + Raises: + ValueError: If no renderer is given and ``isr_render_url`` is unset. + """ + config = config if config is not None else get_config() + if renderer is None: + if not config.isr_render_url: + msg = "ISR requires config.isr_render_url (or an explicit renderer)." + raise ValueError(msg) + renderer = HttpRenderer(config.isr_render_url) + return ISRManager( + cache if cache is not None else get_cache(), + renderer, + build_id=get_build_id(config), + default_revalidate=float(config.isr_revalidate), + ) + + +def _load_shell() -> str | None: + """Read the SPA shell HTML from the compiled static build, if present. + + Returns: + The shell HTML, or None if it cannot be found. + """ + static_dir = prerequisites.get_web_dir() / constants.Dirs.STATIC + for name in (constants.ReactRouter.SPA_FALLBACK, "index.html"): + candidate = static_dir / name + if candidate.exists(): + return candidate.read_text() + return None + + +def create_isr_app( + manager: ISRManager | None = None, + *, + shell: str | None = None, +) -> Starlette: + """Create the dedicated ISR page-server ASGI app. + + Serves documents via ISR (nginx ``proxy_cache`` is expected in front for + edge caching), falling back to the SPA shell on a miss/render failure, and + exposes ``POST /_isr/revalidate`` for on-demand invalidation. Pages are + rendered and cached per raw request path; the query string is neither + forwarded nor part of the cache key, so the page's ``on_load`` fills in + query-dependent content after hydration. The + revalidation endpoint requires a matching ``X-Reflex-ISR-Token`` header when + the ``REFLEX_ISR_REVALIDATE_TOKEN`` env var is set. + + Run in production as an app factory, e.g. + ``granian --factory reflex.isr:create_isr_app``. + + Args: + manager: The ISR manager (defaults to one built from config). + shell: The SPA shell HTML (defaults to reading the compiled build). + + Returns: + A Starlette app. + """ + mgr = manager if manager is not None else create_manager() + shell_html = shell if shell is not None else _load_shell() + revalidate_token = os.environ.get("REFLEX_ISR_REVALIDATE_TOKEN") + + async def serve(request: Request) -> Response: + """Serve a page via ISR, falling back to the SPA shell. + + Args: + request: The page request. + + Returns: + The rendered page, the shell, or a 404. + """ + # The raw path keeps an encoded slash (``/blog/a%2Fb``) in its segment. + raw_path = request.scope.get("raw_path") + html = await mgr.get_html( + raw_path.decode("latin-1") if raw_path is not None else request.url.path + ) + if html is not None: + return HTMLResponse(html) + if shell_html is not None: + # Miss/failure: serve the SPA shell so the client hydrates. + return HTMLResponse(shell_html) + return Response("Not found", status_code=404) + + async def revalidate(request: Request) -> Response: + """Invalidate the cached pages for the posted path and/or tag. + + Args: + request: The revalidation request. + + Returns: + A JSON summary, or 401 when the token does not match. + """ + if revalidate_token and ( + request.headers.get("x-reflex-isr-token") != revalidate_token + ): + return JSONResponse({"error": "unauthorized"}, status_code=401) + body = await request.json() + if path := body.get("path"): + await mgr.revalidate_path(path) + count = 0 + if tag := body.get("tag"): + count = await mgr.revalidate_tag(tag) + return JSONResponse({"revalidated": True, "tag_pages": count}) + + @contextlib.asynccontextmanager + async def _lifespan(_app: Starlette): + """Close the renderer's pooled HTTP client on shutdown, if any. + + Args: + _app: The page-server app. + + Yields: + Control while the app runs. + """ + try: + yield + finally: + aclose = getattr(mgr.renderer, "aclose", None) + if aclose is not None: + await aclose() + + return Starlette( + routes=[ + Route("/_isr/revalidate", revalidate, methods=["POST"]), + Route("/{path:path}", serve, methods=["GET"]), + ], + lifespan=_lifespan, + ) diff --git a/reflex/route.py b/reflex/route.py index afd2c5fde94..6548f193a7b 100644 --- a/reflex/route.py +++ b/reflex/route.py @@ -5,6 +5,7 @@ import functools import re from collections.abc import Callable +from urllib.parse import unquote from reflex_base import constants @@ -96,6 +97,61 @@ def _add_route_arg(arg_name: str, type_: str): return args +@functools.lru_cache(maxsize=1024) +def _get_route_params_regex(route: str) -> tuple[re.Pattern, dict[str, str]] | None: + """Compile a regex that captures the dynamic arguments of a route. + + Args: + route: The route pattern with brackets (e.g. "blog/[slug]"). + + Returns: + The regex with a named group per argument and the argument types, + or None for a static route. + """ + if not (route_args := get_route_args(route)): + return None + regex_parts = [] + for part in route.strip("/").split("/"): + if part == constants.RouteRegex.SPLAT_CATCHALL: + regex_parts.append("(?:/(?P.*))?") + elif arg := constants.RouteRegex.OPTIONAL_ARG.fullmatch(part): + regex_parts.append(f"(?:/(?P<{arg.group(1)}>[^/]+))?") + elif arg := constants.RouteRegex.ARG.fullmatch(part): + regex_parts.append(f"/(?P<{arg.group(1)}>[^/]*)") + else: + regex_parts.append(re.escape("/" + part)) + return re.compile("".join(regex_parts)), route_args + + +def extract_route_params(path: str, route: str) -> dict[str, str | list[str]]: + """Extract dynamic route parameter values from a concrete path. + + Values are URL-decoded as React Router reports them to the client: an + encoded slash stays inside an argument's value, while the catch-all is + split on every slash. Omitted optional segments are left out. + + 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"}; a catch-all maps to a list of segments. + """ + if (params_regex := _get_route_params_regex(route)) is None: + return {} + regex, route_args = params_regex + if (match := regex.fullmatch("/" + path.strip("/"))) is None: + return {} + return { + name: unquote(value).split("/") + if route_args[name] == constants.RouteArgType.LIST + else unquote(value) + for name, value in match.groupdict().items() + if value + } + + 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/ssr.py b/reflex/ssr.py new file mode 100644 index 00000000000..9cdcdecc859 --- /dev/null +++ b/reflex/ssr.py @@ -0,0 +1,184 @@ +"""Server-side rendering (SSR) backend for Reflex apps. + +This is the request-time half of SSR: the ``/_ssr_data`` endpoint that computes +the hydrated initial state for a route by reusing the regular event machinery. +It lives in the top-level ``reflex`` package because it depends on the state and +app machinery that is not part of ``reflex_base``. + +The config gate (:func:`is_enabled`) and the compiler JS snippets live in +:mod:`reflex_base.ssr` so the compiler templates can use them without importing +``reflex``. ``is_enabled`` is re-exported here for convenience. + +Everything here is a no-op unless ``config.ssr_mode`` is not ``OFF``. +""" + +from __future__ import annotations + +import asyncio +import inspect +import traceback +from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qsl + +from reflex_base import constants +from reflex_base.ssr import is_enabled as is_enabled +from starlette.responses import Response + +from reflex.event import Event +from reflex.istate.data import RouterData +from reflex.route import extract_route_params +from reflex.state import BaseState, State +from reflex.utils import console, format + +if TYPE_CHECKING: + from starlette.requests import Request + + from reflex.app import App + +# Sentinel token/session id used for the stateless SSR render. +SSR_TOKEN = "__ssr__" + + +def _build_router_data( + app: App, path: str, search: str, headers: dict[str, str], client_ip: str +) -> dict[str, Any]: + """Build the ``router_data`` dict for a stateless SSR render. + + Args: + app: The app, used to resolve the concrete path to a route pattern. + path: The concrete request path (e.g. ``/blog/hello-world``). + search: The URL query string (e.g. ``?q=reflex``). + headers: The forwarded request headers. + client_ip: The client IP address. + + Returns: + A ``router_data`` dict with the same shape ``process()`` produces. + """ + resolved_route = app.router(path) or "404" + router_data = { + constants.RouteVar.PATH: "/" + resolved_route.removeprefix("/"), + constants.RouteVar.ORIGIN: path + search, + # Route params take precedence over query params, as on the client. + constants.RouteVar.QUERY: { + **dict(parse_qsl(search.removeprefix("?"), keep_blank_values=True)), + **extract_route_params(path, resolved_route), + }, + constants.RouteVar.CLIENT_TOKEN: SSR_TOKEN, + constants.RouteVar.SESSION_ID: SSR_TOKEN, + constants.RouteVar.HEADERS: { + "origin": headers.get("origin", headers.get("host", "http://localhost")), + **headers, + }, + constants.RouteVar.CLIENT_IP: client_ip, + } + router_data[constants.RouteVar.QUERY] = format.format_query_params(router_data) + return router_data + + +async def _run_on_load_event(state: BaseState, event: Event, path: str) -> None: + """Run a single resolved on_load event on the ephemeral SSR state. + + Resolves the handler's target substate from ``event.name`` and invokes it + directly (rather than going through the session-managed event queue, which + needs a real client token and state manager). Handlers mutate the state in + place; their return value is consumed but discarded since the whole tree is + serialized later. + + Args: + state: The ephemeral root state instance. + event: The resolved on_load event (``event.name`` is the dotted handler). + path: The URL path (for error logging). + """ + try: + # e.g. "reflex___state____state.blog_state.on_load" -> substate + method. + *substate_path, method = event.name.split(".") + substate = state.get_substate(substate_path) + handler = substate.event_handlers[method] + + result = handler.fn(substate, **event.payload) + if asyncio.iscoroutine(result): + result = await result + if inspect.isgenerator(result): + for _ in result: + pass + elif inspect.isasyncgen(result): + async for _ in result: + pass + except Exception: + console.warn(f"SSR on_load handler failed for {path}: {traceback.format_exc()}") + + +async def _run_on_load_events(app: App, state: BaseState, path: str) -> None: + """Run the route's on_load handlers on the ephemeral SSR state. + + Args: + app: The app to get load events from. + state: The ephemeral root state instance. + path: The URL path (for error logging). + """ + load_events = app.get_load_events(path) + if not load_events: + return + for event in Event.from_event_type(load_events, router_data=state.router_data): + await _run_on_load_event(state, event, path) + + +def ssr_data(app: App): + """Build the ``/_ssr_data`` endpoint handler. + + The handler creates an ephemeral state, applies route data, runs on_load + handlers, and returns the serialized state tree for server-side rendering. + + Args: + app: The app to get SSR data for. + + Returns: + The SSR data request handler. + """ + + 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", "/") + search = body.get("search", "") + headers = body.get("headers", {}) + + if not app._state: + return Response( + content='{"state": null}', + media_type="application/json", + ) + + # Ephemeral root state — no persistent session is created. Use State + # (root) rather than app._state which may be a subclass whose inherited + # vars can't be set without a parent. + state = State(_reflex_internal_init=True) # pyright: ignore[reportCallIssue] + + router_data = _build_router_data( + app, + path, + search, + headers, + request.client.host if request.client else "0.0.0.0", + ) + # Assigning router_data recomputes dependent DynamicRouteVars. + state.router_data = router_data + state.router = RouterData.from_router_data(router_data) + + await _run_on_load_events(app, state, path) + + json_str = format.json_dumps({"state": state.dict()}) + return Response( + content=json_str, + media_type="application/json", + headers={"Cache-Control": "no-cache"}, + ) + + return ssr_data_handler diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 45bd2895a71..a766c90c87a 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -190,10 +190,11 @@ def zip_app( } if frontend: + web_dir = prerequisites.get_web_dir() _zip( component_name=constants.ComponentName.FRONTEND, target=zip_dest_dir / constants.ComponentName.FRONTEND.zip(), - root_directory=prerequisites.get_web_dir() / constants.Dirs.STATIC, + root_directory=web_dir / constants.Dirs.STATIC, files_to_exclude=files_to_exclude, exclude_venv_directories=False, ) diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 6d7565d7848..31c5a34da96 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Literal -from reflex_base import constants +from reflex_base import constants, ssr from reflex_base.config import Config, get_config from reflex_base.environment import environment from reflex_base.plugins.embed import get_embed_plugin @@ -528,7 +528,7 @@ def _update_react_router_config(config: Config, prerender_routes: bool = False): "future": { "unstable_optimizeDeps": True, }, - "ssr": False, + "ssr": ssr.ssr_build_enabled(), } if prerender_routes: diff --git a/reflex/utils/js_runtimes.py b/reflex/utils/js_runtimes.py index ee407414536..0a38a44b236 100644 --- a/reflex/utils/js_runtimes.py +++ b/reflex/utils/js_runtimes.py @@ -9,7 +9,7 @@ from pathlib import Path from packaging import version -from reflex_base import constants +from reflex_base import constants, ssr from reflex_base.config import Config, get_config from reflex_base.environment import environment from reflex_base.utils.decorator import cached_procedure, once @@ -835,6 +835,10 @@ def install_frontend_packages(packages: set[str], config: Config): constants.PackageJson.REACT_COMPILER_DEV_DEPENDENCIES ) ) + if ssr.ssr_build_enabled(): + packages.update( + _pinned_args_from_constants(constants.PackageJson.ISR_RENDER_DEPENDENCIES) + ) for plugin in config.plugins: development_dependencies.update(plugin.get_frontend_development_dependencies()) packages.update(plugin.get_frontend_dependencies()) diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 4ec4bc3d68c..1f71ef35a5d 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -18,7 +18,11 @@ from reflex_base.constants import Endpoint, Env from reflex_base.plugins import Plugin from reflex_base.plugins.sitemap import SitemapPlugin -from reflex_base.utils.exceptions import ConfigError, InvalidPluginConfigError +from reflex_base.utils.exceptions import ( + ConfigError, + EnvironmentVarValueError, + InvalidPluginConfigError, +) import reflex as rx from reflex.environment import ( @@ -114,6 +118,7 @@ def test_react_compiler_default(base_config_values): ("REFLEX_REACT_COMPILER", False), ("REFLEX_REACT_COMPILER", True), ("REFLEX_DEFAULT_COLOR_MODE", "dark"), + ("REFLEX_SSR_MODE", "always"), ], ) def test_update_from_env( @@ -138,6 +143,22 @@ def test_update_from_env( assert getattr(config, field_name) == value +def test_update_from_env_rejects_unknown_ssr_mode( + base_config_values: dict[str, Any], monkeypatch: pytest.MonkeyPatch +): + """An ssr_mode outside rx.SsrMode fails config validation. + + Args: + base_config_values: Config values. + monkeypatch: The pytest monkeypatch object. + """ + monkeypatch.setenv("REFLEX_SSR_MODE", "bot_only") + with pytest.raises( + EnvironmentVarValueError, match="Invalid enum value: 'bot_only' for ssr_mode" + ): + rx.Config(**base_config_values) + + def test_update_from_env_path( base_config_values: dict[str, Any], monkeypatch: pytest.MonkeyPatch, diff --git a/tests/units/test_isr.py b/tests/units/test_isr.py new file mode 100644 index 00000000000..3220ced8ad6 --- /dev/null +++ b/tests/units/test_isr.py @@ -0,0 +1,242 @@ +"""Unit tests for the ISR (Incremental Static Regeneration) core.""" + +from __future__ import annotations + +import asyncio +import time +import uuid + +import pytest + +from reflex.isr import ( + CachedPage, + ISRManager, + MemoryISRCache, + RedisISRCache, + RenderResult, + cache_key, +) +from reflex.utils import prerequisites + + +class FakeRenderer: + """A controllable renderer that counts calls and can block on demand.""" + + def __init__( + self, + html: str = "rendered", + *, + revalidate: float | None = None, + tags: tuple[str, ...] = (), + gate: asyncio.Event | None = None, + fail: bool = False, + ) -> None: + """Configure the fake renderer.""" + self.html = html + self.revalidate = revalidate + self.tags = tags + self.gate = gate + self.fail = fail + self.calls = 0 + + async def __call__(self, path: str) -> RenderResult | None: + """Render the path, optionally blocking on the gate or failing. + + Args: + path: The path being rendered. + + Returns: + A render result, or None when configured to fail. + """ + self.calls += 1 + if self.gate is not None: + await self.gate.wait() + if self.fail: + return None + return RenderResult( + html=f"{self.html}:{path}:{self.calls}", + revalidate=self.revalidate, + tags=self.tags, + ) + + +async def _drain_background(manager: ISRManager) -> None: + """Wait for any background revalidation tasks to finish. + + Args: + manager: The manager whose background tasks to await. + """ + if manager._background: + await asyncio.gather(*list(manager._background)) + + +def _manager(renderer, **kwargs) -> ISRManager: + """Build a manager on a fresh in-memory cache. + + Args: + renderer: The renderer to use. + **kwargs: Overrides for the manager options. + + Returns: + The ISR manager. + """ + kwargs.setdefault("build_id", "b1") + kwargs.setdefault("default_revalidate", 60.0) + return ISRManager(MemoryISRCache(), renderer, **kwargs) + + +def test_cached_page_staleness(): + """A page is stale once past its revalidate window; 0 means never.""" + now = time.time() + fresh = CachedPage(html="x", generated_at=now, revalidate=60) + assert not fresh.is_stale(now + 30) + assert fresh.is_stale(now + 61) + never = CachedPage(html="x", generated_at=now, revalidate=0) + assert not never.is_stale(now + 10_000) + + +def test_cached_page_roundtrip(): + """CachedPage survives JSON serialization.""" + page = CachedPage(html="

", generated_at=1.0, revalidate=30, tags=("a", "b")) + assert CachedPage.from_json(page.to_json()) == page + + +@pytest.mark.asyncio +async def test_miss_renders_once_and_caches(): + """A cache miss renders and caches; a second request serves from cache.""" + renderer = FakeRenderer() + mgr = _manager(renderer) + + html1 = await mgr.get_html("/page") + html2 = await mgr.get_html("/page") + + assert html1 == html2 + assert renderer.calls == 1 # second request hit the cache + + +@pytest.mark.asyncio +async def test_stale_serves_stale_then_revalidates(): + """A stale entry is served immediately and refreshed in the background.""" + renderer = FakeRenderer() + mgr = _manager(renderer, default_revalidate=0.05) + + first = await mgr.get_html("/p") + assert renderer.calls == 1 + + # Let it go stale. + await asyncio.sleep(0.06) + + stale = await mgr.get_html("/p") + assert stale == first # served the stale copy immediately (no new render yet) + + await _drain_background(mgr) + assert renderer.calls == 2 # background revalidation happened exactly once + + # The refreshed value is now cached. + fresh = await mgr.get_html("/p") + assert fresh != first + assert renderer.calls == 2 + + +@pytest.mark.asyncio +async def test_single_flight_on_concurrent_miss(): + """Concurrent misses for one path trigger exactly one render.""" + gate = asyncio.Event() + renderer = FakeRenderer(gate=gate) + mgr = _manager(renderer, wait_timeout=5.0) + + tasks = [asyncio.create_task(mgr.get_html("/hot")) for _ in range(10)] + await asyncio.sleep(0.1) # let them all reach the lock / poll loop + gate.set() # release the single render + results = await asyncio.gather(*tasks) + + assert renderer.calls == 1 # only one worker rendered + assert len(set(results)) == 1 # everyone got the same HTML + assert results[0] is not None + + +@pytest.mark.asyncio +async def test_revalidate_path_forces_rerender(): + """Invalidating a path drops the cache so the next request re-renders.""" + renderer = FakeRenderer() + mgr = _manager(renderer) + + await mgr.get_html("/p") + assert renderer.calls == 1 + + await mgr.revalidate_path("/p") + await mgr.get_html("/p") + assert renderer.calls == 2 + + +@pytest.mark.asyncio +async def test_revalidate_tag_invalidates_all_matching(): + """Invalidating a tag drops every page carrying it.""" + renderer = FakeRenderer(tags=("blog",)) + mgr = _manager(renderer) + + await mgr.get_html("/blog/a") + await mgr.get_html("/blog/b") + assert renderer.calls == 2 + + count = await mgr.revalidate_tag("blog") + assert count == 2 + + await mgr.get_html("/blog/a") + await mgr.get_html("/blog/b") + assert renderer.calls == 4 # both re-rendered after invalidation + + +@pytest.mark.asyncio +async def test_build_id_scopes_cache(): + """A different build id yields a different key, forcing a re-render.""" + renderer = FakeRenderer() + cache = MemoryISRCache() + + mgr_old = ISRManager(cache, renderer, build_id="old") + mgr_new = ISRManager(cache, renderer, build_id="new") + + await mgr_old.get_html("/p") + await mgr_new.get_html("/p") + + assert renderer.calls == 2 + assert cache_key("old", "/p") != cache_key("new", "/p") + + +@pytest.mark.asyncio +async def test_render_failure_is_not_cached(): + """When the renderer returns None, nothing is cached and None is returned.""" + renderer = FakeRenderer(fail=True) + mgr = _manager(renderer) + + result = await mgr.get_html("/p") + assert result is None + # A subsequent request retries (nothing was cached). + await mgr.get_html("/p") + assert renderer.calls == 2 + + +@pytest.mark.asyncio +async def test_redis_release_lock_keeps_lock_of_next_owner(): + """Releasing an expired lock leaves the lock another worker acquired since.""" + redis = prerequisites.get_redis() + if redis is None: + pytest.skip("Test requires redis") + cache = RedisISRCache(redis) + key = f"test:{uuid.uuid4().hex}" + lock_key = RedisISRCache._LOCK_PREFIX + key + try: + first = await cache.acquire_lock(key, 30) + assert first is not None + await redis.delete(lock_key) # the first holder's lock expires + second = await cache.acquire_lock(key, 30) + assert second is not None + + await cache.release_lock(key, first) + assert await redis.exists(lock_key) + + await cache.release_lock(key, second) + assert not await redis.exists(lock_key) + finally: + await redis.delete(lock_key) + await redis.aclose() diff --git a/tests/units/test_isr_app.py b/tests/units/test_isr_app.py new file mode 100644 index 00000000000..47df57a7048 --- /dev/null +++ b/tests/units/test_isr_app.py @@ -0,0 +1,171 @@ +"""Tests for the ISR page-server app and factory helpers.""" + +from __future__ import annotations + +from starlette.testclient import TestClient + +import reflex as rx +from reflex.isr import ( + ISRManager, + MemoryISRCache, + RenderResult, + create_isr_app, + get_build_id, + is_enabled, +) + + +class _Renderer: + """Minimal renderer that echoes the path and records calls.""" + + def __init__(self) -> None: + """Initialize the call counter and rendered paths.""" + self.calls = 0 + self.paths: list[str] = [] + + async def __call__(self, path: str) -> RenderResult: + """Render the path. + + Args: + path: The path being rendered. + + Returns: + A render result echoing the path. + """ + self.calls += 1 + self.paths.append(path) + return RenderResult(html=f"{path}#{self.calls}", tags=("t",)) + + +def _client(renderer: _Renderer) -> TestClient: + """Build a test client for a page-server around ``renderer``. + + Args: + renderer: The renderer to use. + + Returns: + A test client for the page-server. + """ + manager = ISRManager( + MemoryISRCache(), renderer, build_id="b", default_revalidate=60 + ) + return TestClient(create_isr_app(manager, shell="shell")) + + +def test_serves_and_caches_rendered_html(): + """The page-server renders once and then serves from cache.""" + renderer = _Renderer() + client = _client(renderer) + + r1 = client.get("/blog/hello") + r2 = client.get("/blog/hello") + + assert r1.status_code == 200 + assert "/blog/hello#1" in r1.text + assert r1.text == r2.text + assert renderer.calls == 1 # second request hit the cache + + +def test_renders_and_caches_the_raw_path(): + """An encoded slash reaches the renderer and cache key still encoded.""" + renderer = _Renderer() + client = _client(renderer) + + encoded = client.get("/blog/a%2Fb") + decoded = client.get("/blog/a/b") + + assert "/blog/a%2Fb#1" in encoded.text + assert "/blog/a/b#2" in decoded.text + assert client.get("/blog/a%2Fb").text == encoded.text + assert renderer.paths == ["/blog/a%2Fb", "/blog/a/b"] + + +def test_caches_by_path_without_query_string(): + """Requests differing only in their query string share one cached page.""" + renderer = _Renderer() + client = _client(renderer) + + first = client.get("/search?q=a") + second = client.get("/search?q=b") + + assert first.text == second.text + assert renderer.paths == ["/search"] + + +def test_falls_back_to_shell_on_render_failure(): + """When the renderer yields no HTML, the SPA shell is served.""" + + class _NullRenderer: + """Renderer that never produces HTML.""" + + async def __call__(self, path: str) -> None: + """Fail to render the path. + + Args: + path: The path being rendered. + """ + + manager = ISRManager(MemoryISRCache(), _NullRenderer(), build_id="b") + client = TestClient(create_isr_app(manager, shell="SHELL")) + + r = client.get("/anything") + assert r.status_code == 200 + assert "SHELL" in r.text + + +def test_revalidate_path_endpoint(): + """POST /_isr/revalidate with a path drops that page from cache.""" + renderer = _Renderer() + client = _client(renderer) + + client.get("/p") + assert renderer.calls == 1 + + resp = client.post("/_isr/revalidate", json={"path": "/p"}) + assert resp.status_code == 200 + assert resp.json()["revalidated"] is True + + client.get("/p") + assert renderer.calls == 2 # re-rendered after invalidation + + +def test_revalidate_tag_endpoint(): + """POST /_isr/revalidate with a tag invalidates all tagged pages.""" + renderer = _Renderer() + client = _client(renderer) + + client.get("/a") + client.get("/b") + assert renderer.calls == 2 + + resp = client.post("/_isr/revalidate", json={"tag": "t"}) + assert resp.json()["tag_pages"] == 2 + + client.get("/a") + client.get("/b") + assert renderer.calls == 4 + + +def test_revalidate_requires_token_when_configured(monkeypatch): + """When REFLEX_ISR_REVALIDATE_TOKEN is set, the endpoint enforces it.""" + monkeypatch.setenv("REFLEX_ISR_REVALIDATE_TOKEN", "secret") + renderer = _Renderer() + client = _client(renderer) + + assert client.post("/_isr/revalidate", json={"path": "/p"}).status_code == 401 + ok = client.post( + "/_isr/revalidate", + json={"path": "/p"}, + headers={"X-Reflex-ISR-Token": "secret"}, + ) + assert ok.status_code == 200 + + +def test_config_helpers_reflect_isr_settings(): + """is_enabled / get_build_id read from the config.""" + off = rx.Config(app_name="t") + assert is_enabled(off) is False + + on = rx.Config(app_name="t", isr_render_url="http://render:8000", isr_build_id="v9") + assert is_enabled(on) is True + assert get_build_id(on) == "v9" diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 09b49edefac..35e1f7dc69e 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -1006,6 +1006,30 @@ def test_react_compiler_dependency_versions_invalidate_cache( assert "babel-plugin-react-compiler@0.0.0-test" in calls[1] +def test_ssr_build_installs_isr_render_dependencies( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Only an SSR build installs the packages the ISR render service imports. + + Args: + install_packages_env: The isolated install environment. + monkeypatch: The pytest monkeypatch fixture. + """ + env = install_packages_env + calls = _record_calls(env) + monkeypatch.delenv("REFLEX_SSR_BUILD", raising=False) + env.install() + assert calls == [] + + monkeypatch.setenv("REFLEX_SSR_BUILD", "1") + env.install() + assert len(calls) == 1 + assert "add" in calls[0] + assert "-D" not in calls[0] + express = constants.PackageJson.ISR_RENDER_DEPENDENCIES["express"] + assert f"express@{express}" in calls[0] + + def test_install_frontend_packages_pinned_packages_single_call( install_packages_env: InstallPackagesEnv, ): diff --git a/tests/units/test_route.py b/tests/units/test_route.py index 3e51a5b1bbe..c1d970925b2 100644 --- a/tests/units/test_route.py +++ b/tests/units/test_route.py @@ -4,7 +4,12 @@ import reflex as rx from reflex.app import App -from reflex.route import get_route_args, get_router, verify_route_validity +from reflex.route import ( + extract_route_params, + get_route_args, + get_router, + verify_route_validity, +) @pytest.mark.parametrize( @@ -24,6 +29,33 @@ def test_route_args(route_name, expected): assert get_route_args(route_name) == expected +@pytest.mark.parametrize( + ("path", "route", "expected"), + [ + ("/blog/hello%20world", "blog/[slug]", {"slug": "hello world"}), + ("/blog/a%2Fb", "blog/[slug]", {"slug": "a/b"}), + ("/blog/a%2Fb", "blog/[splat]", {"splat": "a/b"}), + ( + "/users/%C3%BCber/posts/1%2B1", + "users/[id]/posts/[pid]", + {"id": "über", "pid": "1+1"}, + ), + ("/docs/hello%20world", "docs/[[section]]", {"section": "hello world"}), + ("/docs/intro", "docs/[[lang]]/[page]", {"page": "intro"}), + ("/docs/en/intro", "docs/[[lang]]/[page]", {"lang": "en", "page": "intro"}), + ("/files/a%20b/c", "files/[[...splat]]", {"splat": ["a b", "c"]}), + ("/files/a%2Fb/c", "files/[[...splat]]", {"splat": ["a", "b", "c"]}), + ("/files", "files/[[...splat]]", {}), + ], +) +def test_extract_route_params_decodes_like_react_router( + path: str, route: str, expected: dict +): + # React Router decodes each segment, keeping an encoded slash in its value, + # and the client splits the splat on every slash. + assert extract_route_params(path, route) == expected + + @pytest.mark.parametrize( "route_name", [ diff --git a/tests/units/test_ssr_compile.py b/tests/units/test_ssr_compile.py new file mode 100644 index 00000000000..10a95bed705 --- /dev/null +++ b/tests/units/test_ssr_compile.py @@ -0,0 +1,65 @@ +"""Unit tests for SSR route-param extraction.""" + +from __future__ import annotations + +from reflex.route import extract_route_params + + +def test_simple_dynamic_route(): + """Single dynamic segment is extracted correctly.""" + result = extract_route_params("/blog/hello-world", "blog/[slug]") + assert result == {"slug": "hello-world"} + + +def test_multiple_dynamic_segments(): + """Multiple dynamic segments are extracted correctly.""" + result = extract_route_params("/users/42/posts/99", "users/[id]/posts/[pid]") + assert result == {"id": "42", "pid": "99"} + + +def test_no_dynamic_segments(): + """Static route returns empty dict.""" + result = extract_route_params("/about", "about") + assert result == {} + + +def test_root_path(): + """Root path with no segments returns empty dict.""" + result = extract_route_params("/", "/") + assert result == {} + + +def test_leading_slash_handling(): + """Leading slashes on both path and route are handled.""" + result = extract_route_params("/blog/my-post", "/blog/[slug]") + assert result == {"slug": "my-post"} + + +def test_optional_segment(): + """Optional dynamic segment ([[param]]) is extracted.""" + result = extract_route_params("/docs/intro", "docs/[[section]]") + assert result == {"section": "intro"} + + +def test_no_match_shorter_path(): + """When path has fewer segments than route, missing params are skipped.""" + result = extract_route_params("/blog", "blog/[slug]") + assert result == {} + + +def test_preserves_special_characters_in_value(): + """Values with hyphens and other URL-safe chars are preserved.""" + result = extract_route_params("/blog/my-great-post-2024", "blog/[slug]") + assert result == {"slug": "my-great-post-2024"} + + +def test_catch_all_captures_remaining_segments(): + """A catch-all captures every remaining segment as a list, like the client.""" + result = extract_route_params("/docs/a/b/c", "docs/[[...splat]]") + assert result == {"splat": ["a", "b", "c"]} + + +def test_catch_all_without_segments(): + """An empty catch-all is omitted, like the client.""" + result = extract_route_params("/docs", "docs/[[...splat]]") + assert result == {} diff --git a/tests/units/test_ssr_data.py b/tests/units/test_ssr_data.py new file mode 100644 index 00000000000..d88982972b0 --- /dev/null +++ b/tests/units/test_ssr_data.py @@ -0,0 +1,416 @@ +"""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 +from reflex.ssr import ssr_data +from reflex.state import State + + +@pytest.fixture(autouse=True) +def _isolate_ssr_state(clean_registration_context): + """Serialize the /_ssr_data tree against only each test's own states. + + The endpoint calls ``State.dict()``, which walks every registered substate; + with a polluted global registry it would serialize (and crash on) unrelated + states from other modules (e.g. test_app's DynamicState). ``clean_registration_context`` + gives an empty substate registry, but the root ``State``'s per-class dependency + sets are global — reset them to match the clean context (and restore after), + so lookups stay consistent and nothing leaks in or out. + + Args: + clean_registration_context: A fresh, empty registration context. + + Yields: + None. + """ + names = ( + "_potentially_dirty_states", + "_always_dirty_substates", + "_var_dependencies", + ) + original = {name: getattr(State, name) for name in names} + State._potentially_dirty_states = set() + State._always_dirty_substates = set() + State._var_dependencies = {} + State.get_class_substate.cache_clear() + try: + yield + finally: + for name, value in original.items(): + setattr(State, name, value) + State.get_class_substate.cache_clear() + + +def _make_request( + path: str = "/", headers: dict | None = None, search: str = "" +) -> 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. + search: The URL query string (e.g. ``?q=reflex``). + + Returns: + A mock Request object. + """ + body = {"path": path, "search": search, "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() + page = data["state"][root_name]["rx_router_page_rx_state_"] + assert page["params"] == {"slug": "hello-world"} + assert page["raw_path"] == "/blog/hello-world" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("path", "slug"), + [("/blog/hello%20world", "hello world"), ("/blog/a%2Fb", "a/b")], +) +async def test_ssr_data_decodes_route_params(path: str, slug: str): + """Escaped route params reach the state decoded, as in browser events. + + Args: + path: The percent-encoded request path. + slug: The expected decoded slug. + """ + + class PostState(rx.State): + pass + + app = App() + app._state = PostState + app.add_page(lambda: rx.text("post"), route="/blog/[slug]") + + response = await ssr_data(app)(_make_request(path)) + + page = _parse_response(response)["state"][rx.State.get_full_name()][ + "rx_router_page_rx_state_" + ] + assert page["path"] == "/blog/[slug]" + assert page["params"] == {"slug": slug} + + +@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): + """Set the title.""" + 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_on_load_payload(): + """An on_load handler bound with arguments receives them.""" + + class PayloadState(rx.State): + post_id: int = 0 + + @rx.event + def load(self, post_id: int): + """Store the post id. + + Args: + post_id: The bound post id. + """ + self.post_id = post_id + + app = App() + app._state = PayloadState + app.add_page( + lambda: rx.text(PayloadState.post_id), + route="/post", + on_load=PayloadState.load(123), + ) + + response = await ssr_data(app)(_make_request("/post")) + + data = _parse_response(response) + assert data["state"][PayloadState.get_full_name()]["post_id_rx_state_"] == 123 + + +@pytest.mark.asyncio +async def test_ssr_data_query_params(): + """Query parameters reach on_load, with route params taking precedence.""" + + class SearchState(rx.State): + q: str = "" + + @rx.event + def load(self): + """Copy the ``q`` query parameter.""" + self.q = self.router.url.query_parameters.get("q", "") + + app = App() + app._state = SearchState + app.add_page( + lambda: rx.text(SearchState.q), + route="/search/[slug]", + on_load=SearchState.load, + ) + + response = await ssr_data(app)( + _make_request("/search/hello", search="?q=reflex&slug=x&sort-by=new") + ) + + data = _parse_response(response) + assert data["state"][SearchState.get_full_name()]["q_rx_state_"] == "reflex" + page = data["state"][rx.State.get_full_name()]["rx_router_page_rx_state_"] + assert page["params"] == {"q": "reflex", "slug": "hello", "sort_by": "new"} + assert page["raw_path"] == "/search/hello?q=reflex&slug=x&sort-by=new" + + +@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): + """Set the message asynchronously.""" + 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): + """Always fail. + + Raises: + RuntimeError: Always. + """ + 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() + headers = data["state"][root_name]["rx_router_headers_rx_state_"] + assert 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() + session = data["state"][root_name]["rx_router_session_rx_state_"] + assert session["client_ip"] == "10.0.0.42"