From 94402e8032480ec5b0456af2226e59f38cce7535 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 18 Jul 2026 12:39:37 +0200 Subject: [PATCH 1/4] fix: reregister memo in app harness --- .../src/reflex_base/components/memo.py | 19 ++++++++ tests/units/components/test_memo.py | 46 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 80334ab6804..e32c5da2d4a 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -492,6 +492,20 @@ def _register_memo_definition(definition: MemoDefinition) -> None: MEMOS[key] = definition +def _reregister_used_memo(definition: MemoDefinition) -> None: + """Re-register a user ``@rx.memo`` definition when it is used. + + Keeps ``MEMOS`` populated from actual usage so a cleared registry (e.g. the + AppHarness reload, which clears ``MEMOS`` but only reloads the top app + module, so submodule ``@rx.memo`` decorators never re-run) still emits every + memo the compiled tree references. Passthrough auto-memos are defined in + this module and tracked separately by the compiler, so they must stay out of + ``MEMOS``. + """ + if definition.fn.__module__ != __name__: + _register_memo_definition(definition) + + def _annotation_inner_type(annotation: Any) -> Any: """Unwrap a Var-like annotation to its inner type. @@ -1528,6 +1542,7 @@ def call(self, *args: Any, **kwargs: Any) -> Var: Returns: The function call var. """ + _reregister_used_memo(self._definition) return self._imported_var.call( *_bind_function_runtime_args(self._definition, *args, **kwargs) ) @@ -1542,6 +1557,7 @@ def partial(self, *args: Any, **kwargs: Any) -> FunctionVar: Returns: The partially applied function var. """ + _reregister_used_memo(self._definition) return self._imported_var.partial( *_bind_function_runtime_args(self._definition, *args, **kwargs) ) @@ -1552,6 +1568,7 @@ def _as_var(self) -> FunctionVar: Returns: The imported function var. """ + _reregister_used_memo(self._definition) return self._imported_var @@ -1590,6 +1607,7 @@ def __call__(self, *children: Any, **props: Any) -> MemoComponent: The rendered memo component. """ definition = self._definition + _reregister_used_memo(definition) rest_param = self._rest_param # Validate positional children usage and reserved keywords. @@ -1661,6 +1679,7 @@ def _as_var(self) -> Var: Returns: The imported component var. """ + _reregister_used_memo(self._definition) return _component_import_var( self._definition.export_name, self._definition.source_module ) diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index fb91a1b387e..6867ae977a4 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -22,7 +22,9 @@ _analyze_params, _LazyBody, _MemoCallBinding, + _reregister_used_memo, _strip_optional, + create_passthrough_component_memo, ) from reflex_base.event import EventChain, EventHandler, no_args_event_spec from reflex_base.style import Style @@ -1812,3 +1814,47 @@ def recursive_count(n: rx.vars.NumberVar[int]) -> rx.Var[int]: invoked = recursive_count(n=Var(_js_expr="three", _var_type=int)) assert "recursive_count" in str(invoked) + + +def test_memo_reregisters_on_use_after_registry_clear(): + """A used ``@rx.memo`` repopulates ``MEMOS`` after the registry is cleared. + + Regression: ``AppHarness._initialize_app`` clears ``MEMOS`` then reloads only + the top app module (``get_app(reload=True)`` -> ``importlib.reload``), so + submodule ``@rx.memo`` decorators never re-run. Using the memo must + re-register it, otherwise ``compile_memo_components`` emits no file for it + while pages still import it (vite "Failed to resolve import ..."). + """ + + @rx.memo + def sample_card(*, title: rx.Var[str]) -> rx.Component: + return rx.box(rx.heading(title)) + + key = ("SampleCard", __name__) + assert key in MEMOS # registered at decoration time + + MEMOS.clear() + assert key not in MEMOS + + # Using (rendering) the memo must re-register it. + sample_card(title="Hello") + assert key in MEMOS + + # ...so the compiler still emits its module. + sym = memo_paths.mirrored_symbol("SampleCard", __name__) + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) + assert any(f"export const {sym} = memo(" in code for _, code in files) + + +def test_reregister_used_memo_skips_passthrough_auto_memos(): + """Passthrough auto-memos must never enter ``MEMOS`` on use. + + The compiler tracks them separately (``auto_memo_components``); registering + them too emits the same file twice with conflicting bodies. + """ + _, definition = create_passthrough_component_memo( + rx.box(rx.text("x")), source_module=__name__ + ) + MEMOS.clear() + _reregister_used_memo(definition) + assert MEMOS == {} From 40fed7de7d871f1a3b848098412b4bdeec232c62 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 18 Jul 2026 12:58:51 +0200 Subject: [PATCH 2/4] add changelog --- packages/reflex-base/news/+memo-harness-reregister.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/reflex-base/news/+memo-harness-reregister.bugfix.md diff --git a/packages/reflex-base/news/+memo-harness-reregister.bugfix.md b/packages/reflex-base/news/+memo-harness-reregister.bugfix.md new file mode 100644 index 00000000000..fee644c6f8a --- /dev/null +++ b/packages/reflex-base/news/+memo-harness-reregister.bugfix.md @@ -0,0 +1 @@ +Re-register `@rx.memo` definitions on use so a cleared `MEMOS` registry (e.g. an `AppHarness` reload, which only re-imports the top app module) still emits every memo module the compiled tree references, instead of failing to resolve them at runtime. From 384dec715bc03cf01b27de44675bfd19b738c998 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 18 Jul 2026 14:52:06 +0200 Subject: [PATCH 3/4] more memo harness fixes --- news/+harness-real-app-no-reload.bugfix.md | 1 + .../src/reflex_base/components/memo.py | 32 ++++++--- pyi_hashes.json | 2 +- reflex/compiler/compiler.py | 6 ++ reflex/testing.py | 68 ++++++++++--------- tests/units/components/test_memo.py | 59 +++++++++++++--- tests/units/test_testing.py | 10 ++- 7 files changed, 123 insertions(+), 55 deletions(-) create mode 100644 news/+harness-real-app-no-reload.bugfix.md diff --git a/news/+harness-real-app-no-reload.bugfix.md b/news/+harness-real-app-no-reload.bugfix.md new file mode 100644 index 00000000000..83fe184a937 --- /dev/null +++ b/news/+harness-real-app-no-reload.bugfix.md @@ -0,0 +1 @@ +`AppHarness` no longer destructively reloads a real (pre-imported) app on start: reloading only re-ran the top module, silently dropping every page, `rx.State` subclass, and `@rx.memo` defined in a submodule (flaky "Invalid path" compile errors and unresolved `$/app_components/...` memo imports). Such apps are now imported once and used as-is (`reload=False`); generated `app_source` apps are unaffected. diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index e32c5da2d4a..445c7a519f8 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -493,19 +493,34 @@ def _register_memo_definition(definition: MemoDefinition) -> None: def _reregister_used_memo(definition: MemoDefinition) -> None: - """Re-register a user ``@rx.memo`` definition when it is used. - - Keeps ``MEMOS`` populated from actual usage so a cleared registry (e.g. the - AppHarness reload, which clears ``MEMOS`` but only reloads the top app - module, so submodule ``@rx.memo`` decorators never re-run) still emits every - memo the compiled tree references. Passthrough auto-memos are defined in - this module and tracked separately by the compiler, so they must stay out of - ``MEMOS``. + """Re-register a used ``@rx.memo`` so a cleared ``MEMOS`` is repopulated from + usage. Passthrough auto-memos (defined in this module) are tracked + separately and stay out of ``MEMOS``. """ if definition.fn.__module__ != __name__: _register_memo_definition(definition) +def materialize_registered_memo_bodies() -> None: + """Evaluate every registered memo body, until ``MEMOS`` stops growing. + + Reading a memo body re-registers any nested ``@rx.memo`` it references (see + :func:`_reregister_used_memo`), so the compiler must do this before it + snapshots ``MEMOS`` — else a dependency surfaced only while compiling a lazy + var-memo body is left out. Bodies cache, so re-reading one is a no-op. + """ + evaluated: set[tuple[str, str | None]] = set() + while pending := [ + (key, definition) for key, definition in MEMOS.items() if key not in evaluated + ]: + for key, definition in pending: + evaluated.add(key) + if isinstance(definition, MemoFunctionDefinition): + _ = definition.function + elif isinstance(definition, MemoComponentDefinition): + _ = definition.component + + def _annotation_inner_type(annotation: Any) -> Any: """Unwrap a Var-like annotation to its inner type. @@ -2065,5 +2080,6 @@ def memo( "MemoFunctionDefinition", "create_component_memo", "create_passthrough_component_memo", + "materialize_registered_memo_bodies", "memo", ] diff --git a/pyi_hashes.json b/pyi_hashes.json index 481c3e8ef8b..e8919affe3f 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "58521fcd1b514804f534d97624e82c9a", "reflex/__init__.pyi": "56385a4f0d9431eb0056dbc5553a58f9", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", - "reflex/experimental/memo.pyi": "36e5d5f97eb64e94c0974e909e7e2952" + "reflex/experimental/memo.pyi": "63d6db54828066685da93102d94220d4" } diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index f7b079ab0c7..5cc639dfdd5 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -24,6 +24,7 @@ MemoDefinition, MemoFunctionDefinition, create_component_memo, + materialize_registered_memo_bodies, reset_memo_component_classes, ) from reflex_base.config import get_config @@ -1279,6 +1280,11 @@ def compile_app( ] = hydrate_fallback_definition hydrate_fallback_export = hydrate_fallback_definition.export_name + # Settle memo bodies before snapshotting: a var-returning memo's lazy body + # may reference another `@rx.memo` that an AppHarness reload cleared from + # `MEMOS`, re-registering it only when read. Doing so after the snapshot + # would omit the dependency's own module. + materialize_registered_memo_bodies() memo_component_files, memo_components_imports = compile_memo_components( ( *MEMOS.values(), diff --git a/reflex/testing.py b/reflex/testing.py index 2838c7ef746..560aa5decfc 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -238,32 +238,43 @@ def _get_source_from_app_source(self, app_source: Any) -> str: def _initialize_app(self): # disable telemetry reporting for tests os.environ["REFLEX_TELEMETRY_ENABLED"] = "false" - # Reset the global memo registry so previous AppHarness apps do not - # leak compiled component definitions into the next test app. - MEMOS.clear() self.app_path.mkdir(parents=True, exist_ok=True) - if self.app_source is not None: - app_globals = self._get_globals_from_signature(self.app_source) - if isinstance(self.app_source, functools.partial): - self.app_source = self.app_source.func - # get the source from a function or module object - source_code = "\n".join([ - "\n".join([ - self.get_app_global_source(k, v) for k, v in app_globals.items() - ]), - self._get_source_from_app_source(self.app_source), - ]) - get_config().loglevel = reflex.constants.LogLevel.INFO - with chdir(self.app_path): - reflex.reflex._init( - name=self.app_name, - template=reflex.constants.Templates.DEFAULT, - ) - self.app_module_path.write_text(source_code) - else: - # Just initialize the web folder. + + if self.app_source is None: + # Real, pre-existing app: import once and use it as-is. Reloading an + # already-imported app re-runs only the top module, dropping every + # submodule page/state/@rx.memo, so use reload=False (no reset). with chdir(self.app_path): reflex.utils.prerequisites.initialize_frontend_dependencies() + os.environ.pop(reflex.constants.PYTEST_CURRENT_TEST, None) + os.environ[reflex.constants.APP_HARNESS_FLAG] = "true" + get_config(reload=True) + self.app_instance, self.app_module = ( + reflex.utils.prerequisites.get_and_validate_app(reload=False) + ) + self.app_asgi = self.app_instance() + return + + # Generated app: reset the global memo registry so a previous AppHarness + # app does not leak compiled component definitions into the next test app. + MEMOS.clear() + app_globals = self._get_globals_from_signature(self.app_source) + if isinstance(self.app_source, functools.partial): + self.app_source = self.app_source.func + # get the source from a function or module object + source_code = "\n".join([ + "\n".join([ + self.get_app_global_source(k, v) for k, v in app_globals.items() + ]), + self._get_source_from_app_source(self.app_source), + ]) + get_config().loglevel = reflex.constants.LogLevel.INFO + with chdir(self.app_path): + reflex.reflex._init( + name=self.app_name, + template=reflex.constants.Templates.DEFAULT, + ) + self.app_module_path.write_text(source_code) with chdir(self.app_path): # Use a new registration context for a new app. if AppHarness._base_registration_context is None: @@ -274,19 +285,12 @@ def _initialize_app(self): new_registration_context = deepcopy(AppHarness._base_registration_context) self._registry_token = RegistrationContext.set(new_registration_context) # ensure config and app are reloaded when testing different app - config = get_config(reload=True) + get_config(reload=True) # Ensure the AppHarness test does not skip State assignment due to running via pytest os.environ.pop(reflex.constants.PYTEST_CURRENT_TEST, None) os.environ[reflex.constants.APP_HARNESS_FLAG] = "true" - # Ensure we compile generated apps, and reload pre-existing app modules - # that were already imported so they can re-register memo definitions. - should_reload_app = ( - self.app_source is not None or config.module in sys.modules - ) self.app_instance, self.app_module = ( - reflex.utils.prerequisites.get_and_validate_app( - reload=should_reload_app - ) + reflex.utils.prerequisites.get_and_validate_app(reload=True) ) self.app_asgi = self.app_instance() diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index 6867ae977a4..c42db56525e 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -25,6 +25,7 @@ _reregister_used_memo, _strip_optional, create_passthrough_component_memo, + materialize_registered_memo_bodies, ) from reflex_base.event import EventChain, EventHandler, no_args_event_spec from reflex_base.style import Style @@ -1817,13 +1818,8 @@ def recursive_count(n: rx.vars.NumberVar[int]) -> rx.Var[int]: def test_memo_reregisters_on_use_after_registry_clear(): - """A used ``@rx.memo`` repopulates ``MEMOS`` after the registry is cleared. - - Regression: ``AppHarness._initialize_app`` clears ``MEMOS`` then reloads only - the top app module (``get_app(reload=True)`` -> ``importlib.reload``), so - submodule ``@rx.memo`` decorators never re-run. Using the memo must - re-register it, otherwise ``compile_memo_components`` emits no file for it - while pages still import it (vite "Failed to resolve import ..."). + """Using a ``@rx.memo`` repopulates ``MEMOS`` after the registry is cleared, + so the compiler still emits its module. """ @rx.memo @@ -1846,11 +1842,52 @@ def sample_card(*, title: rx.Var[str]) -> rx.Component: assert any(f"export const {sym} = memo(" in code for _, code in files) -def test_reregister_used_memo_skips_passthrough_auto_memos(): - """Passthrough auto-memos must never enter ``MEMOS`` on use. +def test_nested_var_memo_reregistered_before_compile_snapshot(): + """A var memo referenced only inside another memo's body is still emitted. + + Regression: rendering ``outer`` after a registry clear leaves its lazy body + unread, so ``inner`` is missing from the snapshot the compiler takes. + ``materialize_registered_memo_bodies`` re-registers it first. + """ + + @rx.memo + def inner_fmt(*, value: rx.Var[str]) -> rx.Var[str]: + return rx.Var.create(f"[{value}]") + + @rx.memo + def outer_fmt(*, value: rx.Var[str]) -> rx.Var[str]: + return inner_fmt(value=value) + + inner_key = ("inner_fmt", __name__) + outer_key = ("outer_fmt", __name__) + inner_sym = memo_paths.mirrored_symbol("inner_fmt", __name__) - The compiler tracks them separately (``auto_memo_components``); registering - them too emits the same file twice with conflicting bodies. + MEMOS.clear() + + # Rendering the outer memo re-registers only itself; its lazy body (which + # references inner) is not read yet. + outer_fmt(value=rx.Var.create("x")) + assert outer_key in MEMOS + assert inner_key not in MEMOS + + # A snapshot taken now (as the compiler used to) omits inner: compiling it + # reads outer's body — which re-registers inner too late for this snapshot — + # so inner's module is never emitted even though outer.jsx imports it. + stale_snapshot = tuple(MEMOS.values()) + stale_files, _ = compiler.compile_memo_components(stale_snapshot) + assert not any(f"export const {inner_sym} =" in code for _, code in stale_files) + + # The compiler pre-pass settles the registry before it snapshots MEMOS. + materialize_registered_memo_bodies() + assert inner_key in MEMOS + + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) + assert any(f"export const {inner_sym} =" in code for _, code in files) + + +def test_reregister_used_memo_skips_passthrough_auto_memos(): + """Passthrough auto-memos (tracked separately by the compiler) must never + enter ``MEMOS`` on use. """ _, definition = create_passthrough_component_memo( rx.box(rx.text("x")), source_module=__name__ diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index 53804ef60dc..c582f035854 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -111,10 +111,11 @@ def test_app_harness_initialize_clears_memo_registries( harness_mocks.get_and_validate_app.assert_called_once_with(reload=True) -def test_app_harness_initialize_reloads_existing_imported_app( +def test_app_harness_initialize_preserves_existing_imported_app( tmp_path, preserve_memo_registries, harness_mocks, monkeypatch ): - """Ensure pre-existing imported apps are reloaded after memo registry reset. + """A real (``app_source is None``) app is imported with ``reload=False`` and + its registrations (incl. memos) are kept, not reset. Args: tmp_path: pytest tmp_path fixture @@ -132,8 +133,11 @@ def test_app_harness_initialize_reloads_existing_imported_app( harness_mocks.config.module, ModuleType(harness_mocks.config.module), ) + MEMOS["existing_memo", None] = mock.sentinel.memo harness = AppHarness.create(root=tmp_path / "plain_app") harness._initialize_app() - harness_mocks.get_and_validate_app.assert_called_once_with(reload=True) + harness_mocks.get_and_validate_app.assert_called_once_with(reload=False) + # The real app's registrations (incl. memos) are preserved, not reset. + assert ("existing_memo", None) in MEMOS From 377a848a0c0c0b020fd3af5a5a8767a9b6fc0784 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 21 Oct 2026 09:09:51 +0200 Subject: [PATCH 4/4] Restore nested memos from cached bodies and load real-app config first Re-run a memo's cached body when re-registering it after a registry clear so nested memos come back, make the pre-pass test fail without the fix, reload a real app's config before initializing its frontend, and reword the fragments for users. --- news/+harness-real-app-no-reload.bugfix.md | 2 +- .../news/+memo-harness-reregister.bugfix.md | 2 +- .../src/reflex_base/components/memo.py | 27 +++++++++-- pyi_hashes.json | 2 +- reflex/testing.py | 3 +- tests/units/components/test_memo.py | 47 ++++++++++++++++--- tests/units/test_testing.py | 25 ++++++++++ 7 files changed, 92 insertions(+), 16 deletions(-) diff --git a/news/+harness-real-app-no-reload.bugfix.md b/news/+harness-real-app-no-reload.bugfix.md index 83fe184a937..c33152ab20c 100644 --- a/news/+harness-real-app-no-reload.bugfix.md +++ b/news/+harness-real-app-no-reload.bugfix.md @@ -1 +1 @@ -`AppHarness` no longer destructively reloads a real (pre-imported) app on start: reloading only re-ran the top module, silently dropping every page, `rx.State` subclass, and `@rx.memo` defined in a submodule (flaky "Invalid path" compile errors and unresolved `$/app_components/...` memo imports). Such apps are now imported once and used as-is (`reload=False`); generated `app_source` apps are unaffected. +`AppHarness` no longer reloads an existing app (one created without `app_source`), which dropped the pages, states and `@rx.memo` components defined in the app's submodules. diff --git a/packages/reflex-base/news/+memo-harness-reregister.bugfix.md b/packages/reflex-base/news/+memo-harness-reregister.bugfix.md index fee644c6f8a..39dd638bcb3 100644 --- a/packages/reflex-base/news/+memo-harness-reregister.bugfix.md +++ b/packages/reflex-base/news/+memo-harness-reregister.bugfix.md @@ -1 +1 @@ -Re-register `@rx.memo` definitions on use so a cleared `MEMOS` registry (e.g. an `AppHarness` reload, which only re-imports the top app module) still emits every memo module the compiled tree references, instead of failing to resolve them at runtime. +Fix `@rx.memo` definitions from a module shared by several `AppHarness` apps in one test process going missing from later apps, which left their imports unresolved at runtime. diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 2667f2f94ee..3638bb3b297 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -244,6 +244,10 @@ def is_ready(self) -> bool: """ return self._ready + def reset(self) -> None: + """Drop the cached body so the next :meth:`get` runs the thunk again.""" + self._ready = False + def get(self, thunk: Callable[[], _BodyT] | None = None) -> _BodyT: """Return the body, running and caching ``thunk`` on first read. @@ -654,12 +658,25 @@ def _register_memo_definition(definition: MemoDefinition) -> None: def _reregister_used_memo(definition: MemoDefinition) -> None: - """Re-register a used ``@rx.memo`` so a cleared ``MEMOS`` is repopulated from - usage. Passthrough auto-memos (defined in this module) are tracked - separately and stay out of ``MEMOS``. + """Re-register a used ``@rx.memo`` that a cleared ``MEMOS`` dropped. + + Its cached body is reset, so the next read re-runs it and re-registers the + nested memos it uses too. Passthrough auto-memos (defined in this module) + are tracked separately and stay out of ``MEMOS``. + + Args: + definition: The memo definition being used. """ - if definition.fn.__module__ != __name__: - _register_memo_definition(definition) + if definition.fn.__module__ == __name__: + return + key = _memo_registry_key(definition) + if key in MEMOS: + return + MEMOS[key] = definition + if isinstance(definition, MemoComponentDefinition): + definition._component.reset() + elif isinstance(definition, MemoFunctionDefinition): + definition._function.reset() def materialize_registered_memo_bodies() -> None: diff --git a/pyi_hashes.json b/pyi_hashes.json index f331f197efa..3019ace7f18 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", "reflex/__init__.pyi": "1c841ad90d789507cd56d966b474d11c", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", - "reflex/experimental/memo.pyi": "48a304ae05db261ec916d00686fff820" + "reflex/experimental/memo.pyi": "39df3eded29349502d91bac39ad223fd" } diff --git a/reflex/testing.py b/reflex/testing.py index bfe0af5dd2b..8a50c7d8653 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -274,10 +274,11 @@ def _initialize_app(self): # already-imported app re-runs only the top module, dropping every # submodule page/state/@rx.memo, so use reload=False (no reset). with chdir(self.app_path): + # Load this app's config before .web is initialized from it. + reload_config() reflex.utils.prerequisites.initialize_frontend_dependencies() os.environ.pop(reflex.constants.PYTEST_CURRENT_TEST, None) os.environ[reflex.constants.APP_HARNESS_FLAG] = "true" - reload_config() self.app_instance, self.app_module = ( reflex.utils.prerequisites.get_and_validate_app(reload=False) ) diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index 57ee366cc5a..cd5277675c1 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -2066,13 +2066,6 @@ def outer_fmt(*, value: rx.Var[str]) -> rx.Var[str]: assert outer_key in MEMOS assert inner_key not in MEMOS - # A snapshot taken now (as the compiler used to) omits inner: compiling it - # reads outer's body — which re-registers inner too late for this snapshot — - # so inner's module is never emitted even though outer.jsx imports it. - stale_snapshot = tuple(MEMOS.values()) - stale_files, _ = compiler.compile_memo_components(stale_snapshot) - assert not any(f"export const {inner_sym} =" in code for _, code in stale_files) - # The compiler pre-pass settles the registry before it snapshots MEMOS. materialize_registered_memo_bodies() assert inner_key in MEMOS @@ -2081,6 +2074,46 @@ def outer_fmt(*, value: rx.Var[str]) -> rx.Var[str]: assert any(f"export const {inner_sym} =" in code for _, code in files) +def test_cached_memo_bodies_reregister_nested_memos_after_registry_clear(): + """Memos nested in a body an earlier compile cached are emitted again. + + Regression: a later ``AppHarness`` clears ``MEMOS`` without re-importing a + shared memo module, so only re-running the cached outer bodies can + re-register the inner memos their compiled modules import. + """ + + @rx.memo + def nested_fmt(*, value: rx.Var[str]) -> rx.Var[str]: + return rx.Var.create(f"[{value}]") + + @rx.memo + def wrapping_fmt(*, value: rx.Var[str]) -> rx.Var[str]: + return nested_fmt(value=value) + + @rx.memo + def nested_card(*, title: rx.Var[str]) -> rx.Component: + return rx.text(title) + + @rx.memo + def wrapping_card(*, title: rx.Var[str]) -> rx.Component: + return rx.box(nested_card(title=title)) + + # The first app's compile caches every body. + materialize_registered_memo_bodies() + + MEMOS.clear() + wrapping_fmt(value=rx.Var.create("x")) + wrapping_card(title="Hello") + materialize_registered_memo_bodies() + + assert ("nested_fmt", __name__) in MEMOS + assert ("NestedCard", __name__) in MEMOS + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) + for name in ("nested_fmt", "NestedCard"): + sym = memo_paths.mirrored_symbol(name, __name__) + assert any(f"export const {sym} =" in code for _, code in files) + + def test_reregister_used_memo_skips_passthrough_auto_memos(): """Passthrough auto-memos (tracked separately by the compiler) must never enter ``MEMOS`` on use. diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index b040b0fb6d0..8e2486116cd 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -199,6 +199,31 @@ def test_app_harness_initialize_preserves_existing_imported_app( assert ("existing_memo", None) in MEMOS +def test_app_harness_initialize_loads_real_app_config_before_frontend( + tmp_path, harness_mocks, monkeypatch +): + """A real app's ``.web`` is initialized from its own config, not a stale one. + + Args: + tmp_path: pytest tmp_path fixture + harness_mocks: shared AppHarness mock setup + monkeypatch: pytest monkeypatch fixture + """ + calls = [] + monkeypatch.setattr( + reflex_testing, "reload_config", lambda: calls.append("reload_config") + ) + monkeypatch.setattr( + reflex.utils.prerequisites, + "initialize_frontend_dependencies", + lambda: calls.append("initialize_frontend_dependencies"), + ) + + AppHarness.create(root=tmp_path / "plain_app")._initialize_app() + + assert calls == ["reload_config", "initialize_frontend_dependencies"] + + def test_app_harness_frontend_env_has_development_condition( tmp_path, monkeypatch: pytest.MonkeyPatch, harness_mocks ) -> None: