Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@

/docs/ @Alek99 @reflex-dev/reflex-team
/README.md @Alek99 @reflex-dev/reflex-team
/tests/benchmarks/ @Alek99 @reflex-dev/reflex-team
/tests/performance/ @Alek99 @reflex-dev/reflex-team
53 changes: 53 additions & 0 deletions .github/workflows/performance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
benchmarks:
name: Run benchmarks
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
Expand All @@ -45,6 +46,58 @@ jobs:
mode: instrumentation
run: uv run pytest -v tests/benchmarks --codspeed

- name: Run event-loop and wire-size smoke
run: >-
uv run pytest tests/performance/test_event_loop.py tests/performance/test_wire_size.py
--run-performance
--performance-scale smoke
--performance-output .pytest-tmp/performance
-q

- name: Upload performance smoke results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: performance-smoke-benchmarks
path: .pytest-tmp/performance/**
if-no-files-found: ignore

browser-performance:
name: Run browser performance smoke
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false

- uses: ./.github/actions/setup_build_env
with:
python-version: "3.14"
node-version: "22"
run-uv-sync: true

- name: Install playwright
run: uv run playwright install chromium --only-shell

- name: Run browser performance smoke and bundle budget
run: >-
uv run pytest tests/performance/test_browser.py
--run-performance
--performance-scale smoke
--performance-output .pytest-tmp/performance
-q

- name: Upload performance smoke results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: performance-smoke-browser
path: .pytest-tmp/performance/**
if-no-files-found: ignore

lighthouse:
name: Run Lighthouse benchmark
runs-on: ubuntu-22.04
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ preview = true
filterwarnings = "ignore:fields may not start with an underscore:RuntimeWarning"
asyncio_default_fixture_loop_scope = "function"
asyncio_mode = "auto"
markers = [
"performance: scheduled wall-time, service, memory, lifecycle, or browser benchmark",
]

[tool.codespell]
skip = "*.html, examples, *.pyi, */nba.csv, */olympic-winners.json, uv.lock, */bun.lock, node_modules"
Expand Down
20 changes: 15 additions & 5 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
Callable,
Collection,
Coroutine,
Iterable,
Mapping,
Sequence,
)
Expand Down Expand Up @@ -1599,6 +1600,18 @@ async def health(_request: Request) -> JSONResponse:
return JSONResponse(content=health_status, status_code=status_code)


def _decode_asgi_headers(headers: Iterable[tuple[bytes, bytes]]) -> dict[str, str]:
"""Decode raw ASGI scope header pairs into a str-keyed dict.

Args:
headers: Raw (name, value) byte pairs from the ASGI scope.

Returns:
A dict mapping decoded header names to decoded values.
"""
return {k.decode("utf-8"): v.decode("utf-8") for (k, v) in headers}


class EventNamespace(AsyncNamespace):
"""The event namespace."""

Expand Down Expand Up @@ -1770,14 +1783,11 @@ async def on_event(self, sid: str, data: Any):
raise RuntimeError(msg)

# Get the client headers.
headers = {
k.decode("utf-8"): v.decode("utf-8")
for (k, v) in environ["asgi.scope"]["headers"]
}
headers = _decode_asgi_headers(environ["asgi.scope"]["headers"])

# Get the client IP
try:
client_ip = environ["asgi.scope"]["client"][0]
client_ip: str = environ["asgi.scope"]["client"][0]
headers["asgi-scope-client"] = client_ip
except (KeyError, IndexError):
client_ip = environ.get("REMOTE_ADDR", "0.0.0.0")
Expand Down
15 changes: 15 additions & 0 deletions tests/benchmarks/support/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Shared support utilities for Reflex performance benchmarks."""

from .diagnostics import capture_async_diagnostics
from .loop_probe import EventLoopProbe
from .pipeline_trace import PipelineTrace
from .report import BenchmarkResult, PerformanceReport, percentile

__all__ = [
"BenchmarkResult",
"EventLoopProbe",
"PerformanceReport",
"PipelineTrace",
"capture_async_diagnostics",
"percentile",
]
153 changes: 153 additions & 0 deletions tests/benchmarks/support/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Representative Reflex application sources for lifecycle and load tests."""

from __future__ import annotations


def lifecycle_app_source(
rows: int = 100,
pages: int = 1,
reload_version: int = 0,
) -> str:
"""Create a deterministic multi-page application source.

Args:
rows: Number of stateful rows per page.
pages: Number of routes.
reload_version: Value exposed by the backend reload probe.

Returns:
Python source for a Reflex application.
"""
page_registrations = "\n".join(
f'app.add_page(lambda route="page-{index}": index(route), route="/page-{index}")'
for index in range(pages)
)
return f"""import reflex as rx
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

RELOAD_VERSION = {reload_version}

class State(rx.State):
count: int = 0

def increment(self):
self.count += 1

def row(index: int):
return rx.hstack(rx.text(index), rx.text(State.count))

def index(label: str = "index"):
return rx.vstack(
rx.heading(label),
rx.button("increment", on_click=State.increment),
*[row(index) for index in range({rows})],
)

async def reload_version(_request):
return JSONResponse({{"version": RELOAD_VERSION}})

app = rx.App(
api_transformer=Starlette(routes=[Route("/reload-version", reload_version)]),
)
app.add_page(index, route="/")
{page_registrations}
"""


def load_app_source() -> str:
"""Create an app covering the load-test handler scenarios.

Returns:
Python source for a production-mode Reflex load app.
"""
return """import asyncio
import time
import reflex as rx

class State(rx.State):
count: int = 0
values: list[int] = []

def increment(self):
self.count += 1

async def async_io(self):
await asyncio.sleep(0.01)
self.count += 1

def blocking(self):
time.sleep(0.02)
self.count += 1

def stream(self):
for _ in range(3):
self.count += 1
yield

def append_large(self):
self.values.extend(range(1000))

def index():
return rx.vstack(
rx.text(
rx.cond(State.is_hydrated, "hydrated", "loading"),
id="hydrated",
),
rx.text(State.count, id="count"),
rx.button("increment", id="increment", on_click=State.increment),
rx.button("async", id="async-io", on_click=State.async_io),
rx.button("blocking", id="blocking", on_click=State.blocking),
rx.button("stream", id="stream", on_click=State.stream),
rx.button("large", id="append-large", on_click=State.append_large),
rx.foreach(State.values, lambda value: rx.text(value, class_name="value-row")),
rx.link("second", href="/second", id="second-link"),
)

def second():
return rx.heading("Second page", id="second-heading")

class RowState(rx.State):
rows: list[str] = []
selected: int = -1

def create_rows(self):
self.rows = [f"row {index}" for index in range(1000)]

def partial_update(self):
for index in range(0, len(self.rows), 10):
self.rows[index] += " !!!"

def select_row(self, index: int):
self.selected = index

def swap_rows(self):
self.rows[1], self.rows[998] = self.rows[998], self.rows[1]

def rows():
return rx.vstack(
rx.text(
rx.cond(RowState.is_hydrated, "hydrated", "loading"),
id="rows-hydrated",
),
rx.text(RowState.selected, id="selected-row"),
rx.button("create", id="create-rows", on_click=RowState.create_rows),
rx.button("partial", id="partial-update", on_click=RowState.partial_update),
rx.button("swap", id="swap-rows", on_click=RowState.swap_rows),
rx.foreach(
RowState.rows,
lambda row, index: rx.text(
row,
id=f"row-{index}",
class_name=rx.cond(index == RowState.selected, "row selected", "row"),
on_click=RowState.select_row(index),
),
),
)

app = rx.App()
app.add_page(index)
app.add_page(second, route="/second")
app.add_page(rows, route="/rows")
"""
Loading
Loading