Skip to content

feat: add server-side rendering (SSR)#51

Open
emmapowers wants to merge 25 commits into
mainfrom
ssr
Open

feat: add server-side rendering (SSR)#51
emmapowers wants to merge 25 commits into
mainfrom
ssr

Conversation

@emmapowers

@emmapowers emmapowers commented Mar 9, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds SSR support so the initial HTTP response contains rendered HTML instead of a blank page, eliminating the blank flash while the WebSocket connects
  • render_for_ssr() renders the component tree but defers mount hooks; SessionStore holds the session for WebSocket resumption with a 30s TTL
  • Bun sidecar process (SSRRenderer) runs renderToString() over a Unix socket to produce HTML from the serialized element tree
  • SSROrchestrator coordinates rendering, session storage, dehydration script injection, and per-route+theme HTML caching
  • Client detects window.__TRELLIS_SSR__, pre-populates the store with patches, and uses hydrateRoot() for seamless hydration
  • ssr config option (default true) controls the feature; graceful fallback to CSR when the renderer is unavailable
  • SSRBundleBuildStep produces a Node-targeted ESM bundle from a new ssr-entry.tsx
  • Hot reload invalidates the SSR cache via add_on_reload_callback()

Test plan

  • 1860 Python tests pass (pixi run test)
  • 227 JS tests pass
  • Full CI green (pixi run ci — formatting, lint, mypy, tests)
  • Run showcase with --force-build, verify SSR HTML in page source
  • Verify interactive behavior works after hydration
  • Test ssr=false config falls back to CSR
  • Test hot reload invalidates SSR cache

Summary by CodeRabbit

  • New Features
    • Server-side rendering (SSR) enabled by default for faster initial page loads and improved perceived performance
    • Session resumption so clients can continue an SSR session and preserve app state across SSR→client transitions
    • New config options: ssr (default true) and session_ttl (default 300s) to control SSR and session persistence

@coderabbitai

coderabbitai Bot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds end-to-end server-side rendering (SSR): config, build steps, SSR renderer/orchestrator, session store/cache, client hydration, async build/CLI changes, subprocess helpers, and tests for SSR flows and subprocess management.

Changes

Cohort / File(s) Summary
Config & CLI
src/trellis/app/config.py, src/trellis/cli/run.py, src/trellis/cli/bundle.py, src/trellis/cli/package.py, pyproject.toml
Adds ssr and session_ttl config vars/fields; thread bundling into async flow (apploader.bundle → async); CLI calls updated to run async bundle; pytest markers added.
Bundler & Build orchestration
src/trellis/bundler/steps.py, src/trellis/bundler/__init__.py, src/trellis/bundler/build.py, src/trellis/bundler/build.py
Makes BuildStep.run async across implementations; exports SSRBundleBuildStep and SSRPreRenderStep; build pipeline awaits steps.
SSR build & pre-render steps
tests/py/unit/test_ssr_bundle_step.py, tests/py/unit/test_ssr_pre_render_step.py, src/trellis/platforms/browser/serve_platform.py, src/trellis/platforms/desktop/platform.py
Introduces SSR bundle build (Node/ESM target) and pre-render step; build graphs updated to optionally include SSR steps for platforms.
SSR runtime orchestration
src/trellis/platforms/server/ssr.py, src/trellis/platforms/server/ssr_cache.py, src/trellis/platforms/server/ssr_renderer.py, src/trellis/platforms/server/session_store.py
Adds SSROrchestrator (render/dehydrate/store), SSRCache (thread-safe fragment cache), SSRRenderer (Bun sidecar manager over UDS), and TTL-based SessionStore.
Server platform & routing
src/trellis/platforms/server/platform.py, src/trellis/platforms/server/routes.py, src/trellis/platforms/server/handler.py
Wires up SSR infrastructure on startup (session store, orchestrator, cleanup loop), integrates hot-reload cache invalidation, and routes document requests through orchestrator or static fallback.
WebSocket / session resumption
src/trellis/platforms/common/handler.py, src/trellis/platforms/common/messages.py, src/trellis/platforms/common/client/src/types.ts, src/trellis/platforms/server/handler.py
Adds optional session_store to handlers and session_id in Hello messages; MessageHandler supports resuming sessions, executing deferred hooks, and adjusted initial render behavior.
Client hydration & mounting utilities
src/trellis/platforms/common/client/src/ssr.ts, src/trellis/platforms/common/client/src/init.ts, src/trellis/platforms/common/client/src/ClientApp.tsx, src/trellis/platforms/server/client/src/TrellisClient.ts, src/trellis/platforms/common/client/src/types.ts
Adds shared ssr utilities and mountApp with hydrateRoot support; init.ts centralizes polyfills and registry init; ClientApp centralizes lifecycle; TrellisClient accepts optional ssrSessionId.
Browser/Desktop entry & templates
src/trellis/platforms/browser/client/src/main.tsx, src/trellis/platforms/browser/client/src/index.html.j2, src/trellis/platforms/browser/client/src/TrellisApp.tsx, src/trellis/platforms/desktop/client/src/main.tsx, src/trellis/platforms/desktop/client/src/index.html.j2, src/trellis/platforms/server/client/src/main.tsx, src/trellis/platforms/server/client/src/index.html.j2
Entrypoints refactored to shared mount flow; templates gain <!--ssr-outlet-->, conditional injection of ssr_html and window.__TRELLIS_SSR__; TrellisApp gains hydrated path and loading overlay.
Core SSR rendering API
src/trellis/core/rendering/ssr.py, src/trellis/core/rendering/__init__.py
New SSR rendering helpers: SSRRenderResult, render_for_ssr(session) returns patches + deferred hooks, and execute_deferred_hooks to replay them.
SSR dehydration helper
src/trellis/platforms/common/ssr_utils.py, tests/py/unit/test_ssr_utils.py
Adds build_dehydration_data(session_id, patches) producing pre-encoded JSON string for embedding in HTML.
Subprocess & lifecycle utilities
src/trellis/utils/subprocess.py, src/trellis/utils/hot_reload.py, tests/py/unit/test_subprocess_utils.py
Adds cross-platform child process start/stop (POSIX process groups + prctl; Windows job objects) with sync/async APIs; HotReload gains on-reload callbacks for cache invalidation; tests added.
SSR Bun worker
src/trellis/platforms/server/client/src/ssr-entry.tsx
New Bun HTTP worker entry (UNIX socket) handling /health and /render endpoints used by SSRRenderer.
AppLoader & build API changes
src/trellis/app/apploader.py, src/trellis/bundler/build.py, tests updated
AppLoader.bundle and bundler.build made async; many tests updated to run coroutines with asyncio.run.
Tests — SSR, build, and integrations
tests/py/unit/*, tests/py/integration/*, tests/e2e/*
Extensive test additions/updates: session store, SSR render/orchestrator/cache, pre-render/bundle steps, async build step coverage, subprocess utils, and an E2E SSR hydration test using Playwright/Node helper.
Misc
.gitignore, src/trellis/platforms/common/client/src/TrellisRoot.tsx, other small files
Minor ignores and UI component simplifications to align with new mounting/hydration model.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Browser Client
    participant Server as Web Server (HTTP / WebSocket)
    participant Orchestrator as SSROrchestrator
    participant SessionStore as SessionStore
    participant SSRRenderer as SSRRenderer (Bun)

    Client->>Server: GET document (Accept: text/html)
    activate Server
    Server->>Orchestrator: render_to_response(path, template)
    activate Orchestrator
    Orchestrator->>SessionStore: create session_id & store(SessionEntry)
    Orchestrator->>SSRRenderer: render(serialized_tree) (if renderer available)
    SSRRenderer-->>Orchestrator: HTML fragment (or None)
    Orchestrator->>Orchestrator: build dehydration payload (sessionId + patches)
    Orchestrator-->>Server: full HTML (template with ssr_html + <script>__TRELLIS_SSR__</script>)
    deactivate Orchestrator
    Server-->>Client: 200 HTML response
    deactivate Server
Loading
sequenceDiagram
    participant Build as Build System
    participant BundleStep as SSRBundleBuildStep
    participant PreRender as SSRPreRenderStep
    participant Renderer as SSRRenderer (Bun)
    participant Index as IndexHtmlRenderStep

    Build->>BundleStep: run(ctx) (async)
    BundleStep->>esbuild: produce ssr.js (node/esm)
    BundleStep-->>Build: artifact recorded (metafile)
    Build->>PreRender: run(ctx)
    PreRender->>Renderer: start() and render(serialized_tree)
    Renderer-->>PreRender: HTML fragment
    PreRender->>Build: set ctx.template_context (ssr_html, ssr_data)
    Build->>Index: run(ctx)
    Index->>Index: inject ssr_html & dehydration script into template
    Index-->>Build: final HTML artifact
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I hopped through code and found a song,

Sessions saved where patches belong,
Bun and browser hum in tune,
Dehydration dances under the moon,
Hooray — SSR makes web apps strong!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: add server-side rendering (SSR)' is concise and directly describes the main feature added across the entire changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 13

🧹 Nitpick comments (7)
tests/py/unit/test_config.py (1)

305-329: Add direct behavioral coverage for ssr.

This only verifies that to_json() includes the new field. A small set of focused tests for the ssr default plus env/CLI overrides would better cover the bool resolution path that actually gates SSR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/py/unit/test_config.py` around lines 305 - 329, Add focused tests
around the Config.ssr resolution: create one test that constructs
Config(name="myapp", module="main") and asserts config.ssr is the expected
default (False/True per current default) and that config.to_json() contains the
same boolean; add a second test that uses monkeypatch/env setting to set the SSR
environment variable (the env key your code reads for SSR) before building the
Config and assert config.ssr reflects the env override; add a third test that
simulates the CLI flag (call the CLI/argparse entrypoint or the Config factory
used for CLI parsing) to pass the SSR enable/disable flag and assert config.ssr
follows the CLI value and that to_json() shows the same value; reference Config,
to_json, and the test function test_serializes_all_fields as places to add these
small tests.
tests/py/unit/test_ssr_bundle_step.py (1)

15-71: Consider extracting common setup into a fixture.

Both test_uses_node_platform and test_output_name_defaults_to_ssr share identical mock setup code (MagicMock for ctx, patching get_bin/node_modules_path/get_metafile_path/read_metafile). Extracting this into a pytest fixture would reduce duplication and improve maintainability.

♻️ Proposed refactor using a fixture
+import pytest
+
+
+@pytest.fixture
+def ssr_build_context():
+    """Create a mocked build context for SSR bundle step tests."""
+    ctx = MagicMock()
+    ctx.workspace = MagicMock()
+    ctx.dist_dir = MagicMock()
+    ctx.collected = MagicMock()
+    ctx.collected.modules = []
+    ctx.esbuild_args = []
+    ctx.manifest = MagicMock()
+    ctx.manifest.steps = {}
+    return ctx
+
+
 class TestSSRBundleBuildStep:
     def test_step_name(self) -> None:
         step = SSRBundleBuildStep()
         assert step.name == "ssr-bundle-build"

-    def test_uses_node_platform(self) -> None:
+    def test_uses_node_platform(self, ssr_build_context: MagicMock) -> None:
         step = SSRBundleBuildStep()
-        ctx = MagicMock()
-        ctx.workspace = MagicMock()
-        # ... rest of setup ...
+        ctx = ssr_build_context

         with (
             patch("trellis.bundler.steps.get_bin") as mock_get_bin,
             # ... rest unchanged ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/py/unit/test_ssr_bundle_step.py` around lines 15 - 71, Both tests
(test_uses_node_platform and test_output_name_defaults_to_ssr) duplicate the
same setup for SSRBundleBuildStep and the patched helpers; extract that into a
pytest fixture (e.g., ssr_build_ctx) that creates the SSRBundleBuildStep
instance, constructs ctx with
ctx.workspace/ctx.dist_dir/ctx.collected/ctx.manifest and patches get_bin,
node_modules_path, get_metafile_path, read_metafile (returning the same fake
values), then yields ctx (and optionally the patched mocks) so each test simply
accepts the fixture, calls step.run(ctx), and asserts on ctx.exec_in_build_env;
update the tests to remove the duplicated setup and use the new fixture name.
tests/py/unit/test_handler_resumption.py (1)

61-74: Extract the repeated SSR-session setup into a fixture.

The RenderSessionrender_for_ssr()SessionEntry setup is duplicated four times, which makes these tests harder to scan than the behavior they're asserting. A small fixture returning a prepared store/entry would keep each case focused on the resumption branch.

As per coding guidelines, "Use fixtures for shared test code instead of class members and inheritance; refactor common test code into fixtures and check conftest.py for existing fixtures."

Also applies to: 95-106, 123-135, 157-169

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/py/unit/test_handler_resumption.py` around lines 61 - 74, Extract the
repeated SSR-session setup into a pytest fixture that prepares and returns the
SessionStore and SessionEntry (or a tuple of store and the stored key) so tests
can reuse it; move the block that calls app_wrapper(noop_component, "light",
None), instantiates RenderSession, runs render_for_ssr(session), serializes
patches via _serialize_patches, creates SessionEntry(session,
deferred_mounts=ssr_result.deferred_mounts,
deferred_unmounts=ssr_result.deferred_unmounts, patches=wire_patches) and stores
it in a SessionStore(ttl_seconds=30) into a fixture (e.g., prepared_ssr_store)
and replace the duplicated blocks in the tests with calls to that fixture;
ensure the fixture imports or references app_wrapper, RenderSession,
render_for_ssr, _serialize_patches, SessionEntry, SessionStore, and
noop_component so tests remain unchanged.
src/trellis/platforms/server/platform.py (1)

125-127: Hoist these imports or document why they must stay local.

Lazy-loading can be fine here, but this file should say so explicitly. Right now the SSR imports and fallback logging import look accidental.

As per coding guidelines, "Place imports at the top of the file unless there is a compelling reason not to; mid-file imports add complexity and should include a comment explaining why".

Also applies to: 162-165

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/platform.py` around lines 125 - 127, Move the
mid-file imports for SessionStore, SSROrchestrator, SSRRenderer (and the
fallback logging import around lines 162-165) to the top of the module unless
there is a deliberate lazy-load reason; if lazy-loading is required (e.g., to
avoid circular imports or heavy startup cost), add a concise explanatory comment
immediately above each local import mentioning the reason (e.g., "lazy import to
avoid circular dependency with X" or "defer heavy SSR init until runtime") and
ensure the import statements remain grouped and marked with noqa if needed;
update usages in the module (functions/methods referencing SessionStore,
SSROrchestrator, SSRRenderer) accordingly so the imports are clearly documented
and consistent.
tests/py/unit/test_ssr_orchestrator.py (2)

62-70: This test doesn't actually prove the session was stored.

Checking for "sessionId" in the HTML only proves the dehydration script was emitted. If SessionStore.store() stops being called, this still passes. Please parse the emitted ID and assert that SessionStore.pop(session_id) returns a SessionEntry.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/py/unit/test_ssr_orchestrator.py` around lines 62 - 70, The test
currently only checks that the dehydration script emitted a "sessionId" token;
instead parse the actual session_id from the rendered result returned by
label_orchestrator.render_to_response (extract the value emitted by the
dehydration script), then call the orchestrator's SessionStore.pop(session_id)
and assert it returns a SessionEntry (non-None / correct type). In short:
extract session_id from result, call SessionStore.pop(session_id) on the
SSROrchestrator's session store and assert the returned object is a SessionEntry
to verify SessionStore.store() was actually invoked.

72-106: Please add a cache-hit regression test here.

Every test in this file does a single cold render. The new production path has separate cache-hit and invalidate_cache() behavior, and neither is exercised, so cache regressions will slip through.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/py/unit/test_ssr_orchestrator.py` around lines 72 - 106, Add a new
regression test that exercises both the cache-hit path and cache invalidation by
calling SSROrchestrator.render_to_response twice for the same path (e.g.,
path="/") and asserting the second response is produced from cache (for example
the dehydration payload like "sessionId" or "patches" is present and the body
content equals the first response), then call
SSROrchestrator.invalidate_cache(path) and call render_to_response a third time
and assert the response differs from the cached response (e.g., different
sessionId or different HTML) to prove invalidation works; reference the
SSROrchestrator methods render_to_response and invalidate_cache and reuse the
existing html_template and assertion style used in neighboring tests.
src/trellis/platforms/server/ssr.py (1)

31-38: Hoist these imports or add a short lazy-import rationale.

Both helpers use local imports without explaining why they need to stay there. In src/trellis/, that reads as accidental rather than intentional.

As per coding guidelines, "Place imports at the top of the file unless there is a compelling reason not to; mid-file imports add complexity and should include a comment explaining why".

Also applies to: 167-172

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/ssr.py` around lines 31 - 38, Move the local
import "from importlib.metadata import version as get_package_version" out of
the _get_version() function and place it at the top of the module (module-level
import), or if the import must remain local, add a short comment above the local
import inside _get_version() explaining the intentional lazy-import rationale
(e.g., to avoid import-time overhead or optional dependency failures) and keep
the existing exception fallback; apply the same change/comment pattern to the
other helper that has a mid-file import around lines 167-172 so all
non-top-level imports are explicitly justified.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/trellis/app/config.py`:
- Line 213: Update the Config class docstring to include the new public boolean
field ssr in the Attributes list so docs match the dataclass surface: locate the
Config dataclass and its docstring (class Config) and add an entry describing
ssr (type bool and brief purpose, e.g., "ssr: Whether server-side rendering is
enabled.") between the existing port and window_size entries, ensuring wording
and formatting match the other attribute lines and no outdated comment text
remains.

In `@src/trellis/bundler/steps.py`:
- Around line 735-752: The SSRBundleBuildStep's should_build currently ignores
the instance's output_name, so switching SSRBundleBuildStep(output_name=...) can
wrongly return SKIP; update should_build to include output_name in the
cache/compare check by comparing self.output_name against a stored value in
StepManifest (e.g., manifest.metadata["output_name"]) or by adding a synthetic
key (like f"output_name:{self.output_name}") to the set passed to
is_rebuild_needed, and return ShouldBuild.BUILD when they differ; apply the same
change to the analogous check referenced at the other occurrence (around the
791-795 area) so both places account for output_name in the rebuild decision.

In `@src/trellis/platforms/common/handler.py`:
- Around line 332-339: When resuming a session, install router callbacks before
running deferred mounts: move or add a call to self._setup_router_callbacks()
(or ensure the same initialization happens) immediately after restoring the
session (after assigning self.session and self.session_id and registering the
session via get_session_registry()) and before calling
execute_deferred_hooks(self.session, ...). Apply the identical change to the
other resume path that also calls execute_deferred_hooks (the similar block
around the second execute_deferred_hooks call) so router callbacks are always
set up prior to replaying deferred on_mount/on_unmount hooks.

In `@src/trellis/platforms/server/routes.py`:
- Around line 37-40: The 404 handler spa_fallback_handler currently routes every
missing path through _ssr_or_static_response, causing assets and API misses to
trigger full SSR; change spa_fallback_handler to first detect real
navigation/document requests (e.g., only handle GET requests whose Accept header
prefers text/html, and whose path is not an API/static asset — skip paths
starting with /api or those with a file extension like .js/.css/.png/.map/etc.).
If the request is a document navigation, call _ssr_or_static_response(request);
otherwise return a normal 404 response (or re-raise the StarletteHTTPException)
so non-document 404s remain cheap and keep proper status codes. Ensure you
reference Request and StarletteHTTPException in the handler's checks.
- Around line 61-72: The responses serving theme-dependent HTML must advertise
the client hint so caches won't serve the wrong variant: add a Vary header with
"Sec-CH-Prefers-Color-Scheme" to the HTMLResponse returned from the SSR path
(where ssr.render_to_response is used and HTMLResponse is returned) and to the
fallback HTMLResponse that returns get_index_html(); update the response
creation (the HTMLResponse instances) to include headers={"Vary":
"Sec-CH-Prefers-Color-Scheme"} so caches and CDNs vary by the detected theme
produced by _detect_theme.

In `@src/trellis/platforms/server/session_store.py`:
- Around line 39-62: The SessionStore currently only removes entries via pop()
and tests call cleanup_expired(), so add a periodic cleanup task tied to the
server/platform lifecycle that calls cleanup_expired() on a configurable
interval; implement this by registering a lifespan/startup handler that starts a
background worker (async task or daemon thread) which invokes
SessionStore.cleanup_expired() every N seconds and stops on shutdown to avoid
leaks, and expose the interval as a config option and ensure the worker respects
the store's _lock and shutdown signals so no race or dangling thread remains.

In `@src/trellis/platforms/server/ssr_cache.py`:
- Around line 18-35: The cache currently stores raw (route, theme) keys
indefinitely; update the SSR cache implementation (constructor __init__,
internal _cache, and methods get/put/invalidate) to bound the key space by
either normalizing routes and/or adding an eviction policy: add a max_entries
(or ttl_seconds) parameter to __init__, change _cache to store metadata (e.g.,
OrderedDict or dict of (html, timestamp)), implement a normalize_route(route)
helper to strip query strings, session IDs and common probe patterns, and modify
put() to insert normalized keys and evict oldest entries when max_entries is
exceeded (or record timestamps and drop expired entries on get/put); keep
invalidate() behavior to clear the store and continue using _lock for
thread-safety.

In `@src/trellis/platforms/server/ssr_renderer.py`:
- Around line 52-57: The child process is started with stdout/stderr set to PIPE
but those streams are never drained, risking a hang when buffers fill; update
the Popen call (where self._process is created using [str(bun),
str(self._bundle_path)]) to either redirect stdout and stderr to
subprocess.DEVNULL or to files, or implement background readers that
continuously read from self._process.stdout and self._process.stderr and forward
logs to your logger (spawn threads or asyncio tasks) so the pipes are actively
drained for the lifetime of the process.
- Around line 46-49: The code currently uses tempfile.mktemp() to create
self._socket_path which is insecure; change to create a private temp directory
via tempfile.mkdtemp() (e.g., set self._socket_dir =
tempfile.mkdtemp(prefix="trellis-ssr-") in __init__) and then set
self._socket_path to a file inside that dir (e.g.,
os.path.join(self._socket_dir, "trellis-ssr.sock")) and export
TRELLIS_SSR_SOCKET accordingly; also initialize self._socket_dir = None in
__init__ and update stop() to unlink the socket and remove the directory
(os.remove/unlink then os.rmdir or shutil.rmtree) to ensure atomic creation and
proper cleanup.

In `@src/trellis/platforms/server/ssr.py`:
- Around line 69-75: Cache currently keys only by path and system_theme causing
wrong HTML to be reused when theme_mode differs; update the cache key usage so
_cache.get and places that write to the cache use a composite key that includes
theme_mode (e.g., (path, system_theme, theme_mode) or a joined string) so the
lookup in the block calling _cache.get(path, system_theme) becomes
_cache.get(path, system_theme, theme_mode) and ensure corresponding cache writes
in _full_render/_app_wrapper use the same composite key; update any helper
methods that construct cache keys to accept theme_mode so _serve_from_cache and
_full_render continue to operate with the new key shape.
- Around line 115-160: _full_render currently never writes to the SSR cache;
after building the template that will be stored in SSRCache you must call
self._cache.put(...) with a template that contains the _SESSION_ID_PLACEHOLDER
so subsequent requests can be resumed. Concretely: after creating
dehydration_data and before returning, construct a cacheable_template by
injecting a placeholder script (e.g. "<script>window.__TRELLIS_SSR__ = " +
_SESSION_ID_PLACEHOLDER + ";</script>") into html_template (replacing the same
<!--ssr-outlet--> and closing </body> slot you use for the real response), then
call self._cache.put(path, cacheable_template) (or whatever cache key SSRCache
expects) and finally return the real response that embeds the actual
session_id/dehydration_data; update _full_render to reference session_id,
dehydration_data, self._cache.put, and _SESSION_ID_PLACEHOLDER as needed.
- Around line 154-160: The dehydration payload generated by
_build_dehydration_data is embedded raw into inline JS (dehydration_script)
which allows payloads containing "</script>" or other sequences to break out and
execute; fix by serializing the payload to a safe JSON string and escaping the
closing script sequence before embedding (e.g., JSON-serialize dehydration_data
and replace "</" with "<\\/") so dehydration_script no longer contains raw
"</script>" or unescaped HTML/JS content when inserted into html_template in
ssr.py.

---

Nitpick comments:
In `@src/trellis/platforms/server/platform.py`:
- Around line 125-127: Move the mid-file imports for SessionStore,
SSROrchestrator, SSRRenderer (and the fallback logging import around lines
162-165) to the top of the module unless there is a deliberate lazy-load reason;
if lazy-loading is required (e.g., to avoid circular imports or heavy startup
cost), add a concise explanatory comment immediately above each local import
mentioning the reason (e.g., "lazy import to avoid circular dependency with X"
or "defer heavy SSR init until runtime") and ensure the import statements remain
grouped and marked with noqa if needed; update usages in the module
(functions/methods referencing SessionStore, SSROrchestrator, SSRRenderer)
accordingly so the imports are clearly documented and consistent.

In `@src/trellis/platforms/server/ssr.py`:
- Around line 31-38: Move the local import "from importlib.metadata import
version as get_package_version" out of the _get_version() function and place it
at the top of the module (module-level import), or if the import must remain
local, add a short comment above the local import inside _get_version()
explaining the intentional lazy-import rationale (e.g., to avoid import-time
overhead or optional dependency failures) and keep the existing exception
fallback; apply the same change/comment pattern to the other helper that has a
mid-file import around lines 167-172 so all non-top-level imports are explicitly
justified.

In `@tests/py/unit/test_config.py`:
- Around line 305-329: Add focused tests around the Config.ssr resolution:
create one test that constructs Config(name="myapp", module="main") and asserts
config.ssr is the expected default (False/True per current default) and that
config.to_json() contains the same boolean; add a second test that uses
monkeypatch/env setting to set the SSR environment variable (the env key your
code reads for SSR) before building the Config and assert config.ssr reflects
the env override; add a third test that simulates the CLI flag (call the
CLI/argparse entrypoint or the Config factory used for CLI parsing) to pass the
SSR enable/disable flag and assert config.ssr follows the CLI value and that
to_json() shows the same value; reference Config, to_json, and the test function
test_serializes_all_fields as places to add these small tests.

In `@tests/py/unit/test_handler_resumption.py`:
- Around line 61-74: Extract the repeated SSR-session setup into a pytest
fixture that prepares and returns the SessionStore and SessionEntry (or a tuple
of store and the stored key) so tests can reuse it; move the block that calls
app_wrapper(noop_component, "light", None), instantiates RenderSession, runs
render_for_ssr(session), serializes patches via _serialize_patches, creates
SessionEntry(session, deferred_mounts=ssr_result.deferred_mounts,
deferred_unmounts=ssr_result.deferred_unmounts, patches=wire_patches) and stores
it in a SessionStore(ttl_seconds=30) into a fixture (e.g., prepared_ssr_store)
and replace the duplicated blocks in the tests with calls to that fixture;
ensure the fixture imports or references app_wrapper, RenderSession,
render_for_ssr, _serialize_patches, SessionEntry, SessionStore, and
noop_component so tests remain unchanged.

In `@tests/py/unit/test_ssr_bundle_step.py`:
- Around line 15-71: Both tests (test_uses_node_platform and
test_output_name_defaults_to_ssr) duplicate the same setup for
SSRBundleBuildStep and the patched helpers; extract that into a pytest fixture
(e.g., ssr_build_ctx) that creates the SSRBundleBuildStep instance, constructs
ctx with ctx.workspace/ctx.dist_dir/ctx.collected/ctx.manifest and patches
get_bin, node_modules_path, get_metafile_path, read_metafile (returning the same
fake values), then yields ctx (and optionally the patched mocks) so each test
simply accepts the fixture, calls step.run(ctx), and asserts on
ctx.exec_in_build_env; update the tests to remove the duplicated setup and use
the new fixture name.

In `@tests/py/unit/test_ssr_orchestrator.py`:
- Around line 62-70: The test currently only checks that the dehydration script
emitted a "sessionId" token; instead parse the actual session_id from the
rendered result returned by label_orchestrator.render_to_response (extract the
value emitted by the dehydration script), then call the orchestrator's
SessionStore.pop(session_id) and assert it returns a SessionEntry (non-None /
correct type). In short: extract session_id from result, call
SessionStore.pop(session_id) on the SSROrchestrator's session store and assert
the returned object is a SessionEntry to verify SessionStore.store() was
actually invoked.
- Around line 72-106: Add a new regression test that exercises both the
cache-hit path and cache invalidation by calling
SSROrchestrator.render_to_response twice for the same path (e.g., path="/") and
asserting the second response is produced from cache (for example the
dehydration payload like "sessionId" or "patches" is present and the body
content equals the first response), then call
SSROrchestrator.invalidate_cache(path) and call render_to_response a third time
and assert the response differs from the cached response (e.g., different
sessionId or different HTML) to prove invalidation works; reference the
SSROrchestrator methods render_to_response and invalidate_cache and reuse the
existing html_template and assertion style used in neighboring tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e3a8e337-f4a9-4ab4-b8a2-21ab5e858d1e

📥 Commits

Reviewing files that changed from the base of the PR and between 1e8f990 and cbd5e1a.

📒 Files selected for processing (28)
  • src/trellis/app/config.py
  • src/trellis/bundler/__init__.py
  • src/trellis/bundler/steps.py
  • src/trellis/core/rendering/__init__.py
  • src/trellis/core/rendering/ssr.py
  • src/trellis/platforms/common/client/src/types.ts
  • src/trellis/platforms/common/handler.py
  • src/trellis/platforms/common/messages.py
  • src/trellis/platforms/server/client/src/TrellisClient.ts
  • src/trellis/platforms/server/client/src/index.html.j2
  • src/trellis/platforms/server/client/src/main.tsx
  • src/trellis/platforms/server/client/src/ssr-entry.tsx
  • src/trellis/platforms/server/handler.py
  • src/trellis/platforms/server/platform.py
  • src/trellis/platforms/server/routes.py
  • src/trellis/platforms/server/session_store.py
  • src/trellis/platforms/server/ssr.py
  • src/trellis/platforms/server/ssr_cache.py
  • src/trellis/platforms/server/ssr_renderer.py
  • src/trellis/utils/hot_reload.py
  • tests/py/unit/test_build_config.py
  • tests/py/unit/test_config.py
  • tests/py/unit/test_handler_resumption.py
  • tests/py/unit/test_session_store.py
  • tests/py/unit/test_ssr_bundle_step.py
  • tests/py/unit/test_ssr_cache.py
  • tests/py/unit/test_ssr_orchestrator.py
  • tests/py/unit/test_ssr_render.py

Comment thread src/trellis/app/config.py
Comment thread src/trellis/bundler/steps.py
Comment thread src/trellis/platforms/common/handler.py Outdated
Comment thread src/trellis/platforms/server/routes.py Outdated
Comment thread src/trellis/platforms/server/routes.py Outdated
Comment thread src/trellis/platforms/server/ssr_renderer.py Outdated
Comment thread src/trellis/platforms/server/ssr_renderer.py Outdated
Comment thread src/trellis/platforms/server/ssr.py Outdated
Comment thread src/trellis/platforms/server/ssr.py Outdated
Comment thread src/trellis/platforms/server/ssr.py Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/trellis/platforms/common/client/src/TrellisRoot.tsx (1)

14-15: Drop the extra useRootId() wrapper.

TrellisContent is repeating the same rootId subscription/null guard that src/trellis/platforms/common/client/src/TreeRenderer.tsx already does on Lines 17-25. That adds an extra store subscription and leaves a misleading "depending on TrellisContext" abstraction without changing behavior.

♻️ Proposed simplification
 import React from "react";
 import { TrellisClient } from "./TrellisClient";
 import { TrellisContext } from "./TrellisContext";
 import { TreeRenderer } from "./TreeRenderer";
-import { useRootId } from "./core";
@@
   return (
     <TrellisContext.Provider value={client}>
-      <TrellisContent />
+      <TreeRenderer />
     </TrellisContext.Provider>
   );
 }
-
-/**
- * Inner component that uses hooks depending on TrellisContext.
- * Renders nothing until the tree root is available.
- */
-function TrellisContent(): React.ReactElement | null {
-  const rootId = useRootId();
-
-  if (!rootId) {
-    return null;
-  }
-
-  return <TreeRenderer />;
-}

