Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 1fbdfbb

Browse files
authoredSep 16, 2023
SEO Compatible Rendering (#186)
- SEO compatible rendering - Prerenders the initial component via the template tag using `vdom_to_html`, then loads the actual component in the background within a `hidden` div. When loaded, the prerender is replaced with the actual render. - `settings.py:REACTPY_PRERENDER` can be set to `True` to enable this behavior by default - Enable it on individual components via the template tag: `{% component "..." prerender="True" %}` - Docs styling, verbiage, and formatting tweaks - Rename undocumented utility function `reactpy_django.utils.ComponentPreloader` to `reactpy_django.utils.RootComponentFinder`. - Fix JavaScript being via `pip install -e .` on Windows. - Update PyPi package metadata - Update pull request template
1 parent 749e707 commit 1fbdfbb

39 files changed

+487
-126
lines changed
 

‎.github/pull_request_template.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
*By submitting this pull request you agree that all contributions to this project are made under the MIT license.*
2-
31
## Description
42

53
A summary of the changes.
@@ -8,6 +6,9 @@ A summary of the changes.
86

97
Please update this checklist as you complete each item:
108

11-
- [ ] Tests have been included for all bug fixes or added functionality.
12-
- [ ] The changelog has been updated with any significant changes, if necessary.
13-
- [ ] GitHub Issues which may be closed by this PR have been linked.
9+
- [ ] Tests have been developed for bug fixes or new functionality.
10+
- [ ] The changelog has been updated, if necessary.
11+
- [ ] Documentation has been updated, if necessary.
12+
- [ ] GitHub Issues closed by this PR have been linked.
13+
14+
<sub>By submitting this pull request you agree that all contributions comply with this project's open source license(s).</sub>

‎CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ Using the following categories, list your changes in this order:
3232

3333
<!--changelog-start-->
3434

35+
## [Unreleased]
36+
37+
### Added
38+
39+
- ReactPy components can now use SEO compatible rendering!
40+
- `settings.py:REACTPY_PRERENDER` can be set to `True` to enable this behavior by default
41+
- Or, you can enable it on individual components via the template tag: `{% component "..." prerender="True" %}`
42+
43+
### Changed
44+
45+
- Renamed undocumented utility function `reactpy_django.utils.ComponentPreloader` to `reactpy_django.utils.RootComponentFinder`.
46+
3547
## [3.5.1] - 2023-09-07
3648

3749
### Added

‎docs/python/template-tag-bad-view.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22

33

44
def example_view(request):
5-
context_vars = {"dont_do_this": "example_project.my_app.components.hello_world"}
5+
context_vars = {"my_variable": "example_project.my_app.components.hello_world"}
66
return render(request, "my-template.html", context_vars)

‎docs/src/about/code.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Then, by running the command below you can:
3535
- Download, build, and install Javascript dependencies
3636

3737
```bash linenums="0"
38-
pip install -e . -r requirements.txt
38+
pip install -e . -r requirements.txt --verbose --upgrade
3939
```
4040

4141
!!! warning "Pitfall"

‎docs/src/about/docs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Then, by running the command below you can:
2828
- Self-host a test server for the documentation
2929

3030
```bash linenums="0"
31-
pip install -e . -r requirements.txt --upgrade
31+
pip install -r requirements.txt --upgrade
3232
```
3333

3434
Finally, to verify that everything is working properly, you can manually run the docs preview web server.

‎docs/src/assets/css/admonition.css

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
[data-md-color-scheme="slate"] {
22
--admonition-border-color: transparent;
33
--admonition-expanded-border-color: rgba(255, 255, 255, 0.1);
4-
--note-bg-color: rgb(43 110 98/ 0.2);
4+
--note-bg-color: rgba(43, 110, 98, 0.2);
55
--terminal-bg-color: #0c0c0c;
66
--terminal-title-bg-color: #000;
7-
--deep-dive-bg-color: rgb(43 52 145 / 0.2);
7+
--deep-dive-bg-color: rgba(43, 52, 145, 0.2);
88
--you-will-learn-bg-color: #353a45;
9-
--pitfall-bg-color: rgb(182 87 0 / 0.2);
9+
--pitfall-bg-color: rgba(182, 87, 0, 0.2);
1010
}
1111
[data-md-color-scheme="default"] {
1212
--admonition-border-color: rgba(0, 0, 0, 0.08);
1313
--admonition-expanded-border-color: var(--admonition-border-color);
14-
--note-bg-color: rgb(244 251 249);
15-
--terminal-bg-color: rgb(64 71 86);
16-
--terminal-title-bg-color: rgb(35 39 47);
17-
--deep-dive-bg-color: rgb(243 244 253);
14+
--note-bg-color: rgb(244, 251, 249);
15+
--terminal-bg-color: rgb(64, 71, 86);
16+
--terminal-title-bg-color: rgb(35, 39, 47);
17+
--deep-dive-bg-color: rgb(243, 244, 253);
1818
--you-will-learn-bg-color: rgb(246, 247, 249);
1919
--pitfall-bg-color: rgb(254, 245, 231);
2020
}
@@ -81,12 +81,12 @@ React Name: "Note"
8181
font-size: 1rem;
8282
background: transparent;
8383
padding-bottom: 0;
84-
color: rgb(68 172 153);
84+
color: rgb(68, 172, 153);
8585
}
8686

8787
.md-typeset .note .admonition-title:before {
8888
font-size: 1.1rem;
89-
background: rgb(68 172 153);
89+
background: rgb(68, 172, 153);
9090
}
9191

