From 628e2e6d471cfef06b670744498af55e99749390 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sun, 18 Oct 2026 11:06:54 +0530 Subject: [PATCH 1/7] feat: expose react-moment 2 configuration --- docs/library/data-display/moment.md | 20 ++++ .../news/+7100.bugfix.md | 1 + .../src/reflex_components_moment/__init__.py | 3 +- .../src/reflex_components_moment/moment.py | 102 +++++++++++++++++- pyi_hashes.json | 4 +- reflex/__init__.py | 2 +- .../reflex_components_moment/test_moment.py | 77 ++++++++++++- 7 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 packages/reflex-components-moment/news/+7100.bugfix.md diff --git a/docs/library/data-display/moment.md b/docs/library/data-display/moment.md index 3e2ca89ca44..702f90746a3 100644 --- a/docs/library/data-display/moment.md +++ b/docs/library/data-display/moment.md @@ -214,6 +214,26 @@ It fires again when the component remounts, such as after a reload or route navi React Strict Mode can invoke the mount event twice in development, so handlers with side effects should account for repeated calls. +### Additional react-moment 2.x options + +`rx.moment` also supports the `utc`, `calendar`, `from_`, `to`, `ago`, `element`, +`fallback`, `filters`, and `renderers` options from `react-moment` 2.x. In Python, +`from_` maps to the JavaScript `from` prop. `filters` and `renderers` accept +JavaScript function values. + +The old singular `filter` prop was removed by `react-moment` 2.x; use `filters` +instead. Shared defaults are configured with `rx.moment_provider`, not a +`settings` prop on `rx.moment`: + +```python demo exec +rx.moment_provider( + rx.moment("2024-03-14"), + locale="fr", + format="D MMM YYYY", + timezone="Europe/Paris", +) +``` + Connect an event handler with the `on_change` prop: ```python demo exec diff --git a/packages/reflex-components-moment/news/+7100.bugfix.md b/packages/reflex-components-moment/news/+7100.bugfix.md new file mode 100644 index 00000000000..05a9a2b8fe0 --- /dev/null +++ b/packages/reflex-components-moment/news/+7100.bugfix.md @@ -0,0 +1 @@ +Expose the supported `react-moment` 2.x options through `rx.moment`, add `rx.moment_provider` for shared defaults and pooled timers, and report the removed `filter` and unsupported `settings` props instead of treating them as CSS. diff --git a/packages/reflex-components-moment/src/reflex_components_moment/__init__.py b/packages/reflex-components-moment/src/reflex_components_moment/__init__.py index 9f4987c56b5..65b12fd473c 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/__init__.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/__init__.py @@ -1,5 +1,6 @@ """Moment.js component.""" -from .moment import Moment, MomentDelta +from .moment import Moment, MomentDelta as MomentDelta, MomentProvider moment = Moment.create +moment_provider = MomentProvider.create 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 bd1519193d4..ed522e56c6a 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/moment.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/moment.py @@ -4,8 +4,14 @@ import dataclasses import datetime - -from reflex_base.components.component import MemoizationLeaf, NoSSRComponent, field +from typing import Any + +from reflex_base.components.component import ( + Component, + MemoizationLeaf, + NoSSRComponent, + field, +) from reflex_base.event import EventHandler, passthrough_event_spec from reflex_base.utils.imports import ImportDict, ImportVar from reflex_base.vars.base import LiteralVar, Var @@ -26,11 +32,44 @@ class MomentDelta: milliseconds: int | None = dataclasses.field(default=None) +class MomentProvider(Component): + """Provide shared defaults and timer pooling for descendant Moment components.""" + + tag: str | None = "MomentProvider" + library: str | None = "react-moment@2.0.2" + lib_dependencies: list[str] = ["moment@2.31.0"] + + pool: Var[bool] = field(doc="Use one timer for all descendant Moment components.") + interval: Var[int] = field(doc="The interval in milliseconds for the pooled timer.") + moment: Var[Any] = field( + doc="The moment.js instance used by descendant components." + ) + locale: Var[str] = field(doc="The default locale for descendant Moment components.") + local: Var[bool] = field(doc="Display descendant dates in local time by default.") + format: Var[str] = field(doc="The default date format for descendants.") + parse: Var[str | list[str]] = field( + doc="The default date parse format for descendants." + ) + filters: Var[list[Var[Any]]] = field( + doc="JavaScript content filters applied to descendant Moment components." + ) + renderers: Var[list[Var[Any]]] = field( + doc="JavaScript content renderers tried for descendant Moment components." + ) + element: Var[str] = field(doc="The default HTML element for descendant components.") + timezone: Var[str] = field(doc="The default timezone for descendant components.") + + def add_imports(self) -> ImportDict: + """Import the named provider export from react-moment.""" + return {"react-moment@2.0.2": ImportVar(tag="MomentProvider")} + + class Moment(NoSSRComponent, MemoizationLeaf): """The Moment component.""" tag: str | None = "Moment" is_default = True + _rename_props = {"from_": "from"} library: str | None = "react-moment@2.0.2" lib_dependencies: list[str] = ["moment@2.31.0"] @@ -50,6 +89,40 @@ class Moment(NoSSRComponent, MemoizationLeaf): 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." ) + utc: Var[bool] = field(doc="Parses and displays the date in UTC.") + + calendar: Var[bool | dict[str, str]] = field( + doc="Displays the date using Moment's calendar format, optionally with custom calendar strings." + ) + + from_: Var[ + str | datetime.datetime | datetime.date | datetime.time | datetime.timedelta + ] = field(doc="Displays the interval from this date to the component's date.") + + to: Var[ + str | datetime.datetime | datetime.date | datetime.time | datetime.timedelta + ] = field(doc="Displays the interval from the component's date to this date.") + + ago: Var[bool] = field( + doc='Omits the "ago" or "in" suffix from relative-time output.' + ) + + element: Var[str] = field( + doc="The HTML element used to render the formatted date (defaults to time)." + ) + + fallback: Var[str | int] = field( + doc="Content to render when the date input is invalid." + ) + + filters: Var[list[Var[Any]]] = field( + doc="JavaScript functions applied in order to the rendered content." + ) + + renderers: Var[list[Var[Any]]] = field( + doc="JavaScript renderer functions tried before the built-in output modes." + ) + add: Var[MomentDelta] = field( doc='Add a delta to the base date (keys are "years", "quarters", "months", "weeks", "days", "hours", "minutes", "seconds")' ) @@ -119,6 +192,31 @@ class Moment(NoSSRComponent, MemoizationLeaf): doc="Fires when the component mounts and when the date changes, including when interval is 0. React Strict Mode can invoke the mount event twice in development." ) + @classmethod + def create(cls, *children, **props) -> Moment: + """Create a Moment component. + + Args: + *children: The date value to render. + **props: The props passed to the component. + + Returns: + The Moment component. + + Raises: + TypeError: If a removed or unsupported configuration prop is used. + """ + if "filter" in props: + msg = "The `filter` prop was removed in react-moment 2.x; use `filters` instead." + raise TypeError(msg) + if "form_at" in props: + msg = "`form_at` is not a react-moment prop; use `format` to format the date or `from_` to show a relative interval." + raise TypeError(msg) + if "settings" in props: + msg = "`settings` is not a react-moment 2.x prop; use rx.moment_provider(...) with shared defaults or set props on each Moment." + raise TypeError(msg) + return super().create(*children, **props) + def add_imports(self) -> ImportDict: """Add the imports for the Moment component. diff --git a/pyi_hashes.json b/pyi_hashes.json index ea93850b764..7b6975b20d9 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": "6759daf6e242e209d9298045c7b4456a", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "13e39f8d2b46bfd76c50cb04062da8ff", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "268a52ddad6795f6141083445c87de3e", "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "f411341e59a3065fee964747584f9fcf", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "a77352f60fb6f4135b5d08a6e56efa6d", "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "bbd4d1a4fa73275a882c33ba485d0165", @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "f2a9cf6db58d169289fbc76de6813300", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", - "reflex/__init__.pyi": "1c841ad90d789507cd56d966b474d11c", + "reflex/__init__.pyi": "c92b5910a4603dfd17590a33cd434405", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", "reflex/experimental/memo.pyi": "87e485038dc79f66b2ab15144845ef1e" } diff --git a/reflex/__init__.py b/reflex/__init__.py index ddcfec42123..ea1775c275f 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -151,7 +151,7 @@ "reflex_base.components.props": ["PropsBase"], "reflex_components_core.datadisplay.logo": ["logo"], "reflex_components_gridjs": ["data_table"], - "reflex_components_moment": ["MomentDelta", "moment"], + "reflex_components_moment": ["MomentDelta", "moment", "moment_provider"], } _COMPONENT_NAME_TO_PATH: dict[str, str] = { diff --git a/tests/units/reflex_components_moment/test_moment.py b/tests/units/reflex_components_moment/test_moment.py index 0ca16a9ee82..47139e40372 100644 --- a/tests/units/reflex_components_moment/test_moment.py +++ b/tests/units/reflex_components_moment/test_moment.py @@ -1,8 +1,9 @@ """Tests for per-component Moment locale configuration.""" import pytest +from reflex_base.utils.imports import ImportVar from reflex_base.vars.base import Var -from reflex_components_moment.moment import Moment +from reflex_components_moment.moment import Moment, MomentProvider @pytest.mark.parametrize("props", [{}, {"locale": None}, {"locale": "en"}]) @@ -30,3 +31,77 @@ def test_reactive_locale_imports_all_locales(): moment = Moment.create("2024-03-14", locale=locale) assert "locale:selectedLocale" in moment.render()["props"] assert moment.add_imports()[""] == "moment/min/locales" + + +def test_react_moment_2_props_are_forwarded(): + """Moment 2.x props are passed to React instead of becoming CSS.""" + moment = Moment.create( + "2024-03-14", + utc=True, + calendar={"sameDay": "[Today]"}, + from_="2024-03-01", + to="2024-03-31", + ago=True, + element="span", + fallback="Unknown date", + filters=Var(_js_expr="[uppercase]", _var_type=list), + renderers=Var(_js_expr="[customRenderer]", _var_type=list), + ) + + props = moment.render()["props"] + assert "utc:true" in props + assert any(prop.startswith("calendar:") and '"sameDay"' in prop for prop in props) + assert 'from:"2024-03-01"' in props + assert 'to:"2024-03-31"' in props + assert "ago:true" in props + assert 'element:"span"' in props + assert 'fallback:"Unknown date"' in props + assert "filters:[uppercase]" in props + assert "renderers:[customRenderer]" in props + assert "css:" not in props + + +def test_removed_filter_prop_has_migration_error(): + """The removed singular filter prop points users to the 2.x replacement.""" + with pytest.raises(TypeError, match=r"filter.*removed.*filters"): + Moment.create("2024-03-14", filter="uppercase") + + +def test_invalid_format_typo_has_actionable_error(): + """A reported typo is not silently emitted as a CSS style.""" + with pytest.raises(TypeError, match=r"form_at.*not a react-moment prop.*format"): + Moment.create("2024-03-14", form_at="YYYY") + + +def test_settings_prop_has_configuration_error(): + """Shared settings point users to the supported provider API.""" + with pytest.raises( + TypeError, match=r"settings.*not a react-moment 2.x prop.*rx.moment_provider" + ): + Moment.create("2024-03-14", settings={"format": "YYYY"}) + + +def test_moment_provider_exports_named_component_and_shared_props(): + """Provider props are forwarded and use the named react-moment export.""" + import reflex as rx + + assert rx.moment_provider is not None + provider = MomentProvider.create( + Moment.create("2024-03-14"), + pool=True, + interval=30000, + locale="fr", + format="D MMM YYYY", + timezone="America/Los_Angeles", + ) + + assert provider.render()["name"] == "MomentProvider" + props = provider.render()["props"] + assert "pool:true" in props + assert "interval:30000" in props + assert 'locale:"fr"' in props + assert 'format:"D MMM YYYY"' in props + assert 'timezone:"America/Los_Angeles"' in props + assert provider.add_imports() == { + "react-moment@2.0.2": ImportVar(tag="MomentProvider") + } From 2e2108c62d528101897973f29f2b6fd3336667ad Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sun, 18 Oct 2026 11:48:23 +0530 Subject: [PATCH 2/7] fix: honor Moment provider locale and timezone --- docs/library/data-display/moment.md | 13 ++++---- news/+moment-provider.feature.md | 1 + .../src/reflex_components_moment/moment.py | 17 ++++++++--- pyproject.toml | 2 +- .../reflex_components_moment/test_moment.py | 30 +++++++++++++++---- 5 files changed, 47 insertions(+), 16 deletions(-) create mode 100644 news/+moment-provider.feature.md diff --git a/docs/library/data-display/moment.md b/docs/library/data-display/moment.md index 702f90746a3..9e45dd47a82 100644 --- a/docs/library/data-display/moment.md +++ b/docs/library/data-display/moment.md @@ -226,12 +226,13 @@ instead. Shared defaults are configured with `rx.moment_provider`, not a `settings` prop on `rx.moment`: ```python demo exec -rx.moment_provider( - rx.moment("2024-03-14"), - locale="fr", - format="D MMM YYYY", - timezone="Europe/Paris", -) +def moment_provider_example(): + return rx.moment_provider( + rx.moment("2024-03-14"), + locale="fr", + format="D MMM YYYY", + timezone="Europe/Paris", + ) ``` Connect an event handler with the `on_change` prop: diff --git a/news/+moment-provider.feature.md b/news/+moment-provider.feature.md new file mode 100644 index 00000000000..63fad102ef3 --- /dev/null +++ b/news/+moment-provider.feature.md @@ -0,0 +1 @@ +Expose `rx.moment_provider` for sharing locale, timezone, formatting, and timer defaults across Moment components. 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 ed522e56c6a..4e6a80486a3 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/moment.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/moment.py @@ -60,8 +60,18 @@ class MomentProvider(Component): timezone: Var[str] = field(doc="The default timezone for descendant components.") def add_imports(self) -> ImportDict: - """Import the named provider export from react-moment.""" - return {"react-moment@2.0.2": ImportVar(tag="MomentProvider")} + """Import the provider and any modules required by its defaults.""" + imports: ImportDict = {"react-moment@2.0.2": ImportVar(tag="MomentProvider")} + + if isinstance(self.locale, LiteralVar): + if self.locale._var_value != "en": + imports[""] = f"moment/locale/{self.locale._var_value}" + elif self.locale is not None: + imports[""] = "moment/min/locales" + if self.timezone is not None: + imports["moment-timezone@0.6.5"] = "" + + return imports class Moment(NoSSRComponent, MemoizationLeaf): @@ -184,8 +194,7 @@ class Moment(NoSSRComponent, MemoizationLeaf): tz: Var[str] = field(doc="Display the date in the given timezone.") locale: Var[str] = field( - default=Var.create("en"), - doc="The locale for this component. Defaults to English independently of other Moment components.", + doc="The locale for this component. Defaults to the nearest MomentProvider locale or Moment's English default.", ) on_change: EventHandler[passthrough_event_spec(str)] = field( diff --git a/pyproject.toml b/pyproject.toml index 9520867e570..5bccb9bc28b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "reflex-components-gridjs >= 0.10.0", "reflex-components-lucide >= 1.1.0", "reflex-components-markdown >= 0.10.0", - "reflex-components-moment >= 0.10.0", + "reflex-components-moment >= 0.10.1.dev0", "reflex-components-plotly >= 0.10.0", "reflex-components-radix >= 0.10.1.dev0", "reflex-components-react-player >= 0.10.0", diff --git a/tests/units/reflex_components_moment/test_moment.py b/tests/units/reflex_components_moment/test_moment.py index 47139e40372..0131933892e 100644 --- a/tests/units/reflex_components_moment/test_moment.py +++ b/tests/units/reflex_components_moment/test_moment.py @@ -6,14 +6,21 @@ from reflex_components_moment.moment import Moment, MomentProvider -@pytest.mark.parametrize("props", [{}, {"locale": None}, {"locale": "en"}]) -def test_default_locale_is_explicit_english(props): - """Omitted and English locales do not inherit another component's locale. +@pytest.mark.parametrize("props", [{}, {"locale": None}]) +def test_default_locale_is_inherited(props): + """Omitted locales inherit from a provider and use Moment's default otherwise. Args: - props: The omitted or explicitly English locale configuration. + props: The omitted or unset locale configuration. """ moment = Moment.create("2024-03-14", **props) + assert "locale:" not in moment.render()["props"] + assert "moment/locale/en" not in moment.add_imports().values() + + +def test_explicit_english_locale_is_preserved(): + """An explicit English locale overrides a provider locale.""" + moment = Moment.create("2024-03-14", locale="en") assert 'locale:"en"' in moment.render()["props"] assert "moment/locale/en" not in moment.add_imports().values() @@ -103,5 +110,18 @@ def test_moment_provider_exports_named_component_and_shared_props(): assert 'format:"D MMM YYYY"' in props assert 'timezone:"America/Los_Angeles"' in props assert provider.add_imports() == { - "react-moment@2.0.2": ImportVar(tag="MomentProvider") + "react-moment@2.0.2": ImportVar(tag="MomentProvider"), + "": "moment/locale/fr", + "moment-timezone@0.6.5": "", + } + + +def test_moment_provider_reactive_locale_imports_all_locales(): + """A reactive provider locale includes the available locale modules.""" + locale = Var(_js_expr="selectedLocale", _var_type=str) + provider = MomentProvider.create(Moment.create("2024-03-14"), locale=locale) + + assert provider.add_imports() == { + "react-moment@2.0.2": ImportVar(tag="MomentProvider"), + "": "moment/min/locales", } From 68ee8307e183ab6c485fb57f81708fde48adeac1 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sun, 18 Oct 2026 12:29:52 +0530 Subject: [PATCH 3/7] fix: preserve Moment locale defaults with providers --- .../src/reflex_components_moment/__init__.py | 3 +- .../src/reflex_components_moment/moment.py | 42 ++++++++++++++++++- tests/integration/test_moment.py | 14 +++++++ .../reflex_components_moment/test_moment.py | 32 ++++++++++---- 4 files changed, 79 insertions(+), 12 deletions(-) diff --git a/packages/reflex-components-moment/src/reflex_components_moment/__init__.py b/packages/reflex-components-moment/src/reflex_components_moment/__init__.py index 65b12fd473c..4297187f803 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/__init__.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/__init__.py @@ -1,6 +1,7 @@ """Moment.js component.""" -from .moment import Moment, MomentDelta as MomentDelta, MomentProvider +from .moment import Moment, MomentProvider +from .moment import MomentDelta as MomentDelta moment = Moment.create moment_provider = MomentProvider.create 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 4e6a80486a3..af4bd17e11c 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/moment.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/moment.py @@ -2,11 +2,13 @@ from __future__ import annotations +import copy import dataclasses import datetime from typing import Any from reflex_base.components.component import ( + BaseComponent, Component, MemoizationLeaf, NoSSRComponent, @@ -59,8 +61,29 @@ class MomentProvider(Component): element: Var[str] = field(doc="The default HTML element for descendant components.") timezone: Var[str] = field(doc="The default timezone for descendant components.") + @classmethod + def create(cls, *children, **props) -> MomentProvider: + """Create a provider whose implicit Moment locales inherit its context. + + Args: + *children: The components rendered inside the provider. + **props: The provider props. + + Returns: + The provider with implicit child locales unset for React inheritance. + """ + provider = super().create(*children, **props) + provider.children = [ + _inherit_provider_locale(child) for child in provider.children + ] + return provider + def add_imports(self) -> ImportDict: - """Import the provider and any modules required by its defaults.""" + """Import the provider and modules required by its defaults. + + Returns: + Imports for the provider, locale, and timezone modules. + """ imports: ImportDict = {"react-moment@2.0.2": ImportVar(tag="MomentProvider")} if isinstance(self.locale, LiteralVar): @@ -74,6 +97,20 @@ def add_imports(self) -> ImportDict: return imports +def _inherit_provider_locale(component: BaseComponent) -> BaseComponent: + """Clone a child tree and let implicit Moment locales inherit the provider.""" + if not isinstance(component, Component): + return component + child = copy.copy(component) + if isinstance(child, Moment) and "locale" not in vars(child): + child.locale = None + child.children = [ + _inherit_provider_locale(nested) if isinstance(nested, Component) else nested + for nested in component.children + ] + return child + + class Moment(NoSSRComponent, MemoizationLeaf): """The Moment component.""" @@ -194,7 +231,8 @@ class Moment(NoSSRComponent, MemoizationLeaf): tz: Var[str] = field(doc="Display the date in the given timezone.") locale: Var[str] = field( - doc="The locale for this component. Defaults to the nearest MomentProvider locale or Moment's English default.", + default=Var.create("en"), + doc="The locale for this component. Defaults to the nearest MomentProvider locale or English.", ) on_change: EventHandler[passthrough_event_spec(str)] = field( diff --git a/tests/integration/test_moment.py b/tests/integration/test_moment.py index 6dc5655a9e4..c888d80094a 100644 --- a/tests/integration/test_moment.py +++ b/tests/integration/test_moment.py @@ -50,6 +50,14 @@ def index(): locale="fr", id="moment-french", ), + rx.moment_provider( + rx.moment( + "2024-03-14T15:09:26", + format="dddd D MMMM YYYY", + id="moment-provider-locale", + ), + locale="fr", + ), rx.link("Plain page", href="/plain", id="plain-link"), ) @@ -133,6 +141,12 @@ def test_moment_locales_are_isolated(driver: WebDriver) -> None: AppHarness.expect( lambda: driver.find_element(By.ID, "moment-french").text == "jeudi 14 mars 2024" ) + AppHarness.expect( + lambda: ( + driver.find_element(By.ID, "moment-provider-locale").text + == "jeudi 14 mars 2024" + ) + ) driver.find_element(By.ID, "plain-link").click() AppHarness.expect( lambda: ( diff --git a/tests/units/reflex_components_moment/test_moment.py b/tests/units/reflex_components_moment/test_moment.py index 0131933892e..d5611934cfd 100644 --- a/tests/units/reflex_components_moment/test_moment.py +++ b/tests/units/reflex_components_moment/test_moment.py @@ -6,18 +6,32 @@ from reflex_components_moment.moment import Moment, MomentProvider -@pytest.mark.parametrize("props", [{}, {"locale": None}]) -def test_default_locale_is_inherited(props): - """Omitted locales inherit from a provider and use Moment's default otherwise. - - Args: - props: The omitted or unset locale configuration. - """ - moment = Moment.create("2024-03-14", **props) - assert "locale:" not in moment.render()["props"] +def test_default_locale_is_english_outside_provider(): + """Moments outside providers keep an explicit English fallback.""" + moment = Moment.create("2024-03-14") + assert 'locale:"en"' in moment.render()["props"] assert "moment/locale/en" not in moment.add_imports().values() +def test_implicit_child_locale_is_inherited_from_provider(): + """Provider construction leaves implicit child locales to React context.""" + original = Moment.create("2024-03-14") + provider = MomentProvider.create(original, locale="fr") + child = provider.children[0] + + assert not any(prop.startswith("locale:") for prop in child.render()["props"]) + assert 'locale:"en"' in original.render()["props"] + + +def test_explicit_child_locale_overrides_provider(): + """An explicitly configured child locale takes precedence over the provider.""" + provider = MomentProvider.create( + Moment.create("2024-03-14", locale="en"), locale="fr" + ) + + assert 'locale:"en"' in provider.children[0].render()["props"] + + def test_explicit_english_locale_is_preserved(): """An explicit English locale overrides a provider locale.""" moment = Moment.create("2024-03-14", locale="en") From deea560276e2a74b4596df57aaf88dcc80f4bf62 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sun, 18 Oct 2026 13:49:14 +0530 Subject: [PATCH 4/7] fix: inherit Moment locales through providers --- .../src/reflex_components_moment/moment.py | 44 ++++--------------- tests/integration/test_moment.py | 25 +++++++++-- .../reflex_components_moment/test_moment.py | 33 ++++++-------- 3 files changed, 43 insertions(+), 59 deletions(-) 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 af4bd17e11c..9e6b535389d 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/moment.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/moment.py @@ -2,13 +2,11 @@ from __future__ import annotations -import copy import dataclasses import datetime from typing import Any from reflex_base.components.component import ( - BaseComponent, Component, MemoizationLeaf, NoSSRComponent, @@ -61,23 +59,6 @@ class MomentProvider(Component): element: Var[str] = field(doc="The default HTML element for descendant components.") timezone: Var[str] = field(doc="The default timezone for descendant components.") - @classmethod - def create(cls, *children, **props) -> MomentProvider: - """Create a provider whose implicit Moment locales inherit its context. - - Args: - *children: The components rendered inside the provider. - **props: The provider props. - - Returns: - The provider with implicit child locales unset for React inheritance. - """ - provider = super().create(*children, **props) - provider.children = [ - _inherit_provider_locale(child) for child in provider.children - ] - return provider - def add_imports(self) -> ImportDict: """Import the provider and modules required by its defaults. @@ -97,20 +78,6 @@ def add_imports(self) -> ImportDict: return imports -def _inherit_provider_locale(component: BaseComponent) -> BaseComponent: - """Clone a child tree and let implicit Moment locales inherit the provider.""" - if not isinstance(component, Component): - return component - child = copy.copy(component) - if isinstance(child, Moment) and "locale" not in vars(child): - child.locale = None - child.children = [ - _inherit_provider_locale(nested) if isinstance(nested, Component) else nested - for nested in component.children - ] - return child - - class Moment(NoSSRComponent, MemoizationLeaf): """The Moment component.""" @@ -231,7 +198,6 @@ class Moment(NoSSRComponent, MemoizationLeaf): tz: Var[str] = field(doc="Display the date in the given timezone.") locale: Var[str] = field( - default=Var.create("en"), doc="The locale for this component. Defaults to the nearest MomentProvider locale or English.", ) @@ -270,7 +236,7 @@ def add_imports(self) -> ImportDict: Returns: The import dict for the component. """ - imports = {} + imports = {"moment@2.31.0": ImportVar(tag="moment", is_default=True)} if isinstance(self.locale, LiteralVar): # English is built into Moment and has no separate locale module. @@ -286,3 +252,11 @@ def add_imports(self) -> ImportDict: imports["moment-duration-format@2.3.2"] = ImportVar(tag=None) return imports + + def add_custom_code(self) -> list[str]: + """Set Moment's global fallback locale after locale modules are loaded. + + Returns: + Module-level code that keeps unprovided Moment instances in English. + """ + return ['moment.locale("en");'] diff --git a/tests/integration/test_moment.py b/tests/integration/test_moment.py index c888d80094a..796c6ffd29a 100644 --- a/tests/integration/test_moment.py +++ b/tests/integration/test_moment.py @@ -18,6 +18,14 @@ def MomentApp(): class State(rx.State): """State used to wait for the browser connection before asserting output.""" + @rx.memo + def memoized_provider_moment(): + return rx.moment( + "2024-03-14T15:09:26", + format="dddd D MMMM YYYY", + id="moment-provider-memo-locale", + ) + @app.add_page def index(): return rx.vstack( @@ -51,11 +59,14 @@ def index(): id="moment-french", ), rx.moment_provider( - rx.moment( - "2024-03-14T15:09:26", - format="dddd D MMMM YYYY", - id="moment-provider-locale", + rx.box( + rx.moment( + "2024-03-14T15:09:26", + format="dddd D MMMM YYYY", + id="moment-provider-locale", + ) ), + memoized_provider_moment(), locale="fr", ), rx.link("Plain page", href="/plain", id="plain-link"), @@ -147,6 +158,12 @@ def test_moment_locales_are_isolated(driver: WebDriver) -> None: == "jeudi 14 mars 2024" ) ) + AppHarness.expect( + lambda: ( + driver.find_element(By.ID, "moment-provider-memo-locale").text + == "jeudi 14 mars 2024" + ) + ) driver.find_element(By.ID, "plain-link").click() AppHarness.expect( lambda: ( diff --git a/tests/units/reflex_components_moment/test_moment.py b/tests/units/reflex_components_moment/test_moment.py index d5611934cfd..6b6596e5b00 100644 --- a/tests/units/reflex_components_moment/test_moment.py +++ b/tests/units/reflex_components_moment/test_moment.py @@ -6,25 +6,23 @@ from reflex_components_moment.moment import Moment, MomentProvider -def test_default_locale_is_english_outside_provider(): - """Moments outside providers keep an explicit English fallback.""" +def test_default_locale_is_inherited_or_english_fallback(): + """Omitted locales inherit provider context and fall back to English.""" moment = Moment.create("2024-03-14") - assert 'locale:"en"' in moment.render()["props"] + assert not any(prop.startswith("locale:") for prop in moment.render()["props"]) assert "moment/locale/en" not in moment.add_imports().values() + assert moment.add_custom_code() == ['moment.locale("en");'] -def test_implicit_child_locale_is_inherited_from_provider(): - """Provider construction leaves implicit child locales to React context.""" - original = Moment.create("2024-03-14") - provider = MomentProvider.create(original, locale="fr") - child = provider.children[0] - - assert not any(prop.startswith("locale:") for prop in child.render()["props"]) - assert 'locale:"en"' in original.render()["props"] +def test_explicit_english_locale_is_preserved(): + """An explicit English locale overrides a provider locale.""" + moment = Moment.create("2024-03-14", locale="en") + assert 'locale:"en"' in moment.render()["props"] + assert "moment/locale/en" not in moment.add_imports().values() def test_explicit_child_locale_overrides_provider(): - """An explicitly configured child locale takes precedence over the provider.""" + """An explicit child locale remains a per-instance provider override.""" provider = MomentProvider.create( Moment.create("2024-03-14", locale="en"), locale="fr" ) @@ -32,18 +30,13 @@ def test_explicit_child_locale_overrides_provider(): assert 'locale:"en"' in provider.children[0].render()["props"] -def test_explicit_english_locale_is_preserved(): - """An explicit English locale overrides a provider locale.""" - moment = Moment.create("2024-03-14", locale="en") - assert 'locale:"en"' in moment.render()["props"] - assert "moment/locale/en" not in moment.add_imports().values() - - def test_literal_locale_import_is_preserved(): """A component that requests French still imports and renders that locale.""" moment = Moment.create("2024-03-14", locale="fr") assert 'locale:"fr"' in moment.render()["props"] - assert moment.add_imports()[""] == "moment/locale/fr" + imports = moment.add_imports() + assert imports[""] == "moment/locale/fr" + assert imports["moment@2.31.0"] == ImportVar(tag="moment", is_default=True) def test_reactive_locale_imports_all_locales(): From 7a5aa7f8caed117cd786cef0901b97a2ded0c754 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sun, 18 Oct 2026 15:17:20 +0530 Subject: [PATCH 5/7] fix: share Moment locale import handling --- .../src/reflex_components_moment/moment.py | 41 +++++++++---------- pyi_hashes.json | 4 +- 2 files changed, 22 insertions(+), 23 deletions(-) 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 9e6b535389d..7a0c3e258fa 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/moment.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/moment.py @@ -32,6 +32,21 @@ class MomentDelta: milliseconds: int | None = dataclasses.field(default=None) +def _locale_timezone_imports( + locale: Var[str] | None, timezone: Var[str] | None +) -> ImportDict: + """Return the locale and timezone imports required by Moment props.""" + imports: ImportDict = {} + if isinstance(locale, LiteralVar): + if locale._var_value != "en": + imports[""] = f"moment/locale/{locale._var_value}" + elif locale is not None: + imports[""] = "moment/min/locales" + if timezone is not None: + imports["moment-timezone@0.6.5"] = "" + return imports + + class MomentProvider(Component): """Provide shared defaults and timer pooling for descendant Moment components.""" @@ -66,15 +81,7 @@ def add_imports(self) -> ImportDict: Imports for the provider, locale, and timezone modules. """ imports: ImportDict = {"react-moment@2.0.2": ImportVar(tag="MomentProvider")} - - if isinstance(self.locale, LiteralVar): - if self.locale._var_value != "en": - imports[""] = f"moment/locale/{self.locale._var_value}" - elif self.locale is not None: - imports[""] = "moment/min/locales" - if self.timezone is not None: - imports["moment-timezone@0.6.5"] = "" - + imports.update(_locale_timezone_imports(self.locale, self.timezone)) return imports @@ -236,18 +243,10 @@ def add_imports(self) -> ImportDict: Returns: The import dict for the component. """ - imports = {"moment@2.31.0": ImportVar(tag="moment", is_default=True)} - - if isinstance(self.locale, LiteralVar): - # English is built into Moment and has no separate locale module. - if self.locale._var_value != "en": - imports[""] = f"moment/locale/{self.locale._var_value}" - elif self.locale is not None: - # If the user is using a variable for the locale, we can't know the - # value at compile time so import all locales available. - imports[""] = "moment/min/locales" - if self.tz is not None: - imports["moment-timezone@0.6.5"] = "" + imports: ImportDict = { + "moment@2.31.0": ImportVar(tag="moment", is_default=True) + } + imports.update(_locale_timezone_imports(self.locale, self.tz)) if self.duration is not None or self.duration_from_now is not None: imports["moment-duration-format@2.3.2"] = ImportVar(tag=None) diff --git a/pyi_hashes.json b/pyi_hashes.json index 7b6975b20d9..4d6a4add0ba 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": "6759daf6e242e209d9298045c7b4456a", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "268a52ddad6795f6141083445c87de3e", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "6258b087942ce9147501d47b97cafa84", "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "f411341e59a3065fee964747584f9fcf", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "a77352f60fb6f4135b5d08a6e56efa6d", "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "bbd4d1a4fa73275a882c33ba485d0165", @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "f2a9cf6db58d169289fbc76de6813300", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", - "reflex/__init__.pyi": "c92b5910a4603dfd17590a33cd434405", + "reflex/__init__.pyi": "23c090b0294d3a91e59f447f36a7a37f", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", "reflex/experimental/memo.pyi": "87e485038dc79f66b2ab15144845ef1e" } From 7742fe498975518486559e05819f512e406393cb Mon Sep 17 00:00:00 2001 From: Farhan Date: Thu, 22 Oct 2026 22:34:45 +0500 Subject: [PATCH 6/7] fix(moment): default to English through a root MomentProvider Moment components without a locale fall back to moment's global locale, which every locale module import changes. A module-level reset depends on import order: a page whose moments compile into other modules imports moment/locale/fr after every reset and leaks French into later routes. Wrap the app in MomentProvider(locale="en") instead, so an omitted locale reads the nearest provider. Drop the global reset and its moment import, the create() special cases (filter is a valid CSS prop), the redundant from_ rename and provider import, and the provider's moment prop. --- docs/library/data-display/moment.md | 55 +++++++++------ .../news/+7100.bugfix.md | 2 +- .../src/reflex_components_moment/moment.py | 67 +++++++------------ pyi_hashes.json | 2 +- tests/integration/test_moment.py | 49 ++++++++++++++ .../reflex_components_moment/test_moment.py | 58 ++++++++-------- 6 files changed, 136 insertions(+), 97 deletions(-) diff --git a/docs/library/data-display/moment.md b/docs/library/data-display/moment.md index 9e45dd47a82..94e7ee26ef2 100644 --- a/docs/library/data-display/moment.md +++ b/docs/library/data-display/moment.md @@ -185,6 +185,40 @@ rx.vstack( ) ``` +### Shared defaults + +Wrap components in `rx.moment_provider` to give them a shared `locale`, +`format`, `timezone`, `local`, `parse`, `element`, `filters`, or `renderers` +default. A prop set on a component overrides the provider. Components outside +the provider keep English. Set `pool=True` to update all descendants from one +timer that ticks every `interval` milliseconds. + +```python demo exec +def moment_provider_example(): + return rx.moment_provider( + rx.moment("2024-03-14"), + rx.moment("2024-03-14", locale="en"), + locale="fr", + format="D MMM YYYY", + timezone="Europe/Paris", + ) +``` + +### Additional react-moment 2.x options + +`rx.moment` also supports the `utc`, `calendar`, `from_`, `to`, `ago`, `element`, +`fallback`, `filters`, and `renderers` options from `react-moment` 2.x. In Python, +`from_` maps to the JavaScript `from` prop. `filters` and `renderers` accept +JavaScript function values: + +```python +rx.moment( + "2024-03-14", + format="MMMM", + filters=[rx.Var("(content) => content.toUpperCase()")], +) +``` + ### Timezones You can also set dates to display in a specific timezone: @@ -214,27 +248,6 @@ It fires again when the component remounts, such as after a reload or route navi React Strict Mode can invoke the mount event twice in development, so handlers with side effects should account for repeated calls. -### Additional react-moment 2.x options - -`rx.moment` also supports the `utc`, `calendar`, `from_`, `to`, `ago`, `element`, -`fallback`, `filters`, and `renderers` options from `react-moment` 2.x. In Python, -`from_` maps to the JavaScript `from` prop. `filters` and `renderers` accept -JavaScript function values. - -The old singular `filter` prop was removed by `react-moment` 2.x; use `filters` -instead. Shared defaults are configured with `rx.moment_provider`, not a -`settings` prop on `rx.moment`: - -```python demo exec -def moment_provider_example(): - return rx.moment_provider( - rx.moment("2024-03-14"), - locale="fr", - format="D MMM YYYY", - timezone="Europe/Paris", - ) -``` - Connect an event handler with the `on_change` prop: ```python demo exec diff --git a/packages/reflex-components-moment/news/+7100.bugfix.md b/packages/reflex-components-moment/news/+7100.bugfix.md index 05a9a2b8fe0..9f27b68ca6e 100644 --- a/packages/reflex-components-moment/news/+7100.bugfix.md +++ b/packages/reflex-components-moment/news/+7100.bugfix.md @@ -1 +1 @@ -Expose the supported `react-moment` 2.x options through `rx.moment`, add `rx.moment_provider` for shared defaults and pooled timers, and report the removed `filter` and unsupported `settings` props instead of treating them as CSS. +Expose the `utc`, `calendar`, `from_`, `to`, `ago`, `element`, `fallback`, `filters`, and `renderers` options of `react-moment` 2.x, which previously rendered as CSS, and add `moment_provider` for shared defaults and pooled timers. 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 7a0c3e258fa..dea038f03f8 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/moment.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/moment.py @@ -35,7 +35,15 @@ class MomentDelta: def _locale_timezone_imports( locale: Var[str] | None, timezone: Var[str] | None ) -> ImportDict: - """Return the locale and timezone imports required by Moment props.""" + """Return the locale and timezone imports required by Moment props. + + Args: + locale: The locale prop, if set. + timezone: The timezone prop, if set. + + Returns: + The locale and timezone module imports. + """ imports: ImportDict = {} if isinstance(locale, LiteralVar): if locale._var_value != "en": @@ -56,9 +64,6 @@ class MomentProvider(Component): pool: Var[bool] = field(doc="Use one timer for all descendant Moment components.") interval: Var[int] = field(doc="The interval in milliseconds for the pooled timer.") - moment: Var[Any] = field( - doc="The moment.js instance used by descendant components." - ) locale: Var[str] = field(doc="The default locale for descendant Moment components.") local: Var[bool] = field(doc="Display descendant dates in local time by default.") format: Var[str] = field(doc="The default date format for descendants.") @@ -75,14 +80,12 @@ class MomentProvider(Component): timezone: Var[str] = field(doc="The default timezone for descendant components.") def add_imports(self) -> ImportDict: - """Import the provider and modules required by its defaults. + """Import the locale and timezone modules required by the defaults. Returns: - Imports for the provider, locale, and timezone modules. + The locale and timezone module imports. """ - imports: ImportDict = {"react-moment@2.0.2": ImportVar(tag="MomentProvider")} - imports.update(_locale_timezone_imports(self.locale, self.timezone)) - return imports + return _locale_timezone_imports(self.locale, self.timezone) class Moment(NoSSRComponent, MemoizationLeaf): @@ -90,7 +93,6 @@ class Moment(NoSSRComponent, MemoizationLeaf): tag: str | None = "Moment" is_default = True - _rename_props = {"from_": "from"} library: str | None = "react-moment@2.0.2" lib_dependencies: list[str] = ["moment@2.31.0"] @@ -205,57 +207,34 @@ class Moment(NoSSRComponent, MemoizationLeaf): tz: Var[str] = field(doc="Display the date in the given timezone.") locale: Var[str] = field( - doc="The locale for this component. Defaults to the nearest MomentProvider locale or English.", + doc="The locale for this component. Defaults to the nearest rx.moment_provider locale, or English.", ) on_change: EventHandler[passthrough_event_spec(str)] = field( doc="Fires when the component mounts and when the date changes, including when interval is 0. React Strict Mode can invoke the mount event twice in development." ) - @classmethod - def create(cls, *children, **props) -> Moment: - """Create a Moment component. - - Args: - *children: The date value to render. - **props: The props passed to the component. - - Returns: - The Moment component. - - Raises: - TypeError: If a removed or unsupported configuration prop is used. - """ - if "filter" in props: - msg = "The `filter` prop was removed in react-moment 2.x; use `filters` instead." - raise TypeError(msg) - if "form_at" in props: - msg = "`form_at` is not a react-moment prop; use `format` to format the date or `from_` to show a relative interval." - raise TypeError(msg) - if "settings" in props: - msg = "`settings` is not a react-moment 2.x prop; use rx.moment_provider(...) with shared defaults or set props on each Moment." - raise TypeError(msg) - return super().create(*children, **props) - def add_imports(self) -> ImportDict: """Add the imports for the Moment component. Returns: The import dict for the component. """ - imports: ImportDict = { - "moment@2.31.0": ImportVar(tag="moment", is_default=True) - } - imports.update(_locale_timezone_imports(self.locale, self.tz)) + imports = _locale_timezone_imports(self.locale, self.tz) if self.duration is not None or self.duration_from_now is not None: imports["moment-duration-format@2.3.2"] = ImportVar(tag=None) return imports - def add_custom_code(self) -> list[str]: - """Set Moment's global fallback locale after locale modules are loaded. + @staticmethod + def _get_app_wrap_components() -> dict[tuple[int, str], Component]: + """Wrap the app in an English MomentProvider. + + Moment components without a locale read it from the nearest provider, + so they never fall back to moment's global locale, which every locale + module import changes. Returns: - Module-level code that keeps unprovided Moment instances in English. + The root MomentProvider. """ - return ['moment.locale("en");'] + return {(10, "MomentProvider"): MomentProvider.create(locale="en")} diff --git a/pyi_hashes.json b/pyi_hashes.json index 4d6a4add0ba..400a6c7afa6 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": "6759daf6e242e209d9298045c7b4456a", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "6258b087942ce9147501d47b97cafa84", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "597a80bee48e37c23fcdd9d5a5388382", "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "f411341e59a3065fee964747584f9fcf", "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 index 796c6ffd29a..c289e25e360 100644 --- a/tests/integration/test_moment.py +++ b/tests/integration/test_moment.py @@ -86,9 +86,22 @@ def plain(): ), rx.moment("2020-01-01", from_now=True, id="plain-relative"), rx.link("Home", href="/", id="home-link"), + rx.link("Provider page", href="/provider", id="provider-link"), + ) + + def provider(): + """Render a provider locale whose moments compile outside the page module. + + Returns: + The page that loads a foreign locale only through a provider. + """ + return rx.vstack( + rx.moment_provider(memoized_provider_moment(), locale="fr"), + rx.link("Plain page", href="/plain", id="provider-plain-link"), ) app.add_page(plain, route="/plain") + app.add_page(provider, route="/provider") @pytest.fixture(scope="module") @@ -187,3 +200,39 @@ def test_moment_locales_are_isolated(driver: WebDriver) -> None: == "Thursday 14 March 2024" ) ) + + +def test_moment_provider_locale_does_not_leak( + moment_app: AppHarness, driver: WebDriver +) -> None: + """A provider locale stays inside the provider across client-side navigation. + + The page load starts on a route that has not loaded the French locale module. + + Args: + moment_app: The running Moment app harness. + driver: The browser connected to the Moment app. + """ + assert moment_app.frontend_url is not None + driver.get(moment_app.frontend_url.rstrip("/") + "/plain") + AppHarness.expect( + lambda: ( + driver.find_element(By.ID, "plain-date").text == "Thursday 14 March 2024" + ) + ) + driver.find_element(By.ID, "provider-link").click() + AppHarness.expect( + lambda: ( + driver.find_element(By.ID, "moment-provider-memo-locale").text + == "jeudi 14 mars 2024" + ) + ) + driver.find_element(By.ID, "provider-plain-link").click() + AppHarness.expect( + lambda: ( + driver.find_element(By.ID, "plain-date").text == "Thursday 14 March 2024" + ) + ) + AppHarness.expect( + lambda: driver.find_element(By.ID, "plain-relative").text.endswith("ago") + ) diff --git a/tests/units/reflex_components_moment/test_moment.py b/tests/units/reflex_components_moment/test_moment.py index 6b6596e5b00..253a4c0eb45 100644 --- a/tests/units/reflex_components_moment/test_moment.py +++ b/tests/units/reflex_components_moment/test_moment.py @@ -1,17 +1,31 @@ """Tests for per-component Moment locale configuration.""" -import pytest from reflex_base.utils.imports import ImportVar from reflex_base.vars.base import Var from reflex_components_moment.moment import Moment, MomentProvider -def test_default_locale_is_inherited_or_english_fallback(): - """Omitted locales inherit provider context and fall back to English.""" +def test_default_locale_is_inherited_from_root_english_provider(): + """Omitted locales read the nearest provider; the app root provides English.""" moment = Moment.create("2024-03-14") assert not any(prop.startswith("locale:") for prop in moment.render()["props"]) assert "moment/locale/en" not in moment.add_imports().values() - assert moment.add_custom_code() == ['moment.locale("en");'] + + root = moment._get_all_app_wrap_components()[10, "MomentProvider"] + assert isinstance(root, MomentProvider) + assert root.render()["props"] == ['locale:"en"'] + assert root.add_imports() == {} + + +def test_moment_in_memo_requests_root_provider(): + """A Moment inside rx.memo still wraps the app in the English provider.""" + import reflex as rx + + @rx.memo + def memo_moment() -> rx.Component: + return rx.moment("2024-03-14") + + assert (10, "MomentProvider") in memo_moment()._get_all_app_wrap_components() def test_explicit_english_locale_is_preserved(): @@ -34,9 +48,7 @@ def test_literal_locale_import_is_preserved(): """A component that requests French still imports and renders that locale.""" moment = Moment.create("2024-03-14", locale="fr") assert 'locale:"fr"' in moment.render()["props"] - imports = moment.add_imports() - assert imports[""] == "moment/locale/fr" - assert imports["moment@2.31.0"] == ImportVar(tag="moment", is_default=True) + assert moment.add_imports()[""] == "moment/locale/fr" def test_reactive_locale_imports_all_locales(): @@ -75,24 +87,10 @@ def test_react_moment_2_props_are_forwarded(): assert "css:" not in props -def test_removed_filter_prop_has_migration_error(): - """The removed singular filter prop points users to the 2.x replacement.""" - with pytest.raises(TypeError, match=r"filter.*removed.*filters"): - Moment.create("2024-03-14", filter="uppercase") - - -def test_invalid_format_typo_has_actionable_error(): - """A reported typo is not silently emitted as a CSS style.""" - with pytest.raises(TypeError, match=r"form_at.*not a react-moment prop.*format"): - Moment.create("2024-03-14", form_at="YYYY") - - -def test_settings_prop_has_configuration_error(): - """Shared settings point users to the supported provider API.""" - with pytest.raises( - TypeError, match=r"settings.*not a react-moment 2.x prop.*rx.moment_provider" - ): - Moment.create("2024-03-14", settings={"format": "YYYY"}) +def test_undeclared_props_stay_css(): + """Props that react-moment does not accept keep the generic CSS behavior.""" + moment = Moment.create("2024-03-14", filter="blur(2px)") + assert 'css:({ ["filter"] : "blur(2px)" })' in moment.render()["props"] def test_moment_provider_exports_named_component_and_shared_props(): @@ -117,10 +115,13 @@ def test_moment_provider_exports_named_component_and_shared_props(): assert 'format:"D MMM YYYY"' in props assert 'timezone:"America/Los_Angeles"' in props assert provider.add_imports() == { - "react-moment@2.0.2": ImportVar(tag="MomentProvider"), "": "moment/locale/fr", "moment-timezone@0.6.5": "", } + assert ( + ImportVar(tag="MomentProvider") + in provider._get_all_imports()["react-moment@2.0.2"] + ) def test_moment_provider_reactive_locale_imports_all_locales(): @@ -128,7 +129,4 @@ def test_moment_provider_reactive_locale_imports_all_locales(): locale = Var(_js_expr="selectedLocale", _var_type=str) provider = MomentProvider.create(Moment.create("2024-03-14"), locale=locale) - assert provider.add_imports() == { - "react-moment@2.0.2": ImportVar(tag="MomentProvider"), - "": "moment/min/locales", - } + assert provider.add_imports() == {"": "moment/min/locales"} From a1520674183f58dabb03fe610a7b499a96b6609e Mon Sep 17 00:00:00 2001 From: Farhan Date: Fri, 23 Oct 2026 00:17:20 +0500 Subject: [PATCH 7/7] fix(moment): reset the global locale where a locale is imported The root MomentProvider imported react-moment and moment from root.jsx, which added 23 KB gzip to the initial bundle of every app that uses rx.moment and failed the size budget. Locale modules set moment's global locale when they load, so each module that imports one now resets it to English, and moment stays lazily loaded. --- .../src/reflex_components_moment/moment.py | 63 ++++++++++++----- pyi_hashes.json | 2 +- .../reflex_components_moment/test_moment.py | 67 ++++++++++--------- 3 files changed, 83 insertions(+), 49 deletions(-) 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 dea038f03f8..a1ae5069277 100644 --- a/packages/reflex-components-moment/src/reflex_components_moment/moment.py +++ b/packages/reflex-components-moment/src/reflex_components_moment/moment.py @@ -32,6 +32,22 @@ class MomentDelta: milliseconds: int | None = dataclasses.field(default=None) +def _locale_module(locale: Var[str] | None) -> str | None: + """Return the moment locale module that a locale prop requires. + + Args: + locale: The locale prop, if set. + + Returns: + The locale module, or None when the built-in English locale suffices. + """ + if isinstance(locale, LiteralVar): + if locale._var_value == "en": + return None + return f"moment/locale/{locale._var_value}" + return None if locale is None else "moment/min/locales" + + def _locale_timezone_imports( locale: Var[str] | None, timezone: Var[str] | None ) -> ImportDict: @@ -42,19 +58,33 @@ def _locale_timezone_imports( timezone: The timezone prop, if set. Returns: - The locale and timezone module imports. + The locale and timezone module imports, plus moment itself for + _restore_global_locale when a locale module is imported. """ imports: ImportDict = {} - if isinstance(locale, LiteralVar): - if locale._var_value != "en": - imports[""] = f"moment/locale/{locale._var_value}" - elif locale is not None: - imports[""] = "moment/min/locales" + if (module := _locale_module(locale)) is not None: + imports[""] = module + imports["moment@2.31.0"] = ImportVar(tag="moment", is_default=True) if timezone is not None: imports["moment-timezone@0.6.5"] = "" return imports +def _restore_global_locale(locale: Var[str] | None) -> list[str]: + """Return code that resets moment's global locale after a locale import. + + Importing a locale module also makes it moment's global locale, which + Moment components without a locale or provider use. + + Args: + locale: The locale prop, if set. + + Returns: + The reset code, or nothing when no locale module is imported. + """ + return [] if _locale_module(locale) is None else ['moment.locale("en");'] + + class MomentProvider(Component): """Provide shared defaults and timer pooling for descendant Moment components.""" @@ -87,6 +117,14 @@ def add_imports(self) -> ImportDict: """ return _locale_timezone_imports(self.locale, self.timezone) + def add_custom_code(self) -> list[str]: + """Keep moment's global locale English after the locale import. + + Returns: + The global locale reset, if a locale module is imported. + """ + return _restore_global_locale(self.locale) + class Moment(NoSSRComponent, MemoizationLeaf): """The Moment component.""" @@ -226,15 +264,10 @@ def add_imports(self) -> ImportDict: return imports - @staticmethod - def _get_app_wrap_components() -> dict[tuple[int, str], Component]: - """Wrap the app in an English MomentProvider. - - Moment components without a locale read it from the nearest provider, - so they never fall back to moment's global locale, which every locale - module import changes. + def add_custom_code(self) -> list[str]: + """Keep moment's global locale English after the locale import. Returns: - The root MomentProvider. + The global locale reset, if a locale module is imported. """ - return {(10, "MomentProvider"): MomentProvider.create(locale="en")} + return _restore_global_locale(self.locale) diff --git a/pyi_hashes.json b/pyi_hashes.json index 400a6c7afa6..81e75bf7e19 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": "6759daf6e242e209d9298045c7b4456a", - "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "597a80bee48e37c23fcdd9d5a5388382", + "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "21c3396cb28b1ef6442c5a9588dc708b", "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "f411341e59a3065fee964747584f9fcf", "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/units/reflex_components_moment/test_moment.py b/tests/units/reflex_components_moment/test_moment.py index 253a4c0eb45..ffeb464f020 100644 --- a/tests/units/reflex_components_moment/test_moment.py +++ b/tests/units/reflex_components_moment/test_moment.py @@ -1,31 +1,41 @@ """Tests for per-component Moment locale configuration.""" +import pytest from reflex_base.utils.imports import ImportVar from reflex_base.vars.base import Var from reflex_components_moment.moment import Moment, MomentProvider +MOMENT_IMPORT = {"moment@2.31.0": ImportVar(tag="moment", is_default=True)} +RESET = ['moment.locale("en");'] -def test_default_locale_is_inherited_from_root_english_provider(): - """Omitted locales read the nearest provider; the app root provides English.""" + +def test_default_locale_keeps_moment_lazy(): + """An omitted locale adds no eager moment import, reset, or app wrap.""" moment = Moment.create("2024-03-14") assert not any(prop.startswith("locale:") for prop in moment.render()["props"]) - assert "moment/locale/en" not in moment.add_imports().values() - - root = moment._get_all_app_wrap_components()[10, "MomentProvider"] - assert isinstance(root, MomentProvider) - assert root.render()["props"] == ['locale:"en"'] - assert root.add_imports() == {} - - -def test_moment_in_memo_requests_root_provider(): - """A Moment inside rx.memo still wraps the app in the English provider.""" - import reflex as rx - - @rx.memo - def memo_moment() -> rx.Component: - return rx.moment("2024-03-14") - - assert (10, "MomentProvider") in memo_moment()._get_all_app_wrap_components() + assert moment.add_imports() == {} + assert moment.add_custom_code() == [] + assert moment._get_all_app_wrap_components() == {} + + +@pytest.mark.parametrize( + ("component", "locale", "module"), + [ + (Moment, "fr", "moment/locale/fr"), + (MomentProvider, "fr", "moment/locale/fr"), + (Moment, Var(_js_expr="selectedLocale", _var_type=str), "moment/min/locales"), + ( + MomentProvider, + Var(_js_expr="selectedLocale", _var_type=str), + "moment/min/locales", + ), + ], +) +def test_locale_import_restores_global_locale(component, locale, module): + """Every locale import resets the global locale that it changes to English.""" + instance = component.create("2024-03-14", locale=locale) + assert instance.add_imports() == {"": module, **MOMENT_IMPORT} + assert instance.add_custom_code() == RESET def test_explicit_english_locale_is_preserved(): @@ -44,19 +54,17 @@ def test_explicit_child_locale_overrides_provider(): assert 'locale:"en"' in provider.children[0].render()["props"] -def test_literal_locale_import_is_preserved(): - """A component that requests French still imports and renders that locale.""" +def test_literal_locale_is_rendered(): + """A component that requests French renders that locale.""" moment = Moment.create("2024-03-14", locale="fr") assert 'locale:"fr"' in moment.render()["props"] - assert moment.add_imports()[""] == "moment/locale/fr" -def test_reactive_locale_imports_all_locales(): - """Reactive locale selection remains available independently of the default.""" +def test_reactive_locale_is_rendered(): + """Reactive locale selection is forwarded to React.""" locale = Var(_js_expr="selectedLocale", _var_type=str) moment = Moment.create("2024-03-14", locale=locale) assert "locale:selectedLocale" in moment.render()["props"] - assert moment.add_imports()[""] == "moment/min/locales" def test_react_moment_2_props_are_forwarded(): @@ -116,17 +124,10 @@ def test_moment_provider_exports_named_component_and_shared_props(): assert 'timezone:"America/Los_Angeles"' in props assert provider.add_imports() == { "": "moment/locale/fr", + **MOMENT_IMPORT, "moment-timezone@0.6.5": "", } assert ( ImportVar(tag="MomentProvider") in provider._get_all_imports()["react-moment@2.0.2"] ) - - -def test_moment_provider_reactive_locale_imports_all_locales(): - """A reactive provider locale includes the available locale modules.""" - locale = Var(_js_expr="selectedLocale", _var_type=str) - provider = MomentProvider.create(Moment.create("2024-03-14"), locale=locale) - - assert provider.add_imports() == {"": "moment/min/locales"}