Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a453ccb
feat(compiler): add incremental compile cache (REFLEX_COMPILE_CACHE)
FarhanAliRaza Jun 28, 2026
8360450
feat(compiler): warm fork-per-compile hot-reload daemon
FarhanAliRaza Jun 28, 2026
7078d9e
perf(docs): build component prop-docs lazily at page eval
FarhanAliRaza Jun 28, 2026
5ba6614
perf(compiler): trim compile-daemon hot reload
FarhanAliRaza Jun 28, 2026
fe06056
perf(compiler): shrink compile-cache manifest, drop in-process cache
FarhanAliRaza Jun 29, 2026
9f1b1c2
refactor(compiler): drop unused page_cache helpers
FarhanAliRaza Jun 29, 2026
43d5351
fix(compiler): track app-level config and memo imports in compile cache
FarhanAliRaza Jun 29, 2026
19b2df4
test(compiler): skip model-metadata daemon test without sqlmodel
FarhanAliRaza Jun 29, 2026
aac67b2
docs(changelog): add news fragments for the incremental compile cache
FarhanAliRaza Jun 29, 2026
519f885
feat(compiler): track runtime module imports in compile cache
FarhanAliRaza Jun 29, 2026
f79b9db
refactor(compiler): simplify page_cache module-file recording
FarhanAliRaza Jun 30, 2026
451be1e
fix(compiler): guard read-tracker recursion on pathlib lazy imports
FarhanAliRaza Jul 1, 2026
724cd3d
feat(compiler): track dynamic app-import reads for the compile cache
FarhanAliRaza Jul 1, 2026
21e7d3e
fix(compiler): close staleness gaps in the incremental compile cache
FarhanAliRaza Jul 3, 2026
14e5a3c
fix(compiler): make compile output deterministic so dev HMR stays gra…
FarhanAliRaza Jul 3, 2026
bf6a8bf
fix(compiler): keep contexts file and HMR intact across hot reloads
FarhanAliRaza Jul 3, 2026
5cb52c5
perf(compiler): cut redundant work in the incremental compile path
FarhanAliRaza Jul 3, 2026
6dbface
test(compiler): scope contexts snapshot to the test file's states
FarhanAliRaza Jul 3, 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
15 changes: 8 additions & 7 deletions docs/app/reflex_docs/pages/docs/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -921,16 +921,9 @@ def multi_docs(
title: str,
ll_component_list: list | None = None,
):
components = [
component_docs(component_tuple, previews)
for component_tuple in component_list[1:]
]
ll_actual_path = actual_path.replace(".md", "-ll.md")
ll_doc_exists = os.path.exists(ll_actual_path)
ll_list = ll_component_list if ll_component_list is not None else component_list
ll_components = [
component_docs(component_tuple, previews) for component_tuple in ll_list[1:]
]

active_class_name = "font-small bg-secondary-2 p-2 text-secondary-11 rounded-xl shadow-large w-28 cursor-default border border-secondary-4 text-center"

Expand Down Expand Up @@ -982,6 +975,11 @@ def links(current_page, ll_doc_exists, path):

@docpage(set_path=path, t=title)
def out():
# Build prop docs during page eval so imports stay cheap.
components = [
component_docs(component_tuple, previews)
for component_tuple in component_list[1:]
]
toc = get_docgen_toc(actual_path)
doc_content = Path(actual_path).read_text(encoding="utf-8")
# Append API Reference headings for the component list
Expand Down Expand Up @@ -1010,6 +1008,9 @@ def out():

