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 # 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 ## v0.141.1
#### Fix #### Fix

View file

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

View file

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

View file

@ -2277,7 +2277,7 @@ class Component(metaclass=ComponentMeta):
deps_strategy = cast(DependenciesStrategy, default(deps_strategy, "document")) 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.name = _get_component_name(self.__class__, registered_name)
self.registered_name: Optional[str] = registered_name self.registered_name: Optional[str] = registered_name
self.args = default(args, []) 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.template.loader import get_template as django_get_template
from django_components.cache import get_template_cache 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.loader import get_component_dirs
from django_components.util.logger import trace_component_msg from django_components.util.logger import trace_component_msg
from django_components.util.misc import get_import_path, get_module_info from django_components.util.misc import get_import_path, get_module_info
@ -98,7 +98,7 @@ def prepare_component_template(
yield template yield template
return return
if not is_template_cls_patched(template): if not is_cls_patched(template):
raise RuntimeError( raise RuntimeError(
"Django-components received a Template instance which was not patched." "Django-components received a Template instance which was not patched."
"If you are using Django's Template class, check if you added django-components" "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 typing import Any, Optional, Type
from django.template import Context, NodeList, Template 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.context import _COMPONENT_CONTEXT_KEY, _STRATEGY_CONTEXT_KEY, COMPONENT_IS_NESTED_KEY
from django_components.dependencies import COMPONENT_COMMENT_REGEX, render_dependencies 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. # 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: def monkeypatch_template_cls(template_cls: Type[Template]) -> None:
if is_template_cls_patched(template_cls): if is_cls_patched(template_cls):
return return
monkeypatch_template_init(template_cls) 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 # 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 # did NOT do that, so the monkey-patched method is more robust, and can be e.g. copied
# to other. # 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 # Do not patch if done so already. This helps us avoid RecursionError
return return
@ -210,5 +211,37 @@ def monkeypatch_template_render(template_cls: Type[Template]) -> None:
template_cls.render = _template_render template_cls.render = _template_render
def is_template_cls_patched(template_cls: Type[Template]) -> bool: def monkeypatch_include_node(include_node_cls: Type[Node]) -> None:
return getattr(template_cls, "_djc_patched", False) 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 %} {% block content %}
THIS_IS_IN_BASE_TEMPLATE_SO_SHOULD_BE_OVERRIDDEN THIS_IS_IN_BASE_TEMPLATE_SO_SHOULD_BE_OVERRIDDEN
{% endblock %} {% endblock %}

View file

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

View file

@ -25,6 +25,17 @@ def gen_blocked_and_slotted_component():
return BlockedAndSlottedComponent 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 # TESTS
####################### #######################
@ -536,6 +547,112 @@ class TestExtendsCompat:
""" """
assertHTMLEqual(rendered, expected) 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) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_inside_block(self, components_settings): def test_component_inside_block(self, components_settings):
registry.register("slotted_component", gen_slotted_component()) registry.register("slotted_component", gen_slotted_component())