9292
.md-typeset .note > .admonition-title:before,
@@ -109,12 +109,12 @@ React Name: "Pitfall"
109109
font-size: 1rem;
110110
background: transparent;
111111
padding-bottom: 0;
112-
color: rgb(219 125 39);
112+
color: rgb(219, 125, 39);
113113
}
114114

115115
.md-typeset .warning .admonition-title:before {
116116
font-size: 1.1rem;
117-
background: rgb(219 125 39);
117+
background: rgb(219, 125, 39);
118118
}
119119

120120
/*
@@ -131,12 +131,12 @@ React Name: "Deep Dive"
131131
font-size: 1rem;
132132
background: transparent;
133133
padding-bottom: 0;
134-
color: rgb(136 145 236);
134+
color: rgb(136, 145, 236);
135135
}
136136

137137
.md-typeset .info .admonition-title:before {
138138
font-size: 1.1rem;
139-
background: rgb(136 145 236);
139+
background: rgb(136, 145, 236);
140140
}
141141

142142
/*
@@ -152,11 +152,11 @@ React Name: "Terminal"
152152

153153
.md-typeset .example .admonition-title {
154154
background: var(--terminal-title-bg-color);
155-
color: rgb(246 247 249);
155+
color: rgb(246, 247, 249);
156156
}
157157

158158
.md-typeset .example .admonition-title:before {
159-
background: rgb(246 247 249);
159+
background: rgb(246, 247, 249);
160160
}
161161

162162
.md-typeset .admonition.example code {

‎docs/src/assets/css/code.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
--md-code-hl-color: #ffffcf1c;
1010
--md-code-bg-color: #16181d;
1111
--md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43);
12-
--code-tab-color: rgb(52 58 70);
12+
--code-tab-color: rgb(52, 58, 70);
1313
--md-code-hl-name-color: #aadafc;
1414
--md-code-hl-string-color: hsl(21 49% 63% / 1);
1515
--md-code-hl-keyword-color: hsl(289.67deg 35% 60%);

‎docs/src/assets/css/main.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
--reactpy-color: #58b962;
44
--reactpy-color-dark: #42914a;
55
--reactpy-color-darker: #34743b;
6-
--reactpy-color-opacity-10: rgb(88 185 98 / 10%);
6+
--reactpy-color-opacity-10: rgba(88, 185, 98, 0.1);
77
}
88

99
[data-md-color-accent="red"] {
@@ -12,7 +12,7 @@
1212
}
1313

1414
[data-md-color-scheme="slate"] {
15-
--md-default-bg-color: rgb(35 39 47);
15+
--md-default-bg-color: rgb(35, 39, 47);
1616
--md-default-bg-color--light: hsla(var(--md-hue), 15%, 16%, 0.54);
1717
--md-default-bg-color--lighter: hsla(var(--md-hue), 15%, 16%, 0.26);
1818
--md-default-bg-color--lightest: hsla(var(--md-hue), 15%, 16%, 0.07);

‎docs/src/assets/css/navbar.css

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
[data-md-color-scheme="slate"] {
22
--md-header-border-color: rgb(255 255 255 / 5%);
3+
--md-version-bg-color: #ffffff0d;
34
}
45

56
[data-md-color-scheme="default"] {
67
--md-header-border-color: rgb(0 0 0 / 7%);
8+
--md-version-bg-color: #ae58ee2e;
79
}
810

911
.md-header {
@@ -28,12 +30,20 @@
2830
}
2931

3032
.md-version__list {
31-
margin: 0.2rem -0.8rem;
33+
margin: 0;
34+
left: 0;
35+
right: 0;
36+
top: 2.5rem;
3237
}
3338

34-
[dir="ltr"] .md-header__title.md-header__title--active {
35-
margin: 0;
36-
transition: margin 0.35s ease;
39+
.md-version {
40+
background: var(--md-version-bg-color);
41+
border-radius: 999px;
42+
padding: 0 0.8rem;
43+
margin: 0.3rem 0;
44+
height: 1.8rem;
45+
display: flex;
46+
font-size: 0.7rem;
3747
}
3848

3949
/* Mobile Styling */
@@ -97,6 +107,12 @@
97107
.md-header__topic {
98108
position: relative;
99109
}
110+
.md-header__title--active .md-header__topic {
111+
transform: none;
112+
opacity: 1;
113+
pointer-events: auto;
114+
z-index: 4;
115+
}
100116

101117
/* Search */
102118
.md-search {

‎docs/src/assets/css/sidebar.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
}
2929

3030
.md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link {
31-
color: rgb(133 142 159);
31+
color: rgb(133, 142, 159);
3232
margin: 0.5rem;
3333
}
3434

‎docs/src/learn/add-reactpy-to-a-django-project.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject
5353

5454
??? note "Configure ReactPy settings (Optional)"
5555

56+
{% include "../reference/settings.md" start="<!--intro-start-->" end="<!--intro-end-->" %}
57+
5658
{% include "../reference/settings.md" start="<!--config-table-start-->" end="<!--config-table-end-->" %}
5759

5860
## Step 3: Configure `urls.py`

‎docs/src/reference/settings.md

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
<p class="intro" markdown>
44

5-
Your **Django project's** `settings.py` can modify the behavior of ReactPy.
5+
<!--intro-start-->
6+
7+
These are ReactPy-Django's default settings values. You can modify these values in your **Django project's** `settings.py` to change the behavior of ReactPy.
8+
9+
<!--intro-end-->
610

711
</p>
812

@@ -14,25 +18,34 @@ Your **Django project's** `settings.py` can modify the behavior of ReactPy.
1418

1519
---
1620

17-
## Primary Configuration
18-
1921
<!--config-table-start-->
2022

