Skip to content

ssr wip - #6173

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

ssr wip#6173
benedikt-bartscher wants to merge 16 commits into
reflex-dev:mainfrom
benedikt-bartscher:ssr

Conversation

@benedikt-bartscher

Copy link
Copy Markdown
Contributor

No description provided.

Comment thread packages/reflex-base/src/reflex_base/.templates/web/ssr-serve.js Fixed
@codspeed

codspeed Bot commented Mar 13, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 11 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 (451c79d) with main (656e8c7)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 0/5

[Medium impact] The change affects production rendering for apps that opt into runtime SSR.

The PR is not ready to merge because production startup, page rendering, and load-handler behavior remain broken.

Findings

  1. P1 Home-page bots bypass SSR ▶
  2. P1 Direct visits get wrong markup ▶
  3. P1 Load handler arguments are lost ▶
  4. P1 Chained load events are dropped ▶
  5. P1 Navigation runs load handlers twice ▶
  6. P1 SSR data disappears on connection ▶
  7. P1 Production never starts SSR ▶

Summary

Adds experimental, default-off runtime_ssr support for crawler requests.

  • Crawlers get pages rendered with state from the matching route.
  • The browser starts with the same state that the server rendered.
  • SSR builds serve crawlers rendered pages and browsers a static shell.

Reviews (4) · Last reviewed commit: "Choose list route params by segment type..." · Reviewed by Greptile

);

// Other static files (favicon, sitemap, etc.) — short cache.
app.use(express.static(clientDir, { maxAge: "1h" }));

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 Home-page bots bypass SSR A crawler requesting / reaches this static middleware before the bot check. Express serves build/client/index.html as the directory index, so the crawler gets the unrendered SPA shell instead of the server-rendered home page.

Comment on lines +68 to +71
if (accept.includes("text/html") && !req.url.endsWith(".data")) {
return res
.setHeader("Content-Type", "text/html; charset=utf-8")
.send(shellHtml);

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 Direct visits get wrong markup The shell is generated by rendering /, but this branch sends that same HTML for every document URL. Someone opening /blog/hello-world directly therefore receives the index page’s markup and hydration data, causing an initially incorrect page or a hydration mismatch.

Comment thread reflex/app.py
event_handler = target_state.event_handlers[handler_attr]

# Execute the handler on the target substate.
result = event_handler.fn(target_state)

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 Load handler arguments are lost When an on_load event has bound arguments, this call passes only the state instance. The handler raises a TypeError that is caught and logged, leaving bot-rendered pages with default state instead of their loaded data.

Comment thread reflex/app.py
Comment on lines +2075 to +2081
# For generators (event chains), consume them but ignore returned events.
if inspect.isgenerator(result):
for _ in result:
pass
if inspect.isasyncgen(result):
async for _ in result:
pass

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 Chained load events are dropped If an on_load handler returns events or yields them from a generator, this code never dispatches those events. SSR pages consequently render without state updates performed by the chained handlers, unlike pages loaded through normal event processing.

Comment thread reflex/route.py Outdated
Comment thread reflex/app.py Outdated
Comment on lines +266 to +289
// (a) Full SSR render for bots — ssr-serve.js routes bot requests here.
// (b) .data requests for client-side navigation — React Router calls the
// loader to fetch route data as JSON for the next page.
// Both cases need real state data from the backend.
const backendUrl = getBackendURL(env.SSR_DATA);
try {
const res = await fetch(backendUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(request.headers.get("cookie") ? { "Cookie": request.headers.get("cookie") } : {}),
},
body: JSON.stringify({
path: new URL(request.url).pathname,
headers: Object.fromEntries(request.headers),
}),
});
if (res.ok) {
return res.json();
}
} catch (e) {
console.error("SSR data fetch failed:", e);
}
return { state: null };

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 Navigation runs load handlers twice On a client-side navigation, the .data request reaches this loader and runs the page’s on_load handlers through /_ssr_data. The existing navigation effect also sends on_load_internal over the WebSocket. A handler with an external side effect, such as a write or notification, can therefore perform it twice for one navigation.

Comment thread packages/reflex-base/src/reflex_base/config.py
Comment thread tests/integration/tests_playwright/test_ssr.py Outdated
Comment thread tests/units/test_ssr_compile.py
Upstream replaced the per-substate reducer providers with a
useSyncExternalStore-backed state store and moved the boot hydrate into the
socket.io CONNECT packet. The SSR state now seeds the client store
(createStateStore) and backs the server store's getSnapshot, and the
SSR-hydrated first connect sends a plain hydrate as its boot event in
connect() instead of the removed mount-time hydrate effect in useEventLoop.
The SSR template tests were updated to match.
Comment on lines +915 to +918
const boot_event = withRouterData(
first && ssrHydrated
? ReflexEvent(`${app.state_name}.hydrate`)
: app.initialEvents(first)[0],

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 SSR data disappears on connection

For browsers receiving SSR HTML, this branch sends hydrate against the browser's separate backend session. But ssr_data ran the load handlers on a temporary state, not that session. hydrate then sends the session's full state, replacing the loaded values. It also resets client storage and leaves is_hydrated false without queuing load handlers.

Keep the browser's backend session in sync before skipping hydrate_and_load.

Comment on lines +583 to +584
if get_config().runtime_ssr:
scripts["prod"] = constants.PackageJson.Commands.PROD_SSR

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 Production never starts SSR

Adding a prod script does not enable SSR for reflex run --env prod. That command still starts the Python static server, either separately or mounted on the backend. Neither path launches ssr-serve.js or reads this script, so crawlers still receive static HTML with runtime_ssr=True.

Wire the SSR server into the production launch path.

Comment thread packages/reflex-base/src/reflex_base/compiler/templates.py Outdated
Comment thread reflex/route.py Outdated
Comment thread reflex/route.py Outdated
Comment thread reflex/utils/build.py Outdated
Comment thread reflex/utils/build.py Outdated
Comment thread reflex/utils/frontend_skeleton.py Outdated
Match route params with a regex so catch-alls, omitted optional segments
and encoded segments resolve like on the client, forward the query
string to load handlers, strip frontend_path before routing, and only
exclude top-level source dirs from the SSR export. Add news fragments.
Comment thread reflex/route.py Outdated
extract_route_params splits a value into a list only for catch-all arguments, as reported by get_route_args, so a plain argument named splat such as blog/[splat] stays a string.

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.

2 participants