Skip to content

feat(compiler): mirror memo output paths to Python source modules#6457

Open
FarhanAliRaza wants to merge 13 commits into
reflex-dev:mainfrom
FarhanAliRaza:memoize-file-path-mirror
Open

feat(compiler): mirror memo output paths to Python source modules#6457
FarhanAliRaza wants to merge 13 commits into
reflex-dev:mainfrom
FarhanAliRaza:memoize-file-path-mirror

Conversation

@FarhanAliRaza

@FarhanAliRaza FarhanAliRaza commented May 5, 2026

Copy link
Copy Markdown
Contributor

Memos now compile into a single JSX file per user module at a path that mirrors the module's dotted name, instead of one file per memo under . The page-side import surface matches the source
layout, which makes debugging easier and lets Vite group co-defined memos in the same chunk.

Memos without a captured source module keep the legacy per-name files and index. A manifest in records emitted
paths so stale files from previous compiles get pruned.

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.

  • New feature (non-breaking change which adds functionality)

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?

closes #6218
fixes ENG-9150

Memos now compile into a single JSX file per user module at a path that
mirrors the module's dotted name, instead of one file per memo under
. The page-side import surface matches the source
layout, which makes debugging easier and lets Vite group co-defined
memos in the same chunk.

Memos without a captured source module keep the legacy per-name files
and  index. A manifest in  records emitted
paths so stale files from previous compiles get pruned.
@FarhanAliRaza FarhanAliRaza requested a review from a team as a code owner May 5, 2026 09:56
@codspeed-hq

codspeed-hq Bot commented May 5, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing FarhanAliRaza:memoize-file-path-mirror (f667a4a) with main (b288bdc)

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.

@greptile-apps

greptile-apps Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes how rx.memo components are compiled: instead of one JSX file per memo under utils/components/, memos from the same Python source module are now grouped into a single combined file at a path mirroring the module's dotted name. A manifest-driven stale-file cleanup pass (prune_stale_memo_files) removes files from previous compiles that are no longer emitted.

  • memo_paths.py — new helper translating dotted Python module names to safe JSX path segments and $/… library specifiers; uses sys.modules as the live source for package vs. module detection to handle hot-reload switches.
  • _MemoGroup / _compile_memo_components — memos with a captured source_module are accumulated per mirrored segment tuple and emitted as one combined JSX file; memos without a source module continue to use the legacy per-name path.
  • prune_stale_memo_files — reads/writes a .memo-manifest.json in .web/ to detect and delete memo files dropped between compiles; contains a relative-path bug that prevents deletion from ever firing in the default configuration.

Confidence Score: 4/5

The core mirrored-path compilation logic is correct and well-tested; the stale-file cleanup is the only broken path, and failure there is silent — orphaned .jsx files accumulate rather than causing build errors.

The manifest-based stale file pruning never actually deletes files when get_web_dir() returns the default relative Path('.web'). The is-absolute check prepends .web/ to paths that already start with .web/, so every deletion target path is wrong in a standard Reflex project. The cleanup tests all mock get_web_dir() to an absolute path, so they pass without catching this.

reflex/compiler/utils.py — specifically prune_stale_memo_files and its relative-path arithmetic.

Important Files Changed

Filename Overview
reflex/compiler/utils.py Adds manifest-driven stale file pruning; the relative-path handling in prune_stale_memo_files incorrectly double-nests .web/ when get_web_dir() returns a relative path (the default).
packages/reflex-base/src/reflex_base/utils/memo_paths.py New helper module translating Python module names to mirrored JSX paths; uses sys.modules live lookup to handle hot-reload switching between package/module form.
reflex/compiler/compiler.py Groups memos by mirrored source-module path via _MemoGroup; legacy fallback preserved; auto_memo_components keyed changed to (tag, source_module) tuple.
reflex/compiler/plugins/memoize.py _build_wrapper now passes page_context.source_module and keys auto_memo_components by (tag, source_module) to prevent cross-module tag collisions.
reflex/app.py Captures source module at add_page() time (from callable module or frame walk) and stores it on UnevaluatedPage._source_module.
tests/units/compiler/test_stale_cleanup.py New tests for manifest-driven stale cleanup; all tests mock get_web_dir() to return an absolute path, so they pass but don't catch the relative-path bug in production.
tests/units/components/test_memo.py Updated tests for mirrored paths; some assertions use redundant double .with_suffix('.jsx') call; path lookup logic is functionally correct.
tests/units/utils/test_memo_paths.py New unit tests covering capture_source_module, module_to_mirrored_segments, and resolve_user_module_from_frame; good coverage of edge cases.