21-
These are ReactPy-Django's default settings values. You can modify these values in your **Django project's** `settings.py` to change the behavior of ReactPy.
23+
## General Settings
2224

2325
| Setting | Default Value | Example Value(s) | Description |
2426
| --- | --- | --- | --- |
25-
| `#!python REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache. We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). |
26-
| `#!python REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database used to store ReactPy session data. ReactPy requires a multiprocessing-safe and thread-safe database. If configuring `#!python REACTPY_DATABASE`, it is mandatory to enable our database router like such:<br/>`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` |
27-
| `#!python REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy session data, such as `#!python args` and `#!python kwargs` passed into your component template tag. Use `#!python 0` to not store any session data. |
28-
| `#!python REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy WebSocket and HTTP URLs. |
29-
| `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python None`, `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `#!python reactpy_django.hooks.use_query` postprocessor function. Postprocessor functions can be async or sync, and the parameters must contain the arg `#!python data`. Set `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to globally disable the default postprocessor. |
27+
| `#!python REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix used for all ReactPy WebSocket and HTTP URLs. |
28+
| `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.postprocessor"`, `#!python None` | Dotted path to the default `#!python reactpy_django.hooks.use_query` postprocessor function. Postprocessor functions can be async or sync, and the function must contain a `#!python data` parameter. Set `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to globally disable the default postprocessor. |
3029
| `#!python REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:<br/> 1. You are using `#!python AuthMiddlewareStack` and...<br/> 2. You are using Django's `#!python AUTHENTICATION_BACKENDS` setting and...<br/> 3. Your Django user model does not define a `#!python backend` attribute. |
31-
| `#!python REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the web server to process traffic during ReactPy rendering. Vastly improves throughput with web servers such as [`hypercorn`](https://pgjones.gitlab.io/hypercorn/) and [`uvicorn`](https://www.uvicorn.org/). |
32-
| `#!python REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing. You can use the `#!python host` argument in your [template tag](../reference/template-tag.md#component) as a manual override. |
33-
| `#!python REACTPY_RECONNECT_INTERVAL` | `#!python 750` | `#!python 100`, `#!python 2500`, `#!python 6000` | Milliseconds between client reconnection attempts. This value will gradually increase if `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` is greater than `#!python 1`. |
30+
31+
## Performance Settings
32+
33+
| Setting | Default Value | Example Value(s) | Description |
34+
| --- | --- | --- | --- |
35+
| `#!python REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Multiprocessing-safe database used to store ReactPy session data. If configuring `#!python REACTPY_DATABASE`, it is mandatory to enable our database router like such:<br/>`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` |
36+
| `#!python REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used for ReactPy JavaScript modules. We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). |
37+
| `#!python REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Configures whether ReactPy components are rendered in a dedicated thread. This allows the web server to process traffic during ReactPy rendering. Vastly improves throughput with web servers such as [`hypercorn`](https://pgjones.gitlab.io/hypercorn/) and [`uvicorn`](https://www.uvicorn.org/). |
38+
| `#!python REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir"]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing. You can use the `#!python host` argument in your [template tag](../reference/template-tag.md#component) as a manual override. |
39+
| `#!python REACTPY_PRERENDER` | `#!python False` | `#!python True` | Configures whether to pre-render your components, which enables SEO compatibility and increases perceived responsiveness. You can use the `#!python prerender` argument in your [template tag](../reference/template-tag.md#component) as a manual override. During pre-rendering, there are some key differences in behavior:<br/> 1. Only the component's first render is pre-rendered.<br/> 2. All `#!python connection` related hooks use HTTP.<br/> 3. `#!python html.script` is executed during both pre-render and render.<br/> 4. Component is non-interactive until a WebSocket connection is formed. |
40+
41+
## Stability Settings
42+
43+
| Setting | Default Value | Example Value(s) | Description |
44+
| --- | --- | --- | --- |
45+
| `#!python REACTPY_RECONNECT_INTERVAL` | `#!python 750` | `#!python 100`, `#!python 2500`, `#!python 6000` | Milliseconds between client reconnection attempts. |
46+
| `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` | `#!python 1.25` | `#!python 1`, `#!python 1.5`, `#!python 3` | On each reconnection attempt, the `#!python REACTPY_RECONNECT_INTERVAL` will be multiplied by this value to increase the time between attempts. You can keep time between each reconnection the same by setting this to `#!python 1`. |
3447
| `#!python REACTPY_RECONNECT_MAX_INTERVAL` | `#!python 60000` | `#!python 10000`, `#!python 25000`, `#!python 900000` | Maximum milliseconds between client reconnection attempts. This allows setting an upper bound on how high `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` can increase the time between reconnection attempts. |
3548
| `#!python REACTPY_RECONNECT_MAX_RETRIES` | `#!python 150` | `#!python 0`, `#!python 5`, `#!python 300` | Maximum number of reconnection attempts before the client gives up. |
36-
| `#!python REACTPY_RECONNECT_BACKOFF_MULTIPLIER` | `#!python 1.25` | `#!python 1`, `#!python 1.5`, `#!python 3` | Multiplier for the time between client reconnection attempts. On each reconnection attempt, the `#!python REACTPY_RECONNECT_INTERVAL` will be multiplied by this to increase the time between attempts. You can keep time between each reconnection the same by setting this to `#!python 1`. |
49+
| `#!python REACTPY_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy component sessions. This includes data such as `#!python *args` and `#!python **kwargs` passed into your component template tag. Use `#!python 0` to not store any session data. |
3750

3851
<!--config-table-end-->

‎docs/src/reference/template-tag.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ This template tag can be used to insert any number of ReactPy components onto yo
2525
| `#!python dotted_path` | `#!python str` | The dotted path to the component to render. | N/A |
2626
| `#!python *args` | `#!python Any` | The positional arguments to provide to the component. | N/A |
2727
| `#!python class` | `#!python str | None` | The HTML class to apply to the top-level component div. | `#!python None` |
28-
| `#!python key` | `#!python str | None` | Force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `#!python key` within a template tag is effectively useless. | `#!python None` |
29-
| `#!python host` | `#!python str | None` | The host to use for the ReactPy connections. If set to `#!python None`, the host will be automatically configured.<br/>Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `#!python None` |
28+
| `#!python key` | `#!python Any` | Force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `#!python key` within a template tag is effectively useless. | `#!python None` |
29+
| `#!python host` | `#!python str | None` | The host to use for the ReactPy connections. If unset, the host will be automatically configured.<br/>Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `#!python None` |
30+
| `#!python prerender` | `#!python str` | If `#!python "True"`, the component will pre-rendered, which enables SEO compatibility and increases perceived responsiveness. | `#!python "False"` |
3031
| `#!python **kwargs` | `#!python Any` | The keyword arguments to provide to the component. | N/A |
3132

3233
<font size="4">**Returns**</font>
@@ -37,11 +38,11 @@ This template tag can be used to insert any number of ReactPy components onto yo
3738

3839
<!--context-start-->
3940

40-
??? warning "Do not use context variables for the ReactPy component name"
41+
??? warning "Do not use context variables for the component path"
4142

42-
Our preprocessor relies on the template tag containing a string.
43+
The ReactPy component finder (`#!python reactpy_django.utils.RootComponentFinder`) requires that your component path is a string.
4344

44-
**Do not** use Django template/context variables for the component path. Failure to follow this warning can result in unexpected behavior.
45+
**Do not** use Django template/context variables for the component path. Failure to follow this warning can result in unexpected behavior, such as components that will not render.
4546

4647
For example, **do not** do the following:
4748

@@ -52,7 +53,7 @@ This template tag can be used to insert any number of ReactPy components onto yo
5253
{% component "example_project.my_app.components.hello_world" recipient="World" %}
5354

5455
<!-- This is bad -->
55-
{% component dont_do_this recipient="World" %}
56+
{% component my_variable recipient="World" %}
5657
```
5758

5859
=== "views.py"
@@ -81,7 +82,7 @@ This template tag can be used to insert any number of ReactPy components onto yo
8182

8283
1. If your host address are completely separate ( `origin1.com != origin2.com` ) you will need to [configure CORS headers](https://pypi.org/project/django-cors-headers/) on your main application during deployment.
8384
2. You will not need to register ReactPy HTTP or WebSocket paths on any applications that do not perform any component rendering.
84-
3. Your component will only be able to access `#!python *args`/`#!python **kwargs` you provide to the template tag if your applications share a common database.
85+
3. Your component will only be able to access your template tag's `#!python *args`/`#!python **kwargs` if your applications share a common database.
8586

8687
<!--multiple-components-start-->
8788

‎docs/src/reference/utils.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ This function is used manually register a root component with ReactPy.
5252
{% include "../../python/register-component.py" %}
5353
```
5454

55-
??? warning "Only use this within `#!python AppConfig.ready()`"
55+
??? warning "Only use this within `#!python MyAppConfig.ready()`"
5656

57-
You should always call `#!python register_component` within a Django [`#!python AppConfig.ready()` method](https://docs.djangoproject.com/en/4.2/ref/applications/#django.apps.AppConfig.ready). This ensures you will retain multiprocessing compatibility, such as with ASGI web server workers.
57+
You should always call `#!python register_component` within a Django [`#!python MyAppConfig.ready()` method](https://docs.djangoproject.com/en/dev/ref/applications/#django.apps.AppConfig.ready). This ensures you will retain multiprocessing compatibility, such as with ASGI web server workers.
5858

5959
??? question "Do I need to use this?"
6060

‎mkdocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ watch:
112112

113113
site_name: ReactPy-Django
114114
site_author: Archmonger
115-
site_description: It's React, but in Python. Now for Django developers.
115+
site_description: It's React, but in Python. Now with Django integration.
116116
copyright: Copyright &copy; 2023 Reactive Python.<div class="legal-footer-right">This project has no affiliation to ReactJS or Meta Platforms, Inc.</div>
117117
repo_url: https://github.com/reactive-python/reactpy-django
118118
site_url: https://reactive-python.github.io/reactpy-django

‎setup.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from logging import StreamHandler, getLogger
1010
from pathlib import Path
1111

12-
from setuptools import find_packages, setup
12+
from setuptools import find_namespace_packages, setup
1313
from setuptools.command.develop import develop
1414
from setuptools.command.sdist import sdist
1515

@@ -47,27 +47,35 @@ def list2cmdline(cmd_list):
4747
package = {
4848
"name": name,
4949
"python_requires": ">=3.9",
50-
"packages": find_packages(str(src_dir)),
50+
"packages": find_namespace_packages(str(src_dir)),
5151
"package_dir": {"": "src"},
52-
"description": "Control the web with Python",
53-
"author": "Ryan Morshead",
54-
"author_email": "ryan.morshead@gmail.com",
52+
"description": "It's React, but in Python. Now with Django integration.",
53+
"author": "Mark Bakhit",
54+
"author_email": "archiethemonger@gmail.com",
5555
"url": "https://github.com/reactive-python/reactpy-django",
5656
"license": "MIT",
5757
"platforms": "Linux, Mac OS X, Windows",
58-
"keywords": ["interactive", "widgets", "DOM", "React"],
58+
"keywords": [
59+
"interactive",
60+
"reactive",
61+
"widgets",
62+
"DOM",
63+
"React",
64+
"ReactJS",
65+
"ReactPy",
66+
],
5967
"include_package_data": True,
6068
"zip_safe": False,
6169
"classifiers": [
6270
"Framework :: Django",
6371
"Framework :: Django :: 4.0",
72+
"Programming Language :: Python :: 3.9",
73+
"Programming Language :: Python :: 3.10",
74+
"Programming Language :: Python :: 3.11",
6475
"Operating System :: OS Independent",
6576
"Intended Audience :: Developers",
6677
"Intended Audience :: Science/Research",
6778
"Topic :: Multimedia :: Graphics",
68-
"Programming Language :: Python :: 3.9",
69-
"Programming Language :: Python :: 3.10",
70-
"Programming Language :: Python :: 3.11",
7179
"Environment :: Web Environment",
7280
],
7381
}
@@ -129,18 +137,17 @@ def run(self):
129137
log.info(f"> {list2cmdline(args_list)}")
130138
subprocess.run(args_list, cwd=js_dir, check=True)
131139
except Exception:
132-
log.error("Failed to update NPM")
133140
log.error(traceback.format_exc())
134-
raise
141+
log.error("Failed to update NPM, continuing anyway...")
135142

136143
log.info("Installing Javascript...")
137144
try:
138145
args_list = [npm, "install"]
139146
log.info(f"> {list2cmdline(args_list)}")
140147
subprocess.run(args_list, cwd=js_dir, check=True)
141148
except Exception:
142-
log.error("Failed to install Javascript")
143149
log.error(traceback.format_exc())
150+
log.error("Failed to install Javascript")
144151
raise
145152

146153
log.info("Building Javascript...")
@@ -149,8 +156,8 @@ def run(self):
149156
log.info(f"> {list2cmdline(args_list)}")
150157
subprocess.run(args_list, cwd=js_dir, check=True)
151158
except Exception:
152-
log.error("Failed to build Javascript")
153159
log.error(traceback.format_exc())
160+
log.error("Failed to build Javascript")
154161
raise
155162

156163
log.info("Successfully built Javascript")

‎src/js/package-lock.json

Lines changed: 89 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/js/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"@rollup/plugin-commonjs": "^24.0.1",
1414
"@rollup/plugin-node-resolve": "^15.0.1",
1515
"@rollup/plugin-replace": "^5.0.2",
16+
"@types/react": "^17.0",
17+
"@types/react-dom": "^17.0",
18+
"typescript": "^4.9.5",
1619
"prettier": "^3.0.2",
1720
"rollup": "^3.28.1"
1821
},

‎src/js/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mount } from "@reactpy/client";
1+
import { mount } from "./mount";
22
import { ReactPyDjangoClient } from "./client";
33

44
export function mountComponent(

‎src/js/src/mount.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from "react";
2+
import { render } from "react-dom";
3+
import { Layout } from "@reactpy/client/src/components";
4+
import { ReactPyDjangoClient } from "./client";
5+
6+
export function mount(element: HTMLElement, client: ReactPyDjangoClient): void {
7+
const prerenderElement = document.getElementById(element.id + "-prerender");
8+
if (prerenderElement) {
9+
element.hidden = true;
10+
client.onMessage("layout-update", ({ path, model }) => {
11+
if (prerenderElement) {
12+
prerenderElement.replaceWith(element);
13+
element.hidden = false;
14+
}
15+
});
16+
}
17+
render(<Layout client={client} />, element);
18+
}

‎src/js/tsconfig.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,14 @@
33
"target": "ES2017",
44
"module": "esnext",
55
"moduleResolution": "node",
6+
"jsx": "react",
67
},
8+
"paths": {
9+
"react": [
10+
"./node_modules/preact/compat/"
11+
],
12+
"react-dom": [
13+
"./node_modules/preact/compat/"
14+
]
15+
}
716
}

