Skip to content

Commit bf63a62

Browse files
committed
federate modules with mount function
Allows modules to define a mount function that can attach components directly to a DOM element. This gives authors of custom components a high degree of flexibility and alleviates the problem have having conflicting React instances when idom-client-react is installed independently. For example, custom components with a mount function need not be implemented in React.
1 parent bf2a6be commit bf63a62

File tree

16 files changed

+190
-111
lines changed

16 files changed

+190
-111
lines changed

scripts/live_docs.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def new_builder():
2828
[s.stop() for s in _running_idom_servers]
2929

3030
# we need to set this before `docs.main` does
31-
IDOM_CLIENT_IMPORT_SOURCE_URL.set(
31+
IDOM_CLIENT_IMPORT_SOURCE_URL.current = (
3232
f"http://{example_server_host}{IDOM_CLIENT_IMPORT_SOURCE_URL.default}"
3333
)
3434

src/idom/client/_private.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import re
33
import shutil
44
from pathlib import Path
5-
from typing import Dict, List, Tuple, cast
5+
from typing import Dict, Set, Tuple, cast
66

77
from idom.config import IDOM_CLIENT_BUILD_DIR
88

@@ -75,12 +75,12 @@ def build_dependencies() -> Dict[str, str]:
7575
)
7676

7777

78-
def find_js_module_exports_in_source(content: str) -> List[str]:
79-
names: List[str] = []
78+
def find_js_module_exports_in_source(content: str) -> Set[str]:
79+
names: Set[str] = set()
8080
for match in _JS_MODULE_EXPORT_PATTERN.findall(content):
8181
for export in match.split(","):
8282
export_parts = export.split(" as ", 1)
83-
names.append(export_parts[-1].strip())
84-
names.extend(_JS_MODULE_EXPORT_FUNC_PATTERN.findall(content))
85-
names.extend(_JS_MODULE_EXPORT_NAME_PATTERN.findall(content))
83+
names.add(export_parts[-1].strip())
84+
names.update(_JS_MODULE_EXPORT_FUNC_PATTERN.findall(content))
85+
names.update(_JS_MODULE_EXPORT_NAME_PATTERN.findall(content))
8686
return names

src/idom/client/app/packages/idom-client-react/src/index.js

+35-28
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ import htm from "htm";
44

55
import serializeEvent from "./event-to-object";
66

7-
import { applyPatchInplace, getPathProperty, joinUrl } from "./utils";
7+
import { applyPatchInplace, joinUrl } from "./utils";
88

99
const html = htm.bind(react.createElement);
1010
const LayoutConfigContext = react.createContext({});
1111

