fix(compiler): check memo modules for state reads when state is disabled

FI
1d ago
+144-12

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

  • Bug fix (non-breaking change which fixes an issue)

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?

What

With rx.App(enable_state=False), compile_app is supposed to raise "To access rx.State in frontend components, at least one subclass of rx.State must be defined in the app." when a component reads state. The check ran right after the pages were compiled and only looked at page output. Auto-memoization moves most state reads into memo modules, and those are compiled later, together with app-wrap memos and the hydrate fallback memo. So the issue's example (rx.text(DynState.label)) compiled without error and failed at runtime.

The check now runs after compile_memo_components and scans every module in compile_results, which at that point holds the page modules and all memo modules. code_uses_state_contexts itself is unchanged.

Why this direction

I went for the smallest change that keeps the existing guard logic: same predicate, same error, just run over all the compiled JS that can subscribe to state instead of the pages only.

Alternatives I considered:

  • Delete the guard (option 1 in the issue). As noted in the issue, the unsupported state use would then only fail later, at runtime.
  • Collect VarData.state from the compiled hooks (option 2). As @masenf pointed out, this misses the reported example, because _get_vars_hooks keeps only position metadata in the hook entries.
  • Structural validation from the originating Vars across pages, memos, app wraps and the hydrate fallback. This is the more robust fix, but it is a much larger change. It can still be done later; this PR doesn't get in its way.

Known limits, unchanged by this PR: the check still matches the useStateContext( substring, so literal text containing it is still a false positive (now in memo modules too). One behavior change: MEMOS is compiled as a whole, so an @rx.memo that reads state is now rejected in an enable_state=False app even if no page renders it. Before, it was emitted but never imported.

Tests

New cases in tests/units/compiler/test_compiler.py, using the real compile_app(app, dry_run=True, use_rich=False) path:

  • enable_state=False with a state read raises for: an auto-memoized page component, a non-memoized page component, an rx.memo body, an app wrap, and the hydrate fallback.
  • The same five compile when state is enabled.
  • A stateless app with enable_state=False compiles, in dev and in prod.

Without the fix, 4 cases fail with DID NOT RAISE ReflexRuntimeError (auto memo, explicit memo, app wrap, hydrate fallback); the non-memoized page case already raised. With the fix, all 12 pass. The new tests cover every changed line in compile_app.

Also ran test_var.py::test_code_uses_state_contexts, test_compiler.py, test_memoize_plugin.py and test_app.py (434 passed), and ruff check, ruff format --check, pyright and pre-commit on the changed files.

closes #7579