Skip to content

feat(compiler): add incremental compile cache (REFLEX_COMPILE_CACHE)#6688

Draft
FarhanAliRaza wants to merge 4 commits into
reflex-dev:mainfrom
FarhanAliRaza:reflex-hmr
Draft

feat(compiler): add incremental compile cache (REFLEX_COMPILE_CACHE)#6688
FarhanAliRaza wants to merge 4 commits into
reflex-dev:mainfrom
FarhanAliRaza:reflex-hmr

Conversation

@FarhanAliRaza

Copy link
Copy Markdown
Contributor

Add an experimental, flag-gated incremental frontend compile cache that recompiles only the pages whose source actually changed and reuses the rest.

Two layers, both off by default and enabled by REFLEX_COMPILE_CACHE:

  • In-process per-page cache (page_cache.py): a Salsa-style dependency graph records the exact set of source files each page reads, so editing one file invalidates only the pages that depend on it. Pages are keyed by a small genuinely-global epoch (Reflex version + rxconfig + lockfile) plus the content hashes of their dependency set. Speeds up repeat compiles within a single process.

  • On-disk manifest (disk_cache.py): persists each page's serializable contribution and dependency hashes to .web/reflex_compile_cache.json so a fresh process — notably a reflex run hot-reload worker, which respawns on every edit — recompiles only changed pages and reuses the rest. Falls back to a full compile on any unsafe condition.

REFLEX_COMPILE_CACHE_VERIFY runs a full compile alongside the cached one and asserts byte-identical output, falling back on mismatch — the backstop for gaps a static dependency graph cannot see (runtime importlib imports, data read at module-import time).

Supporting changes required for safe page reuse: deterministic compile-time ref-name generation, and own-before-mutate page metadata injection.

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

New Feature Submission:

  • Does your submission pass the tests?
  • Have you linted your code locally prior to submission?

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

After these steps, you're ready to open a pull request.

a. Give a descriptive title to your PR.

b. Describe your changes.

c. Put `closes #XXXX` in your comment to auto-close the issue that your PR fixes (if such).

Add an experimental, flag-gated incremental frontend compile cache that
recompiles only the pages whose source actually changed and reuses the rest.

Two layers, both off by default and enabled by REFLEX_COMPILE_CACHE:

- In-process per-page cache (page_cache.py): a Salsa-style dependency graph
  records the exact set of source files each page reads, so editing one file
  invalidates only the pages that depend on it. Pages are keyed by a small
  genuinely-global epoch (Reflex version + rxconfig + lockfile) plus the
  content hashes of their dependency set. Speeds up repeat compiles within a
  single process.

- On-disk manifest (disk_cache.py): persists each page's serializable
  contribution and dependency hashes to .web/reflex_compile_cache.json so a
  fresh process — notably a `reflex run` hot-reload worker, which respawns on
  every edit — recompiles only changed pages and reuses the rest. Falls back
  to a full compile on any unsafe condition.

REFLEX_COMPILE_CACHE_VERIFY runs a full compile alongside the cached one and
asserts byte-identical output, falling back on mismatch — the backstop for
gaps a static dependency graph cannot see (runtime importlib imports, data
read at module-import time).

Supporting changes required for safe page reuse: deterministic compile-time
ref-name generation, and own-before-mutate page metadata injection.
@FarhanAliRaza FarhanAliRaza requested a review from a team as a code owner June 28, 2026 15:37
@codspeed-hq

codspeed-hq Bot commented Jun 28, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing FarhanAliRaza:reflex-hmr (5ba6614) with main (f86f86c)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@FarhanAliRaza FarhanAliRaza marked this pull request as draft June 28, 2026 15:40
@greptile-apps

greptile-apps Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an experimental incremental compile cache for Reflex. The main changes are:

  • New flag-gated in-process page caching.
  • New disk manifest for reusing page compile output across workers.
  • Source-read tracking and dependency hashing for page invalidation.
  • Deterministic compile-time ref-name generation.
  • Verification mode that compares cached output with a full compile.

Confidence Score: 5/5

This looks safe to merge from this follow-up review.

  • No additional blocking issues were found beyond the already reported cache correctness areas.
  • The latest changes stay focused on the incremental cache plumbing and related compile metadata.

