From a66bb758205758bddd8fdaef1c944b04878ddf89 Mon Sep 17 00:00:00 2001 From: Alek Date: Mon, 7 Sep 2026 10:53:57 -0700 Subject: [PATCH] Reuse normalization of unchanged literal app styles --- ...+shared-style-normalization.performance.md | 1 + ...+shared-style-normalization.performance.md | 1 + .../src/reflex_base/components/component.py | 12 +- reflex/compiler/plugins/_style.py | 87 ++++++++++ reflex/compiler/plugins/builtin.py | 19 ++- tests/benchmarks/test_compilation.py | 43 ++++- tests/units/compiler/test_plugins.py | 112 +++++++++++++ tests/units/compiler/test_style_cache.py | 152 ++++++++++++++++++ 8 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 news/+shared-style-normalization.performance.md create mode 100644 packages/reflex-base/news/+shared-style-normalization.performance.md create mode 100644 reflex/compiler/plugins/_style.py create mode 100644 tests/units/compiler/test_style_cache.py diff --git a/news/+shared-style-normalization.performance.md b/news/+shared-style-normalization.performance.md new file mode 100644 index 00000000000..aef9cf01a04 --- /dev/null +++ b/news/+shared-style-normalization.performance.md @@ -0,0 +1 @@ +Reduce compilation work for repeated literal app styles while preserving style mutations and independent component styles. diff --git a/packages/reflex-base/news/+shared-style-normalization.performance.md b/packages/reflex-base/news/+shared-style-normalization.performance.md new file mode 100644 index 00000000000..6665b14aa91 --- /dev/null +++ b/packages/reflex-base/news/+shared-style-normalization.performance.md @@ -0,0 +1 @@ +Allow the compiler to reuse normalization of unchanged literal app style rules. diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index adf8d36fd0e..5dd3a896d20 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -1344,20 +1344,26 @@ def _add_style(self) -> Style: style_.update(s) return style_ - def _get_component_style(self, styles: ComponentStyle | Style) -> Style | None: + def _get_component_style( + self, + styles: ComponentStyle | Style, + *, + _style_factory: Callable[[dict[str, Any]], Style] = Style, + ) -> Style | None: """Get the style to the component from `App.style`. Args: styles: The style to apply. + _style_factory: Internal compiler factory for normalizing each rule. Returns: The style of the component. """ component_style = None if (style := styles.get(type(self))) is not None: # pyright: ignore [reportArgumentType] - component_style = Style(style) + component_style = _style_factory(style) if (style := styles.get(self.create)) is not None: # pyright: ignore [reportArgumentType] - component_style = Style(style) + component_style = _style_factory(style) return component_style def _add_style_recursive( diff --git a/reflex/compiler/plugins/_style.py b/reflex/compiler/plugins/_style.py new file mode 100644 index 00000000000..9f331cd6b23 --- /dev/null +++ b/reflex/compiler/plugins/_style.py @@ -0,0 +1,87 @@ +"""Reuse normalization of unchanged literal app styles during a page walk.""" + +from typing import Any + +from reflex_base.constants.base import REFLEX_VAR_OPENING_TAG +from reflex_base.style import Style + + +def _literal_style_key(value: Any) -> tuple | None: + """Snapshot literal containers, declining dynamic or custom style values. + + Args: + value: A raw style rule or one of its nested values. + + Returns: + A mutation-sensitive key, or None when conversion must remain uncached. + """ + value_type = type(value) + if value_type is str: + return (str, value) if REFLEX_VAR_OPENING_TAG not in value else None + if value_type is float: + return (float, value.hex()) + if value_type is int or value_type is bool or value_type is type(None): + return (value_type, value) + if value_type is dict: + entries = [] + for key, item in value.items(): + if type(key) is not str or (item_key := _literal_style_key(item)) is None: + return None + entries.append((key, item_key)) + return (dict, tuple(entries)) + if value_type is list: + items = [] + for item in value: + if (item_key := _literal_style_key(item)) is None: + return None + items.append(item_key) + return (list, tuple(items)) + return None + + +def _copy_style_containers(value: Any) -> Any: + """Copy normalized mutable containers while retaining immutable Var leaves. + + Args: + value: A normalized style value. + + Returns: + A value whose nested dictionaries and lists belong to the caller. + """ + if type(value) is dict: + return {key: _copy_style_containers(item) for key, item in value.items()} + if type(value) is list: + return [_copy_style_containers(item) for item in value] + return value + + +class _AppStyleCache: + """Cache literal normalization while preserving mutations and node ownership.""" + + def __init__(self) -> None: + """Create a cache scoped to one bound page walk.""" + self._styles: dict[int, tuple[tuple, Style]] = {} + + def __call__(self, rule: dict[str, Any]) -> Style: + """Normalize a rule, reusing conversion only while its contents match. + + Args: + rule: The current class or factory style rule. + + Returns: + A normalized style with independently owned mutable containers. + """ + key = _literal_style_key(rule) + if key is None: + return Style(rule) + cached = self._styles.get(id(rule)) + if cached is None or cached[0] != key: + cached = self._styles[id(rule)] = (key, Style(rule)) + normalized = cached[1] + result = Style() + dict.update( + result, + {key: _copy_style_containers(value) for key, value in normalized.items()}, + ) + result._var_data = normalized._var_data + return result diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index 4081b4b9d79..04b643a9bfc 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -12,6 +12,7 @@ from reflex_base.constants.compiler import Hooks from reflex_base.plugins import CompileContext, PageContext, PageDefinition, Plugin from reflex_base.plugins.base import HookOrder +from reflex_base.style import Style from reflex_base.utils.format import make_default_page_title from reflex_base.utils.imports import collapse_imports, merge_imports from reflex_base.vars import VarData @@ -19,6 +20,7 @@ from reflex_components_core.base.fragment import Fragment from reflex.compiler import utils +from reflex.compiler.plugins._style import _AppStyleCache def collect_var_app_wraps_in_subtree( @@ -217,7 +219,10 @@ class ApplyStylePlugin(Plugin): @staticmethod def _apply_style( - comp: Component, style: ComponentStyle, page_context: PageContext + comp: Component, + style: ComponentStyle, + page_context: PageContext, + style_factory: Callable[[dict[str, Any]], Style] | None = None, ) -> Component | None: """Apply app-level styles to a single component. @@ -226,6 +231,7 @@ def _apply_style( style: The app-level component style map. page_context: The active page context, used to obtain a page-local clone before rewriting ``style``. + style_factory: Optional page-local normalizer for the base style lookup. Returns: A page-local clone with the merged style, or ``None`` when the @@ -236,7 +242,12 @@ def _apply_style( raise UserWarning(msg) new_style = comp._add_style() - component_style = comp._get_component_style(style) + component_style = ( + comp._get_component_style(style, _style_factory=style_factory) + if style_factory is not None + and type(comp)._get_component_style is Component._get_component_style + else comp._get_component_style(style) + ) if not new_style and not component_style: return None @@ -293,6 +304,8 @@ def enter_component( return enter_component apply_style = self._apply_style + style_factory = _AppStyleCache() + use_style_cache = type(self)._apply_style is ApplyStylePlugin._apply_style def enter_component( comp: BaseComponent, @@ -300,6 +313,8 @@ def enter_component( ) -> BaseComponent | None: if not isinstance(comp, Component) or in_prop_tree: return None + if use_style_cache: + return apply_style(comp, style, page_context, style_factory) return apply_style(comp, style, page_context) return enter_component diff --git a/tests/benchmarks/test_compilation.py b/tests/benchmarks/test_compilation.py index af084860859..02fa88a3991 100644 --- a/tests/benchmarks/test_compilation.py +++ b/tests/benchmarks/test_compilation.py @@ -1,9 +1,10 @@ import copy from pytest_codspeed import BenchmarkFixture -from reflex_base.components.component import Component +from reflex_base.components.component import Component, evaluate_style_namespaces from reflex_base.plugins import CompileContext, CompilerHooks, PageContext +import reflex as rx from reflex.app import UnevaluatedPage from reflex.compiler import compiler from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins @@ -73,11 +74,11 @@ def _compile_page(component: Component) -> str: return compiler.compile_page_from_context(page_ctx)[1] -def _compile_page_full_context(unevaluated_page) -> str: +def _compile_page_full_context(unevaluated_page, style=None) -> str: page = UnevaluatedPage(route="/benchmark", component=unevaluated_page) compile_ctx = CompileContext( pages=[page], - hooks=CompilerHooks(plugins=default_page_plugins()), + hooks=CompilerHooks(plugins=default_page_plugins(style=style)), ) with compile_ctx: @@ -131,3 +132,39 @@ def test_compile_all_artifacts( benchmark( lambda: _compile_page_context(evaluated_page).merged_imports(collapse=True) ) + + +def test_compile_shared_app_styles(benchmark: BenchmarkFixture): + """Compile a card grid with repeated nested and responsive app styles.""" + style = evaluate_style_namespaces({ + rx.card: { + "padding": ["12px", "20px", "24px"], + "border": "1px solid #ddd", + "border_radius": "12px", + "box_shadow": "0 2px 10px #0001", + "transition": "all 0.15s ease", + "_hover": {"background": "#f4f4f4", "box_shadow": "0 4px 12px #0002"}, + }, + rx.text: { + "font_family": "Inter, sans-serif", + "font_size": ["12px", "14px"], + "line_height": "1.5", + "_selection": {"background": "#cdf"}, + }, + }) + + def page(): + """Create a fresh component tree for every measured compile. + + Returns: + A grid whose cards share app-level style rules. + """ + return rx.grid( + *[ + rx.card(rx.heading(f"Service {index}"), rx.text("Requests per hour")) + for index in range(100) + ], + columns="4", + ) + + benchmark(lambda: _compile_page_full_context(page, style)) diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index 351d9cdfccb..000f1bb1587 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -5,6 +5,7 @@ from typing import Any import pytest +from reflex_base import style as style_module from reflex_base.components.component import ( BaseComponent, Component, @@ -22,6 +23,7 @@ Plugin, ) from reflex_base.plugins.base import HookOrder +from reflex_base.style import Style from reflex_base.utils import format as format_utils from reflex_base.utils.exceptions import ReflexError from reflex_base.utils.imports import ImportVar, collapse_imports, merge_imports @@ -1255,3 +1257,113 @@ def test_compile_context_applies_style_before_shared_stateful_render() -> None: assert '["color"] : "red"' in (compile_ctx.compiled_pages["/a"].output_code or "") assert '["color"] : "red"' in (compile_ctx.compiled_pages["/b"].output_code or "") + + +def test_apply_style_plugin_reuses_literal_style_normalization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Normalize an unchanged shared literal rule once within a page walk.""" + rule = {"color": "red", "_hover": {"color": "blue"}, "padding": ["1px", "2px"]} + component = Fragment.create(*(ChildComponent.create() for _ in range(4))) + original_convert = style_module.convert + normalized_rules = [] + + def track_convert(style_dict): + if style_dict is rule: + normalized_rules.append(style_dict) + return original_convert(style_dict) + + monkeypatch.setattr(style_module, "convert", track_convert) + hooks = CompilerHooks(plugins=(ApplyStylePlugin(style={ChildComponent: rule}),)) + page_ctx = PageContext(name="page", route="/page", root_component=component) + compile_ctx = create_compile_context(hooks) + with compile_ctx, page_ctx: + compiled = hooks.compile_component( + component, page_context=page_ctx, compile_context=compile_ctx + ) + + assert len(normalized_rules) == 1 + first, second = compiled.children[:2] + assert isinstance(first, Component) + assert isinstance(second, Component) + first.style["_hover"]["color"] = "pink" + first.style["padding"][0] = "5px" + assert str(second.style["_hover"]["color"]) == '"blue"' + assert str(second.style["padding"][0]) == '"1px"' + assert rule["_hover"] == {"color": "blue"} + assert rule["padding"] == ["1px", "2px"] + + +def test_apply_style_plugin_observes_rule_and_factory_changes() -> None: + """A bound walk sees replaced rules and factory rules still take precedence.""" + + class StyledComponent(Component): + tag = "Styled" + + class_rule = {"color": "red"} + factory_rule = {"color": "blue"} + styles = {StyledComponent: class_rule, StyledComponent.create: factory_rule} + plugin = ApplyStylePlugin(style=styles) + page_ctx = PageContext(name="page", route="/page", root_component=Fragment.create()) + compile_ctx = create_compile_context(CompilerHooks(plugins=(plugin,))) + with compile_ctx, page_ctx: + enter = plugin._compiler_bind_enter_component(page_ctx, compile_ctx) + first = enter(StyledComponent.create(), False) + factory_rule["color"] = "green" + second = enter(StyledComponent.create(), False) + styles[StyledComponent.create] = {"color": "purple"} + third = enter(StyledComponent.create(), False) + del styles[StyledComponent.create] + fourth = enter(StyledComponent.create(), False) + + assert first is not None + assert second is not None + assert third is not None + assert fourth is not None + assert [ + normalize_style(comp)["color"] for comp in (first, second, third, fourth) + ] == ['"blue"', '"green"', '"purple"', '"red"'] + + +def test_apply_style_plugin_preserves_style_lookup_overrides() -> None: + """Component overrides remain free to choose styles per instance.""" + + class StyledComponent(Component): + tag = "Styled" + + def _get_component_style(self, styles): + return Style({"color": self.id}) + + plugin = ApplyStylePlugin(style={}) + page_ctx = PageContext(name="page", route="/page", root_component=Fragment.create()) + compile_ctx = create_compile_context(CompilerHooks(plugins=(plugin,))) + with compile_ctx, page_ctx: + enter = plugin._compiler_bind_enter_component(page_ctx, compile_ctx) + first = enter(StyledComponent.create(id="red"), False) + second = enter(StyledComponent.create(id="blue"), False) + + assert first is not None + assert second is not None + assert normalize_style(first)["color"] == '"red"' + assert normalize_style(second)["color"] == '"blue"' + + +def test_apply_style_plugin_preserves_apply_overrides() -> None: + """A plugin override keeps the original three-argument callback contract.""" + + class CustomApplyStylePlugin(ApplyStylePlugin): + @staticmethod + def _apply_style(comp, style, page_context): + owned = page_context.own(comp) + owned.style = Style({"color": "purple"}) + return owned + + plugin = CustomApplyStylePlugin(style={}) + page_ctx = PageContext(name="page", route="/page", root_component=Fragment.create()) + compile_ctx = create_compile_context(CompilerHooks(plugins=(plugin,))) + with compile_ctx, page_ctx: + enter = plugin._compiler_bind_enter_component(page_ctx, compile_ctx) + component = enter(ChildComponent.create(), False) + + assert component is not None + assert normalize_style(component)["color"] == '"purple"' diff --git a/tests/units/compiler/test_style_cache.py b/tests/units/compiler/test_style_cache.py new file mode 100644 index 00000000000..cb714849645 --- /dev/null +++ b/tests/units/compiler/test_style_cache.py @@ -0,0 +1,152 @@ +"""Tests for page-local shared style normalization.""" + +from typing import Any + +import pytest +from reflex_base.breakpoints import Breakpoints, breakpoints_values +from reflex_base.style import Style +from reflex_base.utils.imports import ImportVar +from reflex_base.vars import VarData +from reflex_base.vars.base import Var + +from reflex.compiler.plugins._style import _AppStyleCache + + +def test_shared_style_observes_nested_mutations() -> None: + """Changing a raw nested mapping or responsive list invalidates conversion.""" + rule = {"_hover": {"color": "red"}, "padding": ["1px", "2px"]} + cache = _AppStyleCache() + first = cache(rule) + rule["_hover"]["color"] = "blue" + rule["padding"].append("3px") + second = cache(rule) + + assert str(first["_hover"]["color"]) == '"red"' + assert str(second["_hover"]["color"]) == '"blue"' + assert len(first["padding"]) == 2 + assert len(second["padding"]) == 3 + + +def test_shared_style_preserves_literal_types() -> None: + """Equal Python values with different literal types must not share results.""" + rule: dict[str, Any] = {"opacity": True} + cache = _AppStyleCache() + first = cache(rule) + rule["opacity"] = 1 + second = cache(rule) + + assert str(first["opacity"]) == "true" + assert str(second["opacity"]) == "1" + + +@pytest.mark.parametrize("encoded", [False, True]) +def test_shared_style_preserves_var_metadata(encoded: bool) -> None: + """Dynamic style rules retain hook and import metadata after changes.""" + old_data = VarData(imports={"old-library": [ImportVar("oldColor")]}) + new_data = VarData(imports={"new-library": [ImportVar("newColor")]}) + old_color = Var("oldColor", _var_type=str, _var_data=old_data) + new_color = Var("newColor", _var_type=str, _var_data=new_data) + rule = {"_hover": {"color": f"{old_color}" if encoded else old_color}} + cache = _AppStyleCache() + first = cache(rule) + rule["_hover"]["color"] = f"{new_color}" if encoded else new_color + second = cache(rule) + + assert first._var_data == old_data + assert second._var_data == new_data + assert str(second["_hover"]["color"]) == "newColor" + + +def test_shared_style_observes_breakpoint_configuration(monkeypatch) -> None: + """Breakpoint names are factorized against the current configuration.""" + rule = {"padding": Breakpoints({"xs": "10px"})} + cache = _AppStyleCache() + first = cache(rule) + monkeypatch.setattr( + "reflex_base.breakpoints.breakpoints_values", ["123em", *breakpoints_values[1:]] + ) + second = cache(rule) + + assert list(first["padding"]) == [breakpoints_values[0]] + assert list(second["padding"]) == ["123em"] + + +def test_shared_style_preserves_custom_mapping_behavior() -> None: + """Custom mappings keep their dynamic conversion behavior.""" + + class DynamicStyle(dict): + color = "red" + + def items(self): + return [("color", self.color)] + + rule = DynamicStyle(color="ignored") + cache = _AppStyleCache() + first = cache(rule) + rule.color = "blue" + second = cache(rule) + + assert str(first["color"]) == '"red"' + assert str(second["color"]) == '"blue"' + + +@pytest.mark.parametrize( + "rule", + [ + {"padding_x": "3px", "font_family": "Inter"}, + {"_hover": {"color": "red"}, "padding": ["1px", "2px"]}, + {"_hover": [{"color": "red"}, {"color": "blue"}]}, + {"--color": "red", "display": None}, + ], +) +def test_shared_style_matches_uncached_conversion(rule: dict[str, Any]) -> None: + """Static rules render identically before and after reuse.""" + cache = _AppStyleCache() + for _ in range(2): + normalized = cache(rule) + expected = Style(rule) + assert str(Var.create(normalized)) == str(Var.create(expected)) + assert normalized._var_data == expected._var_data + + +def test_shared_style_preserves_signed_zero() -> None: + """Equal float values with different signs retain their JavaScript spelling.""" + rule = {"opacity": -0.0} + cache = _AppStyleCache() + first = cache(rule) + rule["opacity"] = 0.0 + second = cache(rule) + + assert str(first["opacity"]) != str(second["opacity"]) + assert str(second["opacity"]) == str(Style(rule)["opacity"]) + + +def test_shared_style_does_not_cache_custom_metaclass_equality() -> None: + """Custom type equality cannot turn a mutable value into a literal key.""" + + class EqualToInt(type): + def __eq__(cls, other): + return other is int + + __hash__ = type.__hash__ + + class DynamicColor(metaclass=EqualToInt): + color = "red" + + def _as_var(self): + """Convert the current mutable color to a Var. + + Returns: + The color at the time of conversion. + """ + return Var.create(self.color) + + color = DynamicColor() + rule = {"color": color} + cache = _AppStyleCache() + first = cache(rule) + color.color = "blue" + second = cache(rule) + + assert str(first["color"]) == '"red"' + assert str(second["color"]) == '"blue"'