Skip to content

Ssr plugin wip - #6800

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

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

Conversation

@benedikt-bartscher

Copy link
Copy Markdown
Contributor

@codspeed

codspeed Bot commented Jul 18, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 9 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 148 untouched benchmarks


Comparing benedikt-bartscher:ssr-plugin (a51c82e) with main (05329d0)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 0/5

[Medium impact] The new rendering and caching behavior reaches apps that opt into SSR or ISR.

Do not merge yet: query-dependent pages render incorrectly, bot-only mode is ignored, and earlier cache invalidation problems remain.

Findings

  1. P1 Query parameters disappear ▶
  2. P1 Bot-only mode is ignored ▶
  3. P1 Old renders undo cache clearing ▶
  4. P1 Security Anyone can clear cached pages ▶
  5. P2 Redis tag sets accumulate without expiry ▶
  6. P2 Generator handler yields are silently discarded ▶

Summary

Adds experimental SSR data loading and an ISR page server.

  • Server-rendered pages start with their route data.
  • The page server caches rendered HTML and refreshes stale pages.

Reviews (7) · Last reviewed commit: "Merge remote-tracking branch 'upstream/m..." · Reviewed by Greptile

Comment thread reflex/route.py
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 Outdated
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.

# Conflicts:
#	packages/reflex-base/src/reflex_base/compiler/templates.py
#	pyi_hashes.json
Comment thread reflex/isr.py Outdated
@greptile-apps

greptile-apps Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff

These findings could not be posted inline.

  • P1 SSR misidentifies prefixed routes reflex/app.py:1407 ▶

    When frontend_path is set, this code strips the prefix before finding on_load events, but /_ssr_data still resolves the unstripped browser path for router data. For /app/blog/hello with frontend_path="/app", the blog/[slug] load handler runs with router data identifying the route as 404 and no slug parameter. Handlers that read the route or its parameters can therefore produce incorrect server-rendered state.

  • P1 Security / ssr data endpoint is unauthenticated and publicly exposed reflex/app.py:443 ▶

    The endpoint is added to the public-facing Starlette API with no authentication. Any caller that can reach the backend can POST {"path": "/profile", "headers": {"cookie": "…"}} and trigger all on_load handlers for that route with arbitrary headers. If those handlers make database queries or external API calls based on the provided path or headers, an attacker can abuse this to probe state, trigger expensive queries, or exercise side-effect-bearing on_load logic without going through normal app flow.

    The /_isr/revalidate endpoint in create_isr_app correctly gates on REFLEX_ISR_REVALIDATE_TOKEN; a similar shared-secret header (REFLEX_SSR_DATA_TOKEN or similar) should guard this endpoint when SSR is deployed.

Upstream replaced the per-substate reducer providers in context.js with a
useSyncExternalStore state store. The SSR state seeding is ported onto it:
ClientStateProvider builds its store from the SSR state with the compiled
defaults as fallback, and ServerStateProvider reads SSR substates through a
store whose getSnapshot falls back to initialState. With SSR enabled,
initialStateHashes is emitted empty, since the store may not start from the
compiled defaults and the first hydrate must then return the full state.
pyi_hashes.json was regenerated.
Comment thread reflex/ssr.py Outdated
Comment thread packages/reflex-base/src/reflex_base/compiler/templates.py Outdated
Comment thread reflex/isr.py
Comment on lines +491 to +499
await self.cache.set(
key,
CachedPage(
html=result.html,
generated_at=time.time(),
revalidate=revalidate,
tags=result.tags,
),
)

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 Old renders undo cache clearing

revalidate_path can be undone by a render already in progress. If that render reads old content, then an update deletes the cache entry, _render_and_store still writes the old HTML back afterward. The next request gets the invalidated content again; with revalidate=0, it stays cached indefinitely. Track an invalidation version and reject writes from renders that started before it changed.

Comment thread reflex/isr.py Outdated
Comment thread reflex/isr.py
Comment on lines +808 to +811
if revalidate_token and (
request.headers.get("x-reflex-isr-token") != revalidate_token
):
return JSONResponse({"error": "unauthorized"}, status_code=401)

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 security Anyone can clear cached pages

If the page server is publicly reachable and REFLEX_ISR_REVALIDATE_TOKEN is unset or empty, anyone can POST a path or tag to /_isr/revalidate and delete cached pages. Repeated invalidation and GET requests defeat the cache and force render work across workers. Disable this endpoint without a configured token, or reject requests until one is configured.

How this was verified: The public POST route skips its only credential check when the token is empty and passes the caller's path or tag directly to cache deletion.

Comment thread tests/units/test_ssr_compile.py Outdated
Pass on_load payloads and query parameters to SSR load handlers, capture
every remaining segment for catch-all routes, and release an ISR lock
only while its acquirer still owns it. Move the SSR compile tests to
module level.
Comment thread reflex/isr.py Outdated
Comment thread reflex/isr.py
Comment thread packages/reflex-base/src/reflex_base/config.py
Move the inline imports in the ISR server and the SSR modules to module
level (only reflex.app stays type-only to avoid a cycle), add docstrings
to the ISR handlers and test helpers, and add feature fragments for the
SSR and ISR support.
Comment thread reflex/isr.py
Returns:
The rendered page, the shell, or a 404.
"""
html = await mgr.get_html(request.url.path)

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 Query parameters disappear

serve drops the query string before rendering and choosing a cache key. A request to /search?q=reflex therefore renders /search without q, and every query variant gets the same cached HTML. Visitors see content without their search, filter, or sort choices.

Pass the query string through to the renderer and include it in the cache key, or skip caching query-dependent requests.

Comment thread reflex/isr.py
Comment on lines +794 to +796
html = await mgr.get_html(request.url.path)
if html is not None:
return HTMLResponse(html)

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 Bot-only mode is ignored

SsrMode.BOT_ONLY promises that regular visitors get the SPA shell, but the page server renders and serves cached HTML for every visitor. The only check of ssr_mode treats BOT_ONLY and ALWAYS alike. Regular visitors can therefore wait for a render instead of receiving the shell.

Check the visitor's user agent before calling get_html and serve the shell to regular visitors in BOT_ONLY mode.

This branch has not been deployed

No deployments
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