Skip to content

Subcomponent notation #1285

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Mar 26, 2025
1 change: 1 addition & 0 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Unreleased
- :pull:`1281` - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``)
- :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries.
- :pull:`1281` - Added type hints to ``reactpy.html`` attributes.
- :pull:`1285` - Added support for nested components in web modules

**Changed**

Expand Down
53 changes: 45 additions & 8 deletions src/js/packages/@reactpy/client/src/vdom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,16 @@ function createImportSourceElement(props: {
stringifyImportSource(props.model.importSource),
);
return null;
} else if (!props.module[props.model.tagName]) {
log.error(
"Module from source " +
stringifyImportSource(props.currentImportSource) +
` does not export ${props.model.tagName}`,
);
return null;
} else {
type = props.module[props.model.tagName];
type = getComponentFromModule(
props.module,
props.model.tagName,
props.model.importSource,
);
if (!type) {
// Error message logged within getComponentFromModule
return null;
}
}
} else {
type = props.model.tagName;
Expand All @@ -103,6 +104,42 @@ function createImportSourceElement(props: {
);
}

function getComponentFromModule(
module: ReactPyModule,
componentName: string,
importSource: ReactPyVdomImportSource,
): any {
/* Gets the component with the provided name from the provided module.

Built specifically to work on inifinitely deep nested components.
For example, component "My.Nested.Component" is accessed from
ModuleA like so: ModuleA["My"]["Nested"]["Component"].
*/
const componentParts: string[] = componentName.split(".");
let Component: any = null;
for (let i = 0; i < componentParts.length; i++) {
const iterAttr = componentParts[i];
Component = i == 0 ? module[iterAttr] : Component[iterAttr];
if (!Component) {
if (i == 0) {
log.error(
"Module from source " +
stringifyImportSource(importSource) +
` does not export ${iterAttr}`,
);
} else {
console.error(
`Component ${componentParts.slice(0, i).join(".")} from source ` +
stringifyImportSource(importSource) +
` does not have subcomponent ${iterAttr}`,
);
}
break;
}
}
return Component;
}

function isImportSourceEqual(
source1: ReactPyVdomImportSource,
source2: ReactPyVdomImportSource,
Expand Down
11 changes: 11 additions & 0 deletions src/reactpy/core/vdom.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@ def __init__(
self.__module__ = module_name
self.__qualname__ = f"{module_name}.{tag_name}"

def __getattr__(self, attr: str) -> Vdom:
"""Supports accessing nested web module components"""
if not self.import_source:
msg = "Nested comopnents can only be accessed on web module components."
raise AttributeError(msg)
return Vdom(
f"{self.__name__}.{attr}",
allow_children=self.allow_children,
import_source=self.import_source,
)

@overload
def __call__(
self, attributes: VdomAttributes, /, *children: VdomChildren
Expand Down
8 changes: 6 additions & 2 deletions src/reactpy/web/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,18 @@ def export(
if isinstance(export_names, str):
if (
web_module.export_names is not None
and export_names not in web_module.export_names
and export_names.split(".")[0] not in web_module.export_names
):
msg = f"{web_module.source!r} does not export {export_names!r}"
raise ValueError(msg)
return _make_export(web_module, export_names, fallback, allow_children)
else:
if web_module.export_names is not None:
missing = sorted(set(export_names).difference(web_module.export_names))
missing = sorted(
{e.split(".")[0] for e in export_names}.difference(
web_module.export_names
)
)
if missing:
msg = f"{web_module.source!r} does not export {missing!r}"
raise ValueError(msg)
Expand Down
15 changes: 12 additions & 3 deletions tests/test_core/test_vdom.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ def test_is_vdom(result, value):
{"tagName": "div", "attributes": {"tagName": "div"}},
),
(
reactpy.Vdom("div")((i for i in range(3))),
reactpy.Vdom("div")(i for i in range(3)),
{"tagName": "div", "children": [0, 1, 2]},
),
(
reactpy.Vdom("div")((x**2 for x in [1, 2, 3])),
reactpy.Vdom("div")(x**2 for x in [1, 2, 3]),
{"tagName": "div", "children": [1, 4, 9]},
),
(
Expand Down Expand Up @@ -123,6 +123,15 @@ def test_make_vdom_constructor():
assert no_children() == {"tagName": "no-children"}


def test_nested_html_access_raises_error():
elmt = Vdom("div")

with pytest.raises(
AttributeError, match="can only be accessed on web module components"
):
elmt.fails()


@pytest.mark.parametrize(
"value",
[
Expand Down Expand Up @@ -293,7 +302,7 @@ def test_invalid_vdom(value, error_message_pattern):
@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode")
def test_warn_cannot_verify_keypath_for_genereators():
with pytest.warns(UserWarning) as record:
reactpy.Vdom("div")((1 for i in range(10)))
reactpy.Vdom("div")(1 for i in range(10))
assert len(record) == 1
assert (
record[0]
Expand Down
6 changes: 5 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,11 @@ def test_string_to_reactpy(case):
# 8: Infer ReactJS `key` from the `key` attribute
{
"source": '<div key="my-key"></div>',
"model": {"tagName": "div", "attributes": {"key": "my-key"}, "key": "my-key"},
"model": {
"tagName": "div",
"attributes": {"key": "my-key"},
"key": "my-key",
},
},
],
)
Expand Down
14 changes: 14 additions & 0 deletions tests/test_web/js_fixtures/subcomponent-notation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from "https://esm.sh/[email protected]"
import ReactDOM from "https://esm.sh/[email protected]/client"
import {InputGroup, Form} from "https://esm.sh/[email protected][email protected],[email protected],[email protected]&exports=InputGroup,Form";
export {InputGroup, Form};

export function bind(node, config) {
const root = ReactDOM.createRoot(node);
return {
create: (type, props, children) =>
React.createElement(type, props, ...children),
render: (element) => root.render(element),
unmount: () => root.unmount()
};
}
187 changes: 153 additions & 34 deletions tests/test_web/test_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ async def test_keys_properly_propagated(display: DisplayFixture):

The `key` property was being lost in its propagation from the server-side ReactPy
definition to the front-end JavaScript.
This property is required for certain JS components, such as the GridLayout from

This property is required for certain JS components, such as the GridLayout from
react-grid-layout.
"""
module = reactpy.web.module_from_file(
Expand All @@ -224,50 +224,169 @@ async def test_keys_properly_propagated(display: DisplayFixture):
GridLayout = reactpy.web.export(module, "GridLayout")

await display.show(
lambda: GridLayout({
"layout": [
{
"i": "a",
"x": 0,
"y": 0,
"w": 1,
"h": 2,
"static": True,
},
{
"i": "b",
"x": 1,
"y": 0,
"w": 3,
"h": 2,
"minW": 2,
"maxW": 4,
},
{
"i": "c",
"x": 4,
"y": 0,
"w": 1,
"h": 2,
}
],
"cols": 12,
"rowHeight": 30,
"width": 1200,
},
lambda: GridLayout(
{
"layout": [
{
"i": "a",
"x": 0,
"y": 0,
"w": 1,
"h": 2,
"static": True,
},
{
"i": "b",
"x": 1,
"y": 0,
"w": 3,
"h": 2,
"minW": 2,
"maxW": 4,
},
{
"i": "c",
"x": 4,
"y": 0,
"w": 1,
"h": 2,
},
],
"cols": 12,
"rowHeight": 30,
"width": 1200,
},
reactpy.html.div({"key": "a"}, "a"),
reactpy.html.div({"key": "b"}, "b"),
reactpy.html.div({"key": "c"}, "c"),
)
)

parent = await display.page.wait_for_selector(".react-grid-layout", state="attached")
parent = await display.page.wait_for_selector(
".react-grid-layout", state="attached"
)
children = await parent.query_selector_all("div")

# The children simply will not render unless they receive the key prop
assert len(children) == 3


async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture):
module = reactpy.web.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"]
)

content = reactpy.html.div(
{"id": "the-parent"},
InputGroup(
InputGroupText({"id": "basic-addon1"}, "@"),
FormControl(
{
"placeholder": "Username",
"aria-label": "Username",
"aria-describedby": "basic-addon1",
}
),
),
InputGroup(
FormControl(
{
"placeholder": "Recipient's username",
"aria-label": "Recipient's username",
"aria-describedby": "basic-addon2",
}
),
InputGroupText({"id": "basic-addon2"}, "@example.com"),
),
FormLabel({"htmlFor": "basic-url"}, "Your vanity URL"),
InputGroup(
InputGroupText({"id": "basic-addon3"}, "https://example.com/users/"),
FormControl({"id": "basic-url", "aria-describedby": "basic-addon3"}),
),
InputGroup(
InputGroupText("$"),
FormControl({"aria-label": "Amount (to the nearest dollar)"}),
InputGroupText(".00"),
),
InputGroup(
InputGroupText("With textarea"),
FormControl({"as": "textarea", "aria-label": "With textarea"}),
),
)

await display.show(lambda: content)

parent = await display.page.wait_for_selector("#the-parent", state="visible")
input_group_text = await parent.query_selector_all(".input-group-text")
form_control = await parent.query_selector_all(".form-control")
form_label = await parent.query_selector_all(".form-label")

assert len(input_group_text) == 6
assert len(form_control) == 5
assert len(form_label) == 1


async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture):
module = reactpy.web.module_from_file(
"subcomponent-notation",
JS_FIXTURES_DIR / "subcomponent-notation.js",
)
InputGroup, Form = reactpy.web.export(module, ["InputGroup", "Form"])

content = reactpy.html.div(
{"id": "the-parent"},
InputGroup(
InputGroup.Text({"id": "basic-addon1"}, "@"),
Form.Control(
{
"placeholder": "Username",
"aria-label": "Username",
"aria-describedby": "basic-addon1",
}
),
),
InputGroup(
Form.Control(
{
"placeholder": "Recipient's username",
"aria-label": "Recipient's username",
"aria-describedby": "basic-addon2",
}
),
InputGroup.Text({"id": "basic-addon2"}, "@example.com"),
),
Form.Label({"htmlFor": "basic-url"}, "Your vanity URL"),
InputGroup(
InputGroup.Text({"id": "basic-addon3"}, "https://example.com/users/"),
Form.Control({"id": "basic-url", "aria-describedby": "basic-addon3"}),
),
InputGroup(
InputGroup.Text("$"),
Form.Control({"aria-label": "Amount (to the nearest dollar)"}),
InputGroup.Text(".00"),
),
InputGroup(
InputGroup.Text("With textarea"),
Form.Control({"as": "textarea", "aria-label": "With textarea"}),
),
)

await display.show(lambda: content)

parent = await display.page.wait_for_selector("#the-parent", state="visible")
input_group_text = await parent.query_selector_all(".input-group-text")
form_control = await parent.query_selector_all(".form-control")
form_label = await parent.query_selector_all(".form-label")

assert len(input_group_text) == 6
assert len(form_control) == 5
assert len(form_label) == 1


def test_module_from_string():
reactpy.web.module_from_string("temp", "old")
with assert_reactpy_did_log(r"Existing web module .* will be replaced with"):
Expand Down
Loading