From c72fed8255e339a29e40db105f1733e491321121 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Thu, 14 Aug 2025 11:33:31 +0200 Subject: [PATCH] feat: render fragments without document strategy (#1339) --- CHANGELOG.md | 15 ++++++ docs/concepts/advanced/rendering_js_css.md | 16 +++--- .../fundamentals/rendering_components.md | 8 +-- src/django_components/component.py | 14 ++--- src/django_components/dependencies.py | 36 +++++++++++++ .../django_components.min.js | 2 +- src/django_components_js/src/manager.ts | 12 +++-- tests/e2e/testserver/testserver/urls.py | 2 + tests/e2e/testserver/testserver/views.py | 42 ++++++++++++++- tests/test_dependencies.py | 44 +++++++++++++++- tests/test_dependency_rendering_e2e.py | 52 +++++++++++++++++++ 11 files changed, 217 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cabbd37..83aebc5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Release notes +## v0.141.3 + +#### Feat + +- You no longer need to render the whole page with the `document` strategy to use HTML fragments. + + Previously, if you wanted to insert rendered components as HTML fragments, you had to ensure + that the HTML document it was being inserted into was rendered with the `document` strategy. + + Now, when you render components with `fragment` strategy, they know how to fetch their own JS / CSS dependencies. + +#### Fix + +- Fix compatibility with django-template-partials ([#1322](https://github.com/django-components/django-components/issues/1322)) + ## v0.141.2 #### Fix diff --git a/docs/concepts/advanced/rendering_js_css.md b/docs/concepts/advanced/rendering_js_css.md index 21621cca..ab24048d 100644 --- a/docs/concepts/advanced/rendering_js_css.md +++ b/docs/concepts/advanced/rendering_js_css.md @@ -142,11 +142,12 @@ There are six dependencies strategies: - [`document`](../../advanced/rendering_js_css#document) (default) - Smartly inserts JS / CSS into placeholders or into `` and `` tags. - - Inserts extra script to allow `fragment` strategy to work. - - Assumes the HTML will be rendered in a JS-enabled browser. + - Requires the HTML to be rendered in a JS-enabled browser. + - Inserts extra script for managing fragments. - [`fragment`](../../advanced/rendering_js_css#fragment) - A lightweight HTML fragment to be inserted into a document with AJAX. - - No JS / CSS included. + - Fragment will fetch its own JS / CSS dependencies when inserted into the page. + - Requires the HTML to be rendered in a JS-enabled browser. - [`simple`](../../advanced/rendering_js_css#simple) - Smartly insert JS / CSS into placeholders or into `` and `` tags. - No extra script loaded. @@ -212,10 +213,6 @@ the page in the browser: - A JS script is injected to manage component dependencies, enabling lazy loading of JS and CSS for HTML fragments. -!!! info - - This strategy is required for fragments to work properly, as it sets up the dependency manager that fragments rely on. - !!! note "How the dependency manager works" The dependency manager is a JS script that keeps track of all the JS and CSS dependencies that have already been loaded. @@ -228,8 +225,7 @@ the page in the browser: ### `fragment` -`deps_strategy="fragment"` is used when rendering a piece of HTML that will be inserted into a page -that has already been rendered with the [`"document"`](#document) strategy: +`deps_strategy="fragment"` is used when rendering a piece of HTML that will be inserted into a page: ```python fragment = MyComponent.render(deps_strategy="fragment") @@ -426,6 +422,6 @@ assert html == html2 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. +This is achieved by the [`"fragment"`](#fragment) strategy. Read more about [HTML fragments](../../advanced/html_fragments). diff --git a/docs/concepts/fundamentals/rendering_components.md b/docs/concepts/fundamentals/rendering_components.md index 38883204..dd66004e 100644 --- a/docs/concepts/fundamentals/rendering_components.md +++ b/docs/concepts/fundamentals/rendering_components.md @@ -349,12 +349,12 @@ There are six dependencies rendering strategies: - [`document`](../../advanced/rendering_js_css#document) (default) - Smartly inserts JS / CSS into placeholders ([`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies)) or into `` and `` tags. - - Inserts extra script to allow `fragment` components to work. - - Assumes the HTML will be rendered in a JS-enabled browser. + - Requires the HTML to be rendered in a JS-enabled browser. + - Inserts extra script for managing fragments. - [`fragment`](../../advanced/rendering_js_css#fragment) - A lightweight HTML fragment to be inserted into a document with AJAX. - - Assumes the page was already rendered with `"document"` strategy. - - No JS / CSS included. + - Fragment will fetch its own JS / CSS dependencies when inserted into the page. + - Requires the HTML to be rendered in a JS-enabled browser. - [`simple`](../../advanced/rendering_js_css#simple) - Smartly insert JS / CSS into placeholders ([`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies)) or into `` and `` tags. - No extra script loaded. diff --git a/src/django_components/component.py b/src/django_components/component.py index 11678f46..1e1e43f0 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -2697,11 +2697,12 @@ class Component(metaclass=ComponentMeta): - [`"document"`](../../concepts/advanced/rendering_js_css#document) (default) - Smartly inserts JS / CSS into placeholders or into `` and `` tags. - - Inserts extra script to allow `fragment` types to work. - - Assumes the HTML will be rendered in a JS-enabled browser. + - Requires the HTML to be rendered in a JS-enabled browser. + - Inserts extra script for managing fragments. - [`"fragment"`](../../concepts/advanced/rendering_js_css#fragment) - A lightweight HTML fragment to be inserted into a document with AJAX. - - No JS / CSS included. + - Fragment will fetch its own JS / CSS dependencies when inserted into the page. + - Requires the HTML to be rendered in a JS-enabled browser. - [`"simple"`](../../concepts/advanced/rendering_js_css#simple) - Smartly insert JS / CSS into placeholders or into `` and `` tags. - No extra script loaded. @@ -3186,11 +3187,12 @@ class Component(metaclass=ComponentMeta): - [`"document"`](../../concepts/advanced/rendering_js_css#document) (default) - Smartly inserts JS / CSS into placeholders or into `` and `` tags. - - Inserts extra script to allow `fragment` types to work. - - Assumes the HTML will be rendered in a JS-enabled browser. + - Requires the HTML to be rendered in a JS-enabled browser. + - Inserts extra script for managing fragments. - [`"fragment"`](../../concepts/advanced/rendering_js_css#fragment) - A lightweight HTML fragment to be inserted into a document with AJAX. - - No JS / CSS included. + - Fragment will fetch its own JS / CSS dependencies when inserted into the page. + - Requires the HTML to be rendered in a JS-enabled browser. - [`"simple"`](../../concepts/advanced/rendering_js_css#simple) - Smartly insert JS / CSS into placeholders or into `` and `` tags. - No extra script loaded. diff --git a/src/django_components/dependencies.py b/src/django_components/dependencies.py index 4f4a8830..e575e028 100644 --- a/src/django_components/dependencies.py +++ b/src/django_components/dependencies.py @@ -476,6 +476,28 @@ def render_dependencies(content: TContent, strategy: DependenciesStrategy = "doc _render_dependencies = render_dependencies +def _pre_loader_js() -> str: + """ + This script checks if our dependency manager script is already loaded on the page, + and loads the manager if not yet. + + This script is included with every "fragment", so that the "fragments" can be rendered + even on pages that weren't rendered with the "document" strategy. + """ + manager_url = static("django_components/django_components.min.js") + return f""" + (() => {{ + if (!globalThis.Components) {{ + const s = document.createElement('script'); + s.src = "{manager_url}"; + document.head.appendChild(s); + }} + // Remove this loader script + if (document.currentScript) document.currentScript.remove(); + }})(); + """ + + # Overview of this function: # 1. We extract all HTML comments like ``. # 2. We look up the corresponding component classes @@ -647,6 +669,20 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) -> js=[static("django_components/django_components.min.js")] if strategy == "document" else [], ).render_js() + # Core scripts without which the rest wouldn't work + core_script_tags = [] + if strategy == "document": + # For full documents, load manager as a normal external ")] + final_script_tags = "".join( [ # JS by us diff --git a/src/django_components/static/django_components/django_components.min.js b/src/django_components/static/django_components/django_components.min.js index 89b7f3cf..d3f612b5 100644 --- a/src/django_components/static/django_components/django_components.min.js +++ b/src/django_components/static/django_components/django_components.min.js @@ -1 +1 @@ -(()=>{var x=o=>new DOMParser().parseFromString(o,"text/html").documentElement.textContent,E=Array.isArray,m=o=>typeof o=="function",H=o=>o!==null&&typeof o=="object",S=o=>(H(o)||m(o))&&m(o.then)&&m(o.catch);function N(o,i){try{return i?o.apply(null,i):o()}catch(s){L(s)}}function g(o,i){if(m(o)){let s=N(o,i);return s&&S(s)&&s.catch(c=>{L(c)}),[s]}if(E(o)){let s=[];for(let c=0;c{let i=new MutationObserver(s=>{for(let c of s)c.type==="childList"&&c.addedNodes.forEach(d=>{d.nodeName==="SCRIPT"&&d.hasAttribute("data-djc")&&o(d)})});return i.observe(document,{childList:!0,subtree:!0}),i};var y=()=>{let o=new Set,i=new Set,s={},c={},d=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("script");if(!e)throw Error("[Components] Failed to extract @@ -239,6 +247,38 @@ def fragment_base_htmx_view(request): return HttpResponse(rendered) +# HTML into which a fragment will be loaded using HTMX +# This variant doesn't include the component manager script, so that we can test +# that the fragment can be rendered even when the page wasn't rendered with the "document" strategy. +def fragment_base_htmx_view__raw(request): + template_str: types.django_html = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + + + {% component 'inner' variable='foo' / %} + +
OLD
+ + + + {% component_js_dependencies %} + + + """ + template = Template(template_str) + + frag = request.GET["frag"] + rendered = template.render(Context({"frag": frag, "DJC_DEPS_STRATEGY": "ignore"})) + return HttpResponse(rendered) + + def fragment_view(request): fragment_type = request.GET["frag"] if fragment_type == "comp": diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index d0aba6fb..19619575 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -366,7 +366,24 @@ class TestRenderDependencies: rendered_raw = Template(template_str).render(Context({"formset": [1], "DJC_DEPS_STRATEGY": "ignore"})) rendered = render_dependencies(rendered_raw, strategy="fragment") - assertHTMLEqual(rendered, "") + # NOTE: Fragments adds a script to optionally load the component manager script. + assertHTMLEqual( + rendered, + """ + + + """, + ) def test_does_not_modify_html_when_no_component_used(self): registry.register(name="test", component=SimpleComponent) @@ -401,6 +418,7 @@ class TestRenderDependencies: rendered_raw = Template(template_str).render(Context({"formset": [1], "DJC_DEPS_STRATEGY": "ignore"})) rendered = render_dependencies(rendered_raw, strategy="fragment") + # NOTE: Fragments adds a script to optionally load the component manager script. expected = """ @@ -422,6 +440,17 @@ class TestRenderDependencies:
+ """ assertHTMLEqual(expected, rendered) @@ -469,6 +498,8 @@ class TestRenderDependencies: # `PGxpbmsgaHJlZj0iL2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50XzMxMTA5Ny5jc3MiIG1lZGlhPSJhbGwiIHJlbD0ic3R5bGVzaGVldCI+` -> `` # noqa: E501 # `PHNjcmlwdCBzcmM9InNjcmlwdC5qcyI+PC9zY3JpcHQ+` -> `` # `PHNjcmlwdCBzcmM9Ii9jb21wb25lbnRzL2NhY2hlL1NpbXBsZUNvbXBvbmVudF8zMTEwOTcuanMiPjwvc2NyaXB0Pg==` -> `` # noqa: E501 + # + # NOTE: Fragments adds a script to optionally load the component manager script. expected = """ @@ -491,6 +522,17 @@ class TestRenderDependencies:
+