Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2ec0b7a
lighthouse test
FarhanAliRaza Apr 7, 2026
8ca31a5
Improve Lighthouse scores and add landing page benchmark
FarhanAliRaza Apr 7, 2026
cb3e850
Add gzip pre-compression plugin and optimize Vite chunk splitting
FarhanAliRaza Apr 7, 2026
40d5aa7
warmup request
FarhanAliRaza Apr 7, 2026
3f6f2a0
Add configurable pre-compression formats and serve precompressed stat…
FarhanAliRaza Apr 9, 2026
cacc11b
feat: add image optimization, CSS purging, and shared Vite plugin uti…
FarhanAliRaza Apr 14, 2026
11aae47
Merge remote-tracking branch 'upstream/main' into lighthouse-improve
FarhanAliRaza Apr 14, 2026
10d0893
feat: add post-build static compression and use JSON5 for socket parsing
FarhanAliRaza Apr 14, 2026
a633787
removed purge css because purgre css does not really work withh radix…
FarhanAliRaza Apr 14, 2026
56fe0f2
feat: serve precompressed static assets in frontend mount
FarhanAliRaza Apr 14, 2026
195986b
feat: use static imports for window libraries and simplify layout setup
FarhanAliRaza Apr 16, 2026
522bcc0
Merge remote-tracking branch 'upstream/main' into lighthouse-improve
FarhanAliRaza Apr 16, 2026
6f9543f
test: add missing prepend_frontend_path param
FarhanAliRaza Apr 16, 2026
dd6803e
feat: make node version configurable and improve timeout error messages
FarhanAliRaza Apr 16, 2026
fa39149
perf: tree-shake radix themes and window.__reflex imports
FarhanAliRaza Apr 17, 2026
31820ec
fix: expose dynamic component tags on window.__reflex
FarhanAliRaza Apr 18, 2026
d8c9bea
Update packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py
FarhanAliRaza Apr 19, 2026
cd16bf4
fix: greptile code fix
FarhanAliRaza Apr 19, 2026
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
6 changes: 5 additions & 1 deletion .github/actions/setup_build_env/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ inputs:
python-version:
description: "Python version setup"
required: true
node-version:
description: "Node.js version setup"
required: false
default: "22"
run-uv-sync:
description: "Whether to run uv sync on current dir"
required: false
Expand All @@ -37,7 +41,7 @@ runs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: ${{ inputs.node-version }}
- name: Install Dependencies
if: inputs.run-uv-sync == 'true'
run: uv sync
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/performance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,37 @@ 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"
node-version: "22"
run-uv-sync: true

- name: Install playwright
run: uv run playwright install chromium --only-shell
Comment on lines +64 to +65
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
2 changes: 2 additions & 0 deletions docs/getting_started/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions docs/hosting/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/pages/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<main>` 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 `<main>` 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.
Expand Down Expand Up @@ -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.
Expand Down
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,9 @@
import { compressDirectory } from "./vite-plugin-compress.js";

const [directory, formatsArg = "[]"] = process.argv.slice(2);

if (!directory) {
throw new Error("Missing static output directory for compression.");
}

await compressDirectory(directory, JSON.parse(formatsArg));
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import JSON5 from "json5";
import env from "$/env.json";

/**
Expand Down Expand Up @@ -45,7 +44,7 @@ export const uploadFiles = async (
// So only process _new_ chunks beyond resp_idx.
chunks.slice(resp_idx).map((chunk_json) => {
try {
const chunk = JSON5.parse(chunk_json);
const chunk = JSON.parse(chunk_json);
event_callbacks.map((f, ix) => {
f(chunk)
.then(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/* 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 } from "node:path";
import { access, readFile, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import {
validateFormats,
outputDirectoryExists,
walkFiles,
} from "./vite-plugin-utils.js";

const gzipAsync = promisify(zlib.gzip);
const brotliAsync =
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 assets rarely benefit,
// but HTML entrypoints are always compressed so their negotiated sidecars exist.
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),
},
};

// Concurrency limit for parallel file compression.
const CONCURRENCY = 16;

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 compressFile(filePath, formats) {
const pendingFormats = [];
for (const format of formats) {
const compressor = COMPRESSORS[format];
try {
await access(filePath + compressor.extension);
} catch {
pendingFormats.push([format, compressor]);
}
}

if (pendingFormats.length === 0) return;

const raw = await readFile(filePath);
if (raw.length < MIN_SIZE && !filePath.endsWith(".html")) return;

await Promise.all(
pendingFormats.map(([_format, compressor]) => {
return compressor
.compress(raw)
.then((compressed) =>
writeFile(filePath + compressor.extension, compressed),
);
}),
);
}

export async function compressDirectory(directory, formats = ["gzip"]) {
validateFormats(formats, COMPRESSORS, "frontend compression format");
ensureFormatsSupported(formats);

if (!(await outputDirectoryExists(directory))) {
return;
}

const pending = [];
for await (const filePath of walkFiles(directory)) {
if (!COMPRESSIBLE_EXTENSIONS.test(filePath)) continue;
pending.push(filePath);
}

for (let i = 0; i < pending.length; i += CONCURRENCY) {
await Promise.all(
pending
.slice(i, i + CONCURRENCY)
.map((file) => compressFile(file, formats)),
);
}
}

/**
* 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 = options.formats ?? ["gzip"];
validateFormats(formats, COMPRESSORS, "frontend compression format");

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);
},
};
}
Loading
Loading