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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/performance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +63 to +64
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Node.js version not pinned

The lighthouse job falls back to npx --yes lighthouse@13.1.0 (via get_lighthouse_command()) when no lighthouse binary is found, relying on whatever Node.js version is pre-installed on ubuntu-22.04. The exact Node.js version shipped with GitHub's runner images can change without notice and is not formally guaranteed.

Adding an explicit actions/setup-node step would make the workflow reproducible across runner image updates:

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - 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
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
Original file line number Diff line number Diff line change
@@ -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);
},
};
}
12 changes: 12 additions & 0 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -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() {{
Expand Down Expand Up @@ -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),
Expand All @@ -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",
}},
],
}},
}},
Expand Down
36 changes: 35 additions & 1 deletion packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading