Skip to content

Commit c890965

Browse files
authoredMar 26, 2025
Support subcomponent notation in export (#1285)
1 parent 9c32dc1 commit c890965

File tree

9 files changed

+252
-48
lines changed

9 files changed

+252
-48
lines changed
 

‎.github/workflows/check.yml

+3
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ jobs:
3737
run-cmd: "hatch run docs:check"
3838
python-version: '["3.11"]'
3939
test-javascript:
40+
# Temporarily disabled, tests are broken but a rewrite is intended
41+
# https://github.com/reactive-python/reactpy/issues/1196
42+
if: 0
4043
uses: ./.github/workflows/.hatch-run.yml
4144
with:
4245
job-name: "{1}"

‎docs/source/about/changelog.rst

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Unreleased
2929
- :pull:`1281` - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``)
3030
- :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries.
3131
- :pull:`1281` - Added type hints to ``reactpy.html`` attributes.
32+
- :pull:`1285` - Added support for nested components in web modules
3233

3334
**Changed**
3435

‎src/js/packages/@reactpy/client/src/vdom.tsx

+45-8
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,16 @@ function createImportSourceElement(props: {
7878
stringifyImportSource(props.model.importSource),
7979
);
8080
return null;
81-
} else if (!props.module[props.model.tagName]) {
82-
log.error(
83-
"Module from source " +
84-
stringifyImportSource(props.currentImportSource) +
85-
` does not export ${props.model.tagName}`,
86-
);
87-
return null;
8881
} else {
89-
type = props.module[props.model.tagName];
82+
type = getComponentFromModule(
83+
props.module,
84+
props.model.tagName,
85+
props.model.importSource,
86+
);
87+
if (!type) {
88+
// Error message logged within getComponentFromModule
89+
return null;
90+
}
9091
}
9192
} else {
9293
type = props.model.tagName;
@@ -103,6 +104,42 @@ function createImportSourceElement(props: {
103104
);
104105
}
105106

107+
function getComponentFromModule(
108+
module: ReactPyModule,
109+
componentName: string,
110+
importSource: ReactPyVdomImportSource,
111+
): any {
112+
/* Gets the component with the provided name from the provided module.
113+
114+
Built specifically to work on inifinitely deep nested components.
115+
For example, component "My.Nested.Component" is accessed from
116+
ModuleA like so: ModuleA["My"]["Nested"]["Component"].
117+
*/
118+
const componentParts: string[] = componentName.split(".");
119+
let Component: any = null;
120+
for (let i = 0; i < componentParts.length; i++) {
121+
const iterAttr = componentParts[i];
122+
Component = i == 0 ? module[iterAttr] : Component[iterAttr];
123+
if (!Component) {
124+
if (i == 0) {
125+
log.error(
126+
"Module from source " +
127+
stringifyImportSource(importSource) +
128+
` does not export ${iterAttr}`,
129+
);
130+
} else {
131+
console.error(
132+
`Component ${componentParts.slice(0, i).join(".")} from source ` +
133+
stringifyImportSource(importSource) +
134+
` does not have subcomponent ${iterAttr}`,
135+
);
136+
}
137+
break;
138+
}
139+
}
140+
return Component;
141+
}
142+
106143
function isImportSourceEqual(
107144
source1: ReactPyVdomImportSource,
108145
source2: ReactPyVdomImportSource,

‎src/reactpy/core/vdom.py

+11
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,17 @@ def __init__(
135135
self.__module__ = module_name
136136
self.__qualname__ = f"{module_name}.{tag_name}"
137137

138+
def __getattr__(self, attr: str) -> Vdom:
139+
"""Supports accessing nested web module components"""
140+
if not self.import_source:
141+
msg = "Nested components can only be accessed on web module components."
142+
raise AttributeError(msg)
143+
return Vdom(
144+
f"{self.__name__}.{attr}",
145+
allow_children=self.allow_children,
146+
import_source=self.import_source,
147+
)
148+
138149
@overload
139150
def __call__(
140151
self, attributes: VdomAttributes, /, *children: VdomChildren

‎src/reactpy/web/module.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -260,14 +260,18 @@ def export(
260260
if isinstance(export_names, str):
261261
if (
262262
web_module.export_names is not None
263-
and export_names not in web_module.export_names
263+
and export_names.split(".")[0] not in web_module.export_names
264264
):
265265
msg = f"{web_module.source!r} does not export {export_names!r}"
266266
raise ValueError(msg)
267267
return _make_export(web_module, export_names, fallback, allow_children)
268268
else:
269269
if web_module.export_names is not None:
270-
missing = sorted(set(export_names).difference(web_module.export_names))
270+
missing = sorted(
271+
{e.split(".")[0] for e in export_names}.difference(
272+
web_module.export_names
273+
)
274+
)
271275
if missing:
272276
msg = f"{web_module.source!r} does not export {missing!r}"
273277
raise ValueError(msg)

‎tests/test_core/test_vdom.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ def test_is_vdom(result, value):
7171
{"tagName": "div", "attributes": {"tagName": "div"}},
7272
),
7373
(
74-
reactpy.Vdom("div")((i for i in range(3))),
74+
reactpy.Vdom("div")(i for i in range(3)),
7575
{"tagName": "div", "children": [0, 1, 2]},
7676
),
7777
(
78-
reactpy.Vdom("div")((x**2 for x in [1, 2, 3])),
78+
reactpy.Vdom("div")(x**2 for x in [1, 2, 3]),
7979
{"tagName": "div", "children": [1, 4, 9]},
8080
),
8181
(
@@ -123,6 +123,15 @@ def test_make_vdom_constructor():
123123
assert no_children() == {"tagName": "no-children"}
124124

125125

126+
def test_nested_html_access_raises_error():
127+
elmt = Vdom("div")
128+
129+
with pytest.raises(
130+
AttributeError, match="can only be accessed on web module components"
131+
):
132+
elmt.fails()
133+
134+
126135
@pytest.mark.parametrize(
127136
"value",
128137
[
@@ -293,7 +302,7 @@ def test_invalid_vdom(value, error_message_pattern):
293302
@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode")
294303
def test_warn_cannot_verify_keypath_for_genereators():
295304
with pytest.warns(UserWarning) as record:
296-
reactpy.Vdom("div")((1 for i in range(10)))
305+
reactpy.Vdom("div")(1 for i in range(10))
297306
assert len(record) == 1
298307
assert (
299308
record[0]

‎tests/test_utils.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,11 @@ def test_string_to_reactpy(case):
188188
# 8: Infer ReactJS `key` from the `key` attribute
189189
{
190190
"source": '<div key="my-key"></div>',
191-
"model": {"tagName": "div", "attributes": {"key": "my-key"}, "key": "my-key"},
191+
"model": {
192+
"tagName": "div",
193+
"attributes": {"key": "my-key"},
194+
"key": "my-key",
195+
},
192196
},
193197
],
194198
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React from "https://esm.sh/react@19.0"
2+
import ReactDOM from "https://esm.sh/react-dom@19.0/client"
3+
import {InputGroup, Form} from "https://esm.sh/react-bootstrap@2.10.2?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=InputGroup,Form";
4+
export {InputGroup, Form};
5+
6+
export function bind(node, config) {
7+
const root = ReactDOM.createRoot(node);
8+
return {
9+
create: (type, props, children) =>
10+
React.createElement(type, props, ...children),
11+
render: (element) => root.render(element),
12+
unmount: () => root.unmount()
13+
};
14+
}

‎tests/test_web/test_module.py

+155-34
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,8 @@ async def test_keys_properly_propagated(display: DisplayFixture):
214214
215215
The `key` property was being lost in its propagation from the server-side ReactPy
216216
definition to the front-end JavaScript.
217-
218-
This property is required for certain JS components, such as the GridLayout from
217+
218+
This property is required for certain JS components, such as the GridLayout from
219219
react-grid-layout.
220220
"""
221221
module = reactpy.web.module_from_file(
@@ -224,50 +224,171 @@ async def test_keys_properly_propagated(display: DisplayFixture):
224224
GridLayout = reactpy.web.export(module, "GridLayout")
225225

226226
await display.show(
227-
lambda: GridLayout({
228-
"layout": [
229-
{
230-
"i": "a",
231-
"x": 0,
232-
"y": 0,
233-
"w": 1,
234-
"h": 2,
235-
"static": True,
236-
},
237-
{
238-
"i": "b",
239-
"x": 1,
240-
"y": 0,
241-
"w": 3,
242-
"h": 2,
243-
"minW": 2,
244-
"maxW": 4,
245-
},
246-
{
247-
"i": "c",
248-
"x": 4,
249-
"y": 0,
250-
"w": 1,
251-
"h": 2,
252-
}
253-
],
254-
"cols": 12,
255-
"rowHeight": 30,
256-
"width": 1200,
257-
},
227+
lambda: GridLayout(
228+
{
229+
"layout": [
230+
{
231+
"i": "a",
232+
"x": 0,
233+
"y": 0,
234+
"w": 1,
235+
"h": 2,
236+
"static": True,
237+
},
238+
{
239+
"i": "b",
240+
"x": 1,
241+
"y": 0,
242+
"w": 3,
243+
"h": 2,
244+
"minW": 2,
245+
"maxW": 4,
246+
},
247+
{
248+
"i": "c",
249+
"x": 4,
250+
"y": 0,
251+
"w": 1,
252+
"h": 2,
253+
},
254+
],
255+
"cols": 12,
256+
"rowHeight": 30,
257+
"width": 1200,
258+
},
258259
reactpy.html.div({"key": "a"}, "a"),
259260
reactpy.html.div({"key": "b"}, "b"),
260261
reactpy.html.div({"key": "c"}, "c"),
261262
)
262263
)
263264

264-
parent = await display.page.wait_for_selector(".react-grid-layout", state="attached")
265+
parent = await display.page.wait_for_selector(
266+
".react-grid-layout", state="attached"
267+
)
265268
children = await parent.query_selector_all("div")
266269

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

270273

274+
async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture):
275+
module = reactpy.web.module_from_file(
276+
"subcomponent-notation",
277+
JS_FIXTURES_DIR / "subcomponent-notation.js",
278+
)
279+
InputGroup, InputGroupText, FormControl, FormLabel = reactpy.web.export(
280+
module, ["InputGroup", "InputGroup.Text", "Form.Control", "Form.Label"]
281+
)
282+
283+
content = reactpy.html.div(
284+
{"id": "the-parent"},
285+
InputGroup(
286+
InputGroupText({"id": "basic-addon1"}, "@"),
287+
FormControl(
288+
{
289+
"placeholder": "Username",
290+
"aria-label": "Username",
291+
"aria-describedby": "basic-addon1",
292+
}
293+
),
294+
),
295+
InputGroup(
296+
FormControl(
297+
{
298+
"placeholder": "Recipient's username",
299+
"aria-label": "Recipient's username",
300+
"aria-describedby": "basic-addon2",
301+
}
302+
),
303+
InputGroupText({"id": "basic-addon2"}, "@example.com"),
304+
),
305+
FormLabel({"htmlFor": "basic-url"}, "Your vanity URL"),
306+
InputGroup(
307+
InputGroupText({"id": "basic-addon3"}, "https://example.com/users/"),
308+
FormControl({"id": "basic-url", "aria-describedby": "basic-addon3"}),
309+
),
310+
InputGroup(
311+
InputGroupText("$"),
312+
FormControl({"aria-label": "Amount (to the nearest dollar)"}),
313+
InputGroupText(".00"),
314+
),
315+
InputGroup(
316+
InputGroupText("With textarea"),
317+
FormControl({"as": "textarea", "aria-label": "With textarea"}),
318+
),
319+
)
320+
321+
await display.show(lambda: content)
322+
323+
await display.page.wait_for_selector("#basic-addon3", state="attached")
324+
parent = await display.page.wait_for_selector("#the-parent", state="attached")
325+
input_group_text = await parent.query_selector_all(".input-group-text")
326+
form_control = await parent.query_selector_all(".form-control")
327+
form_label = await parent.query_selector_all(".form-label")
328+
329+
assert len(input_group_text) == 6
330+
assert len(form_control) == 5
331+
assert len(form_label) == 1
332+
333+
334+
async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture):
335+
module = reactpy.web.module_from_file(
336+
"subcomponent-notation",
337+
JS_FIXTURES_DIR / "subcomponent-notation.js",
338+
)
339+
InputGroup, Form = reactpy.web.export(module, ["InputGroup", "Form"])
340+
341+
content = reactpy.html.div(
342+
{"id": "the-parent"},
343+
InputGroup(
344+
InputGroup.Text({"id": "basic-addon1"}, "@"),
345+
Form.Control(
346+
{
347+
"placeholder": "Username",
348+
"aria-label": "Username",
349+
"aria-describedby": "basic-addon1",
350+
}
351+
),
352+
),
353+
InputGroup(
354+
Form.Control(
355+
{
356+
"placeholder": "Recipient's username",
357+
"aria-label": "Recipient's username",
358+
"aria-describedby": "basic-addon2",
359+
}
360+
),
361+
InputGroup.Text({"id": "basic-addon2"}, "@example.com"),
362+
),
363+
Form.Label({"htmlFor": "basic-url"}, "Your vanity URL"),
364+
InputGroup(
365+
InputGroup.Text({"id": "basic-addon3"}, "https://example.com/users/"),
366+
Form.Control({"id": "basic-url", "aria-describedby": "basic-addon3"}),
367+
),
368+
InputGroup(
369+
InputGroup.Text("$"),
370+
Form.Control({"aria-label": "Amount (to the nearest dollar)"}),
371+
InputGroup.Text(".00"),
372+
),
373+
InputGroup(
374+
InputGroup.Text("With textarea"),
375+
Form.Control({"as": "textarea", "aria-label": "With textarea"}),
376+
),
377+
)
378+
379+
await display.show(lambda: content)
380+
381+
await display.page.wait_for_selector("#basic-addon3", state="attached")
382+
parent = await display.page.wait_for_selector("#the-parent", state="attached")
383+
input_group_text = await parent.query_selector_all(".input-group-text")
384+
form_control = await parent.query_selector_all(".form-control")
385+
form_label = await parent.query_selector_all(".form-label")
386+
387+
assert len(input_group_text) == 6
388+
assert len(form_control) == 5
389+
assert len(form_label) == 1
390+
391+
271392
def test_module_from_string():
272393
reactpy.web.module_from_string("temp", "old")
273394
with assert_reactpy_did_log(r"Existing web module .* will be replaced with"):

0 commit comments

Comments
 (0)
Please sign in to comment.