Repository navigation
Ssr plugin wip - #6800
Ssr plugin wip#6800benedikt-bartscher wants to merge 23 commits into
Conversation
Merging this PR will not alter performance1 warning
Comparing |
[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
SummaryAdds experimental SSR data loading and an ISR page server.
Reviews (7) · Last reviewed commit: "Merge remote-tracking branch 'upstream/m..." · Reviewed by Greptile |
| 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: |
There was a problem hiding this comment.
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.
| 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: |
There was a problem hiding this comment.
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.
Comments Outside DiffThese findings could not be posted inline.
|
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.
| await self.cache.set( | ||
| key, | ||
| CachedPage( | ||
| html=result.html, | ||
| generated_at=time.time(), | ||
| revalidate=revalidate, | ||
| tags=result.tags, | ||
| ), | ||
| ) |
There was a problem hiding this comment.
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.
| if revalidate_token and ( | ||
| request.headers.get("x-reflex-isr-token") != revalidate_token | ||
| ): | ||
| return JSONResponse({"error": "unauthorized"}, status_code=401) |
There was a problem hiding this comment.
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.
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.
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.
| Returns: | ||
| The rendered page, the shell, or a 404. | ||
| """ | ||
| html = await mgr.get_html(request.url.path) |
There was a problem hiding this comment.
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.
| html = await mgr.get_html(request.url.path) | ||
| if html is not None: | ||
| return HTMLResponse(html) |
There was a problem hiding this comment.
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.
#6173