@docpage(set_path=path + "low", t=title + " (Low Level)")
def ll():
ll_components = [
component_docs(component_tuple, previews) for component_tuple in ll_list[1:]
]
ll_virtual = virtual_path.replace(".md", "-ll.md")
toc = get_docgen_toc(ll_actual_path)
doc_content = Path(ll_actual_path).read_text(encoding="utf-8")
Expand Down
1 change: 1 addition & 0 deletions news/6688.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added an experimental disk-persisted incremental compile cache, enabled by the `REFLEX_COMPILE_CACHE` environment variable. When on, a fresh compile reuses the previous build already on disk in `.web` and recompiles only the pages whose source changed, tracked via a per-page dependency graph (Python import closure, files read during page evaluation, component modules, and referenced state). App-wide inputs (Reflex version, config/lockfiles, and the app entrypoint's config modules such as theme/app-wraps/stylesheets) gate the whole cache, falling back to a full compile when they change. `reflex run` dev additionally gains a warm fork-per-compile daemon so hot reloads skip the cold reimport and rebuild only what changed. Off by default — the compile path is unchanged when the flag is unset.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6688.misc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added an optional per-page source-read recorder hook (`page_source_recorder` in the compiler plugin) used by the incremental compile cache to track the exact files each page reads during evaluation, and made auto-generated unique ref names reproducible across in-process compiles so memo content hashes stay stable. No behavior change unless the compile cache is enabled.
6 changes: 6 additions & 0 deletions packages/reflex-base/src/reflex_base/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,12 @@ class EnvironmentVariables:
# If this env var is set to "yes", App.compile will be a no-op
REFLEX_SKIP_COMPILE: EnvVar[bool] = env_var(False, internal=True)

# Experimental: incremental compile cache. A fresh compile process (e.g. a
# reflex-run hot-reload worker) reuses each page's compiled output from an
# on-disk manifest and recompiles only the pages whose source changed.
# See reflex/compiler/disk_cache.py and reflex/compiler/page_cache.py.
REFLEX_COMPILE_CACHE: EnvVar[bool] = env_var(False)

# Inherited by uvicorn/granian reload workers so the backend can distinguish
# dev reload-capable worker boots from other backend starts. Never set in prod.
REFLEX_DEV_BACKEND_RELOAD_ACTIVE: EnvVar[bool] = env_var(False, internal=True)
Expand Down
46 changes: 40 additions & 6 deletions packages/reflex-base/src/reflex_base/plugins/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import dataclasses
import inspect
from collections.abc import Callable, Sequence
from contextlib import AbstractContextManager
from contextvars import ContextVar, Token
from types import TracebackType
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias, TypeVar, cast
Expand Down Expand Up @@ -35,6 +36,10 @@
_BaseComponentT = TypeVar("_BaseComponentT", bound=BaseComponent)


#: Optional recorder for source files read during each page evaluation.
page_source_recorder: Callable[[], AbstractContextManager[set[str]]] | None = None


class PageDefinition(Protocol):
"""Protocol for page-like objects compiled by :class:`CompileContext`."""

Expand Down Expand Up @@ -690,6 +695,12 @@ class PageContext(BaseContext):
output_path: str | None = None
output_code: str | None = None
source_module: str | None = None
# Source files read while evaluating this page, when a recorder is installed.
source_files: set[str] = dataclasses.field(default_factory=set)
# Auto-memo components first registered while compiling this page.
memo_contributions: dict[tuple[str, str | None], Any] = dataclasses.field(
default_factory=dict
)
# Stack of ``id(component)`` for components whose subtree is
# memoize-suppressed. Populated by ``MemoizeStatefulPlugin`` when it
# encounters a ``MemoizationLeaf``-style snapshot boundary and popped on
Expand Down Expand Up @@ -794,6 +805,7 @@ def compile(
"""
from reflex.compiler import compiler
from reflex.state import all_base_state_classes
from reflex_base.vars.base import reset_unique_variable_names

self.ensure_context_attached()
self.compiled_pages.clear()
Expand All @@ -803,15 +815,30 @@ def compile(
self.memoize_wrappers.clear()
self.auto_memo_components.clear()

# Keep generated ref names stable across in-process compiles.
reset_unique_variable_names()
Comment thread
FarhanAliRaza marked this conversation as resolved.

recorder = page_source_recorder
for page in self.pages:
page_fn = page.component
n_states_before = len(all_base_state_classes)
page_ctx = self.hooks.eval_page(
page_fn,
page=page,
compile_context=self,
**kwargs,
)
if recorder is not None:
with recorder() as read_set:
page_ctx = self.hooks.eval_page(
page_fn,
page=page,
compile_context=self,
**kwargs,
)
if page_ctx is not None:
page_ctx.source_files = read_set
else:
page_ctx = self.hooks.eval_page(
page_fn,
page=page,
compile_context=self,
**kwargs,
)
if page_ctx is None:
page_name = getattr(page_fn, "__name__", repr(page_fn))
msg = (
Expand All @@ -836,6 +863,7 @@ def compile(
self.compiled_pages.values(),
strict=True,
):
memo_before = set(self.auto_memo_components)
with page_ctx:
page_ctx.root_component = self.hooks.compile_component(
page_ctx.root_component,
Expand All @@ -848,6 +876,12 @@ def compile(
compile_context=self,
**kwargs,
)
# Attribute newly-registered auto-memo components to this page.
page_ctx.memo_contributions = {
key: value
for key, value in self.auto_memo_components.items()
if key not in memo_before
}

page_ctx.frontend_imports = page_ctx.merged_imports(collapse=True)
self.all_imports = merge_imports(
Expand Down
17 changes: 11 additions & 6 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import functools
import inspect
import json
import random
import re
import string
import uuid
Expand Down Expand Up @@ -44,7 +45,6 @@
from reflex_base.constants.state import FIELD_MARKER
from reflex_base.utils import console, exceptions, imports, serializers, types
from reflex_base.utils.compat import annotations_from_namespace
from reflex_base.utils.decorator import once
from reflex_base.utils.exceptions import (
ComputedVarSignatureError,
UntypedComputedVarError,
Expand Down Expand Up @@ -3211,12 +3211,17 @@ def get_uuid_string_var() -> Var:
# Set of unique variable names.
USED_VARIABLES = set()

_UNIQUE_NAME_RNG = random.Random(42)

@once
def _rng():
import random

return random.Random(42)
def reset_unique_variable_names() -> None:
"""Reset the deterministic unique-name generator to its initial state.

Names only need to be unique within one compile, so resetting before each
compile makes auto-generated ref names reproducible.
"""
USED_VARIABLES.clear()
_UNIQUE_NAME_RNG.seed(42)


def get_unique_variable_name() -> str:
Expand All @@ -3225,7 +3230,7 @@ def get_unique_variable_name() -> str:
Returns:
The unique variable name.
"""
name = "".join([_rng().choice(string.ascii_lowercase) for _ in range(8)])
name = "".join([_UNIQUE_NAME_RNG.choice(string.ascii_lowercase) for _ in range(8)])
if name not in USED_VARIABLES:
USED_VARIABLES.add(name)
return name
Expand Down
59 changes: 47 additions & 12 deletions reflex/compiler/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ def _set_progress_total(
progress.update(task, total=total)


def make_compile_progress(use_rich: bool) -> Progress | console.PoorProgress:
"""Build a compile progress bar.

Args:
use_rich: Whether to use a rich progress bar (else a plain fallback).

Returns:
A progress bar suitable for tracking a compile.
"""
if use_rich:
return Progress(
*Progress.get_default_columns()[:-1],
MofNCompleteColumn(),
TimeElapsedColumn(),
)
return console.PoorProgress()


def _apply_common_imports(
imports: dict[str, list[ImportVar]],
):
Expand Down Expand Up @@ -954,7 +972,7 @@ def compile_unevaluated_page(
meta_args["description"] = page.description

# Add meta information to the component.
utils.add_meta(
component = utils.add_meta(
component,
**meta_args,
)
Expand Down Expand Up @@ -1138,20 +1156,30 @@ def compile_app(
app._add_optional_endpoints()
return False

progress = (
Progress(
*Progress.get_default_columns()[:-1],
MofNCompleteColumn(),
TimeElapsedColumn(),
)
if use_rich
else console.PoorProgress()
)
fixed_steps = 7
cache_on = not dry_run and environment.REFLEX_COMPILE_CACHE.get()

compiler_plugins, radix_themes_plugin = _resolve_radix_themes_plugin(
app,
config.plugins,
)

# Experimental incremental compile cache: in a fresh process, recompile only
# the pages whose source changed and reuse the rest from the on-disk
# manifest. Falls back to a full compile on any unsafe condition.
if cache_on:
from reflex.compiler import disk_cache, page_cache

page_cache.enable_read_tracking()
if disk_cache.try_incremental_rebuild(
app,
compiler_plugins=compiler_plugins,
prerender_routes=prerender_routes,
use_rich=use_rich,
):
return True

progress = make_compile_progress(use_rich)
fixed_steps = 7
reset_bundled_libraries()
# Drop cached memo wrapper classes so each compile recomputes a memo's
# ``library`` from the current module layout (handles a module flipping to
Expand All @@ -1163,9 +1191,11 @@ def compile_app(
base_total = (len(app._unevaluated_pages) * 2) + fixed_steps + len(config.plugins)
progress.start()
task = progress.add_task("Compiling:", total=base_total)
all_pages = list(app._unevaluated_pages.values())

compile_ctx = CompileContext(
app=app,
pages=list(app._unevaluated_pages.values()),
pages=all_pages,
hooks=CompilerHooks(
plugins=default_page_plugins(style=app.style, plugins=compiler_plugins)
),
Expand Down Expand Up @@ -1407,4 +1437,9 @@ def add_save_task(
for output_path, code in output_mapping.items():
utils.write_file(output_path, code)

if cache_on:
from reflex.compiler import disk_cache

disk_cache.write_manifest(compile_ctx, all_pages, all_imports)

return True
Loading
Loading