Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/+shared-style-normalization.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reduce compilation work for repeated literal app styles while preserving style mutations and independent component styles.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow the compiler to reuse normalization of unchanged literal app style rules.
12 changes: 9 additions & 3 deletions packages/reflex-base/src/reflex_base/components/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
87 changes: 87 additions & 0 deletions reflex/compiler/plugins/_style.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 17 additions & 2 deletions reflex/compiler/plugins/builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
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
from reflex_base.vars.base import insert_app_wraps
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(
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -293,13 +304,17 @@ 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,
in_prop_tree: bool,
) -> 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
Expand Down
43 changes: 40 additions & 3 deletions tests/benchmarks/test_compilation.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
112 changes: 112 additions & 0 deletions tests/units/compiler/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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"'
Loading
Loading