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

// react-router's HMR client (refresh-utils.mjs enqueueUpdate) throws when an
// update batch includes a route the browser hasn't loaded, and the throw skips
// the queue cleanup below it — one edit to any not-currently-open page then
// poisons HMR until a full page reload. Rewrite the served runtime so unloaded
// routes keep their manifest metadata update but stay lazy.
function patchReactRouterHmrRuntime() {{
const unloadedRouteThrow = /if\s*\(!imported\)\s*\{{\s*throw\s+Error\([\s\S]*?\);\s*\}}/;
return {{
name: "reflex-patch-react-router-hmr-runtime",
apply: "serve",
transform(code, id) {{
if (id !== "\0virtual:react-router/hmr-runtime") return;
if (!unloadedRouteThrow.test(code)) {{
this.warn(
"react-router hmr runtime changed; unloaded-route HMR patch skipped",
);
return;
}}
return {{
code: code.replace(unloadedRouteThrow, "if (!imported) continue;"),
map: null,
}};
}},
}};
}}

export default defineConfig((config) => ({{
base: "{base}",
plugins: [
alwaysUseReactDomServerNode(),
reactRouter(),
safariCacheBustPlugin(),
patchReactRouterHmrRuntime(),
].concat({"[fullReload()]" if force_full_reload else "[]"}),
build: {{
sourcemap: {"true" if sourcemap is True else "false" if sourcemap is False else repr(sourcemap)},
Expand Down
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
57 changes: 49 additions & 8 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 @@ -762,7 +773,9 @@ class CompileContext(BaseContext):
app_wrap_components: dict[tuple[int, str], Component] = dataclasses.field(
default_factory=dict
)
stateful_routes: dict[str, None] = dataclasses.field(default_factory=dict)
# Routes whose evaluation defined new state classes, mapped to the full
# names of the states each page defined.
stateful_routes: dict[str, list[str]] = dataclasses.field(default_factory=dict)
# Auto-memoize wrapper tags seen during the tree walk (populated by
# ``MemoizeStatefulPlugin``).
memoize_wrappers: dict[str, None] = dataclasses.field(default_factory=dict)
Expand Down Expand Up @@ -794,6 +807,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 +817,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 @@ -824,7 +853,12 @@ def compile(
raise RuntimeError(msg)

if len(all_base_state_classes) > n_states_before:
self.stateful_routes[page.route] = None
# Record which states this page defined (registration order is
# insertion order), so the compile cache can fingerprint the
# page's contribution to the contexts file.
self.stateful_routes[page.route] = list(all_base_state_classes)[
n_states_before:
]

self.compiled_pages[page_ctx.route] = page_ctx

Expand All @@ -836,6 +870,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 +883,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
10 changes: 5 additions & 5 deletions packages/reflex-base/src/reflex_base/utils/imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,18 @@ def collapse_imports(
) -> ParsedImportDict:
"""Remove all duplicate ImportVar within an ImportDict.

Deduplication preserves first-occurrence order: compiled import statements
follow this order, and a hash-seed-dependent order would rewrite every
page/memo module on each dev reload, defeating granular HMR.

Args:
imports: The import dict to collapse.

Returns:
The collapsed import dict.
"""
return {
lib: (
list(set(import_vars))
if isinstance(import_vars, list)
else list(import_vars)
)
lib: list(dict.fromkeys(import_vars))
for lib, import_vars in (
imports if isinstance(imports, tuple) else imports.items()
)
Expand Down
9 changes: 8 additions & 1 deletion packages/reflex-base/src/reflex_base/utils/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,20 @@ def serialize_base_model(model: BaseModel) -> dict:
def serialize_set(value: set) -> list:
"""Serialize a set to a JSON serializable list.

Sets have no meaningful order and their iteration order varies with the
per-process hash seed, so sort when possible to keep serialized output
(compiled JSX, config fingerprints) stable across processes.

Args:
value: The set to serialize.

Returns:
The serialized list.
"""
return list(value)
try:
return sorted(value)
except TypeError:
return list(value)


@serializer
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
8 changes: 6 additions & 2 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1346,8 +1346,12 @@ def _write_stateful_pages_marker(self):
prerequisites.get_backend_dir() / constants.Dirs.STATEFUL_PAGES
)
stateful_pages_marker.parent.mkdir(parents=True, exist_ok=True)
with stateful_pages_marker.open("w") as f:
json.dump(list(self._stateful_pages), f)
content = json.dumps(list(self._stateful_pages))
if (
not stateful_pages_marker.exists()
or stateful_pages_marker.read_text() != content
):
stateful_pages_marker.write_text(content)

def add_all_routes_endpoint(self):
"""Add an endpoint to the app that returns all the routes."""
Expand Down
Loading
Loading