Important Files Changed

Filename Overview
reflex/compiler/disk_cache.py Adds the disk-backed incremental rebuild manifest, page partitioning, partial rebuild path, and manifest refresh logic.
reflex/compiler/page_cache.py Adds per-page source-read tracking, global epoch hashing, import graph dependency tracking, and the in-process page store.
reflex/compiler/compiler.py Connects the cache paths to compile_app, adds cache verification helpers, and writes the manifest after compile.
packages/reflex-base/src/reflex_base/plugins/compiler.py Adds page source recording hooks and tracks per-page memo contributions during compilation.

Reviews (4): Last reviewed commit: "perf(compiler): trim compile-daemon hot ..." | Re-trigger Greptile

Comment on lines +279 to +289
manifest = load_manifest()
if manifest is None:
return False

pages = list(app._unevaluated_pages.values())
routes = {p.route for p in pages}
hasher = page_cache.make_hasher()
epoch = page_cache.global_epoch(root)

if not globals_match(manifest, routes=routes, epoch=epoch):
return False

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.

P1 App Root Cache Stays Stale

When REFLEX_COMPILE_CACHE is enabled, editing only the app entrypoint to add or change an app-level wrapper, toaster, or provider can leave every page as a cache hit because the global epoch does not include that app-root state. This path then returns without running the normal app-wrap resolution, so the frontend can keep using the old app root and miss the new provider.

Comment thread reflex/compiler/disk_cache.py
Comment thread reflex/compiler/page_cache.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a453ccbd69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

!= entry["app_wrap_keys"]
):
return False
if (page.route in miss_ctx.stateful_routes) != entry["is_stateful"]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Regenerate contexts when state files miss

When a page miss is caused by editing an rx.State module used by that page, the page usually remains stateful, so this boolean guard passes and the disk-cache fast path continues. This path never runs compile_contexts(...) (the full compile writes it in reflex/compiler/compiler.py lines 1545-1547), leaving the frontend contexts file with old state defaults/vars while only page files are rewritten; fall back to a full compile for state-file misses or rewrite contexts before returning.

Useful? React with 👍 / 👎.

Comment on lines +368 to +370
memo_defs = list(page_ctx.memo_contributions.values())
memo_files, memo_imports = compiler.compile_memo_components(memo_defs)
for mpath, mcode in memo_files:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve hit-page memo exports when writing miss memos

When multiple stateful pages from the same source module contribute auto-memo components, compile_memo_components groups them into one mirrored module. During an incremental rebuild where only one page misses, memo_defs contains only that page's contributions, so this writes a partial grouped memo file over the existing one and removes exports still imported by hit pages; the reused hit page modules then fail to import their memo exports until a full compile.

Useful? React with 👍 / 👎.

from reflex_base.plugins import PageContext

