Skip to content

Commit 70bd785

Browse files
fix: make Markdown a MemoizationLeaf so Var children stay inlined (#6532)
* 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_<hash> element, which renders as [object Object] and crashes the subtree. * 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.
1 parent 6112083 commit 70bd785

4 files changed

Lines changed: 136 additions & 3 deletions

File tree

packages/reflex-components-markdown/src/reflex_components_markdown/markdown.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
Component,
1515
ComponentNamespace,
1616
CustomComponent,
17+
MemoizationLeaf,
1718
field,
1819
)
1920
from reflex_base.components.tags.tag import Tag
@@ -188,8 +189,15 @@ def get_base_component_map() -> dict[str, Callable]:
188189
}
189190

190191

191-
class Markdown(Component):
192-
"""A markdown component."""
192+
class Markdown(MemoizationLeaf):
193+
"""A markdown component.
194+
195+
``react-markdown`` requires its ``children`` prop to be a string. Acting as
196+
a memoization snapshot boundary keeps any Var child inlined inside the
197+
snapshot body, instead of letting the auto-memoize plugin hoist a state
198+
read into a separate ``Bare_comp_<hash>`` React element child (which would
199+
render as a JSX element, not a string).
200+
"""
193201

194202
library = "react-markdown@10.1.0"
195203

pyi_hashes.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "8e379fa038c7c6c0672639eb5902934d",
4141
"packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7",
4242
"packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "b692058e40b15da293fbf463ad300a83",
43-
"packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "da02f81678d920a68101c08fe64483a5",
43+
"packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "27661fcc57f3aa6b22ebefbc1082350c",
4444
"packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed",
4545
"packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140",
4646
"packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af",

tests/integration/tests_playwright/test_memoize_edge_cases.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
the component child as ``[object Object]`` (or refuses to render at all
1313
for void elements). Snapshot-wrapping keeps the Bare a text interpolation
1414
inside the parent's body.
15+
- Third-party components whose ``children`` prop asserts a string type
16+
(``react-markdown``). Same failure mode as constrained HTML elements:
17+
without snapshot-wrapping, ``rx.markdown(State.var)`` compiles to
18+
``jsx(ReactMarkdown, {...}, jsx(Bare_xxx, {}))``, which raises
19+
"Unexpected value [object Object] for children prop, expected string"
20+
at render time.
1521
1622
Test design notes:
1723
- The page title is supplied via ``app.add_page(..., title=MemoState.title_marker)``
@@ -41,6 +47,7 @@ class MemoState(rx.State):
4147
title_marker: str = "memo-title-home"
4248
css_marker: str = "memo-css-light"
4349
counter: int = 0
50+
markdown_source: str = "Initial **memo-md-home** text"
4451

4552
@rx.event
4653
def toggle_open(self):
@@ -58,6 +65,10 @@ def set_css_dark(self):
5865
def bump(self):
5966
self.counter = self.counter + 1
6067

68+
@rx.event
69+
def set_markdown_alt(self):
70+
self.markdown_source = "Updated **memo-md-away** text"
71+
6172
def index():
6273
return rx.box(
6374
rx.el.style("body { --memo-marker: " + MemoState.css_marker + "; }"),
@@ -66,6 +77,7 @@ def index():
6677
rx.button("title", on_click=MemoState.set_title_about, id="set-title"),
6778
rx.button("css", on_click=MemoState.set_css_dark, id="set-css"),
6879
rx.button("bump", on_click=MemoState.bump, id="bump"),
80+
rx.button("md", on_click=MemoState.set_markdown_alt, id="set-markdown"),
6981
),
7082
rx.accordion.root(
7183
rx.accordion.item(
@@ -84,6 +96,15 @@ def index():
8496
),
8597
),
8698
rx.text(MemoState.counter, id="counter"),
99+
# Mirrors the bug-report repro: a static-source markdown next to
100+
# a Var-source markdown inside the same parent. Pre-fix, the
101+
# Var-source sibling crashed react-markdown with
102+
# "Unexpected value [object Object] for children prop".
103+
rx.vstack(
104+
rx.markdown("This *is* **working**", id="md-static"),
105+
rx.markdown(MemoState.markdown_source, id="md-host"),
106+
id="md-section",
107+
),
87108
)
88109

