diff --git a/news/+7426.bugfix.md b/news/+7426.bugfix.md new file mode 100644 index 00000000000..3066c8712dd --- /dev/null +++ b/news/+7426.bugfix.md @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000000..3066c8712dd --- /dev/null +++ b/packages/reflex-base/news/+7426.bugfix.md @@ -0,0 +1 @@ +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 aef1cb7dd66..449c0b3f83f 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -605,6 +605,14 @@ 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 + ) + # 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] = 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 @@ -615,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. @@ -636,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/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 500579629ce..df57079ec56 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -356,6 +356,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 = "", @@ -367,6 +370,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. @@ -387,6 +391,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 position is not None: console.deprecate( @@ -413,6 +419,11 @@ def __init__( object.__setattr__(self, "deps", tuple(deps or [])) 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. @@ -428,6 +439,11 @@ def __init__( object.__setattr__(self, "deps", merged_var_data.deps) 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: @@ -532,6 +548,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: @@ -547,6 +570,7 @@ def __bool__(self) -> bool: or self.deps or self.components or self.app_wraps + or self.shared_scope_keys ) @functools.cached_property @@ -577,6 +601,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/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 86b6b53a3e4..55e6d6740c1 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -21,13 +21,14 @@ from __future__ import annotations import dataclasses +from collections.abc import Iterable, Iterator from typing import Any from reflex_base.components.app_wraps import ( 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, @@ -39,10 +40,14 @@ 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 +_EMPTY_SHARED_SCOPE_COUNTS: dict[str, int] = {} + def _subtree_has_reactive_data( component: Component, _cache: dict[int, bool] | None = None @@ -87,6 +92,191 @@ def _subtree_has_reactive_data( return result +def _iter_component_descendants( + component: Component, var_data: Iterable[VarData] +) -> Iterator[Component]: + """Yield components reachable through vars, children, and component props. + + Returns: + Each descendant component occurrence in the rendered subtree. + """ + 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( + 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 _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_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_scope_keys: Cached hook scopes from app-level component styles. + + Returns: + Scope occurrence counts in this subtree. + """ + component_id = id(component) + if component_id in visiting: + return _EMPTY_SHARED_SCOPE_COUNTS + if component_id in subtree_counts: + return subtree_counts[component_id] + + visiting.add(component_id) + counts: dict[str, int] | None = None + + try: + 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( + child, subtree_counts, visiting, style_scope_keys + ) + counts = _merge_shared_scope_counts(counts, descendant_counts) + if local_scope_keys: + counts = counts or {} + for key in local_scope_keys: + counts[key] = counts.get(key, 0) + 1 + finally: + visiting.remove(component_id) + + result = counts if counts is not None else _EMPTY_SHARED_SCOPE_COUNTS + subtree_counts[component_id] = result + return result + + +def _subtree_splits_shared_scope( + component_id: int, + split_component_ids: set[int], +) -> bool: + """Whether memoizing this component would split a shared hook scope. + + Args: + component_id: Original identity for a component or its page-owned clone. + split_component_ids: Precomputed candidates that split a shared scope. + + Returns: + Whether this component belongs to the precomputed split set. + """ + return component_id in split_component_ids + + +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 _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 + scope_keys[key] = var_data.shared_scope_keys if var_data is not None else () + 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: @@ -203,6 +393,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. @@ -226,6 +424,15 @@ class MemoizeStatefulPlugin(Plugin): component leaves. """ + 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, comp: BaseComponent, @@ -260,22 +467,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: + _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) - - 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._owned_original_ids.get(id(comp), id(comp)) + if _subtree_splits_shared_scope( + 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( @@ -316,16 +523,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: + 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/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index 1d0b69c1bc1..b8fed53d72b 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -211,6 +211,7 @@ def create( VarData( hooks=hooks, imports=imports, + shared_scope_keys={id_name} if not global_ref else None, ), ), ) @@ -253,7 +254,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 706a95635a0..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") @@ -31,6 +36,8 @@ def push_none(self): return pushed.push(None) 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(): @@ -51,6 +58,62 @@ 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 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 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"), + 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( @@ -78,9 +141,13 @@ 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", "color": local.value}}) 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_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") @@ -159,6 +226,72 @@ 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_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_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: + """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 ffea8c7ca2d..d01007ce343 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 @@ -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 @@ -1666,6 +1671,205 @@ 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")), + rx.text(SpecialFormMemoState.value, id="unrelated-reactive"), + ) + + 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." + ) + assert "local_sibling" not in _compile_memo_module_text(ctx) + assert ( + 'const [local_sibling, setLocal_sibling] = useState("initial")' in page_output + ) + 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_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: + """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() + descendant_walks: dict[int, int] = {} + iter_descendants = memoize_plugin._iter_component_descendants + + 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, "_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 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: + """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 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_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 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.