#: Directories never worth hashing (build artifacts, deps, caches).
_SKIP_DIRS = {".web", ".venv", "venv", "node_modules", "__pycache__", ".git", "assets"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Copy assets before accepting a cache hit

For an assets/-only edit, this exclusion keeps the changed file out of every page dependency set, and global_epoch also does not include assets. The disk fast path can therefore report all pages as hits and return after only updating router/entry files, skipping the full compile's copy step in reflex/compiler/compiler.py lines 1481-1487, so .web/public serves stale images or asset CSS until another change forces a full compile.

Useful? React with 👍 / 👎.

Add a persistent compile daemon (REFLEX_COMPILE_CACHE) that imports the
world once and forks a throwaway child per source change, instead of the
reloader respawning a worker that cold-imports on every edit. The child
re-imports first-party code fresh and runs the incremental rebuild, so
correctness matches a respawn while the cold import is paid once.

Supporting changes that make the daemon safe and complete:

- write_file now writes atomically (temp + os.replace) so a reader (vite,
  a concurrent compile) never sees a half-written file, even when a forked
  child is killed mid-compile.
- _run_dev launches the daemon and sets REFLEX_SKIP_COMPILE on the backend
  so it only evaluates pages to register state.
- The daemon watches what the compiler reads (incl. sibling-dir markdown
  from the manifest); uvicorn reload_includes also covers *.md/*.mdx, so
  markdown edits finally trigger a reload.
- Drop the per-rebuild console.info now that progress is shown inline.
Comment on lines +288 to +289
if not globals_match(manifest, routes=routes, epoch=epoch):
return False

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.

P1 App root stays stale When REFLEX_COMPILE_CACHE is enabled, this global check can still pass after an app entrypoint-only edit that changes app.app_wraps, app.extra_app_wraps, app.toaster, theme, or another app-root provider. If routes and page dependency hashes are unchanged, miss_pages is empty, the app-wrap guard never runs, and try_incremental_rebuild() returns before the normal app root, theme, and context files are regenerated. A hot-reload worker can keep serving the previous provider or theme until a full compile happens. The manifest needs an app-root/app-entrypoint fingerprint, or this path needs to fall back when app-wide inputs may have changed.

"app_wrap_keys": _wrap_key_strs(page_ctx.app_wrap_components.keys()),
"is_stateful": page.route in miss_ctx.stateful_routes,
}
all_imports = merge_imports(all_imports, page_ctx.frontend_imports)

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.

P1 Memo imports stay stale This manifest refresh still saves only page_ctx.frontend_imports, so memo-body imports from a recompiled miss page are used for the current install but are not saved for the next worker. The rebuild path merges memo_imports into the local install set after compile_memo_components(), but _update_manifest_for_misses() rebuilds manifest["all_imports"] without those imports. If the next rebuild is an all-hit run, it can deserialize a manifest missing the package required by the reused generated memo file, so frontend package installation can be incomplete. Persist the same memo imports that were used for the current rebuild when updating the manifest.

multi_docs built every component's prop tables at module import, so the
whole library reference was reconstructed on every import of the docs
tree -- re-run on every dev hot-reload reimport, cold start, and backend
respawn. Move the build into the page render closures (matching the
non-library doc path) so a page builds its prop tables only when it is
actually compiled.

Docs cold import 9.7s -> 1.9s; hot-reload reimport 8.3s -> 0.6s.
- disk_cache: stop re-evaluating stateful HIT pages during an incremental
  rebuild. The compiling process never serves (the daemon, the initial
  compile, and CLI compiles all exit; the serving backend re-evaluates the
  marked stateful pages itself), so re-running their render pipeline was
  pure waste. The stateful-pages marker stays complete -- hits recorded
  from the manifest, misses from the fresh compile.
- compile_daemon: poll faster (0.25s -> 0.05s) but cheaply -- stat the
  known file set each tick and rglob only every 1s for added/removed
  files, cutting detection latency without burning idle CPU.
- compile_daemon: log per-edit timing (reset / reimport / compile).
@FarhanAliRaza

Copy link
Copy Markdown
Contributor Author

@greptile please rereview

"app_wrap_keys": _wrap_key_strs(page_ctx.app_wrap_components.keys()),
"is_stateful": page.route in miss_ctx.stateful_routes,
}
all_imports = merge_imports(all_imports, page_ctx.frontend_imports)

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.

P1 Memo imports omitted

This refresh path still drops memo-component package imports from the manifest. The current rebuild merges memo_imports into install_imports before calling _get_frontend_packages, but _update_manifest_for_misses() persists only page_ctx.frontend_imports. When a changed page introduces a memo file that imports a new package, this run installs it, then the refreshed manifest omits it. On the next all-hit worker, package installation is driven from manifest["all_imports"], so a clean frontend install can reuse the generated memo file while missing its package dependency.

Comment on lines +216 to +220
manifest.get("reflex_version") == page_cache._reflex_version()
and set(manifest.get("pages", {})) == routes
and manifest.get("epoch") == epoch
)

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.

P1 App root ignored

This global match still does not include app-entrypoint or app-root inputs, so an app-root-only edit can stay on the all-hit fast path. global_epoch() covers Reflex version, config, lockfiles, and package files, and this check accepts the manifest when routes and that epoch match. If the entrypoint changes app.app_wraps, extra_app_wraps, toaster, theme, or another provider without changing any page dependency, miss_pages is empty and the app-wrap guard below never runs. The rebuild then reuses the previous app root and context files, leaving the frontend with stale providers or theme until a full compile happens.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant