Skip to content
Open
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/6945.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The compiled frontend now names what React DevTools shows. Every memoized component carries a `displayName` taken from the Python component class or `@rx.memo` function it was generated from, instead of rendering as `Anonymous`; every generated context (`ColorModeContext`, `UploadFilesContext`, `DispatchContext`, `EventLoopContext`, `ThemeContext`, and one per state) is named, so the provider stack reads as `StateContext(reflex___state____state.my_state).Provider` rather than an unlabelled `Context.Provider`; each page is labelled with its route (`Component(blog/[slug])`) instead of a bare `Component`; and client-only (`NoSSRComponent`) wrappers render as `ClientSide(<Tag>)`.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6945.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The compiled frontend now names what React DevTools shows. Every memoized component carries a `displayName` taken from the Python component class or `@rx.memo` function it was generated from, instead of rendering as `Anonymous`; every generated context (`ColorModeContext`, `UploadFilesContext`, `DispatchContext`, `EventLoopContext`, `ThemeContext`, and one per state) is named, so the provider stack reads as `StateContext(reflex___state____state.my_state).Provider` rather than an unlabelled `Context.Provider`; each page is labelled with its route (`Component(blog/[slug])`) instead of a bare `Component`; and client-only (`NoSSRComponent`) wrappers render as `ClientSide(<Tag>)`.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const ThemeContext = createContext({
resolvedTheme: defaultColorMode !== "system" ? defaultColorMode : "light",
setTheme: () => {},
});
ThemeContext.displayName = "ThemeContext";

export function ThemeProvider({ children, defaultTheme = "system" }) {
const [theme, setTheme] = useState(defaultTheme);
Expand Down
67 changes: 58 additions & 9 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,15 @@ def context_template(
for state_name in initial_state
])

# React DevTools labels a context provider from the context's
# ``displayName``; without it every state provider in the tree renders as
# ``Context.Provider``. Name each one after the Python state it carries.
state_context_display_names_str = "\n".join(
f"StateContexts.{format_state_name(state_name)}.displayName = "
f'"StateContext({state_name})";'
for state_name in initial_state
)

