fix: Fix bug where JS and CSS were missing when {% component %} tag was inside {% include %} tag (#1300)

* fix: Fix bug where JS and CSS were missing when `{% component %}` tag was inside `{% include %}` tag

* refactor: fix mypy error
This commit is contained in:
Juro Oravec 2025-07-20 23:42:59 +02:00 committed by GitHub
parent 672811b8b4
commit 81c0d419b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 170 additions and 12 deletions

View file

@ -1,5 +1,11 @@
# Release notes
## v0.141.2
#### Fix
- Fix bug where JS and CSS were missing when `{% component %}` tag was inside `{% include %}` tag ([#1296](https://github.com/django-components/django-components/issues/1296))
## v0.141.1
#### Fix

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "django_components"
version = "0.141.1"
version = "0.141.2"
requires-python = ">=3.8, <4.0"
description = "A way to create simple reusable template components in Django."
keywords = ["django", "components", "css", "js", "html"]

View file

@ -4,6 +4,7 @@ from typing import Any
from django.apps import AppConfig
from django.template import Template
from django.template.loader_tags import IncludeNode
from django.utils.autoreload import file_changed, trigger_reload
@ -18,12 +19,13 @@ class ComponentsConfig(AppConfig):
from django_components.component_registry import registry
from django_components.components.dynamic import DynamicComponent
from django_components.extension import extensions
from django_components.util.django_monkeypatch import monkeypatch_template_cls
from django_components.util.django_monkeypatch import monkeypatch_include_node, monkeypatch_template_cls
# NOTE: This monkeypatch is applied here, before Django processes any requests.
# To make django-components work with django-debug-toolbar-template-profiler
# See https://github.com/django-components/django-components/discussions/819
monkeypatch_template_cls(Template)
monkeypatch_include_node(IncludeNode)
# Import modules set in `COMPONENTS.libraries` setting
import_libraries()

View file

@ -2277,7 +2277,7 @@ class Component(metaclass=ComponentMeta):
deps_strategy = cast(DependenciesStrategy, default(deps_strategy, "document"))
self.id = default(id, _gen_component_id, factory=True)
self.id = default(id, _gen_component_id, factory=True) # type: ignore[arg-type]
self.name = _get_component_name(self.__class__, registered_name)
self.registered_name: Optional[str] = registered_name
self.args = default(args, [])

View file

@ -8,7 +8,7 @@ from django.template import Context, Origin, Template
from django.template.loader import get_template as django_get_template
from django_components.cache import get_template_cache
from django_components.util.django_monkeypatch import is_template_cls_patched
from django_components.util.django_monkeypatch import is_cls_patched
from django_components.util.loader import get_component_dirs
from django_components.util.logger import trace_component_msg
from django_components.util.misc import get_import_path, get_module_info
@ -98,7 +98,7 @@ def prepare_component_template(
yield template
return
if not is_template_cls_patched(template):
if not is_cls_patched(template):
raise RuntimeError(
"Django-components received a Template instance which was not patched."
"If you are using Django's Template class, check if you added django-components"

View file

@ -1,7 +1,8 @@
from typing import Any, Optional, Type
from django.template import Context, NodeList, Template
from django.template.base import Origin, Parser
from django.template.base import Node, Origin, Parser
from django.template.loader_tags import IncludeNode
from django_components.context import _COMPONENT_CONTEXT_KEY, _STRATEGY_CONTEXT_KEY, COMPONENT_IS_NESTED_KEY
from django_components.dependencies import COMPONENT_COMMENT_REGEX, render_dependencies
@ -11,7 +12,7 @@ from django_components.util.template_parser import parse_template
# In some cases we can't work around Django's design, and need to patch the template class.
def monkeypatch_template_cls(template_cls: Type[Template]) -> None:
if is_template_cls_patched(template_cls):
if is_cls_patched(template_cls):
return
monkeypatch_template_init(template_cls)
@ -150,7 +151,7 @@ def monkeypatch_template_render(template_cls: Type[Template]) -> None:
# have passed the value to `monkeypatch_template_render` directly. However, we intentionally
# did NOT do that, so the monkey-patched method is more robust, and can be e.g. copied
# to other.
if is_template_cls_patched(template_cls):
if is_cls_patched(template_cls):
# Do not patch if done so already. This helps us avoid RecursionError
return
@ -210,5 +211,37 @@ def monkeypatch_template_render(template_cls: Type[Template]) -> None:
template_cls.render = _template_render
def is_template_cls_patched(template_cls: Type[Template]) -> bool:
return getattr(template_cls, "_djc_patched", False)
def monkeypatch_include_node(include_node_cls: Type[Node]) -> None:
if is_cls_patched(include_node_cls):
return
monkeypatch_include_render(include_node_cls)
include_node_cls._djc_patched = True
def monkeypatch_include_render(include_node_cls: Type[Node]) -> None:
# Modify `IncludeNode.render()` (what renders `{% include %}` tag) so that the included
# template does NOT render the JS/CSS by itself.
#
# Instead, we want the parent template
# (which contains the `{% component %}` tag) to decide whether to render the JS/CSS.
#
# We achieve this by setting `DJC_DEPS_STRATEGY` to `ignore` in the context.
#
# Fix for https://github.com/django-components/django-components/issues/1296
if is_cls_patched(include_node_cls):
# Do not patch if done so already. This helps us avoid RecursionError
return
orig_include_render = include_node_cls.render
# NOTE: This implementation is based on Django v5.1.3)
def _include_render(self: IncludeNode, context: Context, *args: Any, **kwargs: Any) -> str:
with context.update({_STRATEGY_CONTEXT_KEY: "ignore"}):
return orig_include_render(self, context, *args, **kwargs)
include_node_cls.render = _include_render
def is_cls_patched(cls: Type[Any]) -> bool:
return getattr(cls, "_djc_patched", False)

View file

@ -1,4 +1,4 @@
{% include "test_cached_component_inside_include_sub.html" %}
{% include "component_inside_include_sub.html" %}
{% block content %}
THIS_IS_IN_BASE_TEMPLATE_SO_SHOULD_BE_OVERRIDDEN
{% endblock %}

View file

@ -219,7 +219,7 @@ class TestComponentCache:
template = Template(
"""
{% extends "test_cached_component_inside_include_base.html" %}
{% extends "component_inside_include_base.html" %}
{% block content %}
THIS_IS_IN_ACTUAL_TEMPLATE_SO_SHOULD_NOT_BE_OVERRIDDEN
{% endblock %}

View file

@ -25,6 +25,17 @@ def gen_blocked_and_slotted_component():
return BlockedAndSlottedComponent
def gen_component_inside_include():
class ComponentInsideInclude(Component):
template: types.django_html = """<div>Hello</div>"""
class Media:
css = "style.css"
js = "script.js"
return ComponentInsideInclude
#######################
# TESTS
#######################
@ -536,6 +547,112 @@ class TestExtendsCompat:
"""
assertHTMLEqual(rendered, expected)
# In this case, `{% include %}` is NOT nested inside a `{% component %}` tag.
# We need to ensure that the component inside the `{% include %}` is rendered as if with deps_strategy="ignore",
# so the parent template decides how to render the JS/CSS.
# See https://github.com/django-components/django-components/issues/1296
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_with_media_inside_include(self, components_settings):
registry.register("test_component", gen_component_inside_include())
template: types.django_html = """
{% load component_tags %}
<body>
<outer>
{% include "component_inside_include_sub.html" %}
</outer>
</body>
"""
rendered_raw = Template(template).render(Context({"DJC_DEPS_STRATEGY": "ignore"}))
expected_raw = """
<body>
<outer>
<div data-djc-id-ca1bc3f>Hello</div>
</outer>
</body>
"""
assertHTMLEqual(rendered_raw, expected_raw)
template_obj = Template(template)
context = Context()
rendered = template_obj.render(context)
# NOTE: It's important that the <script> tags are rendered outside of <div> and <outer> tags,
# because that tells us that the JS/CSS is rendered by the parent template, not the component
# inside the include.
expected = """
<body>
<outer>
<div data-djc-id-ca1bc41>Hello</div>
</outer>
<script src="django_components/django_components.min.js"></script>
<script type="application/json" data-djc>{"loadedCssUrls": ["c3R5bGUuY3Nz"],
"loadedJsUrls": ["c2NyaXB0Lmpz"],
"toLoadCssTags": [],
"toLoadJsTags": []}</script>
<script src="script.js"></script>
</body>
"""
assertHTMLEqual(rendered, expected)
# In this case, because `{% include %}` is rendered inside a `{% component %}` tag,
# then the component inside the `{% include %}` knows it's inside another component.
# So it's always rendered as if with deps_strategy="ignore".
# See https://github.com/django-components/django-components/issues/1296
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_with_media_inside_include_inside_component(self, components_settings):
registry.register("test_component", gen_component_inside_include())
@register("component_inside_include")
class CompInsideIncludeComponent(Component):
template: types.django_html = """
<body>
<outer>
{% include "component_inside_include_sub.html" %}
</outer>
</body>
"""
template: types.django_html = """
{% load component_tags %}
<html>
{% component "component_inside_include" / %}
</html>
"""
rendered_raw = Template(template).render(Context({"DJC_DEPS_STRATEGY": "ignore"}))
expected_raw = """
<html>
<body data-djc-id-ca1bc3f>
<outer>
<div data-djc-id-ca1bc41>Hello</div>
</outer>
</body>
</html>
"""
assertHTMLEqual(rendered_raw, expected_raw)
template_obj = Template(template)
context = Context()
rendered = template_obj.render(context)
expected = """
<html>
<body data-djc-id-ca1bc43>
<outer>
<div data-djc-id-ca1bc45>Hello</div>
</outer>
<script src="django_components/django_components.min.js"></script>
<script type="application/json" data-djc>{"loadedCssUrls": ["c3R5bGUuY3Nz"],
"loadedJsUrls": ["c2NyaXB0Lmpz"],
"toLoadCssTags": [],
"toLoadJsTags": []}</script>
<script src="script.js"></script>
</body>
</html>
"""
assertHTMLEqual(rendered, expected)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_inside_block(self, components_settings):
registry.register("slotted_component", gen_slotted_component())