From 9d053ef7b9351be03a5e91d88e8e7ac405861514 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Tue, 6 Oct 2026 13:37:46 +0530 Subject: [PATCH 01/13] Fix local client state across sibling components --- news/+7426.bugfix.md | 1 + packages/reflex-base/news/+7426.bugfix.md | 1 + .../reflex-base/src/reflex_base/vars/base.py | 25 ++++++++++ reflex/compiler/plugins/memoize.py | 46 +++++++++++++++++++ reflex/experimental/client_state.py | 6 ++- .../tests_playwright/test_client_state.py | 31 +++++++++++++ tests/units/compiler/test_memoize_plugin.py | 25 ++++++++++ 7 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 news/+7426.bugfix.md create mode 100644 packages/reflex-base/news/+7426.bugfix.md diff --git a/news/+7426.bugfix.md b/news/+7426.bugfix.md new file mode 100644 index 00000000000..96def533417 --- /dev/null +++ b/news/+7426.bugfix.md @@ -0,0 +1 @@ +Keep page-local client state readers and setters in a shared React scope when auto-memoization is enabled. diff --git a/packages/reflex-base/news/+7426.bugfix.md b/packages/reflex-base/news/+7426.bugfix.md new file mode 100644 index 00000000000..5c27f4db311 --- /dev/null +++ b/packages/reflex-base/news/+7426.bugfix.md @@ -0,0 +1 @@ +Track hook-scope requirements in Var metadata so the compiler can preserve page-local client state behavior. diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 3ab420013c9..9ddcd798007 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -354,6 +354,9 @@ class VarData: default_factory=tuple ) + # Hook scopes that must remain together across every use of the same Var. + shared_scope_keys: tuple[str, ...] = () + def __init__( self, state: str = "", @@ -365,6 +368,7 @@ def __init__( components: Iterable[BaseComponent] | None = None, app_wraps: Iterable[tuple[int, BaseComponent]] | None = None, field_dependencies: Mapping[str, Iterable[str]] | None = None, + shared_scope_keys: Iterable[str] | None = None, ): """Initialize the var data. @@ -384,6 +388,8 @@ def __init__( and ``field_name`` are the shorthand for a single state with a single field. Keyword-only in practice: it trails the older parameters so positional callers of those are unaffected. + shared_scope_keys: Identifiers for hook scopes that must stay + together across every use of this var. """ if isinstance(hooks, str): hooks = [hooks] @@ -403,6 +409,11 @@ def __init__( object.__setattr__(self, "position", position or None) object.__setattr__(self, "components", tuple(components or [])) object.__setattr__(self, "app_wraps", tuple(app_wraps or [])) + object.__setattr__( + self, + "shared_scope_keys", + tuple(dict.fromkeys(shared_scope_keys or ())), + ) if hooks and any(hooks.values()): # Merge our dependencies first, so they can be referenced. @@ -419,6 +430,11 @@ def __init__( object.__setattr__(self, "position", merged_var_data.position) object.__setattr__(self, "components", merged_var_data.components) object.__setattr__(self, "app_wraps", merged_var_data.app_wraps) + object.__setattr__( + self, + "shared_scope_keys", + merged_var_data.shared_scope_keys, + ) @property def state(self) -> str: @@ -539,6 +555,13 @@ def merge(*all: VarData | None) -> VarData | None: app_wraps=tuple( (priority, wrapper) for (priority, _tag), wrapper in app_wraps.items() ), + shared_scope_keys=tuple( + dict.fromkeys( + key + for var_data in all_var_datas + for key in var_data.shared_scope_keys + ) + ), ) def __bool__(self) -> bool: @@ -555,6 +578,7 @@ def __bool__(self) -> bool: or self.position or self.components or self.app_wraps + or self.shared_scope_keys ) @functools.cached_property @@ -586,6 +610,7 @@ def _identity_key(self) -> tuple: (priority, component.tag or type(component).__name__) for priority, component in self.app_wraps ), + self.shared_scope_keys, ) def __eq__(self, other: object) -> bool: diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 86b6b53a3e4..eaddc0a6656 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -87,6 +87,43 @@ def _subtree_has_reactive_data( return result +def _subtree_requires_shared_scope( + component: Component, + scope_owners: dict[str, set[int]], + _seen: set[int] | None = None, +) -> None: + """Collect components using each local hook scope in a subtree. + + Args: + component: Subtree root to scan. + scope_owners: Scope-key to component-identity mapping being populated. + _seen: Component identities already visited through children or Vars. + """ + if _seen is None: + _seen = set() + component_id = id(component) + if component_id in _seen: + return + _seen.add(component_id) + + for var in component._get_vars(include_children=False): + var_data = var._get_all_var_data() + if var_data is None: + continue + for key in var_data.shared_scope_keys: + scope_owners.setdefault(key, set()).add(component_id) + for embedded in var_data.components: + if isinstance(embedded, Component): + _subtree_requires_shared_scope(embedded, scope_owners, _seen) + + for child in component.children: + if isinstance(child, Component): + _subtree_requires_shared_scope(child, scope_owners, _seen) + for prop_component in component._get_components_in_props(): + if isinstance(prop_component, Component): + _subtree_requires_shared_scope(prop_component, scope_owners, _seen) + + def _component_subtree_is_reactive( component: Component, _cache: dict[int, bool] ) -> bool: @@ -264,6 +301,15 @@ def enter_component( return None if not isinstance(comp, Component): return None + root = page_context.root_component + if comp is root or page_context._owned.get(id(root)) is comp: + scope_owners: dict[str, set[int]] = {} + _subtree_requires_shared_scope(comp, scope_owners) + if any(len(owners) > 1 for owners in scope_owners.values()): + # Local client-state setters are bare React setters. Keep their + # readers and setters in the same component scope. + page_context.memoize_suppressor_stack.append(id(comp)) + return None if page_context.memoize_suppressor_stack: return None strategy = get_memoization_strategy(comp) diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index 9a7406cb253..5a9e627227d 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -188,6 +188,7 @@ def create( VarData( hooks=hooks, imports=imports, + shared_scope_keys={id_name} if not global_ref else None, ), ), ) @@ -230,7 +231,10 @@ def set_value(self, value: Any = NoValue) -> Var: setter = ( _client_state_ref(self._setter_name)._replace(merge_var_data=self._var_data) if self._global_ref - else Var(self._setter_name) + else Var( + self._setter_name, + _var_data=VarData(shared_scope_keys={self._id_name}), + ) ).to(FunctionVar) if value is not NoValue: diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py index 4b5f4a4d6cb..66f164bf4bc 100644 --- a/tests/integration/tests_playwright/test_client_state.py +++ b/tests/integration/tests_playwright/test_client_state.py @@ -21,6 +21,7 @@ def reveal(self): self.show = True shared = rx._x.client_state(default="initial") + local = rx._x.client_state(default="local-initial", global_ref=False) backend_default = rx._x.client_state(default=ClientStateAppState.default_text) def index(): @@ -40,6 +41,16 @@ def siblings(): rx.button("set", on_click=shared.set_value("clicked"), id="setter"), ) + def local_siblings(): + return rx.box( + rx.text(local.value, id="local-reader"), + rx.button( + "set local", + on_click=local.set_value("local-clicked"), + id="local-setter", + ), + ) + def backend_default_page(): return rx.box( rx.input( @@ -56,6 +67,7 @@ def backend_default_page(): app = rx.App() app.add_page(index) app.add_page(siblings) + app.add_page(local_siblings, route="/local-siblings") app.add_page(backend_default_page, route="/backend-default") @@ -111,6 +123,25 @@ def test_sibling_setter_updates_reader( assert errors == [] +def test_local_sibling_setter_updates_reader( + client_state_app: AppHarness, page: Page +) -> None: + """A sibling local-state setter updates its reader without a scope error. + + Args: + client_state_app: Running app harness. + page: Playwright page. + """ + assert client_state_app.frontend_url is not None + errors = _collect_page_errors(page) + page.goto(client_state_app.frontend_url.removesuffix("/") + "/local-siblings") + + expect(page.locator("#local-reader")).to_have_text("local-initial") + page.click("#local-setter") + expect(page.locator("#local-reader")).to_have_text("local-clicked") + assert errors == [] + + def test_setter_works_before_reader_mounts( client_state_app: AppHarness, page: Page ) -> None: diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 13f5d3b8c01..35e991b2d63 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1663,6 +1663,31 @@ def page() -> Component: assert "refs['_client_state_setSibling'] = " in button_memo +def test_local_client_state_keeps_sibling_components_in_page_scope() -> None: + """Local client-state readers and setters must share their React scope.""" + from reflex.experimental.client_state import ClientStateVar + + local = ClientStateVar.create("local_sibling", default="initial", global_ref=False) + + def page() -> Component: + return rx.box( + rx.text(local.value), + rx.el.button("set", on_click=local.set_value("changed")), + ) + + ctx, page_ctx = _compile_single_page(page) + page_output = page_ctx.output_code or "" + + assert not ctx.memoize_wrappers, ( + "Page-local client state cannot be split across memo components. " + f"Got wrappers: {list(ctx.memoize_wrappers)}" + ) + assert ( + 'const [local_sibling, setLocal_sibling] = useState("initial")' in page_output + ) + assert 'setLocal_sibling("changed")' in page_output + + def test_debounce_input_memo_renders_react_debounce_wrapper() -> None: """``rx.input(value=..., on_change=..., debounce_timeout=N)`` memoizes via DebounceInput. From a633fb9f185104cc52b7ad71c9dff6cdb35e3aed Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Tue, 6 Oct 2026 14:09:12 +0530 Subject: [PATCH 02/13] Keep unrelated memoization for local client state --- news/+7426.bugfix.md | 2 +- packages/reflex-base/news/+7426.bugfix.md | 2 +- .../src/reflex_base/plugins/compiler.py | 5 ++ reflex/compiler/plugins/memoize.py | 59 +++++++++++++++++-- tests/units/compiler/test_memoize_plugin.py | 8 ++- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/news/+7426.bugfix.md b/news/+7426.bugfix.md index 96def533417..3066c8712dd 100644 --- a/news/+7426.bugfix.md +++ b/news/+7426.bugfix.md @@ -1 +1 @@ -Keep page-local client state readers and setters in a shared React scope when auto-memoization is enabled. +Calling a page-local client state setter from a sibling component no longer raises a `ReferenceError` or leaves the displayed value unchanged. diff --git a/packages/reflex-base/news/+7426.bugfix.md b/packages/reflex-base/news/+7426.bugfix.md index 5c27f4db311..3066c8712dd 100644 --- a/packages/reflex-base/news/+7426.bugfix.md +++ b/packages/reflex-base/news/+7426.bugfix.md @@ -1 +1 @@ -Track hook-scope requirements in Var metadata so the compiler can preserve page-local client state behavior. +Calling a page-local client state setter from a sibling component no longer raises a `ReferenceError` or leaves the displayed value unchanged. diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index cd50d44ac43..21c33f6af06 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -603,6 +603,11 @@ class PageContext(BaseContext): # the matching ``leave_component``. Non-empty iff we are inside such a # subtree. memoize_suppressor_stack: list[int] = dataclasses.field(default_factory=list) + # Number of components using each page-local hook scope shared across + # multiple components. Memoization boundaries must not split these scopes. + memoize_shared_scope_counts: dict[str, int] = dataclasses.field( + default_factory=dict + ) # Maps both the user-owned original's ``id()`` and the clone's ``id()`` to # the page-local clone. Lets the walker and plugins rebind children, style, # or event_triggers on a page-local copy without mutating a user-owned diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index eaddc0a6656..64f5e0984a2 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -124,6 +124,50 @@ def _subtree_requires_shared_scope( _subtree_requires_shared_scope(prop_component, scope_owners, _seen) +def _subtree_splits_shared_scope( + component: Component, shared_scope_counts: dict[str, int] +) -> bool: + """Whether memoizing this subtree would separate users of a shared hook scope. + + Args: + component: Subtree candidate for memoization. + shared_scope_counts: Total component users of each shared scope on the page. + + Returns: + Whether this subtree contains only part of any shared scope. + """ + if not shared_scope_counts: + return False + scope_owners: dict[str, set[int]] = {} + _subtree_requires_shared_scope(component, scope_owners) + return any( + 0 < len(scope_owners.get(key, ())) < count + for key, count in shared_scope_counts.items() + ) + + +def _component_uses_shared_scope( + component: Component, shared_scope_counts: dict[str, int] +) -> bool: + """Whether a component directly uses a hook scope shared across components. + + Args: + component: Component being considered for memoization. + shared_scope_counts: Shared hook scopes present on the page. + + Returns: + Whether the component directly carries a shared-scope Var. + """ + if not shared_scope_counts: + return False + return any( + key in shared_scope_counts + for var in component._get_vars(include_children=False) + if (var_data := var._get_all_var_data()) is not None + for key in var_data.shared_scope_keys + ) + + def _component_subtree_is_reactive( component: Component, _cache: dict[int, bool] ) -> bool: @@ -305,16 +349,18 @@ def enter_component( if comp is root or page_context._owned.get(id(root)) is comp: scope_owners: dict[str, set[int]] = {} _subtree_requires_shared_scope(comp, scope_owners) - if any(len(owners) > 1 for owners in scope_owners.values()): - # Local client-state setters are bare React setters. Keep their - # readers and setters in the same component scope. - page_context.memoize_suppressor_stack.append(id(comp)) - return None + page_context.memoize_shared_scope_counts = { + key: len(owners) + for key, owners in scope_owners.items() + if len(owners) > 1 + } if page_context.memoize_suppressor_stack: return None strategy = get_memoization_strategy(comp) if strategy is not MemoizationStrategy.SNAPSHOT: return None + if _subtree_splits_shared_scope(comp, page_context.memoize_shared_scope_counts): + return None snapshot_boundary = is_snapshot_boundary(comp) if not _should_memoize(comp): @@ -374,6 +420,9 @@ def leave_component( if stack: return None + if _component_uses_shared_scope(comp, page_context.memoize_shared_scope_counts): + return None + if len(children) != len(comp.children) or any( compiled_child is not current_child for compiled_child, current_child in zip( diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 35e991b2d63..4536912645e 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1673,15 +1673,17 @@ def page() -> Component: return rx.box( rx.text(local.value), rx.el.button("set", on_click=local.set_value("changed")), + rx.text(SpecialFormMemoState.value, id="unrelated-reactive"), ) ctx, page_ctx = _compile_single_page(page) page_output = page_ctx.output_code or "" - assert not ctx.memoize_wrappers, ( - "Page-local client state cannot be split across memo components. " - f"Got wrappers: {list(ctx.memoize_wrappers)}" + assert ctx.memoize_wrappers, ( + "Unrelated reactive components should retain auto-memoization when " + "page-local client state is shared." ) + assert "local_sibling" not in _compile_memo_module_text(ctx) assert ( 'const [local_sibling, setLocal_sibling] = useState("initial")' in page_output ) From bdf01e57317b1bf4040f5e2dfe076cd10ac895bf Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 14 Oct 2026 17:01:05 +0530 Subject: [PATCH 03/13] fix: count reused local state component occurrences --- .../src/reflex_base/plugins/compiler.py | 3 + reflex/compiler/plugins/memoize.py | 170 ++++++++++++++---- .../tests_playwright/test_client_state.py | 34 ++++ tests/units/compiler/test_memoize_plugin.py | 25 +++ 4 files changed, 198 insertions(+), 34 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index d3a8f9ecc13..33b18adff73 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -610,6 +610,9 @@ class PageContext(BaseContext): memoize_shared_scope_counts: dict[str, int] = dataclasses.field( default_factory=dict ) + # Snapshot candidates whose subtree contains only part of a shared local + # hook scope, computed once when the root component is visited. + memoize_shared_scope_split_ids: set[int] | None = None # Maps both the user-owned original's ``id()`` and the clone's ``id()`` to # the page-local clone. Lets the walker and plugins rebind children, style, # or event_triggers on a page-local copy without mutating a user-owned diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 64f5e0984a2..a5b04ea5dcc 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -89,59 +89,140 @@ def _subtree_has_reactive_data( def _subtree_requires_shared_scope( component: Component, - scope_owners: dict[str, set[int]], - _seen: set[int] | None = None, + scope_counts: dict[str, int], + _visiting: set[int] | None = None, ) -> None: - """Collect components using each local hook scope in a subtree. + """Count each appearance of a local hook scope in a subtree. Args: component: Subtree root to scan. - scope_owners: Scope-key to component-identity mapping being populated. - _seen: Component identities already visited through children or Vars. + scope_counts: Scope-key to render-occurrence count being populated. + _visiting: Component identities on the current recursion path. """ - if _seen is None: - _seen = set() + if _visiting is None: + _visiting = set() component_id = id(component) - if component_id in _seen: + if component_id in _visiting: return - _seen.add(component_id) + _visiting.add(component_id) + + try: + local_scope_keys = { + key + for var in component._get_vars(include_children=False) + if (var_data := var._get_all_var_data()) is not None + for key in var_data.shared_scope_keys + } + for key in local_scope_keys: + scope_counts[key] = scope_counts.get(key, 0) + 1 + + for var in component._get_vars(include_children=False): + var_data = var._get_all_var_data() + if var_data is not None: + for embedded in var_data.components: + if isinstance(embedded, Component): + _subtree_requires_shared_scope( + embedded, scope_counts, _visiting + ) + + for child in component.children: + if isinstance(child, Component): + _subtree_requires_shared_scope(child, scope_counts, _visiting) + for prop_component in component._get_components_in_props(): + if isinstance(prop_component, Component): + _subtree_requires_shared_scope(prop_component, scope_counts, _visiting) + finally: + _visiting.remove(component_id) + + +def _collect_subtree_shared_scope_counts( + component: Component, + subtree_counts: dict[int, dict[str, int]], + visiting: set[int], +) -> dict[str, int] | None: + """Count each subtree's shared-scope appearances in one post-order traversal. - for var in component._get_vars(include_children=False): - var_data = var._get_all_var_data() - if var_data is None: - continue - for key in var_data.shared_scope_keys: - scope_owners.setdefault(key, set()).add(component_id) - for embedded in var_data.components: - if isinstance(embedded, Component): - _subtree_requires_shared_scope(embedded, scope_owners, _seen) + Args: + component: Subtree root to scan. + subtree_counts: Component identity to scope occurrence counts. + visiting: Component identities on the current traversal path. - for child in component.children: - if isinstance(child, Component): - _subtree_requires_shared_scope(child, scope_owners, _seen) - for prop_component in component._get_components_in_props(): - if isinstance(prop_component, Component): - _subtree_requires_shared_scope(prop_component, scope_owners, _seen) + Returns: + Scope occurrence counts in this subtree, or None if a component cycle is found. + """ + component_id = id(component) + if component_id in visiting: + return None + if component_id in subtree_counts: + return subtree_counts[component_id] + + visiting.add(component_id) + counts: dict[str, int] = {} + + def merge_descendant(descendant: BaseComponent) -> bool: + if not isinstance(descendant, Component): + return True + descendant_counts = _collect_subtree_shared_scope_counts( + descendant, subtree_counts, visiting + ) + if descendant_counts is None: + return False + for key, count in descendant_counts.items(): + counts[key] = counts.get(key, 0) + count + return True + + try: + local_scope_keys: set[str] = set() + embedded_components: list[BaseComponent] = [] + for var in component._get_vars(include_children=False): + var_data = var._get_all_var_data() + if var_data is None: + continue + local_scope_keys.update(var_data.shared_scope_keys) + embedded_components.extend(var_data.components) + + for embedded in embedded_components: + if not merge_descendant(embedded): + return None + + for child in component.children: + if not merge_descendant(child): + return None + for prop_component in component._get_components_in_props(): + if not merge_descendant(prop_component): + return None + for key in local_scope_keys: + counts[key] = counts.get(key, 0) + 1 + finally: + visiting.remove(component_id) + + subtree_counts[component_id] = counts + return counts def _subtree_splits_shared_scope( - component: Component, shared_scope_counts: dict[str, int] + component: Component, + shared_scope_counts: dict[str, int], + split_component_ids: set[int] | None = None, ) -> bool: """Whether memoizing this subtree would separate users of a shared hook scope. Args: component: Subtree candidate for memoization. shared_scope_counts: Total component users of each shared scope on the page. + split_component_ids: Precomputed candidates that split a shared scope. Returns: Whether this subtree contains only part of any shared scope. """ + if split_component_ids is not None: + return id(component) in split_component_ids if not shared_scope_counts: return False - scope_owners: dict[str, set[int]] = {} - _subtree_requires_shared_scope(component, scope_owners) + scope_counts: dict[str, int] = {} + _subtree_requires_shared_scope(component, scope_counts) return any( - 0 < len(scope_owners.get(key, ())) < count + 0 < scope_counts.get(key, 0) < count for key, count in shared_scope_counts.items() ) @@ -347,19 +428,40 @@ def enter_component( return None root = page_context.root_component if comp is root or page_context._owned.get(id(root)) is comp: - scope_owners: dict[str, set[int]] = {} - _subtree_requires_shared_scope(comp, scope_owners) + subtree_counts: dict[int, dict[str, int]] = {} + root_counts = _collect_subtree_shared_scope_counts( + comp, subtree_counts, set() + ) + if root_counts is None: + # Preserve the cycle-safe fallback for unusual component graphs. + scope_counts: dict[str, int] = {} + _subtree_requires_shared_scope(comp, scope_counts) + page_context.memoize_shared_scope_split_ids = None + else: + scope_counts = root_counts + page_context.memoize_shared_scope_split_ids = set() page_context.memoize_shared_scope_counts = { - key: len(owners) - for key, owners in scope_owners.items() - if len(owners) > 1 + key: count for key, count in scope_counts.items() if count > 1 } + if root_counts is not None: + page_context.memoize_shared_scope_split_ids = { + component_id + for component_id, counts in subtree_counts.items() + if any( + 0 < counts.get(key, 0) < count + for key, count in page_context.memoize_shared_scope_counts.items() + ) + } if page_context.memoize_suppressor_stack: return None strategy = get_memoization_strategy(comp) if strategy is not MemoizationStrategy.SNAPSHOT: return None - if _subtree_splits_shared_scope(comp, page_context.memoize_shared_scope_counts): + if _subtree_splits_shared_scope( + comp, + page_context.memoize_shared_scope_counts, + page_context.memoize_shared_scope_split_ids, + ): return None snapshot_boundary = is_snapshot_boundary(comp) diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py index 6969e6060de..e2eae60e2a0 100644 --- a/tests/integration/tests_playwright/test_client_state.py +++ b/tests/integration/tests_playwright/test_client_state.py @@ -32,6 +32,7 @@ def push_none(self): shared = rx._x.client_state(default="initial") local = rx._x.client_state(default="local-initial", global_ref=False) + reused_local_reader = rx.text(local.value, id="reused-local-reader") backend_default = rx._x.client_state(default=ClientStateAppState.default_text) def index(): @@ -62,6 +63,22 @@ def local_siblings(): ), ) + def local_reused_reader(): + return rx.box( + reused_local_reader, + rx.foreach( + ["item"], + lambda _item: rx.box( + reused_local_reader, + rx.button( + "set local", + on_click=local.set_value("local-clicked"), + id="reused-local-setter", + ), + ), + ), + ) + def backend_default_page(): return rx.box( rx.input( @@ -93,6 +110,7 @@ def push_before_mount(): app.add_page(index) app.add_page(siblings) app.add_page(local_siblings, route="/local-siblings") + app.add_page(local_reused_reader, route="/local-reused-reader") app.add_page(backend_default_page, route="/backend-default") app.add_page(push_before_mount, route="/push-before-mount") @@ -190,6 +208,22 @@ def test_local_sibling_setter_updates_reader( assert errors == [] +def test_reused_local_reader_updates_across_foreach_boundary( + client_state_app: AppHarness, page: Page +) -> None: + """A local reader reused inside and outside foreach follows its setter.""" + assert client_state_app.frontend_url is not None + errors = _collect_page_errors(page) + page.goto(client_state_app.frontend_url.removesuffix("/") + "/local-reused-reader") + + readers = page.locator("#reused-local-reader") + expect(readers).to_have_count(2) + expect(readers).to_have_text(["local-initial", "local-initial"]) + page.click("#reused-local-setter") + expect(readers).to_have_text(["local-clicked", "local-clicked"]) + assert errors == [] + + def test_setter_works_before_reader_mounts( client_state_app: AppHarness, page: Page ) -> None: diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 65dae2eaa0f..2c2037fb7bd 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1682,6 +1682,7 @@ def page() -> Component: ctx, page_ctx = _compile_single_page(page) page_output = page_ctx.output_code or "" + assert page_ctx.memoize_shared_scope_split_ids is not None assert ctx.memoize_wrappers, ( "Unrelated reactive components should retain auto-memoization when " "page-local client state is shared." @@ -1693,6 +1694,30 @@ def page() -> Component: assert 'setLocal_sibling("changed")' in page_output +def test_reused_local_client_state_reader_stays_in_page_scope() -> None: + """A reused reader across a foreach boundary counts as two appearances.""" + from reflex.experimental.client_state import ClientStateVar + + local = ClientStateVar.create("reused_reader", default="initial", global_ref=False) + reader = rx.text(local.value) + + def page() -> Component: + return rx.box( + reader, + rx.foreach( + ["item"], + lambda _item: rx.box( + reader, + rx.el.button("set", on_click=local.set_value("changed")), + ), + ), + ) + + _ctx, page_ctx = _compile_single_page(page) + + assert page_ctx.memoize_shared_scope_counts[local._id_name] == 3 + + def test_debounce_input_memo_renders_react_debounce_wrapper() -> None: """``rx.input(value=..., on_change=..., debounce_timeout=N)`` memoizes via DebounceInput. From 8aacd1f701d85415a029da42bd3455bc74f936ca Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 14 Oct 2026 17:05:44 +0530 Subject: [PATCH 04/13] ci: install asyncio dependencies for legacy playground --- .github/workflows/examples.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index c34ec5b014a..e0010c50ddd 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -82,7 +82,9 @@ jobs: - name: Install reflex 0.8.23 into a fresh venv run: | uv venv --python 3.12 "$RUNNER_TEMP/reflex-0.8.23" - uv pip install --python "$RUNNER_TEMP/reflex-0.8.23/bin/python" "reflex==0.8.23" "plotly>=6.0,<7.0" + uv pip install \ + --python "$RUNNER_TEMP/reflex-0.8.23/bin/python" \ + "reflex==0.8.23" "SQLAlchemy[asyncio]" "plotly>=6.0,<7.0" echo "$RUNNER_TEMP/reflex-0.8.23/bin" >> "$GITHUB_PATH" - name: Show the reflex under test working-directory: examples/playground From 1d89259125495e0c2aad1e719935cd804d29170a Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 14 Oct 2026 18:55:54 +0530 Subject: [PATCH 05/13] fix: preserve shared state scope across styled clones --- .../src/reflex_base/plugins/compiler.py | 6 ++++ reflex/compiler/plugins/memoize.py | 8 ++++- .../tests_playwright/test_client_state.py | 32 ++++++++++++++++++- tests/units/compiler/test_memoize_plugin.py | 30 +++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index 33b18adff73..3365c15f93a 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -623,6 +623,9 @@ class PageContext(BaseContext): # garbage collected, and Python may recycle its ``id()`` for a fresh # component, causing ``own()`` to hand back the wrong clone. _owned_refs: list[BaseComponent] = dataclasses.field(default_factory=list) + # Original component identity for page-owned clones, used by compiler passes + # whose prepasses run before plugins create those clones. + _owned_original_ids: dict[int, int] = dataclasses.field(default_factory=dict) def own(self, comp: _BaseComponentT) -> _BaseComponentT: """Return a page-local copy of ``comp``, cloning on first encounter. @@ -644,6 +647,9 @@ def own(self, comp: _BaseComponentT) -> _BaseComponentT: self._owned[id(comp)] = new self._owned[id(new)] = new self._owned_refs.append(comp) + self._owned_original_ids[id(new)] = self._owned_original_ids.get( + id(comp), id(comp) + ) return new def merged_imports(self, *, collapse: bool = False) -> ParsedImportDict: diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index a5b04ea5dcc..ec76c07699f 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -204,6 +204,7 @@ def _subtree_splits_shared_scope( component: Component, shared_scope_counts: dict[str, int], split_component_ids: set[int] | None = None, + component_id: int | None = None, ) -> bool: """Whether memoizing this subtree would separate users of a shared hook scope. @@ -211,12 +212,16 @@ def _subtree_splits_shared_scope( component: Subtree candidate for memoization. shared_scope_counts: Total component users of each shared scope on the page. split_component_ids: Precomputed candidates that split a shared scope. + component_id: Original component identity when ``component`` is a + page-owned clone created after the prepass. Returns: Whether this subtree contains only part of any shared scope. """ if split_component_ids is not None: - return id(component) in split_component_ids + return ( + component_id if component_id is not None else id(component) + ) in split_component_ids if not shared_scope_counts: return False scope_counts: dict[str, int] = {} @@ -461,6 +466,7 @@ def enter_component( comp, page_context.memoize_shared_scope_counts, page_context.memoize_shared_scope_split_ids, + page_context._owned_original_ids.get(id(comp)), ): return None snapshot_boundary = is_snapshot_boundary(comp) diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py index e2eae60e2a0..3d0dfca946f 100644 --- a/tests/integration/tests_playwright/test_client_state.py +++ b/tests/integration/tests_playwright/test_client_state.py @@ -79,6 +79,19 @@ def local_reused_reader(): ), ) + def local_styled_reader(): + return rx.box( + rx.text(local.value, id="styled-outside-reader"), + rx.auto_scroll( + rx.text(local.value, id="styled-inside-reader"), + rx.button( + "set local", + on_click=local.set_value("local-clicked"), + id="styled-local-setter", + ), + ), + ) + def backend_default_page(): return rx.box( rx.input( @@ -106,11 +119,12 @@ def push_before_mount(): rx.text(ClientStateAppState.router.session.client_token, id="token"), ) - app = rx.App() + app = rx.App(style={rx.auto_scroll: {"padding": "1em"}}) app.add_page(index) app.add_page(siblings) app.add_page(local_siblings, route="/local-siblings") app.add_page(local_reused_reader, route="/local-reused-reader") + app.add_page(local_styled_reader, route="/local-styled-reader") app.add_page(backend_default_page, route="/backend-default") app.add_page(push_before_mount, route="/push-before-mount") @@ -224,6 +238,22 @@ def test_reused_local_reader_updates_across_foreach_boundary( assert errors == [] +def test_styled_local_reader_updates_across_auto_scroll_boundary( + client_state_app: AppHarness, page: Page +) -> None: + """App-level styling must not split local client state across auto_scroll.""" + assert client_state_app.frontend_url is not None + errors = _collect_page_errors(page) + page.goto(client_state_app.frontend_url.removesuffix("/") + "/local-styled-reader") + + expect(page.locator("#styled-outside-reader")).to_have_text("local-initial") + expect(page.locator("#styled-inside-reader")).to_have_text("local-initial") + page.click("#styled-local-setter") + expect(page.locator("#styled-outside-reader")).to_have_text("local-clicked") + expect(page.locator("#styled-inside-reader")).to_have_text("local-clicked") + assert errors == [] + + def test_setter_works_before_reader_mounts( client_state_app: AppHarness, page: Page ) -> None: diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 2c2037fb7bd..72ed4a5e46f 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1718,6 +1718,36 @@ def page() -> Component: assert page_ctx.memoize_shared_scope_counts[local._id_name] == 3 +def test_styled_local_client_state_subtree_uses_original_split_id() -> None: + """A page-owned style clone retains the prepass shared-scope decision.""" + from reflex.experimental.client_state import ClientStateVar + + local = ClientStateVar.create("styled_reader", default="initial", global_ref=False) + styled = rx.auto_scroll( + rx.text(local.value), + rx.el.button("set", on_click=local.set_value("changed")), + ) + root = rx.box(rx.text(local.value), styled) + page_ctx = PageContext(name="test", route="/", root_component=root) + plugin = MemoizeStatefulPlugin() + + plugin.enter_component(root, page_context=page_ctx, compile_context=None) + styled_clone = page_ctx.own(styled) + + assert page_ctx.memoize_shared_scope_split_ids is not None + assert id(styled) in page_ctx.memoize_shared_scope_split_ids + assert id(styled_clone) not in page_ctx.memoize_shared_scope_split_ids + assert page_ctx._owned_original_ids[id(styled_clone)] == id(styled) + assert ( + plugin.enter_component( + styled_clone, + page_context=page_ctx, + compile_context=None, + ) + is None + ) + + def test_debounce_input_memo_renders_react_debounce_wrapper() -> None: """``rx.input(value=..., on_change=..., debounce_timeout=N)`` memoizes via DebounceInput. From 0199479616df3a152c6b9da3f89acc8f94f81179 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 15 Oct 2026 11:28:22 +0530 Subject: [PATCH 06/13] fix: include app styles in client state scope scan --- reflex/compiler/plugins/builtin.py | 2 +- reflex/compiler/plugins/memoize.py | 39 +++++++++++++++---- .../tests_playwright/test_client_state.py | 2 +- tests/units/compiler/test_memoize_plugin.py | 17 ++++++++ 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index f8855b8a7b1..b8bc2e1a479 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -468,7 +468,7 @@ def default_page_plugins( chain: list[Plugin] = [*plugins, DefaultPagePlugin()] if style is not None: chain.append(ApplyStylePlugin(style=style)) - chain.extend((DefaultCollectorPlugin(), MemoizeStatefulPlugin())) + chain.extend((DefaultCollectorPlugin(), MemoizeStatefulPlugin(style=style))) return tuple(chain) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index ec76c07699f..a3723899df0 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -27,7 +27,7 @@ collect_var_app_wraps_for_component, collect_var_app_wraps_in_subtree, ) -from reflex_base.components.component import BaseComponent, Component +from reflex_base.components.component import BaseComponent, Component, ComponentStyle from reflex_base.components.memo import create_passthrough_component_memo from reflex_base.components.memoize_helpers import ( MemoizationStrategy, @@ -90,6 +90,7 @@ def _subtree_has_reactive_data( def _subtree_requires_shared_scope( component: Component, scope_counts: dict[str, int], + style: ComponentStyle | None = None, _visiting: set[int] | None = None, ) -> None: """Count each appearance of a local hook scope in a subtree. @@ -97,6 +98,7 @@ def _subtree_requires_shared_scope( Args: component: Subtree root to scan. scope_counts: Scope-key to render-occurrence count being populated. + style: App-level styles whose vars may add hook-scope users. _visiting: Component identities on the current recursion path. """ if _visiting is None: @@ -113,6 +115,12 @@ def _subtree_requires_shared_scope( if (var_data := var._get_all_var_data()) is not None for key in var_data.shared_scope_keys } + if ( + style is not None + and (component_style := component._get_component_style(style)) is not None + and (var_data := component_style._var_data) is not None + ): + local_scope_keys.update(var_data.shared_scope_keys) for key in local_scope_keys: scope_counts[key] = scope_counts.get(key, 0) + 1 @@ -122,15 +130,17 @@ def _subtree_requires_shared_scope( for embedded in var_data.components: if isinstance(embedded, Component): _subtree_requires_shared_scope( - embedded, scope_counts, _visiting + embedded, scope_counts, style, _visiting ) for child in component.children: if isinstance(child, Component): - _subtree_requires_shared_scope(child, scope_counts, _visiting) + _subtree_requires_shared_scope(child, scope_counts, style, _visiting) for prop_component in component._get_components_in_props(): if isinstance(prop_component, Component): - _subtree_requires_shared_scope(prop_component, scope_counts, _visiting) + _subtree_requires_shared_scope( + prop_component, scope_counts, style, _visiting + ) finally: _visiting.remove(component_id) @@ -139,6 +149,7 @@ def _collect_subtree_shared_scope_counts( component: Component, subtree_counts: dict[int, dict[str, int]], visiting: set[int], + style: ComponentStyle | None = None, ) -> dict[str, int] | None: """Count each subtree's shared-scope appearances in one post-order traversal. @@ -146,6 +157,7 @@ def _collect_subtree_shared_scope_counts( component: Subtree root to scan. subtree_counts: Component identity to scope occurrence counts. visiting: Component identities on the current traversal path. + style: App-level styles whose vars may add hook-scope users. Returns: Scope occurrence counts in this subtree, or None if a component cycle is found. @@ -163,7 +175,7 @@ def merge_descendant(descendant: BaseComponent) -> bool: if not isinstance(descendant, Component): return True descendant_counts = _collect_subtree_shared_scope_counts( - descendant, subtree_counts, visiting + descendant, subtree_counts, visiting, style ) if descendant_counts is None: return False @@ -180,6 +192,12 @@ def merge_descendant(descendant: BaseComponent) -> bool: continue local_scope_keys.update(var_data.shared_scope_keys) embedded_components.extend(var_data.components) + if ( + style is not None + and (component_style := component._get_component_style(style)) is not None + and (var_data := component_style._var_data) is not None + ): + local_scope_keys.update(var_data.shared_scope_keys) for embedded in embedded_components: if not merge_descendant(embedded): @@ -205,6 +223,7 @@ def _subtree_splits_shared_scope( shared_scope_counts: dict[str, int], split_component_ids: set[int] | None = None, component_id: int | None = None, + style: ComponentStyle | None = None, ) -> bool: """Whether memoizing this subtree would separate users of a shared hook scope. @@ -214,6 +233,7 @@ def _subtree_splits_shared_scope( split_component_ids: Precomputed candidates that split a shared scope. component_id: Original component identity when ``component`` is a page-owned clone created after the prepass. + style: App-level styles whose vars may add hook-scope users. Returns: Whether this subtree contains only part of any shared scope. @@ -225,7 +245,7 @@ def _subtree_splits_shared_scope( if not shared_scope_counts: return False scope_counts: dict[str, int] = {} - _subtree_requires_shared_scope(component, scope_counts) + _subtree_requires_shared_scope(component, scope_counts, style) return any( 0 < scope_counts.get(key, 0) < count for key, count in shared_scope_counts.items() @@ -393,6 +413,8 @@ class MemoizeStatefulPlugin(Plugin): component leaves. """ + style: ComponentStyle | None = None + def enter_component( self, comp: BaseComponent, @@ -435,12 +457,12 @@ def enter_component( if comp is root or page_context._owned.get(id(root)) is comp: subtree_counts: dict[int, dict[str, int]] = {} root_counts = _collect_subtree_shared_scope_counts( - comp, subtree_counts, set() + comp, subtree_counts, set(), self.style ) if root_counts is None: # Preserve the cycle-safe fallback for unusual component graphs. scope_counts: dict[str, int] = {} - _subtree_requires_shared_scope(comp, scope_counts) + _subtree_requires_shared_scope(comp, scope_counts, self.style) page_context.memoize_shared_scope_split_ids = None else: scope_counts = root_counts @@ -467,6 +489,7 @@ def enter_component( page_context.memoize_shared_scope_counts, page_context.memoize_shared_scope_split_ids, page_context._owned_original_ids.get(id(comp)), + self.style, ): return None snapshot_boundary = is_snapshot_boundary(comp) diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py index 3d0dfca946f..ae7bee92d81 100644 --- a/tests/integration/tests_playwright/test_client_state.py +++ b/tests/integration/tests_playwright/test_client_state.py @@ -119,7 +119,7 @@ def push_before_mount(): rx.text(ClientStateAppState.router.session.client_token, id="token"), ) - app = rx.App(style={rx.auto_scroll: {"padding": "1em"}}) + app = rx.App(style={rx.auto_scroll: {"padding": "1em", "color": local.value}}) app.add_page(index) app.add_page(siblings) app.add_page(local_siblings, route="/local-siblings") diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 72ed4a5e46f..3814e15bd10 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1748,6 +1748,23 @@ def test_styled_local_client_state_subtree_uses_original_split_id() -> None: ) +def test_app_style_local_client_state_is_in_shared_scope_prepass() -> None: + """Vars added by app-level styles participate in shared-scope detection.""" + from reflex.experimental.client_state import ClientStateVar + + local = ClientStateVar.create("styled_scope", default="initial", global_ref=False) + styled = rx.auto_scroll() + root = rx.box(rx.text(local.value), styled) + page_ctx = PageContext(name="test", route="/", root_component=root) + plugin = MemoizeStatefulPlugin(style={rx.auto_scroll: {"color": local.value}}) + + plugin.enter_component(root, page_context=page_ctx, compile_context=None) + + assert page_ctx.memoize_shared_scope_counts[local._id_name] == 2 + assert page_ctx.memoize_shared_scope_split_ids is not None + assert id(styled) in page_ctx.memoize_shared_scope_split_ids + + def test_debounce_input_memo_renders_react_debounce_wrapper() -> None: """``rx.input(value=..., on_change=..., debounce_timeout=N)`` memoizes via DebounceInput. From 52b64fe698cc74badf20b8852a68983f470d3072 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 15 Oct 2026 12:59:32 +0530 Subject: [PATCH 07/13] perf: skip unused shared-scope subtree counts --- reflex/compiler/plugins/memoize.py | 115 ++++++++++++++++++----------- 1 file changed, 71 insertions(+), 44 deletions(-) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index a3723899df0..4dd86c5bcbc 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -43,6 +43,8 @@ from reflex_components_core.core.cond import Cond from reflex_components_core.core.match import Match +_EMPTY_SHARED_SCOPE_COUNTS: dict[str, int] = {} + def _subtree_has_reactive_data( component: Component, _cache: dict[int, bool] | None = None @@ -145,6 +147,23 @@ def _subtree_requires_shared_scope( _visiting.remove(component_id) +def _merge_shared_scope_counts( + counts: dict[str, int] | None, descendant_counts: dict[str, int] +) -> dict[str, int] | None: + """Merge a descendant's scope counts without allocating for empty subtrees. + + Returns: + The merged counts, or ``None`` if both subtrees are empty. + """ + if not descendant_counts: + return counts + if counts is None: + counts = {} + for key, count in descendant_counts.items(): + counts[key] = counts.get(key, 0) + count + return counts + + def _collect_subtree_shared_scope_counts( component: Component, subtree_counts: dict[int, dict[str, int]], @@ -169,29 +188,24 @@ def _collect_subtree_shared_scope_counts( return subtree_counts[component_id] visiting.add(component_id) - counts: dict[str, int] = {} - - def merge_descendant(descendant: BaseComponent) -> bool: - if not isinstance(descendant, Component): - return True - descendant_counts = _collect_subtree_shared_scope_counts( - descendant, subtree_counts, visiting, style - ) - if descendant_counts is None: - return False - for key, count in descendant_counts.items(): - counts[key] = counts.get(key, 0) + count - return True + counts: dict[str, int] | None = None try: local_scope_keys: set[str] = set() - embedded_components: list[BaseComponent] = [] for var in component._get_vars(include_children=False): var_data = var._get_all_var_data() if var_data is None: continue local_scope_keys.update(var_data.shared_scope_keys) - embedded_components.extend(var_data.components) + for embedded in var_data.components: + if not isinstance(embedded, Component): + continue + descendant_counts = _collect_subtree_shared_scope_counts( + embedded, subtree_counts, visiting, style + ) + if descendant_counts is None: + return None + counts = _merge_shared_scope_counts(counts, descendant_counts) if ( style is not None and (component_style := component._get_component_style(style)) is not None @@ -199,23 +213,35 @@ def merge_descendant(descendant: BaseComponent) -> bool: ): local_scope_keys.update(var_data.shared_scope_keys) - for embedded in embedded_components: - if not merge_descendant(embedded): - return None - for child in component.children: - if not merge_descendant(child): + if not isinstance(child, Component): + continue + descendant_counts = _collect_subtree_shared_scope_counts( + child, subtree_counts, visiting, style + ) + if descendant_counts is None: return None + counts = _merge_shared_scope_counts(counts, descendant_counts) for prop_component in component._get_components_in_props(): - if not merge_descendant(prop_component): + if not isinstance(prop_component, Component): + continue + descendant_counts = _collect_subtree_shared_scope_counts( + prop_component, subtree_counts, visiting, style + ) + if descendant_counts is None: return None - for key in local_scope_keys: - counts[key] = counts.get(key, 0) + 1 + counts = _merge_shared_scope_counts(counts, descendant_counts) + if local_scope_keys: + if counts is None: + counts = {} + for key in local_scope_keys: + counts[key] = counts.get(key, 0) + 1 finally: visiting.remove(component_id) - subtree_counts[component_id] = counts - return counts + result = counts if counts is not None else _EMPTY_SHARED_SCOPE_COUNTS + subtree_counts[component_id] = result + return result def _subtree_splits_shared_scope( @@ -455,30 +481,31 @@ def enter_component( return None root = page_context.root_component if comp is root or page_context._owned.get(id(root)) is comp: - subtree_counts: dict[int, dict[str, int]] = {} - root_counts = _collect_subtree_shared_scope_counts( - comp, subtree_counts, set(), self.style - ) - if root_counts is None: - # Preserve the cycle-safe fallback for unusual component graphs. - scope_counts: dict[str, int] = {} - _subtree_requires_shared_scope(comp, scope_counts, self.style) - page_context.memoize_shared_scope_split_ids = None + scope_counts: dict[str, int] = {} + _subtree_requires_shared_scope(comp, scope_counts, self.style) + if any(count > 1 for count in scope_counts.values()): + subtree_counts: dict[int, dict[str, int]] = {} + root_counts = _collect_subtree_shared_scope_counts( + comp, subtree_counts, set(), self.style + ) + if root_counts is None: + # Preserve the cycle-safe fallback for unusual component graphs. + page_context.memoize_shared_scope_split_ids = None + else: + page_context.memoize_shared_scope_split_ids = { + component_id + for component_id, counts in subtree_counts.items() + if any( + 0 < counts.get(key, 0) < count + for key, count in scope_counts.items() + if count > 1 + ) + } else: - scope_counts = root_counts page_context.memoize_shared_scope_split_ids = set() page_context.memoize_shared_scope_counts = { key: count for key, count in scope_counts.items() if count > 1 } - if root_counts is not None: - page_context.memoize_shared_scope_split_ids = { - component_id - for component_id, counts in subtree_counts.items() - if any( - 0 < counts.get(key, 0) < count - for key, count in page_context.memoize_shared_scope_counts.items() - ) - } if page_context.memoize_suppressor_stack: return None strategy = get_memoization_strategy(comp) From 6f551472224d5e839dde3f4db12a820f2d01fe6b Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 15 Oct 2026 14:34:15 +0530 Subject: [PATCH 08/13] perf: reuse shared-scope prepass counts --- reflex/compiler/plugins/memoize.py | 27 +++++++++++---------- tests/units/compiler/test_memoize_plugin.py | 27 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 4dd86c5bcbc..363656cd82a 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -481,17 +481,18 @@ def enter_component( return None root = page_context.root_component if comp is root or page_context._owned.get(id(root)) is comp: - scope_counts: dict[str, int] = {} - _subtree_requires_shared_scope(comp, scope_counts, self.style) - if any(count > 1 for count in scope_counts.values()): - subtree_counts: dict[int, dict[str, int]] = {} - root_counts = _collect_subtree_shared_scope_counts( - comp, subtree_counts, set(), self.style - ) - if root_counts is None: - # Preserve the cycle-safe fallback for unusual component graphs. - page_context.memoize_shared_scope_split_ids = None - else: + subtree_counts: dict[int, dict[str, int]] = {} + root_counts = _collect_subtree_shared_scope_counts( + comp, subtree_counts, set(), self.style + ) + if root_counts is None: + # Preserve the cycle-safe fallback for unusual component graphs. + scope_counts: dict[str, int] = {} + _subtree_requires_shared_scope(comp, scope_counts, self.style) + page_context.memoize_shared_scope_split_ids = None + else: + scope_counts = root_counts + if any(count > 1 for count in scope_counts.values()): page_context.memoize_shared_scope_split_ids = { component_id for component_id, counts in subtree_counts.items() @@ -501,8 +502,8 @@ def enter_component( if count > 1 ) } - else: - page_context.memoize_shared_scope_split_ids = set() + else: + page_context.memoize_shared_scope_split_ids = set() page_context.memoize_shared_scope_counts = { key: count for key, count in scope_counts.items() if count > 1 } diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 3814e15bd10..f2048757102 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1718,6 +1718,33 @@ def page() -> Component: assert page_ctx.memoize_shared_scope_counts[local._id_name] == 3 +def test_shared_scope_prepass_caches_reused_component_subtrees( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The shared-scope prepass counts reused occurrences without rescanning nodes.""" + from reflex.experimental.client_state import ClientStateVar + + local = ClientStateVar.create("cached_scope", default="initial", global_ref=False) + reader = rx.text(local.value) + root = rx.box(reader, reader) + page_ctx = PageContext(name="test", route="/", root_component=root) + plugin = MemoizeStatefulPlugin() + + def unexpected_uncached_walk(*_args: Any, **_kwargs: Any) -> None: + pytest.fail("Acyclic shared-scope scans should use cached counts") + + monkeypatch.setattr( + memoize_plugin, "_subtree_requires_shared_scope", unexpected_uncached_walk + ) + + plugin.enter_component(root, page_context=page_ctx, compile_context=None) + + assert page_ctx.memoize_shared_scope_counts[local._id_name] == 2 + assert page_ctx.memoize_shared_scope_split_ids is not None + assert id(reader) in page_ctx.memoize_shared_scope_split_ids + assert id(root) not in page_ctx.memoize_shared_scope_split_ids + + def test_styled_local_client_state_subtree_uses_original_split_id() -> None: """A page-owned style clone retains the prepass shared-scope decision.""" from reflex.experimental.client_state import ClientStateVar From a2572da067b8634ae6c83dde7d526d832d9883fc Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 15 Oct 2026 15:04:18 +0530 Subject: [PATCH 09/13] test: avoid rounding-sensitive leak assertion --- tests/units/reflex_bench/suites/test_memory.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/units/reflex_bench/suites/test_memory.py b/tests/units/reflex_bench/suites/test_memory.py index 28e701584b5..d6a206b86a0 100644 --- a/tests/units/reflex_bench/suites/test_memory.py +++ b/tests/units/reflex_bench/suites/test_memory.py @@ -785,7 +785,8 @@ def test_a_leaking_server_fails_the_leak_gate(tmp_path: Path, fakes: Fakes): entry = run(tmp_path, "memory.leak", events=1000, warmup_events=0) assert entry["status"] == "failed" error = entry["error"] - assert error.startswith("LeakDetected: memory grows by 2") + assert error.startswith("LeakDetected: memory grows by ") + assert " B per event (95 % CI" in error assert "in the second half after" in error probe, measured = fakes.plans assert (probe.mode, probe.duration_s, probe.sessions) == ("closed", 5.0, 10) From 965287741fb357a1cee39522c6b464670ca05673 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sun, 18 Oct 2026 11:25:48 +0530 Subject: [PATCH 10/13] fix: suppress memoization below shared-scope boundaries --- reflex/compiler/plugins/memoize.py | 4 +- .../tests_playwright/test_client_state.py | 38 +++++++++++++++++++ tests/units/compiler/test_memoize_plugin.py | 35 +++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 363656cd82a..5781174e10e 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -512,6 +512,7 @@ def enter_component( strategy = get_memoization_strategy(comp) if strategy is not MemoizationStrategy.SNAPSHOT: return None + snapshot_boundary = is_snapshot_boundary(comp) if _subtree_splits_shared_scope( comp, page_context.memoize_shared_scope_counts, @@ -519,8 +520,9 @@ def enter_component( page_context._owned_original_ids.get(id(comp)), self.style, ): + if snapshot_boundary: + page_context.memoize_suppressor_stack.append(id(comp)) return None - snapshot_boundary = is_snapshot_boundary(comp) if not _should_memoize(comp): # Boundary not worth wrapping — still suppress descendants so diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py index ae7bee92d81..102911830f7 100644 --- a/tests/integration/tests_playwright/test_client_state.py +++ b/tests/integration/tests_playwright/test_client_state.py @@ -17,11 +17,16 @@ def ClientStateApp(): class ClientStateAppState(rx.State): show: bool = False default_text: str = "from backend" + selected_item: str = "" @rx.event def reveal(self): self.show = True + @rx.event + def select_item(self, item: str): + self.selected_item = item + @rx.event def push_value(self): return pushed.push("pushed") @@ -79,6 +84,23 @@ def local_reused_reader(): ), ) + def local_loop_item(): + return rx.box( + rx.text(local.value, id="loop-outside-reader"), + rx.foreach( + ["alpha"], + lambda item: rx.box( + rx.text(local.value, id="loop-inside-reader"), + rx.el.button( + item, + on_click=ClientStateAppState.select_item(item), + id="loop-item-button", + ), + ), + ), + rx.text(ClientStateAppState.selected_item, id="selected-item"), + ) + def local_styled_reader(): return rx.box( rx.text(local.value, id="styled-outside-reader"), @@ -124,6 +146,7 @@ def push_before_mount(): app.add_page(siblings) app.add_page(local_siblings, route="/local-siblings") app.add_page(local_reused_reader, route="/local-reused-reader") + app.add_page(local_loop_item, route="/local-loop-item") app.add_page(local_styled_reader, route="/local-styled-reader") app.add_page(backend_default_page, route="/backend-default") app.add_page(push_before_mount, route="/push-before-mount") @@ -238,6 +261,21 @@ def test_reused_local_reader_updates_across_foreach_boundary( assert errors == [] +def test_loop_item_callback_stays_in_foreach_scope( + client_state_app: AppHarness, page: Page +) -> None: + """A shared local-state reader must not detach a loop-item callback.""" + assert client_state_app.frontend_url is not None + errors = _collect_page_errors(page) + page.goto(client_state_app.frontend_url.removesuffix("/") + "/local-loop-item") + + expect(page.locator("#loop-outside-reader")).to_have_text("local-initial") + expect(page.locator("#loop-inside-reader")).to_have_text("local-initial") + page.click("#loop-item-button") + expect(page.locator("#selected-item")).to_have_text("alpha") + assert errors == [] + + def test_styled_local_reader_updates_across_auto_scroll_boundary( client_state_app: AppHarness, page: Page ) -> None: diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index d1b22921343..9b43ef234f1 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -128,6 +128,11 @@ class SpecialFormMemoState(BaseState): flag: Field[bool] = field(default=True) value: Field[str] = field(default="a") + @rx.event + def select_item(self, item: str): + """Store the selected loop item.""" + self.value = item + class MemoTriggerState(BaseState): @rx.event @@ -1718,6 +1723,36 @@ def page() -> Component: assert page_ctx.memoize_shared_scope_counts[local._id_name] == 3 +def test_shared_scope_foreach_does_not_memoize_item_callback() -> None: + """A skipped foreach boundary must keep callbacks with loop vars inline.""" + from reflex.experimental.client_state import ClientStateVar + + local = ClientStateVar.create("loop_scope", default="initial", global_ref=False) + + def page() -> Component: + return rx.box( + rx.text(local.value), + rx.foreach( + ["item"], + lambda item: rx.box( + rx.text(local.value), + rx.el.button( + item, + on_click=SpecialFormMemoState.select_item(item), + ), + ), + ), + ) + + ctx, _page_ctx = _compile_single_page(page) + + assert not any( + isinstance(definition, MemoComponentDefinition) + and definition.component.tag == "button" + for definition in ctx.auto_memo_components.values() + ) + + def test_shared_scope_prepass_caches_reused_component_subtrees( monkeypatch: pytest.MonkeyPatch, ) -> None: From 4aa4d75b7c07223e0bd8e737aeb0324624f04b61 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Fri, 23 Oct 2026 15:07:31 +0530 Subject: [PATCH 11/13] refactor: simplify client state scope prepass --- .../src/reflex_base/plugins/compiler.py | 2 +- reflex/compiler/plugins/memoize.py | 286 +++++++----------- tests/units/compiler/test_memoize_plugin.py | 37 ++- .../units/reflex_bench/suites/test_memory.py | 3 +- 4 files changed, 147 insertions(+), 181 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index 3365c15f93a..449c0b3f83f 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -612,7 +612,7 @@ class PageContext(BaseContext): ) # Snapshot candidates whose subtree contains only part of a shared local # hook scope, computed once when the root component is visited. - memoize_shared_scope_split_ids: set[int] | None = None + memoize_shared_scope_split_ids: set[int] = dataclasses.field(default_factory=set) # Maps both the user-owned original's ``id()`` and the clone's ``id()`` to # the page-local clone. Lets the walker and plugins rebind children, style, # or event_triggers on a page-local copy without mutating a user-owned diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 5781174e10e..c84fa83c22a 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -21,6 +21,7 @@ from __future__ import annotations import dataclasses +from collections.abc import Iterable, Iterator from typing import Any from reflex_base.components.app_wraps import ( @@ -39,6 +40,8 @@ from reflex_base.constants.compiler import MemoizationDisposition from reflex_base.plugins import ComponentAndChildren, PageContext from reflex_base.plugins.base import Plugin +from reflex_base.style import Style +from reflex_base.vars.base import VarData from reflex_components_core.base.bare import Bare from reflex_components_core.core.cond import Cond from reflex_components_core.core.match import Match @@ -89,62 +92,22 @@ def _subtree_has_reactive_data( return result -def _subtree_requires_shared_scope( - component: Component, - scope_counts: dict[str, int], - style: ComponentStyle | None = None, - _visiting: set[int] | None = None, -) -> None: - """Count each appearance of a local hook scope in a subtree. +def _iter_component_descendants( + component: Component, var_data: Iterable[VarData] +) -> Iterator[Component]: + """Yield components reachable through vars, children, and component props. - Args: - component: Subtree root to scan. - scope_counts: Scope-key to render-occurrence count being populated. - style: App-level styles whose vars may add hook-scope users. - _visiting: Component identities on the current recursion path. + Returns: + Each descendant component occurrence in the rendered subtree. """ - if _visiting is None: - _visiting = set() - component_id = id(component) - if component_id in _visiting: - return - _visiting.add(component_id) - - try: - local_scope_keys = { - key - for var in component._get_vars(include_children=False) - if (var_data := var._get_all_var_data()) is not None - for key in var_data.shared_scope_keys - } - if ( - style is not None - and (component_style := component._get_component_style(style)) is not None - and (var_data := component_style._var_data) is not None - ): - local_scope_keys.update(var_data.shared_scope_keys) - for key in local_scope_keys: - scope_counts[key] = scope_counts.get(key, 0) + 1 - - for var in component._get_vars(include_children=False): - var_data = var._get_all_var_data() - if var_data is not None: - for embedded in var_data.components: - if isinstance(embedded, Component): - _subtree_requires_shared_scope( - embedded, scope_counts, style, _visiting - ) - - for child in component.children: - if isinstance(child, Component): - _subtree_requires_shared_scope(child, scope_counts, style, _visiting) - for prop_component in component._get_components_in_props(): - if isinstance(prop_component, Component): - _subtree_requires_shared_scope( - prop_component, scope_counts, style, _visiting - ) - finally: - _visiting.remove(component_id) + for data in var_data: + yield from (child for child in data.components if isinstance(child, Component)) + yield from (child for child in component.children if isinstance(child, Component)) + yield from ( + child + for child in component._get_components_in_props() + if isinstance(child, Component) + ) def _merge_shared_scope_counts( @@ -164,26 +127,34 @@ def _merge_shared_scope_counts( return counts +def _component_style_scope_keys( + component: Component, style_scope_keys: dict[Any, tuple[str, ...]] +) -> tuple[str, ...]: + """Return cached app-style scope keys using component style precedence.""" + scope_keys = style_scope_keys.get(type(component), ()) + return style_scope_keys.get(component.create, scope_keys) + + def _collect_subtree_shared_scope_counts( component: Component, subtree_counts: dict[int, dict[str, int]], visiting: set[int], - style: ComponentStyle | None = None, -) -> dict[str, int] | None: + style_scope_keys: dict[Any, tuple[str, ...]], +) -> dict[str, int]: """Count each subtree's shared-scope appearances in one post-order traversal. Args: component: Subtree root to scan. subtree_counts: Component identity to scope occurrence counts. visiting: Component identities on the current traversal path. - style: App-level styles whose vars may add hook-scope users. + style_scope_keys: Cached hook scopes from app-level component styles. Returns: - Scope occurrence counts in this subtree, or None if a component cycle is found. + Scope occurrence counts in this subtree. """ component_id = id(component) if component_id in visiting: - return None + return _EMPTY_SHARED_SCOPE_COUNTS if component_id in subtree_counts: return subtree_counts[component_id] @@ -192,48 +163,24 @@ def _collect_subtree_shared_scope_counts( try: local_scope_keys: set[str] = set() - for var in component._get_vars(include_children=False): - var_data = var._get_all_var_data() - if var_data is None: - continue - local_scope_keys.update(var_data.shared_scope_keys) - for embedded in var_data.components: - if not isinstance(embedded, Component): - continue - descendant_counts = _collect_subtree_shared_scope_counts( - embedded, subtree_counts, visiting, style - ) - if descendant_counts is None: - return None - counts = _merge_shared_scope_counts(counts, descendant_counts) - if ( - style is not None - and (component_style := component._get_component_style(style)) is not None - and (var_data := component_style._var_data) is not None - ): - local_scope_keys.update(var_data.shared_scope_keys) + var_data = tuple( + data + for var in component._get_vars(include_children=False) + if (data := var._get_all_var_data()) is not None + ) + for data in var_data: + local_scope_keys.update(data.shared_scope_keys) + local_scope_keys.update( + _component_style_scope_keys(component, style_scope_keys) + ) - for child in component.children: - if not isinstance(child, Component): - continue - descendant_counts = _collect_subtree_shared_scope_counts( - child, subtree_counts, visiting, style - ) - if descendant_counts is None: - return None - counts = _merge_shared_scope_counts(counts, descendant_counts) - for prop_component in component._get_components_in_props(): - if not isinstance(prop_component, Component): - continue + for child in _iter_component_descendants(component, var_data): descendant_counts = _collect_subtree_shared_scope_counts( - prop_component, subtree_counts, visiting, style + child, subtree_counts, visiting, style_scope_keys ) - if descendant_counts is None: - return None counts = _merge_shared_scope_counts(counts, descendant_counts) if local_scope_keys: - if counts is None: - counts = {} + counts = counts or {} for key in local_scope_keys: counts[key] = counts.get(key, 0) + 1 finally: @@ -245,37 +192,19 @@ def _collect_subtree_shared_scope_counts( def _subtree_splits_shared_scope( - component: Component, - shared_scope_counts: dict[str, int], - split_component_ids: set[int] | None = None, - component_id: int | None = None, - style: ComponentStyle | None = None, + component_id: int, + split_component_ids: set[int], ) -> bool: - """Whether memoizing this subtree would separate users of a shared hook scope. + """Whether memoizing this component would split a shared hook scope. Args: - component: Subtree candidate for memoization. - shared_scope_counts: Total component users of each shared scope on the page. + component_id: Original identity for a component or its page-owned clone. split_component_ids: Precomputed candidates that split a shared scope. - component_id: Original component identity when ``component`` is a - page-owned clone created after the prepass. - style: App-level styles whose vars may add hook-scope users. Returns: - Whether this subtree contains only part of any shared scope. + Whether this component belongs to the precomputed split set. """ - if split_component_ids is not None: - return ( - component_id if component_id is not None else id(component) - ) in split_component_ids - if not shared_scope_counts: - return False - scope_counts: dict[str, int] = {} - _subtree_requires_shared_scope(component, scope_counts, style) - return any( - 0 < scope_counts.get(key, 0) < count - for key, count in shared_scope_counts.items() - ) + return component_id in split_component_ids def _component_uses_shared_scope( @@ -300,6 +229,47 @@ def _component_uses_shared_scope( ) +def _get_style_scope_keys(style: ComponentStyle | None) -> dict[Any, tuple[str, ...]]: + """Extract hook-scope keys from app styles once per memoization plugin. + + Returns: + Style keys mapped to their shared local-state scope identifiers. + """ + if not style: + return {} + scope_keys = {} + for key, style_props in style.items(): + if not isinstance(key, type) and not callable(key): + continue + var_data = Style(style_props)._var_data + if var_data is not None and var_data.shared_scope_keys: + scope_keys[key] = var_data.shared_scope_keys + return scope_keys + + +def _prepare_shared_scopes( + component: Component, + page_context: PageContext, + style_scope_keys: dict[Any, tuple[str, ...]], +) -> None: + """Prepare shared-scope counts and memoization boundaries for a page.""" + subtree_counts: dict[int, dict[str, int]] = {} + scope_counts = _collect_subtree_shared_scope_counts( + component, subtree_counts, set(), style_scope_keys + ) + page_context.memoize_shared_scope_counts = { + key: count for key, count in scope_counts.items() if count > 1 + } + page_context.memoize_shared_scope_split_ids = { + component_id + for component_id, counts in subtree_counts.items() + if any( + 0 < counts.get(key, 0) < count + for key, count in page_context.memoize_shared_scope_counts.items() + ) + } + + def _component_subtree_is_reactive( component: Component, _cache: dict[int, bool] ) -> bool: @@ -416,6 +386,14 @@ def _should_memoize(component: Component) -> bool: return bool(component.event_triggers) +def _suppress_snapshot_descendants( + component: Component, snapshot_boundary: bool, page_context: PageContext +) -> None: + """Keep descendants of an unwrapped snapshot boundary in the same scope.""" + if snapshot_boundary: + page_context.memoize_suppressor_stack.append(id(component)) + + @dataclasses.dataclass(frozen=True, slots=True) class MemoizeStatefulPlugin(Plugin): """Auto-memoize stateful components with experimental-memo wrappers. @@ -440,6 +418,13 @@ class MemoizeStatefulPlugin(Plugin): """ style: ComponentStyle | None = None + style_scope_keys: dict[Any, tuple[str, ...]] = dataclasses.field( + init=False, default_factory=dict, repr=False, compare=False + ) + + def __post_init__(self) -> None: + """Cache hook scopes added by app-level component styles.""" + object.__setattr__(self, "style_scope_keys", _get_style_scope_keys(self.style)) def enter_component( self, @@ -475,60 +460,22 @@ def enter_component( A ``(wrapper, ())`` replacement for memoized boundaries, otherwise ``None``. """ - if in_prop_tree: - return None - if not isinstance(comp, Component): + if in_prop_tree or not isinstance(comp, Component): return None root = page_context.root_component if comp is root or page_context._owned.get(id(root)) is comp: - subtree_counts: dict[int, dict[str, int]] = {} - root_counts = _collect_subtree_shared_scope_counts( - comp, subtree_counts, set(), self.style - ) - if root_counts is None: - # Preserve the cycle-safe fallback for unusual component graphs. - scope_counts: dict[str, int] = {} - _subtree_requires_shared_scope(comp, scope_counts, self.style) - page_context.memoize_shared_scope_split_ids = None - else: - scope_counts = root_counts - if any(count > 1 for count in scope_counts.values()): - page_context.memoize_shared_scope_split_ids = { - component_id - for component_id, counts in subtree_counts.items() - if any( - 0 < counts.get(key, 0) < count - for key, count in scope_counts.items() - if count > 1 - ) - } - else: - page_context.memoize_shared_scope_split_ids = set() - page_context.memoize_shared_scope_counts = { - key: count for key, count in scope_counts.items() if count > 1 - } + _prepare_shared_scopes(comp, page_context, self.style_scope_keys) if page_context.memoize_suppressor_stack: return None strategy = get_memoization_strategy(comp) if strategy is not MemoizationStrategy.SNAPSHOT: return None snapshot_boundary = is_snapshot_boundary(comp) + original_id = page_context._owned_original_ids.get(id(comp), id(comp)) if _subtree_splits_shared_scope( - comp, - page_context.memoize_shared_scope_counts, - page_context.memoize_shared_scope_split_ids, - page_context._owned_original_ids.get(id(comp)), - self.style, - ): - if snapshot_boundary: - page_context.memoize_suppressor_stack.append(id(comp)) - return None - - if not _should_memoize(comp): - # Boundary not worth wrapping — still suppress descendants so - # they don't memoize independently of the boundary's subtree. - if snapshot_boundary: - page_context.memoize_suppressor_stack.append(id(comp)) + original_id, page_context.memoize_shared_scope_split_ids + ) or not _should_memoize(comp): + _suppress_snapshot_descendants(comp, snapshot_boundary, page_context) return None wrapper = self._build_wrapper( @@ -569,19 +516,16 @@ def leave_component( Returns: The memo wrapper for non-boundary memoizables, else ``None``. """ - if in_prop_tree: - return None - if not isinstance(comp, Component): + if in_prop_tree or not isinstance(comp, Component): return None stack = page_context.memoize_suppressor_stack if stack and stack[-1] == id(comp): stack.pop() - if stack: - return None - - if _component_uses_shared_scope(comp, page_context.memoize_shared_scope_counts): + if stack or _component_uses_shared_scope( + comp, page_context.memoize_shared_scope_counts + ): return None if len(children) != len(comp.children) or any( diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 9b43ef234f1..a109d4b682c 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -2,7 +2,7 @@ import dataclasses import re -from collections.abc import Callable +from collections.abc import Callable, Iterator from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -1764,20 +1764,45 @@ def test_shared_scope_prepass_caches_reused_component_subtrees( root = rx.box(reader, reader) page_ctx = PageContext(name="test", route="/", root_component=root) plugin = MemoizeStatefulPlugin() + descendant_walks: dict[int, int] = {} + iter_descendants = memoize_plugin._iter_component_descendants - def unexpected_uncached_walk(*_args: Any, **_kwargs: Any) -> None: - pytest.fail("Acyclic shared-scope scans should use cached counts") + def count_descendant_walks( + component: Component, var_data: tuple[VarData, ...] + ) -> Iterator[Component]: + descendant_walks[id(component)] = descendant_walks.get(id(component), 0) + 1 + yield from iter_descendants(component, var_data) monkeypatch.setattr( - memoize_plugin, "_subtree_requires_shared_scope", unexpected_uncached_walk + memoize_plugin, "_iter_component_descendants", count_descendant_walks ) plugin.enter_component(root, page_context=page_ctx, compile_context=None) assert page_ctx.memoize_shared_scope_counts[local._id_name] == 2 - assert page_ctx.memoize_shared_scope_split_ids is not None assert id(reader) in page_ctx.memoize_shared_scope_split_ids assert id(root) not in page_ctx.memoize_shared_scope_split_ids + assert descendant_walks[id(reader)] == 1 + + +def test_shared_scope_prepass_treats_cycles_as_empty_recursive_branches() -> None: + """A malformed cyclic component graph does not trigger a second traversal.""" + from reflex.experimental.client_state import ClientStateVar + + local = ClientStateVar.create("cycle_scope", default="initial", global_ref=False) + first = rx.text(local.value) + second = rx.text(local.value) + root = rx.box(first, second) + root.children.append(root) + page_ctx = PageContext(name="test", route="/", root_component=root) + + MemoizeStatefulPlugin().enter_component( + root, page_context=page_ctx, compile_context=None + ) + + assert page_ctx.memoize_shared_scope_counts[local._id_name] == 2 + assert {id(first), id(second)} <= page_ctx.memoize_shared_scope_split_ids + assert id(root) not in page_ctx.memoize_shared_scope_split_ids def test_styled_local_client_state_subtree_uses_original_split_id() -> None: @@ -1796,7 +1821,6 @@ def test_styled_local_client_state_subtree_uses_original_split_id() -> None: plugin.enter_component(root, page_context=page_ctx, compile_context=None) styled_clone = page_ctx.own(styled) - assert page_ctx.memoize_shared_scope_split_ids is not None assert id(styled) in page_ctx.memoize_shared_scope_split_ids assert id(styled_clone) not in page_ctx.memoize_shared_scope_split_ids assert page_ctx._owned_original_ids[id(styled_clone)] == id(styled) @@ -1823,7 +1847,6 @@ def test_app_style_local_client_state_is_in_shared_scope_prepass() -> None: plugin.enter_component(root, page_context=page_ctx, compile_context=None) assert page_ctx.memoize_shared_scope_counts[local._id_name] == 2 - assert page_ctx.memoize_shared_scope_split_ids is not None assert id(styled) in page_ctx.memoize_shared_scope_split_ids diff --git a/tests/units/reflex_bench/suites/test_memory.py b/tests/units/reflex_bench/suites/test_memory.py index d6a206b86a0..28e701584b5 100644 --- a/tests/units/reflex_bench/suites/test_memory.py +++ b/tests/units/reflex_bench/suites/test_memory.py @@ -785,8 +785,7 @@ def test_a_leaking_server_fails_the_leak_gate(tmp_path: Path, fakes: Fakes): entry = run(tmp_path, "memory.leak", events=1000, warmup_events=0) assert entry["status"] == "failed" error = entry["error"] - assert error.startswith("LeakDetected: memory grows by ") - assert " B per event (95 % CI" in error + assert error.startswith("LeakDetected: memory grows by 2") assert "in the second half after" in error probe, measured = fakes.plans assert (probe.mode, probe.duration_s, probe.sessions) == ("closed", 5.0, 10) From 83062a415233b62856cd9d4000a193c8f2471ead Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Fri, 23 Oct 2026 15:58:56 +0530 Subject: [PATCH 12/13] fix: preserve empty component style scopes --- reflex/compiler/plugins/memoize.py | 3 +-- tests/units/compiler/test_memoize_plugin.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index c84fa83c22a..7c3619d318f 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -242,8 +242,7 @@ def _get_style_scope_keys(style: ComponentStyle | None) -> dict[Any, tuple[str, if not isinstance(key, type) and not callable(key): continue var_data = Style(style_props)._var_data - if var_data is not None and var_data.shared_scope_keys: - scope_keys[key] = var_data.shared_scope_keys + scope_keys[key] = var_data.shared_scope_keys if var_data is not None else () return scope_keys diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index a109d4b682c..d01007ce343 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1850,6 +1850,26 @@ def test_app_style_local_client_state_is_in_shared_scope_prepass() -> None: assert id(styled) in page_ctx.memoize_shared_scope_split_ids +def test_component_style_override_without_scope_keys_takes_precedence() -> None: + """An empty create-specific style overrides a type-level style scope.""" + from reflex.experimental.client_state import ClientStateVar + + local = ClientStateVar.create("style_override", default="initial", global_ref=False) + styled = rx.auto_scroll() + root = rx.box(rx.text(local.value), styled) + plugin = MemoizeStatefulPlugin( + style={ + type(styled): {"color": local.value}, + styled.create: {"color": "red"}, + } + ) + page_ctx = PageContext(name="test", route="/", root_component=root) + + plugin.enter_component(root, page_context=page_ctx, compile_context=None) + + assert local._id_name not in page_ctx.memoize_shared_scope_counts + + def test_debounce_input_memo_renders_react_debounce_wrapper() -> None: """``rx.input(value=..., on_change=..., debounce_timeout=N)`` memoizes via DebounceInput. From 1211cbf3db1cbad1eaf8f3c8ffd7d5d43d9cf451 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 28 Oct 2026 11:26:48 +0530 Subject: [PATCH 13/13] perf: reduce shared scope collection overhead --- reflex/compiler/plugins/memoize.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 7c3619d318f..55e6d6740c1 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -162,17 +162,25 @@ def _collect_subtree_shared_scope_counts( counts: dict[str, int] | None = None try: - local_scope_keys: set[str] = set() - var_data = tuple( - data - for var in component._get_vars(include_children=False) - if (data := var._get_all_var_data()) is not None - ) - for data in var_data: - local_scope_keys.update(data.shared_scope_keys) - local_scope_keys.update( - _component_style_scope_keys(component, style_scope_keys) - ) + local_scope_keys: set[str] | None = None + var_data: list[VarData] = [] + for var in component._get_vars(include_children=False): + data = var._get_all_var_data() + if data is None: + continue + if data.shared_scope_keys: + if local_scope_keys is None: + local_scope_keys = set() + local_scope_keys.update(data.shared_scope_keys) + if data.components: + var_data.append(data) + + if style_scope_keys: + style_keys = _component_style_scope_keys(component, style_scope_keys) + if style_keys: + if local_scope_keys is None: + local_scope_keys = set() + local_scope_keys.update(style_keys) for child in _iter_component_descendants(component, var_data): descendant_counts = _collect_subtree_shared_scope_counts(