Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Component,
ComponentNamespace,
CustomComponent,
MemoizationLeaf,
field,
)
from reflex_base.components.tags.tag import Tag
Expand Down Expand Up @@ -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_<hash>`` React element child (which would
render as a JSX element, not a string).
"""

library = "react-markdown@10.1.0"

Expand Down
2 changes: 1 addition & 1 deletion pyi_hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions tests/integration/tests_playwright/test_memoize_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)``
Expand Down Expand Up @@ -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):
Expand All @@ -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 + "; }"),
Expand All @@ -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(
Expand All @@ -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()
Expand Down Expand Up @@ -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")
73 changes: 73 additions & 0 deletions tests/units/components/markdown/test_markdown.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down Expand Up @@ -183,3 +189,70 @@ 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.

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.

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)])
Comment thread
FarhanAliRaza marked this conversation as resolved.


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.

``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_<hash>`` React element,
which renders as ``[object Object]`` at runtime.
"""
root = Fragment.create(Markdown.create(MarkdownVarChildRegressionState.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_<hash> memoized "
f"component; ReactMarkdown requires a string child.\nOutput:\n{output}"
)
assert "some_text_rx_state_" in output
Loading