Also applies to: 39-42, 47-59

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/common/client/src/TrellisRoot.tsx` around lines 14 -
15, TrellisContent is redundantly calling useRootId and guarding for null when
TreeRenderer already subscribes and performs the same rootId/null checks; remove
the extra wrapper by deleting the import and any calls to useRootId inside
TrellisContent, eliminate the duplicate null-guard logic in TrellisContent, and
rely on the existing subscription/guard in TreeRenderer and TrellisContext to
provide rootId — ensure TrellisContent simply consumes TrellisContext (or props)
and forwards values to the same children/components without creating another
store subscription.
src/trellis/platforms/server/client/src/main.tsx (1)

22-26: Unused serverVersion field in TrellisSSRData.

The serverVersion field is defined in the interface but not used anywhere in this file. If it's intended for future version compatibility checks or is used in other parts of the system, consider adding a brief comment explaining its purpose. Otherwise, if it's not needed, it could be removed to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/client/src/main.tsx` around lines 22 - 26, The
TrellisSSRData interface declares a serverVersion field that's not used in this
file; either remove serverVersion from the TrellisSSRData interface to avoid
dead/ confusing fields, or if it's intended for cross-process compatibility, add
a concise comment above TrellisSSRData explaining its purpose and where it's
consumed (e.g., version checks), and ensure any code that should read it
references TrellisSSRData.serverVersion accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/trellis/platforms/desktop/client/src/main.tsx`:
- Line 72: The desktop entry removed the loading/reconnect UI by rendering
TrellisRoot with only {client, error}; restore the desktop loading fallback so
the window isn't blank while awaiting the first tree. Update the return in
main.tsx to either (a) render the previous connection/loading component when the
client is initializing or no component tree is present, or (b) pass a
loadingFallback or connection UI prop into TrellisRoot so it shows the desktop
connection UI until the first tree arrives; reference the existing client and
error values and keep ErrorDisplay behavior for real errors. Ensure this change
only affects the desktop entry that uses createRoot() so startup/reconnect shows
the connection UI.

---

Nitpick comments:
In `@src/trellis/platforms/common/client/src/TrellisRoot.tsx`:
- Around line 14-15: TrellisContent is redundantly calling useRootId and
guarding for null when TreeRenderer already subscribes and performs the same
rootId/null checks; remove the extra wrapper by deleting the import and any
calls to useRootId inside TrellisContent, eliminate the duplicate null-guard
logic in TrellisContent, and rely on the existing subscription/guard in
TreeRenderer and TrellisContext to provide rootId — ensure TrellisContent simply
consumes TrellisContext (or props) and forwards values to the same
children/components without creating another store subscription.

In `@src/trellis/platforms/server/client/src/main.tsx`:
- Around line 22-26: The TrellisSSRData interface declares a serverVersion field
that's not used in this file; either remove serverVersion from the
TrellisSSRData interface to avoid dead/ confusing fields, or if it's intended
for cross-process compatibility, add a concise comment above TrellisSSRData
explaining its purpose and where it's consumed (e.g., version checks), and
ensure any code that should read it references TrellisSSRData.serverVersion
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4b5b6fac-f359-4dc0-8362-a7c3b64c9701

📥 Commits

Reviewing files that changed from the base of the PR and between cbd5e1a and 81adcbd.

📒 Files selected for processing (3)
  • src/trellis/platforms/common/client/src/TrellisRoot.tsx
  • src/trellis/platforms/desktop/client/src/main.tsx
  • src/trellis/platforms/server/client/src/main.tsx

Comment thread src/trellis/platforms/desktop/client/src/main.tsx Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

♻️ Duplicate comments (4)
src/trellis/platforms/server/ssr.py (2)

60-65: ⚠️ Potential issue | 🟠 Major

Include theme_mode in the SSR HTML cache key.

App.get_wrapped_top() derives the rendered tree from both system_theme and theme_mode, but Lines 111-125 only cache by path and system_theme. An explicit light/dark override on the same OS theme can therefore reuse the wrong HTML and hydrate against a different tree.

🐛 Minimal fix
-        ssr_html = self._get_ssr_html(path, system_theme, session)
+        ssr_html = self._get_ssr_html(path, system_theme, theme_mode, session)
@@
-    def _get_ssr_html(self, path: str, system_theme: str, session: RenderSession) -> str:
+    def _get_ssr_html(
+        self,
+        path: str,
+        system_theme: str,
+        theme_mode: str | None,
+        session: RenderSession,
+    ) -> str:
         """Get rendered HTML, using the cache when possible."""
-        cached = self._cache.get(path, system_theme)
+        cache_theme = f"{system_theme}:{theme_mode or 'system'}"
+        cached = self._cache.get(path, cache_theme)
@@
-                self._cache.put(path, system_theme, ssr_html)
+                self._cache.put(path, cache_theme, ssr_html)

Also applies to: 97-98, 111-125

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/ssr.py` around lines 60 - 65, The SSR cache key
currently uses only path and system_theme while App.get_wrapped_top() depends on
both system_theme and theme_mode, so update all cache key constructions and
lookups in render_to_response (and the related cache access sites around the
html cache logic) to include theme_mode as part of the key (e.g., compose key =
(path, system_theme, theme_mode) or string-join them) so that when theme_mode is
explicitly set (light/dark/None) the cached HTML matches the rendered tree;
ensure the same adjusted key is used for cache set and cache get operations to
avoid mismatches and refer to render_to_response and App.get_wrapped_top() when
locating the related code.

100-105: ⚠️ Potential issue | 🔴 Critical

Escape the dehydration payload before embedding it in inline JS.

Line 104 injects the serialized dehydration data raw into <script>. wire_patches can contain user text/prop values, so a payload containing </script> breaks out of the tag and becomes executable HTML/JS.

🛡️ Minimal hardening
-        dehydration_script = f"<script>window.__TRELLIS_SSR__ = {dehydration_data};</script>"
+        safe_dehydration_data = (
+            dehydration_data.replace("<", "\\u003c")
+            .replace("\u2028", "\\u2028")
+            .replace("\u2029", "\\u2029")
+        )
+        dehydration_script = (
+            "<script>"
+            f"window.__TRELLIS_SSR__ = {safe_dehydration_data};"
+            "</script>"
+        )

Also applies to: 130-148

src/trellis/platforms/server/ssr_cache.py (1)

24-41: ⚠️ Potential issue | 🟠 Major

Bound the SSR HTML cache.

