From 7285d366f54717dbc7b7624d25ddf05987d7c575 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:12:29 -0800 Subject: [PATCH 01/14] warn & pass through non objects in `event-to-object` --- src/js/packages/event-to-object/src/index.ts | 12 ++++++++++-- .../event-to-object/tests/event-to-object.test.ts | 7 +++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts index 84d6c5f65..f6aebe62e 100644 --- a/src/js/packages/event-to-object/src/index.ts +++ b/src/js/packages/event-to-object/src/index.ts @@ -7,10 +7,18 @@ export default function convert( classObject: { [key: string]: any }, maxDepth: number = 10, ): object { - const visited = new WeakSet(); - visited.add(classObject); + // Immediately return `classObject` if given an unexpected (non-object) input + if (!classObject || typeof classObject !== "object") { + console.warn( + "eventToObject: Expected an object input, received:", + classObject, + ); + return classObject; + } // Begin conversion + const visited = new WeakSet(); + visited.add(classObject); const convertedObj: { [key: string]: any } = {}; for (const key in classObject) { // Skip keys that cannot be converted diff --git a/src/js/packages/event-to-object/tests/event-to-object.test.ts b/src/js/packages/event-to-object/tests/event-to-object.test.ts index 30f87bedf..914beddac 100644 --- a/src/js/packages/event-to-object/tests/event-to-object.test.ts +++ b/src/js/packages/event-to-object/tests/event-to-object.test.ts @@ -670,3 +670,10 @@ test("handles recursive HTML node structures", () => { expect(converted.children[0].parentNode).toBeUndefined(); } }); + +test("pass-through on unexpected non-object inputs", () => { + expect(convert(null as any)).toEqual(null); + expect(convert(undefined as any)).toEqual(undefined); + expect(convert(42 as any)).toEqual(42); + expect(convert("test" as any)).toEqual("test"); +}); From e3f479c8ad2fa5c353c268b8fe8f935c02a3ccc0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:13:17 -0800 Subject: [PATCH 02/14] only convert event if it is an object in reactpy client --- src/js/packages/@reactpy/client/src/client.ts | 2 +- src/js/packages/@reactpy/client/src/vdom.tsx | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/client.ts b/src/js/packages/@reactpy/client/src/client.ts index 8d9333094..3b777210b 100644 --- a/src/js/packages/@reactpy/client/src/client.ts +++ b/src/js/packages/@reactpy/client/src/client.ts @@ -69,7 +69,7 @@ export class ReactPyClient url: this.urls.componentUrl, readyPromise: this.ready, ...props.reconnectOptions, - onMessage: (event) => this.handleIncoming(JSON.parse(event.data)), + onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), }); } diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index f54111de7..f87ae4617 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -1,5 +1,5 @@ import type { ReactPyClientInterface } from "./types"; -import serializeEvent from "event-to-object"; +import eventToObject from "event-to-object"; import type { ReactPyVdom, ReactPyVdomImportSource, @@ -212,7 +212,13 @@ function createEventHandler( if (stopPropagation) { event.stopPropagation(); } - return serializeEvent(event); + + // Convert JavaScript objects to plain JSON, if needed + if (typeof event === "object") { + return eventToObject(event); + } else { + return event; + } }); client.sendMessage({ type: "layout-event", data, target }); }; From f1fd746a2365adc94de8c378f7ff22449793b84c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:24:09 -0800 Subject: [PATCH 03/14] add exception in `use_effect` if user provides an async hook function --- src/reactpy/core/hooks.py | 6 ++++++ tests/test_core/test_hooks.py | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index f02a8cf5b..e7b995273 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -146,6 +146,12 @@ def use_effect( Returns: If not function is provided, a decorator. Otherwise ``None``. """ + if asyncio.iscoroutinefunction(function): + raise TypeError( + "`use_effect` does not support async functions. " + "Use `use_async_effect` instead." + ) + hook = HOOK_STACK.current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 2bd4da81e..59ccc67e0 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -1287,3 +1287,24 @@ def bad_cleanup(): await layout.render() component_hook.latest.schedule_render() await layout.render() # no error + + +def test_use_effect_exception_on_async_function(): + @reactpy.component + def ComponentWithBadEffect(): + @reactpy.hooks.use_effect + async def bad_effect(): + pass + + return reactpy.html.div() + + with assert_reactpy_did_log( + match_error="does not support async functions", + error_type=TypeError, + ): + + async def run_test(): + async with reactpy.Layout(ComponentWithBadEffect()) as layout: + await layout.render() + + asyncio.run(run_test()) From 78bfa82aefeadd97b7ee1bbc09a8e5eac9edd763 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:24:31 -0800 Subject: [PATCH 04/14] remove internal json pointer dependency --- src/reactpy/pyscript/utils.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index 34b54576d..857679898 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -13,8 +13,6 @@ from typing import TYPE_CHECKING, Any from uuid import uuid4 -import jsonpointer - import reactpy from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX, REACTPY_WEB_MODULES_DIR from reactpy.types import VdomDict @@ -116,11 +114,7 @@ def extend_pyscript_config( # Extends ReactPy's default PyScript config with user provided values. pyscript_config: dict[str, Any] = { - "packages": [ - reactpy_version_string(), - f"jsonpointer=={jsonpointer.__version__}", - "ssl", - ], + "packages": [reactpy_version_string(), "jsonpointer==3.*", "ssl"], "js_modules": { "main": { f"{REACTPY_PATH_PREFIX.current}static/morphdom/morphdom-esm.js": "morphdom" From 281410ea15510b18ca2fc4ff01695b80e361916b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:25:54 -0800 Subject: [PATCH 05/14] add better type hints to `reactjs_component_from_*` --- docs/source/about/changelog.rst | 1 + src/reactpy/web/module.py | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 6713da0d8..30864571b 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -57,6 +57,7 @@ Unreleased - :pull:`1196` - Rewrite the ``event-to-object`` package to be more robust at handling properties on events. **Deprecated** + -:pull:`1307` - ``reactpy.web.export`` is deprecated. Use ``reactpy.web.reactjs_component_from_*`` instead. -:pull:`1307` - ``reactpy.web.module_from_file`` is deprecated. Use ``reactpy.web.reactjs_component_from_file`` instead. -:pull:`1307` - ``reactpy.web.module_from_url`` is deprecated. Use ``reactpy.web.reactjs_component_from_url`` instead. diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index 239cf31b5..d825074a5 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -33,6 +33,30 @@ _STRING_WEB_MODULE_CACHE: dict[str, WebModule] = {} +@overload +def reactjs_component_from_url( + url: str, + import_names: str, + fallback: Any | None = ..., + resolve_imports: bool | None = ..., + resolve_imports_depth: int = ..., + unmount_before_update: bool = ..., + allow_children: bool = ..., +) -> VdomConstructor: ... + + +@overload +def reactjs_component_from_url( + url: str, + import_names: list[str] | tuple[str, ...], + fallback: Any | None = ..., + resolve_imports: bool | None = ..., + resolve_imports_depth: int = ..., + unmount_before_update: bool = ..., + allow_children: bool = ..., +) -> list[VdomConstructor]: ... + + def reactjs_component_from_url( url: str, import_names: str | list[str] | tuple[str, ...], @@ -80,6 +104,34 @@ def reactjs_component_from_url( return _vdom_from_web_module(module, import_names, fallback, allow_children) +@overload +def reactjs_component_from_file( + name: str, + file: str | Path, + import_names: str, + fallback: Any | None = ..., + resolve_imports: bool | None = ..., + resolve_imports_depth: int = ..., + unmount_before_update: bool = ..., + symlink: bool = ..., + allow_children: bool = ..., +) -> VdomConstructor: ... + + +@overload +def reactjs_component_from_file( + name: str, + file: str | Path, + import_names: list[str] | tuple[str, ...], + fallback: Any | None = ..., + resolve_imports: bool | None = ..., + resolve_imports_depth: int = ..., + unmount_before_update: bool = ..., + symlink: bool = ..., + allow_children: bool = ..., +) -> list[VdomConstructor]: ... + + def reactjs_component_from_file( name: str, file: str | Path, @@ -135,6 +187,32 @@ def reactjs_component_from_file( return _vdom_from_web_module(module, import_names, fallback, allow_children) +@overload +def reactjs_component_from_string( + name: str, + content: str, + import_names: str, + fallback: Any | None = ..., + resolve_imports: bool | None = ..., + resolve_imports_depth: int = ..., + unmount_before_update: bool = ..., + allow_children: bool = ..., +) -> VdomConstructor: ... + + +@overload +def reactjs_component_from_string( + name: str, + content: str, + import_names: list[str] | tuple[str, ...], + fallback: Any | None = ..., + resolve_imports: bool | None = ..., + resolve_imports_depth: int = ..., + unmount_before_update: bool = ..., + allow_children: bool = ..., +) -> list[VdomConstructor]: ... + + def reactjs_component_from_string( name: str, content: str, From fbaec935e2250fe719977a187ffacf6c241e591a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:33:30 -0800 Subject: [PATCH 06/14] bump package versions --- src/js/packages/@reactpy/client/package.json | 2 +- src/js/packages/event-to-object/package.json | 2 +- src/reactpy/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/js/packages/@reactpy/client/package.json b/src/js/packages/@reactpy/client/package.json index 0285743de..c55622688 100644 --- a/src/js/packages/@reactpy/client/package.json +++ b/src/js/packages/@reactpy/client/package.json @@ -31,5 +31,5 @@ "checkTypes": "tsc --noEmit" }, "type": "module", - "version": "1.0.0" + "version": "1.0.1" } diff --git a/src/js/packages/event-to-object/package.json b/src/js/packages/event-to-object/package.json index 51aa3df24..85c6b1b2c 100644 --- a/src/js/packages/event-to-object/package.json +++ b/src/js/packages/event-to-object/package.json @@ -31,5 +31,5 @@ "checkTypes": "tsc --noEmit" }, "type": "module", - "version": "1.0.0" + "version": "1.0.1" } diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index a6c04af3a..342a1e6dc 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -24,7 +24,7 @@ from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy __author__ = "The Reactive Python Team" -__version__ = "2.0.0b3" +__version__ = "2.0.0b4" __all__ = [ "Event", From 4d2e956a3ee6a10e9497e572d7c19b158d266b46 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:59:15 -0800 Subject: [PATCH 07/14] move Event to types & remove reactpy.Layout --- docs/source/about/changelog.rst | 2 + src/reactpy/__init__.py | 5 +-- src/reactpy/core/events.py | 12 ----- src/reactpy/core/layout.py | 2 +- src/reactpy/types.py | 16 +++++++ tests/test_core/test_events.py | 4 +- tests/test_core/test_hooks.py | 80 ++++++++++++++++----------------- tests/test_core/test_layout.py | 68 ++++++++++++++-------------- tests/test_core/test_serve.py | 2 +- 9 files changed, 96 insertions(+), 95 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 30864571b..b929b78c5 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -82,6 +82,8 @@ Unreleased - :pull:`1281` - Removed ``reactpy.vdom``. Use ``reactpy.Vdom`` instead. - :pull:`1281` - Removed ``reactpy.core.make_vdom_constructor``. Use ``reactpy.Vdom`` instead. - :pull:`1281` - Removed ``reactpy.core.custom_vdom_constructor``. Use ``reactpy.Vdom`` instead. +- :pull:`xxxx` - Removed ``reactpy.core.serve.Stop`` exception type. +- :pull:`xxxx` - Removed ``reactpy.Layout`` top-levle export. Use ``reactpy.core.layout.Layout`` instead. **Fixed** diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 342a1e6dc..bea5c6d1a 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -2,7 +2,7 @@ from reactpy._html import html from reactpy.core import hooks from reactpy.core.component import component -from reactpy.core.events import Event, event +from reactpy.core.events import event from reactpy.core.hooks import ( create_context, use_async_effect, @@ -18,7 +18,6 @@ use_scope, use_state, ) -from reactpy.core.layout import Layout from reactpy.core.vdom import Vdom from reactpy.pyscript.components import pyscript_component from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy @@ -27,8 +26,6 @@ __version__ = "2.0.0b4" __all__ = [ - "Event", - "Layout", "Ref", "Vdom", "component", diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index ff71c6b6f..266d65ae2 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -73,18 +73,6 @@ def setup(function: Callable[..., Any]) -> EventHandler: return setup(function) if function is not None else setup -class Event(dict): - def __getattr__(self, name: str) -> Any: - value = self.get(name) - return Event(value) if isinstance(value, dict) else value - - def preventDefault(self) -> None: - """Prevent the default action of the event.""" - - def stopPropagation(self) -> None: - """Stop the event from propagating.""" - - class EventHandler: """Turn a function or coroutine into an event handler diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 879a71fa0..3c9a1bf39 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -36,11 +36,11 @@ REACTPY_DEBUG, ) from reactpy.core._life_cycle_hook import LifeCycleHook -from reactpy.core.events import Event from reactpy.core.vdom import validate_vdom_json from reactpy.types import ( ComponentType, Context, + Event, EventHandlerDict, Key, LayoutEventMessage, diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 2f0fbed8e..b10e67350 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1071,3 +1071,19 @@ def __call__( class EllipsisRepr: def __repr__(self) -> str: return "..." + + +class Event(dict): + """ + A light `dict` wrapper for event data passed to event handler functions. + """ + + def __getattr__(self, name: str) -> Any: + value = self.get(name) + return Event(value) if isinstance(value, dict) else value + + def preventDefault(self) -> None: + """Prevent the default action of the event.""" + + def stopPropagation(self) -> None: + """Stop the event from propagating.""" diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 6c6b1da26..6e22abf5c 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -3,7 +3,6 @@ import reactpy from reactpy import component, html from reactpy.core.events import ( - Event, EventHandler, merge_event_handler_funcs, merge_event_handlers, @@ -11,6 +10,7 @@ ) from reactpy.core.layout import Layout from reactpy.testing import DisplayFixture, poll +from reactpy.types import Event from tests.tooling.common import DEFAULT_TYPE_DELAY @@ -381,7 +381,7 @@ def handler(event: Event): def test_event_export(): - from reactpy import Event + from reactpy.types import Event assert Event is not None diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 59ccc67e0..41a568326 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -23,7 +23,7 @@ def SimpleComponentWithHook(): with pytest.raises(RuntimeError, match="No life cycle hook is active"): await SimpleComponentWithHook().render() - async with reactpy.Layout(SimpleComponentWithHook()) as layout: + async with Layout(SimpleComponentWithHook()) as layout: await layout.render() @@ -41,7 +41,7 @@ def SimpleStatefulComponent(): sse = SimpleStatefulComponent() - async with reactpy.Layout(sse) as layout: + async with Layout(sse) as layout: update_1 = await layout.render() assert update_1 == update_message( path="", @@ -84,7 +84,7 @@ def SimpleStatefulComponent(): sse = SimpleStatefulComponent() - async with reactpy.Layout(sse) as layout: + async with Layout(sse) as layout: await layout.render() await layout.render() await layout.render() @@ -117,7 +117,7 @@ def Inner(): state, set_inner_state.current = reactpy.use_state(make_default) return reactpy.html.div(state) - async with reactpy.Layout(Outer()) as layout: + async with Layout(Outer()) as layout: await layout.render() assert constructor_call_count.current == 1 @@ -150,7 +150,7 @@ def Counter(): count.current, set_count.current = reactpy.hooks.use_state(0) return reactpy.html.div(count.current) - async with reactpy.Layout(Counter()) as layout: + async with Layout(Counter()) as layout: await layout.render() for i in range(4): @@ -319,7 +319,7 @@ def CheckNoEffectYet(): effect_triggers_after_final_render.current = not effect_triggered.current return reactpy.html.div() - async with reactpy.Layout(OuterComponent()) as layout: + async with Layout(OuterComponent()) as layout: await layout.render() assert effect_triggered.current @@ -347,7 +347,7 @@ def cleanup(): return reactpy.html.div() - async with reactpy.Layout(ComponentWithEffect()) as layout: + async with Layout(ComponentWithEffect()) as layout: await layout.render() assert not cleanup_triggered.current @@ -386,7 +386,7 @@ def cleanup(): return reactpy.html.div() - async with reactpy.Layout(OuterComponent()) as layout: + async with Layout(OuterComponent()) as layout: await layout.render() assert not cleanup_triggered.current @@ -417,7 +417,7 @@ def effect(): return reactpy.html.div() - async with reactpy.Layout(ComponentWithMemoizedEffect()) as layout: + async with Layout(ComponentWithMemoizedEffect()) as layout: await layout.render() assert effect_run_count.current == 1 @@ -460,7 +460,7 @@ def cleanup(): return reactpy.html.div() - async with reactpy.Layout(ComponentWithEffect()) as layout: + async with Layout(ComponentWithEffect()) as layout: await layout.render() assert cleanup_trigger_count.current == 0 @@ -487,7 +487,7 @@ async def effect(): return reactpy.html.div() - async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: + async with Layout(ComponentWithAsyncEffect()) as layout: await layout.render() await asyncio.wait_for(effect_ran.wait(), 1) @@ -508,7 +508,7 @@ async def effect(): return reactpy.html.div() - async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: + async with Layout(ComponentWithAsyncEffect()) as layout: await layout.render() component_hook.latest.schedule_render() @@ -540,7 +540,7 @@ async def effect(): return reactpy.html.div() - async with reactpy.Layout(ComponentWithLongWaitingEffect()) as layout: + async with Layout(ComponentWithLongWaitingEffect()) as layout: await layout.render() await effect_ran.wait() @@ -568,7 +568,7 @@ def bad_effect(): return reactpy.html.div() with assert_reactpy_did_log(match_message=r"Error in effect"): - async with reactpy.Layout(ComponentWithEffect()) as layout: + async with Layout(ComponentWithEffect()) as layout: await layout.render() # no error @@ -596,7 +596,7 @@ def bad_cleanup(): match_message=r"Error in effect", error_type=ValueError, ): - async with reactpy.Layout(OuterComponent()) as layout: + async with Layout(OuterComponent()) as layout: await layout.render() set_key.current("second") await layout.render() # no error @@ -622,7 +622,7 @@ def Counter(initial_count): ) return reactpy.html.div() - async with reactpy.Layout(Counter(0)) as layout: + async with Layout(Counter(0)) as layout: await layout.render() assert saved_count.current == 0 @@ -653,7 +653,7 @@ def ComponentWithUseReduce(): saved_dispatchers.append(reactpy.hooks.use_reducer(reducer, 0)[1]) return reactpy.html.div() - async with reactpy.Layout(ComponentWithUseReduce()) as layout: + async with Layout(ComponentWithUseReduce()) as layout: for _ in range(3): await layout.render() saved_dispatchers[-1]("increment") @@ -673,7 +673,7 @@ def ComponentWithRef(): used_callbacks.append(reactpy.hooks.use_callback(lambda: None)) return reactpy.html.div() - async with reactpy.Layout(ComponentWithRef()) as layout: + async with Layout(ComponentWithRef()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -701,7 +701,7 @@ def cb(): used_callbacks.append(cb) return reactpy.html.div() - async with reactpy.Layout(ComponentWithRef()) as layout: + async with Layout(ComponentWithRef()) as layout: await layout.render() set_state_hook.current(1) await layout.render() @@ -731,7 +731,7 @@ def ComponentWithMemo(): used_values.append(value) return reactpy.html.div() - async with reactpy.Layout(ComponentWithMemo()) as layout: + async with Layout(ComponentWithMemo()) as layout: await layout.render() set_state_hook.current(1) await layout.render() @@ -756,7 +756,7 @@ def ComponentWithMemo(): used_values.append(value) return reactpy.html.div() - async with reactpy.Layout(ComponentWithMemo()) as layout: + async with Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -783,7 +783,7 @@ def ComponentWithMemo(): used_values.append(value) return reactpy.html.div() - async with reactpy.Layout(ComponentWithMemo()) as layout: + async with Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.latest.schedule_render() deps_used_in_memo.current = None @@ -808,7 +808,7 @@ def ComponentWithMemo(): used_values.append(value) return reactpy.html.div() - async with reactpy.Layout(ComponentWithMemo()) as layout: + async with Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -828,7 +828,7 @@ def ComponentWithRef(): used_refs.append(reactpy.hooks.use_ref(1)) return reactpy.html.div() - async with reactpy.Layout(ComponentWithRef()) as layout: + async with Layout(ComponentWithRef()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -864,7 +864,7 @@ def some_effect_that_uses_count(): return reactpy.html.div() - async with reactpy.Layout(CounterWithEffect()) as layout: + async with Layout(CounterWithEffect()) as layout: await layout.render() await did_effect.wait() did_effect.clear() @@ -892,7 +892,7 @@ def some_memo_func_that_uses_count(): return reactpy.html.div() - async with reactpy.Layout(CounterWithEffect()) as layout: + async with Layout(CounterWithEffect()) as layout: await layout.render() await did_memo.wait() did_memo.clear() @@ -917,7 +917,7 @@ def ComponentUsesContext(): value.current = reactpy.use_context(Context) return html.div() - async with reactpy.Layout(ComponentProvidesContext()) as layout: + async with Layout(ComponentProvidesContext()) as layout: await layout.render() assert value.current == "something" @@ -926,7 +926,7 @@ def ComponentUsesContext2(): value.current = reactpy.use_context(Context) return html.div() - async with reactpy.Layout(ComponentUsesContext2()) as layout: + async with Layout(ComponentUsesContext2()) as layout: await layout.render() assert value.current == "something" @@ -958,7 +958,7 @@ def MemoizedComponentUsesContext(): render_count.current += 1 return html.div() - async with reactpy.Layout(ComponentProvidesContext()) as layout: + async with Layout(ComponentProvidesContext()) as layout: await layout.render() assert render_count.current == 1 assert value.current == 0 @@ -1016,7 +1016,7 @@ def bad_effect(): error_type=ValueError, match_error="The error message", ): - async with reactpy.Layout(ComponentWithEffect()) as layout: + async with Layout(ComponentWithEffect()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() # no error @@ -1058,7 +1058,7 @@ def SomeComponent(): reactpy.use_debug_value(f"message is {message!r}") return reactpy.html.div() - async with reactpy.Layout(SomeComponent()) as layout: + async with Layout(SomeComponent()) as layout: with assert_reactpy_did_log(r"SomeComponent\(.*?\) message is 'hello'"): await layout.render() @@ -1085,7 +1085,7 @@ def SomeComponent(): reactpy.use_debug_value(lambda: f"message is {message!r}") return reactpy.html.div() - async with reactpy.Layout(SomeComponent()) as layout: + async with Layout(SomeComponent()) as layout: with assert_reactpy_did_log(r"SomeComponent\(.*?\) message is 'hello'"): await layout.render() @@ -1110,7 +1110,7 @@ def SomeComponent(): reactpy.use_debug_value(lambda: f"message is {message!r}") return reactpy.html.div() - async with reactpy.Layout(SomeComponent()) as layout: + async with Layout(SomeComponent()) as layout: with assert_reactpy_did_not_log(r"SomeComponent\(.*?\) message is 'hello'"): await layout.render() @@ -1141,9 +1141,7 @@ def FirstCondition(): def SecondCondition(): used_context_values.append(reactpy.use_context(some_context) + "-2") - async with reactpy.Layout( - some_context(SomeComponent(), value="the-value") - ) as layout: + async with Layout(some_context(SomeComponent(), value="the-value")) as layout: await layout.render() assert used_context_values == ["the-value-1"] set_state.current(False) @@ -1217,7 +1215,7 @@ def SomeComponent(): _, set_state.current = reactpy.use_state(get_value()) render_count.current += 1 - async with reactpy.Layout(SomeComponent()) as layout: + async with Layout(SomeComponent()) as layout: await layout.render() assert render_count.current == 1 set_state.current(get_value()) @@ -1238,7 +1236,7 @@ def SomeComponent(): def incr_effect_count(): effect_count.current += 1 - async with reactpy.Layout(SomeComponent()) as layout: + async with Layout(SomeComponent()) as layout: await layout.render() assert effect_count.current == 1 value.current = get_value() @@ -1255,7 +1253,7 @@ async def test_use_state_named_tuple(): def some_component(): state.current = reactpy.use_state(1) - async with reactpy.Layout(some_component()) as layout: + async with Layout(some_component()) as layout: await layout.render() assert state.current.value == 1 state.current.set_value(2) @@ -1283,7 +1281,7 @@ def bad_cleanup(): error_type=ValueError, match_error="The error message", ): - async with reactpy.Layout(ComponentWithEffect()) as layout: + async with Layout(ComponentWithEffect()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() # no error @@ -1304,7 +1302,7 @@ async def bad_effect(): ): async def run_test(): - async with reactpy.Layout(ComponentWithBadEffect()) as layout: + async with Layout(ComponentWithBadEffect()) as layout: await layout.render() asyncio.run(run_test()) diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index eb292a1f0..401104473 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -51,15 +51,15 @@ def test_layout_repr(): def MyComponent(): ... my_component = MyComponent() - layout = reactpy.Layout(my_component) + layout = Layout(my_component) assert str(layout) == f"Layout(MyComponent({id(my_component):02x}))" def test_layout_expects_abstract_component(): with pytest.raises(TypeError, match="Expected a ComponentType"): - reactpy.Layout(None) + Layout(None) with pytest.raises(TypeError, match="Expected a ComponentType"): - reactpy.Layout(reactpy.html.div()) + Layout(reactpy.html.div()) async def test_layout_cannot_be_used_outside_context_manager(caplog): @@ -67,7 +67,7 @@ async def test_layout_cannot_be_used_outside_context_manager(caplog): def Component(): ... component = Component() - layout = reactpy.Layout(component) + layout = Layout(component) with pytest.raises(AttributeError): await layout.deliver(event_message("something")) @@ -84,7 +84,7 @@ def SimpleComponent(): tag, set_state_hook.current = reactpy.hooks.use_state("div") return reactpy.Vdom(tag)() - async with reactpy.Layout(SimpleComponent()) as layout: + async with Layout(SimpleComponent()) as layout: update_1 = await layout.render() assert update_1 == update_message( path="", @@ -131,7 +131,7 @@ def make_child_model(state): "children": [{"tagName": "div", "children": [str(state)]}], } - async with reactpy.Layout(Parent()) as layout: + async with Layout(Parent()) as layout: update_1 = await layout.render() assert update_1 == update_message( path="", @@ -174,7 +174,7 @@ def BadChild(): raise ValueError(msg) with assert_reactpy_did_log(match_error="error from bad child"): - async with reactpy.Layout(Main()) as layout: + async with Layout(Main()) as layout: assert (await layout.render()) == update_message( path="", model={ @@ -225,7 +225,7 @@ def BadChild(): raise ValueError(msg) with assert_reactpy_did_log(match_error="error from bad child"): - async with reactpy.Layout(Main()) as layout: + async with Layout(Main()) as layout: assert (await layout.render()) == update_message( path="", model={ @@ -263,7 +263,7 @@ def Main(): def Child(): return {"tagName": "div", "children": {"tagName": "h1"}} - async with reactpy.Layout(Main()) as layout: + async with Layout(Main()) as layout: assert (await layout.render()) == update_message( path="", model={ @@ -313,7 +313,7 @@ def Outer(): def Inner(): return reactpy.html.div() - async with reactpy.Layout(Outer()) as layout: + async with Layout(Outer()) as layout: await layout.render() assert len(live_components) == 2 @@ -356,7 +356,7 @@ def wrapper(*args, **kwargs): def Root(): return reactpy.html.div() - async with reactpy.Layout(Root()) as layout: + async with Layout(Root()) as layout: await layout.render() assert len(live_hooks) == 1 @@ -397,7 +397,7 @@ def Outer(): def Inner(): return reactpy.html.div() - async with reactpy.Layout(Outer()) as layout: + async with Layout(Outer()) as layout: await layout.render() assert len(live_hooks) == 2 @@ -434,7 +434,7 @@ def AnyComponent(): run_count.current += 1 return reactpy.html.div() - async with reactpy.Layout(AnyComponent()) as layout: + async with Layout(AnyComponent()) as layout: await layout.render() assert run_count.current == 1 @@ -466,7 +466,7 @@ def Parent(): def Child(): return reactpy.html.div() - async with reactpy.Layout(Parent()) as layout: + async with Layout(Parent()) as layout: await layout.render() hook.latest.schedule_render() @@ -480,7 +480,7 @@ async def test_log_on_dispatch_to_missing_event_handler(caplog): def SomeComponent(): return reactpy.html.div() - async with reactpy.Layout(SomeComponent()) as layout: + async with Layout(SomeComponent()) as layout: await layout.deliver(event_message("missing")) assert re.match( @@ -522,7 +522,7 @@ def bad_trigger(): return reactpy.html.div(children) - async with reactpy.Layout(MyComponent()) as layout: + async with Layout(MyComponent()) as layout: await layout.render() for _i in range(3): event = event_message(good_handler.target) @@ -574,7 +574,7 @@ def callback(): return reactpy.html.button({"onClick": callback, "id": "good"}, "good") - async with reactpy.Layout(RootComponent()) as layout: + async with Layout(RootComponent()) as layout: await layout.render() for _ in range(3): event = event_message(good_handler.target) @@ -596,7 +596,7 @@ def Outer(): def Inner(): return reactpy.html.div("hello") - async with reactpy.Layout(Outer()) as layout: + async with Layout(Outer()) as layout: assert (await layout.render()) == update_message( path="", model={ @@ -630,7 +630,7 @@ def Inner(finalizer_id): registered_finalizers.add(finalizer_id) return reactpy.html.div(finalizer_id) - async with reactpy.Layout(Outer()) as layout: + async with Layout(Outer()) as layout: await layout.render() pop_item.current() @@ -658,7 +658,7 @@ def HasEventHandlerAtRoot(): event_handler.current = weakref(button["eventHandlers"]["onClick"].function) return button - async with reactpy.Layout(HasEventHandlerAtRoot()) as layout: + async with Layout(HasEventHandlerAtRoot()) as layout: await layout.render() for _i in range(3): @@ -680,7 +680,7 @@ def HasNestedEventHandler(): event_handler.current = weakref(button["eventHandlers"]["onClick"].function) return reactpy.html.div(reactpy.html.div(button)) - async with reactpy.Layout(HasNestedEventHandler()) as layout: + async with Layout(HasNestedEventHandler()) as layout: await layout.render() for _i in range(3): @@ -705,7 +705,7 @@ def ComponentReturnsDuplicateKeys(): else: return reactpy.html.div() - async with reactpy.Layout(ComponentReturnsDuplicateKeys()) as layout: + async with Layout(ComponentReturnsDuplicateKeys()) as layout: with assert_reactpy_did_log( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", @@ -740,7 +740,7 @@ def Outer(): def Inner(): return reactpy.html.div() - async with reactpy.Layout(Outer()) as layout: + async with Layout(Outer()) as layout: await layout.render() old_inner_hook = inner_hook.latest @@ -762,7 +762,7 @@ def raise_error(): return reactpy.html.button({"onClick": raise_error}) with assert_reactpy_did_log(match_error="bad event handler"): - async with reactpy.Layout(ComponentWithBadEventHandler()) as layout: + async with Layout(ComponentWithBadEventHandler()) as layout: await layout.render() event = event_message(bad_handler.target) await layout.deliver(event) @@ -786,7 +786,7 @@ def Child(state): with assert_reactpy_did_log( r"Did not render component with model state ID .*? - component already unmounted", ): - async with reactpy.Layout(Parent()) as layout: + async with Layout(Parent()) as layout: await layout.render() old_hook = child_hook.latest @@ -826,7 +826,7 @@ def some_effect(): return reactpy.html.div(name) - async with reactpy.Layout(Root()) as layout: + async with Layout(Root()) as layout: await layout.render() await poll(lambda: effects).until_equals(["mount x"]) @@ -863,7 +863,7 @@ def SomeComponent(): ] ) - async with reactpy.Layout(SomeComponent()) as layout: + async with Layout(SomeComponent()) as layout: await layout.render() set_items.current([2, 3]) @@ -895,7 +895,7 @@ def HasState(): state.current = reactpy.hooks.use_state(random.random)[0] return reactpy.html.div() - async with reactpy.Layout(Root()) as layout: + async with Layout(Root()) as layout: await layout.render() for _i in range(5): @@ -924,7 +924,7 @@ def SomeComponent(): handler = component_static_handler.use(lambda: None) return html.button({"onAnotherEvent": handler}) - async with reactpy.Layout(Root()) as layout: + async with Layout(Root()) as layout: await layout.render() assert element_static_handler.target in layout._event_handlers @@ -970,7 +970,7 @@ def SecondComponent(): use_effect(lambda: lambda: second_used_state.set_current(None)) return html.div() - async with reactpy.Layout(Root()) as layout: + async with Layout(Root()) as layout: await layout.render() assert first_used_state.current == "first" @@ -1026,7 +1026,7 @@ async def record_if_state_is_reset(): return html.div({"key": child_key}, child_key) - async with reactpy.Layout(Parent()) as layout: + async with Layout(Parent()) as layout: await layout.render() await did_call_effect.wait() assert effect_calls_without_state == {"some-key", "key-0"} @@ -1137,7 +1137,7 @@ def Parent(): def Child(): return html.p("second") - async with reactpy.Layout(Parent()) as layout: + async with Layout(Parent()) as layout: update = await layout.render() assert update["model"] == { "tagName": "", @@ -1173,7 +1173,7 @@ def Child(): nonlocal schedule_removed_child_render schedule_removed_child_render = use_force_render() - async with reactpy.Layout(Parent()) as layout: + async with Layout(Parent()) as layout: await layout.render() # If the context provider does not render its children then internally tracked @@ -1226,7 +1226,7 @@ def App(): items = use_state(["A", "B", "C"]) return html.fragment([Item(item, items, key=item) for item in items.value]) - async with layout_runner(reactpy.Layout(App())) as runner: + async with layout_runner(Layout(App())) as runner: tree = await runner.render() # Delete item B diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index df92b8091..d0e5b5f15 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -135,7 +135,7 @@ def set_did_render(): task = asyncio.create_task( serve_layout( - reactpy.Layout(ComponentWithTwoEventHandlers()), + Layout(ComponentWithTwoEventHandlers()), send_queue.put, recv_queue.get, ) From 094fc47d93085df97ebb11f9302e0bfbc6638b70 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:59:22 -0800 Subject: [PATCH 08/14] remove deprecated Stop exception type --- src/reactpy/core/serve.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index a6397eee8..624bd9b1b 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -25,16 +25,6 @@ """ -class Stop(BaseException): - """Deprecated - - Stop serving changes and events - - Raising this error will tell dispatchers to gracefully exit. Typically this is - called by code running inside a layout to tell it to stop rendering. - """ - - async def serve_layout( layout: LayoutType[ LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] @@ -44,17 +34,9 @@ async def serve_layout( ) -> None: """Run a dispatch loop for a single view instance""" async with layout: - try: - async with create_task_group() as task_group: - task_group.start_soon(_single_outgoing_loop, layout, send) - task_group.start_soon(_single_incoming_loop, task_group, layout, recv) - except Stop: # nocov - warn( - "The Stop exception is deprecated and will be removed in a future version", - UserWarning, - stacklevel=1, - ) - logger.info(f"Stopped serving {layout}") + async with create_task_group() as task_group: + task_group.start_soon(_single_outgoing_loop, layout, send) + task_group.start_soon(_single_incoming_loop, task_group, layout, recv) async def _single_outgoing_loop( From 16d63ec6b685b323c31b0952032118880b16fa09 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:15:28 -0800 Subject: [PATCH 09/14] remove deprecated hotswap function --- src/reactpy/widgets.py | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index 34242a189..b91f52a9f 100644 --- a/src/reactpy/widgets.py +++ b/src/reactpy/widgets.py @@ -2,12 +2,11 @@ from base64 import b64encode from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar +from typing import Any, Callable, Protocol, TypeVar import reactpy from reactpy._html import html -from reactpy._warnings import warn -from reactpy.types import ComponentConstructor, VdomAttributes, VdomDict +from reactpy.types import VdomAttributes, VdomDict def image( @@ -22,11 +21,7 @@ def image( if format == "svg": format = "svg+xml" # noqa: A001 - if isinstance(value, str): - bytes_value = value.encode() - else: - bytes_value = value - + bytes_value = value.encode() if isinstance(value, str) else value base64_value = b64encode(bytes_value).decode() src = f"data:image/{format};base64,{base64_value}" @@ -83,20 +78,3 @@ def sync_inputs(event: dict[str, Any]) -> None: class _CastFunc(Protocol[_CastTo_co]): def __call__(self, value: str) -> _CastTo_co: ... - - -if TYPE_CHECKING: - from reactpy.testing.backend import _MountFunc - - -def hotswap( - update_on_change: bool = False, -) -> tuple[_MountFunc, ComponentConstructor]: # nocov - warn( - "The 'hotswap' function is deprecated and will be removed in a future release", - DeprecationWarning, - stacklevel=2, - ) - from reactpy.testing.backend import _hotswap - - return _hotswap(update_on_change) From 5ec0b43bb90da725d32b018c2db19079bb9ce0e1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:15:33 -0800 Subject: [PATCH 10/14] add changelog --- docs/source/about/changelog.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index b929b78c5..a7f147f24 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -82,8 +82,10 @@ Unreleased - :pull:`1281` - Removed ``reactpy.vdom``. Use ``reactpy.Vdom`` instead. - :pull:`1281` - Removed ``reactpy.core.make_vdom_constructor``. Use ``reactpy.Vdom`` instead. - :pull:`1281` - Removed ``reactpy.core.custom_vdom_constructor``. Use ``reactpy.Vdom`` instead. -- :pull:`xxxx` - Removed ``reactpy.core.serve.Stop`` exception type. -- :pull:`xxxx` - Removed ``reactpy.Layout`` top-levle export. Use ``reactpy.core.layout.Layout`` instead. +- :pull:`1311` - Removed ``reactpy.core.serve.Stop`` type due to extended deprecation. +- :pull:`1311` - Removed ``reactpy.Layout`` top-level export. Use ``reactpy.core.layout.Layout`` instead. +- :pull:`1311` - Removed ``reactpy.widgets.hotswap`` due to extended deprecation. + **Fixed** @@ -291,7 +293,7 @@ v0.43.0 **Deprecated** -- :pull:`870` - ``ComponentType.should_render()``. This method was implemented based on +- :pull:`870` - ``ComponentType.()``. This method was implemented based on reading the React/Preact source code. As it turns out though it seems like it's mostly a vestige from the fact that both these libraries still support class-based components. The ability for components to not render also caused several bugs. From 8db1f9e04a9302e1c9fbe7c42799ee3cb7fdfc7b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:18:54 -0800 Subject: [PATCH 11/14] hatch fmt src --- src/build_scripts/copy_dir.py | 1 - src/reactpy/core/serve.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/build_scripts/copy_dir.py b/src/build_scripts/copy_dir.py index 34c87bf4d..6f0ffc686 100644 --- a/src/build_scripts/copy_dir.py +++ b/src/build_scripts/copy_dir.py @@ -3,7 +3,6 @@ # dependencies = [] # /// -# ruff: noqa: INP001 import logging import shutil import sys diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 624bd9b1b..8479b71c9 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -3,7 +3,6 @@ from collections.abc import Awaitable from logging import getLogger from typing import Any, Callable -from warnings import warn from anyio import create_task_group from anyio.abc import TaskGroup From 3d76c5bb8432d9cb8141a25046e75d0e329693df Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:33:41 -0800 Subject: [PATCH 12/14] export preact in reactpy client --- src/js/packages/@reactpy/client/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/js/packages/@reactpy/client/src/index.ts b/src/js/packages/@reactpy/client/src/index.ts index 15192823d..972d7be4a 100644 --- a/src/js/packages/@reactpy/client/src/index.ts +++ b/src/js/packages/@reactpy/client/src/index.ts @@ -6,3 +6,4 @@ export * from "./vdom"; export * from "./websocket"; export { default as React } from "preact/compat"; export { default as ReactDOM } from "preact/compat"; +export { default as preact } from "preact"; From de4eb440d087de120478228d91634e1858dd7803 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:33:56 -0800 Subject: [PATCH 13/14] use new addops syntax --- pyproject.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fb8a00237..8c1329f08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,10 +98,7 @@ features = ["all"] python = ["3.10", "3.11", "3.12", "3.13"] [tool.pytest.ini_options] -addopts = """\ - --strict-config - --strict-markers -""" +addopts = ["--strict-config", "--strict-markers"] filterwarnings = """ ignore::DeprecationWarning:uvicorn.* ignore::DeprecationWarning:websockets.* From bdff948fb5d20e867f3b67f3e5f2d32b4dd40bb6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:37:44 -0800 Subject: [PATCH 14/14] fix build error --- src/js/packages/@reactpy/client/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/index.ts b/src/js/packages/@reactpy/client/src/index.ts index 972d7be4a..b173a4226 100644 --- a/src/js/packages/@reactpy/client/src/index.ts +++ b/src/js/packages/@reactpy/client/src/index.ts @@ -6,4 +6,4 @@ export * from "./vdom"; export * from "./websocket"; export { default as React } from "preact/compat"; export { default as ReactDOM } from "preact/compat"; -export { default as preact } from "preact"; +export * as preact from "preact";