Skip to content
Merged
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