diff --git a/news/6945.feature.md b/news/6945.feature.md new file mode 100644 index 00000000000..b7569208a1d --- /dev/null +++ b/news/6945.feature.md @@ -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()`. diff --git a/packages/reflex-base/news/6945.feature.md b/packages/reflex-base/news/6945.feature.md new file mode 100644 index 00000000000..b7569208a1d --- /dev/null +++ b/packages/reflex-base/news/6945.feature.md @@ -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()`. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/react-theme.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/react-theme.js index d94717ae39e..e8d629b6117 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/react-theme.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/react-theme.js @@ -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); diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index d8239387903..a20a3352c93 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -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}" @@ -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)}; @@ -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() {{ @@ -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 }}) {{ @@ -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. @@ -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( @@ -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. @@ -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" + ) def memo_components_template( diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index c94e0198c68..1723302477f 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -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}")' ) diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 8c0d1e9d98a..71e874a1437 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -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: @@ -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 diff --git a/packages/reflex-components-plotly/news/6945.feature.md b/packages/reflex-components-plotly/news/6945.feature.md new file mode 100644 index 00000000000..6b2b5999ab2 --- /dev/null +++ b/packages/reflex-components-plotly/news/6945.feature.md @@ -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. diff --git a/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py b/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py index 6867e7df78c..e725e50f544 100644 --- a/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py +++ b/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py @@ -376,7 +376,7 @@ def dynamic_plotly_import(name: str, package: str) -> str: return f""" const {name} = ClientSide(() => {library_import}{mod_import} -) +, "{name}") """ diff --git a/pyi_hashes.json b/pyi_hashes.json index 3d85cc7719e..172d76bf7d4 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -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" } diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 13f10555971..2de27f4e7b4 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -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. @@ -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, ) @@ -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 @@ -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 diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 216b5d4bed6..022531b050b 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -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, diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index d6994a100f8..f92827391a9 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -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")') diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 4f47acc1120..743536857a2 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -601,6 +601,28 @@ def test_generated_memo_component_renders_as_its_exported_tag() -> None: assert wrapper.render()["name"] == tag +def test_auto_memo_display_name_is_the_wrapped_python_class() -> None: + """Auto-memo wrappers are labelled with the class they wrap, not their tag. + + ``export_name`` carries a content hash so identically-rendering subtrees + collapse to one module; that name is unreadable in the React DevTools tree, + so the memo's ``displayName`` names the Python component instead. + """ + from reflex.compiler.compiler import compile_memo_components + + ctx, _ = _compile_single_page( + lambda: Fragment.create(WithProp.create(label=STATE_VAR)) + ) + + definitions = list(ctx.auto_memo_components.values()) + assert [definition.display_name for definition in definitions] == ["WithProp"] + + memo_code = "\n".join( + code for _, code in compile_memo_components(memos=tuple(definitions))[0] + ) + assert f'{definitions[0].export_name}.displayName = "WithProp";' in memo_code + + def test_passthrough_memo_definitions_are_not_shared_globally(monkeypatch) -> None: """Repeated tags across compiles rebuild their passthrough definitions. diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index f1ecea1fc10..fc2eb8bbec1 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -1156,6 +1156,61 @@ def inline_wrapped(label: rx.Var[str]) -> rx.Component: ) +def test_component_memo_sets_display_name_from_python_name(): + """A ``@rx.memo`` component is labelled with its Python function name. + + ``memo()`` erases the name JS would otherwise infer from the assignment, + so React DevTools shows ``Anonymous`` without an explicit ``displayName``. + """ + + @rx.memo + def named_widget(label: rx.Var[str]) -> rx.Component: + return rx.text(label) + + definition = MEMOS["NamedWidget", __name__] + assert isinstance(definition, MemoComponentDefinition) + + files, _ = compiler.compile_memo_components((definition,)) + code = "\n".join(c for _, c in files) + sym = memo_paths.mirrored_symbol("NamedWidget", __name__) + assert f'{sym}.displayName = "NamedWidget";' in code + + +def test_component_memo_display_name_survives_custom_wrapper(): + """The ``displayName`` is assigned on the exported symbol, whatever wraps it.""" + track_render = FunctionStringVar.create( + "trackRender", + _var_data=VarData(imports={"my-render-lib": [ImportVar(tag="trackRender")]}), + ) + + @rx.memo(wrapper=track_render) + def wrapped_widget(label: rx.Var[str]) -> rx.Component: + return rx.text(label) + + files, _ = compiler.compile_memo_components((MEMOS["WrappedWidget", __name__],)) + code = "\n".join(c for _, c in files) + sym = memo_paths.mirrored_symbol("WrappedWidget", __name__) + assert f"export const {sym} = trackRender((" in code + assert f'{sym}.displayName = "WrappedWidget";' in code + + +def test_component_memo_display_name_is_escaped(): + """A display name is emitted as a JS string literal, never raw.""" + definition = MemoComponentDefinition( + fn=lambda: None, + python_name="quoted", + params=(), + export_name="Quoted", + _component=_LazyBody.ready(rx.text("hi")), + passthrough_hole_child=None, + display_name='Weird"Name', + ) + + files, _ = compiler.compile_memo_components((definition,)) + code = "\n".join(c for _, c in files) + assert 'Quoted.displayName = "Weird\\"Name";' in code + + def test_component_memo_wrapper_none_in_unmirrored_module(): """The per-name fallback module honors ``wrapper=None`` too.""" definition = MemoComponentDefinition(