refactor: remove middleware, add strategy "raw", and call render_deps() from within Template.render() (#1166)

* refactor: remove middleware, add strategy "raw", and call render_deps() from within Template.render()

* refactor: fix formatting

* refactor: fix benchmark tests

* refactor: avoid processing deps if rendered HTML contains no components

* refactor: remove comments

* refactor: rename "raw" to "ignore"
This commit is contained in:
Juro Oravec 2025-05-06 21:36:41 +02:00 committed by GitHub
parent 1049c08324
commit 6253042e9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 771 additions and 657 deletions

View file

@ -1,8 +1,7 @@
Django-components provides a seamless integration with HTML fragments with AJAX ([HTML over the wire](https://hotwired.dev/)),
whether you're using jQuery, HTMX, AlpineJS, or vanilla JavaScript.
whether you're using jQuery, HTMX, AlpineJS, vanilla JavaScript, or other.
When you define a component that has extra JS or CSS, and you use django-components
to render the fragment, django-components will:
If the fragment component has any JS or CSS, django-components will:
- Automatically load the associated JS and CSS
- Ensure that JS is loaded and executed only once even if the fragment is inserted multiple times
@ -24,9 +23,11 @@ to render the fragment, django-components will:
## Document and fragment strategies
Components support different "strategies" for rendering JS and CSS.
Components support different ["strategies"](../../advanced/rendering_js_css#dependencies-strategies)
for rendering JS and CSS.
Two of them are used to enable HTML fragments - "document" and "fragment".
Two of them are used to enable HTML fragments - ["document"](../../advanced/rendering_js_css#document)
and ["fragment"](../../advanced/rendering_js_css#fragment).
What's the difference?
@ -36,7 +37,7 @@ Document strategy assumes that the rendered components will be embedded into the
of the initial page load. This means that:
- The JS and CSS is embedded into the HTML as `<script>` and `<style>` tags
(see [JS and CSS output locations](./rendering_js_css.md#js-and-css-output-locations))
(see [Default JS / CSS locations](./rendering_js_css.md#default-js-css-locations))
- Django-components injects a JS script for managing JS and CSS assets
A component is rendered as a "document" when:
@ -103,15 +104,12 @@ Then navigate to these URLs:
### 1. Define document HTML
This is the HTML into which a fragment will be loaded using HTMX.
```djc_py title="[root]/components/demo.py"
from django_components import Component, types
# HTML into which a fragment will be loaded using HTMX
class MyPage(Component):
Class View:
def get(self, request):
return self.component.render_to_response(request=request)
template = """
{% load component_tags %}
<!DOCTYPE html>
@ -135,20 +133,20 @@ class MyPage(Component):
</body>
</html>
"""
class View:
def get(self, request):
return self.component.render_to_response(request=request)
```
### 2. Define fragment HTML
The fragment to be inserted into the document.
IMPORTANT: Don't forget to set `deps_strategy="fragment"`
```djc_py title="[root]/components/demo.py"
class Frag(Component):
class View:
def get(self, request):
return self.component.render_to_response(
request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment",
)
template = """
<div class="frag">
123
@ -165,6 +163,14 @@ class Frag(Component):
background: blue;
}
"""
class View:
def get(self, request):
return self.component.render_to_response(
request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment",
)
```
### 3. Create view and URLs
@ -184,15 +190,12 @@ urlpatterns = [
### 1. Define document HTML
This is the HTML into which a fragment will be loaded using AlpineJS.
```djc_py title="[root]/components/demo.py"
from django_components import Component, types
# HTML into which a fragment will be loaded using AlpineJS
class MyPage(Component):
class View:
def get(self, request):
return self.component.render_to_response(request=request)
template = """
{% load component_tags %}
<!DOCTYPE html>
@ -222,20 +225,20 @@ class MyPage(Component):
</body>
</html>
"""
class View:
def get(self, request):
return self.component.render_to_response(request=request)
```
### 2. Define fragment HTML
The fragment to be inserted into the document.
IMPORTANT: Don't forget to set `deps_strategy="fragment"`
```djc_py title="[root]/components/demo.py"
class Frag(Component):
class View:
def get(self, request):
return self.component.render_to_response(
request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment",
)
# NOTE: We wrap the actual fragment in a template tag with x-if="false" to prevent it
# from being rendered until we have registered the component with AlpineJS.
template = """
@ -265,6 +268,14 @@ class Frag(Component):
background: blue;
}
"""
class View:
def get(self, request):
return self.component.render_to_response(
request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment",
)
```
### 3. Create view and URLs
@ -284,15 +295,12 @@ urlpatterns = [
### 1. Define document HTML
This is the HTML into which a fragment will be loaded using vanilla JS.
```djc_py title="[root]/components/demo.py"
from django_components import Component, types
# HTML into which a fragment will be loaded using JS
class MyPage(Component):
class View:
def get(self, request):
return self.component.render_to_response(request=request)
template = """
{% load component_tags %}
<!DOCTYPE html>
@ -321,20 +329,20 @@ class MyPage(Component):
</body>
</html>
"""
class View:
def get(self, request):
return self.component.render_to_response(request=request)
```
### 2. Define fragment HTML
The fragment to be inserted into the document.
IMPORTANT: Don't forget to set `deps_strategy="fragment"`
```djc_py title="[root]/components/demo.py"
class Frag(Component):
class View:
def get(self, request):
return self.component.render_to_response(
request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment",
)
template = """
<div class="frag">
123
@ -351,6 +359,14 @@ class Frag(Component):
background: blue;
}
"""
class View:
def get(self, request):
return self.component.render_to_response(
request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment",
)
```
### 3. Create view and URLs

View file

@ -1,15 +1,20 @@
## JS and CSS output locations
## Introduction
If:
Components consist of 3 parts - HTML, JS and CSS.
1. Your components use JS and CSS via any of:
- [`Component.css`](#TODO)
- [`Component.js`](#TODO)
- [`Component.Media.css`](#TODO)
- [`Component.Media.js`](#TODO)
2. And you use the [`ComponentDependencyMiddleware`](#TODO) middleware
Handling of HTML is straightforward - it is rendered as is, and inserted where
the [`{% component %}`](../../../reference/template_tags#component) tag is.
Then, by default, the components' JS and CSS will be automatically inserted into the HTML:
However, handling of JS and CSS is more complex:
- JS and CSS is are inserted elsewhere in the HTML. As a best practice, JS is placed in the `<body>` HTML tag, and CSS in the `<head>`.
- Multiple components may use the same JS and CSS files. We don't want to load the same files multiple times.
- Fetching of JS and CSS may block the page, so the JS / CSS should be embedded in the HTML.
- Components inserted as HTML fragments need different handling for JS and CSS.
## Default JS / CSS locations
If your components use JS and CSS then, by default, the JS and CSS will be automatically inserted into the HTML:
- CSS styles will be inserted at the end of the `<head>`
- JS scripts will be inserted at the end of the `<body>`
@ -17,8 +22,8 @@ Then, by default, the components' JS and CSS will be automatically inserted into
If you want to place the dependencies elsewhere in the HTML, you can override
the locations by inserting following Django template tags:
- [`{% component_js_dependencies %}`](#TODO) - Set new location(s) for JS scripts
- [`{% component_css_dependencies %}`](#TODO) - Set new location(s) for CSS styles
- [`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies) - Set new location(s) for JS scripts
- [`{% component_css_dependencies %}`](../../../reference/template_tags#component_css_dependencies) - Set new location(s) for CSS styles
So if you have a component with JS and CSS:
@ -31,6 +36,7 @@ class MyButton(Component):
Click me!
</button>
"""
js: types.js = """
for (const btnEl of document.querySelectorAll(".my-button")) {
btnEl.addEventListener("click", () => {
@ -38,6 +44,7 @@ class MyButton(Component):
});
}
"""
css: types.css """
.my-button {
background: green;
@ -49,11 +56,13 @@ class MyButton(Component):
css = ["/extra/style.css"]
```
Then the JS from `MyButton.js` and `MyButton.Media.js` will be rendered at the default place,
or in [`{% component_js_dependencies %}`](#TODO).
Then:
And the CSS from `MyButton.css` and `MyButton.Media.css` will be rendered at the default place,
or in [`{% component_css_dependencies %}`](#TODO).
- JS from `MyButton.js` and `MyButton.Media.js` will be rendered at the default place (`<body>`),
or in [`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies).
- CSS from `MyButton.css` and `MyButton.Media.css` will be rendered at the default place (`<head>`),
or in [`{% component_css_dependencies %}`](../../../reference/template_tags#component_css_dependencies).
And if you don't specify `{% component_dependencies %}` tags, it is the equivalent of:
@ -74,169 +83,62 @@ And if you don't specify `{% component_dependencies %}` tags, it is the equivale
</html>
```
### Setting up the middleware
!!! warning
[`ComponentDependencyMiddleware`](#TODO) is a Django [middleware](https://docs.djangoproject.com/en/5.1/topics/http/middleware/)
designed to manage and inject CSS / JS dependencies of rendered components dynamically.
It ensures that only the necessary stylesheets and scripts are loaded
in your HTML responses, based on the components used in your Django templates.
If the rendered HTML does NOT contain neither `{% component_dependencies %}` template tags,
nor `<head>` and `<body>` HTML tags, then the JS and CSS will NOT be inserted!
To set it up, add the middleware to your [`MIDDLEWARE`](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-MIDDLEWARE)
in `settings.py`:
```python
MIDDLEWARE = [
# ... other middleware classes ...
'django_components.middleware.ComponentDependencyMiddleware'
# ... other middleware classes ...
]
```
### `render_dependencies` and rendering JS / CSS without the middleware
For most scenarios, using the [`ComponentDependencyMiddleware`](#TODO) middleware will be just fine.
However, this section is for you if you want to:
- Render HTML that will NOT be sent as a server response
- Insert pre-rendered HTML into another component
- Render HTML fragments (partials)
Every time there is an HTML string that has parts which were rendered using components,
and any of those components has JS / CSS, then this HTML string MUST be processed with [`render_dependencies()`](#TODO).
It is actually [`render_dependencies()`](#TODO) that finds all used components in the HTML string,
and inserts the component's JS and CSS into `{% component_dependencies %}` tags, or at the default locations.
#### Render JS / CSS without the middleware
The truth is that the [`ComponentDependencyMiddleware`](#TODO) middleware just calls [`render_dependencies()`](#TODO),
passing in the HTML content. So if you render a template that contained [`{% component %}`](#TODO) tags,
you MUST pass the result through [`render_dependencies()`](#TODO). And the middleware is just one of the options.
Here is how you can achieve the same, without the middleware, using [`render_dependencies()`](#TODO):
```python
from django.template.base import Template
from django.template.context import Context
from django_component import render_dependencies
template = Template("""
{% load component_tags %}
<!doctype html>
<html>
<head>
<title>MyPage</title>
</head>
<body>
<main>
{% component "my_button" %}
Click me!
{% endcomponent %}
</main>
</body>
</html>
""")
rendered = template.render(Context())
rendered = render_dependencies(rendered)
```
Same applies if you render a template using Django's [`django.shortcuts.render`](https://docs.djangoproject.com/en/5.1/topics/http/shortcuts/#render):
```python
from django.shortcuts import render
def my_view(request):
rendered = render(request, "pages/home.html")
rendered = render_dependencies(rendered)
return rendered
```
Alternatively, when you render HTML with [`Component.render()`](#TODO)
or [`Component.render_to_response()`](#TODO),
these, by default, call [`render_dependencies()`](#TODO) for you, so you don't have to:
```python
from django_components import Component
class MyButton(Component):
...
# No need to call `render_dependencies()`
rendered = MyButton.render()
```
#### Inserting pre-rendered HTML into another component
In previous section we've shown that [`render_dependencies()`](#TODO) does NOT need to be called
when you render a component via [`Component.render()`](#TODO).
API of django_components makes it possible to compose components in a "React-like" way,
where we pre-render a piece of HTML and then insert it into a larger structure.
To do this, you must add [`render_dependencies=False`](#TODO) to the nested components:
```python
card_actions = CardActions.render(
kwargs={"editable": editable},
render_dependencies=False,
)
card = Card.render(
slots={"actions": card_actions},
render_dependencies=False,
)
page = MyPage.render(
slots={"card": card},
)
```
Why is `render_dependencies=False` required?
This is a technical limitation of the current implementation.
As mentioned earlier, each time we call [`Component.render()`](#TODO),
we also call [`render_dependencies()`](#TODO).
However, there is a problem here - When we call [`render_dependencies()`](#TODO)
inside [`CardActions.render()`](#TODO),
we extract and REMOVE the info on components' JS and CSS from the HTML. But the template
of `CardActions` contains no `{% component_depedencies %}` tags, and nor `<head>` nor `<body>` HTML tags.
So the component's JS and CSS will NOT be inserted, and will be lost.
To work around this, you must set [`render_dependencies=False`](#TODO) when rendering pieces of HTML
with [`Component.render()`](#TODO) and inserting them into larger structures.
#### Summary
1. Every time you render HTML that contained components, you have to call [`render_dependencies()`](#TODO)
on the rendered output.
2. There are several ways to call [`render_dependencies()`](#TODO):
- Using the [`ComponentDependencyMiddleware`](#TODO) middleware
- Rendering the HTML by calling [`Component.render()`](#TODO) with `render_dependencies=True` (default)
- Rendering the HTML by calling [`Component.render_to_response()`](#TODO) (always renders dependencies)
- Directly passing rendered HTML to [`render_dependencies()`](#TODO)
3. If you pre-render one component to pass it into another, the pre-rendered component must be rendered with
[`render_dependencies=False`](#TODO).
To force the JS and CSS to be inserted, use the [`"append"`](#append) or [`"prepend"`](#prepend)
strategies.
## Dependencies strategies
The rendered HTML may be used in different contexts (browser, email, etc).
If your components use JS and CSS scripts, you may need to handle them differently.
The different ways for handling JS / CSS are called **"dependencies strategies"**.
[`render()`](../../../reference/api/#django_components.Component.render) and [`render_to_response()`](../../../reference/api/#django_components.Component.render_to_response)
accept a `deps_strategy` parameter, which controls where and how the JS / CSS are inserted into the HTML.
```python
main_page = MainPage.render(deps_strategy="document")
fragment = MyComponent.render_to_response(deps_strategy="fragment")
```
The `deps_strategy` parameter is set at the root of a component render tree, which is why it is not available for
the [`{% component %}`](../../../reference/template_tags#component) tag.
When you use Django's [`django.shortcuts.render()`](https://docs.djangoproject.com/en/5.1/topics/http/shortcuts/#render)
or [`Template.render()`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Template.render) to render templates,
you can't directly set the `deps_strategy` parameter.
In this case, you can set the `deps_strategy` with the `DJC_DEPS_STRATEGY` context variable.
```python
from django.template.context import Context
from django.shortcuts import render
ctx = Context({"DJC_DEPS_STRATEGY": "fragment"})
fragment = render(request, "my_component.html", ctx=ctx)
```
!!! info
The `deps_strategy` parameter is ultimately passed to [`render_dependencies()`](../../../reference/api/#django_components.render_dependencies).
There are five dependencies strategies:
!!! note "Why is `deps_strategy` required?"
This is a technical limitation of the current implementation.
When a component is rendered, django-components embeds metadata about the component's JS and CSS into the HTML.
This way we can compose components together, and know which JS / CSS dependencies are needed.
As the last step of rendering, django-components extracts this metadata and uses a selected strategy
to insert the JS / CSS into the HTML.
There are six dependencies strategies:
- [`document`](../../advanced/rendering_js_css#document) (default)
- Smartly inserts JS / CSS into placeholders or into `<head>` and `<body>` tags.
@ -254,6 +156,10 @@ There are five dependencies strategies:
- [`append`](../../advanced/rendering_js_css#append)
- Insert JS / CSS after the rendered HTML.
- No extra script loaded.
- [`ignore`](../../advanced/rendering_js_css#ignore)
- HTML is left as-is. You can still process it with a different strategy later with
[`render_dependencies()`](../../../reference/api/#django_components.render_dependencies).
- Used for inserting rendered HTML into other components.
### `document`
@ -290,7 +196,7 @@ the page in the browser:
</script>
<style>
.button {
background-color: blue;
background-color: blue;
}
</style>
```
@ -420,3 +326,106 @@ JS and CSS is **always** inserted after the rendered content.
**Included scripts:**
Same as for the [`"simple"`](#simple) strategy.
### `ignore`
`deps_strategy="ignore"` is used when you do NOT want to process JS and CSS of the rendered HTML.
```python
html = MyComponent.render(deps_strategy="ignore")
```
The rendered HTML is left as-is. You can still process it with a different strategy later with `render_dependencies()`.
This is useful when you want to insert rendered HTML into another component.
```python
html = MyComponent.render(deps_strategy="ignore")
html = AnotherComponent.render(slots={"content": html})
```
## Manually rendering JS / CSS
When rendering templates or components, django-components covers all the traditional ways how components
or templates can be rendered:
- [`Component.render()`](../../../reference/api/#django_components.Component.render)
- [`Component.render_to_response()`](../../../reference/api/#django_components.Component.render_to_response)
- [`Template.render()`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Template.render)
- [`django.shortcuts.render()`](https://docs.djangoproject.com/en/5.1/topics/http/shortcuts/#render)
This way you don't need to manually handle rendering of JS / CSS.
However, for advanced or low-level use cases, you may need to control when to render JS / CSS.
In such case you can directly pass rendered HTML to [`render_dependencies()`](../../../reference/api/#django_components.render_dependencies).
This function will extract all used components in the HTML string, and insert the components' JS and CSS
based on given strategy.
!!! info
The truth is that all the methods listed above call [`render_dependencies()`](../../../reference/api/#django_components.render_dependencies)
internally.
**Example:**
To see how [`render_dependencies()`](../../../reference/api/#django_components.render_dependencies) works,
let's render a template with a component.
We will render it twice:
- First time, we let `template.render()` handle the rendering.
- Second time, we prevent `template.render()` from inserting the component's JS and CSS with `deps_strategy="ignore"`.
Instead, we pass the "unprocessed" HTML to `render_dependencies()` ourselves to insert the component's JS and CSS.
```python
from django.template.base import Template
from django.template.context import Context
from django_components import render_dependencies
template = Template("""
{% load component_tags %}
<!doctype html>
<html>
<head>
<title>MyPage</title>
</head>
<body>
<main>
{% component "my_button" %}
Click me!
{% endcomponent %}
</main>
</body>
</html>
""")
rendered = template.render(Context({}))
rendered2_raw = template.render(Context({"DJC_DEPS_STRATEGY": "ignore"}))
rendered2 = render_dependencies(rendered2_raw)
assert rendered == rendered2
```
Same applies to other strategies and other methods of rendering:
```python
raw_html = MyComponent.render(deps_strategy="ignore")
html = render_dependencies(raw_html, deps_strategy="document")
html2 = MyComponent.render(deps_strategy="document")
assert html == html2
```
## HTML fragments
Django-components provides a seamless integration with HTML fragments with AJAX ([HTML over the wire](https://hotwired.dev/)),
whether you're using jQuery, HTMX, AlpineJS, vanilla JavaScript, or other.
This is achieved by the combination of the [`"document"`](#document) and [`"fragment"`](#fragment) strategies.
Read more about [HTML fragments](../../advanced/html_fragments).