This cache still grows without limit and only clears on hot reload. Because src/trellis/platforms/server/ssr.py feeds it raw request paths plus theme, long-lived servers can accumulate one permanent entry per probed URL. Add size/TTL eviction or normalize the route key before storing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/ssr_cache.py` around lines 24 - 41, The SSR
cache implemented via _cache and methods get/put/invalidate can grow unbounded
because keys are raw route+theme; update the cache to bound size and/or apply
TTL and normalize route keys before storing: replace the plain dict _cache with
a bounded LRU structure (or dict of entries with timestamps) inside the same
class, ensure put(route, theme, ssr_html) normalizes route (strip query strings,
trailing slashes, and session-specific tokens) and evicts oldest/expired entries
when capacity or TTL is exceeded, update get(route, theme) to normalize the
incoming route and to check/evict expired entries, and keep invalidate()
clearing the underlying store; reference the existing symbols _cache, _lock,
get, put, and invalidate when making the changes.
src/trellis/platforms/server/session_store.py (1)

34-62: ⚠️ Potential issue | 🟠 Major

Make TTL expiry reclaim memory, not just reject resumes.

ttl_seconds is only enforced on pop() and cleanup_expired(). If a client never resumes its SSR session, the entry stays in _entries indefinitely unless production code schedules cleanup_expired(). Either sweep opportunistically on store()/pop() or wire a periodic cleanup into the server lifecycle.

Verify whether production code actually runs the sweep. Expected result: at least one non-test lifecycle/background call site invoking SessionStore.cleanup_expired().

#!/bin/bash
set -euo pipefail

echo "### cleanup_expired() call sites in production code"
rg -n -C2 '\bcleanup_expired\s*\(' src | sed -n '1,120p'

echo
echo "### SessionStore construction sites"
rg -n -C2 '\bSessionStore\s*\(' src | sed -n '1,120p'
🧹 Nitpick comments (1)
tests/e2e/_run_test_server.py (1)

14-24: Align this helper with Trellis import and component conventions.

Use the public trellis imports here, import widgets via w, and rename the component to TestApp. That keeps the E2E harness consistent with the rest of the repo and avoids component functions reading like plain helpers.

♻️ Suggested cleanup
-from trellis.app.app import App
+from trellis import App, component
+from trellis import widgets as w
 from trellis.app.apploader import AppLoader, set_apploader
 from trellis.app.config import Config
-from trellis.core.components.composition import component
 from trellis.platforms.server.platform import ServerPlatform
-from trellis.widgets import Label
@@
 `@component`
-def test_app() -> None:
-    Label(text="Hello from SSR")
+def TestApp() -> None:
+    w.Label(text="Hello from SSR")
@@
-    app_instance = App(test_app)
+    app_instance = App(TestApp)
@@
-            test_app,
+            TestApp,

As per coding guidelines, **/*.py: import Trellis core primitives and App directly, and import widgets via the w alias. Based on learnings, any function decorated with component should be named using PascalCase.

Also applies to: 43-50

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/_run_test_server.py` around lines 14 - 24, Replace the private
trellis imports and lower_snake component name with the public-style imports and
PascalCase component: import App, AppLoader, set_apploader, and Config from
top-level trellis (e.g., "from trellis import App, AppLoader, set_apploader,
Config"), import widgets as the alias "w" (e.g., use "w.Label"), and rename the
component function test_app to TestApp while keeping the `@component` decorator;
update any other similar helper definitions in the file (the block around the
second occurrence) so they follow the same public import and PascalCase
component conventions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/e2e/check_hydration.js`:
- Around line 61-68: The forEach callbacks using implicit-return arrow functions
cause lint rule lint/suspicious/useIterableCallbackReturn; update the two
callbacks on pageErrors.forEach and warnings.forEach to use block bodies (e.g.,
replace (e) => console.error("  ", e) with (e) => { console.error("  ", e); }
and similarly for (w) => { console.error("  ", w); }) so the callbacks have
explicit block bodies and the lint error is resolved.

In `@tests/e2e/test_ssr_hydration.py`:
- Around line 85-86: The current code skips whenever result.returncode == 2
which hides real failures; change the logic in tests/e2e/test_ssr_hydration.py
around the block that handles the check_hydration.js invocation so that you only
call pytest.skip when stderr clearly indicates a missing optional prerequisite
(e.g., contains substrings like "Playwright not found", "playwright is not
installed", or other explicit missing-playwright messages emitted by
check_hydration.js); otherwise do not skip—raise a failure (use pytest.fail or
assert) and include result.stdout and result.stderr in the failure message so
real SSR/hydration regressions surface. Ensure you reference the result variable
from the subprocess call to check_hydration.js and the script name
check_hydration.js when implementing the conditional.

---

Duplicate comments:
In `@src/trellis/platforms/server/ssr_cache.py`:
- Around line 24-41: The SSR cache implemented via _cache and methods
get/put/invalidate can grow unbounded because keys are raw route+theme; update
the cache to bound size and/or apply TTL and normalize route keys before
storing: replace the plain dict _cache with a bounded LRU structure (or dict of
entries with timestamps) inside the same class, ensure put(route, theme,
ssr_html) normalizes route (strip query strings, trailing slashes, and
session-specific tokens) and evicts oldest/expired entries when capacity or TTL
is exceeded, update get(route, theme) to normalize the incoming route and to
check/evict expired entries, and keep invalidate() clearing the underlying
store; reference the existing symbols _cache, _lock, get, put, and invalidate
when making the changes.

In `@src/trellis/platforms/server/ssr.py`:
- Around line 60-65: The SSR cache key currently uses only path and system_theme
while App.get_wrapped_top() depends on both system_theme and theme_mode, so
update all cache key constructions and lookups in render_to_response (and the
related cache access sites around the html cache logic) to include theme_mode as
part of the key (e.g., compose key = (path, system_theme, theme_mode) or
string-join them) so that when theme_mode is explicitly set (light/dark/None)
the cached HTML matches the rendered tree; ensure the same adjusted key is used
for cache set and cache get operations to avoid mismatches and refer to
render_to_response and App.get_wrapped_top() when locating the related code.

---

Nitpick comments:
In `@tests/e2e/_run_test_server.py`:
- Around line 14-24: Replace the private trellis imports and lower_snake
component name with the public-style imports and PascalCase component: import
App, AppLoader, set_apploader, and Config from top-level trellis (e.g., "from
trellis import App, AppLoader, set_apploader, Config"), import widgets as the
alias "w" (e.g., use "w.Label"), and rename the component function test_app to
TestApp while keeping the `@component` decorator; update any other similar helper
definitions in the file (the block around the second occurrence) so they follow
the same public import and PascalCase component conventions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 02f4f9db-afa5-44b8-b1a5-964884183e1a

📥 Commits

Reviewing files that changed from the base of the PR and between 81adcbd and d84dc9c.

⛔ Files ignored due to path filters (1)
  • pixi.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • pyproject.toml
  • src/trellis/platforms/server/session_store.py
  • src/trellis/platforms/server/ssr.py
  • src/trellis/platforms/server/ssr_cache.py
  • tests/e2e/_run_test_server.py
  • tests/e2e/check_hydration.js
  • tests/e2e/test_ssr_hydration.py
  • tests/py/unit/test_ssr_orchestrator.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/py/unit/test_ssr_orchestrator.py

Comment thread tests/e2e/check_hydration.js Outdated
Comment thread tests/e2e/test_ssr_hydration.py Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/trellis/cli/run.py (1)

22-28: ⚠️ Potential issue | 🟠 Major

Forward ssr into platform.run().

Config.ssr is resolved, but _build_run_kwargs() never passes it through. ServerPlatform.run() therefore falls back to its default ssr=True, so --no-ssr / TRELLIS_SERVER_SSR are ignored and build-time/runtime SSR behavior can diverge.

Suggested fix
     kwargs: dict[str, Any] = {
         "host": config.host,
         "port": config.port,
         "batch_delay": config.batch_delay,
         "hot_reload": config.hot_reload,
+        "ssr": config.ssr,
         "session_ttl": config.session_ttl,
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/cli/run.py` around lines 22 - 28, _build_run_kwargs() currently
collects host/port/batch_delay/hot_reload/session_ttl but omits Config.ssr, so
ServerPlatform.run() uses its default ssr=True and CLI flags/env are ignored;
update _build_run_kwargs() to include the ssr key (e.g., "ssr": config.ssr) so
the returned kwargs passed into ServerPlatform.run(...) reflect the resolved
Config.ssr value.
🧹 Nitpick comments (3)
tests/py/unit/test_cli_run.py (1)

394-403: Cover ssr in this helper test too.

This test now checks session_ttl, but not the other new SSR-related kwarg. Adding assert kwargs["ssr"] is True would catch the current omission in _build_run_kwargs() instead of letting it slip through.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/py/unit/test_cli_run.py` around lines 394 - 403, The
test_server_defaults helper doesn't assert the SSR-related kwarg; update the
test (test_server_defaults) to check that _build_run_kwargs returns the SSR flag
by adding an assertion that kwargs["ssr"] is True so the helper covers the new
SSR-related parameter and will catch omissions in _build_run_kwargs.
tests/py/unit/test_ssr_orchestrator.py (2)

60-64: Make the session-storage test inspect SessionStore.

This only proves the dehydration payload contains a sessionId. If SSROrchestrator.render_to_response() stopped calling SessionStore.store(), the test would still pass. Please assert that the emitted ID can actually be retrieved from the store.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/py/unit/test_ssr_orchestrator.py` around lines 60 - 64, Update the
test_render_to_response_stores_session to not only assert "sessionId" is in the
returned payload but also retrieve that ID (e.g. session_id =
result["sessionId"]) and confirm the session was actually stored by calling the
orchestrator's SessionStore retrieval API (e.g.
label_orchestrator.session_store.get(session_id) or the appropriate retrieval
method) and asserting the returned session is not None (and optionally matches
expected contents); keep the call to SSROrchestrator.render_to_response
unchanged and use the store on label_orchestrator to perform the lookup so the
test fails if SSROrchestrator.render_to_response stops calling
SessionStore.store().

10-13: Use Trellis public imports in this test.

Import component from trellis and widgets via from trellis import widgets as w, then call w.Label(...). That keeps the test aligned with the public API surface.

As per coding guidelines, "**/*.py: Import trellis core primitives and App directly: from trellis import App, component, Stateful" and "Import widgets via the w alias: from trellis import widgets as w, then use w.Button, w.Label, etc."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/py/unit/test_ssr_orchestrator.py` around lines 10 - 13, Update the test
to use Trellis public imports: replace direct imports of component and Label
with public API imports (import component from trellis and import widgets as w)
and update usages to call w.Label(...) instead of Label; keep other imports like
CompositionComponent, SessionStore, and SSROrchestrator as needed but ensure any
core primitives follow the guideline (e.g., use from trellis import component
and from trellis import widgets as w).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/trellis/app/config.py`:
- Around line 121-126: The _SESSION_TTL ConfigVar must reject non-positive
values during resolution: add a validation step to the ConfigVar resolution
logic (or attach a validator to the _SESSION_TTL instance) that checks the
resolved value and raises an error if <= 0; specifically update
ConfigVar.resolve (or provide a validator callable for _SESSION_TTL) to detect
values <= 0 and raise a ValueError (or ConfigError) with a clear message so
session_ttl cannot be zero or negative before being used by the session cleanup
loop in platform.py.

In `@tests/py/unit/test_ssr_orchestrator.py`:
- Around line 90-113: The test currently uses label_orchestrator with
ssr_renderer=None so SSROrchestrator._get_ssr_html never populates the cache;
change the fixture or test to provide a stub/mock renderer (implementing the
same interface used by SSROrchestrator, e.g., a simple callable that returns
HTML and is instrumented to count calls) and pass it into the SSROrchestrator
used by the test, then call render_to_response twice with the same
path/system_theme and assert the stub renderer was invoked exactly once while
both returned HTML strings still contain "window.__TRELLIS_SSR__" and produce
different session IDs as before. Ensure you reference
SSROrchestrator._get_ssr_html and render_to_response when locating where to
inject the stub and where to assert the call count.

---

Outside diff comments:
In `@src/trellis/cli/run.py`:
- Around line 22-28: _build_run_kwargs() currently collects
host/port/batch_delay/hot_reload/session_ttl but omits Config.ssr, so
ServerPlatform.run() uses its default ssr=True and CLI flags/env are ignored;
update _build_run_kwargs() to include the ssr key (e.g., "ssr": config.ssr) so
the returned kwargs passed into ServerPlatform.run(...) reflect the resolved
Config.ssr value.

---

Nitpick comments:
In `@tests/py/unit/test_cli_run.py`:
- Around line 394-403: The test_server_defaults helper doesn't assert the
SSR-related kwarg; update the test (test_server_defaults) to check that
_build_run_kwargs returns the SSR flag by adding an assertion that kwargs["ssr"]
is True so the helper covers the new SSR-related parameter and will catch
omissions in _build_run_kwargs.

In `@tests/py/unit/test_ssr_orchestrator.py`:
- Around line 60-64: Update the test_render_to_response_stores_session to not
only assert "sessionId" is in the returned payload but also retrieve that ID
(e.g. session_id = result["sessionId"]) and confirm the session was actually
stored by calling the orchestrator's SessionStore retrieval API (e.g.
label_orchestrator.session_store.get(session_id) or the appropriate retrieval
method) and asserting the returned session is not None (and optionally matches
expected contents); keep the call to SSROrchestrator.render_to_response
unchanged and use the store on label_orchestrator to perform the lookup so the
test fails if SSROrchestrator.render_to_response stops calling
SessionStore.store().
- Around line 10-13: Update the test to use Trellis public imports: replace
direct imports of component and Label with public API imports (import component
from trellis and import widgets as w) and update usages to call w.Label(...)
instead of Label; keep other imports like CompositionComponent, SessionStore,
and SSROrchestrator as needed but ensure any core primitives follow the
guideline (e.g., use from trellis import component and from trellis import
widgets as w).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cd01c05c-2e45-426a-ba7c-39bd713d1d5a

📥 Commits

Reviewing files that changed from the base of the PR and between d84dc9c and 23b00ca.

📒 Files selected for processing (7)
  • src/trellis/app/config.py
  • src/trellis/cli/run.py
  • src/trellis/platforms/server/platform.py
  • src/trellis/platforms/server/session_store.py
  • tests/py/unit/test_cli_run.py
  • tests/py/unit/test_config.py
  • tests/py/unit/test_ssr_orchestrator.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/py/unit/test_config.py

Comment thread src/trellis/app/config.py
Comment thread tests/py/unit/test_ssr_orchestrator.py

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/trellis/platforms/desktop/platform.py (1)

137-145: ⚠️ Potential issue | 🟠 Major

Gate the desktop SSR steps on the new ssr config flag.

The PR introduces ssr as a feature toggle, but this build config always adds SSRBundleBuildStep() and SSRPreRenderStep(). With ssr=False, desktop will still pre-render and embed SSR payloads instead of falling back to CSR.

🪄 Suggested direction
         return BuildConfig(
             entry_point=entry_point,
-            steps=[
-                PackageInstallStep(),
-                RegistryGenerationStep(),
-                BundleBuildStep(output_name="bundle"),
-                SSRBundleBuildStep(),
-                StaticFileCopyStep(),
-                IconAssetStep(icon_path=config.icon, include_icns=True),
-                SSRPreRenderStep(),
-                IndexHtmlRenderStep(template_path, {"title": config.title}),
-            ],
+            steps=[
+                PackageInstallStep(),
+                RegistryGenerationStep(),
+                BundleBuildStep(output_name="bundle"),
+                *([SSRBundleBuildStep()] if config.ssr else []),
+                StaticFileCopyStep(),
+                IconAssetStep(icon_path=config.icon, include_icns=True),
+                *([SSRPreRenderStep()] if config.ssr else []),
+                IndexHtmlRenderStep(template_path, {"title": config.title}),
+            ],
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/desktop/platform.py` around lines 137 - 145, The build
pipeline always includes SSRBundleBuildStep and SSRPreRenderStep regardless of
the new feature toggle; update the code that constructs the steps list (where
PackageInstallStep, RegistryGenerationStep, BundleBuildStep, SSRBundleBuildStep,
SSRPreRenderStep, etc. are created) to conditionally append SSRBundleBuildStep()
and SSRPreRenderStep() only when config.ssr is truthy (e.g., if config.ssr:
steps.extend([SSRBundleBuildStep(), SSRPreRenderStep()]); otherwise omit them)
so that when ssr=False the desktop build falls back to CSR.
♻️ Duplicate comments (2)
src/trellis/bundler/steps.py (1)

742-752: ⚠️ Potential issue | 🟠 Major

Include output_name in SSR bundle staleness tracking.

output_name is part of the public API and changes the emitted filename, but should_build() still only compares old inputs/outputs. Changing SSRBundleBuildStep(output_name=...) against an existing workspace can incorrectly return SKIP and leave the old bundle name in the manifest/dist state.

♻️ Suggested fix
     def should_build(
         self, ctx: BuildContext, step_manifest: StepManifest | None
     ) -> ShouldBuild | None:
         """Check if SSR bundle needs rebuilding based on source/output mtimes."""
         if step_manifest is None:
             return ShouldBuild.BUILD
+        if step_manifest.metadata.get("output_name") != self.output_name:
+            return ShouldBuild.BUILD
         if not step_manifest.source_paths or not step_manifest.dest_files:
             return ShouldBuild.BUILD
         if is_rebuild_needed(step_manifest.source_paths, step_manifest.dest_files):
             return ShouldBuild.BUILD
         return ShouldBuild.SKIP
@@
         # Populate step manifest from metafile
         metafile = read_metafile(ctx.workspace)
         step_manifest = ctx.manifest.steps.setdefault(self.name, StepManifest())
+        step_manifest.source_paths.clear()
+        step_manifest.dest_files.clear()
         step_manifest.source_paths.update(metafile.inputs)
         step_manifest.dest_files.update(metafile.outputs)
+        step_manifest.metadata["output_name"] = self.output_name

Also applies to: 791-795

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/bundler/steps.py` around lines 742 - 752, The should_build method
(used by SSRBundleBuildStep) currently only checks StepManifest.source_paths and
dest_files via is_rebuild_needed and ignores the public API output_name; when
output_name changes the emitted filename should count as a change so
should_build returns BUILD rather than SKIP. Update should_build to compare the
current step's output_name (from SSRBundleBuildStep or its config) against the
saved value in StepManifest (e.g., manifest.output_name or add a field) and
return ShouldBuild.BUILD when they differ; apply the same change to the other
should_build variant referenced in the diff so both code paths consider
output_name when deciding rebuilds.
src/trellis/platforms/desktop/client/src/main.tsx (1)

89-103: ⚠️ Potential issue | 🟠 Major

Keep a desktop loading fallback when SSR data is absent.

The SSR path is fine, but the CSR fallback still renders TrellisRoot, and TrellisRoot only shows an error or the tree. When window.__TRELLIS_SSR__ is missing, startup/reconnect falls back to a blank window until the first tree arrives.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/desktop/client/src/main.tsx` around lines 89 - 103,
When ssrData is absent the CSR path currently mounts <App/> which results in
TrellisRoot showing only an error or the tree and leaving a blank window until
the first tree arrives; update the CSR branch so the initial UI shows a desktop
loading fallback: either render TrellisRoot (the same component used for SSR)
with a loading prop/flag when ssrData is falsy, or modify TrellisRoot to detect
an empty store and render a desktop loading placeholder when neither error nor
tree data exist. Locate the CSR code around ssrData, createRoot, root.render and
the TrellisRoot/App components and add the loading fallback behavior there.
🧹 Nitpick comments (1)
src/trellis/platforms/desktop/client/src/index.html.j2 (1)

53-61: Consider escaping inline JSON to prevent accidental breakage.

Direct interpolation of JSON objects into <script> context (lines 57-58) can break if any string value contains </script>. While the comment notes this is trusted build-time content, a defensive approach would prevent subtle breakage if component data ever includes that sequence.

🛡️ Safer pattern using JSON.parse
     <script>
         // Set dehydration data for the active theme
         (function() {
             var isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
-            var lightData = {{ ssr_light_data | safe }};
-            var darkData = {{ ssr_dark_data | safe }};
+            var lightData = JSON.parse('{{ ssr_light_data | tojson | safe }}');
+            var darkData = JSON.parse('{{ ssr_dark_data | tojson | safe }}');
             window.__TRELLIS_SSR__ = isDark ? darkData : lightData;
         })();
     </script>

Alternatively, the Python side can pre-escape </ as <\/ before templating.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/desktop/client/src/index.html.j2` around lines 53 - 61,
The inline JSON injection for ssr_light_data and ssr_dark_data can break the
script if values contain "</script>"; update the template to render those
payloads as escaped JSON strings and parse them in the IIFE instead of directly
interpolating objects: produce safe stringified/escaped versions of
ssr_light_data and ssr_dark_data on the server (or escape "</" as "<\/"), then
in the script use JSON.parse on the injected string tokens and assign the result
to window.__TRELLIS_SSR__ (references: ssr_light_data, ssr_dark_data,
window.__TRELLIS_SSR__).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/trellis/bundler/steps.py`:
- Around line 834-836: The call to renderer.start() is currently outside the try
block so if SSRRenderer.start() raises after spawning the Bun sidecar the
finally cleanup never runs; move the renderer.start() invocation inside the try
block that follows its construction (i.e., after creating
SSRRenderer(ssr_bundle) and before the existing try body) so any exceptions from
SSRRenderer.start() still trigger the existing finally cleanup that shuts down
the child process.
- Around line 823-826: SSRPreRenderStep currently hardcodes ctx.dist_dir /
"ssr.js", causing pre-render to skip when SSRBundleBuildStep used a non-default
output_name; change the flow so SSRBundleBuildStep.run() stores the actual
bundle filename into the build context (e.g. set ctx["ssr_bundle_name"] or
ctx.ssr_bundle_path when it produces the bundle) and ensure
SSRBundleBuildStep.should_build() restores or preserves that context value when
the step is skipped, then update SSRPreRenderStep to resolve the bundle path
from that context value instead of "ssr.js" (use ctx.dist_dir /
ctx.ssr_bundle_name or the equivalent key you add).

In `@src/trellis/platforms/common/ssr_utils.py`:
- Around line 52-54: The current manual JSON concat in ssr_utils.py (using
data_json, patches_json and _PATCHES_KEY) embeds raw JSON into HTML and can be
broken out of by characters like </script>, <, &, \u2028, or \u2029; update the
return logic to escape the dehydration payload before embedding: produce
data_json and patches_json via json.dumps as now, then run a replacer that
escapes problematic sequences (at minimum replace "</script>" with
"<\\/script>", replace "<" and "&" with their safe escaped forms, and replace
\u2028 and \u2029) before concatenating with _PATCHES_KEY, so the returned
string is safe to embed in a <script> tag. Ensure you apply the same escaping to
both data_json and patches_json and keep using the _PATCHES_KEY insertion logic.

In `@src/trellis/platforms/desktop/client/src/index.html.j2`:
- Around line 43-50: The block that queries and removes the template elements
(the variables "light" and "dark" and the subsequent if (light) light.remove();
if (dark) dark.remove();) is unreachable after assigning root.innerHTML =
template.innerHTML because the templates were children of `#root` and are already
removed; remove the dead code referencing document.getElementById('ssr-light')
and document.getElementById('ssr-dark') and their conditional .remove() calls
(or, alternatively, move any needed cleanup to before root.innerHTML is set) so
only live, necessary DOM manipulation remains in the function that handles
"template" and "root".

In `@src/trellis/utils/subprocess.py`:
- Around line 96-103: In _stop_posix, avoid signaling a recycled PID by
returning early if the child has already been reaped: check process.returncode
and if it is not None, skip the os.getpgid/os.killpg logic and return; otherwise
proceed to call os.getpgid(process.pid), os.killpg(pgid, signal.SIGTERM) and
process.wait(timeout=timeout). This prevents sending signals to an unrelated
process group when the PID was recycled.
- Around line 84-90: The _set_pdeathsig function currently hardcodes
ctypes.CDLL("libc.so.6"), which fails on non-glibc Linux (e.g., musl/Alpine);
update it to locate the C library via ctypes.util.find_library("c") and pass
that name to ctypes.CDLL, falling back to ctypes.CDLL(None) if find_library
returns None, and keep use_errno=True and the existing prctl invocation so the
function works across glibc and musl systems.
- Around line 129-180: The Win32 calls (kernel32.SetInformationJobObject,
kernel32.AssignProcessToJobObject, kernel32.OpenProcess, kernel32.CloseHandle)
must be properly typed and their return values checked before storing
process._job_handle; set restype/argtypes (e.g., restype=ctypes.wintypes.BOOL
for SetInformationJobObject/AssignProcessToJobObject and
restype=ctypes.wintypes.HANDLE for OpenProcess), inspect the returns and call
CloseHandle on any handles you opened if a subsequent step fails, and only
assign process._job_handle = job when AssignProcessToJobObject succeeds; also
surface or log GetLastError when a call fails to aid debugging.

---

Outside diff comments:
In `@src/trellis/platforms/desktop/platform.py`:
- Around line 137-145: The build pipeline always includes SSRBundleBuildStep and
SSRPreRenderStep regardless of the new feature toggle; update the code that
constructs the steps list (where PackageInstallStep, RegistryGenerationStep,
BundleBuildStep, SSRBundleBuildStep, SSRPreRenderStep, etc. are created) to
conditionally append SSRBundleBuildStep() and SSRPreRenderStep() only when
config.ssr is truthy (e.g., if config.ssr: steps.extend([SSRBundleBuildStep(),
SSRPreRenderStep()]); otherwise omit them) so that when ssr=False the desktop
build falls back to CSR.

---

Duplicate comments:
In `@src/trellis/bundler/steps.py`:
- Around line 742-752: The should_build method (used by SSRBundleBuildStep)
currently only checks StepManifest.source_paths and dest_files via
is_rebuild_needed and ignores the public API output_name; when output_name
changes the emitted filename should count as a change so should_build returns
BUILD rather than SKIP. Update should_build to compare the current step's
output_name (from SSRBundleBuildStep or its config) against the saved value in
StepManifest (e.g., manifest.output_name or add a field) and return
ShouldBuild.BUILD when they differ; apply the same change to the other
should_build variant referenced in the diff so both code paths consider
output_name when deciding rebuilds.

In `@src/trellis/platforms/desktop/client/src/main.tsx`:
- Around line 89-103: When ssrData is absent the CSR path currently mounts
<App/> which results in TrellisRoot showing only an error or the tree and
leaving a blank window until the first tree arrives; update the CSR branch so
the initial UI shows a desktop loading fallback: either render TrellisRoot (the
same component used for SSR) with a loading prop/flag when ssrData is falsy, or
modify TrellisRoot to detect an empty store and render a desktop loading
placeholder when neither error nor tree data exist. Locate the CSR code around
ssrData, createRoot, root.render and the TrellisRoot/App components and add the
loading fallback behavior there.

---

Nitpick comments:
In `@src/trellis/platforms/desktop/client/src/index.html.j2`:
- Around line 53-61: The inline JSON injection for ssr_light_data and
ssr_dark_data can break the script if values contain "</script>"; update the
template to render those payloads as escaped JSON strings and parse them in the
IIFE instead of directly interpolating objects: produce safe stringified/escaped
versions of ssr_light_data and ssr_dark_data on the server (or escape "</" as
"<\/"), then in the script use JSON.parse on the injected string tokens and
assign the result to window.__TRELLIS_SSR__ (references: ssr_light_data,
ssr_dark_data, window.__TRELLIS_SSR__).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1e70ceaf-1aca-452d-a2d7-c53b494c0275

📥 Commits

Reviewing files that changed from the base of the PR and between 23b00ca and daf9cdd.

📒 Files selected for processing (13)
  • src/trellis/bundler/__init__.py
  • src/trellis/bundler/steps.py
  • src/trellis/platforms/common/ssr_utils.py
  • src/trellis/platforms/desktop/client/src/index.html.j2
  • src/trellis/platforms/desktop/client/src/main.tsx
  • src/trellis/platforms/desktop/platform.py
  • src/trellis/platforms/server/ssr.py
  • src/trellis/platforms/server/ssr_renderer.py
  • src/trellis/utils/subprocess.py
  • tests/py/unit/test_build_config.py
  • tests/py/unit/test_ssr_pre_render_step.py
  • tests/py/unit/test_ssr_utils.py
  • tests/py/unit/test_subprocess_utils.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/trellis/platforms/server/ssr.py

Comment thread src/trellis/bundler/steps.py Outdated
Comment thread src/trellis/bundler/steps.py
Comment thread src/trellis/platforms/common/ssr_utils.py Outdated
Comment thread src/trellis/platforms/desktop/client/src/index.html.j2 Outdated
Comment thread src/trellis/utils/subprocess.py
Comment thread src/trellis/utils/subprocess.py Outdated
Comment thread src/trellis/utils/subprocess.py
Comment thread tests/py/unit/test_subprocess_utils.py
Render the component tree to HTML on the server and ship it in the
initial HTTP response so users see content immediately instead of a
blank page while the WebSocket connects.

Key changes:
- render_for_ssr() defers mount hooks; execute_deferred_hooks() replays
  them when the WebSocket connects and resumes the session
- SessionStore bridges SSR HTTP responses and WebSocket resumption with
  a 30-second TTL
- SSRBundleBuildStep produces a Node-targeted ESM bundle (ssr.js) from
  a Bun SSR worker entry point that calls React renderToString()
- SSRRenderer manages the Bun sidecar subprocess over a Unix socket
- SSROrchestrator coordinates rendering, session storage, dehydration
  data injection, and per-route+theme HTML caching
- Client detects window.__TRELLIS_SSR__, pre-populates the store, and
  uses hydrateRoot() instead of createRoot()
- ssr config option (default true) controls the feature; graceful
  fallback to CSR when the renderer is unavailable
Show a blank page while waiting for the component tree instead of
the connection status bar (state/session ID/version). With SSR the
user already sees content; without SSR a blank page is preferable
to a status bar that flashes for 200ms.

ErrorDisplay is kept for catastrophic failures. Browser platform
(TrellisApp) is unchanged since Pyodide loading takes seconds and
the spinner is warranted there.
Cache renderToString output separately from dehydration data since
element IDs are non-deterministic across sessions. Increase session
TTL from 30s to 120s. Add Playwright-based e2e test for hydration
mismatch detection.
Add session_ttl (default 300s) to Config, wired through to
SessionStore. Periodic asyncio task cleans up expired sessions.
Configurable via trellis_config.py or TRELLIS_SERVER_SESSION_TTL env.
Ensures Bun SSR sidecar dies when parent Python process dies using
platform-specific mechanisms (prctl on Linux, process groups on macOS,
Job Objects on Windows). SSRRenderer now uses the shared helpers.
Move build_dehydration_data to trellis.platforms.common.ssr_utils so
both server (runtime) and desktop (build-time) SSR can reuse it.
session_id is now optional (None for desktop).
Pre-render both light and dark themes at build time via SSRPreRenderStep.
The desktop index.html uses <template> elements for each theme variant,
selecting the correct one before first paint based on system preference.
main.tsx gains hydration support (hydrateRoot + store.applyPatches).
- Escape </script> in dehydration JSON (replace < with \u003c)
- Replace deprecated tempfile.mktemp with mkdtemp for socket paths
- Redirect sidecar stdout/stderr to DEVNULL to prevent pipe buffer hangs
- Add threading.Lock to SSRRenderer for concurrent render/restart safety
- Include theme_mode in SSR cache key to avoid cross-theme collisions
- Bound SSRCache to 100 entries with FIFO eviction
- Filter non-document 404s from SPA fallback (check Accept: text/html)
- Add Vary: Sec-CH-Prefers-Color-Scheme header to SSR responses
- Install router callbacks before replaying deferred mount hooks
- Validate session_ttl as positive integer
- Document ssr field in Config docstring
- Include output_name in SSRBundleBuildStep cache key
- Improve hydration test exit code handling (only skip on missing Playwright)
- Add cache reuse test with stub renderer
- subprocess.py: use find_library("c") for musl/Alpine portability,
  short-circuit on already-reaped processes, type Win32 kernel32 calls
  with restype and check return values
- steps.py: accept output_name parameter in SSRPreRenderStep, move
  renderer.start() inside try block for cleanup on failure
- index.html.j2: remove dead template cleanup code (innerHTML replaces
  all children)
- check_hydration.js: use block bodies for forEach callbacks (Biome lint)
- test_subprocess_utils.py: mock kernel32 in Windows tests, add
  already-reaped process test, fix returncode on mock processes
render_for_ssr() now temporarily sets the render session on the
current context so component creation (which checks
get_render_session()) works correctly after the session management
changes on main.
React's hydrateRoot requires useSyncExternalStore to have a third
getServerSnapshot argument. Without it, hydration fails and falls
back to full client rendering.
Pre-render the app at bundle time for both light and dark themes
(reusing the desktop SSR pipeline) so browser apps show real UI
immediately instead of a blank page while Pyodide loads.

A theme-aware loading overlay appears from the first paint (baked
into the HTML template) and transitions to React-managed status
updates once hydration completes, then dismisses when connected.
All SSR HTML uses CSS variables for colors, so light and dark renders
produce identical markup. Render once and inline the HTML directly in
#root — no <template> elements or theme-selection scripts needed.
The early theme detection script sets data-theme before first paint,
and CSS variables resolve to the correct colors automatically.
Comment thread src/trellis/bundler/steps.py Outdated
Comment thread src/trellis/bundler/steps.py
Comment thread src/trellis/bundler/steps.py
Comment thread src/trellis/platforms/common/handler.py Outdated
Comment thread src/trellis/platforms/common/handler.py Outdated
Comment thread src/trellis/platforms/server/routes.py Outdated
Comment thread src/trellis/utils/subprocess.py
Comment thread src/trellis/platforms/server/ssr.py
Comment thread src/trellis/platforms/server/ssr.py
Comment thread src/trellis/platforms/server/ssr_renderer.py
- Fix subprocess.py type errors: use Popen[Any] since callers pass
  arbitrary kwargs, and suppress wintypes import on non-Windows
- Remove unused _background_tasks and _render_task from MessageHandler
  (task tracking moved to session on main, these were stale SSR branch
  artifacts from conflict resolution)

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/trellis/platforms/common/handler.py (1)

387-400: ⚠️ Potential issue | 🟠 Major

Reinstall router callbacks after the normal initial render.

The SSR early-return branch calls _setup_router_callbacks(), but the fresh-session branch now returns directly after render(self.session). That leaves CSR/ssr=false sessions without _on_navigate/_on_go_back/_on_go_forward, so server-driven navigation stops updating browser history.

🩹 Minimal fix
             logger.debug(
                 "Initial render complete, sending PatchMessage (%d elements)", element_count
             )
+            self._setup_router_callbacks()
 
             return PatchMessage(patches=wire_patches)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/common/handler.py` around lines 387 - 400, The
initial-render branch skips router setup but the normal fresh-session branch
returns immediately after computing wire_patches, leaving CSR sessions without
navigation callbacks; after calling render(self.session) and before returning
PatchMessage(patches=wire_patches) invoke self._setup_router_callbacks() so that
_on_navigate/_on_go_back/_on_go_forward are installed for non-SSR sessions
(ensure this happens after _serialize_patches(render_patches, self.session) and
before returning the PatchMessage).
♻️ Duplicate comments (4)
tests/e2e/test_ssr_hydration.py (1)

85-89: ⚠️ Potential issue | 🟠 Major

Narrow missing-Playwright detection to explicit Playwright errors.

Line 87 treats any "Cannot find module" as skippable. That can mask real harness regressions unrelated to Playwright.

Suggested fix
     if result.returncode == 2:
         stderr = result.stderr.strip()
-        if "Playwright not installed" in stderr or "Cannot find module" in stderr:
+        lower = stderr.lower()
+        missing_playwright = (
+            "playwright not installed" in lower
+            or "playwright is not installed" in lower
+            or "cannot find module 'playwright'" in lower
+            or 'cannot find module "@playwright/test"' in lower
+        )
+        if missing_playwright:
             pytest.skip(f"Playwright not available: {stderr}")
         pytest.fail(f"Hydration check script error:\nstdout: {result.stdout}\nstderr: {stderr}")
#!/bin/bash
set -euo pipefail

# Verify what check_hydration.js actually treats as missing-Playwright conditions.
nl -ba tests/e2e/check_hydration.js | sed -n '1,260p'
rg -n "playwright|Cannot find module|process.exit|stderr|returncode" tests/e2e/check_hydration.js -C2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/test_ssr_hydration.py` around lines 85 - 89, The current skip logic
in the result.returncode == 2 branch is too broad because it treats any "Cannot
find module" as Playwright missing; update the condition in
tests/e2e/test_ssr_hydration.py (the block handling result.returncode == 2 and
the stderr variable) to only skip when stderr explicitly indicates Playwright is
missing — e.g., stderr contains "Playwright not installed" or matches "Cannot
find module.*playwright" (use a regex/search rather than a plain "Cannot find
module" substring check); leave other errors to hit pytest.fail so real harness
regressions are not masked.
src/trellis/platforms/server/routes.py (2)

27-35: ⚠️ Potential issue | 🟡 Minor

Tighten the SPA fallback gate.

Checking only Accept: text/html still routes POST 404s and browser visits to /api/... or obvious asset URLs through the SPA. Those misses can come back as 200 HTML and still trigger SSR instead of preserving the real 404.

Also applies to: 49-53

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/routes.py` around lines 27 - 35, The SPA
fallback gate in _is_document_request should be tightened: update the
_is_document_request(request: Request) function (and the equivalent logic at the
other occurrence) to only return True for navigational requests by requiring
request.method to be GET or HEAD, ensuring "text/html" is in
request.headers.get("accept", ""), and excluding obvious API or asset requests
(e.g. request.url.path startswith "/api" or the path has a file extension like
".js", ".css", ".png" — detect by checking for a '.' after the last '/' in the
path). Implement these checks together so only true browser navigations trigger
the SPA/SSR fallback.

69-87: ⚠️ Potential issue | 🟠 Major

Don't run the synchronous SSR path on the ASGI event loop.

index() and spa_fallback_handler() call _ssr_or_static_response() directly, and that path does a full render_for_ssr() plus a blocking httpx.Client.post() to the Bun sidecar. One slow render will stall unrelated HTTP/WebSocket work until it finishes; offload it to a worker thread or make the orchestrator/renderer async end-to-end.

Also applies to: 103-105

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/routes.py` around lines 69 - 87, The synchronous
SSR path in _ssr_or_static_response (used by index() and spa_fallback_handler)
blocks the ASGI event loop because ssr.render_to_response (which leads to
render_for_ssr and a blocking httpx.Client.post to the Bun sidecar) runs on the
main thread; change this by offloading the blocking work to a thread pool or
converting the orchestrator/renderer to async: either call the blocking
ssr.render_to_response via loop.run_in_executor (or asyncio.to_thread) and await
it, or implement/replace ssr.render_to_response with an async
render_to_response_async that uses an async HTTP client, and update
_get_ssr_orchestrator/_ssr_or_static_response to await the async path; ensure
headers and exception handling remain the same.
src/trellis/platforms/server/ssr.py (1)

73-91: ⚠️ Potential issue | 🟠 Major

Don't advertise SSR when no SSR HTML exists.

_get_ssr_html() returns "" when the Bun sidecar is absent or unhealthy, but render_to_response() still stores the session and injects window.__TRELLIS_SSR__. The client then tries to hydrate markup that was never sent, so the "renderer unavailable" path no longer falls back to plain CSR.

🩹 Minimal fix
-        # Store session for WebSocket resumption
-        entry = SessionEntry(
-            session=session,
-            deferred_mounts=ssr_result.deferred_mounts,
-            deferred_unmounts=ssr_result.deferred_unmounts,
-            patches=wire_patches,
-        )
-        self._session_store.store(session_id, entry)
-
         # Get rendered HTML — from cache or Bun sidecar
         ssr_html = self._get_ssr_html(path, system_theme, theme_mode, session)
+        if ssr_html is None:
+            raise RuntimeError("SSR renderer unavailable")
 
         # Build dehydration data (always fresh — element IDs are per-session)
         dehydration_data = build_dehydration_data(session_id, wire_patches)
+
+        entry = SessionEntry(
+            session=session,
+            deferred_mounts=ssr_result.deferred_mounts,
+            deferred_unmounts=ssr_result.deferred_unmounts,
+            patches=wire_patches,
+        )
+        self._session_store.store(session_id, entry)
@@
-    ) -> str:
+    ) -> str | None:
         """Get rendered HTML, using the cache when possible."""