Reviews (2): Last reviewed commit: "remove weird importlib import of reflex...." | Re-trigger Greptile

Comment thread packages/reflex-base/src/reflex_base/utils/memo_paths.py Outdated
Comment thread reflex/utils/memo_paths.py Outdated
Comment thread reflex/compiler/utils.py
Identical memoizable subtrees on pages from different user modules
produce the same wrapper tag. The auto-memo registry was keyed by tag
alone, so the second registration overwrote the first — only one of
the source modules got a mirrored memo file emitted, and the other
page imported the tag from a JSX file that never declared it. Vite
failed the prod build with MISSING_EXPORT.

Key the registry by (tag, source_module) so each module's mirrored
file gets its own definition, and add an integration test that builds
two pages from distinct user modules sharing a memoizable subtree.
 was d, so once a
module had been resolved its mirrored path was frozen for the
process. A user toggling a module between a regular  and a
package () during dev reload kept the original origin
and emitted memo files to the stale path.

Drop the cache and read  from  first, falling
back to  only when the module isn't loaded —
is rebound on reload, while a cached spec wouldn't be. Also tighten
 to close the mkstemp fd up front so it can't
leak if reopening raises, and re-export  from
 for parity with the rest of the surface.
Memo components now mirror to their source module's combined file, so
the self-referencing memo test can no longer find a per-name
RecursiveBox.jsx path. Join all emitted code and assert on content.
Auto-memoized (rx.memo) components now compile to .web paths mirroring
their defining Python module instead of a shared components.jsx, scoping
the memo registry per module so same-named components no longer collide.
Adds reflex_base.utils.memo_paths to translate source modules into the
mirrored JSX path and $/... specifier.
Comment thread reflex/utils/memo_paths.py Outdated
Comment thread tests/units/utils/test_memo_paths.py Outdated
masenf added 5 commits June 12, 2026 16:49
This is net-new functionality, we don't need to provide compat shims
use more durable module/package names that are less likely to change in the future
1. the import was weird, why not just import directly?
2. the canonical location for _get_memo_component_class has moved to reflex_base
Comment thread reflex/compiler/utils.py
Comment on lines +889 to +912
def prune_stale_memo_files(emitted_paths: Iterable[str | Path]) -> None:
"""Delete memo files written previously that this compile no longer emits.

Only paths that appear in the previous manifest are considered for
deletion — never a fresh filesystem walk — so files this code did not
emit are never touched. Empty parent directories created by mirrored
output are removed up to (but not including) the ``.web`` root.

Args:
emitted_paths: Absolute (or ``.web``-relative) paths the current
compile produced for the memo pipeline.
"""
web_dir = get_web_dir()

emitted_relative: set[str] = set()
for path in emitted_paths:
absolute = Path(path)
if not absolute.is_absolute():
absolute = web_dir / absolute
try:
relative = absolute.relative_to(web_dir)
except ValueError:
continue
emitted_relative.add(str(relative).replace(os.sep, "/"))

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 Stale-file cleanup silently broken for default (relative) web dir

When get_web_dir() returns the default Path(".web") (a relative path), the if not absolute.is_absolute() branch prepends .web/ to a path that already starts with .web/. For example, path = ".web/myapp/module.jsx" becomes absolute = Path(".web/.web/myapp/module.jsx"). The subsequent relative_to(Path(".web")) strips only the leading .web, recording ".web/myapp/module.jsx" in the manifest. On the next prune pass, target = Path(".web") / ".web/myapp/module.jsx" resolves to .web/.web/myapp/module.jsx, which never exists on disk, so is_file() returns False and the stale file is never deleted.

All tests in test_stale_cleanup.py mock get_web_dir() to an absolute tmp_path / ".web", so they pass without catching this. The simplest fix is to drop the is-absolute guard and call relative_to directly — it works for both absolute and relative paths as long as both sides are consistent: Path(".web/myapp/module.jsx").relative_to(Path(".web")) correctly yields PosixPath("myapp/module.jsx").

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

yep, noticed this in local testing as well. the pruned paths are not cleaned up when the module is deleted/renamed

@masenf masenf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we need to put the modules under a subdir of .web that is not already used, maybe .web/app_components.

The problem arises when I have an app called app and a module called app.root which exports a memo component... this ends up getting overwritten by the app/root.jsx that the framework emits and then the page breaks when you try to load it.

The user's file tree hierarchy really shouldn't be able to break the app, hence keeping it under a subdirectory that is only used for emitting memo components would protect the rest of the app from unexpected changes.

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.

Compiler: Track provenance of rx.memo components and output to mirrored Python paths

2 participants