feat: render fragments without document strategy (#1339)

This commit is contained in:
Juro Oravec 2025-08-14 11:33:31 +02:00 committed by GitHub
parent aa14e3698d
commit c72fed8255
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 217 additions and 26 deletions

View file

@ -1,5 +1,20 @@
# Release notes # 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 ## v0.141.2
#### Fix #### Fix

View file

@ -142,11 +142,12 @@ There are six dependencies strategies:
- [`document`](../../advanced/rendering_js_css#document) (default) - [`document`](../../advanced/rendering_js_css#document) (default)
- Smartly inserts JS / CSS into placeholders or into `<head>` and `<body>` tags. - Smartly inserts JS / CSS into placeholders or into `<head>` and `<body>` tags.
- Inserts extra script to allow `fragment` strategy to work. - Requires the HTML to be rendered in a JS-enabled browser.
- Assumes the HTML will be rendered in a JS-enabled browser. - Inserts extra script for managing fragments.
- [`fragment`](../../advanced/rendering_js_css#fragment) - [`fragment`](../../advanced/rendering_js_css#fragment)
- A lightweight HTML fragment to be inserted into a document with AJAX. - 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) - [`simple`](../../advanced/rendering_js_css#simple)
- Smartly insert JS / CSS into placeholders or into `<head>` and `<body>` tags. - Smartly insert JS / CSS into placeholders or into `<head>` and `<body>` tags.
- No extra script loaded. - 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 - A JS script is injected to manage component dependencies, enabling lazy loading of JS and CSS
for HTML fragments. 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" !!! 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. 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` ### `fragment`
`deps_strategy="fragment"` is used when rendering a piece of HTML that will be inserted into a page `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:
```python ```python
fragment = MyComponent.render(deps_strategy="fragment") 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/)), 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. 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). Read more about [HTML fragments](../../advanced/html_fragments).

View file

@ -349,12 +349,12 @@ There are six dependencies rendering strategies:
- [`document`](../../advanced/rendering_js_css#document) (default) - [`document`](../../advanced/rendering_js_css#document) (default)
- Smartly inserts JS / CSS into placeholders ([`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies)) or into `<head>` and `<body>` tags. - Smartly inserts JS / CSS into placeholders ([`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies)) or into `<head>` and `<body>` tags.
- Inserts extra script to allow `fragment` components to work. - Requires the HTML to be rendered in a JS-enabled browser.
- Assumes the HTML will be rendered in a JS-enabled browser. - Inserts extra script for managing fragments.
- [`fragment`](../../advanced/rendering_js_css#fragment) - [`fragment`](../../advanced/rendering_js_css#fragment)
- A lightweight HTML fragment to be inserted into a document with AJAX. - A lightweight HTML fragment to be inserted into a document with AJAX.
- Assumes the page was already rendered with `"document"` strategy. - Fragment will fetch its own JS / CSS dependencies when inserted into the page.
- No JS / CSS included. - Requires the HTML to be rendered in a JS-enabled browser.
- [`simple`](../../advanced/rendering_js_css#simple) - [`simple`](../../advanced/rendering_js_css#simple)
- Smartly insert JS / CSS into placeholders ([`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies)) or into `<head>` and `<body>` tags. - Smartly insert JS / CSS into placeholders ([`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies)) or into `<head>` and `<body>` tags.
- No extra script loaded. - No extra script loaded.

View file

@ -2697,11 +2697,12 @@ class Component(metaclass=ComponentMeta):
- [`"document"`](../../concepts/advanced/rendering_js_css#document) (default) - [`"document"`](../../concepts/advanced/rendering_js_css#document) (default)
- Smartly inserts JS / CSS into placeholders or into `<head>` and `<body>` tags. - Smartly inserts JS / CSS into placeholders or into `<head>` and `<body>` tags.
- Inserts extra script to allow `fragment` types to work. - Requires the HTML to be rendered in a JS-enabled browser.
- Assumes the HTML will be rendered in a JS-enabled browser. - Inserts extra script for managing fragments.
- [`"fragment"`](../../concepts/advanced/rendering_js_css#fragment) - [`"fragment"`](../../concepts/advanced/rendering_js_css#fragment)
- A lightweight HTML fragment to be inserted into a document with AJAX. - 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) - [`"simple"`](../../concepts/advanced/rendering_js_css#simple)
- Smartly insert JS / CSS into placeholders or into `<head>` and `<body>` tags. - Smartly insert JS / CSS into placeholders or into `<head>` and `<body>` tags.
- No extra script loaded. - No extra script loaded.
@ -3186,11 +3187,12 @@ class Component(metaclass=ComponentMeta):
- [`"document"`](../../concepts/advanced/rendering_js_css#document) (default) - [`"document"`](../../concepts/advanced/rendering_js_css#document) (default)
- Smartly inserts JS / CSS into placeholders or into `<head>` and `<body>` tags. - Smartly inserts JS / CSS into placeholders or into `<head>` and `<body>` tags.
- Inserts extra script to allow `fragment` types to work. - Requires the HTML to be rendered in a JS-enabled browser.
- Assumes the HTML will be rendered in a JS-enabled browser. - Inserts extra script for managing fragments.
- [`"fragment"`](../../concepts/advanced/rendering_js_css#fragment) - [`"fragment"`](../../concepts/advanced/rendering_js_css#fragment)
- A lightweight HTML fragment to be inserted into a document with AJAX. - 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) - [`"simple"`](../../concepts/advanced/rendering_js_css#simple)
- Smartly insert JS / CSS into placeholders or into `<head>` and `<body>` tags. - Smartly insert JS / CSS into placeholders or into `<head>` and `<body>` tags.
- No extra script loaded. - No extra script loaded.

View file

@ -476,6 +476,28 @@ def render_dependencies(content: TContent, strategy: DependenciesStrategy = "doc
_render_dependencies = render_dependencies _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: # Overview of this function:
# 1. We extract all HTML comments like `<!-- _RENDERED table_10bac31,1234-->`. # 1. We extract all HTML comments like `<!-- _RENDERED table_10bac31,1234-->`.
# 2. We look up the corresponding component classes # 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 [], js=[static("django_components/django_components.min.js")] if strategy == "document" else [],
).render_js() ).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 <script src="...">
core_script_tags = Media(js=[static("django_components/django_components.min.js")]).render_js()
elif strategy == "fragment":
# For fragments, inline a script that conditionally injects the dependency manager
# if it's not already loaded.
#
# TODO: Eventually we want to parametrize how the `<script>` tag is rendered
# (e.g. to use `type="module"`, `defer`, or csp nonce) based on which component
# it was defined in.
core_script_tags = [mark_safe(f"<script>{_pre_loader_js()}</script>")]
final_script_tags = "".join( final_script_tags = "".join(
[ [
# JS by us # JS by us

View file

@ -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<o.length;c++)s.push(g(o[c],i));return s}else console.warn(`[Components] Invalid value type passed to callWithAsyncErrorHandling(): ${typeof o}`)}function L(o){console.error(o)}var M=o=>{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 <script> tag. Make sure that the string contains <script><\/script> and is a valid HTML");return e},F=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("link");if(!e)throw Error("[Components] Failed to extract <link> tag. Make sure that the string contains <link></link> and is a valid HTML");return e},T=t=>{let e=document.createElement(t.tagName);e.innerHTML=t.innerHTML;for(let r of t.attributes)e.setAttributeNode(r.cloneNode());return e},f=t=>{let e=d(t),r=e.getAttribute("src");if(!r||C("js",r))return;p("js",r);let a=T(e),l=e.getAttribute("async")!=null||e.getAttribute("defer")!=null||e.getAttribute("type")==="module";a.async=l;let u=new Promise((n,b)=>{a.onload=()=>{n()},globalThis.document.body.append(a)});return{el:a,promise:u}},h=t=>{let e=F(t),r=e.getAttribute("href");if(!r||C("css",r))return;let a=T(e);return globalThis.document.head.append(a),p("css",r),{el:a,promise:Promise.resolve()}},p=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] markScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);(t==="js"?o:i).add(e)},C=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] isScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);return(t==="js"?o:i).has(e)},w=(t,e)=>{s[t]=e},j=(t,e,r)=>{let a=`${t}:${e}`;c[a]=r},A=(t,e,r)=>{let a=s[t];if(!a)throw Error(`[Components] '${t}': No component registered for that name`);let l=Array.from(document.querySelectorAll(`[data-djc-id-${e}]`));if(!l.length)throw Error(`[Components] '${t}': No elements with component ID '${e}' found`);let u=`${t}:${r}`,n=c[u];if(!n)throw Error(`[Components] '${t}': Cannot find input for hash '${r}'`);let b=n(),v={name:t,id:e,els:l},[P]=g(a,[b,v]);return P},k=async t=>{let e=t.loadedCssUrls.map(n=>atob(n)),r=t.loadedJsUrls.map(n=>atob(n)),a=t.toLoadCssTags.map(n=>atob(n)),l=t.toLoadJsTags.map(n=>atob(n));e.forEach(n=>p("css",n)),r.forEach(n=>p("js",n)),Promise.all(a.map(n=>h(n))).catch(console.error);let u=Promise.all(l.map(n=>f(n))).catch(console.error)};return M(t=>{let e=JSON.parse(t.text);k(e)}),{callComponent:A,registerComponent:w,registerComponentData:j,loadJs:f,loadCss:h,markScriptLoaded:p}};var $={manager:y(),createComponentsManager:y,unescapeJs:x};globalThis.Components=$;})(); (()=>{var x=o=>new DOMParser().parseFromString(o,"text/html").documentElement.textContent,E=Array.isArray,m=o=>typeof o=="function",N=o=>o!==null&&typeof o=="object",L=o=>(N(o)||m(o))&&m(o.then)&&m(o.catch);function $(o,i){try{return i?o.apply(null,i):o()}catch(s){M(s)}}function g(o,i){if(m(o)){let s=$(o,i);return s&&L(s)&&s.catch(c=>{M(c)}),[s]}if(E(o)){let s=[];for(let c=0;c<o.length;c++)s.push(g(o[c],i));return s}else console.warn(`[Components] Invalid value type passed to callWithAsyncErrorHandling(): ${typeof o}`)}function M(o){console.error(o)}var j=o=>{let i=new MutationObserver(s=>{for(let c of s)c.type==="childList"&&c.addedNodes.forEach(p=>{p.nodeName==="SCRIPT"&&p.hasAttribute("data-djc")&&o(p)})});return i.observe(document,{childList:!0,subtree:!0}),i};var y=()=>{let o=new Set,i=new Set,s={},c={},p=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("script");if(!e)throw Error("[Components] Failed to extract <script> tag. Make sure that the string contains <script><\/script> and is a valid HTML");return e},F=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("link");if(!e)throw Error("[Components] Failed to extract <link> tag. Make sure that the string contains <link></link> and is a valid HTML");return e},T=t=>{let e=document.createElement(t.tagName);e.innerHTML=t.innerHTML;for(let r of t.attributes)e.setAttributeNode(r.cloneNode());return e},f=t=>{let e=p(t),r=e.getAttribute("src");if(!r||C("js",r))return;d("js",r);let a=T(e),l=e.getAttribute("async")!=null||e.getAttribute("defer")!=null||e.getAttribute("type")==="module";a.async=l;let u=new Promise((n,b)=>{a.onload=()=>{n()},globalThis.document.body.append(a)});return{el:a,promise:u}},h=t=>{let e=F(t),r=e.getAttribute("href");if(!r||C("css",r))return;let a=T(e);return globalThis.document.head.append(a),d("css",r),{el:a,promise:Promise.resolve()}},d=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] markScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);(t==="js"?o:i).add(e)},C=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] isScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);return(t==="js"?o:i).has(e)},w=(t,e)=>{s[t]=e},A=(t,e,r)=>{let a=`${t}:${e}`;c[a]=r},H=(t,e,r)=>{let a=s[t];if(!a)throw Error(`[Components] '${t}': No component registered for that name`);let l=Array.from(document.querySelectorAll(`[data-djc-id-${e}]`));if(!l.length)throw Error(`[Components] '${t}': No elements with component ID '${e}' found`);let u=`${t}:${r}`,n=c[u];if(!n)throw Error(`[Components] '${t}': Cannot find input for hash '${r}'`);let b=n(),v={name:t,id:e,els:l},[P]=g(a,[b,v]);return P},k=async t=>{let e=t.loadedCssUrls.map(n=>atob(n)),r=t.loadedJsUrls.map(n=>atob(n)),a=t.toLoadCssTags.map(n=>atob(n)),l=t.toLoadJsTags.map(n=>atob(n));e.forEach(n=>d("css",n)),r.forEach(n=>d("js",n)),Promise.all(a.map(n=>h(n))).catch(console.error);let u=Promise.all(l.map(n=>f(n))).catch(console.error)},S=t=>{let e=JSON.parse(t.text);k(e)};return j(S),document.querySelectorAll("script[data-djc]").forEach(S),{callComponent:H,registerComponent:w,registerComponentData:A,loadJs:f,loadCss:h,markScriptLoaded:d}};var J={manager:y(),createComponentsManager:y,unescapeJs:x};globalThis.Components=J;})();

View file

@ -258,11 +258,17 @@ export const createComponentsManager = () => {
.catch(console.error); .catch(console.error);
}; };
// Initialise the MutationObserver that watches for `<script>` tags with `data-djc` attribute const onDjcScript = (script: HTMLScriptElement) => {
observeScriptTag((script) => {
const data = JSON.parse(script.text); const data = JSON.parse(script.text);
_loadComponentScripts(data); _loadComponentScripts(data);
}); };
// Initialise the MutationObserver that watches for `<script>` tags with `data-djc` attribute
observeScriptTag(onDjcScript);
// Also consider any embedded scripts at the moment the file is loaded.
const existingScripts = document.querySelectorAll<HTMLScriptElement>('script[data-djc]');
existingScripts.forEach(onDjcScript);
return { return {
callComponent, callComponent,

View file

@ -10,6 +10,7 @@ from testserver.views import (
check_js_order_vars_not_available_before_view, check_js_order_vars_not_available_before_view,
fragment_base_alpine_view, fragment_base_alpine_view,
fragment_base_htmx_view, fragment_base_htmx_view,
fragment_base_htmx_view__raw,
fragment_base_js_view, fragment_base_js_view,
fragment_view, fragment_view,
multiple_components_view, multiple_components_view,
@ -28,6 +29,7 @@ urlpatterns = [
path("js-order/invalid", check_js_order_vars_not_available_before_view), path("js-order/invalid", check_js_order_vars_not_available_before_view),
path("fragment/base/alpine", fragment_base_alpine_view), path("fragment/base/alpine", fragment_base_alpine_view),
path("fragment/base/htmx", fragment_base_htmx_view), path("fragment/base/htmx", fragment_base_htmx_view),
path("fragment/base/htmx_raw", fragment_base_htmx_view__raw),
path("fragment/base/js", fragment_base_js_view), path("fragment/base/js", fragment_base_js_view),
path("fragment/frag", fragment_view), path("fragment/frag", fragment_view),
path("alpine/head", alpine_in_head_view), path("alpine/head", alpine_in_head_view),

View file

@ -146,7 +146,15 @@ def fragment_base_js_view(request):
.then(response => response.text()) .then(response => response.text())
.then(html => { .then(html => {
console.log({ fragment: html }) console.log({ fragment: html })
document.querySelector('#target').outerHTML = html; const target = document.querySelector('#target');
const a = new DOMParser().parseFromString(html, "text/html");
target.replaceWith(...a.body.childNodes);
for (const script of a.querySelectorAll('script')) {
const newScript = document.createElement('script');
newScript.textContent = script.textContent;
newScript.async = false;
document.body.appendChild(newScript);
}
}); });
}); });
</script> </script>
@ -239,6 +247,38 @@ def fragment_base_htmx_view(request):
return HttpResponse(rendered) 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 %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
{% component 'inner' variable='foo' / %}
<div id="target">OLD</div>
<button id="loader" hx-get="/fragment/frag?frag={{ frag }}" hx-swap="outerHTML" hx-target="#target">
Click me!
</button>
{% component_js_dependencies %}
</body>
</html>
"""
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): def fragment_view(request):
fragment_type = request.GET["frag"] fragment_type = request.GET["frag"]
if fragment_type == "comp": if fragment_type == "comp":

View file

@ -366,7 +366,24 @@ class TestRenderDependencies:
rendered_raw = Template(template_str).render(Context({"formset": [1], "DJC_DEPS_STRATEGY": "ignore"})) rendered_raw = Template(template_str).render(Context({"formset": [1], "DJC_DEPS_STRATEGY": "ignore"}))
rendered = render_dependencies(rendered_raw, strategy="fragment") rendered = render_dependencies(rendered_raw, strategy="fragment")
assertHTMLEqual(rendered, "<thead>") # NOTE: Fragments adds a script to optionally load the component manager script.
assertHTMLEqual(
rendered,
"""
<thead>
<script>
(() => {
if (!globalThis.Components) {
const s = document.createElement('script');
s.src = "django_components/django_components.min.js";
document.head.appendChild(s);
}
// Remove this loader script
if (document.currentScript) document.currentScript.remove();
})();
</script>
""",
)
def test_does_not_modify_html_when_no_component_used(self): def test_does_not_modify_html_when_no_component_used(self):
registry.register(name="test", component=SimpleComponent) 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_raw = Template(template_str).render(Context({"formset": [1], "DJC_DEPS_STRATEGY": "ignore"}))
rendered = render_dependencies(rendered_raw, strategy="fragment") rendered = render_dependencies(rendered_raw, strategy="fragment")
# NOTE: Fragments adds a script to optionally load the component manager script.
expected = """ expected = """
<table class="table-auto border-collapse divide-y divide-x divide-slate-300 w-full"> <table class="table-auto border-collapse divide-y divide-x divide-slate-300 w-full">
<!-- Table head --> <!-- Table head -->
@ -422,6 +440,17 @@ class TestRenderDependencies:
</tr> </tr>
</tbody> </tbody>
</table> </table>
<script>
(() => {
if (!globalThis.Components) {
const s = document.createElement('script');
s.src = "django_components/django_components.min.js";
document.head.appendChild(s);
}
// Remove this loader script
if (document.currentScript) document.currentScript.remove();
})();
</script>
""" """
assertHTMLEqual(expected, rendered) assertHTMLEqual(expected, rendered)
@ -469,6 +498,8 @@ class TestRenderDependencies:
# `PGxpbmsgaHJlZj0iL2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50XzMxMTA5Ny5jc3MiIG1lZGlhPSJhbGwiIHJlbD0ic3R5bGVzaGVldCI+` -> `<link href="/components/cache/SimpleComponent_311097.css" media="all" rel="stylesheet">` # noqa: E501 # `PGxpbmsgaHJlZj0iL2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50XzMxMTA5Ny5jc3MiIG1lZGlhPSJhbGwiIHJlbD0ic3R5bGVzaGVldCI+` -> `<link href="/components/cache/SimpleComponent_311097.css" media="all" rel="stylesheet">` # noqa: E501
# `PHNjcmlwdCBzcmM9InNjcmlwdC5qcyI+PC9zY3JpcHQ+` -> `<script src="script.js"></script>` # `PHNjcmlwdCBzcmM9InNjcmlwdC5qcyI+PC9zY3JpcHQ+` -> `<script src="script.js"></script>`
# `PHNjcmlwdCBzcmM9Ii9jb21wb25lbnRzL2NhY2hlL1NpbXBsZUNvbXBvbmVudF8zMTEwOTcuanMiPjwvc2NyaXB0Pg==` -> `<script src="/components/cache/SimpleComponent_311097.js"></script>` # noqa: E501 # `PHNjcmlwdCBzcmM9Ii9jb21wb25lbnRzL2NhY2hlL1NpbXBsZUNvbXBvbmVudF8zMTEwOTcuanMiPjwvc2NyaXB0Pg==` -> `<script src="/components/cache/SimpleComponent_311097.js"></script>` # noqa: E501
#
# NOTE: Fragments adds a script to optionally load the component manager script.
expected = """ expected = """
<table class="table-auto border-collapse divide-y divide-x divide-slate-300 w-full"> <table class="table-auto border-collapse divide-y divide-x divide-slate-300 w-full">
<!-- Table head --> <!-- Table head -->
@ -491,6 +522,17 @@ class TestRenderDependencies:
</tr> </tr>
</tbody> </tbody>
</table> </table>
<script>
(() => {
if (!globalThis.Components) {
const s = document.createElement('script');
s.src = "django_components/django_components.min.js";
document.head.appendChild(s);
}
// Remove this loader script
if (document.currentScript) document.currentScript.remove();
})();
</script>
<script type="application/json" data-djc> <script type="application/json" data-djc>
{"loadedCssUrls": [], {"loadedCssUrls": [],
"loadedJsUrls": [], "loadedJsUrls": [],

View file

@ -539,6 +539,58 @@ class TestE2eDependencyRendering:
await page.close() await page.close()
# Fragment where the page wasn't rendered with the "document" strategy
@with_playwright
async def test_fragment_without_document(self):
page: Page = await self.browser.new_page() # type: ignore[attr-defined]
await page.goto(f"{TEST_SERVER_URL}/fragment/base/htmx_raw?frag=comp")
test_before_js: types.js = """() => {
const targetEl = document.querySelector("#target");
const targetHtml = targetEl ? targetEl.outerHTML : null;
const fragEl = document.querySelector(".frag");
const fragHtml = fragEl ? fragEl.outerHTML : null;
return { targetHtml, fragHtml };
}"""
data_before = await page.evaluate(test_before_js)
assert data_before["targetHtml"] == '<div id="target">OLD</div>'
assert data_before["fragHtml"] is None
# Clicking button should load and insert the fragment
await page.locator("button").click()
# Wait until both JS and CSS are loaded
await page.locator(".frag").wait_for(state="visible")
await page.wait_for_function(
"() => document.head.innerHTML.includes('<link href=\"/components/cache/FragComp_')"
)
await page.wait_for_timeout(100) # NOTE: For CI we need to wait a bit longer
test_js: types.js = """() => {
const targetEl = document.querySelector("#target");
const targetHtml = targetEl ? targetEl.outerHTML : null;
const fragEl = document.querySelector(".frag");
const fragHtml = fragEl ? fragEl.outerHTML : null;
// Get the stylings defined via CSS
const fragBg = fragEl ? globalThis.getComputedStyle(fragEl).getPropertyValue('background') : null;
return { targetHtml, fragHtml, fragBg };
}"""
data = await page.evaluate(test_js)
assert data["targetHtml"] is None
assert re.compile(
r'<div class="frag" data-djc-id-\w{7}="">\s*' r"123\s*" r'<span id="frag-text">xxx</span>\s*' r"</div>"
).search(data["fragHtml"]) is not None
assert "rgb(0, 0, 255)" in data["fragBg"] # AKA 'background: blue'
await page.close()
@with_playwright @with_playwright
async def test_alpine__head(self): async def test_alpine__head(self):
single_comp_url = TEST_SERVER_URL + "/alpine/head" single_comp_url = TEST_SERVER_URL + "/alpine/head"