diff --git a/docs/app/reflex.lock/bun.lock b/docs/app/reflex.lock/bun.lock index ca0a9590f9d..f78ecb91589 100644 --- a/docs/app/reflex.lock/bun.lock +++ b/docs/app/reflex.lock/bun.lock @@ -50,7 +50,7 @@ "react-helmet": "6.1.0", "react-leaflet": "5.0.0", "react-markdown": "10.1.0", - "react-moment": "1.2.2", + "react-moment": "2.0.2", "react-player": "3.4.0", "react-plotly.js": "4.1.0", "react-responsive-carousel": "3.2.23", @@ -1501,7 +1501,7 @@ "react-merge-refs": ["react-merge-refs@2.1.1", "", {}, "sha512-jLQXJ/URln51zskhgppGJ2ub7b2WFKGq3cl3NYKtlHoTG+dN2q7EzWrn3hN3EgPsTMvpR9tpq5ijdp7YwFZkag=="], - "react-moment": ["react-moment@1.2.2", "", { "peerDependencies": { "moment": "^2.29.0", "prop-types": "^15.7.0", "react": "^16.0 || ^17.0.0 || ^18.0.0" } }, "sha512-F5/YLsygduvR5+mpYdHMD9HzyljQFudcoAfL5XmJhGBkZVzJANIx/5PN+kvWO5KELifXXtndXeMCUxH4eB+E9Q=="], + "react-moment": ["react-moment@2.0.2", "", { "peerDependencies": { "moment": "^2.29.0", "moment-duration-format": "^2.2.2", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["moment-duration-format"] }, "sha512-aogeei/erGX9iL7kAwQceaxdW2JR0/XbITYBEN1mFHG0Z02y8G0Wrl2PgbfIPs5EbVFhUB2F2+FopW9UPuP1qg=="], "react-number-format": ["react-number-format@5.4.5", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw=="], diff --git a/docs/app/reflex.lock/package.json b/docs/app/reflex.lock/package.json index 5c7d3dc915c..036eead123a 100644 --- a/docs/app/reflex.lock/package.json +++ b/docs/app/reflex.lock/package.json @@ -51,7 +51,7 @@ "react-helmet": "6.1.0", "react-leaflet": "5.0.0", "react-markdown": "10.1.0", - "react-moment": "1.2.2", + "react-moment": "2.0.2", "react-player": "3.4.0", "react-plotly.js": "4.1.0", "react-responsive-carousel": "3.2.23", @@ -90,4 +90,4 @@ "vite": "8.2.2" }, "overrides": {} -} \ No newline at end of file +} diff --git a/packages/reflex-components-moment/news/7003.bugfix.md b/packages/reflex-components-moment/news/7003.bugfix.md new file mode 100644 index 00000000000..3cb1ffc171f --- /dev/null +++ b/packages/reflex-components-moment/news/7003.bugfix.md @@ -0,0 +1 @@ +Migrate `rx.moment` to `react-moment` 2.0.2 and include its duration-format dependency. diff --git a/packages/reflex-components-moment/src/reflex_components_moment/moment.py b/packages/reflex-components-moment/src/reflex_components_moment/moment.py index fd3558168cd..0ec9eda9c4f 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/moment.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/moment.py @@ -7,7 +7,7 @@ from reflex_base.components.component import MemoizationLeaf, NoSSRComponent, field from reflex_base.event import EventHandler, passthrough_event_spec -from reflex_base.utils.imports import ImportDict +from reflex_base.utils.imports import ImportDict, ImportVar from reflex_base.vars.base import LiteralVar, Var @@ -31,7 +31,7 @@ class Moment(NoSSRComponent, MemoizationLeaf): tag: str | None = "Moment" is_default = True - library: str | None = "react-moment@1.2.2" + library: str | None = "react-moment@2.0.2" lib_dependencies: list[str] = ["moment@2.30.1"] interval: Var[int] = field( @@ -42,12 +42,12 @@ class Moment(NoSSRComponent, MemoizationLeaf): doc="Formats the date according to the given format string." ) - trim: Var[bool] = field( - doc="When formatting duration time, the largest-magnitude tokens are automatically trimmed when they have no value." + trim: Var[bool | str] = field( + doc='When formatting duration time, the largest-magnitude tokens are automatically trimmed when they have no value. Also accepts a trim template: "large", "small", "both", "all", "final", "left" or "right".' ) - parse: Var[str] = field( - doc=" Use the parse attribute to tell moment how to parse the given date when non-standard." + parse: Var[str | list[str]] = field( + doc=" Use the parse attribute to tell moment how to parse the given date when non-standard. Accepts a single format string or a list of formats to try." ) add: Var[MomentDelta] = field( @@ -132,5 +132,7 @@ def add_imports(self) -> ImportDict: imports[""] = "moment/min/locales" if self.tz is not None: imports["moment-timezone@0.6.3"] = "" + if self.duration is not None or self.duration_from_now is not None: + imports["moment-duration-format@2.2.2"] = ImportVar(tag=None) return imports diff --git a/pyi_hashes.json b/pyi_hashes.json index 76ffc5815bb..65f8c8ae669 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -41,7 +41,7 @@ "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "2ce1c076ecf5c2fa4945b4abdbf2f91d", "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "1e331a3d6420b97e5b1ce7f63ad53de8", "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "79d0a59b1ba12a2f2c4a09fa6b5c776f", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "85d515f5254bb4c188075873ec4d8a51", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "92cc72695f0868cdbcec912e916541f2", "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "beb057e382e527224597c320dbb72385", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "a77352f60fb6f4135b5d08a6e56efa6d", "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "bbd4d1a4fa73275a882c33ba485d0165", diff --git a/tests/integration/test_moment.py b/tests/integration/test_moment.py new file mode 100644 index 00000000000..c7457d54612 --- /dev/null +++ b/tests/integration/test_moment.py @@ -0,0 +1,87 @@ +"""Integration tests for the Moment component.""" + +from collections.abc import Generator + +import pytest +from selenium.webdriver.common.by import By + +from reflex.testing import AppHarness, WebDriver + + +def MomentApp(): + """Create an app that exercises the react-moment 2.x prop changes.""" + import reflex as rx + + app = rx.App() + + class State(rx.State): + """State used to wait for the browser connection before asserting output.""" + + @app.add_page + def index(): + return rx.vstack( + rx.el.input( + id="token", + value=State.router.session.client_token, + is_read_only=True, + ), + rx.moment( + "2026-08-30", + format="YYYY-MM-DD", + parse=["YYYY-MM-DD"], + id="moment", + ), + rx.moment( + date="2026-08-30T00:30:00", + duration="2026-08-30T00:00:00", + format="h [hrs] m [min]", + trim="large", + id="moment-duration", + ), + ) + + +@pytest.fixture(scope="module") +def moment_app( + tmp_path_factory, app_harness_env: type[AppHarness] +) -> Generator[AppHarness, None, None]: + """Start the Moment integration app. + + Yields: + The running Moment app harness. + """ + with app_harness_env.create( + root=tmp_path_factory.mktemp("moment"), app_source=MomentApp + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +@pytest.fixture +def driver(moment_app: AppHarness) -> Generator[WebDriver, None, None]: + """Open the Moment integration app in a browser. + + Yields: + The browser driver connected to the Moment app. + """ + driver = moment_app.frontend() + try: + token = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "token") + ) + AppHarness.poll_for_or_raise_timeout(lambda: token.get_attribute("value")) + yield driver + finally: + driver.quit() + + +def test_moment_2_props_render(driver: WebDriver) -> None: + """Changed react-moment 2.x props should render without a client error.""" + moment = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "moment") + ) + AppHarness.expect(lambda: moment.text == "2026-08-30") + moment_duration = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "moment-duration") + ) + AppHarness.expect(lambda: moment_duration.text == "30 mins") diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 0098e49bd76..c7b8615423d 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -22,6 +22,7 @@ from reflex_base.constants.compiler import MemoizationDisposition, MemoizationMode from reflex_base.plugins import CompileContext, CompilerHooks, PageContext from reflex_base.utils import memo_paths +from reflex_base.utils.imports import ImportVar from reflex_base.vars import VarData from reflex_base.vars.base import Field, LiteralVar, Var, field from reflex_components_core.base.bare import Bare @@ -1841,6 +1842,32 @@ def test_moment_with_stateful_var_child_does_not_wrap_bare_independently() -> No ) +def test_moment_uses_react_moment_2_props_and_dependencies() -> None: + """The wrapper exposes the react-moment 2.x props and dependencies.""" + assert Moment.library == "react-moment@2.0.2" + assert Moment.lib_dependencies == [ + "moment@2.30.1", + ] + + moment = Moment.create( + "2026-08-30", + trim="large", + parse=["YYYY-MM-DD"], + ) + props = moment.render()["props"] + assert 'trim:"large"' in props + assert 'parse:["YYYY-MM-DD"]' in props + + duration_from_now = Moment.create( + "2026-08-30", + duration_from_now=True, + ) + assert duration_from_now.add_imports()["moment-duration-format@2.2.2"] == ImportVar( + tag=None + ) + assert "moment-duration-format@2.2.2" not in moment.add_imports() + + def test_moment_memo_body_renders_text_interpolation_not_bare_component() -> None: """The moment's memo body must interpolate the state Var as text, not a Bare wrapper.""" ctx, _page_ctx = _compile_single_page(