diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index f64911892..e9bb8514a 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -31,6 +31,9 @@ Unreleased - :pull:`1281` - Added type hints to ``reactpy.html`` attributes. - :pull:`1285` - Added support for nested components in web modules - :pull:`1289` - Added support for inline JavaScript as event handlers or other attributes that expect a callable via ``reactpy.types.InlineJavaScript`` +-:pull:`1307` - Added ``reactpy.web.reactjs_component_from_file`` to import ReactJS components from a file. +-:pull:`1307` - Added ``reactpy.web.reactjs_component_from_url`` to import ReactJS components from a URL. +-:pull:`1307` - Added ``reactpy.web.reactjs_component_from_string`` to import ReactJS components from a string. **Changed** @@ -51,6 +54,13 @@ Unreleased - :pull:`1281` - ``reactpy.types.VdomDictConstructor`` has been renamed to ``reactpy.types.VdomConstructor``. - :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. +-:pull:`1307` - ``reactpy.web.module_from_string`` is deprecated. Use ``reactpy.web.reactjs_component_from_string`` instead. + + **Removed** - :pull:`1255` - Removed the ability to import ``reactpy.html.*`` elements directly. You must now call ``html.*`` to access the elements. diff --git a/src/reactpy/web/__init__.py b/src/reactpy/web/__init__.py index f27d58ff9..bf75cd372 100644 --- a/src/reactpy/web/__init__.py +++ b/src/reactpy/web/__init__.py @@ -3,6 +3,9 @@ module_from_file, module_from_string, module_from_url, + reactjs_component_from_file, + reactjs_component_from_string, + reactjs_component_from_url, ) __all__ = [ @@ -10,4 +13,7 @@ "module_from_file", "module_from_string", "module_from_url", + "reactjs_component_from_file", + "reactjs_component_from_string", + "reactjs_component_from_url", ] diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index bd35f92cb..239cf31b5 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any, NewType, overload +from reactpy._warnings import warn from reactpy.config import REACTPY_DEBUG, REACTPY_WEB_MODULES_DIR from reactpy.core.vdom import Vdom from reactpy.types import ImportSourceDict, VdomConstructor @@ -27,71 +28,86 @@ """A source loaded from a URL, usually a CDN""" -def module_from_url( +_URL_WEB_MODULE_CACHE: dict[str, WebModule] = {} +_FILE_WEB_MODULE_CACHE: dict[str, WebModule] = {} +_STRING_WEB_MODULE_CACHE: dict[str, WebModule] = {} + + +def reactjs_component_from_url( url: str, + import_names: str | list[str] | tuple[str, ...], fallback: Any | None = None, - resolve_exports: bool | None = None, - resolve_exports_depth: int = 5, + resolve_imports: bool | None = None, + resolve_imports_depth: int = 5, unmount_before_update: bool = False, -) -> WebModule: - """Load a :class:`WebModule` from a :data:`URL_SOURCE` + allow_children: bool = True, +) -> VdomConstructor | list[VdomConstructor]: + """Import a component from a URL. Parameters: url: - Where the javascript module will be loaded from which conforms to the - interface for :ref:`Custom Javascript Components` + The URL to import the component from. + import_names: + One or more component names to import. If given as a string, a single component + will be returned. If a list is given, then a list of components will be + returned. fallback: What to temporarily display while the module is being loaded. resolve_imports: - Whether to try and find all the named exports of this module. - resolve_exports_depth: - How deeply to search for those exports. + Whether to try and find all the named imports of this module. + resolve_imports_depth: + How deeply to search for those imports. unmount_before_update: Cause the component to be unmounted before each update. This option should only be used if the imported package fails to re-render when props change. Using this option has negative performance consequences since all DOM elements must be changed on each render. See :issue:`461` for more info. + allow_children: + Whether or not these components can have children. """ - return WebModule( - source=url, - source_type=URL_SOURCE, - default_fallback=fallback, - file=None, - export_names=( - resolve_module_exports_from_url(url, resolve_exports_depth) - if ( - resolve_exports - if resolve_exports is not None - else REACTPY_DEBUG.current - ) - else None - ), - unmount_before_update=unmount_before_update, - ) + key = f"{url}{resolve_imports}{resolve_imports_depth}{unmount_before_update}" + if key in _URL_WEB_MODULE_CACHE: + module = _URL_WEB_MODULE_CACHE[key] + else: + module = _module_from_url( + url, + fallback=fallback, + resolve_imports=resolve_imports, + resolve_imports_depth=resolve_imports_depth, + unmount_before_update=unmount_before_update, + ) + _URL_WEB_MODULE_CACHE[key] = module + return _vdom_from_web_module(module, import_names, fallback, allow_children) -def module_from_file( +def reactjs_component_from_file( name: str, file: str | Path, + import_names: str | list[str] | tuple[str, ...], fallback: Any | None = None, - resolve_exports: bool | None = None, - resolve_exports_depth: int = 5, + resolve_imports: bool | None = None, + resolve_imports_depth: int = 5, unmount_before_update: bool = False, symlink: bool = False, -) -> WebModule: - """Load a :class:`WebModule` from a given ``file`` + allow_children: bool = True, +) -> VdomConstructor | list[VdomConstructor]: + """Import a component from a file. Parameters: name: The name of the package file: The file from which the content of the web module will be created. + import_names: + One or more component names to import. If given as a string, a single component + will be returned. If a list is given, then a list of components will be + returned. fallback: What to temporarily display while the module is being loaded. resolve_imports: - Whether to try and find all the named exports of this module. - resolve_exports_depth: - How deeply to search for those exports. + Whether to try and find all the named imports of this module. + resolve_imports_depth: + How deeply to search for those imports. unmount_before_update: Cause the component to be unmounted before each update. This option should only be used if the imported package fails to re-render when props change. @@ -99,7 +115,177 @@ def module_from_file( elements must be changed on each render. See :issue:`461` for more info. symlink: Whether the web module should be saved as a symlink to the given ``file``. + allow_children: + Whether or not these components can have children. """ + key = f"{name}{resolve_imports}{resolve_imports_depth}{unmount_before_update}" + if key in _FILE_WEB_MODULE_CACHE: + module = _FILE_WEB_MODULE_CACHE[key] + else: + module = _module_from_file( + name, + file, + fallback=fallback, + resolve_imports=resolve_imports, + resolve_imports_depth=resolve_imports_depth, + unmount_before_update=unmount_before_update, + symlink=symlink, + ) + _FILE_WEB_MODULE_CACHE[key] = module + return _vdom_from_web_module(module, import_names, fallback, allow_children) + + +def reactjs_component_from_string( + name: str, + content: str, + import_names: str | list[str] | tuple[str, ...], + fallback: Any | None = None, + resolve_imports: bool | None = None, + resolve_imports_depth: int = 5, + unmount_before_update: bool = False, + allow_children: bool = True, +) -> VdomConstructor | list[VdomConstructor]: + """Import a component from a string. + + Parameters: + name: + The name of the package + content: + The contents of the web module + import_names: + One or more component names to import. If given as a string, a single component + will be returned. If a list is given, then a list of components will be + returned. + fallback: + What to temporarily display while the module is being loaded. + resolve_imports: + Whether to try and find all the named imports of this module. + resolve_imports_depth: + How deeply to search for those imports. + unmount_before_update: + Cause the component to be unmounted before each update. This option should + only be used if the imported package fails to re-render when props change. + Using this option has negative performance consequences since all DOM + elements must be changed on each render. See :issue:`461` for more info. + allow_children: + Whether or not these components can have children. + """ + key = f"{name}{resolve_imports}{resolve_imports_depth}{unmount_before_update}" + if key in _STRING_WEB_MODULE_CACHE: + module = _STRING_WEB_MODULE_CACHE[key] + else: + module = _module_from_string( + name, + content, + fallback=fallback, + resolve_imports=resolve_imports, + resolve_imports_depth=resolve_imports_depth, + unmount_before_update=unmount_before_update, + ) + _STRING_WEB_MODULE_CACHE[key] = module + return _vdom_from_web_module(module, import_names, fallback, allow_children) + + +def module_from_url( + url: str, + fallback: Any | None = None, + resolve_exports: bool | None = None, + resolve_exports_depth: int = 5, + unmount_before_update: bool = False, +) -> WebModule: # pragma: no cover + warn( + "module_from_url is deprecated, use reactjs_component_from_url instead", + DeprecationWarning, + ) + return _module_from_url( + url, + fallback=fallback, + resolve_imports=resolve_exports, + resolve_imports_depth=resolve_exports_depth, + unmount_before_update=unmount_before_update, + ) + + +def module_from_file( + name: str, + file: str | Path, + fallback: Any | None = None, + resolve_exports: bool | None = None, + resolve_exports_depth: int = 5, + unmount_before_update: bool = False, + symlink: bool = False, +) -> WebModule: # pragma: no cover + warn( + "module_from_file is deprecated, use reactjs_component_from_file instead", + DeprecationWarning, + ) + return _module_from_file( + name, + file, + fallback=fallback, + resolve_imports=resolve_exports, + resolve_imports_depth=resolve_exports_depth, + unmount_before_update=unmount_before_update, + symlink=symlink, + ) + + +def module_from_string( + name: str, + content: str, + fallback: Any | None = None, + resolve_exports: bool | None = None, + resolve_exports_depth: int = 5, + unmount_before_update: bool = False, +) -> WebModule: # pragma: no cover + warn( + "module_from_string is deprecated, use reactjs_component_from_string instead", + DeprecationWarning, + ) + return _module_from_string( + name, + content, + fallback=fallback, + resolve_imports=resolve_exports, + resolve_imports_depth=resolve_exports_depth, + unmount_before_update=unmount_before_update, + ) + + +def _module_from_url( + url: str, + fallback: Any | None = None, + resolve_imports: bool | None = None, + resolve_imports_depth: int = 5, + unmount_before_update: bool = False, +) -> WebModule: + return WebModule( + source=url, + source_type=URL_SOURCE, + default_fallback=fallback, + file=None, + export_names=( + resolve_module_exports_from_url(url, resolve_imports_depth) + if ( + resolve_imports + if resolve_imports is not None + else REACTPY_DEBUG.current + ) + else None + ), + unmount_before_update=unmount_before_update, + ) + + +def _module_from_file( + name: str, + file: str | Path, + fallback: Any | None = None, + resolve_imports: bool | None = None, + resolve_imports_depth: int = 5, + unmount_before_update: bool = False, + symlink: bool = False, +) -> WebModule: name += module_name_suffix(name) source_file = Path(file).resolve() @@ -124,10 +310,10 @@ def module_from_file( default_fallback=fallback, file=target_file, export_names=( - resolve_module_exports_from_file(source_file, resolve_exports_depth) + resolve_module_exports_from_file(source_file, resolve_imports_depth) if ( - resolve_exports - if resolve_exports is not None + resolve_imports + if resolve_imports is not None else REACTPY_DEBUG.current ) else None @@ -152,33 +338,14 @@ def _copy_file(target: Path, source: Path, symlink: bool) -> None: shutil.copy(source, target) -def module_from_string( +def _module_from_string( name: str, content: str, fallback: Any | None = None, - resolve_exports: bool | None = None, - resolve_exports_depth: int = 5, + resolve_imports: bool | None = None, + resolve_imports_depth: int = 5, unmount_before_update: bool = False, ) -> WebModule: - """Load a :class:`WebModule` whose ``content`` comes from a string. - - Parameters: - name: - The name of the package - content: - The contents of the web module - fallback: - What to temporarily display while the module is being loaded. - resolve_imports: - Whether to try and find all the named exports of this module. - resolve_exports_depth: - How deeply to search for those exports. - unmount_before_update: - Cause the component to be unmounted before each update. This option should - only be used if the imported package fails to re-render when props change. - Using this option has negative performance consequences since all DOM - elements must be changed on each render. See :issue:`461` for more info. - """ name += module_name_suffix(name) target_file = _web_module_path(name) @@ -199,10 +366,10 @@ def module_from_string( default_fallback=fallback, file=target_file, export_names=( - resolve_module_exports_from_file(target_file, resolve_exports_depth) + resolve_module_exports_from_file(target_file, resolve_imports_depth) if ( - resolve_exports - if resolve_exports is not None + resolve_imports + if resolve_imports is not None else REACTPY_DEBUG.current ) else None @@ -244,6 +411,19 @@ def export( export_names: str | list[str] | tuple[str, ...], fallback: Any | None = None, allow_children: bool = True, +) -> VdomConstructor | list[VdomConstructor]: # pragma: no cover + warn( + "export is deprecated, use reactjs_component_from_* functions instead", + DeprecationWarning, + ) + return _vdom_from_web_module(web_module, export_names, fallback, allow_children) + + +def _vdom_from_web_module( + web_module: WebModule, + export_names: str | list[str] | tuple[str, ...], + fallback: Any | None = None, + allow_children: bool = True, ) -> VdomConstructor | list[VdomConstructor]: """Return one or more VDOM constructors from a :class:`WebModule` diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index d233396fc..a0083bc6a 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -4,6 +4,7 @@ from servestatic import ServeStaticASGI import reactpy +import reactpy.web.module from reactpy.executors.asgi.standalone import ReactPy from reactpy.testing import ( BackendFixture, @@ -19,8 +20,8 @@ async def test_that_js_module_unmount_is_called(display: DisplayFixture): - SomeComponent = reactpy.web.export( - reactpy.web.module_from_file( + SomeComponent = reactpy.web.module._vdom_from_web_module( + reactpy.web.module._module_from_file( "set-flag-when-unmount-is-called", JS_FIXTURES_DIR / "set-flag-when-unmount-is-called.js", ), @@ -52,8 +53,10 @@ def ShowCurrentComponent(): async def test_module_from_url(browser): - SimpleButton = reactpy.web.export( - reactpy.web.module_from_url("/static/simple-button.js", resolve_exports=False), + SimpleButton = reactpy.web.module._vdom_from_web_module( + reactpy.web.module._module_from_url( + "/static/simple-button.js", resolve_imports=False + ), "SimpleButton", ) @@ -72,8 +75,8 @@ def ShowSimpleButton(): async def test_module_from_file(display: DisplayFixture): - SimpleButton = reactpy.web.export( - reactpy.web.module_from_file( + SimpleButton = reactpy.web.module._vdom_from_web_module( + reactpy.web.module._module_from_file( "simple-button", JS_FIXTURES_DIR / "simple-button.js" ), "SimpleButton", @@ -98,30 +101,30 @@ def test_module_from_file_source_conflict(tmp_path): first_file = tmp_path / "first.js" with pytest.raises(FileNotFoundError, match="does not exist"): - reactpy.web.module_from_file("temp", first_file) + reactpy.web.module._module_from_file("temp", first_file) first_file.touch() - reactpy.web.module_from_file("temp", first_file) + reactpy.web.module._module_from_file("temp", first_file) second_file = tmp_path / "second.js" second_file.touch() # ok, same content - reactpy.web.module_from_file("temp", second_file) + reactpy.web.module._module_from_file("temp", second_file) third_file = tmp_path / "third.js" third_file.write_text("something-different") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.web.module_from_file("temp", third_file) + reactpy.web.module._module_from_file("temp", third_file) def test_web_module_from_file_symlink(tmp_path): file = tmp_path / "temp.js" file.touch() - module = reactpy.web.module_from_file("temp", file, symlink=True) + module = reactpy.web.module._module_from_file("temp", file, symlink=True) assert module.file.resolve().read_text() == "" @@ -134,44 +137,44 @@ def test_web_module_from_file_symlink_twice(tmp_path): file_1 = tmp_path / "temp_1.js" file_1.touch() - reactpy.web.module_from_file("temp", file_1, symlink=True) + reactpy.web.module._module_from_file("temp", file_1, symlink=True) with assert_reactpy_did_not_log(r"Existing web module .* will be replaced with"): - reactpy.web.module_from_file("temp", file_1, symlink=True) + reactpy.web.module._module_from_file("temp", file_1, symlink=True) file_2 = tmp_path / "temp_2.js" file_2.write_text("something") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.web.module_from_file("temp", file_2, symlink=True) + reactpy.web.module._module_from_file("temp", file_2, symlink=True) def test_web_module_from_file_replace_existing(tmp_path): file1 = tmp_path / "temp1.js" file1.touch() - reactpy.web.module_from_file("temp", file1) + reactpy.web.module._module_from_file("temp", file1) file2 = tmp_path / "temp2.js" file2.write_text("something") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.web.module_from_file("temp", file2) + reactpy.web.module._module_from_file("temp", file2) def test_module_missing_exports(): module = WebModule("test", NAME_SOURCE, None, {"a", "b", "c"}, None, False) with pytest.raises(ValueError, match="does not export 'x'"): - reactpy.web.export(module, "x") + reactpy.web.module._vdom_from_web_module(module, "x") with pytest.raises(ValueError, match=r"does not export \['x', 'y'\]"): - reactpy.web.export(module, ["x", "y"]) + reactpy.web.module._vdom_from_web_module(module, ["x", "y"]) async def test_module_exports_multiple_components(display: DisplayFixture): - Header1, Header2 = reactpy.web.export( - reactpy.web.module_from_file( + Header1, Header2 = reactpy.web.module._vdom_from_web_module( + reactpy.web.module._module_from_file( "exports-two-components", JS_FIXTURES_DIR / "exports-two-components.js" ), ["Header1", "Header2"], @@ -187,10 +190,12 @@ async def test_module_exports_multiple_components(display: DisplayFixture): async def test_imported_components_can_render_children(display: DisplayFixture): - module = reactpy.web.module_from_file( + module = reactpy.web.module._module_from_file( "component-can-have-child", JS_FIXTURES_DIR / "component-can-have-child.js" ) - Parent, Child = reactpy.web.export(module, ["Parent", "Child"]) + Parent, Child = reactpy.web.module._vdom_from_web_module( + module, ["Parent", "Child"] + ) await display.show( lambda: Parent( @@ -219,10 +224,10 @@ async def test_keys_properly_propagated(display: DisplayFixture): This property is required for certain JS components, such as the GridLayout from react-grid-layout. """ - module = reactpy.web.module_from_file( + module = reactpy.web.module._module_from_file( "keys-properly-propagated", JS_FIXTURES_DIR / "keys-properly-propagated.js" ) - GridLayout = reactpy.web.export(module, "GridLayout") + GridLayout = reactpy.web.module._vdom_from_web_module(module, "GridLayout") await display.show( lambda: GridLayout( @@ -273,12 +278,14 @@ async def test_keys_properly_propagated(display: DisplayFixture): async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): - module = reactpy.web.module_from_file( + module = reactpy.web.module._module_from_file( "subcomponent-notation", JS_FIXTURES_DIR / "subcomponent-notation.js", ) - InputGroup, InputGroupText, FormControl, FormLabel = reactpy.web.export( - module, ["InputGroup", "InputGroup.Text", "Form.Control", "Form.Label"] + InputGroup, InputGroupText, FormControl, FormLabel = ( + reactpy.web.module._vdom_from_web_module( + module, ["InputGroup", "InputGroup.Text", "Form.Control", "Form.Label"] + ) ) content = reactpy.html.div( @@ -333,11 +340,13 @@ async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): - module = reactpy.web.module_from_file( + module = reactpy.web.module._module_from_file( "subcomponent-notation", JS_FIXTURES_DIR / "subcomponent-notation.js", ) - InputGroup, Form = reactpy.web.export(module, ["InputGroup", "Form"]) + InputGroup, Form = reactpy.web.module._vdom_from_web_module( + module, ["InputGroup", "Form"] + ) content = reactpy.html.div( {"id": "the-parent"}, @@ -391,10 +400,10 @@ async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): async def test_callable_prop_with_javacript(display: DisplayFixture): - module = reactpy.web.module_from_file( + module = reactpy.web.module._module_from_file( "callable-prop", JS_FIXTURES_DIR / "callable-prop.js" ) - Component = reactpy.web.export(module, "Component") + Component = reactpy.web.module._vdom_from_web_module(module, "Component") @reactpy.component def App(): @@ -411,7 +420,101 @@ def App(): assert await my_div.inner_text() == "PREFIX TEXT: TEST 123" -def test_module_from_string(): - reactpy.web.module_from_string("temp", "old") +def test_reactjs_component_from_string(): + reactpy.web.reactjs_component_from_string( + "temp", "old", "Component", resolve_imports=False + ) + reactpy.web.module._STRING_WEB_MODULE_CACHE.clear() with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.web.module_from_string("temp", "new") + reactpy.web.reactjs_component_from_string( + "temp", "new", "Component", resolve_imports=False + ) + + +async def test_reactjs_component_from_url(browser): + SimpleButton = reactpy.web.reactjs_component_from_url( + "/static/simple-button.js", "SimpleButton", resolve_imports=False + ) + + @reactpy.component + def ShowSimpleButton(): + return SimpleButton({"id": "my-button"}) + + app = ReactPy(ShowSimpleButton) + app = ServeStaticASGI(app, JS_FIXTURES_DIR, "/static/") + + async with BackendFixture(app) as server: + async with DisplayFixture(server, browser) as display: + await display.show(ShowSimpleButton) + + await display.page.wait_for_selector("#my-button") + + +async def test_reactjs_component_from_file(display: DisplayFixture): + SimpleButton = reactpy.web.reactjs_component_from_file( + "simple-button", JS_FIXTURES_DIR / "simple-button.js", "SimpleButton" + ) + + is_clicked = reactpy.Ref(False) + + @reactpy.component + def ShowSimpleButton(): + return SimpleButton( + {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)} + ) + + await display.show(ShowSimpleButton) + + button = await display.page.wait_for_selector("#my-button") + await button.click() + await poll(lambda: is_clicked.current).until_is(True) + + +def test_reactjs_component_from_url_caching(): + url = "https://example.com/module.js" + reactpy.web.module._URL_WEB_MODULE_CACHE.clear() + + # First import + reactpy.web.reactjs_component_from_url(url, "Component", resolve_imports=False) + # Find the key that contains the 'url' substring + key = next(x for x in reactpy.web.module._URL_WEB_MODULE_CACHE.keys() if url in x) + module1 = reactpy.web.module._URL_WEB_MODULE_CACHE[key] + assert module1 + initial_length = len(reactpy.web.module._URL_WEB_MODULE_CACHE) + + # Second import + reactpy.web.reactjs_component_from_url(url, "Component", resolve_imports=False) + assert len(reactpy.web.module._URL_WEB_MODULE_CACHE) == initial_length + + +def test_reactjs_component_from_file_caching(tmp_path): + file = tmp_path / "test.js" + file.write_text("export function Component() {}") + name = "test-file-module" + reactpy.web.module._FILE_WEB_MODULE_CACHE.clear() + + reactpy.web.reactjs_component_from_file(name, file, "Component") + key = next(x for x in reactpy.web.module._FILE_WEB_MODULE_CACHE.keys() if name in x) + module1 = reactpy.web.module._FILE_WEB_MODULE_CACHE[key] + assert module1 + initial_length = len(reactpy.web.module._FILE_WEB_MODULE_CACHE) + + reactpy.web.reactjs_component_from_file(name, file, "Component") + assert len(reactpy.web.module._FILE_WEB_MODULE_CACHE) == initial_length + + +def test_reactjs_component_from_string_caching(): + name = "test-string-module" + content = "export function Component() {}" + reactpy.web.module._STRING_WEB_MODULE_CACHE.clear() + + reactpy.web.reactjs_component_from_string(name, content, "Component") + key = next( + x for x in reactpy.web.module._STRING_WEB_MODULE_CACHE.keys() if name in x + ) + module1 = reactpy.web.module._STRING_WEB_MODULE_CACHE[key] + assert module1 + initial_length = len(reactpy.web.module._STRING_WEB_MODULE_CACHE) + + reactpy.web.reactjs_component_from_string(name, content, "Component") + assert len(reactpy.web.module._STRING_WEB_MODULE_CACHE) == initial_length