Skip to content

Commit d84ba2e

Browse files
committed
Fix missing react-dom/client export
1 parent 7253502 commit d84ba2e

File tree

9 files changed

+325
-149
lines changed

9 files changed

+325
-149
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ pip install flask sanic tornado
5454
- `hatch test --cover` -- run tests with coverage reporting (used in CI)
5555
- `hatch test -k test_name` -- run specific tests
5656
- `hatch test tests/test_config.py` -- run specific test files
57-
- Note: Some tests require Playwright browser automation and may fail in headless environments
5857

5958
**Run Python Linting and Formatting:**
6059

@@ -68,7 +67,7 @@ pip install flask sanic tornado
6867

6968
- `hatch run javascript:check` -- Lint and type-check JavaScript (10 seconds). NEVER CANCEL. Set timeout to 30+ minutes.
7069
- `hatch run javascript:fix` -- Format JavaScript code
71-
- `hatch run javascript:test` -- Run JavaScript tests (note: may fail in headless environments due to DOM dependencies)
70+
- `hatch run javascript:test` -- Run JavaScript tests
7271

7372
**Interactive Development Shell:**
7473

@@ -323,13 +322,6 @@ Follow this step-by-step process for effective development:
323322
- Network timeouts during pip install are common in CI environments
324323
- Missing dependencies error: Install ASGI dependencies with `pip install orjson asgiref asgi-tools servestatic`
325324

326-
### Test Issues:
327-
328-
- Playwright tests may fail in headless environments -- this is expected
329-
- Tests requiring browser DOM should be marked appropriately
330-
- Use `hatch test -k "not playwright"` to skip browser-dependent tests
331-
- JavaScript tests may fail with "window is not defined" in Node.js environment -- this is expected
332-
333325
### Import Issues:
334326

335327
- ReactPy must be installed or src/ must be in Python path
@@ -395,7 +387,6 @@ Always ensure your changes pass local validation before pushing, as the CI pipel
395387
- **All builds and tests run quickly** - if something takes more than 60 seconds, investigate the issue
396388
- **Hatch environments provide full isolation** - no need to manage virtual environments manually
397389
- **JavaScript packages are bundled into Python** - the build process combines JS and Python into a single distribution
398-
- **Browser automation tests may fail in headless environments** - this is expected behavior for Playwright tests
399390
- **Documentation updates are required** when making changes to Python source code
400391
- **Always update this file** when making changes to the development workflow, build process, or repository structure
401392
- **All tests must always pass** - failures are never expected or allowed in a healthy development environment

src/js/packages/@reactpy/app/src/react-dom.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@ import ReactDOM from "preact/compat";
33
// @ts-ignore
44
export * from "preact/compat";
55

6+
// @ts-ignore
7+
export * from "preact/compat/client";
8+
69
export default ReactDOM;

