Skip to content

Commit c890965

Browse files
authored
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/[email protected]"
2+
import ReactDOM from "https://esm.sh/[email protected]/client"
3+
import {InputGroup, Form} from "https://esm.sh/[email protected][email protected],[email protected],[email protected]&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+
}

0 commit comments

Comments
 (0)