@@
-        ssr_html = ""
+        ssr_html: str | None = None
         if self._ssr_renderer is not None and self._ssr_renderer.is_available:
             assert session.root_element is not None
             tree = serialize_element(session.root_element, session)
             rendered = self._ssr_renderer.render(tree)
             if rendered is not None:
-                ssr_html = rendered
-                self._cache.put(path, cache_key, ssr_html)
+                self._cache.put(path, cache_key, rendered)
+                ssr_html = rendered
 
         return ssr_html

Also applies to: 97-116

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/ssr.py` around lines 73 - 91, The code currently
stores sessions and injects the dehydration script even when _get_ssr_html(...)
returns an empty string (no SSR available), causing the client to attempt
hydration with no SSR markup; change render logic so you first call ssr_html =
self._get_ssr_html(...), then only create and store SessionEntry via
self._session_store.store(session_id, entry), build_dehydration_data(...), and
inject the dehydration_script when ssr_html is non-empty; if ssr_html is empty,
skip storing the session and skip adding window.__TRELLIS_SSR__ (leave the
template without the SSR outlet/dehydration injection) — apply the same guard to
the second identical block around build_dehydration_data/_session_store usage.
🧹 Nitpick comments (7)
src/trellis/utils/subprocess.py (1)

241-242: Add type: ignore comment for consistency with line 138.

The static analysis hint about wintypes is a type-checker limitation on non-Windows systems. Line 138 already has # type: ignore[attr-defined] for the same pattern; this line should match for consistency and to suppress the CI warning.

♻️ Proposed fix
-                kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)  # type: ignore[attr-defined]
-                kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
+                kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)  # type: ignore[attr-defined]
+                kernel32.CloseHandle.restype = ctypes.wintypes.BOOL  # type: ignore[attr-defined]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/utils/subprocess.py` around lines 241 - 242, The assignment to
kernel32.CloseHandle.restype triggers the same type-checker warning as earlier;
update the CloseHandle.restype line in subprocess.py (the kernel32 =
ctypes.WinDLL(...) / kernel32.CloseHandle.restype = ...) to include the same
ignore comment used on line 138 (append # type: ignore[attr-defined]) so the
static analyzer is suppressed and consistent with the prior ctypes usage.
tests/e2e/_run_test_server.py (2)

27-29: Consider adding argument validation.

The script will crash with an unhelpful IndexError if the required host/port arguments are missing. A brief check would improve the developer experience when running the script incorrectly.

🛡️ Optional: add argument validation
 def main() -> None:
+    if len(sys.argv) < 3:
+        print("Usage: python _run_test_server.py <host> <port>", file=sys.stderr)
+        sys.exit(1)
     host = sys.argv[1]
     port = int(sys.argv[2])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/_run_test_server.py` around lines 27 - 29, The main() function
reads host/port from sys.argv without validation which raises IndexError if
arguments are missing; update main (or the top-level argument handling) to
validate sys.argv length (or use argparse) before accessing sys.argv[1] and
sys.argv[2], print a clear usage message including expected host and port, and
exit with a non-zero status when arguments are missing or port is not an
integer; reference the main function and the host/port variables derived from
sys.argv to locate where to add the checks and error handling.

22-24: Rename component to PascalCase.

Per project conventions, functions decorated with @component should use PascalCase since they behave like components/classes.

♻️ Proposed fix
 `@component`
-def test_app() -> None:
+def TestApp() -> None:
     Label(text="Hello from SSR")

And update the reference at line 43:

-    app_instance = App(test_app)
+    app_instance = App(TestApp)

And line 50:

-            test_app,
+            TestApp,

Based on learnings: "In Trellis, any function decorated with component should be named using PascalCase (e.g., RouterDemo, HelloWorld, WidgetShowcase) since these behave like components/classes rather than plain functions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/_run_test_server.py` around lines 22 - 24, Rename the `@component`
function test_app to PascalCase (e.g., TestApp) and update all references/usages
to the new name; specifically change the function definition def test_app(...)
-> None: to def TestApp(...) -> None: (keep the `@component` decorator) and
replace every call/import/usage of "test_app" (including the references
mentioned in the diff) with "TestApp" so the component follows project naming
conventions.
src/trellis/platforms/common/ssr_utils.py (1)

17-24: Consider reusing _get_version from handler.py.

This function duplicates the implementation in src/trellis/platforms/common/handler.py (lines 63-68). Consider extracting to a shared location to avoid drift.

♻️ Suggested refactor
-def _get_version() -> str:
-    """Get package version from metadata."""
-    try:
-        from importlib.metadata import version as get_package_version  # noqa: PLC0415
-
-        return get_package_version("trellis")
-    except Exception:
-        return "0.0.0"
+from trellis.platforms.common.handler import _get_version

Or extract to a shared utility module if _get_version should remain private.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/common/ssr_utils.py` around lines 17 - 24, The
_get_version implementation in ssr_utils duplicates the same function in
handler; remove the duplicate by centralizing the logic: create or use a single
shared function (e.g., a new get_version utility in a common utils module) or
import the existing _get_version from the handler module, then update
ssr_utils._get_version to call or re-export that shared function; ensure the
shared function uses importlib.metadata.version("trellis") with the same
fallback and update any imports and tests accordingly.
src/trellis/platforms/server/ssr_cache.py (1)

19-33: Declare the cache fields on the class.

_cache, _lock, and _max_entries are owned instance attributes, but their declarations still live inside __init__. Move the annotations to the class body to match the repo rule for plain classes. As per coding guidelines, "Declare all owned instance attributes in the class body with type annotations."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/ssr_cache.py` around lines 19 - 33, Move the
instance attribute annotations for SSRCache out of __init__ into the class body:
declare _cache: dict[tuple[str, str], str], _lock: threading.Lock, and
_max_entries: int as class-level annotations (without initializing them) and
leave initialization of their values inside __init__ (self._cache = {},
self._lock = threading.Lock(), self._max_entries = max_entries); update the
SSRCache class header to include these annotations so the class follows the repo
rule of declaring owned instance attributes in the class body while keeping
construction logic in __init__.
src/trellis/platforms/server/session_store.py (1)

31-38: Declare the store fields on the class.

_ttl, _entries, and _lock are introduced inside __init__ instead of the class body. As per coding guidelines, "Declare all owned instance attributes in the class body with type annotations."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/session_store.py` around lines 31 - 38, Declare
the owned instance attributes on the SessionStore class body by adding typed
declarations for _ttl: float, _entries: dict[str, SessionEntry], and _lock:
threading.Lock (or Optional[threading.Lock] if you prefer) above __init__; keep
the actual assignments inside __init__ (self._ttl = ttl_seconds, self._entries =
{}, self._lock = threading.Lock()) so the class has explicit attribute
annotations while initialization remains in __init__ — update the SessionStore
class definition (symbols: SessionStore, _ttl, _entries, _lock, __init__)
accordingly.
src/trellis/platforms/server/platform.py (1)

160-163: Compute the static directory once.

This path is recomputed again at Line 192 before mounting /static. Reusing a single static value keeps SSR bundle discovery and mounted assets in lockstep, and avoids depending on create_static_dir() being perfectly idempotent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/platform.py` around lines 160 - 163, Compute the
static directory once and reuse it instead of calling create_static_dir() twice:
move the assignment static = static_dir or create_static_dir() to a single
common point used both for SSR bundle discovery (ssr_bundle = static / "ssr.js"
when ssr is true) and later for mounting /static, and remove the second
recomputation; update any code paths that reference the old second call so they
use the shared static variable (referencing ssr, static_dir,
create_static_dir(), and ssr_bundle to locate the changes).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/trellis/bundler/steps.py`:
- Around line 852-854: The code sets ctx.template_context["ssr_enabled"] True
even when renderer.render(tree) returns empty markup; change the logic so you
capture the rendered HTML from renderer.render(tree) into a variable (e.g.,
rendered_html), set ctx.template_context["ssr_html"] to rendered_html or "", set
ctx.template_context["ssr_enabled"] to True only when rendered_html is non-empty
(e.g., bool(rendered_html)), and only populate ctx.template_context["ssr_data"]
(via build_dehydration_data(None, wire_patches)) when ssr_enabled is True to
avoid enabling hydration with no server HTML; look for renderer.render,
build_dehydration_data, and the ssr_* keys to implement this change.

In `@src/trellis/platforms/browser/serve_platform.py`:
- Around line 124-127: The app-mode step list always includes SSRBundleBuildStep
and SSRPreRenderStep which ignores config.ssr; modify the code that composes the
steps (the list containing SSRBundleBuildStep(), StaticFileCopyStep(),
IconAssetStep(icon_path=config.icon), SSRPreRenderStep()) to conditionally
append or include SSRBundleBuildStep and SSRPreRenderStep only when config.ssr
is truthy — e.g., check config.ssr before adding these two steps so setting
ssr=False fully disables SSR for serve_platform.py.

In `@src/trellis/platforms/server/platform.py`:
- Around line 114-115: Validate session_ttl at the start of the run() method and
reject non-positive values to prevent a busy-loop in _session_cleanup_loop():
ensure session_ttl > 0 before using it for SessionStore TTL and asyncio.sleep;
if the value is ≤ 0, raise a ValueError (or otherwise fail-fast) with a clear
message referencing session_ttl so callers cannot start the server with an
invalid TTL.

In `@src/trellis/platforms/server/session_store.py`:
- Around line 44-62: Expired sessions are currently dropped without handing back
the underlying SessionEntry (and its RenderSession) so background tasks never
get shutdown; modify pop and cleanup_expired to return expired SessionEntry
objects instead of discarding them: in pop(session_id) when an entry exists but
now - entry.created_at > self._ttl, remove it from self._entries and return the
expired SessionEntry (so the caller can call its async shutdown on the
RenderSession), and change cleanup_expired() to collect expired SessionEntry
instances into a list, delete them from self._entries while holding the lock,
and return that list of SessionEntry objects for the caller to await shutdown on
each (update the function signatures and callers accordingly; reference symbols:
pop, cleanup_expired, SessionEntry and RenderSession.shutdown).

In `@src/trellis/platforms/server/ssr_cache.py`:
- Around line 30-33: The constructor __init__ must validate the max_entries
parameter to avoid negative/zero sizes causing eviction from an empty cache; add
a check in __init__ that raises a ValueError if max_entries <= 0 (or
alternatively accept 0 and explicitly document/implement it as "disabled"
behavior), assign the validated value to self._max_entries, and ensure the put()
logic that uses self._max_entries behaves consistently with this decision (i.e.,
if you choose to disallow <=0 then put() can assume _max_entries > 0).

In `@src/trellis/platforms/server/ssr_renderer.py`:
- Around line 75-77: In _start_locked() replace the call to self.stop() (while
holding self._lock) with a call to _stop_locked() to avoid deadlocking on the
non-reentrant threading.Lock; locate the failure branch where _wait_for_health()
returns False and invoke _stop_locked() instead of self.stop(), mirroring the
pattern used in _try_restart() which already uses _stop_locked(), so the locked
shutdown logic runs without re-acquiring self._lock.

In `@src/trellis/utils/subprocess.py`:
- Line 24: The Popen[bytes] return annotation is incorrect because callers may
pass text/encoding kwargs; update the annotations to reflect either a type
variable or a union of str|bytes: define a type variable (e.g. P = TypeVar("P",
str, bytes)) and use subprocess.Popen[P] for the return type of
start_child_process, stop_child_process, and the internal _stop_posix /
_stop_windows functions, or alternatively annotate them as
subprocess.Popen[Union[str, bytes]] to allow both modes; ensure all four
function signatures are changed consistently.

In `@tests/e2e/test_ssr_hydration.py`:
- Around line 60-64: The test currently skips when the test server fails to
start (using pytest.skip), which hides regressions; modify the block that calls
_wait_for_server(url, _STARTUP_TIMEOUT) so that after proc.kill() and capturing
stdout via proc.communicate(timeout=5) you call pytest.fail with the decoded
output (instead of pytest.skip) to ensure the test fails and surfaces the server
startup logs; update references in this block (_wait_for_server,
_STARTUP_TIMEOUT, proc.kill(), proc.communicate, pytest.skip) accordingly.

---

Outside diff comments:
In `@src/trellis/platforms/common/handler.py`:
- Around line 387-400: The initial-render branch skips router setup but the
normal fresh-session branch returns immediately after computing wire_patches,
leaving CSR sessions without navigation callbacks; after calling
render(self.session) and before returning PatchMessage(patches=wire_patches)
invoke self._setup_router_callbacks() so that
_on_navigate/_on_go_back/_on_go_forward are installed for non-SSR sessions
(ensure this happens after _serialize_patches(render_patches, self.session) and
before returning the PatchMessage).

---

Duplicate comments:
In `@src/trellis/platforms/server/routes.py`:
- Around line 27-35: The SPA fallback gate in _is_document_request should be
tightened: update the _is_document_request(request: Request) function (and the
equivalent logic at the other occurrence) to only return True for navigational
requests by requiring request.method to be GET or HEAD, ensuring "text/html" is
in request.headers.get("accept", ""), and excluding obvious API or asset
requests (e.g. request.url.path startswith "/api" or the path has a file
extension like ".js", ".css", ".png" — detect by checking for a '.' after the
last '/' in the path). Implement these checks together so only true browser
navigations trigger the SPA/SSR fallback.
- Around line 69-87: The synchronous SSR path in _ssr_or_static_response (used
by index() and spa_fallback_handler) blocks the ASGI event loop because
ssr.render_to_response (which leads to render_for_ssr and a blocking
httpx.Client.post to the Bun sidecar) runs on the main thread; change this by
offloading the blocking work to a thread pool or converting the
orchestrator/renderer to async: either call the blocking ssr.render_to_response
via loop.run_in_executor (or asyncio.to_thread) and await it, or
implement/replace ssr.render_to_response with an async render_to_response_async
that uses an async HTTP client, and update
_get_ssr_orchestrator/_ssr_or_static_response to await the async path; ensure
headers and exception handling remain the same.

In `@src/trellis/platforms/server/ssr.py`:
- Around line 73-91: The code currently stores sessions and injects the
dehydration script even when _get_ssr_html(...) returns an empty string (no SSR
available), causing the client to attempt hydration with no SSR markup; change
render logic so you first call ssr_html = self._get_ssr_html(...), then only
create and store SessionEntry via self._session_store.store(session_id, entry),
build_dehydration_data(...), and inject the dehydration_script when ssr_html is
non-empty; if ssr_html is empty, skip storing the session and skip adding
window.__TRELLIS_SSR__ (leave the template without the SSR outlet/dehydration
injection) — apply the same guard to the second identical block around
build_dehydration_data/_session_store usage.

In `@tests/e2e/test_ssr_hydration.py`:
- Around line 85-89: The current skip logic in the result.returncode == 2 branch
is too broad because it treats any "Cannot find module" as Playwright missing;
update the condition in tests/e2e/test_ssr_hydration.py (the block handling
result.returncode == 2 and the stderr variable) to only skip when stderr
explicitly indicates Playwright is missing — e.g., stderr contains "Playwright
not installed" or matches "Cannot find module.*playwright" (use a regex/search
rather than a plain "Cannot find module" substring check); leave other errors to
hit pytest.fail so real harness regressions are not masked.

---

Nitpick comments:
In `@src/trellis/platforms/common/ssr_utils.py`:
- Around line 17-24: The _get_version implementation in ssr_utils duplicates the
same function in handler; remove the duplicate by centralizing the logic: create
or use a single shared function (e.g., a new get_version utility in a common
utils module) or import the existing _get_version from the handler module, then
update ssr_utils._get_version to call or re-export that shared function; ensure
the shared function uses importlib.metadata.version("trellis") with the same
fallback and update any imports and tests accordingly.

In `@src/trellis/platforms/server/platform.py`:
- Around line 160-163: Compute the static directory once and reuse it instead of
calling create_static_dir() twice: move the assignment static = static_dir or
create_static_dir() to a single common point used both for SSR bundle discovery
(ssr_bundle = static / "ssr.js" when ssr is true) and later for mounting
/static, and remove the second recomputation; update any code paths that
reference the old second call so they use the shared static variable
(referencing ssr, static_dir, create_static_dir(), and ssr_bundle to locate the
changes).

In `@src/trellis/platforms/server/session_store.py`:
- Around line 31-38: Declare the owned instance attributes on the SessionStore
class body by adding typed declarations for _ttl: float, _entries: dict[str,
SessionEntry], and _lock: threading.Lock (or Optional[threading.Lock] if you
prefer) above __init__; keep the actual assignments inside __init__ (self._ttl =
ttl_seconds, self._entries = {}, self._lock = threading.Lock()) so the class has
explicit attribute annotations while initialization remains in __init__ — update
the SessionStore class definition (symbols: SessionStore, _ttl, _entries, _lock,
__init__) accordingly.

In `@src/trellis/platforms/server/ssr_cache.py`:
- Around line 19-33: Move the instance attribute annotations for SSRCache out of
__init__ into the class body: declare _cache: dict[tuple[str, str], str], _lock:
threading.Lock, and _max_entries: int as class-level annotations (without
initializing them) and leave initialization of their values inside __init__
(self._cache = {}, self._lock = threading.Lock(), self._max_entries =
max_entries); update the SSRCache class header to include these annotations so
the class follows the repo rule of declaring owned instance attributes in the
class body while keeping construction logic in __init__.

In `@src/trellis/utils/subprocess.py`:
- Around line 241-242: The assignment to kernel32.CloseHandle.restype triggers
the same type-checker warning as earlier; update the CloseHandle.restype line in
subprocess.py (the kernel32 = ctypes.WinDLL(...) / kernel32.CloseHandle.restype
= ...) to include the same ignore comment used on line 138 (append # type:
ignore[attr-defined]) so the static analyzer is suppressed and consistent with
the prior ctypes usage.

In `@tests/e2e/_run_test_server.py`:
- Around line 27-29: The main() function reads host/port from sys.argv without
validation which raises IndexError if arguments are missing; update main (or the
top-level argument handling) to validate sys.argv length (or use argparse)
before accessing sys.argv[1] and sys.argv[2], print a clear usage message
including expected host and port, and exit with a non-zero status when arguments
are missing or port is not an integer; reference the main function and the
host/port variables derived from sys.argv to locate where to add the checks and
error handling.
- Around line 22-24: Rename the `@component` function test_app to PascalCase
(e.g., TestApp) and update all references/usages to the new name; specifically
change the function definition def test_app(...) -> None: to def TestApp(...) ->
None: (keep the `@component` decorator) and replace every call/import/usage of
"test_app" (including the references mentioned in the diff) with "TestApp" so
the component follows project naming conventions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 293d8f29-024d-4854-b559-1ee464ae4fd5

📥 Commits

Reviewing files that changed from the base of the PR and between daf9cdd and 83e0cbd.

📒 Files selected for processing (37)
  • pyproject.toml
  • src/trellis/app/config.py
  • src/trellis/bundler/__init__.py
  • src/trellis/bundler/steps.py
  • src/trellis/cli/run.py
  • src/trellis/core/rendering/__init__.py
  • src/trellis/core/rendering/ssr.py
  • src/trellis/platforms/browser/client/src/TrellisApp.tsx
  • src/trellis/platforms/browser/client/src/index.html.j2
  • src/trellis/platforms/browser/client/src/main.tsx
  • src/trellis/platforms/browser/serve_platform.py
  • src/trellis/platforms/common/client/src/TrellisRoot.tsx
  • src/trellis/platforms/common/client/src/core/store.ts
  • src/trellis/platforms/common/client/src/types.ts
  • src/trellis/platforms/common/handler.py
  • src/trellis/platforms/common/messages.py
  • src/trellis/platforms/common/ssr_utils.py
  • src/trellis/platforms/desktop/client/src/index.html.j2
  • src/trellis/platforms/desktop/client/src/main.tsx
  • src/trellis/platforms/desktop/platform.py
  • src/trellis/platforms/server/client/src/TrellisClient.ts
  • src/trellis/platforms/server/client/src/index.html.j2
  • src/trellis/platforms/server/client/src/main.tsx
  • src/trellis/platforms/server/client/src/ssr-entry.tsx
  • src/trellis/platforms/server/handler.py
  • src/trellis/platforms/server/platform.py
  • src/trellis/platforms/server/routes.py
  • src/trellis/platforms/server/session_store.py
  • src/trellis/platforms/server/ssr.py
  • src/trellis/platforms/server/ssr_cache.py
  • src/trellis/platforms/server/ssr_renderer.py
  • src/trellis/utils/hot_reload.py
  • src/trellis/utils/subprocess.py
  • tests/e2e/_run_test_server.py
  • tests/e2e/check_hydration.js
  • tests/e2e/test_ssr_hydration.py
  • tests/py/unit/test_build_config.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/trellis/platforms/server/client/src/ssr-entry.tsx
  • src/trellis/utils/hot_reload.py
  • tests/e2e/check_hydration.js
  • src/trellis/platforms/common/messages.py
  • src/trellis/cli/run.py
  • tests/py/unit/test_build_config.py

Comment thread src/trellis/bundler/steps.py Outdated
Comment thread src/trellis/platforms/browser/serve_platform.py Outdated
Comment thread src/trellis/platforms/server/platform.py
Comment thread src/trellis/platforms/server/session_store.py
Comment thread src/trellis/platforms/server/ssr_cache.py
Comment thread src/trellis/platforms/server/ssr_renderer.py Outdated
Comment thread src/trellis/utils/subprocess.py Outdated
Comment thread tests/e2e/test_ssr_hydration.py

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/trellis/platforms/common/handler.py`:
- Around line 373-376: The code claims to set up router callbacks in both SSR
and full-render paths but currently only calls _setup_router_callbacks() in the
SSR-skip branch; update the full initial-render path (the branch where
root_element_id is not set and the code performs the full initial render) to
invoke self._setup_router_callbacks() after the render is completed (same place
where the SSR path calls it) so router history is wired for non-resumed
sessions; ensure the call is added in the method that handles initial rendering
and mirrored for the alternative branch referenced near the logic using
root_element_id so both paths call _setup_router_callbacks().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2bc0751-b89f-46fb-b79f-2c089f615ca2

📥 Commits

Reviewing files that changed from the base of the PR and between 83e0cbd and 7525ca9.

📒 Files selected for processing (2)
  • src/trellis/platforms/common/handler.py
  • src/trellis/utils/subprocess.py

Comment thread src/trellis/platforms/common/handler.py Outdated
- SSRBundleBuildStep: clarify docstring (not shipped to client)
- SSRPreRenderStep: move renderer.start() inside try; only set
  ssr_enabled when HTML is non-empty
- MessageHandler: extract _try_resume_session/_create_session helpers;
  add _ssr_resumed flag to skip initial_render for SSR sessions;
  set up router callbacks in non-SSR initial render path
- ssr_utils: update docstring (used by all platforms); document
  escaping approach
- Browser serve_platform: gate SSR steps on config.ssr
- Server platform: move imports to top level; extract _setup_ssr()
- ssr-entry.tsx: document that it's not shipped to client

@coderabbitai coderabbitai Bot left a comment

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.

♻️ Duplicate comments (1)
src/trellis/platforms/server/platform.py (1)

159-160: ⚠️ Potential issue | 🟡 Minor

Validate session_ttl to prevent busy-loop in cleanup task.

A non-positive session_ttl would cause _session_cleanup_loop to spin tightly since asyncio.sleep(0) returns immediately. Add validation at the start of run().

🛡️ Suggested fix
     ) -> None:
         """Start FastAPI server with WebSocket support.
         ...
             session_ttl: Session time-to-live in seconds (default 300)
         """