89110
app = rx.App()
@@ -207,3 +228,34 @@ def test_style_element_renders_stateful_css_as_text(
207228
)
208229
assert _document_contains_style(page, "memo-css-dark")
209230
assert not _document_contains_style(page, "memo-css-light")
231+
232+
233+
def test_markdown_with_state_var_renders_and_updates(
234+
memo_app: AppHarness, page: Page
235+
) -> None:
236+
"""``rx.markdown(State.var)`` renders the Var as a string and tracks state.
237+
238+
Mirrors the bug-report repro: static-source markdown sibling next to a
239+
Var-source markdown. Pre-fix, the Var-source markdown crashed
240+
react-markdown and prevented the whole subtree from rendering.
241+
242+
Args:
243+
memo_app: Running app harness.
244+
page: Playwright page.
245+
"""
246+
assert memo_app.frontend_url is not None
247+
page.goto(memo_app.frontend_url)
248+
249+
static = page.locator("#md-static")
250+
expect(static.locator("em")).to_have_text("is")
251+
expect(static.locator("strong")).to_have_text("working")
252+
253+
host = page.locator("#md-host")
254+
expect(host.locator("strong")).to_have_text("memo-md-home")
255+
expect(host).not_to_contain_text("[object Object]")
256+
257+
page.click("#set-markdown")
258+
259+
expect(host.locator("strong")).to_have_text("memo-md-away")
260+
expect(host).not_to_contain_text("[object Object]")
261+
expect(static.locator("strong")).to_have_text("working")

tests/units/components/markdown/test_markdown.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import pytest
22
from reflex_base.components.component import Component, memo
3+
from reflex_base.plugins import CompileContext, CompilerHooks, PageContext
34
from reflex_base.vars.base import Var
45
from reflex_components_code.code import CodeBlock
56
from reflex_components_code.shiki_code_block import ShikiHighLevelCodeBlock
7+
from reflex_components_core.base.fragment import Fragment
68
from reflex_components_core.core.markdown_component_map import MarkdownComponentMap
79
from reflex_components_markdown.markdown import Markdown
810
from reflex_components_radix.themes.layout.box import Box
911
from reflex_components_radix.themes.typography.heading import Heading
1012

13+
import reflex as rx
14+
from reflex.compiler import compiler
15+
from reflex.compiler.plugins import default_page_plugins
16+
1117

1218
class CustomMarkdownComponent(Component, MarkdownComponentMap):
1319
"""A custom markdown component."""
@@ -183,3 +189,70 @@ def test_markdown_format_component(key, component_map, expected):
183189
result = markdown.format_component_map()
184190
print(str(result[key]))
185191
assert str(result[key]) == expected
192+
193+
194+
def _compile_page_output(root: Component) -> str:
195+
"""Compile ``root`` through the full page pipeline and return the JSX.
196+
197+
The result includes any per-memo wrapper modules emitted alongside the
198+
page, so callers can match against JSX wherever the auto-memoize plugin
199+
chose to place it.
200+
201+
Reaches into compiler internals (``CompileContext.auto_memo_components``,
202+
``compiler.compile_page_from_context``, ``compiler.compile_memo_components``)
203+
because no public driver returns the combined page+memo JSX text. If those
204+
APIs are renamed, update here.
205+
206+
Args:
207+
root: The page root component to compile.
208+
209+
Returns:
210+
The combined page-module JSX plus each per-memo module's JSX.
211+
"""
212+
page_ctx = PageContext(name="page", route="/page", root_component=root)
213+
hooks = CompilerHooks(plugins=default_page_plugins())
214+
compile_ctx = CompileContext(pages=[], hooks=hooks)
215+
216+
with compile_ctx, page_ctx:
217+
page_ctx.root_component = hooks.compile_component(
218+
page_ctx.root_component,
219+
page_context=page_ctx,
220+
compile_context=compile_ctx,
221+
)
222+
hooks.compile_page(page_ctx, compile_context=compile_ctx)
223+
_, page_code = compiler.compile_page_from_context(page_ctx)
224+
memo_files, _ = compiler.compile_memo_components(
225+
(), compile_ctx.auto_memo_components.values()
226+
)
227+
return "\n".join([page_code, *(code for _, code in memo_files)])
228+
229+
230+
class MarkdownVarChildRegressionState(rx.State):
231+
"""Module-scope state for the Var-child regression test.
232+
233+
Defined at module scope (not inside the test function) so the state
234+
registry keys this class by a stable ``module.MarkdownVarChildRegressionState``
235+
full name, avoiding re-registration leaks under pytest-repeat or duplicate
236+
test collection.
237+
"""
238+
239+
some_text: str = "hello"
240+
241+
242+
def test_markdown_var_child_inlined_not_wrapped():
243+
"""``rx.markdown(State.var)`` must inline the Var as the JSX child.
244+
245+
``react-markdown`` asserts its ``children`` prop is a string. Without the
246+
snapshot-boundary wrapper on ``Markdown``, the auto-memoize plugin hoists
247+
the Bare(state-Var) child into its own ``Bare_comp_<hash>`` React element,
248+
which renders as ``[object Object]`` at runtime.
249+
"""
250+
root = Fragment.create(Markdown.create(MarkdownVarChildRegressionState.some_text))
251+
output = _compile_page_output(root)
252+
253+
assert "jsx(ReactMarkdown" in output
254+
assert "Bare_comp_" not in output, (
255+
"Markdown Var child was wrapped in a Bare_comp_<hash> memoized "
256+
f"component; ReactMarkdown requires a string child.\nOutput:\n{output}"
257+
)
258+
assert "some_text_rx_state_" in output

0 commit comments

Comments
 (0)