‎src/reactpy_django/apps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from django.apps import AppConfig
22

3-
from reactpy_django.utils import ComponentPreloader
3+
from reactpy_django.utils import RootComponentFinder
44

55

66
class ReactPyConfig(AppConfig):
77
name = "reactpy_django"
88

99
def ready(self):
1010
# Populate the ReactPy component registry when Django is ready
11-
ComponentPreloader().run()
11+
RootComponentFinder().run()

‎src/reactpy_django/checks.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,4 +473,13 @@ def reactpy_errors(app_configs, **kwargs):
473473
)
474474
)
475475

476+
if not isinstance(config.REACTPY_PRERENDER, bool):
477+
errors.append(
478+
Error(
479+
"Invalid type for REACTPY_PRERENDER.",
480+
hint="REACTPY_PRERENDER should be a boolean.",
481+
id="reactpy_django.E021",
482+
)
483+
)
484+
476485
return errors

‎src/reactpy_django/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,8 @@
107107
"REACTPY_RECONNECT_BACKOFF_MULTIPLIER",
108108
1.25, # Default to 25% backoff per connection attempt
109109
)
110+
REACTPY_PRERENDER: bool = getattr(
111+
settings,
112+
"REACTPY_PRERENDER",
113+
False,
114+
)

‎src/reactpy_django/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@ class ComponentDoesNotExistError(AttributeError):
88