12-
export function mountLayout(mountElement, saveUpdateHook, sendEvent, importSourceUrl) {
12+
export function mountLayout(
13+
mountElement,
14+
saveUpdateHook,
15+
sendEvent,
16+
importSourceUrl
17+
) {
1318
reactDOM.render(
1419
html`
1520
<${Layout}
@@ -53,24 +58,18 @@ function Element({ model }) {
5358

5459
function ImportedElement({ model }) {
5560
const config = react.useContext(LayoutConfigContext);
56-
const module = useLazyModule(model.importSource.source, config.importSourceUrl);
57-
if (module) {
58-
const cmpt = getPathProperty(module, model.tagName);
59-
const children = elementChildren(model);
60-
const attributes = elementAttributes(model, config.sendEvent);
61-
return html`<${cmpt} ...${attributes}>${children}<//>`;
62-
} else {
63-
const fallback = model.importSource.fallback;
64-
if (!fallback) {
65-
return html`<div />`;
66-
}
67-
switch (typeof fallback) {
68-
case "object":
69-
return html`<${Element} model=${fallback} />`;
70-
case "string":
71-
return html`<div>${fallback}</div>`;
72-
}
73-
}
61+
const mountPoint = react.useRef(null);
62+
63+
react.useEffect(() => {
64+
const importSource = joinUrl(
65+
config.importSourceUrl,
66+
model.importSource.source
67+
);
68+
eval(`import("${importSource}")`).then((module) => {
69+
mountImportSource(module, mountPoint.current, model, config);
70+
});
71+
});
72+
return html`<div ref=${mountPoint} />`;
7473
}
7574

7675
function StandardElement({ model }) {
@@ -91,7 +90,7 @@ function elementChildren(model) {
9190
return model.children.map((child) => {
9291
switch (typeof child) {
9392
case "object":
94-
return html`<${Element} model=${child} />`;
93+
return html`<${Element} key=${child.key} model=${child} />`;
9594
case "string":
9695
return child;
9796
}
@@ -138,13 +137,21 @@ function eventHandler(sendEvent, eventSpec) {
138137
};
139138
}
140139

141-
function useLazyModule(source, sourceUrlBase = "") {
142-
const [module, setModule] = react.useState(null);
143-
if (!module) {
144-
// use eval() to avoid weird build behavior by bundlers like Webpack
145-
eval(`import("${joinUrl(sourceUrlBase, source)}")`).then(setModule);
146-
}
147-
return module;
140+
function mountImportSource(module, mountPoint, model, config) {
141+
const mountFunction = model.importSource.hasMount
142+
? module.mount
143+
: (element, component, props, children) =>
144+
reactDOM.render(
145+
react.createElement(component, props, ...children),
146+
element
147+
);
148+
149+
const component = module[model.tagName];
150+
const children = elementChildren(model);
151+
const attributes = elementAttributes(model, config.sendEvent);
152+
const props = { key: model.key, ...attributes };
153+
154+
mountFunction(mountPoint, component, props, children);
148155
}
149156

150157
function useInplaceJsonPatch(doc) {

src/idom/client/app/packages/idom-client-react/src/utils.js

-11
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,6 @@ export function applyPatchInplace(doc, path, patch) {
1919
}
2020
}
2121

22-
export function getPathProperty(obj, prop) {
23-
// properties may be dot seperated strings
24-
const path = prop.split(".");
25-
const firstProp = path.shift();
26-
let value = obj[firstProp];
27-
for (let i = 0; i < path.length; i++) {
28-
value = value[path[i]];
29-
}
30-
return value;
31-
}
32-
3322
export function joinUrl(base, tail) {
3423
return tail.startsWith("./")
3524
? (base.endsWith("/") ? base.slice(0, -1) : base) + tail.slice(1)

src/idom/client/manage.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def web_module_path(package_name: str, must_exist: bool = False) -> Path:
2828
return path
2929

3030

31-
def web_module_exports(package_name: str) -> List[str]:
31+
def web_module_exports(package_name: str) -> Set[str]:
3232
"""Get a list of names this module exports"""
3333
web_module_path(package_name, must_exist=True)
3434
return _private.find_js_module_exports_in_source(

src/idom/client/module.py

+54-23
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
from __future__ import annotations
77

88
from pathlib import Path
9-
from typing import Any, Dict, List, Optional, Tuple, Union, overload
9+
from typing import Any, Dict, List, Optional, Set, Tuple, Union, overload
1010
from urllib.parse import urlparse
1111

12+
from idom.config import IDOM_CLIENT_MODULES_MUST_HAVE_MOUNT
1213
from idom.core.vdom import ImportSourceDict, VdomDict, make_vdom_constructor
1314

1415
from . import _private, manage
@@ -36,6 +37,8 @@ def install(
3637
packages: Union[str, List[str], Tuple[str]],
3738
ignore_installed: bool = False,
3839
fallback: Optional[str] = None,
40+
# dynamically installed modules probably won't have a mount so we default to False
41+
has_mount: bool = False,
3942
) -> Union[Module, List[Module]]:
4043
return_one = False
4144
if isinstance(packages, str):
@@ -48,9 +51,11 @@ def install(
4851
manage.build(packages, clean_build=False)
4952

5053
if return_one:
51-
return Module(pkg_names[0], fallback=fallback)
54+
return Module(pkg_names[0], fallback=fallback, has_mount=has_mount)
5255
else:
53-
return [Module(pkg, fallback=fallback) for pkg in pkg_names]
56+
return [
57+
Module(pkg, fallback=fallback, has_mount=has_mount) for pkg in pkg_names
58+
]
5459

5560

5661
class Module:
@@ -67,6 +72,12 @@ class Module:
6772
built-in client will inject this module adjacent to other installed modules
6873
which means they can be imported via a relative path like
6974
``./some-other-installed-module.js``.
75+
fallack:
76+
What to display while the modules is being loaded.
77+
has_mount:
78+
Whether the module exports a ``mount`` function that allows components to
79+
be mounted directly to the DOM. Such a mount function enables greater
80+
flexibility in how custom components can be implemented.
7081
7182
Attributes:
7283
installed:
@@ -75,41 +86,52 @@ class Module:
7586
The URL this module will be imported from.
7687
"""
7788

78-
__slots__ = "url", "fallback", "exports", "_export_names"
89+
__slots__ = (
90+
"url",
91+
"fallback",
92+
"exports",
93+
"has_mount",
94+
"check_exports",
95+
"_export_names",
96+
)
7997

8098
def __init__(
8199
self,
82100
url_or_name: str,
83101
source_file: Optional[Union[str, Path]] = None,
84102
fallback: Optional[str] = None,
103+
has_mount: bool = False,
85104
check_exports: bool = True,
86105
) -> None:
87106
self.fallback = fallback
88-
self._export_names: Optional[List[str]] = None
107+
self.has_mount = has_mount
108+
self.check_exports = check_exports
109+
110+
self.exports: Set[str] = set()
89111
if source_file is not None:
90112
self.url = (
91113
manage.web_module_url(url_or_name)
92114
if manage.web_module_exists(url_or_name)
93115
else manage.add_web_module(url_or_name, source_file)
94116
)
95117
if check_exports:
96-
self._export_names = manage.web_module_exports(url_or_name)
118+
self.exports = manage.web_module_exports(url_or_name)
97119
elif _is_url(url_or_name):
98120
self.url = url_or_name
121+
self.check_exports = False
99122
elif manage.web_module_exists(url_or_name):
100123
self.url = manage.web_module_url(url_or_name)
101124
if check_exports:
102-
self._export_names = manage.web_module_exports(url_or_name)
125+
self.exports = manage.web_module_exports(url_or_name)
103126
else:
104127
raise ValueError(f"{url_or_name!r} is not installed or is not a URL")
105-
self.exports = {name: self.declare(name) for name in (self._export_names or [])}
106128

107129
def declare(
108130
self,
109131
name: str,
110132
has_children: bool = True,
111133
fallback: Optional[str] = None,
112-
) -> "Import":
134+
) -> Import:
113135
"""Return an :class:`Import` for the given :class:`Module` and ``name``
114136
115137
This roughly translates to the javascript statement
@@ -121,19 +143,20 @@ def declare(
121143
Where ``name`` is the given name, and ``module`` is the :attr:`Module.url` of
122144
this :class:`Module` instance.
123145
"""
124-
if (
125-
self._export_names is not None
126-
# if 'default' is exported there's not much we can infer
127-
and "default" not in self._export_names
128-
):
129-
if name not in self._export_names:
130-
raise ValueError(
131-
f"{self} does not export {name!r}, available options are {self._export_names}"
132-
)
133-
return Import(self.url, name, has_children, fallback=fallback or self.fallback)
134-
135-
def __getattr__(self, name: str) -> "Import":
136-
return self.exports.get(name) or self.declare(name)
146+
if self.check_exports and name not in self.exports:
147+
raise ValueError(
148+
f"{self} does not export {name!r}, available options are {list(self.exports)}"
149+
)
150+
return Import(
151+
self.url,
152+
name,
153+
has_children,
154+
has_mount=self.has_mount,
155+
fallback=fallback or self.fallback,
156+
)
157+
158+
def __getattr__(self, name: str) -> Import:
159+
return self.declare(name)
137160

138161
def __repr__(self) -> str:
139162
return f"{type(self).__name__}({self.url})"
@@ -161,11 +184,19 @@ def __init__(
161184
module: str,
162185
name: str,
163186
has_children: bool = True,
187+
has_mount: bool = False,
164188
fallback: Optional[str] = None,
165189
) -> None:
190+
if IDOM_CLIENT_MODULES_MUST_HAVE_MOUNT.current and not has_mount:
191+
raise RuntimeError(
192+
f"{IDOM_CLIENT_MODULES_MUST_HAVE_MOUNT} is set and {module} has no mount"
193+
)
194+
166195
self._name = name
167196
self._constructor = make_vdom_constructor(name, has_children)
168-
self._import_source = ImportSourceDict(source=module, fallback=fallback)
197+
self._import_source = ImportSourceDict(
198+
source=module, fallback=fallback, hasMount=has_mount
199+
)
169200

170201
def __call__(
171202
self,

src/idom/config.py

+13
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ def all_options() -> List[_option.Option[Any]]:
6666
``ABSOLUTE_PATH/my-module.js``
6767
"""
6868

69+
IDOM_CLIENT_MODULES_MUST_HAVE_MOUNT = _option.Option(
70+
"IDOM_CLIENT_MODULES_MUST_HAVE_MOUNT",
71+
default=False,
72+
validator=lambda x: bool(int(x)),
73+
)
74+
"""Control whether imported modules must have a mounting function.
75+
76+
Client implementations that do not support dynamically installed modules can set this
77+
option to block the usages of components that are not mounted in isolation. More
78+
specifically, this requires the ``has_mount`` option of
79+
:class:`~idom.client.module.Module` must be set to ``True``.
80+
"""
81+
6982
IDOM_FEATURE_INDEX_AS_DEFAULT_KEY = _option.Option(
7083
"IDOM_FEATURE_INDEX_AS_DEFAULT_KEY",
7184
default=False,

src/idom/core/vdom.py

+2
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"if": {"not": {"type": "null"}},
5959
"then": {"$ref": "#/definitions/elementOrString"},
6060
},
61+
"hasMount": {"type": "boolean"},
6162
},
6263
"required": ["source"],
6364
},
@@ -83,6 +84,7 @@ def validate_vdom(value: Any) -> None:
8384
class ImportSourceDict(TypedDict):
8485
source: str
8586
fallback: Any
87+
hasMount: bool # noqa
8688

8789

8890
class _VdomDictOptional(TypedDict, total=False):

tests/test_cli.py

+1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def test_show_options():
8282
"True",
8383
],
8484
["IDOM_CLIENT_IMPORT_SOURCE_URL", "/client", "/client", "True"],
85+
["IDOM_CLIENT_MODULES_MUST_HAVE_MOUNT", "False", "False", "True"],
8586
[
8687
"IDOM_DEBUG_MODE",
8788
IDOM_DEBUG_MODE.current,

tests/test_client/conftest.py

-13
This file was deleted.

tests/test_client/js/simple-button.js

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import react from "./react.js";
2+
3+
export function SimpleButton(props) {
4+
return react.createElement(
5+
"button",
6+
{
7+
id: props.id,
8+
onClick(event) {
9+
props.onClick({ data: props.eventResponseData });
10+
},
11+
},
12+
"simple button"
13+
);
14+
}

0 commit comments

Comments
 (0)