From 2ec0b7aa4fd32fc53d5fd7a9b7b834c957f3d3c2 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 7 Apr 2026 18:39:04 +0500 Subject: [PATCH 01/16] lighthouse test --- .github/workflows/performance.yml | 32 +++ pyi_hashes.json | 119 ---------- scripts/run_lighthouse.py | 42 ++++ tests/integration/lighthouse_utils.py | 318 ++++++++++++++++++++++++++ tests/integration/test_lighthouse.py | 53 +++++ 5 files changed, 445 insertions(+), 119 deletions(-) create mode 100644 scripts/run_lighthouse.py create mode 100644 tests/integration/lighthouse_utils.py create mode 100644 tests/integration/test_lighthouse.py diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index e6cc29dd98a..25c6df6b1e1 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -44,3 +44,35 @@ jobs: with: mode: instrumentation run: uv run pytest -v tests/benchmarks --codspeed + + lighthouse: + name: Run Lighthouse benchmark + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + + - uses: ./.github/actions/setup_build_env + with: + python-version: "3.14" + run-uv-sync: true + + - name: Install playwright + run: uv run playwright install chromium --only-shell + + - name: Run Lighthouse benchmark + env: + REFLEX_RUN_LIGHTHOUSE: "1" + run: | + uv run pytest tests/integration/test_lighthouse.py -q -s --tb=no --basetemp=.pytest-tmp/lighthouse + + - name: Upload Lighthouse artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: lighthouse-report + path: .pytest-tmp/lighthouse + if-no-files-found: ignore diff --git a/pyi_hashes.json b/pyi_hashes.json index f7900836abd..7b2a6e0adec 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,123 +1,4 @@ { - "packages/reflex-components-code/src/reflex_components_code/code.pyi": "2797061144c4199f57848f6673a05a7f", - "packages/reflex-components-code/src/reflex_components_code/shiki_code_block.pyi": "db0de2879d57870831a030a69b5282b7", - "packages/reflex-components-core/src/reflex_components_core/__init__.pyi": "82b29d23f2490161d42fd21021bd39c3", - "packages/reflex-components-core/src/reflex_components_core/base/__init__.pyi": "7009187aaaf191814d031e5462c48318", - "packages/reflex-components-core/src/reflex_components_core/base/app_wrap.pyi": "e7dfa98f5df5e30cb6d01d61b6974bef", - "packages/reflex-components-core/src/reflex_components_core/base/body.pyi": "0f98a7c1247e35059b76ae2985b7c81b", - "packages/reflex-components-core/src/reflex_components_core/base/document.pyi": "80a3090e5b7a46de6daa8e97e68e8638", - "packages/reflex-components-core/src/reflex_components_core/base/error_boundary.pyi": "f36f27e580041af842d348adbddcd600", - "packages/reflex-components-core/src/reflex_components_core/base/fragment.pyi": "39abed241f2def793dd0c59328bb0470", - "packages/reflex-components-core/src/reflex_components_core/base/link.pyi": "05d96de8a1d5f7be08de831b99663e67", - "packages/reflex-components-core/src/reflex_components_core/base/meta.pyi": "b83e94900f988ef5d2fdf121b01be7fa", - "packages/reflex-components-core/src/reflex_components_core/base/script.pyi": "cfb0d5bcfe67f7c2b40868cdf3a5f7c1", - "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "8a69093c8d40b10b1f0b1c4e851e9d53", - "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "dd5142b3c9087bf2bf22651adf6f2724", - "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "29f5c106b98ddac94cf7c1244a02cfb1", - "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "9af2721b01868b24a48c7899ad6b1c69", - "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "20a3f4f500d44ac4365b6d831c6816ff", - "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "eb606cf8151e6769df7f2443ece739cd", - "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "5e28d554d2b4d7fae1ba35809c24f4fc", - "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "28bd59898f0402b33c34e14f3eef1282", - "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "4b34eca0e7338ec80ac5985345717bc9", - "packages/reflex-components-core/src/reflex_components_core/core/upload.pyi": "6f3cdef9956dbe5c917edeefdffd1b0e", - "packages/reflex-components-core/src/reflex_components_core/core/window_events.pyi": "28e901ee970bec806ee766d0d126d739", - "packages/reflex-components-core/src/reflex_components_core/datadisplay/__init__.pyi": "c96fed4da42a13576d64f84e3c7cb25c", - "packages/reflex-components-core/src/reflex_components_core/el/__init__.pyi": "f09129ddefb57ab4c7769c86dc9a3153", - "packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "1a8824cdd243efc876157b97f9f1b714", - "packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "e6c845f2f29eb079697a2e31b0c2f23a", - "packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "7c74980207dc1a5cac14083f2edd31ba", - "packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "da7ef00fd67699eeeb55e33279c2eb8d", - "packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "0ea0058ea7b6ae03138c7c85df963c32", - "packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "97f7f6c66533bb3947a43ceefe160d49", - "packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "7ea09671a42d75234a0464fc3601577c", - "packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "869dca86b783149f9c59e1ae0d2900c1", - "packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "c3a5a4f2d0594414a160fe59b13ccc26", - "packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "b2acdc964feabe78154be141dc978555", - "packages/reflex-components-core/src/reflex_components_core/el/elements/tables.pyi": "e75fbe0454df06abf462ab579b698897", - "packages/reflex-components-core/src/reflex_components_core/el/elements/typography.pyi": "f88089a2f4270b981a28e385d07460b5", - "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "c5ac8ba14fdce557063a832a79f43f68", - "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "e10210239ce7dc18980e70eec19b9353", - "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "2a93782c63e82a6939411273fe2486d9", - "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "f654cc9cb305712b485fcd676935c0c1", - "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "2d6efa2d5f2586a7036d606a24fb425d", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "ad4b084d94e50311f761d69b3173e357", - "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "241b80584f3e029145e6e003d1c476f2", - "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "b2f485bfde4978047b7b944cf15d92cb", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "18ed34323f671fcf655639dc78d7c549", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "9c80e740d177b4a805dee3038d580941", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "b47313aefc9a740851ee332656446afd", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "d6a4f88f2988fa50fbed8a9026f5ef8b", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/form.pyi": "00c0e0b6c8190f2db7fd847a25b5c03d", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/progress.pyi": "577ec9714a4d8bc9f7dd7eca22fe5252", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/slider.pyi": "bc69b9443d04ae7856c0a411a90755a9", - "packages/reflex-components-radix/src/reflex_components_radix/themes/__init__.pyi": "b433b9a099dc5b0ab008d02c85d38059", - "packages/reflex-components-radix/src/reflex_components_radix/themes/base.pyi": "90a182a1444b73c006e52ea67c2b3db1", - "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "3a419f78071b0dd6be55dc55e7334a1b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/__init__.pyi": "f10f0169f81c78290333da831915762f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/alert_dialog.pyi": "2b8c68239c9e9646e71ef8e81d7b5f69", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/aspect_ratio.pyi": "0f981ee0589f5501ab3c57e0aec01316", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/avatar.pyi": "d30f1bfb42198177ea08d7d358e99339", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/badge.pyi": "c3bb335b309177ff03d2cadcaf623744", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/button.pyi": "6a01812d601e8bf3dcd30dcccc75cb79", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/callout.pyi": "9b853e851805addacc2fcd995119f857", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/card.pyi": "67a71ec6ed4945a9ce270bd51d40b94e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox.pyi": "0c975a4812efc267c87119f10880e1a9", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_cards.pyi": "6425aae44ffe78f48699910906d16285", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/checkbox_group.pyi": "d0029ee04a971d8a51be0c99e414a139", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/context_menu.pyi": "1ee25c7dd27fece9881800226e322d6b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/data_list.pyi": "924addbc155a178709f5fd38af4eb547", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dialog.pyi": "e315e9779663f2f2fc9c2ca322a5645f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/dropdown_menu.pyi": "ec6cb8830971b2a04bebe7459c059b15", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/hover_card.pyi": "28384945a53620ad6075797f8ada7354", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.pyi": "6a3a37bdc9136f8c19fb3a7f55e76d64", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/inset.pyi": "05cfece835e2660bbc1b096529dfdec0", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/popover.pyi": "3033070773e8e32de283ad917367b386", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/progress.pyi": "798eadec25895a56e36d23203a4e0444", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio.pyi": "f6140dbf7ad4c25595c6983dcacc2a60", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "e16ca79a2ad4c2919f56efb54830c1ef", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "473703616ed18d983dda3600899710a5", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "12eb86d24886764bf1a5815e87ea519c", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "6319f89d046b0fce8e9efb51e50dda9f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "c6da1db236da70dc40815a404d2e29b3", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "d2dabb895d7fc63a556d3c3220e38b4d", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "55b003f62cc3e5c85c90c82f8f595bc6", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/slider.pyi": "c204f30612bfa35a62cb9f525a913f77", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/spinner.pyi": "faeddfd0e3dc0e3bbcfdeaa6e42cb755", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/switch.pyi": "70f1d8fc55398d3cbb01f157c768419e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/table.pyi": "a4c3052bc449924a630dad911f975e26", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tabs.pyi": "ec4e4ed03bd892c6f7d50ae4b490adb9", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.pyi": "06549c800759ae541cc3c3a74240af59", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_field.pyi": "dcb6a8ff4668082fc9406579098abf87", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/tooltip.pyi": "69e4ce4eeaa60ac90ef120331cb8601c", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/__init__.pyi": "dcbb1dc8e860379188924c15dd21605b", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/base.pyi": "28e6cd3869c9cbad886b69b339e3ecf6", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/box.pyi": "004cae8160c3a91ae6c12b54205f5112", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/center.pyi": "9dbe595eddc2ec731beeb3a98743be36", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/container.pyi": "1fb9d0ce37de9c64f681ad70375b9e42", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/flex.pyi": "a729044bfe2d82404de07c4570262b55", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/grid.pyi": "74b017b63728ce328e110bc64f20a205", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/list.pyi": "3a595ec7faf95645ab52bdad1bf9dc4a", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/section.pyi": "f3e44e291f3d96d06850d262de5d43a8", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/spacer.pyi": "a0a59ca93ea1e3a0e5136b9692a68d18", - "packages/reflex-components-radix/src/reflex_components_radix/themes/layout/stack.pyi": "6ab750e790f0687b735d7464fa289c1f", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/__init__.pyi": "de7ee994f66a4c1d1a6ac2ad3370c30e", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/blockquote.pyi": "3dd8bc1d7117b4e2b3b38438b4d6631a", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/code.pyi": "a71f56a8c51e9b00f953d87b16724bdb", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/heading.pyi": "47a5f03dc4c85c473026069d23b6c531", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/link.pyi": "ced137b2820a5e156cd1846ff113cfc9", - "packages/reflex-components-radix/src/reflex_components_radix/themes/typography/text.pyi": "014444973b21272cf8c572b2913dfdf5", - "packages/reflex-components-react-player/src/reflex_components_react_player/audio.pyi": "2c3c398ec0cc1476995f316cf8d0d271", - "packages/reflex-components-react-player/src/reflex_components_react_player/react_player.pyi": "9f8631e66d64f8bed90cbfd63615a97a", - "packages/reflex-components-react-player/src/reflex_components_react_player/video.pyi": "d0efeacb8b4162e9ace79f99c03e4368", - "packages/reflex-components-recharts/src/reflex_components_recharts/__init__.pyi": "7b8b69840a3637c1f1cac45ba815cccf", - "packages/reflex-components-recharts/src/reflex_components_recharts/cartesian.pyi": "9e99f951112c86ec7991bc80985a76b1", - "packages/reflex-components-recharts/src/reflex_components_recharts/charts.pyi": "5730b770af97f8c67d6d2d50e84fe14d", - "packages/reflex-components-recharts/src/reflex_components_recharts/general.pyi": "4097350ca05011733ce998898c6aefe7", - "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "db5298160144f23ae7abcaac68e845c7", - "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "75150b01510bdacf2c97fca347c86c59", - "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "dc43e142b089b1158588e999505444f6", "reflex/__init__.pyi": "5de3d4af8ea86e9755f622510b868196", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "c10cbc554fe2ffdb3a008b59bc503936" diff --git a/scripts/run_lighthouse.py b/scripts/run_lighthouse.py new file mode 100644 index 00000000000..dd5fc338c2d --- /dev/null +++ b/scripts/run_lighthouse.py @@ -0,0 +1,42 @@ +"""Run the local Lighthouse benchmark with persistent caching.""" + +from __future__ import annotations + +import contextlib +import io +from pathlib import Path + +from tests.integration.lighthouse_utils import ( + get_local_cached_app_root, + run_blank_prod_lighthouse_benchmark, +) + + +def main() -> int: + """Run the Lighthouse benchmark and print a compact summary. + + Returns: + The process exit code. + """ + app_root = get_local_cached_app_root() + report_path = Path(".states") / "lighthouse" / "blank-prod-lighthouse.json" + + stdout_buffer = io.StringIO() + stderr_buffer = io.StringIO() + with ( + contextlib.redirect_stdout(stdout_buffer), + contextlib.redirect_stderr(stderr_buffer), + ): + result = run_blank_prod_lighthouse_benchmark( + app_root=app_root, + report_path=report_path, + ) + + print(result.summary) # noqa: T201 + if result.failures: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/integration/lighthouse_utils.py b/tests/integration/lighthouse_utils.py new file mode 100644 index 00000000000..a00a0d1537a --- /dev/null +++ b/tests/integration/lighthouse_utils.py @@ -0,0 +1,318 @@ +"""Shared utilities for Lighthouse benchmarking.""" + +from __future__ import annotations + +import json +import operator +import os +import shlex +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest + +from reflex.testing import AppHarnessProd, chdir +from reflex.utils.templates import initialize_default_app + +LIGHTHOUSE_RUN_ENV_VAR = "REFLEX_RUN_LIGHTHOUSE" +LIGHTHOUSE_COMMAND_ENV_VAR = "REFLEX_LIGHTHOUSE_COMMAND" +LIGHTHOUSE_CHROME_PATH_ENV_VAR = "REFLEX_LIGHTHOUSE_CHROME_PATH" +LIGHTHOUSE_APP_ROOT_ENV_VAR = "REFLEX_LIGHTHOUSE_APP_ROOT" +LIGHTHOUSE_CLI_PACKAGE = "lighthouse@13.1.0" +TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"} +LIGHTHOUSE_CATEGORY_THRESHOLDS = { + "performance": 0.9, + "accessibility": 0.9, + "best-practices": 0.9, + "seo": 0.9, +} +LIGHTHOUSE_CATEGORIES = tuple(LIGHTHOUSE_CATEGORY_THRESHOLDS) +LIGHTHOUSE_APP_NAME = "lighthouse_blank" + + +@dataclass(frozen=True) +class LighthouseBenchmarkResult: + """A structured Lighthouse benchmark result.""" + + report: dict[str, Any] + report_path: Path + summary: str + failures: list[str] + + +def should_run_lighthouse() -> bool: + """Check whether Lighthouse benchmarks are enabled. + + Returns: + Whether Lighthouse benchmarks are enabled. + """ + return os.environ.get(LIGHTHOUSE_RUN_ENV_VAR, "").lower() in TRUTHY_ENV_VALUES + + +def format_score(score: float | None) -> str: + """Format a Lighthouse score for display. + + Args: + score: The Lighthouse score in the 0-1 range. + + Returns: + The score formatted as a 0-100 string. + """ + if score is None: + return "n/a" + return str(round(score * 100)) + + +def format_lighthouse_summary(report: dict[str, Any], report_path: Path) -> str: + """Format a compact Lighthouse score summary. + + Args: + report: The parsed Lighthouse JSON report. + report_path: The saved report path. + + Returns: + A human-readable multi-line summary of Lighthouse scores. + """ + lines = [ + "Lighthouse summary for blank prod app", + "", + f"{'Category':<16} {'Score':>5} {'Target':>6} {'Status':>6}", + f"{'-' * 16} {'-' * 5} {'-' * 6} {'-' * 6}", + ] + failure_details = [] + + for category_name, threshold in LIGHTHOUSE_CATEGORY_THRESHOLDS.items(): + score = report["categories"][category_name]["score"] + passed = score is not None and score >= threshold + lines.append( + f"{category_name:<16} {format_score(score):>5} {round(threshold * 100):>6} {'PASS' if passed else 'FAIL':>6}" + ) + if not passed: + failure_details.append( + f"- {category_name}: {get_category_failure_details(report, category_name)}" + ) + + lines.extend([ + "", + f"Report: {report_path}", + ]) + if failure_details: + lines.extend([ + "", + "Lowest-scoring audits:", + *failure_details, + ]) + + return "\n".join(lines) + + +def get_lighthouse_command() -> list[str]: + """Resolve the Lighthouse CLI command. + + Returns: + The command prefix used to invoke Lighthouse. + """ + if command := os.environ.get(LIGHTHOUSE_COMMAND_ENV_VAR): + return shlex.split(command) + if shutil.which("lighthouse") is not None: + return ["lighthouse"] + if shutil.which("npx") is not None: + return ["npx", "--yes", LIGHTHOUSE_CLI_PACKAGE] + pytest.skip( + "Lighthouse CLI is unavailable. " + f"Install `lighthouse`, make `npx` available, or set {LIGHTHOUSE_COMMAND_ENV_VAR}." + ) + + +def get_chrome_path() -> str: + """Resolve the Chromium executable used by Lighthouse. + + Returns: + The path to the Chromium executable Lighthouse should launch. + """ + if chrome_path := os.environ.get(LIGHTHOUSE_CHROME_PATH_ENV_VAR): + resolved_path = Path(chrome_path).expanduser() + if not resolved_path.exists(): + pytest.skip( + f"{LIGHTHOUSE_CHROME_PATH_ENV_VAR} points to a missing binary: {resolved_path}" + ) + return str(resolved_path) + + sync_api = pytest.importorskip( + "playwright.sync_api", + reason="Playwright is required to locate a Chromium binary for Lighthouse.", + ) + candidates: list[Path] = [] + with sync_api.sync_playwright() as playwright: + candidates.append(Path(playwright.chromium.executable_path)) + + browser_cache_dirs = [ + Path.home() / ".cache" / "ms-playwright", + Path.home() / "Library" / "Caches" / "ms-playwright", + ] + if local_app_data := os.environ.get("LOCALAPPDATA"): + browser_cache_dirs.append(Path(local_app_data) / "ms-playwright") + + browser_glob_patterns = [ + "chromium_headless_shell-*/*/chrome-headless-shell", + "chromium-*/*/chrome", + "chromium-*/*/chrome.exe", + "chromium-*/*/Chromium.app/Contents/MacOS/Chromium", + ] + for cache_dir in browser_cache_dirs: + if not cache_dir.exists(): + continue + for pattern in browser_glob_patterns: + candidates.extend(sorted(cache_dir.glob(pattern), reverse=True)) + + for resolved_path in candidates: + if resolved_path.exists(): + return str(resolved_path) + + pytest.skip( + "Playwright Chromium is not installed. " + "Run `uv run playwright install chromium --only-shell` first." + ) + + +def get_category_failure_details(report: dict[str, Any], category_name: str) -> str: + """Summarize the lowest-scoring weighted audits in a Lighthouse category. + + Args: + report: The parsed Lighthouse JSON report. + category_name: The category to summarize. + + Returns: + A short summary of the lowest-scoring weighted audits. + """ + category = report["categories"][category_name] + audits = report["audits"] + failing_audits: list[tuple[float, str]] = [] + + for audit_ref in category["auditRefs"]: + if audit_ref["weight"] <= 0: + continue + audit = audits[audit_ref["id"]] + score = audit.get("score") + if score is None or score >= 1: + continue + failing_audits.append((score, audit["title"])) + + if not failing_audits: + return "no weighted audit details" + + failing_audits.sort(key=operator.itemgetter(0)) + return ", ".join( + f"{title} ({format_score(score)})" for score, title in failing_audits[:3] + ) + + +def run_lighthouse(url: str, report_path: Path) -> dict[str, Any]: + """Run Lighthouse against a URL and return the parsed JSON report. + + Args: + url: The URL to audit. + report_path: Where to save the JSON report. + + Returns: + The parsed Lighthouse JSON report. + """ + command = [ + *get_lighthouse_command(), + url, + "--output=json", + f"--output-path={report_path}", + f"--chrome-path={get_chrome_path()}", + f"--only-categories={','.join(LIGHTHOUSE_CATEGORIES)}", + "--quiet", + "--chrome-flags=--headless=new --no-sandbox --disable-dev-shm-usage", + ] + + try: + subprocess.run( + command, + check=True, + capture_output=True, + text=True, + timeout=180, + ) + except subprocess.CalledProcessError as err: + pytest.fail( + "Lighthouse execution failed.\n" + f"Command: {' '.join(command)}\n" + f"stdout:\n{err.stdout}\n" + f"stderr:\n{err.stderr}" + ) + return json.loads(report_path.read_text()) + + +def get_local_cached_app_root() -> Path: + """Get the local cached app root used by the CLI runner. + + Returns: + The local cached app root path. + """ + return Path(".states") / LIGHTHOUSE_APP_NAME + + +def get_env_or_default_app_root(default_root: Path) -> Path: + """Resolve the app root, optionally overridden by an environment variable. + + Args: + default_root: The fallback app root. + + Returns: + The resolved app root path. + """ + if app_root := os.environ.get(LIGHTHOUSE_APP_ROOT_ENV_VAR): + return Path(app_root).expanduser() + return default_root + + +def ensure_blank_lighthouse_app(root: Path) -> None: + """Ensure the cached blank benchmark app exists. + + Args: + root: The app root directory. + """ + root.mkdir(parents=True, exist_ok=True) + with chdir(root): + if not Path("rxconfig.py").exists(): + initialize_default_app(LIGHTHOUSE_APP_NAME) + + +def run_blank_prod_lighthouse_benchmark( + app_root: Path, + report_path: Path, +) -> LighthouseBenchmarkResult: + """Run Lighthouse against the stock blank Reflex app in prod mode. + + Args: + app_root: The app root to initialize or reuse. + report_path: Where to save the Lighthouse JSON report. + + Returns: + A structured benchmark result. + """ + ensure_blank_lighthouse_app(app_root) + report_path.parent.mkdir(parents=True, exist_ok=True) + + with AppHarnessProd.create(root=app_root, app_name=LIGHTHOUSE_APP_NAME) as harness: + assert harness.frontend_url is not None + report = run_lighthouse(harness.frontend_url, report_path) + + failures = [] + for category_name, threshold in LIGHTHOUSE_CATEGORY_THRESHOLDS.items(): + score = report["categories"][category_name]["score"] + if score is None or score < threshold: + failures.append(category_name) + + return LighthouseBenchmarkResult( + report=report, + report_path=report_path, + summary=format_lighthouse_summary(report, report_path), + failures=failures, + ) diff --git a/tests/integration/test_lighthouse.py b/tests/integration/test_lighthouse.py new file mode 100644 index 00000000000..b82db306f55 --- /dev/null +++ b/tests/integration/test_lighthouse.py @@ -0,0 +1,53 @@ +"""Lighthouse benchmark tests for production Reflex apps.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from .lighthouse_utils import ( + get_env_or_default_app_root, + run_blank_prod_lighthouse_benchmark, + should_run_lighthouse, +) + +pytestmark = pytest.mark.skipif( + not should_run_lighthouse(), + reason="Set REFLEX_RUN_LIGHTHOUSE=1 to run Lighthouse benchmark tests.", +) + + +@pytest.fixture(scope="module") +def lighthouse_app_root( + tmp_path_factory: pytest.TempPathFactory, +) -> Path: + """Get the app root for the Lighthouse benchmark. + + Args: + tmp_path_factory: Pytest helper for allocating temporary directories. + + Returns: + The app root path for the benchmark app. + """ + return get_env_or_default_app_root( + tmp_path_factory.mktemp("lighthouse_blank_app"), + ) + + +def test_blank_template_lighthouse_scores( + lighthouse_app_root: Path, + tmp_path: Path, +): + """Assert that the stock prod app stays in the 90s across Lighthouse categories.""" + result = run_blank_prod_lighthouse_benchmark( + app_root=lighthouse_app_root, + report_path=tmp_path / "blank-prod-lighthouse.json", + ) + print(result.summary) + + if result.failures: + pytest.fail( + "Lighthouse thresholds not met. See score summary above.", + pytrace=False, + ) From 8ca31a5b499cfc02ff8053306116ccd1824d8e5b Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 7 Apr 2026 20:13:41 +0500 Subject: [PATCH 02/16] Improve Lighthouse scores and add landing page benchmark - Wrap blank template in rx.el.main landmark with page title/description - Add aria_label/title to ColorModeIconButton and StickyBadge - Add landing page prod benchmark alongside blank template test - Document page structure and metadata best practices --- .github/workflows/performance.yml | 1 + .../.templates/apps/blank/code/blank.py | 45 +- .../src/reflex_components_core/core/sticky.py | 2 + .../themes/color_mode.py | 2 + pyi_hashes.json | 2 + .../docs/getting_started/project-structure.md | 2 + reflex/docs/pages/overview.md | 22 + scripts/run_lighthouse.py | 65 +- tests/integration/lighthouse_utils.py | 667 +++++++++++++++++- tests/integration/test_lighthouse.py | 39 +- tests/units/components/core/test_sticky.py | 8 + .../units/components/radix/test_color_mode.py | 18 + 12 files changed, 800 insertions(+), 73 deletions(-) create mode 100644 tests/units/components/core/test_sticky.py create mode 100644 tests/units/components/radix/test_color_mode.py diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 25c6df6b1e1..fdfb93f6c7f 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -67,6 +67,7 @@ jobs: env: REFLEX_RUN_LIGHTHOUSE: "1" run: | + mkdir -p .pytest-tmp/lighthouse uv run pytest tests/integration/test_lighthouse.py -q -s --tb=no --basetemp=.pytest-tmp/lighthouse - name: Upload Lighthouse artifacts diff --git a/packages/reflex-base/src/reflex_base/.templates/apps/blank/code/blank.py b/packages/reflex-base/src/reflex_base/.templates/apps/blank/code/blank.py index 4d7059db546..a948369c43f 100644 --- a/packages/reflex-base/src/reflex_base/.templates/apps/blank/code/blank.py +++ b/packages/reflex-base/src/reflex_base/.templates/apps/blank/code/blank.py @@ -11,26 +11,37 @@ class State(rx.State): def index() -> rx.Component: # Welcome Page (Index) - return rx.container( - rx.color_mode.button(position="top-right"), - rx.vstack( - rx.heading("Welcome to Reflex!", size="9"), - rx.text( - "Get started by editing ", - rx.code(f"{config.app_name}/{config.app_name}.py"), - size="5", + return rx.el.main( + rx.container( + rx.color_mode.button(position="top-right"), + rx.vstack( + rx.heading("Welcome to Reflex!", size="9"), + rx.text( + "Get started by editing ", + rx.code(f"{config.app_name}/{config.app_name}.py"), + size="5", + ), + rx.button( + rx.link( + "Check out our docs!", + href="https://reflex.dev/docs/getting-started/introduction/", + is_external=True, + underline="none", + ), + as_child=True, + high_contrast=True, + ), + spacing="5", + justify="center", + min_height="85vh", ), - rx.link( - rx.button("Check out our docs!"), - href="https://reflex.dev/docs/getting-started/introduction/", - is_external=True, - ), - spacing="5", - justify="center", - min_height="85vh", ), ) app = rx.App() -app.add_page(index) +app.add_page( + index, + title="Welcome to Reflex", + description="A starter Reflex app.", +) diff --git a/packages/reflex-components-core/src/reflex_components_core/core/sticky.py b/packages/reflex-components-core/src/reflex_components_core/core/sticky.py index 3881d77e0dd..c21b35874c0 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/sticky.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/sticky.py @@ -84,6 +84,8 @@ def create(cls): desktop_only(StickyLabel.create()), href="https://reflex.dev", target="_blank", + aria_label="Built with Reflex", + title="Built with Reflex", width="auto", padding="0.375rem", align="center", diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.py index c0d664796ea..575189f8f10 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.py @@ -147,6 +147,8 @@ def create( props.setdefault("background", "transparent") props.setdefault("color", "inherit") props.setdefault("z_index", "20") + props.setdefault("aria_label", "Toggle color mode") + props.setdefault("title", "Toggle color mode") props.setdefault(":hover", {"cursor": "pointer"}) if allow_system: diff --git a/pyi_hashes.json b/pyi_hashes.json index 7b2a6e0adec..f993d2dd7c4 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,4 +1,6 @@ { + "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "4b34eca0e7338ec80ac5985345717bc9", + "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "3a419f78071b0dd6be55dc55e7334a1b", "reflex/__init__.pyi": "5de3d4af8ea86e9755f622510b868196", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "c10cbc554fe2ffdb3a008b59bc503936" diff --git a/reflex/docs/getting_started/project-structure.md b/reflex/docs/getting_started/project-structure.md index b4e38f8a663..5fa1082b7aa 100644 --- a/reflex/docs/getting_started/project-structure.md +++ b/reflex/docs/getting_started/project-structure.md @@ -64,6 +64,8 @@ Initializing your project creates a directory with the same name as your app. Th Reflex generates a default app within the `{app_name}/{app_name}.py` file. You can modify this file to customize your app. +The starter page also includes explicit page metadata. As you customize the app, update the page `title` and `description` in `app.add_page(...)` or `@rx.page(...)` so your production pages describe your project clearly. + ## Python Project Files `pyproject.toml` defines your Python project metadata and dependencies. `uv add reflex` records the Reflex dependency there before you initialize the app. diff --git a/reflex/docs/pages/overview.md b/reflex/docs/pages/overview.md index 439151a5f24..2f852c51060 100644 --- a/reflex/docs/pages/overview.md +++ b/reflex/docs/pages/overview.md @@ -45,6 +45,26 @@ In this example we create three pages: # Video: Pages and URL Routes ``` +## Page Structure and Accessibility + +For better accessibility and Lighthouse scores, wrap your page content in an `rx.el.main` element. This provides the `
` HTML landmark that screen readers and search engines use to identify the primary content of the page. + +```python +def index(): + return rx.el.main( + navbar(), + rx.container( + rx.heading("Welcome"), + rx.text("Page content here."), + ), + footer(), + ) +``` + +```md alert +# Every page should have exactly one `
` landmark. Without it, accessibility tools like Lighthouse will flag the "Document does not have a main landmark" audit. +``` + ## Page Decorator You can also use the `@rx.page` decorator to add a page. @@ -207,6 +227,8 @@ You can add page metadata such as: {meta_data} ``` +For production apps, set `title` and `description` explicitly on each public page with `@rx.page(...)` or `app.add_page(...)`. Reflex will use what you provide there, so it is best to treat page metadata as part of the page definition rather than something to fill in later. + ## Getting the Current Page You can access the current page from the `router` attribute in any state. See the [router docs](/docs/utility_methods/router_attributes) for all available attributes. diff --git a/scripts/run_lighthouse.py b/scripts/run_lighthouse.py index dd5fc338c2d..84ffdf658eb 100644 --- a/scripts/run_lighthouse.py +++ b/scripts/run_lighthouse.py @@ -1,41 +1,72 @@ -"""Run the local Lighthouse benchmark with persistent caching.""" +"""Run the local Lighthouse benchmark with a fresh app build.""" from __future__ import annotations import contextlib import io +import shutil +from collections.abc import Callable from pathlib import Path from tests.integration.lighthouse_utils import ( - get_local_cached_app_root, + LIGHTHOUSE_APP_NAME, + LIGHTHOUSE_LANDING_APP_NAME, + LighthouseBenchmarkResult, run_blank_prod_lighthouse_benchmark, + run_landing_prod_lighthouse_benchmark, ) -def main() -> int: - """Run the Lighthouse benchmark and print a compact summary. +def _run_benchmark( + run_fn: Callable[..., LighthouseBenchmarkResult], + app_root: Path, + report_path: Path, +) -> LighthouseBenchmarkResult: + """Run a single benchmark, suppressing internal output. Returns: - The process exit code. + The benchmark result. """ - app_root = get_local_cached_app_root() - report_path = Path(".states") / "lighthouse" / "blank-prod-lighthouse.json" - + shutil.rmtree(app_root, ignore_errors=True) stdout_buffer = io.StringIO() stderr_buffer = io.StringIO() with ( contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer), ): - result = run_blank_prod_lighthouse_benchmark( - app_root=app_root, - report_path=report_path, - ) - - print(result.summary) # noqa: T201 - if result.failures: - return 1 - return 0 + return run_fn(app_root=app_root, report_path=report_path) + + +def main() -> int: + """Run the Lighthouse benchmarks and print compact summaries. + + Returns: + The process exit code. + """ + report_dir = Path(".states") / "lighthouse" + all_failures = [] + + benchmarks = [ + ( + LIGHTHOUSE_APP_NAME, + run_blank_prod_lighthouse_benchmark, + report_dir / "blank-prod-lighthouse.json", + ), + ( + LIGHTHOUSE_LANDING_APP_NAME, + run_landing_prod_lighthouse_benchmark, + report_dir / "landing-prod-lighthouse.json", + ), + ] + + for name, run_fn, report_path in benchmarks: + app_root = Path(".states") / name + result = _run_benchmark(run_fn, app_root, report_path) + print(result.summary) # noqa: T201 + print() # noqa: T201 + all_failures.extend(result.failures) + + return 1 if all_failures else 0 if __name__ == "__main__": diff --git a/tests/integration/lighthouse_utils.py b/tests/integration/lighthouse_utils.py index a00a0d1537a..4392d882092 100644 --- a/tests/integration/lighthouse_utils.py +++ b/tests/integration/lighthouse_utils.py @@ -20,7 +20,6 @@ LIGHTHOUSE_RUN_ENV_VAR = "REFLEX_RUN_LIGHTHOUSE" LIGHTHOUSE_COMMAND_ENV_VAR = "REFLEX_LIGHTHOUSE_COMMAND" LIGHTHOUSE_CHROME_PATH_ENV_VAR = "REFLEX_LIGHTHOUSE_CHROME_PATH" -LIGHTHOUSE_APP_ROOT_ENV_VAR = "REFLEX_LIGHTHOUSE_APP_ROOT" LIGHTHOUSE_CLI_PACKAGE = "lighthouse@13.1.0" TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"} LIGHTHOUSE_CATEGORY_THRESHOLDS = { @@ -31,6 +30,572 @@ } LIGHTHOUSE_CATEGORIES = tuple(LIGHTHOUSE_CATEGORY_THRESHOLDS) LIGHTHOUSE_APP_NAME = "lighthouse_blank" +LIGHTHOUSE_LANDING_APP_NAME = "lighthouse_landing" + +LANDING_PAGE_SOURCE = '''\ +"""A single-page landing page for Lighthouse benchmarking.""" + +import reflex as rx + + +class State(rx.State): + """The app state.""" + + +def navbar() -> rx.Component: + return rx.el.nav( + rx.container( + rx.hstack( + rx.hstack( + rx.icon("hexagon", size=28, color="var(--accent-9)"), + rx.heading("Acme", size="5", weight="bold"), + align="center", + spacing="2", + ), + rx.hstack( + rx.link("Features", href="#features", underline="none", size="3"), + rx.link("How It Works", href="#how-it-works", underline="none", size="3"), + rx.link("Pricing", href="#pricing", underline="none", size="3"), + rx.link("Testimonials", href="#testimonials", underline="none", size="3"), + spacing="5", + display={"base": "none", "md": "flex"}, + ), + rx.button("Sign Up", size="2", high_contrast=True, radius="full"), + justify="between", + align="center", + width="100%", + ), + size="4", + ), + style={ + "position": "sticky", + "top": "0", + "z_index": "50", + "backdrop_filter": "blur(12px)", + "border_bottom": "1px solid var(--gray-a4)", + "padding_top": "12px", + "padding_bottom": "12px", + }, + ) + + +def hero() -> rx.Component: + return rx.section( + rx.container( + rx.vstack( + rx.badge("Now in Public Beta", variant="surface", size="2", radius="full"), + rx.heading( + "Ship products 10x faster ", + rx.text.span("with pure Python", color="var(--accent-9)"), + size="9", + weight="bold", + align="center", + line_height="1.1", + ), + rx.text( + "Stop wrestling with JavaScript. Build beautiful, performant " + "full-stack web apps using nothing but Python. " + "From prototype to production in record time.", + size="5", + align="center", + color="var(--gray-11)", + max_width="640px", + ), + rx.hstack( + rx.button( + rx.icon("arrow-right", size=16), + "Get Started Free", + size="4", + high_contrast=True, + radius="full", + ), + rx.button( + rx.icon("play", size=16), + "Watch Demo", + size="4", + variant="outline", + radius="full", + ), + spacing="3", + ), + rx.hstack( + rx.hstack( + rx.avatar(fallback="A", size="2", radius="full"), + rx.avatar(fallback="B", size="2", radius="full", style={"margin_left": "-8px"}), + rx.avatar(fallback="C", size="2", radius="full", style={"margin_left": "-8px"}), + rx.avatar(fallback="D", size="2", radius="full", style={"margin_left": "-8px"}), + spacing="0", + ), + rx.text( + "Trusted by 50,000+ developers worldwide", + size="2", + color="var(--gray-11)", + ), + align="center", + spacing="3", + pt="2", + ), + spacing="5", + align="center", + py="9", + ), + size="4", + ), + ) + + +def stat_card(value: str, label: str) -> rx.Component: + return rx.vstack( + rx.heading(value, size="8", weight="bold", color="var(--accent-9)"), + rx.text(label, size="3", color="var(--gray-11)"), + align="center", + spacing="1", + ) + + +def stats_bar() -> rx.Component: + return rx.section( + rx.container( + rx.grid( + stat_card("50K+", "Developers"), + stat_card("10M+", "Apps Built"), + stat_card("99.9%", "Uptime"), + stat_card("150+", "Components"), + columns="4", + spacing="6", + width="100%", + ), + size="4", + ), + style={ + "background": "var(--accent-2)", + "border_top": "1px solid var(--gray-a4)", + "border_bottom": "1px solid var(--gray-a4)", + }, + ) + + +def feature_card(icon_name: str, title: str, description: str) -> rx.Component: + return rx.card( + rx.vstack( + rx.flex( + rx.icon(icon_name, size=24, color="var(--accent-9)"), + align="center", + justify="center", + style={ + "width": "48px", + "height": "48px", + "border_radius": "12px", + "background": "var(--accent-3)", + }, + ), + rx.heading(title, size="4", weight="bold"), + rx.text(description, size="3", color="var(--gray-11)", line_height="1.6"), + spacing="3", + ), + size="3", + ) + + +def features() -> rx.Component: + return rx.section( + rx.container( + rx.vstack( + rx.badge("Features", variant="surface", size="2", radius="full"), + rx.heading("Everything you need to build", size="8", weight="bold", align="center"), + rx.text( + "A complete toolkit for modern web development, " + "designed for developers who value productivity.", + size="4", + color="var(--gray-11)", + align="center", + max_width="540px", + ), + rx.grid( + feature_card( + "code", + "Pure Python", + "Write your frontend and backend in Python. " + "No JavaScript, no HTML templates, no CSS files to manage.", + ), + feature_card( + "zap", + "Lightning Fast Refresh", + "See your changes reflected instantly. Hot reload keeps " + "your development loop tight and productive.", + ), + feature_card( + "layers", + "60+ Built-in Components", + "From data tables to charts, forms to navigation. " + "Production-ready components out of the box.", + ), + feature_card( + "shield-check", + "Type Safe", + "Full type safety across your entire stack. " + "Catch bugs at development time, not in production.", + ), + feature_card( + "database", + "Built-in State Management", + "Reactive state that syncs between frontend and backend " + "automatically. No boilerplate, no Redux.", + ), + feature_card( + "rocket", + "One-Command Deploy", + "Deploy to production with a single command. " + "Built-in hosting or bring your own infrastructure.", + ), + columns={"base": "1", "sm": "2", "lg": "3"}, + spacing="5", + width="100%", + ), + spacing="5", + align="center", + py="6", + ), + size="4", + ), + id="features", + ) + + +def step_card(number: str, title: str, description: str) -> rx.Component: + return rx.vstack( + rx.flex( + rx.text(number, size="5", weight="bold", color="white"), + align="center", + justify="center", + style={ + "width": "48px", + "height": "48px", + "border_radius": "50%", + "background": "var(--accent-9)", + "flex_shrink": "0", + }, + ), + rx.heading(title, size="5", weight="bold"), + rx.text(description, size="3", color="var(--gray-11)", line_height="1.6"), + spacing="3", + align="center", + flex="1", + ) + + +def how_it_works() -> rx.Component: + return rx.section( + rx.container( + rx.vstack( + rx.badge("How It Works", variant="surface", size="2", radius="full"), + rx.heading("Up and running in minutes", size="8", weight="bold", align="center"), + rx.text( + "Three simple steps to go from idea to deployed application.", + size="4", + color="var(--gray-11)", + align="center", + ), + rx.grid( + step_card( + "1", + "Install & Initialize", + "Install the framework with pip and scaffold a new project " + "with a single command. Choose from starter templates.", + ), + step_card( + "2", + "Build Your App", + "Write components in pure Python. Use reactive state to " + "handle user interactions. Style with built-in themes.", + ), + step_card( + "3", + "Deploy", + "Push to production with one command. Automatic SSL, " + "CDN, and scaling handled for you.", + ), + columns={"base": "1", "md": "3"}, + spacing="6", + width="100%", + ), + spacing="5", + align="center", + py="6", + ), + size="4", + ), + id="how-it-works", + style={"background": "var(--accent-2)"}, + ) + + +def pricing_card( + name: str, price: str, period: str, description: str, + features: list, highlighted: bool = False, +) -> rx.Component: + return rx.card( + rx.vstack( + rx.heading(name, size="5", weight="bold"), + rx.hstack( + rx.heading(price, size="8", weight="bold"), + rx.text(period, size="3", color="var(--gray-11)", style={"align_self": "flex-end", "padding_bottom": "4px"}), + align="end", + spacing="1", + ), + rx.text(description, size="2", color="var(--gray-11)"), + rx.separator(size="4"), + rx.vstack( + *[ + rx.hstack( + rx.icon("check", size=16, color="var(--accent-9)"), + rx.text(f, size="2"), + spacing="2", + align="center", + ) + for f in features + ], + spacing="2", + width="100%", + ), + rx.button( + "Get Started", + size="3", + width="100%", + radius="full", + variant="solid" if highlighted else "outline", + high_contrast=highlighted, + ), + spacing="4", + p="2", + ), + size="3", + style={"border": "2px solid var(--accent-9)"} if highlighted else {}, + ) + + +def pricing() -> rx.Component: + return rx.section( + rx.container( + rx.vstack( + rx.badge("Pricing", variant="surface", size="2", radius="full"), + rx.heading("Simple, transparent pricing", size="8", weight="bold", align="center"), + rx.text( + "No hidden fees. Start free and scale as you grow.", + size="4", + color="var(--gray-11)", + align="center", + ), + rx.grid( + pricing_card( + "Hobby", + "$0", + "/month", + "Perfect for side projects and learning.", + ["1 project", "Community support", "Basic analytics", "Custom domain"], + ), + pricing_card( + "Pro", + "$29", + "/month", + "For professionals shipping real products.", + ["Unlimited projects", "Priority support", "Advanced analytics", "Team collaboration", "Custom branding"], + highlighted=True, + ), + pricing_card( + "Enterprise", + "$99", + "/month", + "For teams that need full control.", + ["Everything in Pro", "SSO & SAML", "Dedicated infrastructure", "SLA guarantee", "24/7 phone support"], + ), + columns={"base": "1", "md": "3"}, + spacing="5", + width="100%", + ), + spacing="5", + align="center", + py="6", + ), + size="4", + ), + id="pricing", + ) + + +def testimonial_card(quote: str, name: str, role: str, initials: str) -> rx.Component: + return rx.card( + rx.vstack( + rx.hstack( + *[rx.icon("star", size=14, color="var(--amber-9)") for _ in range(5)], + spacing="1", + ), + rx.text( + f"\\"{quote}\\"", + size="3", + style={"font_style": "italic"}, + color="var(--gray-12)", + line_height="1.6", + ), + rx.hstack( + rx.avatar(fallback=initials, size="3", radius="full"), + rx.vstack( + rx.text(name, size="2", weight="bold"), + rx.text(role, size="1", color="var(--gray-11)"), + spacing="0", + ), + align="center", + spacing="3", + ), + spacing="4", + ), + size="3", + ) + + +def testimonials() -> rx.Component: + return rx.section( + rx.container( + rx.vstack( + rx.badge("Testimonials", variant="surface", size="2", radius="full"), + rx.heading("Loved by developers", size="8", weight="bold", align="center"), + rx.text( + "See what developers around the world are saying.", + size="4", + color="var(--gray-11)", + align="center", + ), + rx.grid( + testimonial_card( + "This cut our development time in half. We shipped our MVP in two weeks instead of two months.", + "Sarah Chen", + "CTO at LaunchPad", + "SC", + ), + testimonial_card( + "Finally, a framework that lets me build full-stack apps without leaving Python. Game changer.", + "Marcus Johnson", + "Senior Engineer at DataFlow", + "MJ", + ), + testimonial_card( + "The component library is incredible. I spent zero time building UI primitives and all my time on business logic.", + "Priya Patel", + "Founder of MetricsDash", + "PP", + ), + columns={"base": "1", "md": "3"}, + spacing="5", + width="100%", + ), + spacing="5", + align="center", + py="6", + ), + size="4", + ), + id="testimonials", + style={"background": "var(--accent-2)"}, + ) + + +def cta() -> rx.Component: + return rx.section( + rx.container( + rx.card( + rx.vstack( + rx.heading("Ready to build something amazing?", size="7", weight="bold", align="center"), + rx.text( + "Join thousands of developers shipping faster with pure Python. " + "Get started in under 60 seconds.", + size="4", + color="var(--gray-11)", + align="center", + max_width="480px", + ), + rx.hstack( + rx.button( + rx.icon("arrow-right", size=16), + "Start Building", + size="4", + high_contrast=True, + radius="full", + ), + rx.button( + "Talk to Sales", + size="4", + variant="outline", + radius="full", + ), + spacing="3", + ), + spacing="5", + align="center", + py="6", + ), + size="5", + ), + size="4", + ), + ) + + +def footer() -> rx.Component: + return rx.el.footer( + rx.container( + rx.vstack( + rx.separator(size="4"), + rx.hstack( + rx.hstack( + rx.icon("hexagon", size=20, color="var(--accent-9)"), + rx.text("Acme", size="3", weight="bold"), + align="center", + spacing="2", + ), + rx.hstack( + rx.link("Privacy", href="#", underline="none", size="2", color="var(--gray-11)"), + rx.link("Terms", href="#", underline="none", size="2", color="var(--gray-11)"), + rx.link("Contact", href="#", underline="none", size="2", color="var(--gray-11)"), + spacing="4", + ), + justify="between", + align="center", + width="100%", + ), + rx.text( + "\\u00a9 2026 Acme Inc. All rights reserved.", + size="1", + color="var(--gray-11)", + ), + spacing="4", + py="6", + ), + size="4", + ), + ) + + +def index() -> rx.Component: + return rx.el.main( + navbar(), + hero(), + stats_bar(), + features(), + how_it_works(), + pricing(), + testimonials(), + cta(), + footer(), + ) + + +app = rx.App() +app.add_page( + index, + title="Acme - Ship Products 10x Faster", + description="Build beautiful full-stack web apps with pure Python. No JavaScript required.", +) +''' @dataclass(frozen=True) @@ -66,18 +631,21 @@ def format_score(score: float | None) -> str: return str(round(score * 100)) -def format_lighthouse_summary(report: dict[str, Any], report_path: Path) -> str: +def format_lighthouse_summary( + report: dict[str, Any], report_path: Path, label: str = "blank prod app" +) -> str: """Format a compact Lighthouse score summary. Args: report: The parsed Lighthouse JSON report. report_path: The saved report path. + label: A short label describing the app under test. Returns: A human-readable multi-line summary of Lighthouse scores. """ lines = [ - "Lighthouse summary for blank prod app", + f"Lighthouse summary for {label}", "", f"{'Category':<16} {'Score':>5} {'Target':>6} {'Status':>6}", f"{'-' * 16} {'-' * 5} {'-' * 6} {'-' * 6}", @@ -249,58 +817,43 @@ def run_lighthouse(url: str, report_path: Path) -> dict[str, Any]: return json.loads(report_path.read_text()) -def get_local_cached_app_root() -> Path: - """Get the local cached app root used by the CLI runner. - - Returns: - The local cached app root path. - """ - return Path(".states") / LIGHTHOUSE_APP_NAME - - -def get_env_or_default_app_root(default_root: Path) -> Path: - """Resolve the app root, optionally overridden by an environment variable. - - Args: - default_root: The fallback app root. - - Returns: - The resolved app root path. - """ - if app_root := os.environ.get(LIGHTHOUSE_APP_ROOT_ENV_VAR): - return Path(app_root).expanduser() - return default_root - - -def ensure_blank_lighthouse_app(root: Path) -> None: - """Ensure the cached blank benchmark app exists. +def _ensure_lighthouse_app( + root: Path, app_name: str, page_source: str | None = None +) -> None: + """Initialize a Lighthouse benchmark app. Args: root: The app root directory. + app_name: The app name for initialization. + page_source: Optional custom page source to overwrite the generated page. """ root.mkdir(parents=True, exist_ok=True) with chdir(root): - if not Path("rxconfig.py").exists(): - initialize_default_app(LIGHTHOUSE_APP_NAME) + initialize_default_app(app_name) + if page_source is not None: + (Path(app_name) / f"{app_name}.py").write_text(page_source) -def run_blank_prod_lighthouse_benchmark( +def _run_prod_lighthouse_benchmark( app_root: Path, + app_name: str, report_path: Path, + label: str, ) -> LighthouseBenchmarkResult: - """Run Lighthouse against the stock blank Reflex app in prod mode. + """Run Lighthouse against a Reflex app in prod mode. Args: app_root: The app root to initialize or reuse. + app_name: The app name matching the directory layout. report_path: Where to save the Lighthouse JSON report. + label: A short label for the summary output. Returns: A structured benchmark result. """ - ensure_blank_lighthouse_app(app_root) report_path.parent.mkdir(parents=True, exist_ok=True) - with AppHarnessProd.create(root=app_root, app_name=LIGHTHOUSE_APP_NAME) as harness: + with AppHarnessProd.create(root=app_root, app_name=app_name) as harness: assert harness.frontend_url is not None report = run_lighthouse(harness.frontend_url, report_path) @@ -313,6 +866,50 @@ def run_blank_prod_lighthouse_benchmark( return LighthouseBenchmarkResult( report=report, report_path=report_path, - summary=format_lighthouse_summary(report, report_path), + summary=format_lighthouse_summary(report, report_path, label=label), failures=failures, ) + + +def run_blank_prod_lighthouse_benchmark( + app_root: Path, + report_path: Path, +) -> LighthouseBenchmarkResult: + """Run Lighthouse against the stock blank Reflex app in prod mode. + + Args: + app_root: The app root to initialize or reuse. + report_path: Where to save the Lighthouse JSON report. + + Returns: + A structured benchmark result. + """ + _ensure_lighthouse_app(app_root, LIGHTHOUSE_APP_NAME) + return _run_prod_lighthouse_benchmark( + app_root=app_root, + app_name=LIGHTHOUSE_APP_NAME, + report_path=report_path, + label="blank prod app", + ) + + +def run_landing_prod_lighthouse_benchmark( + app_root: Path, + report_path: Path, +) -> LighthouseBenchmarkResult: + """Run Lighthouse against a single-page landing app in prod mode. + + Args: + app_root: The app root to initialize or reuse. + report_path: Where to save the Lighthouse JSON report. + + Returns: + A structured benchmark result. + """ + _ensure_lighthouse_app(app_root, LIGHTHOUSE_LANDING_APP_NAME, LANDING_PAGE_SOURCE) + return _run_prod_lighthouse_benchmark( + app_root=app_root, + app_name=LIGHTHOUSE_LANDING_APP_NAME, + report_path=report_path, + label="landing page prod app", + ) diff --git a/tests/integration/test_lighthouse.py b/tests/integration/test_lighthouse.py index b82db306f55..1b48e3a1540 100644 --- a/tests/integration/test_lighthouse.py +++ b/tests/integration/test_lighthouse.py @@ -7,8 +7,8 @@ import pytest from .lighthouse_utils import ( - get_env_or_default_app_root, run_blank_prod_lighthouse_benchmark, + run_landing_prod_lighthouse_benchmark, should_run_lighthouse, ) @@ -30,9 +30,7 @@ def lighthouse_app_root( Returns: The app root path for the benchmark app. """ - return get_env_or_default_app_root( - tmp_path_factory.mktemp("lighthouse_blank_app"), - ) + return tmp_path_factory.mktemp("lighthouse_blank_app") def test_blank_template_lighthouse_scores( @@ -51,3 +49,36 @@ def test_blank_template_lighthouse_scores( "Lighthouse thresholds not met. See score summary above.", pytrace=False, ) + + +@pytest.fixture(scope="module") +def lighthouse_landing_app_root( + tmp_path_factory: pytest.TempPathFactory, +) -> Path: + """Get the app root for the landing-page Lighthouse benchmark. + + Args: + tmp_path_factory: Pytest helper for allocating temporary directories. + + Returns: + The app root path for the landing-page benchmark app. + """ + return tmp_path_factory.mktemp("lighthouse_landing_app") + + +def test_landing_page_lighthouse_scores( + lighthouse_landing_app_root: Path, + tmp_path: Path, +): + """Assert that a single-page landing app stays in the 90s across Lighthouse categories.""" + result = run_landing_prod_lighthouse_benchmark( + app_root=lighthouse_landing_app_root, + report_path=tmp_path / "landing-prod-lighthouse.json", + ) + print(result.summary) + + if result.failures: + pytest.fail( + "Lighthouse thresholds not met. See score summary above.", + pytrace=False, + ) diff --git a/tests/units/components/core/test_sticky.py b/tests/units/components/core/test_sticky.py new file mode 100644 index 00000000000..7a34f5fc099 --- /dev/null +++ b/tests/units/components/core/test_sticky.py @@ -0,0 +1,8 @@ +from reflex_components_core.core.sticky import StickyBadge + + +def test_sticky_badge_accessible_name(): + props = StickyBadge.create().render()["props"] + + assert '"aria-label":"Built with Reflex"' in props + assert 'title:"Built with Reflex"' in props diff --git a/tests/units/components/radix/test_color_mode.py b/tests/units/components/radix/test_color_mode.py new file mode 100644 index 00000000000..29209eadd7f --- /dev/null +++ b/tests/units/components/radix/test_color_mode.py @@ -0,0 +1,18 @@ +from reflex_components_radix.themes.color_mode import ColorModeIconButton + + +def test_color_mode_icon_button_accessible_defaults(): + props = ColorModeIconButton.create().render()["props"] + + assert '"aria-label":"Toggle color mode"' in props + assert 'title:"Toggle color mode"' in props + + +def test_color_mode_icon_button_accessible_overrides(): + props = ColorModeIconButton.create( + aria_label="Switch theme", + title="Switch theme", + ).render()["props"] + + assert '"aria-label":"Switch theme"' in props + assert 'title:"Switch theme"' in props From cb3e850be90c0d00e183304b39742df028460036 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 7 Apr 2026 21:38:05 +0500 Subject: [PATCH 03/16] Add gzip pre-compression plugin and optimize Vite chunk splitting Add a Vite build plugin that generates .gz copies of assets for sirv to serve pre-compressed. Split socket.io and radix-ui into separate chunks for better caching. Refactor lighthouse benchmark to use `reflex run --env prod` directly instead of AppHarnessProd. --- .../.templates/web/vite-plugin-compress.js | 60 +++++++++++++++++ .../src/reflex_base/compiler/templates.py | 10 +++ tests/integration/lighthouse_utils.py | 67 +++++++++++++++++-- 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js new file mode 100644 index 00000000000..ff2d625dfc7 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js @@ -0,0 +1,60 @@ +/* vite-plugin-compress.js + * + * Generate pre-compressed .gz copies of build assets so that sirv (the + * production static file server) can serve them directly. sirv has built-in + * support for pre-compressed files (--gzip flag, enabled by default) but only + * looks for existing .gz files on disk -- it does not compress on-the-fly. + * + * Without this plugin the browser receives uncompressed assets, which is the + * single biggest Lighthouse performance bottleneck for Reflex apps. With gzip + * the total asset payload typically shrinks by ~75-80%. + */ + +import { promisify } from "node:util"; +import { gzip } from "node:zlib"; + +const gzipAsync = promisify(gzip); + +const COMPRESSIBLE_EXTENSIONS = /\.(js|css|html|json|svg|xml|txt|map|mjs)$/; + +// Only compress files above this size (bytes). Tiny files don't benefit +// and the overhead of Content-Encoding negotiation can outweigh the saving. +const MIN_SIZE = 256; + +/** + * Vite plugin that generates .gz files for all eligible build assets. + * @returns {import('vite').Plugin} + */ +export default function compressPlugin() { + return { + name: "vite-plugin-compress", + apply: "build", + enforce: "post", + + async generateBundle(_options, bundle) { + const jobs = []; + + for (const [fileName, asset] of Object.entries(bundle)) { + if (!COMPRESSIBLE_EXTENSIONS.test(fileName)) continue; + + const source = asset.type === "chunk" ? asset.code : asset.source; + if (source == null) continue; + + const raw = typeof source === "string" ? Buffer.from(source) : source; + if (raw.length < MIN_SIZE) continue; + + jobs.push( + gzipAsync(raw, { level: 9 }).then((compressed) => { + this.emitFile({ + type: "asset", + fileName: fileName + ".gz", + source: compressed, + }); + }), + ); + } + + await Promise.all(jobs); + }, + }; +} diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index be3e3f6eee4..f305cd54f29 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -526,6 +526,7 @@ def vite_config_template( import {{ reactRouter }} from "@react-router/dev/vite"; import {{ defineConfig }} from "vite"; import safariCacheBustPlugin from "./vite-plugin-safari-cachebust"; +import compressPlugin from "./vite-plugin-compress"; // Ensure that bun always uses the react-dom/server.node functions. function alwaysUseReactDomServerNode() {{ @@ -566,6 +567,7 @@ def vite_config_template( alwaysUseReactDomServerNode(), reactRouter(), safariCacheBustPlugin(), + compressPlugin(), ].concat({"[fullReload()]" if force_full_reload else "[]"}), build: {{ assetsDir: "{base}assets".slice(1), @@ -583,6 +585,14 @@ def vite_config_template( test: /env.json/, name: "reflex-env", }}, + {{ + test: /node_modules\/socket\.io|node_modules\/engine\.io/, + name: "socket-io", + }}, + {{ + test: /node_modules\/@radix-ui/, + name: "radix-ui", + }}, ], }}, }}, diff --git a/tests/integration/lighthouse_utils.py b/tests/integration/lighthouse_utils.py index 4392d882092..9d6d46d109e 100644 --- a/tests/integration/lighthouse_utils.py +++ b/tests/integration/lighthouse_utils.py @@ -5,16 +5,18 @@ import json import operator import os +import re import shlex import shutil import subprocess +import time from dataclasses import dataclass from pathlib import Path from typing import Any import pytest -from reflex.testing import AppHarnessProd, chdir +from reflex.testing import chdir from reflex.utils.templates import initialize_default_app LIGHTHOUSE_RUN_ENV_VAR = "REFLEX_RUN_LIGHTHOUSE" @@ -840,7 +842,10 @@ def _run_prod_lighthouse_benchmark( report_path: Path, label: str, ) -> LighthouseBenchmarkResult: - """Run Lighthouse against a Reflex app in prod mode. + """Run Lighthouse against a Reflex app via ``reflex run --env prod``. + + Uses the real production code path so the benchmark automatically + reflects any future changes to how Reflex serves apps in prod. Args: app_root: The app root to initialize or reuse. @@ -853,9 +858,61 @@ def _run_prod_lighthouse_benchmark( """ report_path.parent.mkdir(parents=True, exist_ok=True) - with AppHarnessProd.create(root=app_root, app_name=app_name) as harness: - assert harness.frontend_url is not None - report = run_lighthouse(harness.frontend_url, report_path) + proc = subprocess.Popen( + [ + "uv", + "run", + "reflex", + "run", + "--env", + "prod", + "--frontend-only", + "--loglevel", + "info", + ], + cwd=str(app_root), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + # Wait for the frontend URL to appear in stdout. + frontend_url = None + captured_output: list[str] = [] + deadline = time.monotonic() + 120 + assert proc.stdout is not None + while time.monotonic() < deadline: + line = proc.stdout.readline() + if not line: + break + captured_output.append(line) + m = re.search(r"App running at:\s*(http\S+)", line) + if m: + frontend_url = m.group(1).rstrip("/") + break + + if frontend_url is None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + output = "".join(captured_output) + pytest.fail( + f"reflex run --env prod did not start within timeout for {label}\n" + f"Captured output:\n{output}" + ) + + try: + report = run_lighthouse(frontend_url, report_path) + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() failures = [] for category_name, threshold in LIGHTHOUSE_CATEGORY_THRESHOLDS.items(): From 40d5aa736ec40c1c4bfba9f9160492b1a807ecaa Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 7 Apr 2026 22:01:24 +0500 Subject: [PATCH 04/16] warmup request --- tests/integration/lighthouse_utils.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/integration/lighthouse_utils.py b/tests/integration/lighthouse_utils.py index 9d6d46d109e..06c24818795 100644 --- a/tests/integration/lighthouse_utils.py +++ b/tests/integration/lighthouse_utils.py @@ -10,6 +10,7 @@ import shutil import subprocess import time +import urllib.request from dataclasses import dataclass from pathlib import Path from typing import Any @@ -904,6 +905,19 @@ def _run_prod_lighthouse_benchmark( f"Captured output:\n{output}" ) + # Warmup request: ensure the server is fully ready before benchmarking. + warmup_deadline = time.monotonic() + 30 + while time.monotonic() < warmup_deadline: + try: + urllib.request.urlopen(frontend_url, timeout=5) + break + except Exception: + time.sleep(0.5) + else: + proc.terminate() + proc.wait(timeout=10) + pytest.fail(f"Warmup request to {frontend_url} never succeeded for {label}") + try: report = run_lighthouse(frontend_url, report_path) finally: From 3f6f2a08cad06baec51b3218f37fb7b097738b48 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 9 Apr 2026 22:18:06 +0500 Subject: [PATCH 05/16] Add configurable pre-compression formats and serve precompressed static assets Support gzip, brotli, and zstd build-time pre-compression via the new `frontend_compression_formats` config option. The Vite compress plugin now handles multiple formats, and a new PrecompressedStaticFiles middleware negotiates Accept-Encoding to serve matching sidecar files directly. Replace the custom http.server-based prod test harness with Starlette/Uvicorn to match the production serving stack. Remove redundant post-build compression pass that was re-compressing assets already handled by the Vite plugin. Move sidecar stat calls off the async event loop into a worker thread. --- .../.templates/web/vite-plugin-compress.js | 168 +++++++--- .../src/reflex_base/compiler/templates.py | 4 +- .../reflex-base/src/reflex_base/config.py | 36 ++- reflex/app.py | 4 +- reflex/docs/hosting/self-hosting.md | 47 +++ reflex/testing.py | 140 +++------ reflex/utils/build.py | 45 ++- reflex/utils/frontend_skeleton.py | 1 + reflex/utils/precompressed_staticfiles.py | 291 ++++++++++++++++++ .../test_precompressed_frontend.py | 105 +++++++ tests/units/test_build.py | 20 ++ tests/units/test_config.py | 24 ++ tests/units/test_prerequisites.py | 11 + .../utils/test_precompressed_staticfiles.py | 120 ++++++++ 14 files changed, 871 insertions(+), 145 deletions(-) create mode 100644 reflex/utils/precompressed_staticfiles.py create mode 100644 tests/integration/test_precompressed_frontend.py create mode 100644 tests/units/test_build.py create mode 100644 tests/units/utils/test_precompressed_staticfiles.py diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js index ff2d625dfc7..12a7b3ddef9 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js @@ -1,19 +1,22 @@ /* vite-plugin-compress.js * - * Generate pre-compressed .gz copies of build assets so that sirv (the - * production static file server) can serve them directly. sirv has built-in - * support for pre-compressed files (--gzip flag, enabled by default) but only - * looks for existing .gz files on disk -- it does not compress on-the-fly. - * - * Without this plugin the browser receives uncompressed assets, which is the - * single biggest Lighthouse performance bottleneck for Reflex apps. With gzip - * the total asset payload typically shrinks by ~75-80%. + * Generate pre-compressed build assets so they can be served directly by + * production static file servers and reverse proxies without on-the-fly + * compression. The default format is gzip, with optional brotli and zstd. */ +import * as zlib from "node:zlib"; +import { dirname, join } from "node:path"; +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; import { promisify } from "node:util"; -import { gzip } from "node:zlib"; -const gzipAsync = promisify(gzip); +const gzipAsync = promisify(zlib.gzip); +const brotliAsync = + typeof zlib.brotliCompress === "function" + ? promisify(zlib.brotliCompress) + : null; +const zstdAsync = + typeof zlib.zstdCompress === "function" ? promisify(zlib.zstdCompress) : null; const COMPRESSIBLE_EXTENSIONS = /\.(js|css|html|json|svg|xml|txt|map|mjs)$/; @@ -21,40 +24,133 @@ const COMPRESSIBLE_EXTENSIONS = /\.(js|css|html|json|svg|xml|txt|map|mjs)$/; // and the overhead of Content-Encoding negotiation can outweigh the saving. const MIN_SIZE = 256; +const COMPRESSORS = { + gzip: { + extension: ".gz", + compress: (raw) => gzipAsync(raw, { level: 9 }), + }, + brotli: brotliAsync && { + extension: ".br", + compress: (raw) => + brotliAsync(raw, { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: + zlib.constants.BROTLI_MAX_QUALITY ?? 11, + }, + }), + }, + zstd: zstdAsync && { + extension: ".zst", + compress: (raw) => zstdAsync(raw), + }, +}; + +function normalizeFormats(formats = ["gzip"]) { + const normalized = []; + const seen = new Set(); + + for (const format of formats) { + const normalizedFormat = String(format).trim().toLowerCase(); + if (!normalizedFormat || seen.has(normalizedFormat)) { + continue; + } + if (!(normalizedFormat in COMPRESSORS)) { + throw new Error( + `Unsupported frontend compression format "${format}". ` + + 'Expected one of: "gzip", "brotli", "zstd".', + ); + } + normalized.push(normalizedFormat); + seen.add(normalizedFormat); + } + + return normalized; +} + +async function* walkFiles(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + yield* walkFiles(entryPath); + continue; + } + if (entry.isFile()) { + yield entryPath; + } + } +} + +function ensureFormatsSupported(formats) { + const unavailableFormats = formats.filter( + (format) => !COMPRESSORS[format]?.compress, + ); + if (unavailableFormats.length > 0) { + throw new Error( + `The configured frontend compression formats are not supported by this Node.js runtime: ${unavailableFormats.join(", ")}`, + ); + } +} + +async function outputDirectoryExists(outputDir) { + return Boolean( + await stat(outputDir).catch((error) => + error?.code === "ENOENT" ? null : Promise.reject(error), + ), + ); +} + +async function compressFile(filePath, formats) { + const raw = await readFile(filePath); + if (raw.length < MIN_SIZE) return; + + await Promise.all( + formats.map((format) => { + const compressor = COMPRESSORS[format]; + return compressor + .compress(raw) + .then((compressed) => + writeFile(filePath + compressor.extension, compressed), + ); + }), + ); +} + +export async function compressDirectory(directory, formats = ["gzip"]) { + const normalizedFormats = normalizeFormats(formats); + ensureFormatsSupported(normalizedFormats); + + if (!(await outputDirectoryExists(directory))) { + return; + } + + const jobs = []; + for await (const filePath of walkFiles(directory)) { + if (!COMPRESSIBLE_EXTENSIONS.test(filePath)) continue; + jobs.push(compressFile(filePath, normalizedFormats)); + } + + await Promise.all(jobs); +} + /** - * Vite plugin that generates .gz files for all eligible build assets. + * Vite plugin that generates pre-compressed files for eligible build assets. + * @param {{ formats?: string[] }} [options] * @returns {import('vite').Plugin} */ -export default function compressPlugin() { +export default function compressPlugin(options = {}) { + const formats = normalizeFormats(options.formats); + return { name: "vite-plugin-compress", apply: "build", enforce: "post", - async generateBundle(_options, bundle) { - const jobs = []; - - for (const [fileName, asset] of Object.entries(bundle)) { - if (!COMPRESSIBLE_EXTENSIONS.test(fileName)) continue; - - const source = asset.type === "chunk" ? asset.code : asset.source; - if (source == null) continue; - - const raw = typeof source === "string" ? Buffer.from(source) : source; - if (raw.length < MIN_SIZE) continue; - - jobs.push( - gzipAsync(raw, { level: 9 }).then((compressed) => { - this.emitFile({ - type: "asset", - fileName: fileName + ".gz", - source: compressed, - }); - }), - ); - } - - await Promise.all(jobs); + async writeBundle(outputOptions) { + const outputDir = + outputOptions.dir ?? + (outputOptions.file ? dirname(outputOptions.file) : null); + if (!outputDir) return; + await compressDirectory(outputDir, formats); }, }; } diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index f305cd54f29..fc1a63c7a05 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -502,6 +502,7 @@ def vite_config_template( experimental_hmr: bool, sourcemap: bool | Literal["inline", "hidden"], allowed_hosts: bool | list[str] = False, + compression_formats: list[str] | None = None, ): """Template for vite.config.js. @@ -512,6 +513,7 @@ def vite_config_template( experimental_hmr: Whether to enable experimental HMR features. sourcemap: The sourcemap configuration. allowed_hosts: Allow all hosts (True), specific hosts (list of strings), or only localhost (False). + compression_formats: Build-time pre-compression formats to emit. Returns: Rendered vite.config.js content as string. @@ -567,7 +569,7 @@ def vite_config_template( alwaysUseReactDomServerNode(), reactRouter(), safariCacheBustPlugin(), - compressPlugin(), + compressPlugin({{ formats: {json.dumps(compression_formats if compression_formats is not None else ["gzip"])} }}), ].concat({"[fullReload()]" if force_full_reload else "[]"}), build: {{ assetsDir: "{base}assets".slice(1), diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index ec32a5623b1..ecfc774fcab 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -167,6 +167,7 @@ class BaseConfig: cors_allowed_origins: Comma separated list of origins that are allowed to connect to the backend API. vite_allowed_hosts: Allowed hosts for the Vite dev server. Set to True to allow all hosts, or provide a list of hostnames (e.g. ["myservice.local"]) to allow specific ones. Prevents 403 errors in Docker, Codespaces, reverse proxies, etc. react_strict_mode: Whether to use React strict mode. + frontend_compression_formats: Pre-compressed frontend asset formats to generate for production builds. Supported values are "gzip", "brotli", and "zstd". Use an empty list to disable build-time pre-compression. frontend_packages: Additional frontend packages to install. state_manager_mode: Indicate which type of state manager to use. redis_lock_expiration: Maximum expiration lock time for redis state manager. @@ -221,6 +222,11 @@ class BaseConfig: react_strict_mode: bool = True + frontend_compression_formats: Annotated[ + list[str], + SequenceOptions(delimiter=",", strip=True), + ] = dataclasses.field(default_factory=lambda: ["gzip"]) + frontend_packages: list[str] = dataclasses.field(default_factory=list) state_manager_mode: constants.StateManagerMode = constants.StateManagerMode.DISK @@ -305,7 +311,7 @@ class Config(BaseConfig): - **App Settings**: `app_name`, `loglevel`, `telemetry_enabled` - **Server**: `frontend_port`, `backend_port`, `api_url`, `cors_allowed_origins` - **Database**: `db_url`, `async_db_url`, `redis_url` - - **Frontend**: `frontend_packages`, `react_strict_mode` + - **Frontend**: `frontend_packages`, `react_strict_mode`, `frontend_compression_formats` - **State Management**: `state_manager_mode`, `state_auto_setters` - **Plugins**: `plugins`, `disable_plugins` @@ -345,6 +351,8 @@ def _post_init(self, **kwargs): for key, env_value in env_kwargs.items(): setattr(self, key, env_value) + self._normalize_frontend_compression_formats() + # Normalize disable_plugins: convert strings and Plugin subclasses to instances. self._normalize_disable_plugins() @@ -415,6 +423,32 @@ def _normalize_disable_plugins(self): ) self.disable_plugins = normalized + def _normalize_frontend_compression_formats(self): + """Normalize and validate configured frontend compression formats. + + Raises: + ConfigError: If an unsupported format is configured. + """ + supported_formats = {"brotli", "gzip", "zstd"} + normalized = [] + seen = set() + + for format_name in self.frontend_compression_formats: + normalized_name = format_name.strip().lower() + if not normalized_name or normalized_name in seen: + continue + if normalized_name not in supported_formats: + supported = ", ".join(sorted(supported_formats)) + msg = ( + "frontend_compression_formats contains unsupported format " + f"{format_name!r}. Expected one of: {supported}." + ) + raise ConfigError(msg) + normalized.append(normalized_name) + seen.add(normalized_name) + + self.frontend_compression_formats = normalized + def _add_builtin_plugins(self): """Add the builtin plugins to the config.""" for plugin in _PLUGINS_ENABLED_BY_DEFAULT: diff --git a/reflex/app.py b/reflex/app.py index 482f8be3c15..39941211a41 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -112,6 +112,7 @@ should_prerender_routes, ) from reflex.utils.misc import run_in_thread +from reflex.utils.precompressed_staticfiles import PrecompressedStaticFiles from reflex.utils.token_manager import RedisTokenManager, TokenManager if sys.version_info < (3, 13): @@ -649,11 +650,12 @@ def __call__(self) -> ASGIApp: if environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.get(): asgi_app.mount( "/" + config.frontend_path.strip("/"), - StaticFiles( + PrecompressedStaticFiles( directory=prerequisites.get_web_dir() / constants.Dirs.STATIC / config.frontend_path.strip("/"), html=True, + encodings=config.frontend_compression_formats, ), name="frontend", ) diff --git a/reflex/docs/hosting/self-hosting.md b/reflex/docs/hosting/self-hosting.md index cd793f30ade..a8e584009a1 100644 --- a/reflex/docs/hosting/self-hosting.md +++ b/reflex/docs/hosting/self-hosting.md @@ -43,6 +43,53 @@ the backend (event handlers) will be listening on port `8000`. Because the backend uses websockets, some reverse proxy servers, like [nginx](https://nginx.org/en/docs/http/websocket.html) or [apache](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#protoupgrade), must be configured to pass the `Upgrade` header to allow backend connectivity. ``` +## Pre-compressed Frontend Assets + +Production builds generate pre-compressed frontend assets so they can be served +without compressing responses on the fly. By default Reflex emits `gzip` +sidecars. You can also opt into Brotli and Zstandard in `rxconfig.py`: + +```python +config = rx.Config( + app_name="your_app_name", + frontend_compression_formats=["gzip", "brotli", "zstd"], +) +``` + +When Reflex serves the compiled frontend itself, it will negotiate +`Accept-Encoding` and serve matching sidecar files directly. If you would rather +have your reverse proxy handle compression itself, set +`frontend_compression_formats=[]` to disable build-time pre-compression. + +If you are serving `.web/build/client` from a reverse proxy, enable its +precompressed-file support: + +### Caddy + +```caddy +example.com { + root * /srv/your-app/.web/build/client + try_files {path} /404.html + file_server { + precompressed zstd br gzip + } +} +``` + +### Nginx + +```nginx +location / { + root /srv/your-app/.web/build/client; + try_files $uri $uri/ /404.html; + gzip_static on; +} +``` + +Nginx supports prebuilt `gzip` files directly. If you also want Brotli or Zstd +at the proxy layer, use the corresponding Nginx modules or handle compression +at a CDN/load-balancer layer instead. + ## Exporting a Static Build Exporting a static build of the frontend allows the app to be served using a diff --git a/reflex/testing.py b/reflex/testing.py index c144b22d7c6..056bb1080ff 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -13,7 +13,6 @@ import re import signal import socket -import socketserver import subprocess import sys import textwrap @@ -22,7 +21,6 @@ import types from collections.abc import Callable, Coroutine, Sequence from copy import deepcopy -from http.server import SimpleHTTPRequestHandler from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar @@ -33,6 +31,7 @@ from reflex_base.environment import environment from reflex_base.registry import RegistrationContext from reflex_base.utils.types import ASGIApp +from starlette.applications import Starlette from typing_extensions import Self import reflex @@ -46,6 +45,7 @@ from reflex.state import reload_state_module from reflex.utils import console, js_runtimes from reflex.utils.export import export +from reflex.utils.precompressed_staticfiles import PrecompressedStaticFiles from reflex.utils.token_manager import TokenManager try: @@ -801,94 +801,16 @@ def expect( ) -class SimpleHTTPRequestHandlerCustomErrors(SimpleHTTPRequestHandler): - """SimpleHTTPRequestHandler with custom error page handling.""" - - def __init__(self, *args, error_page_map: dict[int, Path], **kwargs): - """Initialize the handler. - - Args: - error_page_map: map of error code to error page path - *args: passed through to superclass - **kwargs: passed through to superclass - """ - self.error_page_map = error_page_map - super().__init__(*args, **kwargs) - - def send_error( - self, code: int, message: str | None = None, explain: str | None = None - ) -> None: - """Send the error page for the given error code. - - If the code matches a custom error page, then message and explain are - ignored. - - Args: - code: the error code - message: the error message - explain: the error explanation - """ - error_page = self.error_page_map.get(code) - if error_page: - self.send_response(code, message) - self.send_header("Connection", "close") - body = error_page.read_bytes() - self.send_header("Content-Type", self.error_content_type) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - else: - super().send_error(code, message, explain) - - -class Subdir404TCPServer(socketserver.TCPServer): - """TCPServer for SimpleHTTPRequestHandlerCustomErrors that serves from a subdir.""" - - def __init__( - self, - *args, - root: Path, - error_page_map: dict[int, Path] | None, - **kwargs, - ): - """Initialize the server. - - Args: - root: the root directory to serve from - error_page_map: map of error code to error page path - *args: passed through to superclass - **kwargs: passed through to superclass - """ - self.root = root - self.error_page_map = error_page_map or {} - super().__init__(*args, **kwargs) - - def finish_request(self, request: socket.socket, client_address: tuple[str, int]): - """Finish one request by instantiating RequestHandlerClass. - - Args: - request: the requesting socket - client_address: (host, port) referring to the client's address. - """ - self.RequestHandlerClass( - request, - client_address, - self, - directory=str(self.root), # pyright: ignore [reportCallIssue] - error_page_map=self.error_page_map, # pyright: ignore [reportCallIssue] - ) - - class AppHarnessProd(AppHarness): """AppHarnessProd executes a reflex app in-process for testing. In prod mode, instead of running `react-router dev` the app is exported as static - files and served via the builtin python http.server with custom 404 redirect - handling. Additionally, the backend runs in multi-worker mode. + files and served via Starlette StaticFiles in a dedicated Uvicorn server. + Additionally, the backend runs in multi-worker mode. """ frontend_thread: threading.Thread | None = None - frontend_server: Subdir404TCPServer | None = None + frontend_server: uvicorn.Server | None = None def _run_frontend(self): web_root = ( @@ -896,19 +818,25 @@ def _run_frontend(self): / reflex.utils.prerequisites.get_web_dir() / reflex.constants.Dirs.STATIC ) - error_page_map = { - 404: web_root / "404.html", - } - with Subdir404TCPServer( - ("", 0), - SimpleHTTPRequestHandlerCustomErrors, - root=web_root, - error_page_map=error_page_map, - ) as self.frontend_server: - self.frontend_url = "http://localhost:{1}".format( - *self.frontend_server.socket.getsockname() + config = get_config() + frontend_app = Starlette() + frontend_app.mount( + "/" + config.frontend_path.strip("/"), + PrecompressedStaticFiles( + directory=web_root / config.frontend_path.strip("/"), + html=True, + encodings=config.frontend_compression_formats, + ), + name="frontend", + ) + self.frontend_server = uvicorn.Server( + uvicorn.Config( + app=frontend_app, + host="127.0.0.1", + port=0, ) - self.frontend_server.serve_forever() + ) + self.frontend_server.run() def _start_frontend(self): # Set up the frontend. @@ -941,10 +869,22 @@ def _start_frontend(self): self.frontend_thread.start() def _wait_frontend(self): - self._poll_for(lambda: self.frontend_server is not None) - if self.frontend_server is None or not self.frontend_server.socket.fileno(): + self._poll_for( + lambda: ( + self.frontend_server + and getattr(self.frontend_server, "servers", False) + and getattr(self.frontend_server.servers[0], "sockets", False) + ) + ) + if self.frontend_server is None or not self.frontend_server.servers[0].sockets: msg = "Frontend did not start" raise RuntimeError(msg) + frontend_socket = self.frontend_server.servers[0].sockets[0] + if not frontend_socket.fileno(): + msg = "Frontend did not start" + raise RuntimeError(msg) + self.frontend_url = "http://{}:{}".format(*frontend_socket.getsockname()) + get_config().deploy_url = self.frontend_url def _start_backend(self): if self.app_asgi is None: @@ -981,9 +921,9 @@ def _poll_for_servers(self, timeout: TimeoutType = None) -> socket.socket: environment.REFLEX_SKIP_COMPILE.set(None) def stop(self): - """Stop the frontend python webserver.""" - super().stop() + """Stop the frontend and backend servers.""" if self.frontend_server is not None: - self.frontend_server.shutdown() + self.frontend_server.should_exit = True + super().stop() if self.frontend_thread is not None: self.frontend_thread.join() diff --git a/reflex/utils/build.py b/reflex/utils/build.py index e4f928434b3..d9a1f38c5f5 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -12,6 +12,7 @@ from reflex.utils import console, js_runtimes, path_ops, prerequisites, processes from reflex.utils.exec import is_in_app_harness +from reflex.utils.precompressed_staticfiles import _SUPPORTED_ENCODINGS def set_env_json(): @@ -164,13 +165,16 @@ def zip_app( ) -def _duplicate_index_html_to_parent_directory(directory: Path): +def _duplicate_index_html_to_parent_directory( + directory: Path, suffixes: tuple[str, ...] +): """Duplicate index.html in the child directories to the given directory. This makes accessing /route and /route/ work in production. Args: directory: The directory to duplicate index.html to. + suffixes: Precompressed sidecar suffixes to copy alongside each file. """ for child in directory.iterdir(): if child.is_dir(): @@ -183,8 +187,27 @@ def _duplicate_index_html_to_parent_directory(directory: Path): path_ops.cp(index_html, target) else: console.debug(f"Skipping {index_html}, already exists at {target}") + _copy_precompressed_sidecars(index_html, target, suffixes) # Recursively call this function for the child directory. - _duplicate_index_html_to_parent_directory(child) + _duplicate_index_html_to_parent_directory(child, suffixes) + + +def _copy_precompressed_sidecars(source: Path, target: Path, suffixes: tuple[str, ...]): + """Copy precompressed sidecars for a file if they exist. + + Args: + source: The original file path. + target: The copied file path. + suffixes: The file suffixes to look for (e.g. ``(".gz",)``). + """ + for suffix in suffixes: + source_sidecar = source.with_name(source.name + suffix) + if not source_sidecar.exists(): + continue + + target_sidecar = target.with_name(target.name + suffix) + console.debug(f"Copying {source_sidecar} to {target_sidecar}") + path_ops.cp(source_sidecar, target_sidecar) def build(): @@ -226,19 +249,29 @@ def build(): "Failed to build the frontend. Please run with --loglevel debug for more information.", ) raise SystemExit(1) - _duplicate_index_html_to_parent_directory(wdir / constants.Dirs.STATIC) + + config = get_config() + sidecar_suffixes = tuple( + _SUPPORTED_ENCODINGS[fmt].suffix + for fmt in config.frontend_compression_formats + if fmt in _SUPPORTED_ENCODINGS + ) + + _duplicate_index_html_to_parent_directory( + wdir / constants.Dirs.STATIC, sidecar_suffixes + ) spa_fallback = wdir / constants.Dirs.STATIC / constants.ReactRouter.SPA_FALLBACK if not spa_fallback.exists(): spa_fallback = wdir / constants.Dirs.STATIC / "index.html" if spa_fallback.exists(): + target_404 = wdir / constants.Dirs.STATIC / "404.html" path_ops.cp( spa_fallback, - wdir / constants.Dirs.STATIC / "404.html", + target_404, ) - - config = get_config() + _copy_precompressed_sidecars(spa_fallback, target_404, sidecar_suffixes) if frontend_path := config.frontend_path.strip("/"): frontend_path = PosixPath(frontend_path) diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 73990acd386..caeefefab0b 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -258,6 +258,7 @@ def _compile_vite_config(config: Config): experimental_hmr=environment.VITE_EXPERIMENTAL_HMR.get(), sourcemap=environment.VITE_SOURCEMAP.get(), allowed_hosts=config.vite_allowed_hosts, + compression_formats=config.frontend_compression_formats, ) diff --git a/reflex/utils/precompressed_staticfiles.py b/reflex/utils/precompressed_staticfiles.py new file mode 100644 index 00000000000..7001a84cf8e --- /dev/null +++ b/reflex/utils/precompressed_staticfiles.py @@ -0,0 +1,291 @@ +"""Serve precompressed static assets when the client supports them.""" + +from __future__ import annotations + +import errno +import os +import stat +from collections.abc import Sequence +from dataclasses import dataclass +from mimetypes import guess_type +from pathlib import Path + +from anyio import to_thread +from starlette.datastructures import URL, Headers +from starlette.exceptions import HTTPException +from starlette.responses import FileResponse, RedirectResponse, Response +from starlette.staticfiles import NotModifiedResponse, StaticFiles +from starlette.types import Scope + + +@dataclass(frozen=True, slots=True) +class _EncodingFormat: + """Mapping between a configured format and its HTTP/static-file details.""" + + name: str + content_encoding: str + suffix: str + + +_SUPPORTED_ENCODINGS = { + "gzip": _EncodingFormat( + name="gzip", + content_encoding="gzip", + suffix=".gz", + ), + "brotli": _EncodingFormat( + name="brotli", + content_encoding="br", + suffix=".br", + ), + "zstd": _EncodingFormat( + name="zstd", + content_encoding="zstd", + suffix=".zst", + ), +} + + +def _normalize_encoding_formats(formats: Sequence[str]) -> tuple[_EncodingFormat, ...]: + """Normalize configured encoding names to supported sidecar formats. + + Args: + formats: The configured compression format names. + + Returns: + The normalized supported sidecar encodings in configured order. + + Raises: + ValueError: If an unknown format is configured. + """ + normalized_formats = [] + seen = set() + for format_name in formats: + normalized_name = format_name.strip().lower() + if not normalized_name or normalized_name in seen: + continue + encoding = _SUPPORTED_ENCODINGS.get(normalized_name) + if encoding is None: + supported = ", ".join(sorted(_SUPPORTED_ENCODINGS)) + msg = ( + f"Unsupported frontend compression format {format_name!r}. " + f"Expected one of: {supported}." + ) + raise ValueError(msg) + normalized_formats.append(encoding) + seen.add(normalized_name) + return tuple(normalized_formats) + + +def _parse_accept_encoding(header_value: str | None) -> dict[str, float]: + """Parse an ``Accept-Encoding`` header into quality values. + + Args: + header_value: The raw ``Accept-Encoding`` header value. + + Returns: + A mapping of accepted encodings to their quality values. + """ + if not header_value: + return {} + + parsed: dict[str, float] = {} + for entry in header_value.split(","): + token, *params = entry.split(";") + encoding = token.strip().lower() + if not encoding: + continue + + quality = 1.0 + for param in params: + key, _, value = param.strip().partition("=") + if key.lower() != "q" or not value: + continue + try: + quality = float(value) + except ValueError: + quality = 0.0 + break + + parsed[encoding] = max(parsed.get(encoding, 0.0), quality) + return parsed + + +class PrecompressedStaticFiles(StaticFiles): + """StaticFiles that prefers matching precompressed sidecar files.""" + + def __init__( + self, + *args, + encodings: Sequence[str] = (), + **kwargs, + ): + """Initialize the static file server. + + Args: + *args: Passed through to ``StaticFiles``. + encodings: Ordered list of supported precompressed formats. + **kwargs: Passed through to ``StaticFiles``. + """ + super().__init__(*args, **kwargs) + self._encodings = _normalize_encoding_formats(encodings) + + def _find_precompressed_variant_sync( + self, + path: str, + accepted_encodings: dict[str, float], + ) -> tuple[_EncodingFormat, str, os.stat_result] | None: + """Select the best matching precompressed sidecar for a request path. + + This performs blocking filesystem lookups and must be called via + ``to_thread.run_sync`` from async contexts. + + Args: + path: The requested relative file path. + accepted_encodings: Parsed Accept-Encoding quality values. + + Returns: + The selected encoding format, file path, and stat result, or ``None``. + """ + best_match = None + best_quality = 0.0 + + for encoding in self._encodings: + quality = accepted_encodings.get( + encoding.content_encoding, accepted_encodings.get("*", 0.0) + ) + if quality <= 0: + continue + + full_path, stat_result = self.lookup_path(path + encoding.suffix) + if stat_result is None or not stat.S_ISREG(stat_result.st_mode): + continue + + if quality > best_quality: + best_match = (encoding, full_path, stat_result) + best_quality = quality + if best_quality >= 1.0: + break + + return best_match + + async def _build_file_response( + self, + *, + path: str, + full_path: str, + stat_result: os.stat_result, + scope: Scope, + status_code: int = 200, + ) -> Response: + """Build a ``FileResponse`` with optional precompressed sidecar support. + + Args: + path: The requested relative file path. + full_path: The resolved on-disk path to the uncompressed file. + stat_result: The stat result for the uncompressed file. + scope: The ASGI request scope. + status_code: The response status code to use. + + Returns: + A file response that serves the best matching asset variant. + """ + request_headers = Headers(scope=scope) + response_headers = {} + response_path = full_path + response_stat = stat_result + media_type = None + + if self._encodings and not any( + path.endswith(fmt.suffix) for fmt in self._encodings + ): + accepted_encodings = _parse_accept_encoding( + request_headers.get("accept-encoding") + ) + if accepted_encodings: + matched_variant = await to_thread.run_sync( + lambda: self._find_precompressed_variant_sync( + path, accepted_encodings + ) + ) + if matched_variant: + encoding, response_path, response_stat = matched_variant + response_headers["Content-Encoding"] = encoding.content_encoding + media_type = guess_type(path)[0] or "text/plain" + + if self._encodings: + response_headers["Vary"] = "Accept-Encoding" + + response = FileResponse( + response_path, + status_code=status_code, + headers=response_headers or None, + media_type=media_type, + stat_result=response_stat, + ) + if self.is_not_modified(response.headers, request_headers): + return NotModifiedResponse(response.headers) + return response + + async def get_response(self, path: str, scope: Scope) -> Response: + """Return the best static response for ``path`` and ``scope``. + + Args: + path: The requested relative file path. + scope: The ASGI request scope. + + Returns: + The resolved static response for the request. + """ + if scope["method"] not in ("GET", "HEAD"): + raise HTTPException(status_code=405) + + try: + full_path, stat_result = await to_thread.run_sync(self.lookup_path, path) + except PermissionError: + raise HTTPException(status_code=401) from None + except OSError as exc: + if exc.errno == errno.ENAMETOOLONG: + raise HTTPException(status_code=404) from None + raise + + if stat_result and stat.S_ISREG(stat_result.st_mode): + return await self._build_file_response( + path=path, + full_path=full_path, + stat_result=stat_result, + scope=scope, + ) + + if stat_result and stat.S_ISDIR(stat_result.st_mode) and self.html: + index_path = str(Path(path) / "index.html") + full_index_path, index_stat_result = await to_thread.run_sync( + self.lookup_path, index_path + ) + if index_stat_result is not None and stat.S_ISREG( + index_stat_result.st_mode + ): + if not scope["path"].endswith("/"): + url = URL(scope=scope) + return RedirectResponse(url=url.replace(path=url.path + "/")) + return await self._build_file_response( + path=index_path, + full_path=full_index_path, + stat_result=index_stat_result, + scope=scope, + ) + + if self.html: + full_404_path, stat_404_result = await to_thread.run_sync( + self.lookup_path, "404.html" + ) + if stat_404_result and stat.S_ISREG(stat_404_result.st_mode): + return await self._build_file_response( + path="404.html", + full_path=full_404_path, + stat_result=stat_404_result, + scope=scope, + status_code=404, + ) + + raise HTTPException(status_code=404) diff --git a/tests/integration/test_precompressed_frontend.py b/tests/integration/test_precompressed_frontend.py new file mode 100644 index 00000000000..8382c3ac55f --- /dev/null +++ b/tests/integration/test_precompressed_frontend.py @@ -0,0 +1,105 @@ +"""Integration tests for precompressed production frontend responses.""" + +from __future__ import annotations + +from collections.abc import Generator +from http.client import HTTPConnection +from urllib.parse import urlsplit + +import pytest + +from reflex.testing import AppHarness, AppHarnessProd + + +def PrecompressedFrontendApp(): + """A minimal app for production static frontend checks.""" + import reflex as rx + + app = rx.App() + + @app.add_page + def index(): + return rx.el.main( + rx.heading("Precompressed Frontend"), + rx.text("Hello from Reflex"), + ) + + +def _request_raw( + frontend_url: str, + path: str, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, str], bytes]: + """Send a raw HTTP request without client-side decompression. + + Args: + frontend_url: The frontend base URL. + path: The request path. + headers: Optional request headers. + + Returns: + The status code, response headers, and raw response body. + """ + parsed = urlsplit(frontend_url) + assert parsed.hostname is not None + assert parsed.port is not None + connection = HTTPConnection(parsed.hostname, parsed.port, timeout=10) + connection.request("GET", path, headers=headers or {}) + response = connection.getresponse() + body = response.read() + response_headers = {key.lower(): value for key, value in response.getheaders()} + status = response.status + connection.close() + return status, response_headers, body + + +@pytest.fixture(scope="module") +def prod_test_app( + app_harness_env: type[AppHarness], + tmp_path_factory, +) -> Generator[AppHarness, None, None]: + """Start the precompressed test app in production mode only. + + Yields: + A running production app harness. + """ + if app_harness_env is not AppHarnessProd: + pytest.skip("precompressed frontend checks are prod-only") + + with app_harness_env.create( + root=tmp_path_factory.mktemp("precompressed_frontend"), + app_name="precompressed_frontend", + app_source=PrecompressedFrontendApp, + ) as harness: + yield harness + + +def test_prod_frontend_serves_precompressed_index_html(prod_test_app: AppHarness): + """Root HTML should be served from its precompressed sidecar.""" + assert prod_test_app.frontend_url is not None + + status, headers, body = _request_raw( + prod_test_app.frontend_url, + "/", + headers={"Accept-Encoding": "gzip"}, + ) + + assert status == 200 + assert headers["content-encoding"] == "gzip" + assert headers["vary"] == "Accept-Encoding" + assert body.startswith(b"\x1f\x8b") + + +def test_prod_frontend_serves_precompressed_404_fallback(prod_test_app: AppHarness): + """Unknown routes should serve the compressed 404.html fallback.""" + assert prod_test_app.frontend_url is not None + + status, headers, body = _request_raw( + prod_test_app.frontend_url, + "/missing-route", + headers={"Accept-Encoding": "gzip"}, + ) + + assert status == 404 + assert headers["content-encoding"] == "gzip" + assert body.startswith(b"\x1f\x8b") diff --git a/tests/units/test_build.py b/tests/units/test_build.py new file mode 100644 index 00000000000..719d7e94d79 --- /dev/null +++ b/tests/units/test_build.py @@ -0,0 +1,20 @@ +"""Unit tests for frontend build helpers.""" + +from pathlib import Path + +from reflex.utils.build import _duplicate_index_html_to_parent_directory + + +def test_duplicate_index_html_to_parent_directory_copies_sidecars(tmp_path: Path): + """Duplicate index.html sidecars alongside copied route HTML files.""" + route_dir = tmp_path / "docs" + route_dir.mkdir() + (route_dir / "index.html").write_text("docs") + (route_dir / "index.html.gz").write_bytes(b"gzip") + (route_dir / "index.html.br").write_bytes(b"brotli") + + _duplicate_index_html_to_parent_directory(tmp_path, (".gz", ".br")) + + assert (tmp_path / "docs.html").read_text() == "docs" + assert (tmp_path / "docs.html.gz").read_bytes() == b"gzip" + assert (tmp_path / "docs.html.br").read_bytes() == b"brotli" diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 38267c9c53b..55750cfdfce 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -133,6 +133,30 @@ def test_update_from_env_cors( ] +def test_update_from_env_frontend_compression_formats( + base_config_values: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +): + """Test comma-delimited frontend compression formats from the environment.""" + monkeypatch.setenv( + "REFLEX_FRONTEND_COMPRESSION_FORMATS", "gzip, brotli , zstd, gzip" + ) + config = rx.Config(**base_config_values) + assert config.frontend_compression_formats == ["gzip", "brotli", "zstd"] + + +def test_invalid_frontend_compression_formats(base_config_values: dict[str, Any]): + """Test that unsupported frontend compression formats raise config errors.""" + with pytest.raises( + reflex_base.config.ConfigError, + match="frontend_compression_formats contains unsupported format", + ): + rx.Config( + **base_config_values, + frontend_compression_formats=["gzip", "snappy"], + ) + + @pytest.mark.parametrize( ("kwargs", "expected"), [ diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 136b32efb12..a90ad2f5adb 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -92,6 +92,17 @@ def test_initialise_vite_config(config, expected_output): assert expected_output in output +def test_vite_config_uses_frontend_compression_formats(): + config = Config( + app_name="test", + frontend_compression_formats=["gzip", "brotli"], + ) + + output = _compile_vite_config(config) + + assert 'compressPlugin({ formats: ["gzip", "brotli"] }),' in output + + @pytest.mark.parametrize( ("frontend_path", "expected_command"), [ diff --git a/tests/units/utils/test_precompressed_staticfiles.py b/tests/units/utils/test_precompressed_staticfiles.py new file mode 100644 index 00000000000..e63e62bca05 --- /dev/null +++ b/tests/units/utils/test_precompressed_staticfiles.py @@ -0,0 +1,120 @@ +"""Unit tests for precompressed static file serving.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from starlette.responses import FileResponse + +from reflex.utils.precompressed_staticfiles import PrecompressedStaticFiles + + +def _scope(path: str, accept_encoding: str | None = None) -> dict: + headers = [] + if accept_encoding is not None: + headers.append((b"accept-encoding", accept_encoding.encode())) + return { + "type": "http", + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": headers, + "client": ("127.0.0.1", 1234), + "server": ("testserver", 80), + "root_path": "", + } + + +@pytest.mark.asyncio +async def test_precompressed_static_files_supports_html_mode(tmp_path: Path): + """Serve a precompressed index.html sidecar for directory requests.""" + (tmp_path / "index.html").write_text("hello") + (tmp_path / "index.html.gz").write_bytes(b"compressed-index") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + html=True, + encodings=["gzip"], + ) + + response = await static_files.get_response("", _scope("/", "gzip")) + + assert isinstance(response, FileResponse) + assert response.status_code == 200 + assert str(response.path).endswith("index.html.gz") + assert response.headers["content-encoding"] == "gzip" + assert response.headers["vary"] == "Accept-Encoding" + assert response.media_type == "text/html" + + +@pytest.mark.asyncio +async def test_precompressed_static_files_supports_html_404_fallback(tmp_path: Path): + """Serve a precompressed 404.html sidecar for HTML fallback responses.""" + (tmp_path / "404.html").write_text("missing") + (tmp_path / "404.html.gz").write_bytes(b"compressed-404") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + html=True, + encodings=["gzip"], + ) + + response = await static_files.get_response("missing", _scope("/missing", "gzip")) + + assert isinstance(response, FileResponse) + assert response.status_code == 404 + assert str(response.path).endswith("404.html.gz") + assert response.headers["content-encoding"] == "gzip" + assert response.media_type == "text/html" + + +@pytest.mark.asyncio +async def test_precompressed_static_files_prefers_best_accept_encoding( + tmp_path: Path, +): + """Prefer the highest-quality configured encoding that exists on disk.""" + (tmp_path / "app.js").write_text("console.log('hello');") + (tmp_path / "app.js.gz").write_bytes(b"compressed-gzip") + (tmp_path / "app.js.br").write_bytes(b"compressed-brotli") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + encodings=["gzip", "brotli"], + ) + + response = await static_files.get_response( + "app.js", + _scope("/app.js", "gzip;q=0.5, br;q=1"), + ) + + assert isinstance(response, FileResponse) + assert str(response.path).endswith("app.js.br") + assert response.headers["content-encoding"] == "br" + assert response.media_type is not None + assert "javascript" in response.media_type + + +@pytest.mark.asyncio +async def test_precompressed_static_files_fall_back_to_identity(tmp_path: Path): + """Keep serving the original file when no accepted sidecar is available.""" + (tmp_path / "app.js").write_text("console.log('hello');") + (tmp_path / "app.js.gz").write_bytes(b"compressed-gzip") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + encodings=["gzip"], + ) + + response = await static_files.get_response( + "app.js", + _scope("/app.js", "identity"), + ) + + assert isinstance(response, FileResponse) + assert str(response.path).endswith("app.js") + assert "content-encoding" not in response.headers + assert response.headers["vary"] == "Accept-Encoding" From cacc11b7955c68b49439873a404e3385f6e3788c Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 14 Apr 2026 22:17:28 +0500 Subject: [PATCH 06/16] feat: add image optimization, CSS purging, and shared Vite plugin utilities - Extract shared walkFiles/outputDirectoryExists/validateFormats into vite-plugin-utils.js - Add vite-plugin-image-optimize for WebP/AVIF sidecar generation - Add vite-plugin-purgecss to strip unused CSS from radix-ui - Add bounded concurrency to compression plugin - Simplify format normalization: config validates, downstream just looks up - Fix sidecar copy only running when target HTML was actually created - Remove blank page lighthouse test, keep only landing page - Add integration test for full prod build pipeline (gz, purge, image opt) --- .../.templates/web/utils/helpers/upload.js | 3 +- .../reflex_base/.templates/web/utils/state.js | 3 +- .../.templates/web/vite-plugin-compress.js | 72 ++---- .../web/vite-plugin-image-optimize.js | 130 +++++++++++ .../.templates/web/vite-plugin-purgecss.js | 123 ++++++++++ .../.templates/web/vite-plugin-utils.js | 35 +++ .../src/reflex_base/compiler/templates.py | 64 ++++-- .../reflex-base/src/reflex_base/config.py | 61 +++-- .../src/reflex_base/constants/installer.py | 3 +- pyi_hashes.json | 2 - reflex/app.py | 1 + reflex/testing.py | 1 + reflex/utils/build.py | 6 +- reflex/utils/frontend_skeleton.py | 1 + reflex/utils/precompressed_staticfiles.py | 162 +++++++++---- scripts/run_lighthouse.py | 58 +---- tests/integration/lighthouse_utils.py | 153 ++++++++++--- tests/integration/test_lighthouse.py | 34 --- tests/integration/test_prod_build_pipeline.py | 214 ++++++++++++++++++ tests/units/compiler/test_compiler.py | 48 ++++ tests/units/test_config.py | 31 ++- tests/units/test_lighthouse_utils.py | 106 +++++++++ tests/units/test_prerequisites.py | 22 ++ .../utils/test_precompressed_staticfiles.py | 143 +++++++++++- 24 files changed, 1230 insertions(+), 246 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-image-optimize.js create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-purgecss.js create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-utils.js create mode 100644 tests/integration/test_prod_build_pipeline.py create mode 100644 tests/units/test_lighthouse_utils.py diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/upload.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/upload.js index 6d3d146c6c5..dbfcea88718 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/upload.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/upload.js @@ -1,4 +1,3 @@ -import JSON5 from "json5"; import env from "$/env.json"; /** @@ -45,7 +44,7 @@ export const uploadFiles = async ( // So only process _new_ chunks beyond resp_idx. chunks.slice(resp_idx).map((chunk_json) => { try { - const chunk = JSON5.parse(chunk_json); + const chunk = JSON.parse(chunk_json); event_callbacks.map((f, ix) => { f(chunk) .then(() => { diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index fd2b5e5741b..dc49762c4a4 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -1,6 +1,5 @@ // State management for Reflex web apps. import io from "socket.io-client"; -import JSON5 from "json5"; import env from "$/env.json"; import reflexEnvironment from "$/reflex.json"; import Cookies from "universal-cookie"; @@ -541,7 +540,7 @@ export const connect = async ( socket.current.io.encoder.replacer = (k, v) => (v === undefined ? null : v); socket.current.io.decoder.tryParse = (str) => { try { - return JSON5.parse(str); + return JSON.parse(str); } catch (e) { return false; } diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js index 12a7b3ddef9..a84d9166eef 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js @@ -6,9 +6,14 @@ */ import * as zlib from "node:zlib"; -import { dirname, join } from "node:path"; -import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; import { promisify } from "node:util"; +import { + validateFormats, + outputDirectoryExists, + walkFiles, +} from "./vite-plugin-utils.js"; const gzipAsync = promisify(zlib.gzip); const brotliAsync = @@ -45,40 +50,8 @@ const COMPRESSORS = { }, }; -function normalizeFormats(formats = ["gzip"]) { - const normalized = []; - const seen = new Set(); - - for (const format of formats) { - const normalizedFormat = String(format).trim().toLowerCase(); - if (!normalizedFormat || seen.has(normalizedFormat)) { - continue; - } - if (!(normalizedFormat in COMPRESSORS)) { - throw new Error( - `Unsupported frontend compression format "${format}". ` + - 'Expected one of: "gzip", "brotli", "zstd".', - ); - } - normalized.push(normalizedFormat); - seen.add(normalizedFormat); - } - - return normalized; -} - -async function* walkFiles(directory) { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const entryPath = join(directory, entry.name); - if (entry.isDirectory()) { - yield* walkFiles(entryPath); - continue; - } - if (entry.isFile()) { - yield entryPath; - } - } -} +// Concurrency limit for parallel file compression. +const CONCURRENCY = 16; function ensureFormatsSupported(formats) { const unavailableFormats = formats.filter( @@ -91,14 +64,6 @@ function ensureFormatsSupported(formats) { } } -async function outputDirectoryExists(outputDir) { - return Boolean( - await stat(outputDir).catch((error) => - error?.code === "ENOENT" ? null : Promise.reject(error), - ), - ); -} - async function compressFile(filePath, formats) { const raw = await readFile(filePath); if (raw.length < MIN_SIZE) return; @@ -116,20 +81,26 @@ async function compressFile(filePath, formats) { } export async function compressDirectory(directory, formats = ["gzip"]) { - const normalizedFormats = normalizeFormats(formats); - ensureFormatsSupported(normalizedFormats); + validateFormats(formats, COMPRESSORS, "frontend compression format"); + ensureFormatsSupported(formats); if (!(await outputDirectoryExists(directory))) { return; } - const jobs = []; + const pending = []; for await (const filePath of walkFiles(directory)) { if (!COMPRESSIBLE_EXTENSIONS.test(filePath)) continue; - jobs.push(compressFile(filePath, normalizedFormats)); + pending.push(filePath); } - await Promise.all(jobs); + for (let i = 0; i < pending.length; i += CONCURRENCY) { + await Promise.all( + pending + .slice(i, i + CONCURRENCY) + .map((file) => compressFile(file, formats)), + ); + } } /** @@ -138,7 +109,8 @@ export async function compressDirectory(directory, formats = ["gzip"]) { * @returns {import('vite').Plugin} */ export default function compressPlugin(options = {}) { - const formats = normalizeFormats(options.formats); + const formats = options.formats ?? ["gzip"]; + validateFormats(formats, COMPRESSORS, "frontend compression format"); return { name: "vite-plugin-compress", diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-image-optimize.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-image-optimize.js new file mode 100644 index 00000000000..955be3274c5 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-image-optimize.js @@ -0,0 +1,130 @@ +/* vite-plugin-image-optimize.js + * + * Generate optimized image format variants (WebP, AVIF) as sidecar files + * alongside originals during production builds. The server can then use + * Accept-header content negotiation to serve the best format to each client. + */ + +import { dirname, extname } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; +import { + validateFormats, + outputDirectoryExists, + walkFiles, +} from "./vite-plugin-utils.js"; + +const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|bmp|tiff?)$/i; + +// Skip images smaller than this — tiny icons/favicons rarely benefit. +const MIN_SIZE = 1024; + +// Limit parallel sharp operations to avoid memory pressure. +const CONCURRENCY = 8; + +const FORMAT_CONFIG = { + webp: { suffix: ".webp", sharpMethod: "webp" }, + avif: { suffix: ".avif", sharpMethod: "avif" }, +}; + +/** + * Process a single image file, generating optimized format variants. + * Never throws — errors for individual images are silently skipped. + */ +async function optimizeImage(sharp, filePath, formats, quality) { + let raw; + try { + raw = await readFile(filePath); + } catch { + return; + } + if (raw.length < MIN_SIZE) return; + + const stem = filePath.replace(/\.[^.]+$/, ""); + + await Promise.all( + formats.map(async (format) => { + const config = FORMAT_CONFIG[format]; + const outputPath = stem + config.suffix; + + try { + const result = await sharp(raw) + [config.sharpMethod]({ quality }) + .toBuffer(); + + if (result.length < raw.length) { + await writeFile(outputPath, result); + } + } catch { + // Skip this format on error (e.g. unsupported input). + } + }), + ); +} + +/** + * Process all images in a directory tree, with bounded concurrency. + */ +async function optimizeDirectory(sharp, directory, formats, quality) { + if (!(await outputDirectoryExists(directory))) return; + + const pending = []; + for await (const filePath of walkFiles(directory)) { + if (!IMAGE_EXTENSIONS.test(filePath)) continue; + + // Don't re-encode files that are already in a target format. + const ext = extname(filePath).toLowerCase(); + if (formats.some((f) => ext === FORMAT_CONFIG[f].suffix)) continue; + + pending.push(filePath); + } + + // Process in batches to bound concurrency. + for (let i = 0; i < pending.length; i += CONCURRENCY) { + await Promise.all( + pending + .slice(i, i + CONCURRENCY) + .map((file) => optimizeImage(sharp, file, formats, quality)), + ); + } +} + +/** + * Vite plugin that generates optimized image format variants for build assets. + * @param {{ formats?: string[], quality?: number }} [options] + * @returns {import('vite').Plugin} + */ +export default function imageOptimizePlugin(options = {}) { + const formats = options.formats ?? ["webp", "avif"]; + validateFormats(formats, FORMAT_CONFIG, "image optimization format"); + const quality = options.quality ?? 80; + + if (formats.length === 0) { + return { name: "vite-plugin-image-optimize", apply: "build" }; + } + + return { + name: "vite-plugin-image-optimize", + apply: "build", + enforce: "post", + + async writeBundle(outputOptions) { + let sharp; + try { + sharp = (await import("sharp")).default; + } catch { + console.warn( + "[vite-plugin-image-optimize] sharp is not available — skipping image optimization. " + + "Install it with: npm install -D sharp", + ); + return; + } + + const outputDir = + outputOptions.dir ?? + (outputOptions.file ? dirname(outputOptions.file) : null); + if (!outputDir) return; + + await optimizeDirectory(sharp, outputDir, formats, quality); + }, + }; +} diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-purgecss.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-purgecss.js new file mode 100644 index 00000000000..3167f353648 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-purgecss.js @@ -0,0 +1,123 @@ +/* vite-plugin-purgecss.js + * + * Remove unused CSS selectors from production bundles. The primary target + * is @radix-ui/themes/styles.css which weighs ~200 KB and includes styles + * for every Radix component, even ones the app never uses. + * + * How it works: + * 1. After Vite writes the bundle, collect JS asset contents as "content" + * for PurgeCSS to scan for referenced selectors. The radix-ui chunk + * is excluded because it contains string literals for ALL component + * class names (e.g. "rt-Button", "rt-Dialog") regardless of which + * components the app actually imports — including it defeats purging. + * 2. Run PurgeCSS on each CSS asset with a safelist that preserves + * CSS custom properties, theme tokens, and data-attribute selectors. + * 3. Overwrite the CSS assets with the purged output. + */ + +import { PurgeCSS } from "purgecss"; +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Vite plugin that purges unused CSS in production builds. + * @returns {import('vite').Plugin} + */ +export default function purgeCSSPlugin() { + return { + name: "vite-plugin-purgecss", + apply: "build", + enforce: "post", + + async writeBundle(outputOptions) { + const outputDir = outputOptions.dir; + if (!outputDir) return; + + // The output directory may not exist (e.g. React Router removes + // build/server when ssr is disabled). + if ( + !(await stat(outputDir).catch((e) => + e?.code === "ENOENT" ? null : Promise.reject(e), + )) + ) + return; + + const entries = await readdir(outputDir, { + withFileTypes: true, + recursive: true, + }); + + const jsContents = []; + const cssFiles = []; + + for (const entry of entries) { + if (!entry.isFile()) continue; + const fullPath = join(entry.parentPath ?? entry.path, entry.name); + + if (/\.(js|jsx|mjs)$/.test(entry.name)) { + // Skip the radix-ui chunk: it contains string literals for every + // component class name ("rt-Button", "rt-Dialog", …) which makes + // PurgeCSS think they are all in use. + if (/^radix-ui[.-]/.test(entry.name)) continue; + jsContents.push({ + raw: await readFile(fullPath, "utf-8"), + extension: "js", + }); + } else if (/\.css$/.test(entry.name)) { + cssFiles.push(fullPath); + } + } + + if (cssFiles.length === 0 || jsContents.length === 0) return; + + for (const cssPath of cssFiles) { + const originalCSS = await readFile(cssPath, "utf-8"); + // Skip tiny files -- not worth purging. + if (originalCSS.length < 4096) continue; + + const result = await new PurgeCSS().purge({ + content: jsContents, + css: [{ raw: originalCSS }], + safelist: { + standard: [ + // Radix Themes root and theme classes + /^\.radix-themes$/, + /^\.light/, + /^\.dark/, + // Keep the Reflex base layer reset styles + /^html$/, + /^body$/, + /^\*$/, + ], + // Deep patterns: keep entire rule blocks when selector matches + deep: [ + // CSS custom properties and tokens (--color-*, --space-*, etc.) + /^:root$/, + /^:where\(:root\)$/, + /^:where\(\.radix-themes\)$/, + ], + // Greedy patterns: keep selector if substring matches + greedy: [ + // Data attribute selectors used by Radix for component state + /data-/, + ], + }, + variables: true, + fontFace: true, + keyframes: true, + defaultExtractor: (content) => { + return content.match(/[\w-/:]+/g) || []; + }, + }); + + if (result.length > 0 && result[0].css) { + const purged = result[0].css; + // Only write if we actually removed something meaningful + if (purged.length < originalCSS.length * 0.98) { + await writeFile(cssPath, purged); + } + } + } + }, + }; +} diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-utils.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-utils.js new file mode 100644 index 00000000000..becadb79930 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-utils.js @@ -0,0 +1,35 @@ +/* vite-plugin-utils.js — Shared utilities for Reflex Vite plugins. */ + +import { join } from "node:path"; +import { readdir, stat } from "node:fs/promises"; + +export async function* walkFiles(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + yield* walkFiles(entryPath); + } else if (entry.isFile()) { + yield entryPath; + } + } +} + +export async function outputDirectoryExists(dir) { + return Boolean( + await stat(dir).catch((error) => + error?.code === "ENOENT" ? null : Promise.reject(error), + ), + ); +} + +/** + * Validate format names against a registry. Config already normalizes — + * this just guards against typos in direct callers of exported functions. + */ +export function validateFormats(formats, registry, label) { + for (const name of formats) { + if (!(name in registry)) { + throw new Error(`Unsupported ${label} "${name}".`); + } + } +} diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index fc1a63c7a05..20a982098e4 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -188,14 +188,29 @@ def app_root_template( custom_code_str = "\n".join(custom_codes) - import_window_libraries = "\n".join([ - f'import * as {lib_alias} from "{lib_path}";' - for lib_alias, lib_path in window_libraries - ]) - - window_imports_str = "\n".join([ - f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries - ]) + if window_libraries: + # Use dynamic imports to avoid blocking the critical rendering path. + # These libraries are only needed for dynamically eval'd components. + lazy_import_entries = ",\n ".join([ + f'import("{lib_path}").then(m => ["{lib_path}", m])' + for _lib_alias, lib_path in window_libraries + ]) + window_libraries_block = f""" + useEffect(() => {{ + if (!window["__reflex"]) {{ + Promise.all([ + {lazy_import_entries} + ]).then((modules) => {{ + const imports = {{}}; + for (const [path, mod] of modules) {{ + imports[path] = mod; + }} + window["__reflex"] = imports; + }}); + }} + }}, []);""" + else: + window_libraries_block = "" return f""" {imports_str} @@ -204,7 +219,6 @@ def app_root_template( import {{ ThemeProvider }} from '$/utils/react-theme'; import {{ Layout as AppLayout }} from './_document'; import {{ Outlet }} from 'react-router'; -{import_window_libraries} {custom_code_str} @@ -215,13 +229,7 @@ def app_root_template( export function Layout({{children}}) {{ - useEffect(() => {{ - // Make contexts and state objects available globally for dynamic eval'd components - let windowImports = {{ - {window_imports_str} - }}; - window["__reflex"] = windowImports; - }}, []); +{window_libraries_block} return jsx(AppLayout, {{}}, jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}}, @@ -488,6 +496,7 @@ def package_json_template( return json.dumps({ "name": "reflex", "type": "module", + "sideEffects": ["*.css"], "scripts": scripts, "dependencies": dependencies, "devDependencies": dev_dependencies, @@ -503,6 +512,7 @@ def vite_config_template( sourcemap: bool | Literal["inline", "hidden"], allowed_hosts: bool | list[str] = False, compression_formats: list[str] | None = None, + image_formats: list[str] | None = None, ): """Template for vite.config.js. @@ -514,6 +524,7 @@ def vite_config_template( sourcemap: The sourcemap configuration. allowed_hosts: Allow all hosts (True), specific hosts (list of strings), or only localhost (False). compression_formats: Build-time pre-compression formats to emit. + image_formats: Optimized image formats to generate (e.g. ["webp", "avif"]). Returns: Rendered vite.config.js content as string. @@ -528,7 +539,9 @@ def vite_config_template( import {{ reactRouter }} from "@react-router/dev/vite"; import {{ defineConfig }} from "vite"; import safariCacheBustPlugin from "./vite-plugin-safari-cachebust"; +import imageOptimizePlugin from "./vite-plugin-image-optimize"; import compressPlugin from "./vite-plugin-compress"; +import purgeCSSPlugin from "./vite-plugin-purgecss"; // Ensure that bun always uses the react-dom/server.node functions. function alwaysUseReactDomServerNode() {{ @@ -569,9 +582,12 @@ def vite_config_template( alwaysUseReactDomServerNode(), reactRouter(), safariCacheBustPlugin(), + imageOptimizePlugin({{ formats: {json.dumps(image_formats if image_formats is not None else ["webp", "avif"])}, quality: 80 }}), compressPlugin({{ formats: {json.dumps(compression_formats if compression_formats is not None else ["gzip"])} }}), + ...(config.mode === "production" ? [purgeCSSPlugin()] : []), ].concat({"[fullReload()]" if force_full_reload else "[]"}), build: {{ + target: "es2022", assetsDir: "{base}assets".slice(1), sourcemap: {"true" if sourcemap is True else "false" if sourcemap is False else repr(sourcemap)}, rollupOptions: {{ @@ -592,8 +608,20 @@ def vite_config_template( name: "socket-io", }}, {{ - test: /node_modules\/@radix-ui/, - name: "radix-ui", + test: /node_modules\/@mantine/, + name: "mantine", + }}, + {{ + test: /node_modules\/lucide-react/, + name: "lucide-icons", + }}, + {{ + test: /node_modules\/react-helmet/, + name: "react-helmet", + }}, + {{ + test: /node_modules\/recharts|node_modules\/d3-/, + name: "recharts", }}, ], }}, diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index ecfc774fcab..d55ee2d92a8 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -168,6 +168,7 @@ class BaseConfig: vite_allowed_hosts: Allowed hosts for the Vite dev server. Set to True to allow all hosts, or provide a list of hostnames (e.g. ["myservice.local"]) to allow specific ones. Prevents 403 errors in Docker, Codespaces, reverse proxies, etc. react_strict_mode: Whether to use React strict mode. frontend_compression_formats: Pre-compressed frontend asset formats to generate for production builds. Supported values are "gzip", "brotli", and "zstd". Use an empty list to disable build-time pre-compression. + frontend_image_formats: Optimized image formats to generate as sidecar files alongside originals during production builds. Supported values are "webp" and "avif". The server negotiates the ``Accept`` header and serves the best variant. Use an empty list to disable. frontend_packages: Additional frontend packages to install. state_manager_mode: Indicate which type of state manager to use. redis_lock_expiration: Maximum expiration lock time for redis state manager. @@ -227,6 +228,11 @@ class BaseConfig: SequenceOptions(delimiter=",", strip=True), ] = dataclasses.field(default_factory=lambda: ["gzip"]) + frontend_image_formats: Annotated[ + list[str], + SequenceOptions(delimiter=",", strip=True), + ] = dataclasses.field(default_factory=lambda: ["webp", "avif"]) + frontend_packages: list[str] = dataclasses.field(default_factory=list) state_manager_mode: constants.StateManagerMode = constants.StateManagerMode.DISK @@ -311,7 +317,7 @@ class Config(BaseConfig): - **App Settings**: `app_name`, `loglevel`, `telemetry_enabled` - **Server**: `frontend_port`, `backend_port`, `api_url`, `cors_allowed_origins` - **Database**: `db_url`, `async_db_url`, `redis_url` - - **Frontend**: `frontend_packages`, `react_strict_mode`, `frontend_compression_formats` + - **Frontend**: `frontend_packages`, `react_strict_mode`, `frontend_compression_formats`, `frontend_image_formats` - **State Management**: `state_manager_mode`, `state_auto_setters` - **Plugins**: `plugins`, `disable_plugins` @@ -352,6 +358,7 @@ def _post_init(self, **kwargs): setattr(self, key, env_value) self._normalize_frontend_compression_formats() + self._normalize_frontend_image_formats() # Normalize disable_plugins: convert strings and Plugin subclasses to instances. self._normalize_disable_plugins() @@ -423,31 +430,59 @@ def _normalize_disable_plugins(self): ) self.disable_plugins = normalized - def _normalize_frontend_compression_formats(self): - """Normalize and validate configured frontend compression formats. + @staticmethod + def _normalize_format_list( + formats: list[str], + supported: set[str], + config_key: str, + ) -> list[str]: + """Normalize, deduplicate, and validate a list of format names. + + Args: + formats: The raw format names from config. + supported: Set of valid format names. + config_key: Config field name for error messages. + + Returns: + Normalized list of valid format names. Raises: - ConfigError: If an unsupported format is configured. + ConfigError: If an unsupported format is found. """ - supported_formats = {"brotli", "gzip", "zstd"} - normalized = [] - seen = set() + normalized: list[str] = [] + seen: set[str] = set() - for format_name in self.frontend_compression_formats: + for format_name in formats: normalized_name = format_name.strip().lower() if not normalized_name or normalized_name in seen: continue - if normalized_name not in supported_formats: - supported = ", ".join(sorted(supported_formats)) + if normalized_name not in supported: + supported_str = ", ".join(sorted(supported)) msg = ( - "frontend_compression_formats contains unsupported format " - f"{format_name!r}. Expected one of: {supported}." + f"{config_key} contains unsupported format " + f"{format_name!r}. Expected one of: {supported_str}." ) raise ConfigError(msg) normalized.append(normalized_name) seen.add(normalized_name) - self.frontend_compression_formats = normalized + return normalized + + def _normalize_frontend_compression_formats(self): + """Normalize and validate configured frontend compression formats.""" + self.frontend_compression_formats = self._normalize_format_list( + self.frontend_compression_formats, + {"brotli", "gzip", "zstd"}, + "frontend_compression_formats", + ) + + def _normalize_frontend_image_formats(self): + """Normalize and validate configured frontend image formats.""" + self.frontend_image_formats = self._normalize_format_list( + self.frontend_image_formats, + {"avif", "webp"}, + "frontend_image_formats", + ) def _add_builtin_plugins(self): """Add the builtin plugins to the config.""" diff --git a/packages/reflex-base/src/reflex_base/constants/installer.py b/packages/reflex-base/src/reflex_base/constants/installer.py index 5fa2b5b561b..9f7315a4826 100644 --- a/packages/reflex-base/src/reflex_base/constants/installer.py +++ b/packages/reflex-base/src/reflex_base/constants/installer.py @@ -136,7 +136,6 @@ def DEPENDENCIES(cls) -> dict[str, str]: A dictionary of dependencies with their versions. """ return { - "json5": "2.2.3", "react-router": cls._react_router_version, "react-router-dom": cls._react_router_version, "@react-router/node": cls._react_router_version, @@ -154,6 +153,8 @@ def DEPENDENCIES(cls) -> dict[str, str]: "autoprefixer": "10.4.27", "postcss": "8.5.8", "postcss-import": "16.1.1", + "purgecss": "7.0.2", + "sharp": "0.34.5", "@react-router/dev": _react_router_version, "@react-router/fs-routes": _react_router_version, "vite": "8.0.0", diff --git a/pyi_hashes.json b/pyi_hashes.json index f993d2dd7c4..7b2a6e0adec 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,6 +1,4 @@ { - "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "4b34eca0e7338ec80ac5985345717bc9", - "packages/reflex-components-radix/src/reflex_components_radix/themes/color_mode.pyi": "3a419f78071b0dd6be55dc55e7334a1b", "reflex/__init__.pyi": "5de3d4af8ea86e9755f622510b868196", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "c10cbc554fe2ffdb3a008b59bc503936" diff --git a/reflex/app.py b/reflex/app.py index 39941211a41..60f03561b9a 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -656,6 +656,7 @@ def __call__(self) -> ASGIApp: / config.frontend_path.strip("/"), html=True, encodings=config.frontend_compression_formats, + image_formats=config.frontend_image_formats, ), name="frontend", ) diff --git a/reflex/testing.py b/reflex/testing.py index 056bb1080ff..5823a5abc20 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -826,6 +826,7 @@ def _run_frontend(self): directory=web_root / config.frontend_path.strip("/"), html=True, encodings=config.frontend_compression_formats, + image_formats=config.frontend_image_formats, ), name="frontend", ) diff --git a/reflex/utils/build.py b/reflex/utils/build.py index d9a1f38c5f5..178baf96125 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -185,9 +185,9 @@ def _duplicate_index_html_to_parent_directory( if not target.exists(): console.debug(f"Copying {index_html} to {target}") path_ops.cp(index_html, target) + _copy_precompressed_sidecars(index_html, target, suffixes) else: console.debug(f"Skipping {index_html}, already exists at {target}") - _copy_precompressed_sidecars(index_html, target, suffixes) # Recursively call this function for the child directory. _duplicate_index_html_to_parent_directory(child, suffixes) @@ -252,9 +252,7 @@ def build(): config = get_config() sidecar_suffixes = tuple( - _SUPPORTED_ENCODINGS[fmt].suffix - for fmt in config.frontend_compression_formats - if fmt in _SUPPORTED_ENCODINGS + _SUPPORTED_ENCODINGS[fmt].suffix for fmt in config.frontend_compression_formats ) _duplicate_index_html_to_parent_directory( diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index caeefefab0b..7e736e7165a 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -259,6 +259,7 @@ def _compile_vite_config(config: Config): sourcemap=environment.VITE_SOURCEMAP.get(), allowed_hosts=config.vite_allowed_hosts, compression_formats=config.frontend_compression_formats, + image_formats=config.frontend_image_formats, ) diff --git a/reflex/utils/precompressed_staticfiles.py b/reflex/utils/precompressed_staticfiles.py index 7001a84cf8e..bfa3e3218f9 100644 --- a/reflex/utils/precompressed_staticfiles.py +++ b/reflex/utils/precompressed_staticfiles.py @@ -5,10 +5,11 @@ import errno import os import stat -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from mimetypes import guess_type from pathlib import Path +from typing import TypeVar from anyio import to_thread from starlette.datastructures import URL, Headers @@ -46,45 +47,59 @@ class _EncodingFormat: } -def _normalize_encoding_formats(formats: Sequence[str]) -> tuple[_EncodingFormat, ...]: - """Normalize configured encoding names to supported sidecar formats. +@dataclass(frozen=True, slots=True) +class _ImageFormat: + """Mapping between an image format and its HTTP/static-file details.""" + + name: str + media_type: str + suffix: str + + +_SUPPORTED_IMAGE_FORMATS = { + "webp": _ImageFormat(name="webp", media_type="image/webp", suffix=".webp"), + "avif": _ImageFormat(name="avif", media_type="image/avif", suffix=".avif"), +} + +# Extensions of image files that can have optimized format variants. +_OPTIMIZABLE_IMAGE_EXTENSIONS = frozenset({ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".tif", + ".tiff", +}) + + +_T = TypeVar("_T") + + +def _resolve_formats( + names: Sequence[str], + registry: Mapping[str, _T], +) -> tuple[_T, ...]: + """Look up format objects by name. Config already validates inputs. Args: - formats: The configured compression format names. + names: Validated format names from config. + registry: Mapping of format name to format object. Returns: - The normalized supported sidecar encodings in configured order. - - Raises: - ValueError: If an unknown format is configured. + Format objects in the configured order. """ - normalized_formats = [] - seen = set() - for format_name in formats: - normalized_name = format_name.strip().lower() - if not normalized_name or normalized_name in seen: - continue - encoding = _SUPPORTED_ENCODINGS.get(normalized_name) - if encoding is None: - supported = ", ".join(sorted(_SUPPORTED_ENCODINGS)) - msg = ( - f"Unsupported frontend compression format {format_name!r}. " - f"Expected one of: {supported}." - ) - raise ValueError(msg) - normalized_formats.append(encoding) - seen.add(normalized_name) - return tuple(normalized_formats) + return tuple(registry[n] for n in names if n in registry) -def _parse_accept_encoding(header_value: str | None) -> dict[str, float]: - """Parse an ``Accept-Encoding`` header into quality values. +def _parse_quality_header(header_value: str | None) -> dict[str, float]: + """Parse a ``token;q=value`` HTTP header (Accept, Accept-Encoding, etc.). Args: - header_value: The raw ``Accept-Encoding`` header value. + header_value: The raw header value. Returns: - A mapping of accepted encodings to their quality values. + A mapping of tokens to their quality values. """ if not header_value: return {} @@ -92,8 +107,8 @@ def _parse_accept_encoding(header_value: str | None) -> dict[str, float]: parsed: dict[str, float] = {} for entry in header_value.split(","): token, *params = entry.split(";") - encoding = token.strip().lower() - if not encoding: + token = token.strip().lower() + if not token: continue quality = 1.0 @@ -107,7 +122,7 @@ def _parse_accept_encoding(header_value: str | None) -> dict[str, float]: quality = 0.0 break - parsed[encoding] = max(parsed.get(encoding, 0.0), quality) + parsed[token] = max(parsed.get(token, 0.0), quality) return parsed @@ -118,6 +133,7 @@ def __init__( self, *args, encodings: Sequence[str] = (), + image_formats: Sequence[str] = (), **kwargs, ): """Initialize the static file server. @@ -125,10 +141,13 @@ def __init__( Args: *args: Passed through to ``StaticFiles``. encodings: Ordered list of supported precompressed formats. + image_formats: Ordered list of optimized image formats to negotiate. **kwargs: Passed through to ``StaticFiles``. """ super().__init__(*args, **kwargs) - self._encodings = _normalize_encoding_formats(encodings) + self._encodings = _resolve_formats(encodings, _SUPPORTED_ENCODINGS) + self._encoding_suffixes = tuple(fmt.suffix for fmt in self._encodings) + self._image_formats = _resolve_formats(image_formats, _SUPPORTED_IMAGE_FORMATS) def _find_precompressed_variant_sync( self, @@ -169,6 +188,47 @@ def _find_precompressed_variant_sync( return best_match + def _find_image_format_variant_sync( + self, + path: str, + accepted_types: dict[str, float], + ) -> tuple[_ImageFormat, str, os.stat_result] | None: + """Select the best matching optimized image variant for a request path. + + This performs blocking filesystem lookups and must be called via + ``to_thread.run_sync`` from async contexts. + + Args: + path: The requested relative file path. + accepted_types: Parsed Accept header quality values. + + Returns: + The selected image format, file path, and stat result, or ``None``. + """ + best_match = None + best_quality = 0.0 + + # Strip the original extension to build sidecar paths. + stem, _dot, _ext = path.rpartition(".") + + for fmt in self._image_formats: + quality = accepted_types.get(fmt.media_type, accepted_types.get("*/*", 0.0)) + if quality <= 0: + continue + + sidecar_path = f"{stem}{fmt.suffix}" + full_path, stat_result = self.lookup_path(sidecar_path) + if stat_result is None or not stat.S_ISREG(stat_result.st_mode): + continue + + if quality > best_quality: + best_match = (fmt, full_path, stat_result) + best_quality = quality + if best_quality >= 1.0: + break + + return best_match + async def _build_file_response( self, *, @@ -195,11 +255,34 @@ async def _build_file_response( response_path = full_path response_stat = stat_result media_type = None - - if self._encodings and not any( - path.endswith(fmt.suffix) for fmt in self._encodings + vary_parts: list[str] = [] + + # Image format negotiation via Accept header. + if self._image_formats: + ext = Path(path).suffix + if ext.lower() in _OPTIMIZABLE_IMAGE_EXTENSIONS: + accepted_types = _parse_quality_header(request_headers.get("accept")) + if accepted_types: + matched_image = await to_thread.run_sync( + lambda: self._find_image_format_variant_sync( + path, accepted_types + ) + ) + if matched_image: + fmt, response_path, response_stat = matched_image + media_type = fmt.media_type + vary_parts.append("Accept") + + # Encoding negotiation via Accept-Encoding header. + # Skip if image format negotiation already changed the response — the + # precompressed sidecars are keyed to the original path, and modern + # image formats (WebP, AVIF) are already compressed. + if ( + self._encodings + and media_type is None + and not path.endswith(self._encoding_suffixes) ): - accepted_encodings = _parse_accept_encoding( + accepted_encodings = _parse_quality_header( request_headers.get("accept-encoding") ) if accepted_encodings: @@ -214,7 +297,10 @@ async def _build_file_response( media_type = guess_type(path)[0] or "text/plain" if self._encodings: - response_headers["Vary"] = "Accept-Encoding" + vary_parts.append("Accept-Encoding") + + if vary_parts: + response_headers["Vary"] = ", ".join(vary_parts) response = FileResponse( response_path, diff --git a/scripts/run_lighthouse.py b/scripts/run_lighthouse.py index 84ffdf658eb..a61ce3bda42 100644 --- a/scripts/run_lighthouse.py +++ b/scripts/run_lighthouse.py @@ -2,71 +2,31 @@ from __future__ import annotations -import contextlib -import io import shutil -from collections.abc import Callable from pathlib import Path from tests.integration.lighthouse_utils import ( - LIGHTHOUSE_APP_NAME, LIGHTHOUSE_LANDING_APP_NAME, - LighthouseBenchmarkResult, - run_blank_prod_lighthouse_benchmark, run_landing_prod_lighthouse_benchmark, ) -def _run_benchmark( - run_fn: Callable[..., LighthouseBenchmarkResult], - app_root: Path, - report_path: Path, -) -> LighthouseBenchmarkResult: - """Run a single benchmark, suppressing internal output. - - Returns: - The benchmark result. - """ - shutil.rmtree(app_root, ignore_errors=True) - stdout_buffer = io.StringIO() - stderr_buffer = io.StringIO() - with ( - contextlib.redirect_stdout(stdout_buffer), - contextlib.redirect_stderr(stderr_buffer), - ): - return run_fn(app_root=app_root, report_path=report_path) - - def main() -> int: - """Run the Lighthouse benchmarks and print compact summaries. + """Run the Lighthouse benchmark and print a compact summary. Returns: The process exit code. """ report_dir = Path(".states") / "lighthouse" - all_failures = [] - - benchmarks = [ - ( - LIGHTHOUSE_APP_NAME, - run_blank_prod_lighthouse_benchmark, - report_dir / "blank-prod-lighthouse.json", - ), - ( - LIGHTHOUSE_LANDING_APP_NAME, - run_landing_prod_lighthouse_benchmark, - report_dir / "landing-prod-lighthouse.json", - ), - ] - - for name, run_fn, report_path in benchmarks: - app_root = Path(".states") / name - result = _run_benchmark(run_fn, app_root, report_path) - print(result.summary) # noqa: T201 - print() # noqa: T201 - all_failures.extend(result.failures) + app_root = Path(".states") / LIGHTHOUSE_LANDING_APP_NAME + shutil.rmtree(app_root, ignore_errors=True) - return 1 if all_failures else 0 + result = run_landing_prod_lighthouse_benchmark( + app_root=app_root, + report_path=report_dir / "landing-prod-lighthouse.json", + ) + print(result.summary) # noqa: T201 + return 1 if result.failures else 0 if __name__ == "__main__": diff --git a/tests/integration/lighthouse_utils.py b/tests/integration/lighthouse_utils.py index 06c24818795..74a99d0e1f5 100644 --- a/tests/integration/lighthouse_utils.py +++ b/tests/integration/lighthouse_utils.py @@ -12,8 +12,10 @@ import time import urllib.request from dataclasses import dataclass +from functools import cache from pathlib import Path from typing import Any +from urllib.parse import urlsplit, urlunsplit import pytest @@ -24,6 +26,8 @@ LIGHTHOUSE_COMMAND_ENV_VAR = "REFLEX_LIGHTHOUSE_COMMAND" LIGHTHOUSE_CHROME_PATH_ENV_VAR = "REFLEX_LIGHTHOUSE_CHROME_PATH" LIGHTHOUSE_CLI_PACKAGE = "lighthouse@13.1.0" +LIGHTHOUSE_COMMAND_PREP_TIMEOUT_SECONDS = 300 +LIGHTHOUSE_RUN_TIMEOUT_SECONDS = 300 TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"} LIGHTHOUSE_CATEGORY_THRESHOLDS = { "performance": 0.9, @@ -32,7 +36,6 @@ "seo": 0.9, } LIGHTHOUSE_CATEGORIES = tuple(LIGHTHOUSE_CATEGORY_THRESHOLDS) -LIGHTHOUSE_APP_NAME = "lighthouse_blank" LIGHTHOUSE_LANDING_APP_NAME = "lighthouse_landing" LANDING_PAGE_SOURCE = '''\ @@ -692,12 +695,110 @@ def get_lighthouse_command() -> list[str]: return ["lighthouse"] if shutil.which("npx") is not None: return ["npx", "--yes", LIGHTHOUSE_CLI_PACKAGE] + if shutil.which("pnpx") is not None: + return ["pnpx", LIGHTHOUSE_CLI_PACKAGE] pytest.skip( "Lighthouse CLI is unavailable. " - f"Install `lighthouse`, make `npx` available, or set {LIGHTHOUSE_COMMAND_ENV_VAR}." + "Install `lighthouse`, make `npx` or `pnpx` available, " + f"or set {LIGHTHOUSE_COMMAND_ENV_VAR}." ) +def _format_subprocess_output(output: str | bytes | None) -> str: + """Normalize subprocess output for failure messages. + + Args: + output: The captured subprocess output. + + Returns: + The output as a decoded string. + """ + if output is None: + return "" + if isinstance(output, bytes): + return output.decode(errors="replace") + return output + + +@cache +def _prepare_lighthouse_command(command: tuple[str, ...]) -> tuple[str, ...]: + """Warm package-runner-based Lighthouse commands before the benchmark. + + Args: + command: The Lighthouse command prefix. + + Returns: + The original command prefix. + """ + if not command or command[0] not in {"npx", "pnpx"}: + return command + + prepare_command = [*command, "--version"] + try: + subprocess.run( + prepare_command, + check=True, + capture_output=True, + text=True, + timeout=LIGHTHOUSE_COMMAND_PREP_TIMEOUT_SECONDS, + ) + except subprocess.CalledProcessError as err: + pytest.fail( + "Lighthouse CLI preparation failed. " + "If Lighthouse is not already installed, make sure the npm registry " + f"is reachable or set {LIGHTHOUSE_COMMAND_ENV_VAR} to an installed CLI.\n" + f"Command: {' '.join(prepare_command)}\n" + f"stdout:\n{_format_subprocess_output(err.stdout)}\n" + f"stderr:\n{_format_subprocess_output(err.stderr)}" + ) + except subprocess.TimeoutExpired as err: + pytest.fail( + "Lighthouse CLI preparation timed out. " + "If Lighthouse is not already installed, make sure the npm registry " + f"is reachable or set {LIGHTHOUSE_COMMAND_ENV_VAR} to an installed CLI.\n" + f"Command: {' '.join(prepare_command)}\n" + f"stdout:\n{_format_subprocess_output(err.stdout)}\n" + f"stderr:\n{_format_subprocess_output(err.stderr)}" + ) + + return command + + +def _get_lighthouse_target_url(url: str) -> str: + """Convert bind-all URLs into loopback URLs that browser clients can reach. + + Args: + url: The reported frontend URL. + + Returns: + A client-reachable URL for Lighthouse. + """ + parsed = urlsplit(url) + replacement_host = { + "0.0.0.0": "127.0.0.1", + "::": "::1", + }.get(parsed.hostname or "") + if replacement_host is None: + return url + + auth = "" + if parsed.username is not None: + auth = parsed.username + if parsed.password is not None: + auth += f":{parsed.password}" + auth += "@" + + host = replacement_host + if ":" in host: + host = f"[{host}]" + + netloc = f"{auth}{host}" + if parsed.port is not None: + netloc = f"{netloc}:{parsed.port}" + + return urlunsplit(parsed._replace(netloc=netloc)) + + def get_chrome_path() -> str: """Resolve the Chromium executable used by Lighthouse. @@ -792,7 +893,7 @@ def run_lighthouse(url: str, report_path: Path) -> dict[str, Any]: The parsed Lighthouse JSON report. """ command = [ - *get_lighthouse_command(), + *_prepare_lighthouse_command(tuple(get_lighthouse_command())), url, "--output=json", f"--output-path={report_path}", @@ -808,14 +909,21 @@ def run_lighthouse(url: str, report_path: Path) -> dict[str, Any]: check=True, capture_output=True, text=True, - timeout=180, + timeout=LIGHTHOUSE_RUN_TIMEOUT_SECONDS, ) except subprocess.CalledProcessError as err: pytest.fail( "Lighthouse execution failed.\n" f"Command: {' '.join(command)}\n" - f"stdout:\n{err.stdout}\n" - f"stderr:\n{err.stderr}" + f"stdout:\n{_format_subprocess_output(err.stdout)}\n" + f"stderr:\n{_format_subprocess_output(err.stderr)}" + ) + except subprocess.TimeoutExpired as err: + pytest.fail( + "Lighthouse execution timed out.\n" + f"Command: {' '.join(command)}\n" + f"stdout:\n{_format_subprocess_output(err.stdout)}\n" + f"stderr:\n{_format_subprocess_output(err.stderr)}" ) return json.loads(report_path.read_text()) @@ -905,21 +1013,26 @@ def _run_prod_lighthouse_benchmark( f"Captured output:\n{output}" ) + benchmark_url = _get_lighthouse_target_url(frontend_url) + # Warmup request: ensure the server is fully ready before benchmarking. warmup_deadline = time.monotonic() + 30 while time.monotonic() < warmup_deadline: try: - urllib.request.urlopen(frontend_url, timeout=5) + urllib.request.urlopen(benchmark_url, timeout=5) break except Exception: time.sleep(0.5) else: proc.terminate() proc.wait(timeout=10) - pytest.fail(f"Warmup request to {frontend_url} never succeeded for {label}") + pytest.fail( + f"Warmup request to {benchmark_url} " + f"(reported as {frontend_url}) never succeeded for {label}" + ) try: - report = run_lighthouse(frontend_url, report_path) + report = run_lighthouse(benchmark_url, report_path) finally: proc.terminate() try: @@ -942,28 +1055,6 @@ def _run_prod_lighthouse_benchmark( ) -def run_blank_prod_lighthouse_benchmark( - app_root: Path, - report_path: Path, -) -> LighthouseBenchmarkResult: - """Run Lighthouse against the stock blank Reflex app in prod mode. - - Args: - app_root: The app root to initialize or reuse. - report_path: Where to save the Lighthouse JSON report. - - Returns: - A structured benchmark result. - """ - _ensure_lighthouse_app(app_root, LIGHTHOUSE_APP_NAME) - return _run_prod_lighthouse_benchmark( - app_root=app_root, - app_name=LIGHTHOUSE_APP_NAME, - report_path=report_path, - label="blank prod app", - ) - - def run_landing_prod_lighthouse_benchmark( app_root: Path, report_path: Path, diff --git a/tests/integration/test_lighthouse.py b/tests/integration/test_lighthouse.py index 1b48e3a1540..fc89c6c4587 100644 --- a/tests/integration/test_lighthouse.py +++ b/tests/integration/test_lighthouse.py @@ -7,7 +7,6 @@ import pytest from .lighthouse_utils import ( - run_blank_prod_lighthouse_benchmark, run_landing_prod_lighthouse_benchmark, should_run_lighthouse, ) @@ -18,39 +17,6 @@ ) -@pytest.fixture(scope="module") -def lighthouse_app_root( - tmp_path_factory: pytest.TempPathFactory, -) -> Path: - """Get the app root for the Lighthouse benchmark. - - Args: - tmp_path_factory: Pytest helper for allocating temporary directories. - - Returns: - The app root path for the benchmark app. - """ - return tmp_path_factory.mktemp("lighthouse_blank_app") - - -def test_blank_template_lighthouse_scores( - lighthouse_app_root: Path, - tmp_path: Path, -): - """Assert that the stock prod app stays in the 90s across Lighthouse categories.""" - result = run_blank_prod_lighthouse_benchmark( - app_root=lighthouse_app_root, - report_path=tmp_path / "blank-prod-lighthouse.json", - ) - print(result.summary) - - if result.failures: - pytest.fail( - "Lighthouse thresholds not met. See score summary above.", - pytrace=False, - ) - - @pytest.fixture(scope="module") def lighthouse_landing_app_root( tmp_path_factory: pytest.TempPathFactory, diff --git a/tests/integration/test_prod_build_pipeline.py b/tests/integration/test_prod_build_pipeline.py new file mode 100644 index 00000000000..c964b11b0e8 --- /dev/null +++ b/tests/integration/test_prod_build_pipeline.py @@ -0,0 +1,214 @@ +"""Integration tests for the production build pipeline. + +Tests precompressed assets, image optimization, and CSS purging +against a real prod build served via AppHarnessProd. +""" + +from __future__ import annotations + +import struct +import zlib +from collections.abc import Generator +from http.client import HTTPConnection +from pathlib import Path +from urllib.parse import urlsplit + +import pytest + +from reflex.testing import AppHarness, AppHarnessProd + + +def _make_test_png(min_size: int = 2048) -> bytes: + """Build a valid PNG that is at least *min_size* bytes. + + Returns: + The PNG file bytes. + """ + ihdr_data = b"IHDR" + struct.pack(">II", 2, 2) + b"\x08\x02\x00\x00\x00" + ihdr = ( + struct.pack(">I", 13) + + ihdr_data + + struct.pack(">I", zlib.crc32(ihdr_data) & 0xFFFFFFFF) + ) + + raw_rows = b"\x00\xff\x00\x00\x00\xff\x00" * 2 + compressed = zlib.compress(raw_rows) + idat_data = b"IDAT" + compressed + idat = ( + struct.pack(">I", len(compressed)) + + idat_data + + struct.pack(">I", zlib.crc32(idat_data) & 0xFFFFFFFF) + ) + + iend = b"\x00\x00\x00\x00IEND\xaeB`\x82" + png = b"\x89PNG\r\n\x1a\n" + ihdr + idat + iend + + if len(png) < min_size: + text_payload = b"tEXtComment\x00" + b"A" * (min_size - len(png) - 12) + text_chunk = ( + struct.pack(">I", len(text_payload)) + + text_payload + + struct.pack(">I", zlib.crc32(text_payload) & 0xFFFFFFFF) + ) + png = png[:-12] + text_chunk + iend + return png + + +def ProdBuildPipelineApp(): + """A minimal app with an image asset for build pipeline testing.""" + import reflex as rx + + app = rx.App() + + @app.add_page + def index(): + return rx.el.main( + rx.heading("Build Pipeline Test"), + rx.text("Hello"), + rx.image(src="/test_image.png", alt="test"), + ) + + +def _request_raw( + url: str, + path: str, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, str], bytes]: + """Send a raw HTTP request without client-side decompression. + + Returns: + The status code, response headers, and raw body. + """ + parsed = urlsplit(url) + assert parsed.hostname is not None + conn = HTTPConnection(parsed.hostname, parsed.port, timeout=10) + conn.request("GET", path, headers=headers or {}) + resp = conn.getresponse() + body = resp.read() + hdrs = {k.lower(): v for k, v in resp.getheaders()} + status = resp.status + conn.close() + return status, hdrs, body + + +@pytest.fixture(scope="module") +def prod_app( + app_harness_env: type[AppHarness], + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Build and serve the test app in production mode. + + Yields: + A running production app harness. + """ + if app_harness_env is not AppHarnessProd: + pytest.skip("build pipeline checks are prod-only") + + root = tmp_path_factory.mktemp("prod_build_pipeline") + harness = app_harness_env.create( + root=root, + app_name="prod_build_pipeline", + app_source=ProdBuildPipelineApp, + ) + # Initialize the app (creates .web/public/ etc.) but don't start yet. + harness._initialize_app() + + # Place a test PNG in .web/public/ so the Vite build picks it up. + # (Reflex serves `assets/` via the backend at runtime, but the Vite + # image-optimize plugin only processes files inside the build tree.) + import reflex.utils.prerequisites as prerequisites + + public_dir = root / prerequisites.get_web_dir() / "public" + public_dir.mkdir(exist_ok=True) + (public_dir / "test_image.png").write_bytes(_make_test_png()) + + # Now run the rest of startup (backend, build, frontend server). + harness._start_backend() + harness._start_frontend() + harness._wait_frontend() + try: + yield harness + finally: + harness.stop() + + +def _find_build_files(harness: AppHarness, pattern: str) -> list[Path]: + """Find files matching a glob pattern in the prod build output. + + Returns: + Sorted list of matching paths. + """ + import reflex.constants as constants + import reflex.utils.prerequisites as prerequisites + + static_dir = harness.app_path / prerequisites.get_web_dir() / constants.Dirs.STATIC + return sorted(static_dir.rglob(pattern)) + + +# -- Precompressed assets -- + + +def test_js_bundles_have_gz_sidecars(prod_app: AppHarness): + """Production JS bundles should have .gz sidecar files.""" + assert _find_build_files(prod_app, "**/*.js"), "No JS files in build" + assert _find_build_files(prod_app, "**/*.js.gz"), "No .js.gz sidecars found" + + +def test_css_bundles_have_gz_sidecars(prod_app: AppHarness): + """Production CSS bundles should have .gz sidecar files.""" + assert _find_build_files(prod_app, "**/*.css"), "No CSS files in build" + assert _find_build_files(prod_app, "**/*.css.gz"), "No .css.gz sidecars found" + + +def test_gzip_content_negotiation(prod_app: AppHarness): + """Server should return gzip-encoded response when client accepts it.""" + assert prod_app.frontend_url is not None + # Request a JS bundle rather than / since HTML may be small + js_files = _find_build_files(prod_app, "assets/**/*.js") + assert js_files, "No JS bundles to test" + + # Get the relative path from the static dir to use as URL path + import reflex.constants as constants + import reflex.utils.prerequisites as prerequisites + + static_dir = prod_app.app_path / prerequisites.get_web_dir() / constants.Dirs.STATIC + js_path = "/" + str(js_files[0].relative_to(static_dir)) + + status, headers, body = _request_raw( + prod_app.frontend_url, + js_path, + headers={"Accept-Encoding": "gzip"}, + ) + assert status == 200 + assert headers.get("content-encoding") == "gzip" + assert body[:2] == b"\x1f\x8b" + + +# -- CSS purging -- + + +def test_css_was_purged(prod_app: AppHarness): + """Total CSS should be well under the raw radix-ui CSS (~200KB).""" + css_files = _find_build_files(prod_app, "**/*.css") + assert css_files, "No CSS files in build" + + total = sum(f.stat().st_size for f in css_files) + assert total < 150_000, f"Total CSS is {total} bytes — PurgeCSS may not be running" + + +# -- Image optimization -- + + +def test_png_has_webp_sidecar(prod_app: AppHarness): + """PNG assets should produce WebP sidecars after the build.""" + png_files = _find_build_files(prod_app, "**/test_image.png") + if not png_files: + pytest.skip( + "test_image.png not in build output — Vite may have " + "fingerprinted or inlined the asset" + ) + + webp_files = _find_build_files(prod_app, "**/test_image.webp") + assert webp_files, ( + "No test_image.webp sidecar — image optimization may not be running" + ) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 218ae1de6d2..637a8d22c9c 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1,10 +1,12 @@ import importlib.util import os +import re from pathlib import Path import pytest from pytest_mock import MockerFixture from reflex_base import constants +from reflex_base.compiler.templates import vite_config_template from reflex_base.constants.compiler import PageNames from reflex_base.utils.imports import ImportVar, ParsedImportDict from reflex_base.vars.base import Var @@ -448,3 +450,49 @@ def test_create_document_root_with_meta_viewport(): assert str(root.children[0].children[2].name) == '"viewport"' # pyright: ignore [reportAttributeAccessIssue] assert str(root.children[0].children[2].content) == '"foo"' # pyright: ignore [reportAttributeAccessIssue] assert str(root.children[0].children[3].char_set) == '"utf-8"' # pyright: ignore [reportAttributeAccessIssue] + + +class TestViteConfigChunking: + """Tests for Vite config chunk splitting strategy.""" + + def _generate_vite_config(self) -> str: + return vite_config_template( + base="/", + hmr=True, + force_full_reload=False, + experimental_hmr=False, + sourcemap=False, + ) + + def test_no_monolithic_radix_ui_chunk(self): + """Radix-ui packages must not be grouped into a single monolithic chunk. + + A single 'radix-ui' chunk forces every page to download ALL radix code + even when it only uses a fraction, wasting 55+ KB on typical pages. + """ + config = self._generate_vite_config() + + # There should be no chunk rule that matches ALL @radix-ui/* packages + # under a single name like "radix-ui". + monolithic_radix = re.search(r"""name:\s*["']radix-ui["']""", config) + assert monolithic_radix is None, ( + "Vite config must not group all @radix-ui/* packages into a single " + "'radix-ui' chunk. This forces pages to download unused radix code. " + "Remove the monolithic radix-ui chunk rule and let Vite split per-route." + ) + + def test_vendor_chunks_exist_for_large_libraries(self): + """Key vendor libraries should still have dedicated chunks for caching.""" + config = self._generate_vite_config() + + # These libraries are large and benefit from dedicated chunks for + # cross-page cache reuse. + for lib_name in ["socket-io", "mantine", "recharts"]: + assert re.search(rf"""name:\s*["']{lib_name}["']""", config), ( + f"Expected dedicated chunk for '{lib_name}'" + ) + + def test_reflex_env_chunk_exists(self): + """The env.json chunk should always exist for config isolation.""" + config = self._generate_vite_config() + assert re.search(r"""name:\s*["']reflex-env["']""", config) diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 55750cfdfce..71d9b0eab81 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -9,6 +9,7 @@ from reflex_base.constants import Endpoint, Env from reflex_base.plugins import Plugin from reflex_base.plugins.sitemap import SitemapPlugin +from reflex_base.utils.exceptions import ConfigError import reflex as rx from reflex.environment import ( @@ -148,7 +149,7 @@ def test_update_from_env_frontend_compression_formats( def test_invalid_frontend_compression_formats(base_config_values: dict[str, Any]): """Test that unsupported frontend compression formats raise config errors.""" with pytest.raises( - reflex_base.config.ConfigError, + ConfigError, match="frontend_compression_formats contains unsupported format", ): rx.Config( @@ -157,6 +158,34 @@ def test_invalid_frontend_compression_formats(base_config_values: dict[str, Any] ) +def test_default_frontend_image_formats(base_config_values: dict[str, Any]): + """Test default image optimization config values.""" + config = rx.Config(**base_config_values) + assert config.frontend_image_formats == ["webp", "avif"] + + +def test_update_from_env_frontend_image_formats( + base_config_values: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +): + """Test comma-delimited frontend image formats from the environment.""" + monkeypatch.setenv("REFLEX_FRONTEND_IMAGE_FORMATS", "webp, avif , webp") + config = rx.Config(**base_config_values) + assert config.frontend_image_formats == ["webp", "avif"] + + +def test_invalid_frontend_image_formats(base_config_values: dict[str, Any]): + """Test that unsupported frontend image formats raise config errors.""" + with pytest.raises( + ConfigError, + match="frontend_image_formats contains unsupported format", + ): + rx.Config( + **base_config_values, + frontend_image_formats=["webp", "png"], + ) + + @pytest.mark.parametrize( ("kwargs", "expected"), [ diff --git a/tests/units/test_lighthouse_utils.py b/tests/units/test_lighthouse_utils.py new file mode 100644 index 00000000000..1c32ac4e55a --- /dev/null +++ b/tests/units/test_lighthouse_utils.py @@ -0,0 +1,106 @@ +"""Unit tests for Lighthouse benchmark utilities.""" + +from types import SimpleNamespace + +import pytest + +from tests.integration import lighthouse_utils + + +@pytest.fixture(autouse=True) +def clear_lighthouse_command_cache(): + """Reset cached Lighthouse command preparation between tests.""" + lighthouse_utils._prepare_lighthouse_command.cache_clear() + yield + lighthouse_utils._prepare_lighthouse_command.cache_clear() + + +def test_get_lighthouse_command_prefers_npx_before_pnpx( + monkeypatch: pytest.MonkeyPatch, +): + """Use npx first when both package runners are available.""" + monkeypatch.delenv(lighthouse_utils.LIGHTHOUSE_COMMAND_ENV_VAR, raising=False) + monkeypatch.setattr( + lighthouse_utils.shutil, + "which", + lambda command: { + "npx": "/usr/bin/npx", + "pnpx": "/usr/bin/pnpx", + }.get(command), + ) + + assert lighthouse_utils.get_lighthouse_command() == [ + "npx", + "--yes", + lighthouse_utils.LIGHTHOUSE_CLI_PACKAGE, + ] + + +def test_get_lighthouse_command_falls_back_to_pnpx( + monkeypatch: pytest.MonkeyPatch, +): + """Use pnpx when npx is unavailable.""" + monkeypatch.delenv(lighthouse_utils.LIGHTHOUSE_COMMAND_ENV_VAR, raising=False) + monkeypatch.setattr( + lighthouse_utils.shutil, + "which", + lambda command: { + "pnpx": "/usr/bin/pnpx", + }.get(command), + ) + + assert lighthouse_utils.get_lighthouse_command() == [ + "pnpx", + lighthouse_utils.LIGHTHOUSE_CLI_PACKAGE, + ] + + +def test_prepare_lighthouse_command_warms_package_runner_once( + monkeypatch: pytest.MonkeyPatch, +): + """Warm package-runner commands once before Lighthouse executes.""" + calls: list[tuple[list[str], dict[str, object]]] = [] + + def fake_run(command: list[str], **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(lighthouse_utils.subprocess, "run", fake_run) + command = ("npx", "--yes", lighthouse_utils.LIGHTHOUSE_CLI_PACKAGE) + + assert lighthouse_utils._prepare_lighthouse_command(command) == command + assert lighthouse_utils._prepare_lighthouse_command(command) == command + + assert calls == [ + ( + [*command, "--version"], + { + "check": True, + "capture_output": True, + "text": True, + "timeout": lighthouse_utils.LIGHTHOUSE_COMMAND_PREP_TIMEOUT_SECONDS, + }, + ) + ] + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ( + "http://0.0.0.0:3001/dashboard?tab=perf", + "http://127.0.0.1:3001/dashboard?tab=perf", + ), + ( + "http://[::]:3001/dashboard?tab=perf", + "http://[::1]:3001/dashboard?tab=perf", + ), + ( + "http://localhost:3001/dashboard?tab=perf", + "http://localhost:3001/dashboard?tab=perf", + ), + ], +) +def test_get_lighthouse_target_url(url: str, expected: str): + """Convert bind-all addresses into loopback addresses for browser clients.""" + assert lighthouse_utils._get_lighthouse_target_url(url) == expected diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index a90ad2f5adb..615d5d73ba7 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -103,6 +103,28 @@ def test_vite_config_uses_frontend_compression_formats(): assert 'compressPlugin({ formats: ["gzip", "brotli"] }),' in output +def test_vite_config_uses_frontend_image_formats(): + config = Config( + app_name="test", + frontend_image_formats=["webp"], + ) + + output = _compile_vite_config(config) + + assert 'imageOptimizePlugin({ formats: ["webp"], quality: 80 }),' in output + + +def test_vite_config_disables_image_optimization(): + config = Config( + app_name="test", + frontend_image_formats=[], + ) + + output = _compile_vite_config(config) + + assert "imageOptimizePlugin({ formats: [], quality: 80 })," in output + + @pytest.mark.parametrize( ("frontend_path", "expected_command"), [ diff --git a/tests/units/utils/test_precompressed_staticfiles.py b/tests/units/utils/test_precompressed_staticfiles.py index e63e62bca05..5689af10294 100644 --- a/tests/units/utils/test_precompressed_staticfiles.py +++ b/tests/units/utils/test_precompressed_staticfiles.py @@ -10,10 +10,16 @@ from reflex.utils.precompressed_staticfiles import PrecompressedStaticFiles -def _scope(path: str, accept_encoding: str | None = None) -> dict: +def _scope( + path: str, + accept_encoding: str | None = None, + accept: str | None = None, +) -> dict: headers = [] if accept_encoding is not None: headers.append((b"accept-encoding", accept_encoding.encode())) + if accept is not None: + headers.append((b"accept", accept.encode())) return { "type": "http", "http_version": "1.1", @@ -118,3 +124,138 @@ async def test_precompressed_static_files_fall_back_to_identity(tmp_path: Path): assert str(response.path).endswith("app.js") assert "content-encoding" not in response.headers assert response.headers["vary"] == "Accept-Encoding" + + +@pytest.mark.asyncio +async def test_image_format_negotiation_serves_webp(tmp_path: Path): + """Serve a WebP variant when the client accepts image/webp.""" + (tmp_path / "hero.png").write_bytes(b"png-data") + (tmp_path / "hero.webp").write_bytes(b"webp-data") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + image_formats=["webp"], + ) + + response = await static_files.get_response( + "hero.png", + _scope("/hero.png", accept="image/webp, image/png, */*"), + ) + + assert isinstance(response, FileResponse) + assert str(response.path).endswith("hero.webp") + assert response.media_type == "image/webp" + assert "Accept" in response.headers["vary"] + + +@pytest.mark.asyncio +async def test_image_format_negotiation_serves_avif(tmp_path: Path): + """Serve an AVIF variant when the client accepts image/avif.""" + (tmp_path / "photo.jpg").write_bytes(b"jpeg-data") + (tmp_path / "photo.avif").write_bytes(b"avif-data") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + image_formats=["avif"], + ) + + response = await static_files.get_response( + "photo.jpg", + _scope("/photo.jpg", accept="image/avif, image/jpeg"), + ) + + assert isinstance(response, FileResponse) + assert str(response.path).endswith("photo.avif") + assert response.media_type == "image/avif" + + +@pytest.mark.asyncio +async def test_image_format_negotiation_prefers_best_quality(tmp_path: Path): + """Prefer the highest-quality accepted image format.""" + (tmp_path / "hero.png").write_bytes(b"png-data") + (tmp_path / "hero.webp").write_bytes(b"webp-data") + (tmp_path / "hero.avif").write_bytes(b"avif-data") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + image_formats=["webp", "avif"], + ) + + response = await static_files.get_response( + "hero.png", + _scope("/hero.png", accept="image/webp;q=0.5, image/avif;q=1"), + ) + + assert isinstance(response, FileResponse) + assert str(response.path).endswith("hero.avif") + assert response.media_type == "image/avif" + + +@pytest.mark.asyncio +async def test_image_format_negotiation_falls_back_to_original(tmp_path: Path): + """Serve the original image when no accepted format variant exists.""" + (tmp_path / "hero.png").write_bytes(b"png-data") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + image_formats=["webp", "avif"], + ) + + response = await static_files.get_response( + "hero.png", + _scope("/hero.png", accept="image/png"), + ) + + assert isinstance(response, FileResponse) + assert str(response.path).endswith("hero.png") + assert "Accept" in response.headers["vary"] + + +@pytest.mark.asyncio +async def test_image_format_negotiation_ignores_non_image_files(tmp_path: Path): + """Non-image files are not affected by image format negotiation.""" + (tmp_path / "app.js").write_text("console.log('hello');") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + image_formats=["webp"], + ) + + response = await static_files.get_response( + "app.js", + _scope("/app.js", accept="image/webp, */*"), + ) + + assert isinstance(response, FileResponse) + assert str(response.path).endswith("app.js") + + +@pytest.mark.asyncio +async def test_image_and_encoding_negotiation_combined(tmp_path: Path): + """Both image format and encoding negotiation work together.""" + (tmp_path / "hero.png").write_bytes(b"png-data") + (tmp_path / "hero.webp").write_bytes(b"webp-data") + (tmp_path / "hero.webp.gz").write_bytes(b"webp-gzip") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + encodings=["gzip"], + image_formats=["webp"], + ) + + response = await static_files.get_response( + "hero.png", + _scope( + "/hero.png", + accept_encoding="gzip", + accept="image/webp, image/png", + ), + ) + + assert isinstance(response, FileResponse) + # Image format negotiation serves webp, but encoding negotiation + # does not apply since the path changed and the compressed sidecar + # is for the original path. + assert str(response.path).endswith("hero.webp") + assert response.media_type == "image/webp" + assert "Accept" in response.headers["vary"] From 10d0893bfa33a8bba86f4d6a9247c4c8c78bd0c7 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 15 Apr 2026 01:12:38 +0500 Subject: [PATCH 07/16] feat: add post-build static compression and use JSON5 for socket parsing - Run compress-static.js on final static output to pre-compress assets - Skip already-compressed sidecars and always compress HTML entrypoints - Switch socket.io decoder to JSON5.parse for more lenient message parsing - Add json5 as a frontend dependency --- .../.templates/web/compress-static.js | 9 ++++ .../reflex_base/.templates/web/utils/state.js | 3 +- .../.templates/web/vite-plugin-compress.js | 23 +++++++--- .../src/reflex_base/constants/installer.py | 1 + reflex/utils/build.py | 43 +++++++++++++++++++ 5 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/compress-static.js diff --git a/packages/reflex-base/src/reflex_base/.templates/web/compress-static.js b/packages/reflex-base/src/reflex_base/.templates/web/compress-static.js new file mode 100644 index 00000000000..3cb6bf3bbe5 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/compress-static.js @@ -0,0 +1,9 @@ +import { compressDirectory } from "./vite-plugin-compress.js"; + +const [directory, formatsArg = "[]"] = process.argv.slice(2); + +if (!directory) { + throw new Error("Missing static output directory for compression."); +} + +await compressDirectory(directory, JSON.parse(formatsArg)); diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index dc49762c4a4..fd2b5e5741b 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -1,5 +1,6 @@ // State management for Reflex web apps. import io from "socket.io-client"; +import JSON5 from "json5"; import env from "$/env.json"; import reflexEnvironment from "$/reflex.json"; import Cookies from "universal-cookie"; @@ -540,7 +541,7 @@ export const connect = async ( socket.current.io.encoder.replacer = (k, v) => (v === undefined ? null : v); socket.current.io.decoder.tryParse = (str) => { try { - return JSON.parse(str); + return JSON5.parse(str); } catch (e) { return false; } diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js index a84d9166eef..53b3bcc5b10 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js @@ -7,7 +7,7 @@ import * as zlib from "node:zlib"; import { dirname } from "node:path"; -import { readFile, writeFile } from "node:fs/promises"; +import { access, readFile, writeFile } from "node:fs/promises"; import { promisify } from "node:util"; import { validateFormats, @@ -25,8 +25,8 @@ const zstdAsync = const COMPRESSIBLE_EXTENSIONS = /\.(js|css|html|json|svg|xml|txt|map|mjs)$/; -// Only compress files above this size (bytes). Tiny files don't benefit -// and the overhead of Content-Encoding negotiation can outweigh the saving. +// Only compress files above this size (bytes). Tiny assets rarely benefit, +// but HTML entrypoints are always compressed so their negotiated sidecars exist. const MIN_SIZE = 256; const COMPRESSORS = { @@ -65,12 +65,23 @@ function ensureFormatsSupported(formats) { } async function compressFile(filePath, formats) { + const pendingFormats = []; + for (const format of formats) { + const compressor = COMPRESSORS[format]; + try { + await access(filePath + compressor.extension); + } catch { + pendingFormats.push([format, compressor]); + } + } + + if (pendingFormats.length === 0) return; + const raw = await readFile(filePath); - if (raw.length < MIN_SIZE) return; + if (raw.length < MIN_SIZE && !filePath.endsWith(".html")) return; await Promise.all( - formats.map((format) => { - const compressor = COMPRESSORS[format]; + pendingFormats.map(([_format, compressor]) => { return compressor .compress(raw) .then((compressed) => diff --git a/packages/reflex-base/src/reflex_base/constants/installer.py b/packages/reflex-base/src/reflex_base/constants/installer.py index 77ec5b58423..f771e320415 100644 --- a/packages/reflex-base/src/reflex_base/constants/installer.py +++ b/packages/reflex-base/src/reflex_base/constants/installer.py @@ -122,6 +122,7 @@ def DEPENDENCIES(cls) -> dict[str, str]: A dictionary of dependencies with their versions. """ return { + "json5": "2.2.3", "react-router": cls._react_router_version, "react-router-dom": cls._react_router_version, "@react-router/node": cls._react_router_version, diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 178baf96125..63ac13ac4c3 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import zipfile from pathlib import Path, PosixPath @@ -210,6 +211,43 @@ def _copy_precompressed_sidecars(source: Path, target: Path, suffixes: tuple[str path_ops.cp(source_sidecar, target_sidecar) +def _compress_static_output(directory: Path, formats: tuple[str, ...]) -> None: + """Run the shared frontend compressor against the final static output tree. + + Args: + directory: The static output directory. + formats: The configured frontend compression formats. + + Raises: + SystemExit: If no JavaScript runtime is available or compression fails. + """ + if not formats: + return + + web_dir = prerequisites.get_web_dir().resolve() + runtime = path_ops.get_node_path() or path_ops.get_bun_path() + if runtime is None: + console.error("Node.js or Bun is required to compress the exported frontend.") + raise SystemExit(1) + + result = processes.new_process( + [ + runtime, + web_dir / "compress-static.js", + directory.resolve(), + json.dumps(formats), + ], + cwd=web_dir, + shell=constants.IS_WINDOWS, + run=True, + ) + if result.returncode != 0: + console.error( + "Failed to compress the exported frontend. Please run with --loglevel debug for more information." + ) + raise SystemExit(1) + + def build(): """Build the app for deployment. @@ -271,6 +309,11 @@ def build(): ) _copy_precompressed_sidecars(spa_fallback, target_404, sidecar_suffixes) + _compress_static_output( + wdir / constants.Dirs.STATIC, + tuple(config.frontend_compression_formats), + ) + if frontend_path := config.frontend_path.strip("/"): frontend_path = PosixPath(frontend_path) first_part = frontend_path.parts[0] From a63378775284b8008ad34672dfc0f99e92c7e2b9 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 15 Apr 2026 02:01:24 +0500 Subject: [PATCH 08/16] removed purge css because purgre css does not really work withh radix ui properly --- .../.templates/web/vite-plugin-purgecss.js | 123 ------------------ .../src/reflex_base/compiler/templates.py | 2 - .../src/reflex_base/constants/installer.py | 1 - tests/integration/test_prod_build_pipeline.py | 12 -- 4 files changed, 138 deletions(-) delete mode 100644 packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-purgecss.js diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-purgecss.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-purgecss.js deleted file mode 100644 index 3167f353648..00000000000 --- a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-purgecss.js +++ /dev/null @@ -1,123 +0,0 @@ -/* vite-plugin-purgecss.js - * - * Remove unused CSS selectors from production bundles. The primary target - * is @radix-ui/themes/styles.css which weighs ~200 KB and includes styles - * for every Radix component, even ones the app never uses. - * - * How it works: - * 1. After Vite writes the bundle, collect JS asset contents as "content" - * for PurgeCSS to scan for referenced selectors. The radix-ui chunk - * is excluded because it contains string literals for ALL component - * class names (e.g. "rt-Button", "rt-Dialog") regardless of which - * components the app actually imports — including it defeats purging. - * 2. Run PurgeCSS on each CSS asset with a safelist that preserves - * CSS custom properties, theme tokens, and data-attribute selectors. - * 3. Overwrite the CSS assets with the purged output. - */ - -import { PurgeCSS } from "purgecss"; -import { readdir, readFile, stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -/** - * Vite plugin that purges unused CSS in production builds. - * @returns {import('vite').Plugin} - */ -export default function purgeCSSPlugin() { - return { - name: "vite-plugin-purgecss", - apply: "build", - enforce: "post", - - async writeBundle(outputOptions) { - const outputDir = outputOptions.dir; - if (!outputDir) return; - - // The output directory may not exist (e.g. React Router removes - // build/server when ssr is disabled). - if ( - !(await stat(outputDir).catch((e) => - e?.code === "ENOENT" ? null : Promise.reject(e), - )) - ) - return; - - const entries = await readdir(outputDir, { - withFileTypes: true, - recursive: true, - }); - - const jsContents = []; - const cssFiles = []; - - for (const entry of entries) { - if (!entry.isFile()) continue; - const fullPath = join(entry.parentPath ?? entry.path, entry.name); - - if (/\.(js|jsx|mjs)$/.test(entry.name)) { - // Skip the radix-ui chunk: it contains string literals for every - // component class name ("rt-Button", "rt-Dialog", …) which makes - // PurgeCSS think they are all in use. - if (/^radix-ui[.-]/.test(entry.name)) continue; - jsContents.push({ - raw: await readFile(fullPath, "utf-8"), - extension: "js", - }); - } else if (/\.css$/.test(entry.name)) { - cssFiles.push(fullPath); - } - } - - if (cssFiles.length === 0 || jsContents.length === 0) return; - - for (const cssPath of cssFiles) { - const originalCSS = await readFile(cssPath, "utf-8"); - // Skip tiny files -- not worth purging. - if (originalCSS.length < 4096) continue; - - const result = await new PurgeCSS().purge({ - content: jsContents, - css: [{ raw: originalCSS }], - safelist: { - standard: [ - // Radix Themes root and theme classes - /^\.radix-themes$/, - /^\.light/, - /^\.dark/, - // Keep the Reflex base layer reset styles - /^html$/, - /^body$/, - /^\*$/, - ], - // Deep patterns: keep entire rule blocks when selector matches - deep: [ - // CSS custom properties and tokens (--color-*, --space-*, etc.) - /^:root$/, - /^:where\(:root\)$/, - /^:where\(\.radix-themes\)$/, - ], - // Greedy patterns: keep selector if substring matches - greedy: [ - // Data attribute selectors used by Radix for component state - /data-/, - ], - }, - variables: true, - fontFace: true, - keyframes: true, - defaultExtractor: (content) => { - return content.match(/[\w-/:]+/g) || []; - }, - }); - - if (result.length > 0 && result[0].css) { - const purged = result[0].css; - // Only write if we actually removed something meaningful - if (purged.length < originalCSS.length * 0.98) { - await writeFile(cssPath, purged); - } - } - } - }, - }; -} diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 20a982098e4..d71932a0b60 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -541,7 +541,6 @@ def vite_config_template( import safariCacheBustPlugin from "./vite-plugin-safari-cachebust"; import imageOptimizePlugin from "./vite-plugin-image-optimize"; import compressPlugin from "./vite-plugin-compress"; -import purgeCSSPlugin from "./vite-plugin-purgecss"; // Ensure that bun always uses the react-dom/server.node functions. function alwaysUseReactDomServerNode() {{ @@ -584,7 +583,6 @@ def vite_config_template( safariCacheBustPlugin(), imageOptimizePlugin({{ formats: {json.dumps(image_formats if image_formats is not None else ["webp", "avif"])}, quality: 80 }}), compressPlugin({{ formats: {json.dumps(compression_formats if compression_formats is not None else ["gzip"])} }}), - ...(config.mode === "production" ? [purgeCSSPlugin()] : []), ].concat({"[fullReload()]" if force_full_reload else "[]"}), build: {{ target: "es2022", diff --git a/packages/reflex-base/src/reflex_base/constants/installer.py b/packages/reflex-base/src/reflex_base/constants/installer.py index f771e320415..9732cc3f5c1 100644 --- a/packages/reflex-base/src/reflex_base/constants/installer.py +++ b/packages/reflex-base/src/reflex_base/constants/installer.py @@ -139,7 +139,6 @@ def DEPENDENCIES(cls) -> dict[str, str]: "autoprefixer": "10.4.27", "postcss": "8.5.8", "postcss-import": "16.1.1", - "purgecss": "7.0.2", "sharp": "0.34.5", "@react-router/dev": _react_router_version, "@react-router/fs-routes": _react_router_version, diff --git a/tests/integration/test_prod_build_pipeline.py b/tests/integration/test_prod_build_pipeline.py index c964b11b0e8..726ac535fef 100644 --- a/tests/integration/test_prod_build_pipeline.py +++ b/tests/integration/test_prod_build_pipeline.py @@ -184,18 +184,6 @@ def test_gzip_content_negotiation(prod_app: AppHarness): assert body[:2] == b"\x1f\x8b" -# -- CSS purging -- - - -def test_css_was_purged(prod_app: AppHarness): - """Total CSS should be well under the raw radix-ui CSS (~200KB).""" - css_files = _find_build_files(prod_app, "**/*.css") - assert css_files, "No CSS files in build" - - total = sum(f.stat().st_size for f in css_files) - assert total < 150_000, f"Total CSS is {total} bytes — PurgeCSS may not be running" - - # -- Image optimization -- From 56fe0f265bfec4a969159dbe2ea13ca33194be83 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 15 Apr 2026 02:10:49 +0500 Subject: [PATCH 09/16] feat: serve precompressed static assets in frontend mount Replace StaticFiles with PrecompressedStaticFiles in get_frontend_mount to support content negotiation for compressed and optimized image formats. Add unit tests for the new behavior. --- reflex/utils/exec.py | 15 ++++--- tests/units/utils/test_exec.py | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 tests/units/utils/test_exec.py diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 26479b96e11..dd74b26670e 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -24,6 +24,7 @@ from reflex.utils import path_ops from reflex.utils.misc import get_module_path +from reflex.utils.precompressed_staticfiles import PrecompressedStaticFiles from reflex.utils.prerequisites import get_web_dir # For uvicorn windows bug fix (#2335) @@ -273,19 +274,17 @@ def get_frontend_mount(): A Mount serving the compiled frontend static files. """ from starlette.routing import Mount - from starlette.staticfiles import StaticFiles - - from reflex.utils import prerequisites config = get_config() + frontend_path = config.frontend_path.strip("/") return Mount( - "/" + config.frontend_path.strip("/"), - app=StaticFiles( - directory=prerequisites.get_web_dir() - / constants.Dirs.STATIC - / config.frontend_path.strip("/"), + "/" + frontend_path, + app=PrecompressedStaticFiles( + directory=get_web_dir() / constants.Dirs.STATIC / frontend_path, html=True, + encodings=config.frontend_compression_formats, + image_formats=config.frontend_image_formats, ), name="frontend", ) diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py new file mode 100644 index 00000000000..e66243eafff --- /dev/null +++ b/tests/units/utils/test_exec.py @@ -0,0 +1,72 @@ +"""Unit tests for execution helpers.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from starlette.responses import FileResponse + +from reflex.utils import exec as exec_utils +from reflex.utils.precompressed_staticfiles import PrecompressedStaticFiles + + +def _scope( + path: str, + accept_encoding: str | None = None, + accept: str | None = None, +) -> dict: + headers = [] + if accept_encoding is not None: + headers.append((b"accept-encoding", accept_encoding.encode())) + if accept is not None: + headers.append((b"accept", accept.encode())) + return { + "type": "http", + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": headers, + "client": ("127.0.0.1", 1234), + "server": ("testserver", 80), + "root_path": "", + } + + +@pytest.mark.asyncio +async def test_get_frontend_mount_uses_precompressed_staticfiles( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): + """The prod frontend mount should negotiate precompressed assets.""" + web_dir = tmp_path / ".web" + frontend_dir = web_dir / "build" / "client" / "app" + frontend_dir.mkdir(parents=True) + (frontend_dir / "index.html").write_text("hello") + (frontend_dir / "index.html.gz").write_bytes(b"compressed-index") + + config = SimpleNamespace( + frontend_path="app", + frontend_compression_formats=["gzip"], + frontend_image_formats=["webp"], + ) + monkeypatch.setattr(exec_utils, "get_config", lambda: config) + monkeypatch.setattr(exec_utils, "get_web_dir", lambda: web_dir) + + mount = exec_utils.get_frontend_mount() + + assert mount.path == "/app" + assert isinstance(mount.app, PrecompressedStaticFiles) + assert tuple(fmt.name for fmt in mount.app._encodings) == ("gzip",) + assert tuple(fmt.name for fmt in mount.app._image_formats) == ("webp",) + + response = await mount.app.get_response("", _scope("/", accept_encoding="gzip")) + + assert isinstance(response, FileResponse) + assert response.status_code == 200 + assert str(response.path).endswith("index.html.gz") + assert response.headers["content-encoding"] == "gzip" + assert response.headers["vary"] == "Accept-Encoding" From 195986b898b758d30e7416e93c849d8c4dceccbe Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 16 Apr 2026 15:22:22 +0500 Subject: [PATCH 10/16] feat: use static imports for window libraries and simplify layout setup --- .../src/reflex_base/compiler/templates.py | 40 +++++++------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index d71932a0b60..585cb8a9e69 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -188,29 +188,14 @@ def app_root_template( custom_code_str = "\n".join(custom_codes) - if window_libraries: - # Use dynamic imports to avoid blocking the critical rendering path. - # These libraries are only needed for dynamically eval'd components. - lazy_import_entries = ",\n ".join([ - f'import("{lib_path}").then(m => ["{lib_path}", m])' - for _lib_alias, lib_path in window_libraries - ]) - window_libraries_block = f""" - useEffect(() => {{ - if (!window["__reflex"]) {{ - Promise.all([ - {lazy_import_entries} - ]).then((modules) => {{ - const imports = {{}}; - for (const [path, mod] of modules) {{ - imports[path] = mod; - }} - window["__reflex"] = imports; - }}); - }} - }}, []);""" - else: - window_libraries_block = "" + import_window_libraries = "\n".join([ + f'import * as {lib_alias} from "{lib_path}";' + for lib_alias, lib_path in window_libraries + ]) + + window_imports_str = "\n".join([ + f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries + ]) return f""" {imports_str} @@ -219,6 +204,7 @@ def app_root_template( import {{ ThemeProvider }} from '$/utils/react-theme'; import {{ Layout as AppLayout }} from './_document'; import {{ Outlet }} from 'react-router'; +{import_window_libraries} {custom_code_str} @@ -229,7 +215,12 @@ def app_root_template( export function Layout({{children}}) {{ -{window_libraries_block} + useEffect(() => {{ + // Make contexts and state objects available globally for dynamic eval'd components. + window["__reflex"] = {{ +{window_imports_str} + }}; + }}, []); return jsx(AppLayout, {{}}, jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}}, @@ -496,7 +487,6 @@ def package_json_template( return json.dumps({ "name": "reflex", "type": "module", - "sideEffects": ["*.css"], "scripts": scripts, "dependencies": dependencies, "devDependencies": dev_dependencies, From 6f9543f72ef4e058ef1b4a6f97ee3f8e59b5baab Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 16 Apr 2026 22:12:18 +0500 Subject: [PATCH 11/16] test: add missing prepend_frontend_path param --- tests/units/utils/test_exec.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index e66243eafff..398d7458340 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -52,6 +52,9 @@ async def test_get_frontend_mount_uses_precompressed_staticfiles( frontend_path="app", frontend_compression_formats=["gzip"], frontend_image_formats=["webp"], + prepend_frontend_path=lambda path: ( + "/app" + path if path.startswith("/") else path + ), ) monkeypatch.setattr(exec_utils, "get_config", lambda: config) monkeypatch.setattr(exec_utils, "get_web_dir", lambda: web_dir) From dd6803e3e467e23c5e081d5dac53320e1ceceaf2 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 16 Apr 2026 22:23:03 +0500 Subject: [PATCH 12/16] feat: make node version configurable and improve timeout error messages Add node-version input to setup_build_env action instead of hardcoding v22. Include actual timeout duration in Lighthouse CLI failure messages and add unit tests for both preparation and execution timeout paths. --- .github/actions/setup_build_env/action.yml | 6 ++- .github/workflows/performance.yml | 1 + tests/integration/lighthouse_utils.py | 4 +- tests/units/test_lighthouse_utils.py | 50 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup_build_env/action.yml b/.github/actions/setup_build_env/action.yml index 773c0f9cb6d..d45668dd4b4 100644 --- a/.github/actions/setup_build_env/action.yml +++ b/.github/actions/setup_build_env/action.yml @@ -14,6 +14,10 @@ inputs: python-version: description: "Python version setup" required: true + node-version: + description: "Node.js version setup" + required: false + default: "22" run-uv-sync: description: "Whether to run uv sync on current dir" required: false @@ -37,7 +41,7 @@ runs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ inputs.node-version }} - name: Install Dependencies if: inputs.run-uv-sync == 'true' run: uv sync diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index fdfb93f6c7f..815322a733b 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -58,6 +58,7 @@ jobs: - uses: ./.github/actions/setup_build_env with: python-version: "3.14" + node-version: "22" run-uv-sync: true - name: Install playwright diff --git a/tests/integration/lighthouse_utils.py b/tests/integration/lighthouse_utils.py index 74a99d0e1f5..2fa895da641 100644 --- a/tests/integration/lighthouse_utils.py +++ b/tests/integration/lighthouse_utils.py @@ -753,7 +753,7 @@ def _prepare_lighthouse_command(command: tuple[str, ...]) -> tuple[str, ...]: ) except subprocess.TimeoutExpired as err: pytest.fail( - "Lighthouse CLI preparation timed out. " + f"Lighthouse CLI preparation timed out after {err.timeout}s. " "If Lighthouse is not already installed, make sure the npm registry " f"is reachable or set {LIGHTHOUSE_COMMAND_ENV_VAR} to an installed CLI.\n" f"Command: {' '.join(prepare_command)}\n" @@ -920,7 +920,7 @@ def run_lighthouse(url: str, report_path: Path) -> dict[str, Any]: ) except subprocess.TimeoutExpired as err: pytest.fail( - "Lighthouse execution timed out.\n" + f"Lighthouse execution timed out after {err.timeout}s.\n" f"Command: {' '.join(command)}\n" f"stdout:\n{_format_subprocess_output(err.stdout)}\n" f"stderr:\n{_format_subprocess_output(err.stderr)}" diff --git a/tests/units/test_lighthouse_utils.py b/tests/units/test_lighthouse_utils.py index 1c32ac4e55a..e0a511ab5ac 100644 --- a/tests/units/test_lighthouse_utils.py +++ b/tests/units/test_lighthouse_utils.py @@ -1,5 +1,6 @@ """Unit tests for Lighthouse benchmark utilities.""" +import subprocess from types import SimpleNamespace import pytest @@ -84,6 +85,55 @@ def fake_run(command: list[str], **kwargs): ] +def test_prepare_lighthouse_command_timeout_has_friendly_message( + monkeypatch: pytest.MonkeyPatch, +): + """Timeouts during CLI warmup should fail with helpful pytest output.""" + + def fake_run(*_args, **_kwargs): + raise subprocess.TimeoutExpired( + cmd=["npx", "--yes", lighthouse_utils.LIGHTHOUSE_CLI_PACKAGE, "--version"], + timeout=lighthouse_utils.LIGHTHOUSE_COMMAND_PREP_TIMEOUT_SECONDS, + output="prep stdout", + stderr="prep stderr", + ) + + monkeypatch.setattr(lighthouse_utils.subprocess, "run", fake_run) + command = ("npx", "--yes", lighthouse_utils.LIGHTHOUSE_CLI_PACKAGE) + + with pytest.raises(pytest.fail.Exception, match="timed out after 300s"): + lighthouse_utils._prepare_lighthouse_command(command) + + +def test_run_lighthouse_timeout_has_friendly_message( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): + """Timeouts during a Lighthouse run should be reported via pytest.fail.""" + + def fake_run(*_args, **_kwargs): + raise subprocess.TimeoutExpired( + cmd=["lighthouse", "http://localhost:3000"], + timeout=lighthouse_utils.LIGHTHOUSE_RUN_TIMEOUT_SECONDS, + output="run stdout", + stderr="run stderr", + ) + + monkeypatch.setattr( + lighthouse_utils, "_prepare_lighthouse_command", lambda command: command + ) + monkeypatch.setattr( + lighthouse_utils, "get_lighthouse_command", lambda: ["lighthouse"] + ) + monkeypatch.setattr(lighthouse_utils, "get_chrome_path", lambda: "/tmp/chrome") + monkeypatch.setattr(lighthouse_utils.subprocess, "run", fake_run) + + with pytest.raises(pytest.fail.Exception, match="timed out after 300s"): + lighthouse_utils.run_lighthouse( + "http://localhost:3000", tmp_path / "lighthouse-report.json" + ) + + @pytest.mark.parametrize( ("url", "expected"), [ From fa3914956c195eb473972767d66f3715f23414de Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 18 Apr 2026 00:53:53 +0500 Subject: [PATCH 13/16] perf: tree-shake radix themes and window.__reflex imports Only ship the Radix color scales actually referenced by Theme components (falling back to the monolithic stylesheet when a color is state-driven), and expose external libraries on window.__reflex via named imports so Rolldown can drop unused exports. --- .../src/reflex_base/compiler/templates.py | 93 ++++++-- .../src/reflex_base/plugins/base.py | 2 + .../reflex_base/plugins/shared_tailwind.py | 22 ++ .../src/reflex_base/plugins/tailwind_v3.py | 39 ++- .../src/reflex_base/plugins/tailwind_v4.py | 39 ++- reflex/app.py | 21 +- reflex/compiler/compiler.py | 223 ++++++++++++++++-- tests/units/compiler/test_compiler.py | 126 ++++++++++ 8 files changed, 492 insertions(+), 73 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 3fbb2ccea18..bd79e9ac833 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -161,12 +161,79 @@ def document_root_template(*, imports: list[_ImportDict], document: dict[str, An }}""" +def _normalize_window_lib_alias(lib: str) -> str: + """Produce a safe JS identifier for a library path. + + Args: + lib: The library path to normalize. + + Returns: + A JS-safe identifier derived from the library path. + """ + return ( + lib + .replace("$/", "") + .replace("@", "") + .replace("/", "_") + .replace("-", "_") + .replace(".", "_") + ) + + +def _render_window_reflex_block( + window_library_imports: dict[str, set[str] | None], +) -> tuple[str, str]: + """Render the extra imports + useEffect block for window.__reflex. + + External libraries (``@radix-ui/themes`` etc.) use named imports derived + from the app's actual usage so Rolldown can tree-shake unused exports; + a star import would pin the library's entire surface onto the critical + path. Internal ``$/utils/*`` modules still use star imports since their + surface is small and Reflex-controlled. + + Args: + window_library_imports: Mapping from library path to the set of + named exports to expose (external libs) or ``None`` (internal + libs, star import). + + Returns: + A tuple of ``(import_block, useEffect_body)``. Both are empty when + no dynamic components are in play. + """ + if not window_library_imports: + return "", "" + import_lines: list[str] = [] + entries: list[str] = [] + for lib, names in window_library_imports.items(): + alias = f"__reflex_{_normalize_window_lib_alias(lib)}" + if names is None: + import_lines.append(f'import * as {alias} from "{lib}";') + entries.append(f' "{lib}": {alias},') + else: + sorted_names = sorted(names) + specs = ", ".join(f"{n} as {alias}_{n}" for n in sorted_names) + import_lines.append(f'import {{ {specs} }} from "{lib}";') + obj_entries = ", ".join(f"{n}: {alias}_{n}" for n in sorted_names) + entries.append(f' "{lib}": {{ {obj_entries} }},') + if not entries: + return "", "" + import_block = "\n".join(import_lines) + effect = ( + " useEffect(() => {\n" + ' window["__reflex"] = {\n' + f"{chr(10).join(entries)}\n" + " };\n" + " }, []);\n" + ) + return import_block, effect + + def app_root_template( *, imports: list[_ImportDict], custom_codes: Iterable[str], hooks: dict[str, VarData | None], - window_libraries: list[tuple[str, str]], + window_library_imports: dict[str, set[str] | None], render: dict[str, Any], dynamic_imports: set[str], ): @@ -176,7 +243,8 @@ def app_root_template( imports: The list of import statements. custom_codes: The set of custom code snippets. hooks: The dictionary of hooks. - window_libraries: The list of window libraries. + window_library_imports: Per-library named-export surface for + ``window.__reflex`` (see ``collect_window_library_imports``). render: The dictionary of render functions. dynamic_imports: The set of dynamic imports. @@ -188,14 +256,9 @@ def app_root_template( custom_code_str = "\n".join(custom_codes) - import_window_libraries = "\n".join([ - f'import * as {lib_alias} from "{lib_path}";' - for lib_alias, lib_path in window_libraries - ]) - - window_imports_str = "\n".join([ - f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries - ]) + window_imports_block, window_reflex_effect = _render_window_reflex_block( + window_library_imports + ) return f""" {imports_str} @@ -204,7 +267,7 @@ def app_root_template( import {{ ThemeProvider }} from '$/utils/react-theme'; import {{ Layout as AppLayout }} from './_document'; import {{ Outlet }} from 'react-router'; -{import_window_libraries} +{window_imports_block} {custom_code_str} @@ -215,13 +278,7 @@ def app_root_template( export function Layout({{children}}) {{ - useEffect(() => {{ - // Make contexts and state objects available globally for dynamic eval'd components. - window["__reflex"] = {{ -{window_imports_str} - }}; - }}, []); - +{window_reflex_effect} return jsx(AppLayout, {{}}, jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}}, jsx(StateProvider, {{}}, diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py index 52dfa8d7805..b38f560e71b 100644 --- a/packages/reflex-base/src/reflex_base/plugins/base.py +++ b/packages/reflex-base/src/reflex_base/plugins/base.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from reflex.app import App, UnevaluatedPage + from reflex_base.components.component import BaseComponent class CommonContext(TypedDict): @@ -42,6 +43,7 @@ class PreCompileContext(CommonContext): add_save_task: AddTaskProtocol add_modify_task: Callable[[str, Callable[[str], str]], None] unevaluated_pages: Sequence["UnevaluatedPage"] + theme_roots: Sequence["BaseComponent | None"] class PostCompileContext(CommonContext): diff --git a/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py b/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py index 62180093ee6..4b19963403c 100644 --- a/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py +++ b/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py @@ -1,6 +1,7 @@ """Tailwind CSS configuration types for Reflex plugins.""" import dataclasses +import re from collections.abc import Mapping from copy import deepcopy from typing import Any, Literal, TypedDict @@ -9,6 +10,27 @@ from .base import Plugin as PluginBase +_RADIX_IMPORT_RE = re.compile( + r"^@import url\(['\"]@radix-ui/themes/[^'\"]+['\"]\);\s*\n?", + re.MULTILINE, +) + + +def strip_radix_theme_imports(css: str) -> tuple[str, int]: + """Remove every Radix Themes @import line from a stylesheet. + + Handles both the monolithic ``styles.css`` and the granular per-token + imports emitted by the compiler. + + Args: + css: The stylesheet content. + + Returns: + The stripped content and the number of imports removed. + """ + return _RADIX_IMPORT_RE.subn("", css) + + TailwindPluginImport = TypedDict( "TailwindPluginImport", { diff --git a/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py b/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py index d67264fc0e3..7d72486a829 100644 --- a/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py +++ b/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py @@ -1,17 +1,23 @@ """Base class for all plugins.""" import dataclasses +from collections.abc import Sequence from pathlib import Path from types import SimpleNamespace +from typing import TYPE_CHECKING from reflex_base.constants.base import Dirs from reflex_base.constants.compiler import Ext, PageNames from reflex_base.plugins.shared_tailwind import ( TailwindConfig, TailwindPlugin, + strip_radix_theme_imports, tailwind_config_js_template, ) +if TYPE_CHECKING: + from reflex_base.components.component import BaseComponent + class Constants(SimpleNamespace): """Tailwind constants.""" @@ -29,7 +35,7 @@ class Constants(SimpleNamespace): ROOT_STYLE_CONTENT = """ @import "tailwindcss/base"; -@import url('{radix_url}'); +{radix_imports} @tailwind components; @tailwind utilities; @@ -54,18 +60,28 @@ def compile_config(config: TailwindConfig): ) -def compile_root_style(): +def compile_root_style( + theme_roots: Sequence["BaseComponent | None"] | None = None, +): """Compile the Tailwind root style. + Args: + theme_roots: Component roots used to detect which Radix color scales are + actually referenced so only those CSS files are imported. + Returns: The compiled Tailwind root style. """ - from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET + from reflex.compiler.compiler import get_radix_themes_stylesheets + radix_imports = "\n".join( + f"@import url('{sheet}');" + for sheet in get_radix_themes_stylesheets(theme_roots) + ) return str( Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH ), Constants.ROOT_STYLE_CONTENT.format( - radix_url=RADIX_THEMES_STYLESHEET, + radix_imports=radix_imports, ) @@ -121,20 +137,17 @@ def add_tailwind_to_css_file(css_file_content: str) -> str: Returns: The modified css file content. """ - from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET - if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content: return css_file_content - if RADIX_THEMES_STYLESHEET not in css_file_content: + + stripped, count = strip_radix_theme_imports(css_file_content) + if count == 0: print( # noqa: T201 - f"Could not find line with '{RADIX_THEMES_STYLESHEET}' in {Dirs.STYLES}. " + f"Could not find any '@radix-ui/themes' import in {Dirs.STYLES}. " "Please make sure the file exists and is valid." ) return css_file_content - return css_file_content.replace( - f"@import url('{RADIX_THEMES_STYLESHEET}');", - Constants.TAILWIND_CSS, - ) + return stripped.rstrip() + "\n" + Constants.TAILWIND_CSS + "\n" @dataclasses.dataclass @@ -162,7 +175,7 @@ def pre_compile(self, **context): context: The context for the plugin. """ context["add_save_task"](compile_config, self.get_unversioned_config()) - context["add_save_task"](compile_root_style) + context["add_save_task"](compile_root_style, context.get("theme_roots")) context["add_modify_task"](Dirs.POSTCSS_JS, add_tailwind_to_postcss_config) context["add_modify_task"]( str(Path(Dirs.STYLES) / (PageNames.STYLESHEET_ROOT + Ext.CSS)), diff --git a/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py b/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py index 4ae637752a1..b9aee70b05b 100644 --- a/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py +++ b/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py @@ -1,17 +1,23 @@ """Base class for all plugins.""" import dataclasses +from collections.abc import Sequence from pathlib import Path from types import SimpleNamespace +from typing import TYPE_CHECKING from reflex_base.constants.base import Dirs from reflex_base.constants.compiler import Ext, PageNames from reflex_base.plugins.shared_tailwind import ( TailwindConfig, TailwindPlugin, + strip_radix_theme_imports, tailwind_config_js_template, ) +if TYPE_CHECKING: + from reflex_base.components.component import BaseComponent + class Constants(SimpleNamespace): """Tailwind constants.""" @@ -29,7 +35,7 @@ class Constants(SimpleNamespace): ROOT_STYLE_CONTENT = """@layer theme, base, components, utilities; @import "tailwindcss/theme.css" layer(theme); @import "tailwindcss/preflight.css" layer(base); -@import "{radix_url}" layer(components); +{radix_imports} @import "tailwindcss/utilities.css" layer(utilities); @config "../tailwind.config.js"; """ @@ -53,18 +59,28 @@ def compile_config(config: TailwindConfig): ) -def compile_root_style(): +def compile_root_style( + theme_roots: Sequence["BaseComponent | None"] | None = None, +): """Compile the Tailwind root style. + Args: + theme_roots: Component roots used to detect which Radix color scales are + actually referenced so only those CSS files are imported. + Returns: The compiled Tailwind root style. """ - from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET + from reflex.compiler.compiler import get_radix_themes_stylesheets + radix_imports = "\n".join( + f'@import "{sheet}" layer(components);' + for sheet in get_radix_themes_stylesheets(theme_roots) + ) return str( Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH ), Constants.ROOT_STYLE_CONTENT.format( - radix_url=RADIX_THEMES_STYLESHEET, + radix_imports=radix_imports, ) @@ -124,20 +140,17 @@ def add_tailwind_to_css_file(css_file_content: str) -> str: Returns: The modified css file content. """ - from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET - if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content: return css_file_content - if RADIX_THEMES_STYLESHEET not in css_file_content: + + stripped, count = strip_radix_theme_imports(css_file_content) + if count == 0: print( # noqa: T201 - f"Could not find line with '{RADIX_THEMES_STYLESHEET}' in {Dirs.STYLES}. " + f"Could not find any '@radix-ui/themes' import in {Dirs.STYLES}. " "Please make sure the file exists and is valid." ) return css_file_content - return css_file_content.replace( - f"@import url('{RADIX_THEMES_STYLESHEET}');", - Constants.TAILWIND_CSS, - ) + return stripped.rstrip() + "\n" + Constants.TAILWIND_CSS + "\n" @dataclasses.dataclass @@ -166,7 +179,7 @@ def pre_compile(self, **context): context: The context for the plugin. """ context["add_save_task"](compile_config, self.get_unversioned_config()) - context["add_save_task"](compile_root_style) + context["add_save_task"](compile_root_style, context.get("theme_roots")) context["add_modify_task"](Dirs.POSTCSS_JS, add_tailwind_to_postcss_config) context["add_modify_task"]( str(Path(Dirs.STYLES) / (PageNames.STYLESHEET_ROOT + Ext.CSS)), diff --git a/reflex/app.py b/reflex/app.py index bdbb90bfe40..c10a25fb8d9 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1412,9 +1412,15 @@ def _submit_work( route, ) - # Compile the root stylesheet with base styles. + # Compile the root stylesheet with base styles. theme_roots lets + # the compiler ship only the Radix color scales that are actually + # referenced by Theme components in the tree. + theme_roots = [self.theme, *self._pages.values()] _submit_work( - compiler.compile_root_stylesheet, self.stylesheets, self.reset_style + compiler.compile_root_stylesheet, + self.stylesheets, + self.reset_style, + theme_roots, ) # Compile the theme. @@ -1438,6 +1444,7 @@ def _submit_work_without_advancing( )) ), unevaluated_pages=list(self._unevaluated_pages.values()), + theme_roots=theme_roots, ) # Wait for all compilation tasks to complete. @@ -1466,9 +1473,17 @@ def _submit_work_without_advancing( self.theme.appearance = None # pyright: ignore[reportAttributeAccessIssue] progress.advance(task) + # Star imports of large libraries (e.g. @radix-ui/themes) defeat + # Rolldown tree-shaking for window.__reflex; pass per-source dicts + # so tags from multiple pages union instead of clobbering. + window_library_imports = compiler.collect_window_library_imports([ + *(p._get_all_imports() for p in self._pages.values()), + app_root._get_all_imports(), + ]) + # Compile the app root. compile_results.append( - compiler.compile_app(app_root), + compiler.compile_app(app_root, window_library_imports), ) progress.advance(task) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index feec49ee69a..e85c7bda416 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -24,7 +24,7 @@ from reflex_base.utils.exceptions import ReflexError from reflex_base.utils.format import to_title_case from reflex_base.utils.imports import ImportVar, ParsedImportDict -from reflex_base.vars.base import LiteralVar, Var +from reflex_base.vars.base import LiteralVar, Var, get_python_literal from reflex_components_core.base.fragment import Fragment from reflex.compiler import templates, utils @@ -65,37 +65,74 @@ def _compile_document_root(root: Component) -> str: ) -def _normalize_library_name(lib: str) -> str: - """Normalize the library name. +# Path-like imports resolve to Reflex-controlled internal modules; non-matching +# imports are external npm libraries where star imports defeat tree-shaking. +_INTERNAL_LIB_PREFIXES = ("$/", "/", ".") + + +def collect_window_library_imports( + import_sources: Iterable[dict[str, Any]], +) -> dict[str, set[str] | None]: + """Build the ``window.__reflex`` surface for runtime-eval'd code. + + Each bundled library gets either a set of named exports (for external libs, + collected from the app's actual static usage so Rolldown can tree-shake) or + ``None`` (for internal Reflex modules, which use a star import). + + ``window.__reflex`` is always emitted because consumers are diverse -- + ``evalReactComponent`` (dynamic components) is one, but plugins like + ``reflex_enterprise``'s ``LiteralLambdaVar`` also emit JS that reads + ``window.__reflex['']`` and a static detection is fragile. Named + imports keep the bundle tree-shakable regardless. + + Takes an iterable of import dicts (one per page / app_root / memo group) + instead of a single merged dict because ``dict.update`` loses information + when multiple sources import from the same library. Args: - lib: The library name to normalize. + import_sources: One import dict per source (page, app_root, etc). Returns: - The normalized library name. + Mapping from library path to either the set of named exports to expose + (external libs) or None (internal libs, use star import). """ - if lib == "react": - return "React" - return lib.replace("$/", "").replace("@", "").replace("/", "_").replace("-", "_") - - -def _compile_app(app_root: Component) -> str: + from reflex_base.components.dynamic import bundled_libraries + from reflex_base.utils.format import format_library_name + + per_lib_tags: dict[str, set[str]] = {} + for source in import_sources: + for imported_lib, import_vars in source.items(): + key = format_library_name(imported_lib) + for iv in import_vars: + if iv.tag and not iv.is_default: + per_lib_tags.setdefault(key, set()).add(iv.tag) + + result: dict[str, set[str] | None] = {} + for lib in bundled_libraries: + if lib.startswith(_INTERNAL_LIB_PREFIXES): + result[lib] = None + continue + tags = per_lib_tags.get(lib) + if tags: + result[lib] = tags + return result + + +def _compile_app( + app_root: Component, + window_library_imports: dict[str, set[str] | None] | None = None, +) -> str: """Compile the app template component. Args: app_root: The app root to compile. + window_library_imports: Per-library named-export surface to expose on + ``window.__reflex`` for dynamic components. Empty/None skips the + bootstrap entirely. Returns: The compiled app. """ - from reflex_base.components.dynamic import bundled_libraries - - window_libraries = [ - (_normalize_library_name(name), name) for name in bundled_libraries - ] - - window_libraries_deduped = list(dict.fromkeys(window_libraries)) - app_root_imports = app_root._get_all_imports() _apply_common_imports(app_root_imports) @@ -103,7 +140,7 @@ def _compile_app(app_root: Component) -> str: imports=utils.compile_imports(app_root_imports), custom_codes=app_root._get_all_custom_code(), hooks=app_root._get_all_hooks(), - window_libraries=window_libraries_deduped, + window_library_imports=window_library_imports or {}, render=app_root.render(), dynamic_imports=app_root._get_all_dynamic_imports(), ) @@ -175,20 +212,24 @@ def _compile_page(component: BaseComponent) -> str: def compile_root_stylesheet( - stylesheets: list[str], reset_style: bool = True + stylesheets: list[str], + reset_style: bool = True, + theme_roots: Sequence[BaseComponent | None] | None = None, ) -> tuple[str, str]: """Compile the root stylesheet. Args: stylesheets: The stylesheets to include in the root stylesheet. reset_style: Whether to include CSS reset for margin and padding. + theme_roots: Component roots to scan for Theme components so only the + used Radix color scales are shipped. Returns: The path and code of the compiled root stylesheet. """ output_path = utils.get_root_stylesheet_path() - code = _compile_root_stylesheet(stylesheets, reset_style) + code = _compile_root_stylesheet(stylesheets, reset_style, theme_roots) return output_path, code @@ -230,13 +271,137 @@ def _validate_stylesheet(stylesheet_full_path: Path, assets_app_path: Path) -> N RADIX_THEMES_STYLESHEET = "@radix-ui/themes/styles.css" +# Granular Radix Themes entry points. Importing these instead of the monolithic +# styles.css lets us drop the ~30 unused color scales (~120KB raw of tokens.css). +# Layout + reset live in tokens/components/utilities, so these three are always needed. +_RADIX_THEMES_TOKENS_BASE = "@radix-ui/themes/tokens/base.css" +_RADIX_THEMES_COMPONENTS = "@radix-ui/themes/components.css" +_RADIX_THEMES_UTILITIES = "@radix-ui/themes/utilities.css" + + +def _radix_color_stylesheet(color: str) -> str: + return f"@radix-ui/themes/tokens/colors/{color}.css" + + +# When gray_color is "auto" or unset, Radix pairs each accent with a specific gray. +# https://www.radix-ui.com/themes/docs/theme/color#natural-pairing +_RADIX_ACCENT_TO_AUTO_GRAY: dict[str, str] = { + "tomato": "mauve", + "red": "mauve", + "ruby": "mauve", + "crimson": "mauve", + "pink": "mauve", + "plum": "mauve", + "purple": "mauve", + "violet": "mauve", + "iris": "slate", + "indigo": "slate", + "blue": "slate", + "sky": "slate", + "cyan": "slate", + "teal": "sage", + "jade": "sage", + "mint": "sage", + "green": "sage", + "grass": "sage", + "orange": "sand", + "amber": "sand", + "yellow": "sand", + "lime": "sand", + "brown": "sand", + "bronze": "sand", + "gold": "sand", + "gray": "gray", +} + + +def _extract_literal_prop(component: Any, prop_name: str) -> str | None: + """Return the literal string for a Theme prop, or None if unresolvable.""" + literal = get_python_literal(getattr(component, prop_name, None)) + return literal if isinstance(literal, str) else None + + +def _walk_components(root: Any) -> Iterable[Any]: + """Yield root and every descendant via .children.""" + stack = [root] + while stack: + node = stack.pop() + yield node + children = getattr(node, "children", None) + if children: + stack.extend(children) + + +def _collect_radix_theme_colors( + roots: Iterable[Any], +) -> tuple[set[str], set[str], bool]: + """Walk component trees for Theme components and collect their colors. + + Args: + roots: Component trees to walk for Theme components. -def _compile_root_stylesheet(stylesheets: list[str], reset_style: bool = True) -> str: + Returns: + A tuple ``(accent_colors, gray_colors, has_dynamic)``. ``has_dynamic`` + is True if any Theme has a non-literal (state-driven) color, in which + case the caller should fall back to the monolithic stylesheet. + """ + accents: set[str] = set() + grays: set[str] = set() + has_dynamic = False + for root in roots: + if root is None: + continue + for node in _walk_components(root): + if getattr(node, "tag", None) != "Theme": + continue + accent = _extract_literal_prop(node, "accent_color") + gray = _extract_literal_prop(node, "gray_color") + # A Theme instance with a prop attribute but non-literal value is dynamic. + if accent is None and getattr(node, "accent_color", None) is not None: + has_dynamic = True + if gray is None and getattr(node, "gray_color", None) is not None: + has_dynamic = True + if accent: + accents.add(accent) + if gray and gray != "auto": + grays.add(gray) + # When gray_color is unset or "auto", Radix pairs the accent with a + # natural gray scale. Ship that scale too. + if accent and (gray is None or gray == "auto"): + grays.add(_RADIX_ACCENT_TO_AUTO_GRAY.get(accent, "gray")) + return accents, grays, has_dynamic + + +def get_radix_themes_stylesheets(roots: Iterable[Any] | None = None) -> list[str]: + """Return the list of Radix Themes stylesheets to import. + + If any Theme component uses a state-driven color, falls back to the + monolithic styles.css so runtime color changes keep working. + """ + if roots is None: + return [RADIX_THEMES_STYLESHEET] + accents, grays, has_dynamic = _collect_radix_theme_colors(roots) + if has_dynamic or not accents: + return [RADIX_THEMES_STYLESHEET] + sheets = [_RADIX_THEMES_TOKENS_BASE] + sheets.extend(_radix_color_stylesheet(c) for c in sorted(grays)) + sheets.extend(_radix_color_stylesheet(c) for c in sorted(accents)) + sheets.extend([_RADIX_THEMES_COMPONENTS, _RADIX_THEMES_UTILITIES]) + return sheets + + +def _compile_root_stylesheet( + stylesheets: list[str], + reset_style: bool = True, + theme_roots: Sequence[BaseComponent | None] | None = None, +) -> str: """Compile the root stylesheet. Args: stylesheets: The stylesheets to include in the root stylesheet. reset_style: Whether to include CSS reset for margin and padding. + theme_roots: Component roots to scan for Theme components so only the + used Radix color scales are shipped. Returns: The compiled root stylesheet. @@ -253,7 +418,7 @@ def _compile_root_stylesheet(stylesheets: list[str], reset_style: bool = True) - sheets.append(f"./{ResetStylesheet.FILENAME}") sheets.extend( - [RADIX_THEMES_STYLESHEET] + get_radix_themes_stylesheets(theme_roots) + [ sheet for plugin in get_config().plugins @@ -521,11 +686,17 @@ def compile_document_root( return output_path, code -def compile_app(app_root: Component) -> tuple[str, str]: +def compile_app( + app_root: Component, + window_library_imports: dict[str, set[str] | None] | None = None, +) -> tuple[str, str]: """Compile the app root. Args: app_root: The app root component to compile. + window_library_imports: Per-library named-export surface for + ``window.__reflex`` (see ``collect_window_library_imports``). Pass + ``None`` to skip emitting ``window.__reflex`` entirely. Returns: The path and code of the compiled app wrapper. @@ -536,7 +707,7 @@ def compile_app(app_root: Component) -> tuple[str, str]: ) # Compile the document root. - code = _compile_app(app_root) + code = _compile_app(app_root, window_library_imports) return output_path, code diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 637a8d22c9c..faf0cf09bea 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -361,6 +361,132 @@ def test_compile_nonexistent_stylesheet(tmp_path, mocker: MockerFixture): compiler.compile_root_stylesheet(stylesheets) +class TestGetRadixThemesStylesheets: + """Tests for the granular Radix Themes stylesheet selection.""" + + def test_no_roots_falls_back_to_monolith(self): + """When no roots are provided, use the monolithic stylesheet.""" + assert compiler.get_radix_themes_stylesheets(None) == [ + "@radix-ui/themes/styles.css" + ] + + def test_literal_accent_emits_granular_imports(self): + """A literal accent_color emits only the needed granular imports.""" + import reflex as rx + + sheets = compiler.get_radix_themes_stylesheets([rx.theme(accent_color="blue")]) + assert sheets == [ + "@radix-ui/themes/tokens/base.css", + # blue's natural gray pairing is slate + "@radix-ui/themes/tokens/colors/slate.css", + "@radix-ui/themes/tokens/colors/blue.css", + "@radix-ui/themes/components.css", + "@radix-ui/themes/utilities.css", + ] + + def test_explicit_gray_overrides_auto_pairing(self): + """An explicit gray_color replaces the accent's auto-paired gray.""" + import reflex as rx + + sheets = compiler.get_radix_themes_stylesheets([ + rx.theme(accent_color="red", gray_color="mauve") + ]) + assert "@radix-ui/themes/tokens/colors/mauve.css" in sheets + assert "@radix-ui/themes/tokens/colors/red.css" in sheets + # The default auto pairing for red is also mauve, so no extra colors. + color_sheets = [s for s in sheets if "/colors/" in s] + assert len(color_sheets) == 2 + + def test_nested_themes_union_colors(self): + """Nested Theme components contribute the union of their colors.""" + import reflex as rx + + root = rx.box( + rx.theme(accent_color="green"), + rx.theme(accent_color="pink"), + ) + sheets = compiler.get_radix_themes_stylesheets([root]) + color_sheets = {s for s in sheets if "/colors/" in s} + assert "@radix-ui/themes/tokens/colors/green.css" in color_sheets + assert "@radix-ui/themes/tokens/colors/pink.css" in color_sheets + + def test_dynamic_color_falls_back_to_monolith(self): + """A state-driven Theme color forces the monolithic stylesheet.""" + from typing import Literal + + import reflex as rx + + class _S(rx.State): + color: Literal["red", "blue"] = "red" + + sheets = compiler.get_radix_themes_stylesheets([ + rx.theme(accent_color=_S.color) + ]) + assert sheets == ["@radix-ui/themes/styles.css"] + + +class TestCollectWindowLibraryImports: + """Tests for the named-import collection that drives window.__reflex.""" + + def test_always_emits_internal_modules(self): + """Internal Reflex modules always map to None (star import) so that + ``window.__reflex`` is populated for dynamic components / plugins even + when the app has no statically-referenced external tags. + """ + result = compiler.collect_window_library_imports([{}]) + assert result["$/utils/state"] is None + # External libs with no referenced tags are omitted entirely. + assert "@radix-ui/themes" not in result + + def test_external_lib_gets_named_imports_from_usage(self): + """External library exposure on window.__reflex uses named imports. + + Star imports would pin every export of the library onto the critical + path and defeat Rolldown's tree-shaking. + """ + from reflex_base.utils.imports import ImportVar + + # Separate sources = separate pages/app_root. Mirrors how app.py + # passes per-source dicts so tags from multiple sources don't clobber. + sources = [ + # Page that renders a Component-typed Var triggers evalReactComponent + {"$/utils/state": [ImportVar(tag="evalReactComponent")]}, + # App root uses Theme + Button from Radix Themes + { + "@radix-ui/themes@3.3.0": [ + ImportVar(tag="Theme"), + ImportVar(tag="Button"), + ] + }, + ] + result = compiler.collect_window_library_imports(sources) + assert result["@radix-ui/themes"] == {"Theme", "Button"} + + def test_multiple_sources_union_tags_per_library(self): + """Tags from different sources for the same lib must be unioned.""" + from reflex_base.utils.imports import ImportVar + + sources = [ + { + "$/utils/state": [ImportVar(tag="evalReactComponent")], + "@radix-ui/themes@3.3.0": [ImportVar(tag="Theme")], + }, + # A different page that uses Button instead of Theme + {"@radix-ui/themes@3.3.0": [ImportVar(tag="Button")]}, + ] + result = compiler.collect_window_library_imports(sources) + assert result["@radix-ui/themes"] == {"Theme", "Button"} + + def test_internal_lib_uses_star_import(self): + """Internal Reflex modules still use star imports (small, controlled).""" + from reflex_base.utils.imports import ImportVar + + sources = [{"$/utils/state": [ImportVar(tag="evalReactComponent")]}] + result = compiler.collect_window_library_imports(sources) + # Internal modules map to None (= star import) + assert result["$/utils/state"] is None + + def test_create_document_root(): """Test that the document root is created correctly.""" # Test with no components. From 31820eccef95ad41f92a7a6fad7a1782288dd31b Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 18 Apr 2026 21:55:20 +0500 Subject: [PATCH 14/16] fix: expose dynamic component tags on window.__reflex Capture named imports during Component serialization so tags only reachable via evalReactComponent (Component-typed vars, state field defaults) are included in window.__reflex. Without this, dynamic components fail to resolve their imports after the tree-shaking changes. Also splits @radix-ui/themes into its own chunk. --- .../src/reflex_base/compiler/templates.py | 4 ++ .../src/reflex_base/components/dynamic.py | 16 +++++ reflex/app.py | 3 + reflex/compiler/compiler.py | 19 +++--- tests/units/compiler/test_compiler.py | 59 ++++++++----------- 5 files changed, 60 insertions(+), 41 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index bd79e9ac833..5727a200ee0 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -668,6 +668,10 @@ def vite_config_template( test: /node_modules\/recharts|node_modules\/d3-/, name: "recharts", }}, + {{ + test: /node_modules\/@radix-ui\/themes/, + name: "radix-themes", + }}, ], }}, }}, diff --git a/packages/reflex-base/src/reflex_base/components/dynamic.py b/packages/reflex-base/src/reflex_base/components/dynamic.py index 6c2100a40e8..a9a156127ca 100644 --- a/packages/reflex-base/src/reflex_base/components/dynamic.py +++ b/packages/reflex-base/src/reflex_base/components/dynamic.py @@ -36,6 +36,17 @@ def get_cdn_url(lib: str) -> str: ] +# Captured during Component serialization so ``collect_window_library_imports`` +# can expose tags reachable only through eval'd code on ``window.__reflex``; +# without this, ``evalReactComponent`` would fail to resolve them. +dynamic_component_imports: dict[str, set[imports.ImportVar]] = {} + + +def reset_dynamic_component_imports() -> None: + """Clear the captured dynamic-component import set.""" + dynamic_component_imports.clear() + + def bundle_library(component: Union["Component", str]): """Bundle a library with the component. @@ -98,6 +109,11 @@ def make_component(component: Component) -> str: component_imports = component._get_all_imports() compiler._apply_common_imports(component_imports) + for lib, ivs in component_imports.items(): + named = {iv for iv in ivs if iv.tag and not iv.is_default} + if named: + dynamic_component_imports.setdefault(lib, set()).update(named) + imports = {} for lib, names in component_imports.items(): formatted_lib_name = format_library_name(lib) diff --git a/reflex/app.py b/reflex/app.py index c10a25fb8d9..010b5bad81a 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1149,8 +1149,11 @@ def _compile( ReflexRuntimeError: When any page uses state, but no rx.State subclass is defined. FileNotFoundError: When a plugin requires a file that does not exist. """ + from reflex_base.components.dynamic import reset_dynamic_component_imports from reflex_base.utils.exceptions import ReflexRuntimeError + reset_dynamic_component_imports() + self._apply_decorated_pages() self._pages = {} diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index e85c7bda416..7b76652c479 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -5,6 +5,7 @@ import sys from collections.abc import Callable, Iterable, Sequence from inspect import getmodule +from itertools import chain from pathlib import Path from typing import TYPE_CHECKING, Any @@ -79,11 +80,12 @@ def collect_window_library_imports( collected from the app's actual static usage so Rolldown can tree-shake) or ``None`` (for internal Reflex modules, which use a star import). - ``window.__reflex`` is always emitted because consumers are diverse -- - ``evalReactComponent`` (dynamic components) is one, but plugins like - ``reflex_enterprise``'s ``LiteralLambdaVar`` also emit JS that reads - ``window.__reflex['']`` and a static detection is fragile. Named - imports keep the bundle tree-shakable regardless. + External library tags come from two sources: + (1) static page / app-root imports, and + (2) tags captured during compile-time serialization of dynamic Component + values (Component-typed state field defaults, computed Component vars + evaluated when generating the initial state) -- see + ``reflex_base.components.dynamic.dynamic_component_imports``. Takes an iterable of import dicts (one per page / app_root / memo group) instead of a single merged dict because ``dict.update`` loses information @@ -96,11 +98,14 @@ def collect_window_library_imports( Mapping from library path to either the set of named exports to expose (external libs) or None (internal libs, use star import). """ - from reflex_base.components.dynamic import bundled_libraries + from reflex_base.components.dynamic import ( + bundled_libraries, + dynamic_component_imports, + ) from reflex_base.utils.format import format_library_name per_lib_tags: dict[str, set[str]] = {} - for source in import_sources: + for source in chain(import_sources, (dynamic_component_imports,)): for imported_lib, import_vars in source.items(): key = format_library_name(imported_lib) for iv in import_vars: diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index faf0cf09bea..8c69c51d8e4 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -426,32 +426,33 @@ class _S(rx.State): class TestCollectWindowLibraryImports: - """Tests for the named-import collection that drives window.__reflex.""" + """Tests for the import collection that drives window.__reflex.""" - def test_always_emits_internal_modules(self): - """Internal Reflex modules always map to None (star import) so that - ``window.__reflex`` is populated for dynamic components / plugins even - when the app has no statically-referenced external tags. + @pytest.fixture(autouse=True) + def _isolate_dynamic_imports(self): + from reflex_base.components.dynamic import reset_dynamic_component_imports + + reset_dynamic_component_imports() + yield + reset_dynamic_component_imports() + + def test_internal_modules_always_star_imported(self): + """Internal Reflex modules map to None (star import) so dynamic + components / plugins reading window.__reflex find what they need + even when the app has no static external references. """ result = compiler.collect_window_library_imports([{}]) assert result["$/utils/state"] is None - # External libs with no referenced tags are omitted entirely. assert "@radix-ui/themes" not in result - def test_external_lib_gets_named_imports_from_usage(self): - """External library exposure on window.__reflex uses named imports. - - Star imports would pin every export of the library onto the critical - path and defeat Rolldown's tree-shaking. + def test_external_lib_uses_named_imports_from_static_usage(self): + """External library exposure on window.__reflex uses named imports + so Rolldown can tree-shake unused exports. """ from reflex_base.utils.imports import ImportVar - # Separate sources = separate pages/app_root. Mirrors how app.py - # passes per-source dicts so tags from multiple sources don't clobber. sources = [ - # Page that renders a Component-typed Var triggers evalReactComponent {"$/utils/state": [ImportVar(tag="evalReactComponent")]}, - # App root uses Theme + Button from Radix Themes { "@radix-ui/themes@3.3.0": [ ImportVar(tag="Theme"), @@ -462,29 +463,19 @@ def test_external_lib_gets_named_imports_from_usage(self): result = compiler.collect_window_library_imports(sources) assert result["@radix-ui/themes"] == {"Theme", "Button"} - def test_multiple_sources_union_tags_per_library(self): - """Tags from different sources for the same lib must be unioned.""" + def test_unions_dynamic_component_tags(self): + """Tags captured during dynamic-Component serialization are unioned + into the named-import surface so runtime-eval'd code finds them on + window.__reflex. + """ + from reflex_base.components.dynamic import dynamic_component_imports from reflex_base.utils.imports import ImportVar - sources = [ - { - "$/utils/state": [ImportVar(tag="evalReactComponent")], - "@radix-ui/themes@3.3.0": [ImportVar(tag="Theme")], - }, - # A different page that uses Button instead of Theme - {"@radix-ui/themes@3.3.0": [ImportVar(tag="Button")]}, - ] - result = compiler.collect_window_library_imports(sources) - assert result["@radix-ui/themes"] == {"Theme", "Button"} + sources = [{"@radix-ui/themes@3.3.0": [ImportVar(tag="Theme")]}] + dynamic_component_imports["@radix-ui/themes@3.3.0"] = {ImportVar(tag="Flex")} - def test_internal_lib_uses_star_import(self): - """Internal Reflex modules still use star imports (small, controlled).""" - from reflex_base.utils.imports import ImportVar - - sources = [{"$/utils/state": [ImportVar(tag="evalReactComponent")]}] result = compiler.collect_window_library_imports(sources) - # Internal modules map to None (= star import) - assert result["$/utils/state"] is None + assert result["@radix-ui/themes"] == {"Theme", "Flex"} def test_create_document_root(): From d8c9beac3586809eac5fcfbb1f9a25112f705493 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:39:09 +0500 Subject: [PATCH 15/16] Update packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../reflex-base/src/reflex_base/plugins/shared_tailwind.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py b/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py index 4b19963403c..19c822f7298 100644 --- a/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py +++ b/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py @@ -11,14 +11,10 @@ from .base import Plugin as PluginBase _RADIX_IMPORT_RE = re.compile( - r"^@import url\(['\"]@radix-ui/themes/[^'\"]+['\"]\);\s*\n?", + r"^@import (?:url\(['\"]|['\"])@radix-ui/themes/[^'\"]+['\"](?:\))?(?:\s+layer\(\w+\))?;\s*\n?", re.MULTILINE, ) - -def strip_radix_theme_imports(css: str) -> tuple[str, int]: - """Remove every Radix Themes @import line from a stylesheet. - Handles both the monolithic ``styles.css`` and the granular per-token imports emitted by the compiler. From cd16bf4063c858e12fb676f9f64ddb4f5e97e502 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sun, 19 Apr 2026 22:42:21 +0500 Subject: [PATCH 16/16] fix: greptile code fix --- .../reflex-base/src/reflex_base/plugins/shared_tailwind.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py b/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py index 19c822f7298..1c915228599 100644 --- a/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py +++ b/packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py @@ -15,6 +15,10 @@ re.MULTILINE, ) + +def strip_radix_theme_imports(css: str) -> tuple[str, int]: + """Remove every Radix Themes @import line from a stylesheet. + Handles both the monolithic ``styles.css`` and the granular per-token imports emitted by the compiler.