+        if session_ttl <= 0:
+            raise ValueError("session_ttl must be greater than 0")
+
         # Start hot reload if enabled
         if hot_reload:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/platform.py` around lines 159 - 160, The run()
entry should validate session_ttl to prevent a busy-loop in
_session_cleanup_loop when session_ttl is non-positive; add a guard at the start
of run() that checks the session_ttl parameter (or self.session_ttl) and either
raise a ValueError for non-positive values or coerce it to a safe minimum (e.g.,
max(1, session_ttl)) before spawning the cleanup task so asyncio.sleep is never
called with 0 or negative.
🧹 Nitpick comments (1)
src/trellis/platforms/common/ssr_utils.py (1)

17-24: Consider deduplicating _get_version() helper.

This function is nearly identical to _get_version() in src/trellis/platforms/common/handler.py (lines 63-68). Consider extracting to a shared utility to avoid duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/common/ssr_utils.py` around lines 17 - 24, Duplicate
helper _get_version should be extracted to a single shared utility and both
callers should import it: create a shared function (e.g., get_package_version or
keep _get_version) in a common module (e.g., a new common version util) that
contains the try/importlib.metadata logic and fallback "0.0.0", then replace the
local implementations in ssr_utils._get_version and the one in handler (the
other _get_version) to import and call the shared function instead of
duplicating the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/trellis/platforms/server/platform.py`:
- Around line 159-160: The run() entry should validate session_ttl to prevent a
busy-loop in _session_cleanup_loop when session_ttl is non-positive; add a guard
at the start of run() that checks the session_ttl parameter (or
self.session_ttl) and either raise a ValueError for non-positive values or
coerce it to a safe minimum (e.g., max(1, session_ttl)) before spawning the
cleanup task so asyncio.sleep is never called with 0 or negative.

---

Nitpick comments:
In `@src/trellis/platforms/common/ssr_utils.py`:
- Around line 17-24: Duplicate helper _get_version should be extracted to a
single shared utility and both callers should import it: create a shared
function (e.g., get_package_version or keep _get_version) in a common module
(e.g., a new common version util) that contains the try/importlib.metadata logic
and fallback "0.0.0", then replace the local implementations in
ssr_utils._get_version and the one in handler (the other _get_version) to import
and call the shared function instead of duplicating the code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0f0595a8-0e5c-4971-b25e-fa4a0cc652d4

📥 Commits

Reviewing files that changed from the base of the PR and between 7525ca9 and 5c6e199.

📒 Files selected for processing (7)
  • in-progress-review.md
  • src/trellis/bundler/steps.py
  • src/trellis/platforms/browser/serve_platform.py
  • src/trellis/platforms/common/handler.py
  • src/trellis/platforms/common/ssr_utils.py
  • src/trellis/platforms/server/client/src/ssr-entry.tsx
  • src/trellis/platforms/server/platform.py

All SSR HTML uses CSS variables, so light and dark themes produce
identical markup. Remove theme detection from routes, theme params
from the orchestrator, and simplify the cache to key by route only.
This halves the cache entries per route.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

♻️ Duplicate comments (2)
src/trellis/platforms/server/ssr_cache.py (1)

33-36: ⚠️ Potential issue | 🟡 Minor

Reject non-positive cache sizes.

max_entries <= 0 makes the first put() evict from an empty dict and raise StopIteration. Guard this in __init__, or explicitly define 0 as “cache disabled.”

Suggested fix
     def __init__(self, max_entries: int = _DEFAULT_MAX_ENTRIES) -> None:
+        if max_entries <= 0:
+            raise ValueError("max_entries must be greater than 0")
         self._cache: dict[str, str] = {}
         self._lock = threading.Lock()
         self._max_entries = max_entries
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/ssr_cache.py` around lines 33 - 36, The
constructor currently allows non-positive max_entries which causes put() to
evict from an empty dict; in __init__ of the SSR cache class validate
max_entries and reject <= 0 (raise ValueError) or explicitly treat 0 as
"disabled" by storing _max_entries = 0 and updating put() to skip eviction when
_max_entries == 0; locate the __init__ method and the put() method (references:
__init__, self._max_entries, put()) and implement the chosen guard so no
StopIteration can occur.
src/trellis/platforms/server/routes.py (1)

27-34: ⚠️ Potential issue | 🟠 Major

Only hijack true document navigations here.

_is_document_request() only checks Accept, and the non-document branch synthesizes HTMLResponse("Not Found"). That lets HTML-accepting POST/HEAD misses turn into SPA responses, while API/static 404s lose FastAPI's normal payload and content type. Restrict this to GET/HEAD document requests and delegate every other miss to FastAPI's default HTTP exception handler.

Minimal fix
-from fastapi.responses import HTMLResponse
+from fastapi.exception_handlers import http_exception_handler
+from fastapi.responses import HTMLResponse, Response
@@
 def _is_document_request(request: Request) -> bool:
@@
     accept = request.headers.get("accept", "")
-    return "text/html" in accept
+    return request.method in {"GET", "HEAD"} and "text/html" in accept
@@
-    async def spa_fallback_handler(request: Request, exc: StarletteHTTPException) -> HTMLResponse:
+    async def spa_fallback_handler(request: Request, exc: StarletteHTTPException) -> Response:
         """Return SPA HTML for document 404s to support client-side routing."""
         if not _is_document_request(request):
-            return HTMLResponse(content="Not Found", status_code=404)
+            return await http_exception_handler(request, exc)
         return _ssr_or_static_response(request)

Also applies to: 49-53

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/routes.py` around lines 27 - 34, The current
_is_document_request(request: Request) only checks the Accept header and should
instead only return True for actual browser navigations (GET or HEAD that accept
text/html); update _is_document_request to verify request.method is "GET" or
"HEAD" in addition to "text/html" in request.headers.get("accept", ""), and
leave all other methods (e.g., POST, PUT, etc.) to fall through so the route's
non-document branch delegates to FastAPI's default HTTP exception handling (do
not synthesize an HTMLResponse("Not Found") for non-GET/HEAD requests).
🧹 Nitpick comments (2)
in-progress-review.md (2)

76-83: Consider reorganizing the "Already resolved" section.

The items 24-28 create a numbering gap between item 23 and item 29, which could cause confusion when referencing items. Consider either:

  • Moving these items into the "Actionable" section with strikethrough formatting if they were originally part of that list, or
  • Removing this section entirely if these items are truly redundant (since items marked "✅ Previously fixed" already appear in the Actionable section)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@in-progress-review.md` around lines 76 - 83, The "Already resolved"
subsection creates a numbering gap (items 24–28) and should be reorganized:
either move items 24–28 into the "Actionable" list with strikethrough formatting
so numbering remains contiguous with items before/after, or delete the entire
"Already resolved" subsection if those entries are redundant with the "✅
**Previously fixed**" entries; update the "Already resolved" header and the
lists surrounding it (the section containing items 24-28 and the "Actionable"
list) so numbering flows without gaps and references remain unambiguous.

64-74: Add priority/timeline context to open items.

Three items remain open without clear indication of whether they block this PR or can be deferred to follow-up work:

  • Item 20: Platform JS audit for shared SSR/hydration code
  • Items 22-23: Async subprocess/renderer operations (already linked)

Consider adding one of these markers to each open item:

  • Blocking — must be resolved before merge
  • Follow-up — tracked in a separate issue for post-merge work

This will help clarify what remains for PR approval vs. future improvements.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@in-progress-review.md` around lines 64 - 74, Update the open checklist
entries to include clear priority/timeline markers so reviewers know what blocks
merging versus what can be deferred: for the entry referencing main.tsx:48
(emma) add either "→ **Open — Blocking**" or "→ **Open — Follow-up**" after the
Platform JS audit line; for subprocess.py:110 (emma) and ssr_renderer.py:28
(emma) append the same marker (or different ones if one is blocking and the
other is follow-up) next to their "→ **Open**" statuses and, if choosing
Follow-up, add a short note referencing the linked work (e.g., "tracked in issue
#...") so items 20, 22, and 23 visibly indicate whether they must be resolved
pre-merge or can be addressed post-merge.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/trellis/platforms/server/routes.py`:
- Around line 49-53: spa_fallback_handler and index call the blocking helper
_ssr_or_static_response directly from async handlers; change both to offload
that call to a thread pool using starlette.concurrency.run_in_threadpool (i.e.,
await run_in_threadpool(_ssr_or_static_response, request)) so disk I/O
(get_index_html/Path.read_text) and the Bun IPC/httpx POST work off the event
loop; update imports if necessary and ensure both async handlers await the
run_in_threadpool call rather than calling _ssr_or_static_response
synchronously.

In `@src/trellis/platforms/server/ssr.py`:
- Around line 68-86: The code currently stores SessionEntry via
self._session_store.store(session_id, entry) before obtaining SSR HTML and
dehydration payload; change render_to_response so that you call
self._session_store.store(...) only after ssr_html = self._get_ssr_html(path,
session) returns a non-empty/non-None value and after dehydration_data =
build_dehydration_data(session_id, wire_patches) is successfully created; if
_get_ssr_html returns None/"" then return None or raise so the plain-CSR path
can be taken and no SessionEntry is stored; apply the same ordering and guard to
the alternate branch around the second occurrence (lines analogous to 92-108) so
storage happens only after SSR HTML and dehydration payload are validated.

---

Duplicate comments:
In `@src/trellis/platforms/server/routes.py`:
- Around line 27-34: The current _is_document_request(request: Request) only
checks the Accept header and should instead only return True for actual browser
navigations (GET or HEAD that accept text/html); update _is_document_request to
verify request.method is "GET" or "HEAD" in addition to "text/html" in
request.headers.get("accept", ""), and leave all other methods (e.g., POST, PUT,
etc.) to fall through so the route's non-document branch delegates to FastAPI's
default HTTP exception handling (do not synthesize an HTMLResponse("Not Found")
for non-GET/HEAD requests).

In `@src/trellis/platforms/server/ssr_cache.py`:
- Around line 33-36: The constructor currently allows non-positive max_entries
which causes put() to evict from an empty dict; in __init__ of the SSR cache
class validate max_entries and reject <= 0 (raise ValueError) or explicitly
treat 0 as "disabled" by storing _max_entries = 0 and updating put() to skip
eviction when _max_entries == 0; locate the __init__ method and the put() method
(references: __init__, self._max_entries, put()) and implement the chosen guard
so no StopIteration can occur.

---

Nitpick comments:
In `@in-progress-review.md`:
- Around line 76-83: The "Already resolved" subsection creates a numbering gap
(items 24–28) and should be reorganized: either move items 24–28 into the
"Actionable" list with strikethrough formatting so numbering remains contiguous
with items before/after, or delete the entire "Already resolved" subsection if
those entries are redundant with the "✅ **Previously fixed**" entries; update
the "Already resolved" header and the lists surrounding it (the section
containing items 24-28 and the "Actionable" list) so numbering flows without
gaps and references remain unambiguous.
- Around line 64-74: Update the open checklist entries to include clear
priority/timeline markers so reviewers know what blocks merging versus what can
be deferred: for the entry referencing main.tsx:48 (emma) add either "→ **Open —
Blocking**" or "→ **Open — Follow-up**" after the Platform JS audit line; for
subprocess.py:110 (emma) and ssr_renderer.py:28 (emma) append the same marker
(or different ones if one is blocking and the other is follow-up) next to their
"→ **Open**" statuses and, if choosing Follow-up, add a short note referencing
the linked work (e.g., "tracked in issue #...") so items 20, 22, and 23 visibly
indicate whether they must be resolved pre-merge or can be addressed post-merge.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f2a7fc6c-f0ff-4984-9f30-b1f4819c1c24

📥 Commits

Reviewing files that changed from the base of the PR and between 5c6e199 and 2a2ad71.

📒 Files selected for processing (6)
  • in-progress-review.md
  • src/trellis/platforms/server/routes.py
  • src/trellis/platforms/server/ssr.py
  • src/trellis/platforms/server/ssr_cache.py
  • tests/py/unit/test_ssr_cache.py
  • tests/py/unit/test_ssr_orchestrator.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/py/unit/test_ssr_orchestrator.py

Comment thread src/trellis/platforms/server/routes.py Outdated
Comment thread src/trellis/platforms/server/ssr.py
- BuildStep.run() is now async; all 15 build steps updated
- build() is now async, awaits each step
- AppLoader.bundle() is now async
- CLI commands (run, bundle, package) use asyncio.run()
- SSRRenderer uses asyncio subprocess + httpx.AsyncClient instead
  of blocking Popen + sync httpx.Client
- SSROrchestrator.render_to_response() is now async
- Added start_child_process_async/stop_child_process_async to
  subprocess utils using asyncio.create_subprocess_exec
- All test call sites updated to asyncio.run() or AsyncMock

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

♻️ Duplicate comments (3)
src/trellis/platforms/server/ssr_renderer.py (1)

81-83: ⚠️ Potential issue | 🔴 Critical

Deadlock: self.stop() called while holding self._lock.

_start_locked() is called with self._lock held (from start() at line 54-55). When the health check fails at line 81, calling await self.stop() attempts to acquire self._lock again at line 145. Since asyncio.Lock is not reentrant, this causes a deadlock.

Use _stop_locked() instead, which is the correct pattern already used in _try_restart() at line 137.

🔒 Proposed fix
         if not await self._wait_for_health():
-            await self.stop()
+            await self._stop_locked()
             raise RuntimeError("SSR renderer failed health check")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/ssr_renderer.py` around lines 81 - 83, _in
_start_locked(), you're calling await self.stop() while still holding self._lock
which deadlocks because asyncio.Lock isn't reentrant; replace that call with
await self._stop_locked() (the helper that assumes the lock is already held) to
mirror the pattern used in _try_restart() and avoid attempting to re-acquire
self._lock.
src/trellis/platforms/server/platform.py (1)

159-160: ⚠️ Potential issue | 🟠 Major

Validate session_ttl to prevent busy-loop in cleanup task.

The session_ttl parameter is used as the sleep interval in _session_cleanup_loop(). A non-positive value would cause the loop to spin continuously since asyncio.sleep(0) or asyncio.sleep(negative) returns immediately.

🛡️ Proposed fix to add validation
     async def run(
         self,
         root_component: Callable[[], Element],
         app_wrapper: AppWrapper,
         *,
         host: str = "127.0.0.1",
         port: int | None = None,
         static_dir: Path | None = None,
         batch_delay: float = 1.0 / 30,
         hot_reload: bool = True,
         ssr: bool = True,
         session_ttl: int = 300,
         **_kwargs: Any,  # Ignore other platform args
     ) -> None:
         """Start FastAPI server with WebSocket support.
         ...
         """
+        if session_ttl <= 0:
+            raise ValueError("session_ttl must be greater than 0")
+
         # Start hot reload if enabled
         if hot_reload:

Also applies to: 241-245

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/platform.py` around lines 159 - 160, session_ttl
is used as the sleep interval in _session_cleanup_loop and must be validated to
avoid a busy-loop; add a check where session_ttl is accepted (e.g., the class
constructor or factory that defines the parameter) to ensure session_ttl is a
positive integer (>0) and raise ValueError (or TypeError for non-int) with a
clear message if invalid, and update any other locations that accept session_ttl
(the other occurrence around the secondary parameter handling) to perform the
same validation so _session_cleanup_loop always receives a positive sleep
interval.
src/trellis/utils/subprocess.py (1)

170-175: ⚠️ Potential issue | 🟠 Major

Define argtypes for kernel32 calls to avoid unsafe argument marshaling.

Lines [170]-[175] set restype only. Without argtypes, ctypes may coerce HANDLE arguments incorrectly on 64-bit Windows.

💡 Proposed fix
         kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)  # type: ignore[attr-defined]
         kernel32.CreateJobObjectW.restype = ctypes.wintypes.HANDLE
