From 85876a9d5ea918086834bcf89d8e35523f38c981 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 11:10:06 +0000 Subject: [PATCH 1/5] feat: name generated memo components and contexts for React DevTools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React DevTools labels a component from its `displayName` (or the name of the function it wraps). The compiled frontend supplied neither: memo bodies are anonymous arrow functions passed to `memo()`, which erases the name JS would otherwise infer from the assignment, and none of the generated contexts were named. The result was a tree of `Anonymous` entries under an unlabelled stack of `Context.Provider`s. Each generated memo module now assigns a `displayName` to its export: `@rx.memo` components use the decorated function's name, and auto-memoized wrappers use the Python class they wrap rather than their content-hashed tag (`Foreach`, not `Foreach_comp_70fd2c58...`). The name is carried on `MemoComponentDefinition.display_name` and JSON-escaped at render time. Every context in the generated `context.js` is named too — `ColorModeContext`, `UploadFilesContext`, `DispatchContext`, `EventLoopContext`, `ThemeContext`, and one per state (`StateContext()`) — and the `ClientSide` HOC returns a named component tagged with the client-only component it loads. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W4Psp6qZsEpbuRePuRVkYX --- news/+devtools-display-names.feature.md | 1 + .../.templates/web/utils/react-theme.js | 1 + .../src/reflex_base/compiler/templates.py | 44 +++++++++++--- .../src/reflex_base/components/component.py | 5 +- .../src/reflex_base/components/memo.py | 12 +++- .../src/reflex_components_plotly/plotly.py | 2 +- pyi_hashes.json | 2 +- reflex/compiler/utils.py | 1 + tests/units/compiler/test_compiler.py | 59 +++++++++++++++++++ tests/units/compiler/test_memoize_plugin.py | 22 +++++++ tests/units/components/test_memo.py | 55 +++++++++++++++++ 11 files changed, 190 insertions(+), 14 deletions(-) create mode 100644 news/+devtools-display-names.feature.md diff --git a/news/+devtools-display-names.feature.md b/news/+devtools-display-names.feature.md new file mode 100644 index 00000000000..d2a37ac902e --- /dev/null +++ b/news/+devtools-display-names.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`; 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..bc430b01f00 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 }}) {{ @@ -790,10 +809,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 +833,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/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/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..6d391e75077 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1425,3 +1425,62 @@ 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 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( From 7ba7bd473dd8ee0e92d998ba186791380fc60660 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:11:37 +0000 Subject: [PATCH 2/5] feat: label compiled pages with their route in React DevTools Every page compiles to a component named `Component`, so the DevTools tree showed the same label whichever route was mounted. `page_template` now takes the route and emits `Component.displayName = "Component()"`, threaded through both the page-context and legacy compile paths. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W4Psp6qZsEpbuRePuRVkYX --- news/+devtools-display-names.feature.md | 2 +- .../src/reflex_base/compiler/templates.py | 10 +++++++- reflex/compiler/compiler.py | 7 ++++-- tests/units/compiler/test_compiler.py | 23 +++++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/news/+devtools-display-names.feature.md b/news/+devtools-display-names.feature.md index d2a37ac902e..b7569208a1d 100644 --- a/news/+devtools-display-names.feature.md +++ b/news/+devtools-display-names.feature.md @@ -1 +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`; and client-only (`NoSSRComponent`) wrappers render as `ClientSide()`. +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/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index bc430b01f00..ed3c3756672 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -531,15 +531,21 @@ 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. + 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. @@ -561,7 +567,9 @@ def page_template( return ( {_RenderUtils.render(render)} ) -}}""" +}} +Component.displayName = {json.dumps(f"Component({route})")}; +""" def package_json_template( 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/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 6d391e75077..e78ede47427 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1479,6 +1479,29 @@ def test_context_template_client_side_component_is_named(): assert "return ClientSideComponent;" in rendered +def test_page_template_display_name_carries_the_route(): + """Every page compiles to ``Component``; its route is in the display name.""" + from reflex_base.compiler.templates import page_template + + rendered = page_template( + imports=[], + dynamic_imports=[], + custom_codes=[], + hooks={}, + render=rx.el.div("hi").render(), + route="test/[dynamic]", + ) + + assert 'Component.displayName = "Component(test/[dynamic])";' in rendered + + +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 From 04c3057b3b9321d36a5baa2477ed674f8139214b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:28:56 +0000 Subject: [PATCH 3/5] chore: number the changelog fragment for #6945 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W4Psp6qZsEpbuRePuRVkYX --- news/{+devtools-display-names.feature.md => 6945.feature.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename news/{+devtools-display-names.feature.md => 6945.feature.md} (100%) diff --git a/news/+devtools-display-names.feature.md b/news/6945.feature.md similarity index 100% rename from news/+devtools-display-names.feature.md rename to news/6945.feature.md From 92721c666be8e4f6a8c9822ce56a5d06c87b56b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 20:25:58 +0000 Subject: [PATCH 4/5] fix: keep the page component binding alive for its displayName React Router's `decorateComponentExportsWithProps` rewrites an exported function *declaration* into a function *expression* wrapped in `UNSAFE_withComponentProps`, so `export default function Component() {}` leaves no module-scope `Component` binding. The trailing `Component.displayName = ...` then threw `ReferenceError: Component is not defined` as soon as a route module loaded, breaking every page. Declare the function, name it, and export the identifier instead. React Router then wraps the binding rather than the declaration, the assignment resolves, and the wrapper renders `Component` as a child so the route name still shows in the DevTools tree. Verified against a real `react-router build` (8.3.0): the old shape's route chunk throws `ReferenceError: Component is not defined` on evaluation, the new one evaluates and keeps `Component.displayName`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W4Psp6qZsEpbuRePuRVkYX --- .../src/reflex_base/compiler/templates.py | 13 +++++- tests/units/compiler/test_compiler.py | 42 ++++++++++++++++--- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index ed3c3756672..a20a3352c93 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -539,6 +539,15 @@ def page_template( 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. @@ -561,7 +570,7 @@ def page_template( {custom_code_str} -export default function Component() {{ +function Component() {{ {hooks_str} return ( @@ -569,6 +578,8 @@ def page_template( ) }} Component.displayName = {json.dumps(f"Component({route})")}; + +export default Component; """ diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index e78ede47427..f92827391a9 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1479,20 +1479,52 @@ def test_context_template_client_side_component_is_named(): assert "return ClientSideComponent;" in rendered -def test_page_template_display_name_carries_the_route(): - """Every page compiles to ``Component``; its route is in the display name.""" +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 - rendered = page_template( + return page_template( imports=[], dynamic_imports=[], custom_codes=[], hooks={}, render=rx.el.div("hi").render(), - route="test/[dynamic]", + route=route, ) - assert 'Component.displayName = "Component(test/[dynamic])";' in rendered + +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(): From 0cb0d8253fcba1cd81780f62ef50e5209de382e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 20:26:51 +0000 Subject: [PATCH 5/5] chore: add per-package changelog fragments for #6945 The changelog check runs towncrier per affected package, so reflex-base and reflex-components-plotly each need their own fragment alongside the root one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W4Psp6qZsEpbuRePuRVkYX --- packages/reflex-base/news/6945.feature.md | 1 + packages/reflex-components-plotly/news/6945.feature.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 packages/reflex-base/news/6945.feature.md create mode 100644 packages/reflex-components-plotly/news/6945.feature.md 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-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.