diff --git a/packages/reflex-components-code/news/6520.bugfix.md b/packages/reflex-components-code/news/6520.bugfix.md new file mode 100644 index 00000000000..cd88eb0ba8c --- /dev/null +++ b/packages/reflex-components-code/news/6520.bugfix.md @@ -0,0 +1 @@ +Track state vars referenced in `rx.code_block`'s `custom_style` dict so styles bound to state re-render on updates, and make `wrap_long_lines=True` apply `whiteSpace: pre-wrap` to the code tag even when `code_tag_props` is also provided. diff --git a/packages/reflex-components-code/src/reflex_components_code/code.py b/packages/reflex-components-code/src/reflex_components_code/code.py index a6fec988df6..03a2f43b66d 100644 --- a/packages/reflex-components-code/src/reflex_components_code/code.py +++ b/packages/reflex-components-code/src/reflex_components_code/code.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses +from collections.abc import Iterator from typing import ClassVar, Literal from reflex_base.components.component import Component, ComponentNamespace, field @@ -409,7 +410,6 @@ class CodeBlock(Component, MarkdownComponentMap): custom_style: dict[str, str | Var | Color] = field( doc="A custom style for the code block.", default_factory=dict, - is_javascript_property=False, ) code_tag_props: Var[dict[str, str | dict[str, str]]] = field( @@ -459,6 +459,25 @@ def create( dark=Theme.one_dark, ) + if props.get("wrap_long_lines") is True: + code_tag_props = props.get("code_tag_props") + if code_tag_props is None: + props["code_tag_props"] = {"style": {"whiteSpace": "pre-wrap"}} + elif isinstance(code_tag_props, dict): + code_tag_props = code_tag_props.copy() + code_tag_style = code_tag_props.get("style") + if code_tag_style is None: + code_tag_props["style"] = {"whiteSpace": "pre-wrap"} + elif ( + isinstance(code_tag_style, dict) + and "whiteSpace" not in code_tag_style + ): + code_tag_props["style"] = { + "whiteSpace": "pre-wrap", + **code_tag_style, + } + props["code_tag_props"] = code_tag_props + if can_copy: code = children[0] copy_button = ( @@ -499,6 +518,30 @@ def add_style(self): """Add style to the component.""" self.custom_style.update(self.style) + def _get_vars( + self, include_children: bool = False, ignore_ids: set[int] | None = None + ) -> Iterator[Var]: + """Walk all Vars used in this component. + + Args: + include_children: Whether to include Vars from children. + ignore_ids: The ids to ignore. + + Yields: + Each var referenced by the component, including any embedded in + the custom_style dict, which is not a Var-typed prop. + """ + yield from super()._get_vars( + include_children=include_children, ignore_ids=ignore_ids + ) + custom_style = self.custom_style + if isinstance(custom_style, Style) and custom_style._var_data: + yield Var( + _js_expr="custom_style", + _var_type=str, + _var_data=custom_style._var_data, + ) + def _render(self): out = super()._render() diff --git a/tests/integration/tests_playwright/test_code_block.py b/tests/integration/tests_playwright/test_code_block.py new file mode 100644 index 00000000000..ca4143f9df6 --- /dev/null +++ b/tests/integration/tests_playwright/test_code_block.py @@ -0,0 +1,223 @@ +"""Integration tests for the code block component.""" + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + +DEFAULT_COPY_CODE = "print('copied from default button')" +CUSTOM_COPY_CODE = "print('copied from custom button')" + + +def CodeBlockApp(): + """App exercising code block rendering options.""" + import reflex as rx + + primary_code = "def add(x, y):\n return x + y" + long_line_code = ( + "message = 'this line is intentionally long so wrap_long_lines changes " + "the rendered whitespace behavior'" + ) + default_copy_code = "print('copied from default button')" + custom_copy_code = "print('copied from custom button')" + + def index(): + return rx.vstack( + rx.box( + rx.code_block( + primary_code, + language="python", + theme=rx.code_block.themes.one_light, + show_line_numbers=True, + starting_line_number=41, + custom_style={ + "background_color": "rgb(17, 34, 51)", + "border_radius": "6px", + "padding": "12px", + }, + code_tag_props={ + "data-testid": "primary-code-tag", + "data-code-prop": "tag-prop", + }, + ), + id="primary-code-block", + ), + rx.box( + rx.code_block( + long_line_code, + language="python", + wrap_long_lines=True, + ), + id="wrapped-code-block", + width="220px", + ), + rx.box( + rx.code_block( + "const answer = 42;", + language="javascript", + theme=rx.code_block.themes.one_dark, + ), + id="theme-code-block", + ), + rx.box( + rx.code_block(default_copy_code, language="python", can_copy=True), + id="default-copy-block", + ), + rx.box( + rx.code_block( + custom_copy_code, + language="python", + can_copy=True, + copy_button=rx.button( + "Copy custom", + id="custom-copy", + on_click=rx.set_clipboard(custom_copy_code), + ), + ), + id="custom-copy-block", + ), + align_items="stretch", + spacing="4", + width="480px", + ) + + app = rx.App(enable_state=False) + app.add_page(index) + + +@pytest.fixture(scope="module") +def code_block_app( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Start the code block test app. + + Args: + tmp_path_factory: Pytest tmp path factory. + + Yields: + The running app harness. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("code_block_app"), + app_source=CodeBlockApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +def _goto_code_block_app(code_block_app: AppHarness, page: Page) -> None: + """Navigate to the code block test app. + + Args: + code_block_app: Running code block app harness. + page: Playwright page. + """ + assert code_block_app.frontend_url is not None + page.goto(code_block_app.frontend_url) + expect(page.locator("#primary-code-block pre")).to_be_visible() + + +def test_code_block_renders_code_language_line_numbers_and_code_tag_props( + code_block_app: AppHarness, page: Page +) -> None: + """The primary code block renders code text and structural props. + + Args: + code_block_app: Running code block app harness. + page: Playwright page. + """ + _goto_code_block_app(code_block_app, page) + + code_tag = page.get_by_test_id("primary-code-tag") + expect(code_tag).to_contain_text("def add") + expect(code_tag).to_contain_text("return x + y") + expect(code_tag).to_have_attribute("data-code-prop", "tag-prop") + + # PrismAsyncLight loads the language grammar asynchronously, so token + # spans appear after the initial plain-text render; wait for one. + expect(code_tag.get_by_text("def", exact=True)).to_be_visible() + + line_numbers = page.locator("#primary-code-block .linenumber") + expect(line_numbers).to_have_count(2) + assert line_numbers.all_inner_texts() == ["41", "42"] + + +def test_code_block_applies_custom_style_and_theme( + code_block_app: AppHarness, page: Page +) -> None: + """Code block styles from custom_style and theme reach the DOM. + + Args: + code_block_app: Running code block app harness. + page: Playwright page. + """ + _goto_code_block_app(code_block_app, page) + + primary = page.locator("#primary-code-block pre") + expect(primary).to_have_css("background-color", "rgb(17, 34, 51)") + expect(primary).to_have_css("border-radius", "6px") + + themed = page.locator("#theme-code-block pre") + expect(themed).to_have_css("background-color", "rgb(40, 44, 52)") + + +def test_code_block_wraps_long_lines(code_block_app: AppHarness, page: Page) -> None: + """wrap_long_lines changes rendered whitespace behavior. + + Args: + code_block_app: Running code block app harness. + page: Playwright page. + """ + _goto_code_block_app(code_block_app, page) + + wrapped = page.locator("#wrapped-code-block") + expect(wrapped.locator("code")).to_contain_text("this line is intentionally long") + assert wrapped.locator("pre").evaluate("el => el.scrollWidth <= el.clientWidth + 1") + + +def test_code_block_default_copy_button(code_block_app: AppHarness, page: Page) -> None: + """The built-in copy button writes the code text to the clipboard. + + Args: + code_block_app: Running code block app harness. + page: Playwright page. + """ + assert code_block_app.frontend_url is not None + page.context.grant_permissions( + ["clipboard-read", "clipboard-write"], origin=code_block_app.frontend_url + ) + _goto_code_block_app(code_block_app, page) + + page.evaluate("navigator.clipboard.writeText('')") + page.locator("#default-copy-block button").click() + page.wait_for_function( + "expected => navigator.clipboard.readText().then(text => text === expected)", + arg=DEFAULT_COPY_CODE, + ) + + +def test_code_block_custom_copy_button(code_block_app: AppHarness, page: Page) -> None: + """A custom copy button replaces the default and can run its own event. + + Args: + code_block_app: Running code block app harness. + page: Playwright page. + """ + assert code_block_app.frontend_url is not None + page.context.grant_permissions( + ["clipboard-read", "clipboard-write"], origin=code_block_app.frontend_url + ) + _goto_code_block_app(code_block_app, page) + + custom_block = page.locator("#custom-copy-block") + expect(custom_block.get_by_role("button")).to_have_text("Copy custom") + expect(custom_block.locator("svg.lucide-copy")).to_have_count(0) + + page.evaluate("navigator.clipboard.writeText('')") + page.locator("#custom-copy").click() + page.wait_for_function( + "expected => navigator.clipboard.readText().then(text => text === expected)", + arg=CUSTOM_COPY_CODE, + ) diff --git a/tests/units/components/datadisplay/test_code.py b/tests/units/components/datadisplay/test_code.py index 42b51b773e5..9c185ccdb65 100644 --- a/tests/units/components/datadisplay/test_code.py +++ b/tests/units/components/datadisplay/test_code.py @@ -19,6 +19,26 @@ def test_code_block_rejects_string_theme(): CodeBlock.create("print('Hello')", theme="one_dark") # pyright: ignore[reportArgumentType] +def test_code_block_collects_custom_style_state_vars(): + class CustomStyleState(rx.State): + color: str = "red" + + code_block = CodeBlock.create( + "x = 1", custom_style={"color": CustomStyleState.color} + ) + + rendered = str(code_block.render()) + assert CustomStyleState.color._js_expr in rendered # pyright: ignore [reportAttributeAccessIssue] + + var_data = CustomStyleState.color._get_all_var_data() # pyright: ignore [reportAttributeAccessIssue] + assert var_data is not None + assert var_data.hooks + collected_hooks = code_block._get_all_hooks() + assert all(hook in collected_hooks for hook in var_data.hooks), ( + "state hook for custom_style var was not collected" + ) + + def test_code_block_accepts_color_mode_cond_theme(): theme = rx.color_mode_cond( light=rx.code_block.themes.one_light,