Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Release notes for Pyxle. While we're in beta (`0.x`), minor versions may include
- **Converted Markdown keeps agents on the Markdown channel** (breaking behavior change). `autoConvert` output — and the public `html_to_markdown()` helper — now rewrites internal page links to their `.md` renditions: `[About](/about)` becomes `[About](/about.md)`, preserving query strings and fragments (`/about?x=1#y` → `/about.md?x=1#y`). External URLs, `mailto:`/`tel:` links, `/api/` routes, asset paths with a file extension, and links already ending in `.md` are untouched, and `wrap_markdown` hooks receive the rewritten Markdown. **Breaking:** `html_to_markdown()` rewrites by default — pass `rewrite_links=False` for the old verbatim behavior. See [AI accessibility → autoConvert](guides/llms.md#autoconvert-the-lossy-fallback).
- **Fix: a burst of rapid file saves can no longer kill the dev server.** Two dev-server robustness fixes land together. First, build passes are now serialized and `meta.json` is written atomically and only when its content changed — previously a debounce-timer race could run two rebuilds at once, and one pass reading the build metadata mid-write misdiagnosed it as a schema mismatch and wiped the whole `.pyxle-build` cache, deleting and recreating `vite.config.js`. Vite answers a config change with a full restart, and a restart racing the next rebuild could die (`[vite] process exited`), leaving nothing listening until a manual restart. Second, the Vite subprocess is now properly supervised: **any** exit the dev server didn't initiate — including a "clean" exit code 0 — logs a clear warning and relaunches Vite with a short exponential backoff (0.5s, doubling) and a bounded budget (3 consecutive attempts). If relaunching keeps failing, a structured `ViteSupervisionError` is surfaced loudly in the terminal instead of a silently dead asset server.
- **Fix: touching `request.state.<name>` without the providing plugin now explains itself.** A loader or `@action` that reads a `request.state` attribute nothing populated (most commonly `request.state.db` without the pyxle-db plugin) used to 500 with a bare `AttributeError: 'State' object has no attribute 'db'` and a raw traceback that pointed nowhere. The framework now detects exactly this case and reports it as a structured error with guidance: state attributes are provided by plugins or middleware — e.g. `request.state.db` requires the pyxle-db plugin in `pyxle.config.json` (`"plugins": ["pyxle-db"]`). In dev the message appears in the error overlay, the action's JSON error, and the server log (with the original traceback chained); in production the response stays generic, as always. Any other `AttributeError` from your code flows through unchanged.
- **Actionable SSR error when a component touches a browser global.** A component that evaluates `window`, `document`, `navigator`, `localStorage`, `sessionStorage`, `location`, `matchMedia`, or `requestAnimationFrame` at render scope used to fail SSR with a bare `window is not defined` and a traceback of framework frames — naming the route but never your file, with no hint at the fix. Dev mode (error page, overlay, log) now explains that browser globals don't exist during server-side rendering, names the page's `.pyxl` source file, and gives the remedy: move the browser-only code into a `useEffect` hook or an event handler, or render the subtree with `<ClientOnly>` — see the [Client Components guide](guides/client-components.md). Production responses stay generic (no file paths or internals in the body); the enriched explanation goes to the server log. `ReferenceError`s on other names, and all other render errors, flow through exactly as before.

## 0.6.1 — 2026-07-01

Expand Down
15 changes: 14 additions & 1 deletion pyxle/ssr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,26 @@
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING: # pragma: no cover - import-time helpers
from .renderer import ComponentRenderer, ComponentRenderError, pool_render_factory
from .renderer import (
BROWSER_ONLY_GLOBALS,
BrowserGlobalRenderError,
ComponentRenderer,
ComponentRenderError,
detect_browser_only_global,
pool_render_factory,
)
from .template import render_document, render_error_document
from .view import build_page_response
from .worker_pool import SsrWorkerPool, WorkerPoolError

__all__ = [
"BROWSER_ONLY_GLOBALS",
"BrowserGlobalRenderError",
"ComponentRenderError",
"ComponentRenderer",
"InlineStyleFragment",
"RenderResult",
"detect_browser_only_global",
"pool_render_factory",
"SsrWorkerPool",
"WorkerPoolError",
Expand All @@ -29,10 +39,13 @@

def __getattr__(name: str) -> Any: # pragma: no cover - module-level indirection
if name in {
"BROWSER_ONLY_GLOBALS",
"BrowserGlobalRenderError",
"ComponentRenderer",
"ComponentRenderError",
"InlineStyleFragment",
"RenderResult",
"detect_browser_only_global",
"pool_render_factory",
}:
module = import_module(".renderer", __name__)
Expand Down
80 changes: 80 additions & 0 deletions pyxle/ssr/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import inspect
import json
import re
import shutil
import subprocess
from dataclasses import dataclass
Expand All @@ -16,6 +17,82 @@ class ComponentRenderError(RuntimeError):
"""Raised when a component cannot be rendered server-side."""


#: Browser-only globals that do not exist in the Node SSR environment. A
#: ``ReferenceError`` on one of these names during a server render means the
#: component evaluated a browser API at render scope — the exact mistake
#: :class:`BrowserGlobalRenderError` explains and points at the fix for.
BROWSER_ONLY_GLOBALS: tuple[str, ...] = (
"window",
"document",
"navigator",
"localStorage",
"sessionStorage",
"location",
"matchMedia",
"requestAnimationFrame",
)

#: Matches Node's ``ReferenceError`` message shape — ``"<name> is not
#: defined"``, optionally prefixed with ``"ReferenceError:"`` — and captures
#: the offending identifier.
_REFERENCE_ERROR_RE = re.compile(
r"(?:\bReferenceError:\s*)?\b([A-Za-z_$][A-Za-z0-9_$]*) is not defined\b"
)


def detect_browser_only_global(message: str) -> str | None:
"""Return the browser-only global named in a Node ``ReferenceError`` message.

Component render bodies also run in Node during SSR, where browser APIs do
not exist — evaluating e.g. ``window.location.pathname`` at render scope
fails with ``ReferenceError: window is not defined``. This helper
recognizes that message shape and returns the offending name when it is
one of :data:`BROWSER_ONLY_GLOBALS`. Any other message — including a
``ReferenceError`` on an unrelated identifier — returns ``None`` so those
errors flow through unchanged.
"""
for match in _REFERENCE_ERROR_RE.finditer(message):
name = match.group(1)
if name in BROWSER_ONLY_GLOBALS:
return name
return None


class BrowserGlobalRenderError(ComponentRenderError):
"""A render failed because a browser-only global was evaluated during SSR.

Raised in place of a bare :class:`ComponentRenderError` when the Node
render error is a ``ReferenceError`` on one of
:data:`BROWSER_ONLY_GLOBALS`. The message explains why the global is
missing on the server, names the page's ``.pyxl`` source file, and points
at the remedy. It is surfaced in development only (error overlay, dev
error page, server log); production HTTP responses stay generic and the
rich message reaches the server log alone.
"""

def __init__(self, *, global_name: str, source_file: str, original_message: str) -> None:
"""Build the enriched message from the failing global and source file.

``global_name`` is the browser global the component evaluated,
``source_file`` identifies the page's ``.pyxl`` source (e.g.
``pages/dashboard.pyxl``), and ``original_message`` is the Node
runtime's raw error text, preserved verbatim at the front of the
enriched message.
"""
self.global_name = global_name
self.source_file = source_file
self.original_message = original_message
super().__init__(
f"{original_message}. '{global_name}' is a browser global, and browser "
f"globals do not exist during server-side rendering: the component in "
f"{source_file} runs in Node on the server first, where there is no "
f"'{global_name}'. Move the browser-only code into a useEffect hook or "
"an event handler (neither runs during the server render), or render "
"the subtree client-only with <ClientOnly>. See the Client Components "
"guide (docs/guides/client-components.md)."
)


@dataclass(frozen=True)
class InlineStyleFragment:
"""Inline CSS artifact emitted by the SSR runtime."""
Expand Down Expand Up @@ -420,9 +497,12 @@ async def _render(


__all__ = [
"BROWSER_ONLY_GLOBALS",
"BrowserGlobalRenderError",
"ComponentRenderError",
"ComponentRenderer",
"InlineStyleFragment",
"RenderResult",
"detect_browser_only_global",
"pool_render_factory",
]
44 changes: 42 additions & 2 deletions pyxle/ssr/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@
from pyxle.devserver.routes import PageRoute
from pyxle.devserver.settings import DevServerSettings

from .renderer import ComponentRenderer, ComponentRenderError, InlineStyleFragment
from .renderer import (
BrowserGlobalRenderError,
ComponentRenderer,
ComponentRenderError,
InlineStyleFragment,
detect_browser_only_global,
)
from .template import (
_AUTH_SEED_ABSENT,
ManifestLookupError,
Expand Down Expand Up @@ -160,6 +166,39 @@ def _log_render_failure(
)


def _enrich_render_error(exc: ComponentRenderError, page: PageRoute) -> ComponentRenderError:
"""Upgrade a browser-global ``ReferenceError`` into an actionable error.

A component that evaluates a browser global (``window``, ``document``, …)
at render scope fails SSR with a bare ``"window is not defined"`` whose
traceback is entirely framework frames — it names no user file and hints
at no fix. When the render error matches that shape, return a
:class:`~pyxle.ssr.renderer.BrowserGlobalRenderError` that explains the
server/browser split, names the page's ``.pyxl`` source file, and points
at the remedy. Any other render error is returned unchanged so unrelated
failures flow through exactly as before.

The enriched message is shown in development (error overlay, dev error
page) and written to the server log; production responses are already
sanitized to a generic document, so no file path or internals reach the
HTTP body (CLAUDE.md rule 18).
"""
if isinstance(exc, BrowserGlobalRenderError):
return exc
global_name = detect_browser_only_global(str(exc))
if global_name is None:
return exc
enriched = BrowserGlobalRenderError(
global_name=global_name,
source_file=f"pages/{page.source_relative_path.as_posix()}",
original_message=str(exc),
)
# Preserve the original exception as the cause so log tracebacks keep the
# raw Node runtime failure alongside the enriched explanation.
enriched.__cause__ = exc
return enriched


def _error_response(
*,
settings: DevServerSettings,
Expand Down Expand Up @@ -385,6 +424,7 @@ async def _handle_render_exception(
elif isinstance(exc, HeadEvaluationError):
stage, status_code = "server", 500
elif isinstance(exc, ComponentRenderError):
exc = _enrich_render_error(exc, page)
stage, status_code = "renderer", 500
else: # pragma: no cover - defensive guardrail for unexpected faults
if overlay is not None:
Expand Down Expand Up @@ -693,7 +733,7 @@ async def build_page_navigation_response(
overlay=overlay,
loader_breadcrumb=loader_breadcrumb,
stage="renderer",
error=exc,
error=_enrich_render_error(exc, page),
)
except Exception as exc: # pragma: no cover - defensive guardrail
return await _navigation_error_response(
Expand Down
65 changes: 65 additions & 0 deletions tests/ssr/test_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@

import pyxle.ssr.renderer as renderer_module
from pyxle.ssr.renderer import (
BROWSER_ONLY_GLOBALS,
BrowserGlobalRenderError,
ComponentRenderer,
ComponentRenderError,
RenderResult,
_derive_project_paths,
_format_node_error,
_parse_runtime_output,
detect_browser_only_global,
)
from tests.ssr.utils import ensure_test_node_modules

Expand Down Expand Up @@ -535,3 +538,65 @@ class DummyProcess:

with pytest.raises(ComponentRenderError):
runtime.render({})


# ---------------------------------------------------------------------------
# Browser-global ReferenceError detection
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("name", BROWSER_ONLY_GLOBALS)
def test_detect_browser_only_global_matches_each_global(name: str) -> None:
"""Every browser-only global's bare ReferenceError message is detected."""
assert detect_browser_only_global(f"{name} is not defined") == name


def test_detect_browser_only_global_accepts_reference_error_prefix() -> None:
"""The full Node message shape with the ReferenceError prefix is detected."""
assert detect_browser_only_global("ReferenceError: window is not defined") == "window"


def test_detect_browser_only_global_matches_inside_larger_message() -> None:
"""A ReferenceError embedded in surrounding runtime text is still found."""
message = "Render failed: localStorage is not defined\n at Page (pages/index.jsx:3)"
assert detect_browser_only_global(message) == "localStorage"


@pytest.mark.parametrize(
"message",
[
"myHelper is not defined",
"ReferenceError: fetchData is not defined",
"SSR runtime failed to execute",
"window.matchMedia is not a function",
"thewindow is not defined",
"",
],
)
def test_detect_browser_only_global_ignores_unrelated_errors(message: str) -> None:
"""Unrelated errors — including ReferenceErrors on other names — return None."""
assert detect_browser_only_global(message) is None


def test_browser_global_render_error_message_and_attributes() -> None:
"""The enriched error names the source file, the global, and the remedy."""
error = BrowserGlobalRenderError(
global_name="window",
source_file="pages/dashboard.pyxl",
original_message="window is not defined",
)

assert isinstance(error, ComponentRenderError)
assert error.global_name == "window"
assert error.source_file == "pages/dashboard.pyxl"
assert error.original_message == "window is not defined"

message = str(error)
assert message.startswith("window is not defined")
assert "pages/dashboard.pyxl" in message
assert "browser global" in message
assert "server-side rendering" in message
assert "useEffect" in message
assert "event handler" in message
assert "<ClientOnly>" in message
assert "docs/guides/client-components.md" in message
Loading
Loading