99
class InvalidHostError(ValueError):
1010
...
11+
12+
13+
class ComponentCarrierError(ValueError):
14+
...

‎src/reactpy_django/hooks.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,27 @@ def use_origin() -> str | None:
4848
this will be None."""
4949
scope = _use_scope()
5050
try:
51-
return next(
52-
(
53-
header[1].decode("utf-8")
54-
for header in scope["headers"]
55-
if header[0] == b"origin"
56-
),
57-
None,
58-
)
51+
if scope["type"] == "websocket":
52+
return next(
53+
(
54+
header[1].decode("utf-8")
55+
for header in scope["headers"]
56+
if header[0] == b"origin"
57+
),
58+
None,
59+
)
60+
if scope["type"] == "http":
61+
host = next(
62+
(
63+
header[1].decode("utf-8")
64+
for header in scope["headers"]
65+
if header[0] == b"host"
66+
)
67+
)
68+
return f"{scope['scheme']}://{host}" if host else None
5969
except Exception:
60-
return None
70+
_logger.info("Failed to get origin")
71+
return None
6172

6273

6374
def use_scope() -> dict[str, Any]:

‎src/reactpy_django/templates/reactpy/component.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
{% load static %}
2-
{% if reactpy_failure %}
3-
{% if reactpy_debug_mode %}
2+
3+
{% if reactpy_failure and reactpy_debug_mode %}
44
<b>{% firstof reactpy_error "UnknownError" %}:</b> "{% firstof reactpy_dotted_path "UnknownPath" %}"
55
{% endif %}
6-
{% else %}
6+
7+
{% if not reactpy_failure %}
8+
{% if reactpy_prerender_html %}<div id="{{ reactpy_uuid }}-prerender">{{ reactpy_prerender_html|safe }}</div>{% endif %}
79
<div id="{{ reactpy_uuid }}" class="{{ reactpy_class }}"></div>
810
<script type="module" crossorigin="anonymous">
911
import { mountComponent } from "{% static 'reactpy_django/client.js' %}";

‎src/reactpy_django/templatetags/reactpy.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
from __future__ import annotations
22

3+
from distutils.util import strtobool
34
from logging import getLogger
45
from uuid import uuid4
56

67
import dill as pickle
78
from django import template
89
from django.http import HttpRequest
910
from django.urls import NoReverseMatch, reverse
11+
from reactpy.backend.hooks import ConnectionContext
12+
from reactpy.backend.types import Connection, Location
1013
from reactpy.core.types import ComponentConstructor
14+
from reactpy.utils import vdom_to_html
1115

1216
from reactpy_django import config, models
1317
from reactpy_django.exceptions import (
18+
ComponentCarrierError,
1419
ComponentDoesNotExistError,
1520
ComponentParamError,
1621
InvalidHostError,
1722
)
1823
from reactpy_django.types import ComponentParams
19-
from reactpy_django.utils import validate_component_args
24+
from reactpy_django.utils import SyncLayout, validate_component_args
2025

2126
try:
2227
RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/")
@@ -32,6 +37,7 @@ def component(
3237
dotted_path: str,
3338
*args,
3439
host: str | None = None,
40+
prerender: str = str(config.REACTPY_PRERENDER),
3541
**kwargs,
3642
):
3743
"""This tag is used to embed an existing ReactPy component into your HTML template.
@@ -41,9 +47,14 @@ def component(
4147
*args: The positional arguments to provide to the component.
4248
4349
Keyword Args:
50+
class: The HTML class to apply to the top-level component div.
51+
key: Force the component's root node to use a specific key value. Using \
52+
key within a template tag is effectively useless.
4453
host: The host to use for the ReactPy connections. If set to `None`, \
4554
the host will be automatically configured. \
4655
Example values include: `localhost:8000`, `example.com`, `example.com/subdir`
56+
prerender: Configures whether to pre-render this component, which \
57+
enables SEO compatibility and increases perceived responsiveness.
4758
**kwargs: The keyword arguments to provide to the component.
4859
4960
Example ::
@@ -67,6 +78,7 @@ def component(
6778
class_ = kwargs.pop("class", "")
6879
component_has_args = args or kwargs
6980
user_component: ComponentConstructor | None = None
81+
_prerender_html = ""
7082

7183
# Validate the host
7284
if host and config.REACTPY_DEBUG_MODE:
@@ -102,6 +114,25 @@ def component(
102114
)
103115
return failure_context(dotted_path, e)
104116

117+
# Pre-render the component, if requested
118+
if strtobool(prerender):
119+
if not is_local:
120+
msg = "Cannot pre-render non-local components."
121+
_logger.error(msg)
122+
return failure_context(dotted_path, ComponentDoesNotExistError(msg))
123+
if not user_component:
124+
msg = "Cannot pre-render component that is not registered."
125+
_logger.error(msg)
126+
return failure_context(dotted_path, ComponentDoesNotExistError(msg))
127+
if not request:
128+
msg = (
129+
"Cannot pre-render component without a HTTP request. Are you missing the "
130+
"request context processor in settings.py:TEMPLATES['OPTIONS']['context_processors']?"
131+
)
132+
_logger.error(msg)
133+
return failure_context(dotted_path, ComponentCarrierError(msg))
134+
_prerender_html = prerender_component(user_component, args, kwargs, request)
135+
105136
# Return the template rendering context
106137
return {
107138
"reactpy_class": class_,
@@ -116,6 +147,7 @@ def component(
116147
"reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL,
117148
"reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER,
118149
"reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_RETRIES,
150+
"reactpy_prerender_html": _prerender_html,
119151
}
120152

121153

@@ -143,3 +175,24 @@ def validate_host(host: str):
143175
)
144176
_logger.error(msg)
145177
raise InvalidHostError(msg)
178+
179+
180+
def prerender_component(
181+
user_component: ComponentConstructor, args, kwargs, request: HttpRequest
182+
):
183+
search = request.GET.urlencode()
184+
with SyncLayout(
185+
ConnectionContext(
186+
user_component(*args, **kwargs),
187+
value=Connection(
188+
scope=getattr(request, "scope", {}),
189+
location=Location(
190+
pathname=request.path, search=f"?{search}" if search else ""
191+
),
192+
carrier=request,
193+
),
194+
)
195+
) as layout:
196+
vdom_tree = layout.render()["model"]
197+
198+
return vdom_to_html(vdom_tree)

‎src/reactpy_django/types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from django.db.models.base import Model
1818
from django.db.models.query import QuerySet
19+
from django.http import HttpRequest
1920
from django.views.generic import View
2021
from reactpy.types import Connection as _Connection
2122
from typing_extensions import ParamSpec
@@ -50,7 +51,7 @@ class ComponentWebsocket:
5051
dotted_path: str
5152

5253

53-
Connection = _Connection[ComponentWebsocket]
54+
Connection = _Connection[Union[ComponentWebsocket, HttpRequest]]
5455

5556

5657
@dataclass

‎src/reactpy_django/utils.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from inspect import iscoroutinefunction
1212
from typing import Any, Callable, Sequence
1313

14+
from asgiref.sync import async_to_sync
1415
from channels.db import database_sync_to_async
1516
from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects
1617
from django.db.models.base import Model
@@ -20,6 +21,7 @@
2021
from django.utils import timezone
2122
from django.utils.encoding import smart_str
2223
from django.views import View
24+
from reactpy.core.layout import Layout
2325
from reactpy.types import ComponentConstructor
2426

2527
from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError
@@ -117,8 +119,8 @@ def import_dotted_path(dotted_path: str) -> Callable:
117119
return getattr(module, component_name)
118120

119121

120-
class ComponentPreloader:
121-
"""Preloads all ReactPy components found within Django templates.
122+
class RootComponentFinder:
123+
"""Searches Django templates to find and register all root components.
122124
This should only be `run` once on startup to maintain synchronization during mulitprocessing.
123125
"""
124126

@@ -352,3 +354,19 @@ def delete_expired_sessions(immediate: bool = False):
352354
"This may indicate a performance issue with your system, cache, or database.",
353355
clean_duration.total_seconds(),
354356
)
357+
358+
359+
class SyncLayout(Layout):
360+
"""Sync adapter for ReactPy's `Layout`. Allows it to be used in Django template tags.
361+
This can be removed when Django supports async template tags.
362+
"""
363+
364+
def __enter__(self):
365+
async_to_sync(self.__aenter__)()
366+
return self
367+
368+
def __exit__(self, *_):
369+
async_to_sync(self.__aexit__)(*_)
370+
371+
def render(self):
372+
return async_to_sync(super().render)()

‎tests/test_app/asgi.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22
ASGI config for test_app project.
33
44
It exposes the ASGI callable as a module-level variable named ``application``.
5-
6-
For more information on this file, see
7-
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
85
"""
96

107
import os

‎tests/test_app/prerender/__init__.py

Whitespace-only changes.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from time import sleep
2+
3+
import reactpy_django
4+
from reactpy import component, html
5+
6+
7+
@component
8+
def prerender_string():
9+
scope = reactpy_django.hooks.use_scope()
10+
11+
sleep(0.5)
12+
return (
13+
"prerender_string: Fully Rendered"
14+
if scope.get("type") == "websocket"
15+
else "prerender_string: Prerendered"
16+
)
17+
18+
19+
@component
20+
def prerender_vdom():
21+
scope = reactpy_django.hooks.use_scope()
22+
23+
if scope.get("type") == "http":
24+
return html.div("prerender_vdom: Prerendered")
25+
26+
return html.div("prerender_vdom: Fully Rendered")
27+
28+
29+
@component
30+
def prerender_component():
31+
scope = reactpy_django.hooks.use_scope()
32+
33+
@component
34+
def inner(value):
35+
return html.div(value)
36+
37+
if scope.get("type") == "http":
38+
return inner("prerender_component: Prerendered")
39+
40+
return inner("prerender_component: Fully Rendered")

‎tests/test_app/prerender/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.urls import path
2+
3+
from .views import prerender
4+
5+
urlpatterns = [
6+
path("prerender/", prerender),
7+
]

‎tests/test_app/prerender/views.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.shortcuts import render
2+
3+
4+
def prerender(request):
5+
return render(request, "prerender.html", {})

‎tests/test_app/settings.py

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,3 @@
1-
"""
2-
Django settings for test_app project.
3-
4-
Generated by 'django-admin startproject' using Django 3.2.3.
5-
6-
For more information on this file, see
7-
https://docs.djangoproject.com/en/3.2/topics/settings/
8-
9-
For the full list of settings and their values, see
10-
https://docs.djangoproject.com/en/3.2/ref/settings/
11-
"""
12-
131
import os
142
import sys
153
from pathlib import Path
@@ -19,7 +7,6 @@
197
SRC_DIR = BASE_DIR.parent / "src"
208

219
# Quick-start development settings - unsuitable for production
22-
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
2310

2411
# SECURITY WARNING: keep the secret key used in production secret!
2512
SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c"
@@ -73,7 +60,6 @@
7360
sys.path.append(str(SRC_DIR))
7461

7562
# Database
76-
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
7763
# WARNING: There are overrides in `test_components.py` that require no in-memory
7864
# databases are used for testing. Make sure all SQLite databases are on disk.
7965
DATABASES = {
@@ -118,7 +104,6 @@
118104
}
119105

120106
# Password validation
121-
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
122107
AUTH_PASSWORD_VALIDATORS = [
123108
{
124109
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
@@ -135,7 +120,6 @@
135120
]
136121

137122
# Internationalization
138-
# https://docs.djangoproject.com/en/3.2/topics/i18n/
139123
LANGUAGE_CODE = "en-us"
140124
TIME_ZONE = "UTC"
141125
USE_I18N = True
@@ -144,12 +128,10 @@
144128

145129

146130
# Default primary key field type
147-
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
148131
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
149132
STATIC_ROOT = os.path.join(BASE_DIR, "static-deploy")
150133

151134
# Static Files (CSS, JavaScript, Images)
152-
# https://docs.djangoproject.com/en/3.2/howto/static-files/
153135
STATIC_URL = "/static/"
154136
STATICFILES_DIRS = [
155137
os.path.join(BASE_DIR, "test_app", "static"),
@@ -159,6 +141,7 @@
159141
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
160142
]
161143

144+
162145
# Logging
163146
LOG_LEVEL = "WARNING"
164147
if DEBUG and ("test" not in sys.argv):
@@ -177,6 +160,10 @@
177160
"handlers": ["console"],
178161
"level": LOG_LEVEL,
179162
},
163+
"reactpy": {
164+
"handlers": ["console"],
165+
"level": LOG_LEVEL,
166+
},
180167
},
181168
}
182169

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{% load static %} {% load reactpy %}
2+
<!DOCTYPE html>
3+
<html lang="en">
4+
5+
<head>
6+
<meta charset="UTF-8" />
7+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
8+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
9+
<link rel="shortcut icon" type="image/png" href="{% static 'favicon.ico' %}" />
10+
<title>ReactPy</title>
11+
</head>
12+
13+
<body>
14+
<h1>ReactPy Prerender Test Page</h1>
15+
<hr>
16+
<div id="prerender_string">
17+
{% component "test_app.prerender.components.prerender_string" class="prerender-string" prerender="true" %}
18+
</div>
19+
<hr>
20+
<div id="prerender_vdom">
21+
{% component "test_app.prerender.components.prerender_vdom" class="prerender-vdom" prerender="true" %}
22+
</div>
23+
<hr>
24+
<div id="prerender_component">
25+
{% component "test_app.prerender.components.prerender_component" class="prerender-component" prerender="true" %}
26+
</div>
27+
<hr>
28+
</body>
29+
30+
</html>

