Skip to content

Ssr plugin wip - #6800

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

benedikt-bartscher wants to merge 27 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

⚠️ 14 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 (1c6edaa) with main (656e8c7)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 2/5

[Medium impact] The changes affect page rendering, caching, and state loading for apps that opt in.

The PR should not merge until cache clearing survives in-flight renders and requires a configured credential.

Findings

  1. P1 Old renders undo cache clearing ▶
  2. P1 Security Anyone can clear cached pages ▶
  3. P2 Redis tag sets accumulate without expiry ▶
  4. P2 Generator handler yields are silently discarded ▶

Summary

Adds experimental SSR data loading and an ISR page server with shared caching and on-demand refresh.

  • SSR-enabled apps load route state before rendering a page.
  • The page server caches rendered HTML and refreshes stale pages.

Reviews (10) · Last reviewed commit: "Cache ISR pages by raw path and drop the..." · Reviewed by Greptile

Comment thread reflex/route.py Outdated
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 Outdated
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 Outdated
Comment thread reflex/isr.py Outdated
Comment thread packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs
Comment thread reflex/route.py Outdated
Install express into .web only for an SSR build, since isr-render.mjs is the
only code that imports it.
extract_route_params now URL-decodes each value and splits only the catch-all
on slashes, so server-rendered params match what the client sees.
Starlette decodes request.url.path, so /blog/a%2Fb was rendered and
cached as /blog/a/b; the page server now uses the raw path, and the
docs state that pages are cached per path without the query string.
SsrMode.BOT_ONLY was never implemented and behaved like ALWAYS.

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