+        kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, ctypes.wintypes.LPCWSTR]
         kernel32.SetInformationJobObject.restype = ctypes.wintypes.BOOL
+        kernel32.SetInformationJobObject.argtypes = [
+            ctypes.wintypes.HANDLE,
+            ctypes.wintypes.INT,
+            ctypes.c_void_p,
+            ctypes.wintypes.DWORD,
+        ]
         kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
+        kernel32.OpenProcess.argtypes = [
+            ctypes.wintypes.DWORD,
+            ctypes.wintypes.BOOL,
+            ctypes.wintypes.DWORD,
+        ]
         kernel32.AssignProcessToJobObject.restype = ctypes.wintypes.BOOL
+        kernel32.AssignProcessToJobObject.argtypes = [
+            ctypes.wintypes.HANDLE,
+            ctypes.wintypes.HANDLE,
+        ]
         kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
+        kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
#!/bin/bash
# Verify current ctypes prototype coverage for Win32 APIs in this file.
nl -ba src/trellis/utils/subprocess.py | sed -n '165,240p'
echo "---- argtypes declarations ----"
rg -n 'kernel32\..*\.argtypes\s*=' src/trellis/utils/subprocess.py
echo "---- restype declarations ----"
rg -n 'kernel32\..*\.restype\s*=' src/trellis/utils/subprocess.py
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/utils/subprocess.py` around lines 170 - 175, The kernel32
functions CreateJobObjectW, SetInformationJobObject, OpenProcess,
AssignProcessToJobObject and CloseHandle currently only set restype; add
explicit .argtypes declarations matching the Win32 signatures to avoid unsafe
marshaling on 64-bit Windows (e.g., CreateJobObjectW(lpJobAttributes, lpName) ->
(ctypes.c_void_p, ctypes.wintypes.LPCWSTR), SetInformationJobObject(hJob,
infoClass, info, infoSize) -> (ctypes.wintypes.HANDLE, ctypes.c_int,
ctypes.c_void_p, ctypes.wintypes.DWORD), OpenProcess(desiredAccess,
inheritHandle, pid) -> (ctypes.wintypes.DWORD, ctypes.wintypes.BOOL,
ctypes.wintypes.DWORD), AssignProcessToJobObject(hJob, hProcess) ->
(ctypes.wintypes.HANDLE, ctypes.wintypes.HANDLE), CloseHandle(hObject) ->
(ctypes.wintypes.HANDLE)); add these .argtypes next to the existing .restype
lines for the functions named above.
🧹 Nitpick comments (4)
tests/e2e/_run_test_server.py (2)

22-24: Rename component function to PascalCase.

Line 23 defines a @component function as test_app; Trellis component functions are expected to use PascalCase for consistency.

♻️ Proposed rename
 `@component`
-def test_app() -> None:
+def TestApp() -> None:
     w.Label(text="Hello from SSR")
-    app_instance = App(test_app)
+    app_instance = App(TestApp)
-            test_app,
+            TestApp,

Based on learnings: “In Trellis, any function decorated with component should be named using PascalCase … enforce this convention across Python files in the repo.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/_run_test_server.py` around lines 22 - 24, The `@component` function
currently named test_app should be renamed to PascalCase (e.g., TestApp) to
follow Trellis conventions: change the function name from test_app to TestApp,
update any imports or references that call or register test_app (search for
"test_app") to the new name, and keep the `@component` decorator and body
unchanged so behavior remains identical; ensure unit/e2e test harness or server
startup code that expects the component is updated to reference TestApp.

14-20: Use canonical Trellis imports for component/widgets.

Line 17 and Line 19 use non-canonical import paths for Trellis component/widget APIs. Please align with repository import conventions.

♻️ Proposed import/style adjustment
-from trellis.app.app import App
 from trellis.app.apploader import AppLoader, set_apploader
 from trellis.app.config import Config
-from trellis.core.components.composition import component
 from trellis.platforms.server.platform import ServerPlatform
-from trellis.widgets import Label
+from trellis import App, component
+from trellis import widgets as w
 def test_app() -> None:
-    Label(text="Hello from SSR")
+    w.Label(text="Hello from SSR")

As per coding guidelines: “Use canonical import style: from trellis import App, component, Stateful … import widgets via from trellis import widgets as w”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/_run_test_server.py` around lines 14 - 20, The imports use
non‑canonical paths; change them to the repository's canonical style by
importing core APIs from the trellis top-level and widgets via an alias (e.g.,
replace explicit module imports of App, AppLoader, set_apploader, Config,
component, ServerPlatform with top‑level imports like "from trellis import App,
AppLoader, set_apploader, Config, component, ServerPlatform" and import widgets
as an alias like "from trellis import widgets as w"), then update usages such as
the Label symbol to use the widget alias (e.g., w.Label) so component/widget
references match the project's canonical import conventions.
src/trellis/platforms/server/routes.py (1)

68-68: Minor: get_index_html() performs synchronous file I/O in async handlers.

Path.read_text() inside get_index_html() is a blocking call. While unlikely to cause issues for a small file like index.html, it could briefly block the event loop under high concurrency.

Consider caching the content in memory after the first read, or using asyncio.to_thread() / run_in_threadpool() for the file read. This is a low-priority optimization.

Also applies to: 74-74

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/routes.py` at line 68, get_index_html()
currently uses Path.read_text() which does synchronous file I/O inside async
request handlers (routes that pass html_template=get_index_html()), so change
get_index_html to avoid blocking the event loop: either cache the file contents
in a module-level variable after the first read (e.g., lazy_load_index_html) or
perform the read off the loop with asyncio.to_thread()/run_in_threadpool and
await the result; update callers that reference get_index_html() (the route
handlers that set html_template=...) to call the async-safe variant or the
cached value instead.
src/trellis/platforms/server/ssr_renderer.py (1)

105-125: Consider the race window between lock release and HTTP request.