state_str = (
rf"""
export const state_name = "{state_name}"
Expand Down Expand Up @@ -409,6 +418,12 @@ def context_template(
export const EventLoopContext = createContext(null);
export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)}

ColorModeContext.displayName = "ColorModeContext";
UploadFilesContext.displayName = "UploadFilesContext";
DispatchContext.displayName = "DispatchContext";
EventLoopContext.displayName = "EventLoopContext";
{state_context_display_names_str}

{state_str}

export const isDevMode = {json.dumps(is_dev_mode)};
Expand Down Expand Up @@ -445,8 +460,10 @@ def context_template(
);
}}

export function ClientSide(component) {{
return ({{ children, ...props }}) => {{
// ``displayName`` is what React DevTools shows for the wrapper; without it
// every client-only component in the tree renders as ``Anonymous``.
export function ClientSide(component, name) {{
function ClientSideComponent({{ children, ...props }}) {{
const [Component, setComponent] = useState(null);
useEffect(() => {{
async function load() {{
Expand All @@ -456,7 +473,9 @@ def context_template(
load();
}}, []);
return Component ? jsx(Component, props, children) : null;
}};
}}
ClientSideComponent.displayName = name ? `ClientSide(${{name}})` : "ClientSide";
return ClientSideComponent;
}}

export function EventLoopProvider({{ children }}) {{
Expand Down Expand Up @@ -512,15 +531,30 @@ def page_template(
custom_codes: Iterable[str],
hooks: dict[str, VarData | None],
render: dict[str, Any],
route: str,
):
"""Template for a single react page.

Every page compiles to a component named ``Component``, so the route is
carried in its ``displayName`` — otherwise React DevTools shows the same
``Component`` label for whichever page is mounted.

The function is declared, named, and only then exported. React Router's
``decorateComponentExportsWithProps`` rewrites an exported function
*declaration* into a function *expression* wrapped in
``UNSAFE_withComponentProps``, leaving no module-scope binding behind: a
trailing ``Component.displayName = ...`` would then throw
``ReferenceError: Component is not defined`` when the route module loads.
Exporting the identifier instead keeps the declaration in module scope, and
the wrapper renders ``Component`` as a child, so the name still shows.

Args:
imports: List of import statements.
dynamic_imports: List of dynamic import statements.
custom_codes: List of custom code snippets.
hooks: Dictionary of hooks.
render: Render function for the component.
route: The route this page is compiled for, used as its display name.

Returns:
Rendered React page component as string.
Expand All @@ -536,13 +570,17 @@ def page_template(

{custom_code_str}

export default function Component() {{
function Component() {{
{hooks_str}

return (
{_RenderUtils.render(render)}
)
}}"""
}}
Component.displayName = {json.dumps(f"Component({route})")};

export default Component;
"""


def package_json_template(
Expand Down Expand Up @@ -790,10 +828,16 @@ def dynamic_components_module_template(
def _render_memo_component(component: dict[str, Any]) -> str:
"""Render the ``export const`` statement for one memoized component.

The exported symbol carries a ``displayName`` so React DevTools labels the
memo with the name of the Python component it came from. Without it, the
wrapped arrow function is anonymous and every memo in the tree shows up as
``Anonymous``; ``memo()`` also drops the inferred name of the function it
wraps, so the assignment is needed even for readable symbols.

Args:
component: The component render dict (name, signature, render, hooks,
and the optional ``wrapper`` JS expression the function component
is wrapped in).
component: The component render dict (name, display_name, signature,
render, hooks, and the optional ``wrapper`` JS expression the
function component is wrapped in).

Returns:
Rendered component export as string.
Expand All @@ -808,7 +852,12 @@ def _render_memo_component(component: dict[str, Any]) -> str:
if wrapper and not _MEMO_WRAPPER_CALLEE_RE.fullmatch(wrapper):
wrapper = f"({wrapper})"
export_expr = f"{wrapper}{function_expr}" if wrapper else function_expr
return f"\nexport const {component['name']} = {export_expr};\n"
name = component["name"]
display_name = json.dumps(component.get("display_name") or name)
return (
f"\nexport const {name} = {export_expr};\n"
f"{name}.displayName = {display_name};\n"
Comment thread
masenf marked this conversation as resolved.
Comment thread
masenf marked this conversation as resolved.
)


def memo_components_template(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2339,11 +2339,12 @@ def _get_dynamic_imports(self) -> str:
if not self.is_default
else ".then((mod) => mod.default.default ?? mod.default)"
)
name = self.alias or self.tag
return (
f"const {self.alias or self.tag} = ClientSide(() => "
f"const {name} = ClientSide(() => "
+ library_import
+ mod_import
+ ")"
+ f', "{name}")'
)


Expand Down
12 changes: 9 additions & 3 deletions packages/reflex-base/src/reflex_base/components/memo.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,11 @@ class MemoComponentDefinition(MemoDefinition):
# wrapper's ``VarData`` supplies its imports, so a custom wrapper brings
# its own and ``None`` pulls in nothing.
wrapper: Var | None = DEFAULT_MEMO_WRAPPER
# The name React DevTools shows for this memo. ``export_name`` (derived
# from the decorated function) is already readable for ``@rx.memo``, but
# auto-memoized wrappers carry a hash-suffixed tag, so the plugin sets this
# to the wrapped component's Python class name instead.
display_name: str | None = None

@property
def component(self) -> Component:
Expand Down Expand Up @@ -1849,13 +1854,14 @@ def passthrough(children: Var[Component]) -> Component:
passthrough.__module__ = __name__

definition = _create_component_definition(passthrough, Component, source_module)
replacements: dict[str, Any] = {}
# ``export_name`` is the content-hashed tag, which reads as noise in the
# React DevTools tree. Name the memo after the Python class it wraps.
replacements: dict[str, Any] = {"display_name": type(component).__qualname__}
if definition.export_name != tag:
replacements["export_name"] = tag
if captured_hole_child:
replacements["passthrough_hole_child"] = captured_hole_child[0]
if replacements:
definition = dataclasses.replace(definition, **replacements)
definition = dataclasses.replace(definition, **replacements)

return _create_component_wrapper(definition), definition

Expand Down
1 change: 1 addition & 0 deletions packages/reflex-components-plotly/news/6945.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The generated client-only wrapper for each plotly component now carries the component's name, so React DevTools shows `ClientSide(Plot)` instead of an anonymous wrapper.
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ def dynamic_plotly_import(name: str, package: str) -> str:
return f"""
const {name} = ClientSide(() =>
{library_import}{mod_import}
)
, "{name}")
"""


Expand Down
2 changes: 1 addition & 1 deletion pyi_hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,5 @@
"packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3",
"reflex/__init__.pyi": "a3e1782fab4a9aed55f66cc98af8c217",
"reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388",
"reflex/experimental/memo.pyi": "bc8b48357bef580e70a5881b65d3d3f7"
"reflex/experimental/memo.pyi": "35583b85befadf5cb125b14f7cd459cb"
}
7 changes: 5 additions & 2 deletions reflex/compiler/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,12 @@ def _compile_contexts(state: type[BaseState] | None, theme: Component | None) ->
)


def _compile_page(component: BaseComponent) -> str:
def _compile_page(component: BaseComponent, route: str) -> str:
"""Compile the component.

Args:
component: The component to compile.
route: The route the page is compiled for.

Returns:
The compiled component.
Expand All @@ -256,6 +257,7 @@ def _compile_page(component: BaseComponent) -> str:
custom_codes=component._get_all_custom_code(),
hooks=component._get_all_hooks(),
render=component.render(),
route=route,
)


Expand Down Expand Up @@ -737,7 +739,7 @@ def compile_page(path: str, component: BaseComponent) -> tuple[str, str]:
output_path = utils.get_page_path(path)

# Add the style to the component.
code = _compile_page(component)
code = _compile_page(component, path)
return output_path, code


Expand Down Expand Up @@ -765,6 +767,7 @@ def compile_page_from_context(page_ctx: PageContext) -> tuple[str, str]:
custom_codes=page_ctx.custom_code_dict(),
hooks=page_ctx.hooks,
render=page_ctx.root_component.render(),
route=page_ctx.route,
)
return output_path, code

Expand Down
1 change: 1 addition & 0 deletions reflex/compiler/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ def compile_experimental_component_memo(
"name": memo_paths.library_and_symbol(
definition.source_module, definition.export_name
)[1],
"display_name": definition.display_name or definition.export_name,
"signature": DestructuredArg(
fields=tuple(signature_fields),
rest=rest_param.placeholder_name if rest_param is not None else None,
Expand Down
114 changes: 114 additions & 0 deletions tests/units/compiler/test_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1425,3 +1425,117 @@ def test_context_template_owner_stack_pin(disable_owner_stacks: bool):
assert "REFLEX_REACT_OWNER_STACKS" in rendered
# The trade-off must be stated where a reader of the output will see it.
assert "captureOwnerStack" in rendered


def test_context_template_names_contexts_for_devtools():
"""Every context in the generated module carries a ``displayName``.

React DevTools labels a provider from its context's ``displayName``;
without one the whole provider stack renders as ``Context.Provider``.
"""
from reflex_base.compiler.templates import context_template

rendered = context_template(
is_dev_mode=True,
default_color_mode='"light"',
initial_state={
"reflex___state____state": {},
"reflex___state____state.demo_state": {},
},
state_name="reflex___state____state",
)

for context_name in (
"ColorModeContext",
"UploadFilesContext",
"DispatchContext",
"EventLoopContext",
):
assert f'{context_name}.displayName = "{context_name}";' in rendered

# State contexts are named for the Python state they carry, using the
# dotted state name rather than the mangled JS identifier.
assert (
"StateContexts.reflex___state____state.displayName = "
'"StateContext(reflex___state____state)";' in rendered
)
assert (
"StateContexts.reflex___state____state__demo_state.displayName = "
'"StateContext(reflex___state____state.demo_state)";' in rendered
)


def test_context_template_client_side_component_is_named():
"""``ClientSide`` returns a named component, not an anonymous arrow."""
from reflex_base.compiler.templates import context_template

rendered = context_template(is_dev_mode=True, default_color_mode='"light"')

assert "function ClientSideComponent({ children, ...props })" in rendered
assert (
"ClientSideComponent.displayName = name ? `ClientSide(${name})` : "
'"ClientSide";' in rendered
)
assert "return ClientSideComponent;" in rendered


def _render_page_template(route: str = "test/[dynamic]") -> str:
"""Render the page template for ``route``.

Args:
route: The route to compile the page for.

Returns:
The rendered page module source.
"""
from reflex_base.compiler.templates import page_template

return page_template(
imports=[],
dynamic_imports=[],
custom_codes=[],
hooks={},
render=rx.el.div("hi").render(),
route=route,
)


def test_page_template_display_name_carries_the_route():
"""Every page compiles to ``Component``; its route is in the display name."""
assert (
'Component.displayName = "Component(test/[dynamic])";'
in _render_page_template()
)


def test_page_template_exports_the_component_binding_separately():
"""The page component is declared and named before it is exported.

React Router rewrites an exported function *declaration* into a function
*expression* wrapped in ``UNSAFE_withComponentProps``
(``decorateComponentExportsWithProps``), which leaves no module-scope
binding behind. A trailing ``Component.displayName = ...`` would then throw
``ReferenceError: Component is not defined`` when the route module loads,
breaking every page. Exporting the identifier keeps the declaration intact.
"""
rendered = _render_page_template()

assert "export default function Component" not in rendered
assert "\nfunction Component() {" in rendered
assert rendered.index("Component.displayName") < rendered.index(
"export default Component;"
)


def test_compile_page_passes_its_route_to_the_template():
"""The route reaches the template through the legacy page compile path."""
_, code = compiler.compile_page("about", rx.el.div("hi"))

assert 'Component.displayName = "Component(about)";' in code


def test_no_ssr_dynamic_import_names_the_client_side_wrapper():
"""A client-only component passes its tag through to the wrapper's name."""
from reflex_components_plotly.plotly import Plotly

assert Plotly.create()._get_dynamic_imports().endswith(', "Plot")')
Loading
Loading