Skip to content

Ssr plugin wip#6800

Draft
benedikt-bartscher wants to merge 18 commits into
reflex-dev:mainfrom
benedikt-bartscher:ssr-plugin
Draft

Ssr plugin wip#6800
benedikt-bartscher wants to merge 18 commits into
reflex-dev:mainfrom
benedikt-bartscher:ssr-plugin

Conversation

@benedikt-bartscher

Copy link
Copy Markdown
Contributor

@codspeed-hq

codspeed-hq Bot commented Jul 18, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing benedikt-bartscher:ssr-plugin (0f7d74b) with main (f79d011)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces SSR (Server-Side Rendering) and ISR (Incremental Static Regeneration) support for Reflex apps. A new /_ssr_data POST endpoint runs on_load handlers on an ephemeral state and returns the serialized state tree; the React frontend loader fetches this during SSR so the initial HTML is fully hydrated. ISR is layered on top: a Python cache layer (Redis-backed in production, in-memory for dev/test) stores rendered HTML with stale-while-revalidate semantics and cross-worker single-flight coordination.

  • reflex/ssr.py — new /_ssr_data endpoint; reflex/isr.py — new ISR manager, Redis and in-memory cache backends, create_isr_app page-server factory.
  • packages/reflex-base/.../compiler/templates.py — SSR-conditional JS injection: react-router loader, SSRStateContext, and reducer seeding from server-fetched state.
  • reflex/route.py — new extract_route_params helper; new config fields (ssr_mode, isr_render_url, isr_revalidate, isr_build_id); SsrMode enum added throughout the constants/__init__ chain.

Confidence Score: 3/5

The PR introduces two new capabilities (SSR state hydration and ISR caching) that are off by default, limiting blast radius — but the on_load handler runner silently drops event payloads, and the /_ssr_data endpoint is publicly reachable without any authentication.

The on_load handler invocation calls handler.fn(substate) without forwarding event.payload, so any parameterized on_load will be called without its arguments — silently producing wrong SSR state or swallowing TypeErrors. Combined with the unauthenticated /_ssr_data endpoint, these are material correctness and security gaps that should be resolved before merging.

reflex/ssr.py needs the payload-forwarding fix in _run_on_load_event and hardening of the JSON body parsing; reflex/app.py / reflex/ssr.py need authentication on the /_ssr_data route; reflex/isr.py's MemoryISRCache.delete should prune the tag index.

Important Files Changed

Filename Overview
reflex/ssr.py New /_ssr_data endpoint and on_load runner; event payload is dropped for parameterized handlers, causing silent incorrect SSR state; no JSON error handling on request body; endpoint is unauthenticated (flagged in prior review)
reflex/isr.py New ISR caching layer with Redis and in-memory backends; MemoryISRCache.delete leaves stale tag index entries; Redis tag sets have no TTL (flagged in prior review); create_isr_app correctly gates the revalidation endpoint on a shared secret
reflex/route.py New extract_route_params for SSR; catch-all routes ([[...splat]]) only capture the first segment instead of joining remaining path parts (flagged in prior review)
packages/reflex-base/src/reflex_base/compiler/templates.py SSR-conditional JS code injection: adds react-router loader, useLoaderData, and SSRStateContext wiring; env.SSR_DATA is auto-populated via the Endpoint enum in set_env_json; changes are correctly gated on ssr.is_enabled()
packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs New ISR render-tier Node service; no authentication on the POST / endpoint (noted in comments), but it is intended as an internal service; health, error, and header-forwarding logic look correct
tests/units/test_ssr_data.py Good coverage of the /_ssr_data handler for basic, async, and error-path handlers; missing test for parameterized on_load (e.g., State.set_x(value="v")), which is the case where the payload-dropping bug manifests
packages/reflex-base/src/reflex_base/config.py Adds ssr_mode, isr_render_url, isr_revalidate, and isr_build_id config fields; well-documented and with sensible defaults

Reviews (2): Last reviewed commit: "ruff" | Re-trigger Greptile

Comment thread reflex/route.py
Comment on lines +120 to +128
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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Catch-all route captures only one segment

extract_route_params stores only path_parts[i] for every matched dynamic segment, including catch-all routes ([[...splat]]). For a URL like /docs/a/b/c against route docs/[[...splat]], the function assigns {"splat": "a"} instead of capturing all remaining segments (a/b/c). Any on_load handler that reads self.router.page.params["splat"] to fetch content by path will silently receive a truncated value, producing wrong SSR output for every catch-all route.

The fix requires detecting that the matched param has type RouteArgType.LIST (already tracked in route_args) and joining the remaining path parts when that type is seen.

Comment thread reflex/isr.py
Comment on lines +776 to +782
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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Redis tag sets accumulate without expiry

pipe.sadd(self._TAG_PREFIX + tag, key) writes tag index entries with no TTL. When a page's TTL expires naturally in Redis, the corresponding entry in the tag set remains indefinitely. On long-lived deployments with many unique tags or frequent cache churn, these orphaned set members grow without bound. When invalidate_tag is later called, it deletes pages that no longer exist (harmless but wasteful), and the sets themselves are never reaped unless explicitly invalidated.

Consider setting a TTL on each tag key (e.g. EXPIRE tag_key ttl) in the pipeline, or using EXPIREAT set to the furthest expiry of any member.

Comment thread reflex/ssr.py
Comment on lines +91 to +98
result = handler.fn(substate)
if asyncio.iscoroutine(result):
result = await result
if inspect.isgenerator(result):
for _ in result:
pass
elif inspect.isasyncgen(result):
async for _ in result:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Generator handler yields are silently discarded

Generator and async-generator handlers are iterated with for _ in result, which correctly preserves self mutations but silently drops every yielded value. Reflex generator handlers can yield EventSpec objects (e.g. yield SomeState.other_handler()) or explicit state deltas that operate on other substates. Those cross-substate side effects will not be reflected in the SSR output. Apps that use chained generator events for data loading may render with partially hydrated state without any warning in the logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant