diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index e6cc29dd98a..fdfb93f6c7f 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -44,3 +44,36 @@ 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: | + 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 + if: always() + uses: actions/upload-artifact@v4 + with: + name: lighthouse-report + path: .pytest-tmp/lighthouse + if-no-files-found: ignore 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-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..12a7b3ddef9 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-compress.js @@ -0,0 +1,156 @@ +/* vite-plugin-compress.js + * + * 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"; + +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)$/; + +// 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; + +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 pre-compressed files for eligible build assets. + * @param {{ formats?: string[] }} [options] + * @returns {import('vite').Plugin} + */ +export default function compressPlugin(options = {}) { + const formats = normalizeFormats(options.formats); + + return { + name: "vite-plugin-compress", + apply: "build", + enforce: "post", + + 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 be3e3f6eee4..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. @@ -526,6 +528,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 +569,7 @@ def vite_config_template( alwaysUseReactDomServerNode(), reactRouter(), safariCacheBustPlugin(), + 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), @@ -583,6 +587,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/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/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 f7900836abd..f993d2dd7c4 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -1,123 +1,6 @@ { - "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/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/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/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/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/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/scripts/run_lighthouse.py b/scripts/run_lighthouse.py new file mode 100644 index 00000000000..84ffdf658eb --- /dev/null +++ b/scripts/run_lighthouse.py @@ -0,0 +1,73 @@ +"""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 ( + 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. + + 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__": + raise SystemExit(main()) diff --git a/tests/integration/lighthouse_utils.py b/tests/integration/lighthouse_utils.py new file mode 100644 index 00000000000..06c24818795 --- /dev/null +++ b/tests/integration/lighthouse_utils.py @@ -0,0 +1,986 @@ +"""Shared utilities for Lighthouse benchmarking.""" + +from __future__ import annotations + +import json +import operator +import os +import re +import shlex +import shutil +import subprocess +import time +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest + +from reflex.testing import 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_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" +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) +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, 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 = [ + f"Lighthouse summary for {label}", + "", + 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 _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): + initialize_default_app(app_name) + if page_source is not None: + (Path(app_name) / f"{app_name}.py").write_text(page_source) + + +def _run_prod_lighthouse_benchmark( + app_root: Path, + app_name: str, + report_path: Path, + label: str, +) -> LighthouseBenchmarkResult: + """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. + 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. + """ + report_path.parent.mkdir(parents=True, exist_ok=True) + + 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}" + ) + + # 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: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + 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, 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 new file mode 100644 index 00000000000..1b48e3a1540 --- /dev/null +++ b/tests/integration/test_lighthouse.py @@ -0,0 +1,84 @@ +"""Lighthouse benchmark tests for production Reflex apps.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from .lighthouse_utils import ( + run_blank_prod_lighthouse_benchmark, + run_landing_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 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, +) -> 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/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/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 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"