‎tests/test_app/tests/test_components.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
from distutils.util import strtobool
66
from functools import partial
7+
from time import sleep
78

89
from channels.testing import ChannelsLiveServerTestCase
910
from channels.testing.live import make_application
@@ -375,3 +376,36 @@ def test_broken_postprocessor_query(self):
375376
broken_component = self.page.locator("#broken_postprocessor_query pre")
376377
broken_component.wait_for()
377378
self.assertIn("SynchronousOnlyOperation:", broken_component.text_content())
379+
380+
def test_prerender(self):
381+
"""Verify if round-robin host selection is working."""
382+
new_page = self.browser.new_page()
383+
try:
384+
new_page.goto(f"{self.live_server_url}/prerender/")
385+
string = new_page.locator("#prerender_string")
386+
vdom = new_page.locator("#prerender_vdom")
387+
component = new_page.locator("#prerender_component")
388+
389+
string.wait_for()
390+
vdom.wait_for()
391+
component.wait_for()
392+
393+
# Check if the prerender occurred
394+
self.assertEqual(
395+
string.all_inner_texts(), ["prerender_string: Prerendered"]
396+
)
397+
self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Prerendered"])
398+
self.assertEqual(
399+
component.all_inner_texts(), ["prerender_component: Prerendered"]
400+
)
401+
402+
sleep(1)
403+
self.assertEqual(
404+
string.all_inner_texts(), ["prerender_string: Fully Rendered"]
405+
)
406+
self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Fully Rendered"])
407+
self.assertEqual(
408+
component.all_inner_texts(), ["prerender_component: Fully Rendered"]
409+
)
410+
finally:
411+
new_page.close()