The lock is released after capturing client at line 110, but the actual HTTP request happens outside the lock (lines 112-121). If _try_restart() runs concurrently (triggered by another request's failure), it could close and replace self._client while this request is in flight.

This is a minor concern since the captured client reference remains valid for the duration of the request, and httpx handles connection closure gracefully. However, for robustness, consider keeping the lock held for the entire render operation or using a separate read-write lock pattern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/ssr_renderer.py` around lines 105 - 125, The
render method currently releases self._lock before performing the HTTP call,
allowing _try_restart to replace or close self._client mid-request; fix by
keeping the lock held for the duration of the render operation (i.e., move the
await client.post(...) and response handling inside the async with self._lock
block) or alternatively replace self._lock with a read-write lock and acquire a
read lock in render while _try_restart uses a write lock — reference symbols:
render, self._lock, self._client, _try_restart.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/trellis/utils/subprocess.py`:
- Around line 268-278: The Windows job-handle cleanup leaves process._job_handle
set so subsequent cleanup can try to close the same/stale handle; after
successfully calling kernel32.CloseHandle(job_handle) (in the try block handling
process and _job_handle), clear or remove the attribute (e.g., set
process._job_handle = None or delattr(process, "_job_handle")) to make the
operation idempotent and avoid double-closing the handle; ensure this change
happens only after a successful CloseHandle call and keep the existing exception
fallback around kernel32.CloseHandle and imports.

In `@tests/e2e/_run_test_server.py`:
- Around line 27-30: The main function currently reads host and port directly
from sys.argv which can raise IndexError/ValueError; update main to validate
inputs before parsing by checking len(sys.argv) >= 3, parse host and port inside
a try/except that catches ValueError for int() conversion, and on error print a
clear usage/error message to stderr and call sys.exit(1). Locate the logic in
main (references to sys.argv, host, port and int(...)) and replace the direct
indexing/conversion with this guarded parsing so failures produce deterministic,
descriptive exits.

---

Duplicate comments:
In `@src/trellis/platforms/server/platform.py`:
- Around line 159-160: session_ttl is used as the sleep interval in
_session_cleanup_loop and must be validated to avoid a busy-loop; add a check
where session_ttl is accepted (e.g., the class constructor or factory that
defines the parameter) to ensure session_ttl is a positive integer (>0) and
raise ValueError (or TypeError for non-int) with a clear message if invalid, and
update any other locations that accept session_ttl (the other occurrence around
the secondary parameter handling) to perform the same validation so
_session_cleanup_loop always receives a positive sleep interval.

In `@src/trellis/platforms/server/ssr_renderer.py`:
- Around line 81-83: _in _start_locked(), you're calling await self.stop() while
still holding self._lock which deadlocks because asyncio.Lock isn't reentrant;
replace that call with await self._stop_locked() (the helper that assumes the
lock is already held) to mirror the pattern used in _try_restart() and avoid
attempting to re-acquire self._lock.

In `@src/trellis/utils/subprocess.py`:
- Around line 170-175: The kernel32 functions CreateJobObjectW,
SetInformationJobObject, OpenProcess, AssignProcessToJobObject and CloseHandle
currently only set restype; add explicit .argtypes declarations matching the
Win32 signatures to avoid unsafe marshaling on 64-bit Windows (e.g.,
CreateJobObjectW(lpJobAttributes, lpName) -> (ctypes.c_void_p,
ctypes.wintypes.LPCWSTR), SetInformationJobObject(hJob, infoClass, info,
infoSize) -> (ctypes.wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p,
ctypes.wintypes.DWORD), OpenProcess(desiredAccess, inheritHandle, pid) ->
(ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD),
AssignProcessToJobObject(hJob, hProcess) -> (ctypes.wintypes.HANDLE,
ctypes.wintypes.HANDLE), CloseHandle(hObject) -> (ctypes.wintypes.HANDLE)); add
these .argtypes next to the existing .restype lines for the functions named
above.

---

Nitpick comments:
In `@src/trellis/platforms/server/routes.py`:
- Line 68: get_index_html() currently uses Path.read_text() which does
synchronous file I/O inside async request handlers (routes that pass
html_template=get_index_html()), so change get_index_html to avoid blocking the
event loop: either cache the file contents in a module-level variable after the
first read (e.g., lazy_load_index_html) or perform the read off the loop with
asyncio.to_thread()/run_in_threadpool and await the result; update callers that
reference get_index_html() (the route handlers that set html_template=...) to
call the async-safe variant or the cached value instead.

In `@src/trellis/platforms/server/ssr_renderer.py`:
- Around line 105-125: The render method currently releases self._lock before
performing the HTTP call, allowing _try_restart to replace or close self._client
mid-request; fix by keeping the lock held for the duration of the render
operation (i.e., move the await client.post(...) and response handling inside
the async with self._lock block) or alternatively replace self._lock with a
read-write lock and acquire a read lock in render while _try_restart uses a
write lock — reference symbols: render, self._lock, self._client, _try_restart.

In `@tests/e2e/_run_test_server.py`:
- Around line 22-24: The `@component` function currently named test_app should be
renamed to PascalCase (e.g., TestApp) to follow Trellis conventions: change the
function name from test_app to TestApp, update any imports or references that
call or register test_app (search for "test_app") to the new name, and keep the
`@component` decorator and body unchanged so behavior remains identical; ensure
unit/e2e test harness or server startup code that expects the component is
updated to reference TestApp.
- Around line 14-20: The imports use non‑canonical paths; change them to the
repository's canonical style by importing core APIs from the trellis top-level
and widgets via an alias (e.g., replace explicit module imports of App,
AppLoader, set_apploader, Config, component, ServerPlatform with top‑level
imports like "from trellis import App, AppLoader, set_apploader, Config,
component, ServerPlatform" and import widgets as an alias like "from trellis
import widgets as w"), then update usages such as the Label symbol to use the
widget alias (e.g., w.Label) so component/widget references match the project's
canonical import conventions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 996e61a3-3511-4041-a40a-a86b8e4c899a

📥 Commits

Reviewing files that changed from the base of the PR and between 2a2ad71 and 74bd224.

📒 Files selected for processing (22)
  • src/trellis/app/apploader.py
  • src/trellis/bundler/build.py
  • src/trellis/bundler/steps.py
  • src/trellis/cli/bundle.py
  • src/trellis/cli/package.py
  • src/trellis/cli/run.py
  • src/trellis/platforms/browser/build_steps.py
  • src/trellis/platforms/server/platform.py
  • src/trellis/platforms/server/routes.py
  • src/trellis/platforms/server/ssr.py
  • src/trellis/platforms/server/ssr_renderer.py
  • src/trellis/utils/subprocess.py
  • tests/e2e/_run_test_server.py
  • tests/py/integration/test_platform_incremental_builds.py
  • tests/py/unit/test_apploader.py
  • tests/py/unit/test_browser_build_steps.py
  • tests/py/unit/test_build_steps.py
  • tests/py/unit/test_bundler_unit.py
  • tests/py/unit/test_ssr_bundle_step.py
  • tests/py/unit/test_ssr_orchestrator.py
  • tests/py/unit/test_ssr_pre_render_step.py
  • tests/py/unit/test_step_contracts.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/py/unit/test_ssr_bundle_step.py
  • tests/py/unit/test_ssr_orchestrator.py

Comment thread src/trellis/utils/subprocess.py
Comment thread src/trellis/utils/subprocess.py
Comment thread tests/e2e/_run_test_server.py
- init.ts: Symbol.dispose polyfill, registry init, theme CSS, console
- ssr.ts: TrellisSSRData type, ssrData extraction, mountApp() helper
  that handles hydrate-vs-createRoot branching
- ClientApp.tsx: shared App wrapper for server+desktop with client
  lifecycle management (connect/disconnect/error)

All three platform entry points now import from common instead of
duplicating initialization, SSR types, and hydration logic.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

♻️ Duplicate comments (2)
src/trellis/platforms/server/platform.py (1)

159-160: ⚠️ Potential issue | 🟡 Minor

Add validation for session_ttl to prevent busy-loop in cleanup task.

A direct programmatic call like ServerPlatform().run(..., session_ttl=0) would cause _session_cleanup_loop to spin tightly via asyncio.sleep(0). While config-level validation may protect the CLI path, the public API should fail fast on invalid values.

🛡️ Proposed fix
     async def run(
         self,
         root_component: Callable[[], Element],
         app_wrapper: AppWrapper,
         *,
         host: str = "127.0.0.1",
         port: int | None = None,
         static_dir: Path | None = None,
         batch_delay: float = 1.0 / 30,
         hot_reload: bool = True,
         ssr: bool = True,
         session_ttl: int = 300,
         **_kwargs: Any,
     ) -> None:
+        if session_ttl <= 0:
+            raise ValueError("session_ttl must be greater than 0")
+
         # Start hot reload if enabled
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/platform.py` around lines 159 - 160, Validate
session_ttl early in the public API to avoid a busy-loop: in ServerPlatform.run
(and/or ServerPlatform.__init__ if more appropriate) add a check that
session_ttl is an integer > 0 and raise a ValueError with a clear message if
not; this prevents _session_cleanup_loop from calling asyncio.sleep(0) and
spinning tightly. Ensure the validation references the session_ttl parameter
passed to run so callers receive a fast, explicit failure instead of triggering
the cleanup loop.
src/trellis/utils/subprocess.py (1)

76-93: ⚠️ Potential issue | 🟠 Major

Async stop path does not terminate process group on POSIX.

start_child_process_async() starts the process in a new session (line 72), but stop_child_process_async() only calls process.terminate() and process.kill(), which signal the child PID directly. Descendant processes (grandchildren) will survive, potentially leaving orphans.

The sync _stop_posix() correctly uses os.killpg() (lines 140-141, 147-148). The async variant should mirror this behavior.

🔧 Proposed fix
 async def stop_child_process_async(
     process: asyncio.subprocess.Process, timeout: float = 5.0
 ) -> None:
     """Stop an async child process gracefully, escalating to SIGKILL on timeout."""
     if process.returncode is not None:
         return
 
     try:
-        process.terminate()
+        if os.name == "nt":
+            process.terminate()
+        else:
+            import signal  # noqa: PLC0415
+            os.killpg(os.getpgid(process.pid), signal.SIGTERM)
         await asyncio.wait_for(process.wait(), timeout=timeout)
     except ProcessLookupError:
         pass
     except TimeoutError:
         try:
-            process.kill()
+            if os.name == "nt":
+                process.kill()
+            else:
+                import signal  # noqa: PLC0415
+                os.killpg(os.getpgid(process.pid), signal.SIGKILL)
             await process.wait()
         except ProcessLookupError:
             pass
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/utils/subprocess.py` around lines 76 - 93, The async shutdown in
stop_child_process_async currently signals only the child PID; update it to
mirror _stop_posix() by sending signals to the process group on POSIX: when
process.pid is set and the platform supports process groups (e.g., os.name !=
"nt" or hasattr(os, "killpg")), call os.killpg(os.getpgid(process.pid),
signal.SIGTERM) instead of process.terminate(), await process.wait() with
asyncio.TimeoutError handling, and on timeout call os.killpg(...,
signal.SIGKILL); fall back to process.terminate()/process.kill() for non-POSIX
or if process.pid is missing, and keep the existing ProcessLookupError handling.
Ensure imports for os and signal and use asyncio.TimeoutError in the except
clause.
🧹 Nitpick comments (1)
src/trellis/platforms/common/client/src/ClientApp.tsx (1)

28-33: Connection error is logged but not surfaced to user.

When client.connect() rejects, the error is logged to console but setError is not called. The user sees no indication of the connection failure. Consider calling setError with a meaningful message:

   useEffect(() => {
     client.connect().catch((err) => {
       console.error("Failed to connect:", err);
+      setError(err instanceof Error ? err.message : "Connection failed");
     });
     return () => client.disconnect();
   }, [client]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/common/client/src/ClientApp.tsx` around lines 28 - 33,
The connection failure is only console.logged inside the useEffect that calls
client.connect(); update that catch handler in the useEffect (the
client.connect() promise) to call setError with a meaningful message (including
err.message or err) and clear any connecting state (e.g., call
setIsConnecting(false) if present) so the UI is updated; ensure the same is done
for any thrown errors from client.connect() so the user sees the error and the
component stops showing a loading/connecting state.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/trellis/platforms/common/client/src/ClientApp.tsx`:
- Line 26: The client factory passed into ClientApp is unstable because
server/main.tsx supplies createClient as an inline arrow function, causing
ClientApp's useMemo (const client = useMemo(() => createClient(setError),
[createClient])) to recreate the client every render; fix by stabilizing the
factory in server/main.tsx—wrap the inline createClient arrow function in
useCallback so the reference passed into ClientApp remains stable (mirroring the
desktop entry's approach) and only changes when its true dependencies change.

In `@src/trellis/platforms/server/client/src/main.tsx`:
- Around line 10-22: The inline factory passed to ClientApp is recreated on
every render causing ServerTrellisClient to be rebuilt; wrap the createClient
factory in React's useCallback inside the App component to memoize it
(referencing the createClient prop passed to ClientApp and the
ServerTrellisClient constructor) and include ssrData?.sessionId and onError in
the useCallback dependency array so the factory only changes when those values
change.

In `@src/trellis/platforms/server/ssr_renderer.py`:
- Around line 81-83: The health-check failure path in _start_locked() calls
await self.stop() while the asyncio.Lock is held, causing a deadlock; replace
that call with a direct call to self._stop_locked() (as done in _try_restart())
so the locked shutdown runs without attempting to re-acquire the same
lock—update the failure branch in _start_locked() to call _stop_locked() instead
of await self.stop(), keeping the raise RuntimeError afterwards.

---

Duplicate comments:
In `@src/trellis/platforms/server/platform.py`:
- Around line 159-160: Validate session_ttl early in the public API to avoid a
busy-loop: in ServerPlatform.run (and/or ServerPlatform.__init__ if more
appropriate) add a check that session_ttl is an integer > 0 and raise a
ValueError with a clear message if not; this prevents _session_cleanup_loop from
calling asyncio.sleep(0) and spinning tightly. Ensure the validation references
the session_ttl parameter passed to run so callers receive a fast, explicit
failure instead of triggering the cleanup loop.

In `@src/trellis/utils/subprocess.py`:
- Around line 76-93: The async shutdown in stop_child_process_async currently
signals only the child PID; update it to mirror _stop_posix() by sending signals
to the process group on POSIX: when process.pid is set and the platform supports
process groups (e.g., os.name != "nt" or hasattr(os, "killpg")), call
os.killpg(os.getpgid(process.pid), signal.SIGTERM) instead of
process.terminate(), await process.wait() with asyncio.TimeoutError handling,
and on timeout call os.killpg(..., signal.SIGKILL); fall back to
process.terminate()/process.kill() for non-POSIX or if process.pid is missing,
and keep the existing ProcessLookupError handling. Ensure imports for os and
signal and use asyncio.TimeoutError in the except clause.

---

Nitpick comments:
In `@src/trellis/platforms/common/client/src/ClientApp.tsx`:
- Around line 28-33: The connection failure is only console.logged inside the
useEffect that calls client.connect(); update that catch handler in the
useEffect (the client.connect() promise) to call setError with a meaningful
message (including err.message or err) and clear any connecting state (e.g.,
call setIsConnecting(false) if present) so the UI is updated; ensure the same is
done for any thrown errors from client.connect() so the user sees the error and
the component stops showing a loading/connecting state.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6d353c9d-0b85-468f-ba1f-1c975058613c

📥 Commits

Reviewing files that changed from the base of the PR and between 2a2ad71 and 9a2f51a.

📒 Files selected for processing (29)
  • .gitignore
  • src/trellis/app/apploader.py
  • src/trellis/bundler/build.py
  • src/trellis/bundler/steps.py
  • src/trellis/cli/bundle.py
  • src/trellis/cli/package.py
  • src/trellis/cli/run.py
  • src/trellis/platforms/browser/build_steps.py
  • src/trellis/platforms/browser/client/src/main.tsx
  • src/trellis/platforms/common/client/src/ClientApp.tsx
  • src/trellis/platforms/common/client/src/init.ts
  • src/trellis/platforms/common/client/src/ssr.ts
  • src/trellis/platforms/desktop/client/src/main.tsx
  • src/trellis/platforms/server/client/src/main.tsx
  • src/trellis/platforms/server/platform.py
  • src/trellis/platforms/server/routes.py
  • src/trellis/platforms/server/ssr.py
  • src/trellis/platforms/server/ssr_renderer.py
  • src/trellis/utils/subprocess.py
  • tests/e2e/_run_test_server.py
  • tests/py/integration/test_platform_incremental_builds.py
  • tests/py/unit/test_apploader.py
  • tests/py/unit/test_browser_build_steps.py
  • tests/py/unit/test_build_steps.py
  • tests/py/unit/test_bundler_unit.py
  • tests/py/unit/test_ssr_bundle_step.py
  • tests/py/unit/test_ssr_orchestrator.py
  • tests/py/unit/test_ssr_pre_render_step.py
  • tests/py/unit/test_step_contracts.py
✅ Files skipped from review due to trivial changes (7)
  • .gitignore
  • tests/py/unit/test_bundler_unit.py
  • tests/py/integration/test_platform_incremental_builds.py
  • tests/py/unit/test_apploader.py
  • tests/py/unit/test_ssr_bundle_step.py
  • tests/py/unit/test_build_steps.py
  • src/trellis/platforms/server/ssr.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/trellis/cli/package.py
  • src/trellis/bundler/build.py
  • tests/e2e/_run_test_server.py
  • src/trellis/cli/run.py
  • src/trellis/platforms/browser/build_steps.py
  • tests/py/unit/test_ssr_orchestrator.py

Comment thread src/trellis/platforms/common/client/src/ClientApp.tsx
Comment thread src/trellis/platforms/server/client/src/main.tsx
Comment thread src/trellis/platforms/server/ssr_renderer.py
- server/main.tsx: wrap createClient in useCallback to prevent
  client recreation on every render (matching desktop pattern)
- ssr_renderer.py: call _stop_locked() instead of self.stop() in
  _start_locked() to avoid deadlock on non-reentrant asyncio.Lock

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/trellis/platforms/server/platform.py (1)

196-239: ⚠️ Potential issue | 🟠 Major

Make the shutdown finally cover SSR startup too.

ssr_renderer and cleanup_task are created before Line 233 enters the try. If anything later in startup raises, both resources leak.

♻️ Wrap resource acquisition and await task cancellation
+import contextlib
@@
-        session_store, ssr_renderer = await _setup_ssr(
-            app,
-            root_component=root_component,
-            app_wrapper=app_wrapper,
-            static_dir=static_dir,
-            session_ttl=session_ttl,
-            ssr_enabled=ssr,
-            hot_reload=hot_reload,
-        )
-
-        # Periodic cleanup of expired sessions
-        cleanup_task = asyncio.create_task(
-            _session_cleanup_loop(session_store, interval=session_ttl)
-        )
-
-        # Set up static file serving
-        static = static_dir or create_static_dir()
-        if static.exists():
-            app.mount("/static", StaticFiles(directory=static), name="static")
-
-        # Register SPA fallback for client-side routing (must be after static files)
-        register_spa_fallback(app)
-
-        # Find available port if not specified
-        if port is None:
-            port = find_available_port(host=host)
-
-        _print_startup_banner(host, port)
-
-        config = uvicorn.Config(
-            app,
-            host=host,
-            port=port,
-            log_config=None,  # Don't override logging config
-            log_level="warning",  # Suppress uvicorn's info messages
-        )
-        server = uvicorn.Server(config)
+        ssr_renderer: SSRRenderer | None = None
+        cleanup_task: asyncio.Task[None] | None = None
         try:
+            session_store, ssr_renderer = await _setup_ssr(
+                app,
+                root_component=root_component,
+                app_wrapper=app_wrapper,
+                static_dir=static_dir,
+                session_ttl=session_ttl,
+                ssr_enabled=ssr,
+                hot_reload=hot_reload,
+            )
+            cleanup_task = asyncio.create_task(
+                _session_cleanup_loop(session_store, interval=session_ttl)
+            )
+
+            static = static_dir or create_static_dir()
+            if static.exists():
+                app.mount("/static", StaticFiles(directory=static), name="static")
+
+            register_spa_fallback(app)
+
+            if port is None:
+                port = find_available_port(host=host)
+
+            _print_startup_banner(host, port)
+
+            config = uvicorn.Config(
+                app,
+                host=host,
+                port=port,
+                log_config=None,
+                log_level="warning",
+            )
+            server = uvicorn.Server(config)
             await server.serve()
         finally:
-            cleanup_task.cancel()
+            if cleanup_task is not None:
+                cleanup_task.cancel()
+                with contextlib.suppress(asyncio.CancelledError):
+                    await cleanup_task
             if ssr_renderer is not None:
                 await ssr_renderer.stop()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/platform.py` around lines 196 - 239, The code
currently creates cleanup_task and ssr_renderer (from _setup_ssr) before
entering the try that awaits server. Move the try/finally to start immediately
after acquiring those resources so that cleanup_task.cancel() and await
ssr_renderer.stop() always run if any subsequent startup step fails;
specifically wrap the block starting just after calling _setup_ssr (and
including creation of cleanup_task, static mounting, register_spa_fallback,
find_available_port, _print_startup_banner, uvicorn.Config/Server creation, and
await server.serve()) in a try with the existing finally that cancels
cleanup_task and stops ssr_renderer, ensuring you check ssr_renderer is not None
before awaiting stop and handle cancellation safely.
♻️ Duplicate comments (2)
src/trellis/platforms/server/platform.py (1)

159-160: ⚠️ Potential issue | 🟠 Major

Validate session_ttl before starting the cleanup loop.

This value becomes the sleep interval at Line 244. A direct run(..., session_ttl=0) call will spin _session_cleanup_loop() continuously.

🛡️ Fail fast here
     ) -> None:
         """Start FastAPI server with WebSocket support.
@@
             ssr: Enable server-side rendering (default True)
             session_ttl: Session time-to-live in seconds (default 300)
         """
+        if session_ttl <= 0:
+            raise ValueError("session_ttl must be greater than 0")
+
         # Start hot reload if enabled
         if hot_reload:

Also applies to: 206-209, 241-245

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/platform.py` around lines 159 - 160, Validate
the session_ttl parameter before starting the cleanup loop: in the entry point
that accepts session_ttl (the run(...) function / Platform startup path) ensure
session_ttl is a positive integer (e.g., raise ValueError or coerce to a safe
minimum like 1) and reject or normalize zero/negative values so
_session_cleanup_loop() never receives 0 and cannot spin continuously; update
the validation in the same call site that defines the ssr: bool = True,
session_ttl: int = 300 signature and any other places that start the loop so all
callers (including where _session_cleanup_loop is scheduled) use the validated
value.
src/trellis/utils/subprocess.py (1)

76-93: ⚠️ Potential issue | 🟠 Major

Terminate the whole POSIX process group in the async stop path.

SSRRenderer starts Bun in a new session, but this helper only signals the leader PID. On POSIX, any children of that sidecar survive restart/shutdown.

🩹 Mirror `_stop_posix()` here
 async def stop_child_process_async(
     process: asyncio.subprocess.Process, timeout: float = 5.0
 ) -> None:
@@
     try:
-        process.terminate()
+        if os.name == "nt":
+            process.terminate()
+        else:
+            import signal  # noqa: PLC0415 — only needed on POSIX
+
+            os.killpg(process.pid, signal.SIGTERM)
         await asyncio.wait_for(process.wait(), timeout=timeout)
     except ProcessLookupError:
         pass
     except TimeoutError:
         try:
-            process.kill()
+            if os.name == "nt":
+                process.kill()
+            else:
+                import signal  # noqa: PLC0415 — only needed on POSIX
+
+                os.killpg(process.pid, signal.SIGKILL)
             await process.wait()
-        except ProcessLookupError:
+        except (ProcessLookupError, OSError):
             pass
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/utils/subprocess.py` around lines 76 - 93,
stop_child_process_async currently only signals the leader PID so descendant
processes in a POSIX session survive; on POSIX platforms, detect os.name ==
"posix" and if process.pid is set send the signal to the process group (use
os.killpg(process.pid, signal.SIGTERM) or os.kill(-process.pid, signal.SIGTERM))
instead of process.terminate(), await wait with timeout, and on TimeoutError
escalate by sending SIGKILL to the group (os.killpg/kill with SIGKILL). Keep
existing ProcessLookupError handling and ensure you only use the group-kill
branch on POSIX and when process.pid is present.
🧹 Nitpick comments (1)
tests/py/unit/test_ssr_orchestrator.py (1)

45-59: Avoid asserting through orch._ssr_renderer.

Line 140 reaches into a private field. Returning the mock renderer from the fixture, or adding an # INTERNAL TEST: note, keeps this test aligned with the repo’s test contract.

♻️ Small fixture tweak
-@pytest.fixture
-def label_orchestrator_with_renderer(app_wrapper: tp.Any) -> SSROrchestrator:
+@pytest.fixture
+def label_orchestrator_with_renderer(
+    app_wrapper: tp.Any,
+) -> tuple[SSROrchestrator, MagicMock]:
@@
-    return SSROrchestrator(
+    orchestrator = SSROrchestrator(
         root_component=App,
         app_wrapper=app_wrapper,
         session_store=store,
         ssr_renderer=renderer,
     )
+    return orchestrator, renderer
@@
-        orch = label_orchestrator_with_renderer
-        renderer = orch._ssr_renderer
-        assert renderer is not None
+        orch, renderer = label_orchestrator_with_renderer

As per coding guidelines, "Test via public API, not _private attributes; document with # INTERNAL TEST: <reason> comment if testing internals is necessary".

Also applies to: 135-141

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/py/unit/test_ssr_orchestrator.py` around lines 45 - 59, The test
fixture label_orchestrator_with_renderer currently reaches into the private
field orch._ssr_renderer to assert behavior; instead modify the fixture to
return the mock renderer alongside/within the SSROrchestrator (e.g., return a
tuple (orchestrator, renderer) or attach the mock to a public test-only
property) so tests use the public API, or if you must inspect internals add a
clear "# INTERNAL TEST: accessing _ssr_renderer to verify mock renderer from
fixture" comment next to the SSROrchestrator creation; update all tests that
currently reference _ssr_renderer to use the returned renderer or acknowledge
the internal access comment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/trellis/platforms/server/routes.py`:
- Around line 27-35: _is_document_request currently treats any request with
"text/html" in the Accept header as a document navigation; update it to only
return True for browser navigations that are safe for SPA fallback by also
checking the HTTP method: require request.method to be "GET" or "HEAD" in
addition to Accept containing "text/html". Modify the
_is_document_request(request: Request) function to inspect request.method (use
request.method.upper()) and only return True when method is "GET" or "HEAD" and
Accept includes "text/html" so POST/PUT/DELETE requests no longer produce the
SPA HTML fallback.

---

Outside diff comments:
In `@src/trellis/platforms/server/platform.py`:
- Around line 196-239: The code currently creates cleanup_task and ssr_renderer
(from _setup_ssr) before entering the try that awaits server. Move the
try/finally to start immediately after acquiring those resources so that
cleanup_task.cancel() and await ssr_renderer.stop() always run if any subsequent
startup step fails; specifically wrap the block starting just after calling
_setup_ssr (and including creation of cleanup_task, static mounting,
register_spa_fallback, find_available_port, _print_startup_banner,
uvicorn.Config/Server creation, and await server.serve()) in a try with the
existing finally that cancels cleanup_task and stops ssr_renderer, ensuring you
check ssr_renderer is not None before awaiting stop and handle cancellation
safely.

---

Duplicate comments:
In `@src/trellis/platforms/server/platform.py`:
- Around line 159-160: Validate the session_ttl parameter before starting the
cleanup loop: in the entry point that accepts session_ttl (the run(...) function
/ Platform startup path) ensure session_ttl is a positive integer (e.g., raise
ValueError or coerce to a safe minimum like 1) and reject or normalize
zero/negative values so _session_cleanup_loop() never receives 0 and cannot spin
continuously; update the validation in the same call site that defines the ssr:
bool = True, session_ttl: int = 300 signature and any other places that start
the loop so all callers (including where _session_cleanup_loop is scheduled) use
the validated value.

In `@src/trellis/utils/subprocess.py`:
- Around line 76-93: stop_child_process_async currently only signals the leader
PID so descendant processes in a POSIX session survive; on POSIX platforms,
detect os.name == "posix" and if process.pid is set send the signal to the
process group (use os.killpg(process.pid, signal.SIGTERM) or
os.kill(-process.pid, signal.SIGTERM)) instead of process.terminate(), await
wait with timeout, and on TimeoutError escalate by sending SIGKILL to the group
(os.killpg/kill with SIGKILL). Keep existing ProcessLookupError handling and
ensure you only use the group-kill branch on POSIX and when process.pid is
present.

---

Nitpick comments:
In `@tests/py/unit/test_ssr_orchestrator.py`:
- Around line 45-59: The test fixture label_orchestrator_with_renderer currently
reaches into the private field orch._ssr_renderer to assert behavior; instead
modify the fixture to return the mock renderer alongside/within the
SSROrchestrator (e.g., return a tuple (orchestrator, renderer) or attach the
mock to a public test-only property) so tests use the public API, or if you must
inspect internals add a clear "# INTERNAL TEST: accessing _ssr_renderer to
verify mock renderer from fixture" comment next to the SSROrchestrator creation;
update all tests that currently reference _ssr_renderer to use the returned
renderer or acknowledge the internal access comment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f401b2f8-e02b-4762-9b62-8245eb1a7750

📥 Commits

Reviewing files that changed from the base of the PR and between 2a2ad71 and 2fb7a51.

📒 Files selected for processing (29)
  • .gitignore
  • src/trellis/app/apploader.py
  • src/trellis/bundler/build.py
  • src/trellis/bundler/steps.py
  • src/trellis/cli/bundle.py
  • src/trellis/cli/package.py
  • src/trellis/cli/run.py
  • src/trellis/platforms/browser/build_steps.py
  • src/trellis/platforms/browser/client/src/main.tsx
  • src/trellis/platforms/common/client/src/ClientApp.tsx
  • src/trellis/platforms/common/client/src/init.ts
  • src/trellis/platforms/common/client/src/ssr.ts
  • src/trellis/platforms/desktop/client/src/main.tsx
  • src/trellis/platforms/server/client/src/main.tsx
  • src/trellis/platforms/server/platform.py
  • src/trellis/platforms/server/routes.py
  • src/trellis/platforms/server/ssr.py
  • src/trellis/platforms/server/ssr_renderer.py
  • src/trellis/utils/subprocess.py
  • tests/e2e/_run_test_server.py
  • tests/py/integration/test_platform_incremental_builds.py
  • tests/py/unit/test_apploader.py
  • tests/py/unit/test_browser_build_steps.py
  • tests/py/unit/test_build_steps.py
  • tests/py/unit/test_bundler_unit.py
  • tests/py/unit/test_ssr_bundle_step.py
  • tests/py/unit/test_ssr_orchestrator.py
  • tests/py/unit/test_ssr_pre_render_step.py
  • tests/py/unit/test_step_contracts.py
✅ Files skipped from review due to trivial changes (4)
  • .gitignore
  • tests/py/unit/test_bundler_unit.py
  • tests/py/unit/test_ssr_bundle_step.py
  • src/trellis/platforms/server/ssr.py
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/trellis/cli/package.py
  • src/trellis/cli/run.py
  • src/trellis/bundler/build.py
  • tests/py/integration/test_platform_incremental_builds.py
  • tests/e2e/_run_test_server.py
  • src/trellis/platforms/browser/build_steps.py
  • src/trellis/platforms/browser/client/src/main.tsx
  • tests/py/unit/test_ssr_pre_render_step.py

Comment thread src/trellis/platforms/server/routes.py

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
src/trellis/platforms/server/routes.py (1)

63-76: Consider caching index.html content to avoid repeated disk reads.

get_index_html() is called on every request and reads from disk synchronously. For high-traffic scenarios, caching the content in memory would eliminate repeated I/O. This could use a simple module-level cache with invalidation on hot reload.

♻️ Example caching approach
_index_html_cache: str | None = None

def get_index_html() -> str:
    global _index_html_cache
    if _index_html_cache is None:
        index_path = create_static_dir() / "index.html"
        _index_html_cache = index_path.read_text()
    return _index_html_cache

def invalidate_index_cache() -> None:
    global _index_html_cache
    _index_html_cache = None

Wire invalidate_index_cache() into the hot-reload callback alongside SSR cache invalidation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/trellis/platforms/server/routes.py` around lines 63 - 76, The code
repeatedly calls get_index_html() per request causing synchronous disk I/O; add
a module-level cache (e.g., _index_html_cache) inside the module that
get_index_html() uses to return cached content on subsequent calls, and
implement invalidate_index_cache() to clear the cache; update any hot-reload or
SSR invalidation hooks (where _get_ssr_orchestrator or SSR orchestration
invalidation occurs) to call invalidate_index_cache() so updated index.html is
picked up on reload.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/trellis/platforms/server/routes.py`:
- Around line 63-76: The code repeatedly calls get_index_html() per request
causing synchronous disk I/O; add a module-level cache (e.g., _index_html_cache)
inside the module that get_index_html() uses to return cached content on
subsequent calls, and implement invalidate_index_cache() to clear the cache;
update any hot-reload or SSR invalidation hooks (where _get_ssr_orchestrator or
SSR orchestration invalidation occurs) to call invalidate_index_cache() so
updated index.html is picked up on reload.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 53217d7e-0afe-4ff4-898c-4a446c5e6e52

📥 Commits

Reviewing files that changed from the base of the PR and between 2fb7a51 and b885152.

📒 Files selected for processing (1)
  • src/trellis/platforms/server/routes.py

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