Skip to content

Commit 564a726

Browse files
feat: modules can be imported from a url
define_module(name, url="/static/bundle.mjs") imports the module from the url instead of shipping the code over the widget model (e.g. a bundle served from the app's static dir, cacheable and preloadable). Exactly one of module (a Path), code= or url= must be passed; a plain str still works as module code for backwards compatibility, with a DeprecationWarning. Note: under solara server define_module is patched by solara.server.esm, which needs the matching update (widgetti/solara#1169) for the new signature to apply there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f745b52 commit 564a726

3 files changed

Lines changed: 77 additions & 13 deletions

File tree

ipyreact/module.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import warnings
12
from pathlib import Path
2-
from typing import List, Union
3+
from typing import List, Optional, Union
34

45
import traitlets
56
from ipywidgets import DOMWidget
@@ -18,7 +19,10 @@ class Module(DOMWidget):
1819
_view_module = Unicode(module_name).tag(sync=True)
1920
_view_module_version = Unicode(module_version).tag(sync=True)
2021
name = Unicode(allow_none=False).tag(sync=True)
21-
code = Unicode(allow_none=False).tag(sync=True)
22+
code = Unicode("").tag(sync=True)
23+
# when set, the module is imported from this url instead of shipping the
24+
# code over the widget model (e.g. a bundle served from a static dir)
25+
url = Unicode(None, allow_none=True).tag(sync=True)
2226
dependencies = traitlets.List(Unicode(), allow_none=True).tag(sync=True)
2327
status = Unicode(allow_none=True).tag(sync=True)
2428
react_version = Int(18).tag(sync=True)
@@ -32,15 +36,36 @@ def get_module_names():
3236
return _standard_dependencies
3337

3438

35-
def define_module(name, module: Union[str, Path]):
39+
def define_module(name, module: Union[str, Path, None] = None, *, code: Optional[str] = None, url: Optional[str] = None):
3640
"""Register a ES module under a name.
3741
3842
Parameters
3943
----------
4044
name: str
4145
Name of the es module to register
42-
module: str | Path
43-
The module code to register
46+
module: Path
47+
Path to the module source on disk
48+
code: str
49+
The module source as a string
50+
url: str
51+
A url the module is served from (e.g. a bundle in the app's
52+
static dir)
4453
"""
54+
if sum(x is not None for x in (module, code, url)) != 1:
55+
raise TypeError("pass exactly one of module (a Path), code or url")
56+
if isinstance(module, str):
57+
# the pre-0.6 API: a plain str was module code
58+
warnings.warn(
59+
"passing module code as a plain str is deprecated, use code=... (or url=... for a url)",
60+
DeprecationWarning,
61+
stacklevel=2,
62+
)
63+
code = module
64+
module = None
4565
_standard_dependencies.append(name)
46-
return Module(code=module if not isinstance(module, Path) else module.read_text(encoding="utf8"), name=name)
66+
if url is not None:
67+
return Module(url=url, name=name)
68+
if code is None:
69+
assert module is not None
70+
code = module.read_text(encoding="utf8")
71+
return Module(code=code, name=name)

src/widget.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -310,13 +310,14 @@ export class Module extends WidgetModel {
310310
_view_name: Module.view_name,
311311
_view_module: Module.view_module,
312312
_view_module_version: Module.view_module_version,
313+
url: null,
313314
};
314315
}
315316
initialize(attributes: any, options: any): void {
316317
super.initialize(attributes, options);
317318
this.addModule();
318319
// hot reload: re-import when the kernel ships new module code
319-
this.on("change:code change:dependencies", () => {
320+
this.on("change:code change:url change:dependencies", () => {
320321
invalidateModule(this.get("name"));
321322
this.addModule();
322323
});
@@ -342,12 +343,13 @@ export class Module extends WidgetModel {
342343
const code = this.get("code");
343344
let name = this.get("name");
344345
try {
345-
if (this.codeUrl) {
346+
if (this.codeUrl && this.codeUrl.startsWith("blob:")) {
346347
URL.revokeObjectURL(this.codeUrl);
347348
}
348-
this.codeUrl = URL.createObjectURL(
349-
new Blob([code], { type: "text/javascript" }),
350-
);
349+
const moduleUrl: string | null = this.get("url");
350+
this.codeUrl = moduleUrl
351+
? moduleUrl
352+
: URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
351353
let dependencies = this.get("dependencies") || [];
352354
this.set(
353355
"status",
@@ -357,7 +359,7 @@ export class Module extends WidgetModel {
357359
await ensureImportShimLoaded();
358360
await this.updateImportMap();
359361
this.set("status", "Loading module...");
360-
let module = await importShim(this.codeUrl);
362+
let module = await importShim(this.codeUrl!);
361363
try {
362364
// remapping an already-resolved specifier throws (hot reload in the
363365
// same page); the module registry is the source of truth, so only
@@ -888,7 +890,7 @@ export class ReactModel extends DOMWidgetModel {
888890
private rejectComponent: (value: any) => void;
889891
private compiledCode: string | null = null;
890892
private compileError: any | null = null;
891-
private codeUrl: string | null = null;
893+
private codeUrl: string | null;
892894
// this used so that the WrapperComponent can be rendered synchronously,
893895
private currentComponentToWrapOrError: any = null;
894896
private queue: Promise<any>;

tests/unit/define_module_test.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
import ipyreact
6+
7+
8+
def test_define_module_url():
9+
module = ipyreact.define_module("t-url", url="/static/bundle.mjs")
10+
assert module.url == "/static/bundle.mjs"
11+
assert module.code == ""
12+
13+
14+
def test_define_module_code():
15+
module = ipyreact.define_module("t-code", code="export default 1")
16+
assert module.code == "export default 1"
17+
assert module.url is None
18+
19+
20+
def test_define_module_path(tmp_path: Path):
21+
file = tmp_path / "bundle.mjs"
22+
file.write_text("export default 2")
23+
module = ipyreact.define_module("t-path", file)
24+
assert module.code == "export default 2"
25+
26+
27+
def test_define_module_str_is_deprecated_code():
28+
with pytest.warns(DeprecationWarning, match="code="):
29+
module = ipyreact.define_module("t-str", "export default 3")
30+
assert module.code == "export default 3"
31+
32+
33+
def test_define_module_exactly_one():
34+
with pytest.raises(TypeError, match="exactly one"):
35+
ipyreact.define_module("t-none")
36+
with pytest.raises(TypeError, match="exactly one"):
37+
ipyreact.define_module("t-both", Path("x"), code="y")

0 commit comments

Comments
 (0)