From ee570c10eb4d20d1265da437cab0054f39e083bc Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 19 May 2026 23:52:25 +0500 Subject: [PATCH 1/2] fix: make Markdown a MemoizationLeaf so Var children stay inlined react-markdown asserts its children prop is a string. Without the snapshot boundary, the auto-memoize plugin hoists a Var child into its own Bare_comp_ element, which renders as [object Object] and crashes the subtree. --- .../reflex_components_markdown/markdown.py | 12 +++- pyi_hashes.json | 2 +- .../test_memoize_edge_cases.py | 52 ++++++++++++++++ .../components/markdown/test_markdown.py | 60 +++++++++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/packages/reflex-components-markdown/src/reflex_components_markdown/markdown.py b/packages/reflex-components-markdown/src/reflex_components_markdown/markdown.py index f6033c83a3a..aea8542fd17 100644 --- a/packages/reflex-components-markdown/src/reflex_components_markdown/markdown.py +++ b/packages/reflex-components-markdown/src/reflex_components_markdown/markdown.py @@ -14,6 +14,7 @@ Component, ComponentNamespace, CustomComponent, + MemoizationLeaf, field, ) from reflex_base.components.tags.tag import Tag @@ -188,8 +189,15 @@ def get_base_component_map() -> dict[str, Callable]: } -class Markdown(Component): - """A markdown component.""" +class Markdown(MemoizationLeaf): + """A markdown component. + + ``react-markdown`` requires its ``children`` prop to be a string. Acting as + a memoization snapshot boundary keeps any Var child inlined inside the + snapshot body, instead of letting the auto-memoize plugin hoist a state + read into a separate ``Bare_comp_`` React element child (which would + render as a JSX element, not a string). + """ library = "react-markdown@10.1.0" diff --git a/pyi_hashes.json b/pyi_hashes.json index a422e6a6855..00ea0719dfd 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -40,7 +40,7 @@ "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "8e379fa038c7c6c0672639eb5902934d", "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7", "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "b692058e40b15da293fbf463ad300a83", - "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "da02f81678d920a68101c08fe64483a5", + "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "27661fcc57f3aa6b22ebefbc1082350c", "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed", "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af", diff --git a/tests/integration/tests_playwright/test_memoize_edge_cases.py b/tests/integration/tests_playwright/test_memoize_edge_cases.py index 0d4e92e5f9c..6a1eb068123 100644 --- a/tests/integration/tests_playwright/test_memoize_edge_cases.py +++ b/tests/integration/tests_playwright/test_memoize_edge_cases.py @@ -12,6 +12,12 @@ the component child as ``[object Object]`` (or refuses to render at all for void elements). Snapshot-wrapping keeps the Bare a text interpolation inside the parent's body. +- Third-party components whose ``children`` prop asserts a string type + (``react-markdown``). Same failure mode as constrained HTML elements: + without snapshot-wrapping, ``rx.markdown(State.var)`` compiles to + ``jsx(ReactMarkdown, {...}, jsx(Bare_xxx, {}))``, which raises + "Unexpected value [object Object] for children prop, expected string" + at render time. Test design notes: - The page title is supplied via ``app.add_page(..., title=MemoState.title_marker)`` @@ -41,6 +47,7 @@ class MemoState(rx.State): title_marker: str = "memo-title-home" css_marker: str = "memo-css-light" counter: int = 0 + markdown_source: str = "Initial **memo-md-home** text" @rx.event def toggle_open(self): @@ -58,6 +65,10 @@ def set_css_dark(self): def bump(self): self.counter = self.counter + 1 + @rx.event + def set_markdown_alt(self): + self.markdown_source = "Updated **memo-md-away** text" + def index(): return rx.box( rx.el.style("body { --memo-marker: " + MemoState.css_marker + "; }"), @@ -66,6 +77,7 @@ def index(): rx.button("title", on_click=MemoState.set_title_about, id="set-title"), rx.button("css", on_click=MemoState.set_css_dark, id="set-css"), rx.button("bump", on_click=MemoState.bump, id="bump"), + rx.button("md", on_click=MemoState.set_markdown_alt, id="set-markdown"), ), rx.accordion.root( rx.accordion.item( @@ -84,6 +96,15 @@ def index(): ), ), rx.text(MemoState.counter, id="counter"), + # Mirrors the bug-report repro: a static-source markdown next to + # a Var-source markdown inside the same parent. Pre-fix, the + # Var-source sibling crashed react-markdown with + # "Unexpected value [object Object] for children prop". + rx.vstack( + rx.markdown("This *is* **working**", id="md-static"), + rx.markdown(MemoState.markdown_source, id="md-host"), + id="md-section", + ), ) app = rx.App() @@ -207,3 +228,34 @@ def test_style_element_renders_stateful_css_as_text( ) assert _document_contains_style(page, "memo-css-dark") assert not _document_contains_style(page, "memo-css-light") + + +def test_markdown_with_state_var_renders_and_updates( + memo_app: AppHarness, page: Page +) -> None: + """``rx.markdown(State.var)`` renders the Var as a string and tracks state. + + Mirrors the bug-report repro: static-source markdown sibling next to a + Var-source markdown. Pre-fix, the Var-source markdown crashed + react-markdown and prevented the whole subtree from rendering. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + assert memo_app.frontend_url is not None + page.goto(memo_app.frontend_url) + + static = page.locator("#md-static") + expect(static.locator("em")).to_have_text("is") + expect(static.locator("strong")).to_have_text("working") + + host = page.locator("#md-host") + expect(host.locator("strong")).to_have_text("memo-md-home") + expect(host).not_to_contain_text("[object Object]") + + page.click("#set-markdown") + + expect(host.locator("strong")).to_have_text("memo-md-away") + expect(host).not_to_contain_text("[object Object]") + expect(static.locator("strong")).to_have_text("working") diff --git a/tests/units/components/markdown/test_markdown.py b/tests/units/components/markdown/test_markdown.py index 512a26a6255..e5dd802ec10 100644 --- a/tests/units/components/markdown/test_markdown.py +++ b/tests/units/components/markdown/test_markdown.py @@ -1,13 +1,19 @@ import pytest from reflex_base.components.component import Component, memo +from reflex_base.plugins import CompileContext, CompilerHooks, PageContext from reflex_base.vars.base import Var from reflex_components_code.code import CodeBlock from reflex_components_code.shiki_code_block import ShikiHighLevelCodeBlock +from reflex_components_core.base.fragment import Fragment from reflex_components_core.core.markdown_component_map import MarkdownComponentMap from reflex_components_markdown.markdown import Markdown from reflex_components_radix.themes.layout.box import Box from reflex_components_radix.themes.typography.heading import Heading +import reflex as rx +from reflex.compiler import compiler +from reflex.compiler.plugins import default_page_plugins + class CustomMarkdownComponent(Component, MarkdownComponentMap): """A custom markdown component.""" @@ -183,3 +189,57 @@ def test_markdown_format_component(key, component_map, expected): result = markdown.format_component_map() print(str(result[key])) assert str(result[key]) == expected + + +def _compile_page_output(root: Component) -> str: + """Compile ``root`` through the full page pipeline and return the JSX. + + The result includes any per-memo wrapper modules emitted alongside the + page, so callers can match against JSX wherever the auto-memoize plugin + chose to place it. + + Args: + root: The page root component to compile. + + Returns: + The combined page-module JSX plus each per-memo module's JSX. + """ + page_ctx = PageContext(name="page", route="/page", root_component=root) + hooks = CompilerHooks(plugins=default_page_plugins()) + compile_ctx = CompileContext(pages=[], hooks=hooks) + + with compile_ctx, page_ctx: + page_ctx.root_component = hooks.compile_component( + page_ctx.root_component, + page_context=page_ctx, + compile_context=compile_ctx, + ) + hooks.compile_page(page_ctx, compile_context=compile_ctx) + _, page_code = compiler.compile_page_from_context(page_ctx) + memo_files, _ = compiler.compile_memo_components( + (), compile_ctx.auto_memo_components.values() + ) + return "\n".join([page_code, *(code for _, code in memo_files)]) + + +def test_markdown_var_child_inlined_not_wrapped(): + """``rx.markdown(State.var)`` must inline the Var as the JSX child. + + ``react-markdown`` asserts its ``children`` prop is a string. Without the + snapshot-boundary wrapper on ``Markdown``, the auto-memoize plugin hoists + the Bare(state-Var) child into its own ``Bare_comp_`` React element, + which renders as ``[object Object]`` at runtime. + """ + + class _MdState(rx.State): + some_text: str = "hello" + + root = Fragment.create(Markdown.create(_MdState.some_text)) + output = _compile_page_output(root) + + assert "jsx(ReactMarkdown" in output + assert "Bare_comp_" not in output, ( + "Markdown Var child was wrapped in a Bare_comp_ memoized " + f"component; ReactMarkdown requires a string child.\nOutput:\n{output}" + ) + assert "some_text_rx_state_" in output From 5aa7159c17bc632f74d15bf13f61b4bab7386b97 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 20 May 2026 00:01:41 +0500 Subject: [PATCH 2/2] test: hoist Markdown Var-child regression state to module scope Defining the State subclass inside the test body re-registers it on every collection, which leaks under pytest-repeat and duplicate runs. Module scope gives it a stable registry key. --- .../components/markdown/test_markdown.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/units/components/markdown/test_markdown.py b/tests/units/components/markdown/test_markdown.py index e5dd802ec10..8874b126bc9 100644 --- a/tests/units/components/markdown/test_markdown.py +++ b/tests/units/components/markdown/test_markdown.py @@ -198,6 +198,11 @@ def _compile_page_output(root: Component) -> str: page, so callers can match against JSX wherever the auto-memoize plugin chose to place it. + Reaches into compiler internals (``CompileContext.auto_memo_components``, + ``compiler.compile_page_from_context``, ``compiler.compile_memo_components``) + because no public driver returns the combined page+memo JSX text. If those + APIs are renamed, update here. + Args: root: The page root component to compile. @@ -222,6 +227,18 @@ def _compile_page_output(root: Component) -> str: return "\n".join([page_code, *(code for _, code in memo_files)]) +class MarkdownVarChildRegressionState(rx.State): + """Module-scope state for the Var-child regression test. + + Defined at module scope (not inside the test function) so the state + registry keys this class by a stable ``module.MarkdownVarChildRegressionState`` + full name, avoiding re-registration leaks under pytest-repeat or duplicate + test collection. + """ + + some_text: str = "hello" + + def test_markdown_var_child_inlined_not_wrapped(): """``rx.markdown(State.var)`` must inline the Var as the JSX child. @@ -230,11 +247,7 @@ def test_markdown_var_child_inlined_not_wrapped(): the Bare(state-Var) child into its own ``Bare_comp_`` React element, which renders as ``[object Object]`` at runtime. """ - - class _MdState(rx.State): - some_text: str = "hello" - - root = Fragment.create(Markdown.create(_MdState.some_text)) + root = Fragment.create(Markdown.create(MarkdownVarChildRegressionState.some_text)) output = _compile_page_output(root) assert "jsx(ReactMarkdown" in output