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
29 changes: 26 additions & 3 deletions src/js/packages/@reactpy/client/src/vdom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function createImportSourceElement(props: {
}): any {
let type: any;
if (props.model.importSource) {
const rootType = props.model.tagName.split(".")[0];
Copy link
Contributor

@Archmonger Archmonger Mar 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this variable name makes much sense. Shouldn't it be something like rootPackageName?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used rootType since this the variable two lines above is type - which refers to the type of Element (i.e. Component) being created. But I went ahead and changed this anyway. In fact, I decided to somewhat refactor the createImportSourceElement function to move a bit more logic into the getComponentFromModule function (renamed from tryGetSubType, as you requested below). Have a look at it now and see if the logic flows well.

if (
!isImportSourceEqual(props.currentImportSource, props.model.importSource)
) {
Expand All @@ -78,15 +79,16 @@ function createImportSourceElement(props: {
stringifyImportSource(props.model.importSource),
);
return null;
} else if (!props.module[props.model.tagName]) {
} else if (!props.module[rootType]) {
log.error(
"Module from source " +
stringifyImportSource(props.currentImportSource) +
` does not export ${props.model.tagName}`,
` does not export ${rootType}`,
);
return null;
} else {
type = props.module[props.model.tagName];
type = tryGetSubType(props.module, props.model.tagName);
if (!type) return null;
}
} else {
type = props.model.tagName;
Expand All @@ -103,6 +105,27 @@ function createImportSourceElement(props: {
);
}

function tryGetSubType(module: ReactPyModule, component: string) {
let subComponents: string[] = component.split(".");
const rootComponent: string = subComponents[0];
let subComponentAccessor: string = rootComponent;
let type: any = module[rootComponent];

subComponents = subComponents.slice(1);
for (let i = 0; i < subComponents.length; i++) {
const subComponent = subComponents[i];
subComponentAccessor += "." + subComponent;
type = type[subComponent];
if (!type) {
console.error(
`Component ${rootComponent} does not have subcomponent ${subComponentAccessor}`,
);
break;
}
}
return type;
}

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

def __getattr__(self, attr: str) -> Vdom:
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
6 changes: 3 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 @@ -293,7 +293,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