src/reactpy/reactjs/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def component_from_url(
5959
def component_from_url(
6060
url: str,
6161
import_names: str | list[str] | tuple[str, ...],
62-
resolve_imports: bool = True,
62+
resolve_imports: bool = False,
6363
resolve_imports_depth: int = 5,
6464
fallback: Any | None = None,
6565
unmount_before_update: bool = False,
@@ -134,7 +134,7 @@ def component_from_npm(
134134
def component_from_npm(
135135
package: str,
136136
import_names: str | list[str] | tuple[str, ...],
137-
resolve_imports: bool = True,
137+
resolve_imports: bool = False,
138138
resolve_imports_depth: int = 5,
139139
version: str = "latest",
140140
cdn: str = "https://esm.sh",
@@ -222,7 +222,7 @@ def component_from_file(
222222
def component_from_file(
223223
file: str | Path,
224224
import_names: str | list[str] | tuple[str, ...],
225-
resolve_imports: bool = True,
225+
resolve_imports: bool = False,
226226
resolve_imports_depth: int = 5,
227227
name: str = "",
228228
fallback: Any | None = None,
@@ -304,7 +304,7 @@ def component_from_string(
304304
def component_from_string(
305305
content: str,
306306
import_names: str | list[str] | tuple[str, ...],
307-
resolve_imports: bool = True,
307+
resolve_imports: bool = False,
308308
resolve_imports_depth: int = 5,
309309
name: str = "",
310310
fallback: Any | None = None,

src/reactpy/reactjs/module.py

Lines changed: 26 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
are_files_identical,
1313
copy_file,
1414
module_name_suffix,
15-
resolve_module_exports_from_file,
16-
resolve_module_exports_from_url,
15+
resolve_from_module_file,
16+
resolve_from_module_url,
1717
)
1818
from reactpy.types import ImportSourceDict, JavaScriptModule, VdomConstructor
1919

@@ -32,13 +32,9 @@ def url_to_module(
3232
source_type=URL_SOURCE,
3333
default_fallback=fallback,
3434
file=None,
35-
export_names=(
36-
resolve_module_exports_from_url(url, resolve_imports_depth)
37-
if (
38-
resolve_imports
39-
if resolve_imports is not None
40-
else config.REACTPY_DEBUG.current
41-
)
35+
import_names=(
36+
resolve_from_module_url(url, resolve_imports_depth)
37+
if resolve_imports
4238
else None
4339
),
4440
unmount_before_update=unmount_before_update,
@@ -77,13 +73,9 @@ def file_to_module(
7773
source_type=NAME_SOURCE,
7874
default_fallback=fallback,
7975
file=target_file,
80-
export_names=(
81-
resolve_module_exports_from_file(source_file, resolve_imports_depth)
82-
if (
83-
resolve_imports
84-
if resolve_imports is not None
85-
else config.REACTPY_DEBUG.current
86-
)
76+
import_names=(
77+
resolve_from_module_file(source_file, resolve_imports_depth)
78+
if resolve_imports
8779
else None
8880
),
8981
unmount_before_update=unmount_before_update,
@@ -117,13 +109,9 @@ def string_to_module(
117109
source_type=NAME_SOURCE,
118110
default_fallback=fallback,
119111
file=target_file,
120-
export_names=(
121-
resolve_module_exports_from_file(target_file, resolve_imports_depth)
122-
if (
123-
resolve_imports
124-
if resolve_imports is not None
125-
else config.REACTPY_DEBUG.current
126-
)
112+
import_names=(
113+
resolve_from_module_file(target_file, resolve_imports_depth)
114+
if resolve_imports
127115
else None
128116
),
129117
unmount_before_update=unmount_before_update,
@@ -132,43 +120,43 @@ def string_to_module(
132120

133121
def module_to_vdom(
134122
web_module: JavaScriptModule,
135-
export_names: str | list[str] | tuple[str, ...],
123+
import_names: str | list[str] | tuple[str, ...],
136124
fallback: Any | None = None,
137125
allow_children: bool = True,
138126
) -> VdomConstructor | list[VdomConstructor]:
139127
"""Return one or more VDOM constructors from a :class:`JavaScriptModule`
140128
141129
Parameters:
142-
export_names:
143-
One or more names to export. If given as a string, a single component
130+
import_names:
131+
One or more names to import. If given as a string, a single component
144132
will be returned. If a list is given, then a list of components will be
145133
returned.
146134
fallback:
147135
What to temporarily display while the module is being loaded.
148136
allow_children:
149137
Whether or not these components can have children.
150138
"""
151-
if isinstance(export_names, str):
139+
if isinstance(import_names, str):
152140
if (
153-
web_module.export_names is not None
154-
and export_names.split(".")[0] not in web_module.export_names
141+
web_module.import_names is not None
142+
and import_names.split(".")[0] not in web_module.import_names
155143
):
156-
msg = f"{web_module.source!r} does not export {export_names!r}"
144+
msg = f"{web_module.source!r} does not contain {import_names!r}"
157145
raise ValueError(msg)
158-
return make_module(web_module, export_names, fallback, allow_children)
146+
return make_module(web_module, import_names, fallback, allow_children)
159147
else:
160-
if web_module.export_names is not None:
148+
if web_module.import_names is not None:
161149
missing = sorted(
162-
{e.split(".")[0] for e in export_names}.difference(
163-
web_module.export_names
150+
{e.split(".")[0] for e in import_names}.difference(
151+
web_module.import_names
164152
)
165153
)
166154
if missing:
167-
msg = f"{web_module.source!r} does not export {missing!r}"
155+
msg = f"{web_module.source!r} does not contain {missing!r}"
168156
raise ValueError(msg)
169157
return [
170158
make_module(web_module, name, fallback, allow_children)
171-
for name in export_names
159+
for name in import_names
172160
]
173161

174162

@@ -197,7 +185,7 @@ def get_module_path(name: str) -> Path:
197185

198186

199187
def import_reactjs():
200-
from reactpy import config, html
188+
from reactpy import html
201189

202190
base_url = config.REACTPY_PATH_PREFIX.current.strip("/")
203191
return html.script(
@@ -207,6 +195,7 @@ def import_reactjs():
207195
"imports": {{
208196
"react": "/{base_url}/static/react.js",
209197
"react-dom": "/{base_url}/static/react-dom.js",
198+
"react-dom/client": "/{base_url}/static/react-dom.js",
210199
"react/jsx-runtime": "/{base_url}/static/react-jsx-runtime.js"
211200
}}
212201
}}

src/reactpy/reactjs/utils.py

Lines changed: 58 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,119 +18,125 @@ def module_name_suffix(name: str) -> str:
1818
return PurePosixPath(tail or head).suffix or ".js"
1919

2020

21-
def resolve_module_exports_from_file(
21+
def resolve_from_module_file(
2222
file: Path,
2323
max_depth: int,
24-
is_re_export: bool = False,
24+
is_regex_import: bool = False,
2525
) -> set[str]:
2626
if max_depth == 0:
27-
logger.warning(f"Did not resolve all exports for {file} - max depth reached")
27+
logger.warning(f"Did not resolve all imports for {file} - max depth reached")
2828
return set()
2929
elif not file.exists():
30-
logger.warning(f"Did not resolve exports for unknown file {file}")
30+
logger.warning(f"Did not resolve imports for unknown file {file}")
3131
return set()
3232

33-
export_names, references = resolve_module_exports_from_source(
34-
file.read_text(encoding="utf-8"), exclude_default=is_re_export
33+
names, references = resolve_from_module_source(
34+
file.read_text(encoding="utf-8"), exclude_default=is_regex_import
3535
)
3636

3737
for ref in references:
3838
if urlparse(ref).scheme: # is an absolute URL
39-
export_names.update(
40-
resolve_module_exports_from_url(ref, max_depth - 1, is_re_export=True)
39+
names.update(
40+
resolve_from_module_url(ref, max_depth - 1, is_regex_import=True)
4141
)
4242
else:
4343
path = file.parent.joinpath(*ref.split("/"))
44-
export_names.update(
45-
resolve_module_exports_from_file(path, max_depth - 1, is_re_export=True)
44+
names.update(
45+
resolve_from_module_file(path, max_depth - 1, is_regex_import=True)
4646
)
4747

48-
return export_names
48+
return names
4949

5050

51-
def resolve_module_exports_from_url(
51+
def resolve_from_module_url(
5252
url: str,
5353
max_depth: int,
54-
is_re_export: bool = False,
54+
is_regex_import: bool = False,
5555
) -> set[str]:
5656
if max_depth == 0:
57-
logger.warning(f"Did not resolve all exports for {url} - max depth reached")
57+
logger.warning(f"Did not resolve all imports for {url} - max depth reached")
5858
return set()
5959

6060
try:
6161
text = requests.get(url, timeout=5).text
6262
except requests.exceptions.ConnectionError as error:
6363
reason = "" if error is None else " - {error.errno}"
64-
logger.warning(f"Did not resolve exports for url {url} {reason}")
64+
logger.warning(f"Did not resolve imports for url {url} {reason}")
6565
return set()
6666

67-
export_names, references = resolve_module_exports_from_source(
68-
text, exclude_default=is_re_export
67+
names, references = resolve_from_module_source(
68+
text, exclude_default=is_regex_import
6969
)
7070

7171
for ref in references:
72-
url = resolve_relative_url(url, ref)
73-
export_names.update(
74-
resolve_module_exports_from_url(url, max_depth - 1, is_re_export=True)
75-
)
72+
url = normalize_url_path(url, ref)
73+
names.update(resolve_from_module_url(url, max_depth - 1, is_regex_import=True))
7674

77-
return export_names
75+
return names
7876

7977

80-
def resolve_module_exports_from_source(
78+
def resolve_from_module_source(
8179
content: str, exclude_default: bool
8280
) -> tuple[set[str], set[str]]:
83-
names: set[str] = set()
81+
"""Find names exported by the given JavaScript module content to assist with ReactPy import resolution.
82+
83+
Parmeters:
84+
content: The content of the JavaScript module.
85+
Returns:
86+
A tuple where the first item is a set of exported names and the second item is a set of
87+
referenced module paths.
88+
"""
89+
all_names: set[str] = set()
8490
references: set[str] = set()
8591

8692
if _JS_DEFAULT_EXPORT_PATTERN.search(content):
87-
names.add("default")
93+
all_names.add("default")
8894

8995
# Exporting functions and classes
90-
names.update(_JS_FUNC_OR_CLS_EXPORT_PATTERN.findall(content))
96+
all_names.update(_JS_FUNC_OR_CLS_EXPORT_PATTERN.findall(content))
9197

92-
for export in _JS_GENERAL_EXPORT_PATTERN.findall(content):
93-
export = export.rstrip(";").strip()
98+
for name in _JS_GENERAL_EXPORT_PATTERN.findall(content):
99+
name = name.rstrip(";").strip()
94100
# Exporting individual features
95-
if export.startswith("let "):
96-
names.update(let.split("=", 1)[0] for let in export[4:].split(","))
101+
if name.startswith("let "):
102+
all_names.update(let.split("=", 1)[0] for let in name[4:].split(","))
97103
# Renaming exports and export list
98-
elif export.startswith("{") and export.endswith("}"):
99-
names.update(
100-
item.split(" as ", 1)[-1] for item in export.strip("{}").split(",")
104+
elif name.startswith("{") and name.endswith("}"):
105+
all_names.update(
106+
item.split(" as ", 1)[-1] for item in name.strip("{}").split(",")
101107
)
102108
# Exporting destructured assignments with renaming
103-
elif export.startswith("const "):
104-
names.update(
109+
elif name.startswith("const "):
110+
all_names.update(
105111
item.split(":", 1)[0]
106-
for item in export[6:].split("=", 1)[0].strip("{}").split(",")
112+
for item in name[6:].split("=", 1)[0].strip("{}").split(",")
107113
)
108114
# Default exports
109-
elif export.startswith("default "):
110-
names.add("default")
115+
elif name.startswith("default "):
116+
all_names.add("default")
111117
# Aggregating modules
112-
elif export.startswith("* as "):
113-
names.add(export[5:].split(" from ", 1)[0])
114-
elif export.startswith("* "):
115-
references.add(export[2:].split("from ", 1)[-1].strip("'\""))
116-
elif export.startswith("{") and " from " in export:
117-
names.update(
118+
elif name.startswith("* as "):
119+
all_names.add(name[5:].split(" from ", 1)[0])
120+
elif name.startswith("* "):
121+
references.add(name[2:].split("from ", 1)[-1].strip("'\""))
122+
elif name.startswith("{") and " from " in name:
123+
all_names.update(
118124
item.split(" as ", 1)[-1]
119-
for item in export.split(" from ")[0].strip("{}").split(",")
125+
for item in name.split(" from ")[0].strip("{}").split(",")
120126
)
121-
elif not (export.startswith("function ") or export.startswith("class ")):
122-
logger.warning(f"Unknown export type {export!r}")
127+
elif not (name.startswith("function ") or name.startswith("class ")):
128+
logger.warning(f"Found unknown export type {name!r}")
123129

124-
names = {n.strip() for n in names}
130+
all_names = {n.strip() for n in all_names}
125131
references = {r.strip() for r in references}
126132

127-
if exclude_default and "default" in names:
128-
names.remove("default")
133+
if exclude_default and "default" in all_names:
134+
all_names.remove("default")
129135

130-
return names, references
136+
return all_names, references
131137

132138

133-
def resolve_relative_url(base_url: str, rel_url: str) -> str:
139+
def normalize_url_path(base_url: str, rel_url: str) -> str:
134140
if not rel_url.startswith("."):
135141
if rel_url.startswith("/"):
136142
# copy scheme and hostname from base_url

0 commit comments

Comments
 (0)