‎tests/test_app/urls.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
"""test_app URL Configuration
22
3-
The `urlpatterns` list routes URLs to views. For more information please see:
4-
https://docs.djangoproject.com/en/3.2/topics/http/urls/
5-
63
Examples:
74
85
Function views
@@ -20,7 +17,7 @@
2017
from django.contrib import admin
2118
from django.urls import include, path
2219

23-
from .views import base_template, host_port_roundrobin_template, host_port_template
20+
from . import views
2421

2522

2623
class AccessUser:
@@ -30,11 +27,13 @@ class AccessUser:
3027
admin.site.has_permission = lambda r: setattr(r, "user", AccessUser()) or True # type: ignore
3128

3229
urlpatterns = [
33-
path("", base_template),
34-
path("port/<int:port>/", host_port_template),
30+
path("", views.base_template),
31+
path("port/<int:port>/", views.host_port_template),
3532
path(
36-
"roundrobin/<int:port1>/<int:port2>/<int:count>/", host_port_roundrobin_template
33+
"roundrobin/<int:port1>/<int:port2>/<int:count>/",
34+
views.host_port_roundrobin_template,
3735
),
36+
path("", include("test_app.prerender.urls")),
3837
path("", include("test_app.performance.urls")),
3938
path("reactpy/", include("reactpy_django.http.urls")),
4039
path("admin/", admin.site.urls),

0 commit comments

Comments
 (0)
Please sign in to comment.