feat: Add support for HTML fragments (#845)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-12-19 10:03:35 +01:00 committed by GitHub
parent 6681fc0085
commit 4dab940db8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1225 additions and 246 deletions

View file

@ -1,6 +1,6 @@
---
title: Authoring component libraries
weight: 7
weight: 8
---
You can publish and share your components for others to use. Here are the steps to do so:

View file

@ -1,6 +1,6 @@
---
title: Registering components
weight: 4
weight: 5
---
In previous examples you could repeatedly see us using `@register()` to "register"

View file

@ -1,6 +1,6 @@
---
title: Lifecycle hooks
weight: 3
weight: 4
---
_New in version 0.96_

View file

@ -0,0 +1,361 @@
---
title: HTML fragments
weight: 2
---
Django-components provides a seamless integration with HTML fragments ([HTML over the wire](https://hotwired.dev/)),
whether you're using HTMX, AlpineJS, or vanilla JavaScript.
When you define a component that has extra JS or CSS, and you use django-components
to render the fragment, 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
!!! info
**What are HTML fragments and "HTML over the wire"?**
It is one of the methods for updating the state in the browser UI upon user interaction.
How it works is that:
1. User makes an action - clicks a button or submits a form
2. The action causes a request to be made from the client to the server.
3. Server processes the request (e.g. form submission), and responds with HTML
of some part of the UI (e.g. a new entry in a table).
4. A library like HTMX, AlpineJS, or custom function inserts the new HTML into
the correct place.
## Document and fragment types
Components support two modes of rendering - As a "document" or as a "fragment".
What's the difference?
### Document mode
Document mode assumes that the rendered components will be embedded into the HTML
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))
- Django-components injects a JS script for managing JS and CSS assets
A component is rendered as a "document" when:
- It is embedded inside a template as [`{% component %}`](../../reference/template_tags.md#component)
- It is rendered with [`Component.render()`](../../../reference/api#django_components.Component.render)
or [`Component.render_to_response()`](../../../reference/api#django_components.Component.render_to_response)
with the `type` kwarg set to `"document"` (default)
Example:
```py
MyTable.render(
kwargs={...},
)
# or
MyTable.render(
kwargs={...},
type="document",
)
```
### Fragment mode
Fragment mode assumes that the main HTML has already been rendered and loaded on the page.
The component renders HTML that will be inserted into the page as a fragment, at a LATER time:
- JS and CSS is not directly embedded to avoid duplicately executing the same JS scripts.
So template tags like [`{% component_js_dependencies %}`](../../reference/template_tags.md#component_js_dependencies)
inside of fragments are ignored.
- Instead, django-components appends the fragment's content with a JSON `<script>` to trigger a call
to its asset manager JS script, which will load the JS and CSS smartly.
- The asset manager JS script is assumed to be already loaded on the page.
A component is rendered as "fragment" when:
- It is rendered with [`Component.render()`](../../../reference/api#django_components.Component.render)
or [`Component.render_to_response()`](../../../reference/api#django_components.Component.render_to_response)
with the `type` kwarg set to `"fragment"`
Example:
```py
MyTable.render(
kwargs={...},
type="fragment",
)
```
## Live examples
For live interactive examples, [start our demo project](../../overview/development.md#developing-against-live-django-app)
(`sampleproject`).
Then navigate to these URLs:
- `/fragment/base/alpine`
- `/fragment/base/htmx`
- `/fragment/base/js`
## Example - HTMX
### 1. Define document HTML
```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):
def get(self, request):
return self.render_to_response()
template = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<div id="target">OLD</div>
<button
hx-get="/mypage/frag"
hx-swap="outerHTML"
hx-target="#target"
>
Click me!
</button>
{% component_js_dependencies %}
</body>
</html>
"""
```
### 2. Define fragment HTML
```py title="[root]/components/demo.py"
class Frag(Component):
def get(self, request):
return self.render_to_response(
# IMPORTANT: Don't forget `type="fragment"`
type="fragment",
)
template = """
<div class="frag">
123
<span id="frag-text"></span>
</div>
"""
js = """
document.querySelector('#frag-text').textContent = 'xxx';
"""
css = """
.frag {
background: blue;
}
"""
```
### 3. Create view and URLs
```py title="[app]/urls.py"
from django.urls import path
from components.demo import MyPage, Frag
urlpatterns = [
path("mypage/", MyPage.as_view())
path("mypage/frag", Frag.as_view()),
]
```
## Example - AlpineJS
### 1. Define document HTML
```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):
def get(self, request):
return self.render_to_response()
template = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
<script defer src="https://unpkg.com/alpinejs"></script>
</head>
<body x-data="{
htmlVar: 'OLD',
loadFragment: function () {
const url = '/mypage/frag';
fetch(url)
.then(response => response.text())
.then(html => {
this.htmlVar = html;
});
}
}">
<div id="target" x-html="htmlVar">OLD</div>
<button @click="loadFragment">
Click me!
</button>
{% component_js_dependencies %}
</body>
</html>
"""
```
### 2. Define fragment HTML
```py title="[root]/components/demo.py"
class Frag(Component):
def get(self, request):
# IMPORTANT: Don't forget `type="fragment"`
return self.render_to_response(
type="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 = """
<template x-if="false" data-name="frag">
<div class="frag">
123
<span x-data="frag" x-text="fragVal">
</span>
</div>
</template>
"""
js = """
Alpine.data('frag', () => ({
fragVal: 'xxx',
}));
// Now that the component has been defined in AlpineJS, we can "activate"
// all instances where we use the `x-data="frag"` directive.
document.querySelectorAll('[data-name="frag"]').forEach((el) => {
el.setAttribute('x-if', 'true');
});
"""
css = """
.frag {
background: blue;
}
"""
```
### 3. Create view and URLs
```py title="[app]/urls.py"
from django.urls import path
from components.demo import MyPage, Frag
urlpatterns = [
path("mypage/", MyPage.as_view())
path("mypage/frag", Frag.as_view()),
]
```
## Example - Vanilla JS
### 1. Define document HTML
```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):
def get(self, request):
return self.render_to_response()
template = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
</head>
<body>
<div id="target">OLD</div>
<button>
Click me!
</button>
<script>
const url = `/mypage/frag`;
document.querySelector('#loader').addEventListener('click', function () {
fetch(url)
.then(response => response.text())
.then(html => {
document.querySelector('#target').outerHTML = html;
});
});
</script>
{% component_js_dependencies %}
</body>
</html>
"""
```
### 2. Define fragment HTML
```py title="[root]/components/demo.py"
class Frag(Component):
def get(self, request):
return self.render_to_response(
# IMPORTANT: Don't forget `type="fragment"`
type="fragment",
)
template = """
<div class="frag">
123
<span id="frag-text"></span>
</div>
"""
js = """
document.querySelector('#frag-text').textContent = 'xxx';
"""
css = """
.frag {
background: blue;
}
"""
```
### 3. Create view and URLs
```py title="[app]/urls.py"
from django.urls import path
from components.demo import MyPage, Frag
urlpatterns = [
path("mypage/", MyPage.as_view())
path("mypage/frag", Frag.as_view()),
]
```

View file

@ -1,6 +1,6 @@
---
title: Prop drilling and provide / inject
weight: 2
weight: 3
---
_New in version 0.80_:

View file

@ -1,6 +1,6 @@
---
title: Tag formatters
weight: 6
weight: 7
---
## Customizing component tags with TagFormatter

View file

@ -1,6 +1,6 @@
---
title: Typing and validation
weight: 5
weight: 6
---
## Adding type hints with Generics

View file

@ -0,0 +1,159 @@
from django_components import Component, types
# HTML into which a fragment will be loaded using vanilla JS
class FragmentBaseJs(Component):
def get(self, request):
return self.render_to_response()
template: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
</head>
<body>
<div id="target">OLD</div>
<button id="loader">
Click me!
</button>
<script>
const url = `/fragment/frag/js`;
document.querySelector('#loader').addEventListener('click', function () {
fetch(url)
.then(response => response.text())
.then(html => {
console.log({ fragment: html })
document.querySelector('#target').outerHTML = html;
});
});
</script>
{% component_js_dependencies %}
</body>
</html>
"""
# HTML into which a fragment will be loaded using AlpineJs
class FragmentBaseAlpine(Component):
def get(self, request):
return self.render_to_response()
template: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
<script defer src="https://unpkg.com/alpinejs"></script>
</head>
<body x-data="{
htmlVar: 'OLD',
loadFragment: function () {
const url = '/fragment/frag/alpine';
fetch(url)
.then(response => response.text())
.then(html => {
console.log({ fragment: html });
this.htmlVar = html;
});
}
}">
<div id="target" x-html="htmlVar">OLD</div>
<button id="loader" @click="loadFragment">
Click me!
</button>
{% component_js_dependencies %}
</body>
</html>
"""
# HTML into which a fragment will be loaded using HTMX
class FragmentBaseHtmx(Component):
def get(self, request):
return self.render_to_response()
template: 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>
<div id="target">OLD</div>
<button id="loader" hx-get="/fragment/frag/js" hx-swap="outerHTML" hx-target="#target">
Click me!
</button>
{% component_js_dependencies %}
</body>
</html>
"""
# Fragment where the JS and CSS are defined on the Component
class FragJs(Component):
def get(self, request):
return self.render_to_response(type="fragment")
template: types.django_html = """
<div class="frag">
123
<span id="frag-text"></span>
</div>
"""
js: types.js = """
document.querySelector('#frag-text').textContent = 'xxx';
"""
css: types.css = """
.frag {
background: blue;
}
"""
# Fragment that defines an AlpineJS component
class FragAlpine(Component):
def get(self, request):
return self.render_to_response(type="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: types.django_html = """
<template x-if="false" data-name="frag">
<div class="frag">
123
<span x-data="frag" x-text="fragVal">
</span>
</div>
</template>
"""
js: types.js = """
Alpine.data('frag', () => ({
fragVal: 'xxx',
}));
// Now that the component has been defined in AlpineJS, we can "activate" all instances
// where we use the `x-data="frag"` directive.
document.querySelectorAll('[data-name="frag"]').forEach((el) => {
el.setAttribute('x-if', 'true');
});
"""
css: types.css = """
.frag {
background: blue;
}
"""

View file

@ -1,4 +1,5 @@
from components.calendar.calendar import Calendar, CalendarRelative
from components.fragment import FragAlpine, FragJs, FragmentBaseAlpine, FragmentBaseHtmx, FragmentBaseJs
from components.greeting import Greeting
from components.nested.calendar.calendar import CalendarNested
from django.urls import path
@ -8,4 +9,9 @@ urlpatterns = [
path("calendar/", Calendar.as_view(), name="calendar"),
path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"),
path("calendar-nested/", CalendarNested.as_view(), name="calendar-nested"),
path("fragment/base/alpine", FragmentBaseAlpine.as_view()),
path("fragment/base/htmx", FragmentBaseHtmx.as_view()),
path("fragment/base/js", FragmentBaseJs.as_view()),
path("fragment/frag/alpine", FragAlpine.as_view()),
path("fragment/frag/js", FragJs.as_view()),
]

View file

@ -1,12 +1,12 @@
"""All code related to management of component dependencies (JS and CSS scripts)"""
import base64
import json
import re
import sys
from abc import ABC, abstractmethod
from functools import lru_cache
from hashlib import md5
from textwrap import dedent
from typing import (
TYPE_CHECKING,
Callable,
@ -34,9 +34,8 @@ from django.urls import path, reverse
from django.utils.decorators import sync_and_async_middleware
from django.utils.safestring import SafeString, mark_safe
import django_components.types as types
from django_components.util.html import SoupNode
from django_components.util.misc import _escape_js, get_import_path
from django_components.util.misc import get_import_path
if TYPE_CHECKING:
from django_components.component import Component
@ -325,6 +324,9 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
return HttpResponse(processed_html)
```
"""
if type not in ("document", "fragment"):
raise ValueError(f"Invalid type '{type}'")
is_safestring = isinstance(content, SafeString)
if isinstance(content, str):
@ -335,18 +337,24 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, type)
# Replace the placeholders with the actual content
# If type == `document`, we insert the JS and CSS directly into the HTML,
# where the placeholders were.
# If type == `fragment`, we let the client-side manager load the JS and CSS,
# and remove the placeholders.
did_find_js_placeholder = False
did_find_css_placeholder = False
css_replacement = css_dependencies if type == "document" else b""
js_replacement = js_dependencies if type == "document" else b""
def on_replace_match(match: "re.Match[bytes]") -> bytes:
nonlocal did_find_css_placeholder
nonlocal did_find_js_placeholder
if match[0] == CSS_PLACEHOLDER_BYTES:
replacement = css_dependencies
replacement = css_replacement
did_find_css_placeholder = True
elif match[0] == JS_PLACEHOLDER_BYTES:
replacement = js_dependencies
replacement = js_replacement
did_find_js_placeholder = True
else:
raise RuntimeError(
@ -370,6 +378,10 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
if maybe_transformed is not None:
content_ = maybe_transformed.encode()
# In case of a fragment, we only append the JS (actually JSON) to trigger the call of dependency-manager
if type == "fragment":
content_ += js_dependencies
# Return the same type as we were given
output = content_.decode() if isinstance(content, str) else content_
output = mark_safe(output) if is_safestring else output
@ -505,7 +517,8 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes,
# Core scripts without which the rest wouldn't work
core_script_tags = Media(
js=[static("django_components/django_components.min.js")],
# NOTE: When rendering a document, the initial JS is inserted directly into the HTML
js=[static("django_components/django_components.min.js")] if type == "document" else [],
).render_js()
final_script_tags = "".join(
@ -514,7 +527,7 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes,
*[tag for tag in core_script_tags],
# Make calls to the JS dependency manager
# Loads JS from `Media.js` and `Component.js` if fragment
exec_script,
*([exec_script] if exec_script else []),
# JS from `Media.js`
# NOTE: When rendering a document, the initial JS is inserted directly into the HTML
# so the scripts are executed at proper order. In the dependency manager, we only mark those
@ -620,7 +633,7 @@ def _prepare_tags_and_urls(
to_load_js_urls.append(get_script_url("js", comp_cls))
if _is_nonempty_str(comp_cls.css):
loaded_css_urls.append(get_script_url("css", comp_cls))
to_load_css_urls.append(get_script_url("css", comp_cls))
return (
to_load_js_urls,
@ -650,9 +663,20 @@ def _get_script_tag(
script = get_script_content(script_type, comp_cls)
if script_type == "js":
return f"<script>{_escape_js(script)}</script>"
if "</script" in script:
raise RuntimeError(
f"Content of `Component.js` for component '{comp_cls.__name__}' contains '</script>' end tag. "
"This is not allowed, as it would break the HTML."
)
return f"<script>{script}</script>"
elif script_type == "css":
if "</style" in script:
raise RuntimeError(
f"Content of `Component.css` for component '{comp_cls.__name__}' contains '</style>' end tag. "
"This is not allowed, as it would break the HTML."
)
return f"<style>{script}</style>"
return script
@ -678,51 +702,33 @@ def _gen_exec_script(
to_load_css_tags: List[str],
loaded_js_urls: List[str],
loaded_css_urls: List[str],
) -> str:
# Generate JS expression like so:
# ```js
# Promise.all([
# Components.manager.loadJs('<script src="/abc/def1">...</script>'),
# Components.manager.loadJs('<script src="/abc/def2">...</script>'),
# Components.manager.loadCss('<link href="/abc/def3">'),
# ]);
# ```
) -> Optional[str]:
if not to_load_js_tags and not to_load_css_tags and not loaded_css_urls and not loaded_js_urls:
return None
def map_to_base64(lst: List[str]) -> List[str]:
return [base64.b64encode(tag.encode()).decode() for tag in lst]
# Generate JSON that will tell the JS dependency manager which JS and CSS to load
#
# or
#
# ```js
# Components.manager.markScriptLoaded("css", "/abc/def1.css"),
# Components.manager.markScriptLoaded("css", "/abc/def2.css"),
# Components.manager.markScriptLoaded("js", "/abc/def3.js"),
# ```
#
# NOTE: It would be better to pass only the URL itself for `loadJs/loadCss`, instead of a whole tag.
# NOTE: It would be simpler to pass only the URL itself for `loadJs/loadCss`, instead of a whole tag.
# But because we allow users to specify the Media class, and thus users can
# configure how the `<link>` or `<script>` tags are rendered, we need pass the whole tag.
escaped_to_load_js_tags = [_escape_js(tag, eval=False) for tag in to_load_js_tags]
escaped_to_load_css_tags = [_escape_js(tag, eval=False) for tag in to_load_css_tags]
#
# NOTE 2: Convert to Base64 to avoid any issues with `</script>` tags in the content
exec_script_data = {
"loadedCssUrls": map_to_base64(loaded_css_urls),
"loadedJsUrls": map_to_base64(loaded_js_urls),
"toLoadCssTags": map_to_base64(to_load_css_tags),
"toLoadJsTags": map_to_base64(to_load_js_tags),
}
# Make JS array whose items are interpreted as JS statements (e.g. functions)
def js_arr(lst: List) -> str:
return "[" + ", ".join(lst) + "]"
# NOTE: Wrap the body in self-executing function to avoid polluting the global scope.
exec_script: types.js = f"""
(() => {{
Components.manager._loadComponentScripts({{
loadedCssUrls: {json.dumps(loaded_css_urls)},
loadedJsUrls: {json.dumps(loaded_js_urls)},
toLoadCssTags: {js_arr(escaped_to_load_css_tags)},
toLoadJsTags: {js_arr(escaped_to_load_js_tags)},
}});
document.currentScript.remove();
}})();
"""
# NOTE: The exec script MUST be executed SYNC, so we MUST NOT put `type="module"`,
# `async`, nor `defer` on it.
# See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#notes
exec_script = f"<script>{_escape_js(dedent(exec_script))}</script>"
# NOTE: This data is embedded into the HTML as JSON. It is the responsibility of
# the client-side code to detect that this script was inserted, and to load the
# corresponding assets
# See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#embedding_data_in_html
exec_script = json.dumps(exec_script_data)
exec_script = f'<script type="application/json" data-djc>{exec_script}</script>'
return exec_script
@ -807,8 +813,8 @@ def cached_script_view(
urlpatterns = [
# E.g. `/components/cache/table.js/`
path("cache/<str:comp_cls_hash>.<str:script_type>/", cached_script_view, name=CACHE_ENDPOINT_NAME),
# E.g. `/components/cache/table.js`
path("cache/<str:comp_cls_hash>.<str:script_type>", cached_script_view, name=CACHE_ENDPOINT_NAME),
]

View file

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

View file

@ -1,8 +1,6 @@
import re
from typing import Any, Callable, List, Optional, Type, TypeVar
from django.template.defaultfilters import escape
from django_components.util.nanoid import generate
T = TypeVar("T")
@ -52,22 +50,6 @@ def get_import_path(cls_or_fn: Type[Any]) -> str:
return module + "." + cls_or_fn.__qualname__
# See https://stackoverflow.com/a/58800331/9788634
# str.replace(/\\|`|\$/g, '\\$&');
JS_STRING_LITERAL_SPECIAL_CHARS_REGEX = re.compile(r"\\|`|\$")
# See https://stackoverflow.com/a/34064434/9788634
def escape_js_string_literal(js: str) -> str:
escaped_js = escape(js)
def on_replace_match(match: "re.Match[str]") -> str:
return f"\\{match[0]}"
escaped_js = JS_STRING_LITERAL_SPECIAL_CHARS_REGEX.sub(on_replace_match, escaped_js)
return escaped_js
def default(val: Optional[T], default: T) -> T:
return val if val is not None else default
@ -77,10 +59,3 @@ def get_last_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]:
if key(item):
return len(lst) - 1 - index
return None
def _escape_js(js: str, eval: bool = True) -> str:
escaped_js = escape_js_string_literal(js)
# `unescapeJs` is the function we call in the browser to parse the escaped JS
escaped_js = f"Components.unescapeJs(`{escaped_js}`)"
return f"eval({escaped_js})" if eval else escaped_js

View file

@ -1,22 +1,14 @@
/** This file defines the API of the JS code. */
import { createComponentsManager } from './manager';
import { unescapeJs } from './utils';
export type * from './manager';
export const Components = (() => {
const manager = createComponentsManager();
/** Unescape JS that was escaped in Django side with `escape_js` */
const unescapeJs = (escapedJs: string) => {
return new DOMParser().parseFromString(escapedJs, 'text/html').documentElement.textContent;
};
return {
manager,
createComponentsManager,
unescapeJs,
};
})();
export const Components = {
manager: createComponentsManager(),
createComponentsManager,
unescapeJs,
};
// In browser, this is accessed as `Components.manager`, etc
globalThis.Components = Components;

View file

@ -1,5 +1,7 @@
/** The actual code of the JS dependency manager */
import { callWithAsyncErrorHandling } from './errorHandling';
import { observeScriptTag } from './mutationObserver';
import { unescapeJs } from './utils';
type MaybePromise<T> = Promise<T> | T;
@ -233,24 +235,35 @@ export const createComponentsManager = () => {
toLoadCssTags: string[];
toLoadJsTags: string[];
}) => {
const loadedCssUrls = inputs.loadedCssUrls.map((s) => atob(s));
const loadedJsUrls = inputs.loadedJsUrls.map((s) => atob(s));
const toLoadCssTags = inputs.toLoadCssTags.map((s) => atob(s));
const toLoadJsTags = inputs.toLoadJsTags.map((s) => atob(s));
// Mark as loaded the CSS that WAS inlined into the HTML.
inputs.loadedCssUrls.forEach((s) => markScriptLoaded("css", s));
inputs.loadedJsUrls.forEach((s) => markScriptLoaded("js", s));
loadedCssUrls.forEach((s) => markScriptLoaded("css", s));
loadedJsUrls.forEach((s) => markScriptLoaded("js", s));
// Load CSS that was not inlined into the HTML
// NOTE: We don't need to wait for CSS to load
Promise
.all(inputs.toLoadCssTags.map((s) => loadCss(s)))
.all(toLoadCssTags.map((s) => loadCss(s)))
.catch(console.error);
// Load JS that was not inlined into the HTML
const jsScriptsPromise = Promise
// NOTE: Interestingly enough, when we insert scripts into the DOM programmatically,
// the order of execution is the same as the order of insertion.
.all(inputs.toLoadJsTags.map((s) => loadJs(s)))
.all(toLoadJsTags.map((s) => loadJs(s)))
.catch(console.error);
};
// Initialise the MutationObserver that watches for `<script>` tags with `data-djc` attribute
observeScriptTag((script) => {
const data = JSON.parse(script.text);
_loadComponentScripts(data);
});
return {
callComponent,
registerComponent,
@ -258,6 +271,5 @@ export const createComponentsManager = () => {
loadJs,
loadCss,
markScriptLoaded,
_loadComponentScripts,
};
};

View file

@ -0,0 +1,27 @@
/** Set up MutationObserver that watches for `<script>` tags with `data-djc` attribute */
export const observeScriptTag = (onScriptTag: (node: HTMLScriptElement) => void) => {
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
// Check added nodes
mutation.addedNodes.forEach((node) => {
if (
node.nodeName === "SCRIPT" &&
(node as HTMLElement).hasAttribute("data-djc")
) {
onScriptTag(node as HTMLScriptElement);
}
});
2;
}
}
});
// Observe the entire document
observer.observe(document, {
childList: true,
subtree: true, // To detect nodes added anywhere in the DOM
});
return observer;
};

View file

@ -1,10 +1,19 @@
// Helper functions taken from @vue/shared
/** Unescape JS that was escaped in Django side with `escape_js` */
export const unescapeJs = (escapedJs: string) => {
const doc = new DOMParser().parseFromString(escapedJs, "text/html")
return doc.documentElement.textContent as string;
};
// ////////////////////////////////////////////////////////
// Helper functions below were taken from @vue/shared
// See https://github.com/vuejs/core/blob/91112520427ff55941a1c759d7d60a0811ff4a61/packages/shared/src/general.ts#L105
// ////////////////////////////////////////////////////////
export const isArray = Array.isArray;
export const isFunction = (val: unknown): val is Function => typeof val === 'function';
export const isFunction = (val: unknown): val is Function =>
typeof val === "function";
export const isObject = (val: unknown): val is Record<any, any> => {
return val !== null && typeof val === 'object';
return val !== null && typeof val === "object";
};
export const isPromise = <T = any>(val: unknown): val is Promise<T> => {
return (

View file

@ -111,6 +111,74 @@ class CheckScriptOrderInMedia(Component):
js = "check_script_order.js"
# Fragment where the JS and CSS are defined on the Component
@register("frag_comp")
class FragComp(Component):
template: types.django_html = """
<div class="frag">
123
<span id="frag-text"></span>
</div>
"""
js = """
document.querySelector('#frag-text').textContent = 'xxx';
"""
css = """
.frag {
background: blue;
}
"""
# Fragment where the JS and CSS are defined on the Media class
@register("frag_media")
class FragMedia(Component):
template = """
<div class="frag">
123
<span id="frag-text"></span>
</div>
"""
class Media:
js = "fragment.js"
css = "fragment.css"
# Fragment that defines an AlpineJS component
@register("frag_alpine")
class FragAlpine(Component):
template = """
<template x-if="false" data-name="frag">
<div class="frag">
123
<span x-data="frag" x-text="fragVal">
</span>
</div>
</template>
"""
js = """
Alpine.data('frag', () => ({
fragVal: 'xxx',
}));
// Now that the component has been defined in AlpineJS, we can "activate" all instances
// where we use the `x-data="frag"` directive.
document.querySelectorAll('[data-name="frag"]').forEach((el) => {
el.setAttribute('x-if', 'true');
});
"""
css = """
.frag {
background: blue;
}
"""
@register("alpine_test_in_media")
class AlpineCompInMedia(Component):
template: types.django_html = """

View file

@ -0,0 +1,3 @@
.frag {
background: blue;
}

View file

@ -0,0 +1 @@
document.querySelector('#frag-text').textContent = 'xxx';

View file

@ -8,6 +8,10 @@ from testserver.views import (
check_js_order_in_js_view,
check_js_order_in_media_view,
check_js_order_vars_not_available_before_view,
fragment_base_alpine_view,
fragment_base_htmx_view,
fragment_base_js_view,
fragment_view,
multiple_components_view,
single_component_view,
)
@ -22,6 +26,10 @@ urlpatterns = [
path("js-order/js", check_js_order_in_js_view),
path("js-order/media", check_js_order_in_media_view),
path("js-order/invalid", check_js_order_vars_not_available_before_view),
path("fragment/base/alpine", fragment_base_alpine_view),
path("fragment/base/htmx", fragment_base_htmx_view),
path("fragment/base/js", fragment_base_js_view),
path("fragment/frag", fragment_view),
path("alpine/head", alpine_in_head_view),
path("alpine/body", alpine_in_body_view),
path("alpine/body2", alpine_in_body_view_2),

View file

@ -1,5 +1,6 @@
from django.http import HttpResponse
from django.template import Context, Template
from testserver.components import FragComp, FragMedia
from django_components import render_dependencies, types
@ -126,6 +127,136 @@ def check_js_order_vars_not_available_before_view(request):
return HttpResponse(rendered)
# HTML into which a fragment will be loaded using vanilla JS
def fragment_base_js_view(request):
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
</head>
<body>
{% component 'inner' variable='foo' / %}
<div id="target">OLD</div>
<button id="loader">
Click me!
</button>
<script>
const frag = "{{ frag }}";
document.querySelector('#loader').addEventListener('click', function () {
fetch(`/fragment/frag?frag=${frag}`)
.then(response => response.text())
.then(html => {
console.log({ fragment: html })
document.querySelector('#target').outerHTML = html;
});
});
</script>
{% component_js_dependencies %}
</body>
</html>
"""
template = Template(template_str)
frag = request.GET["frag"]
rendered_raw = template.render(
Context(
{
"frag": frag,
}
)
)
rendered = render_dependencies(rendered_raw)
return HttpResponse(rendered)
# HTML into which a fragment will be loaded using AlpineJS
def fragment_base_alpine_view(request):
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
<script defer src="https://unpkg.com/alpinejs"></script>
</head>
<body x-data="{
htmlVar: 'OLD',
loadFragment: function () {
const frag = '{{ frag }}';
fetch(`/fragment/frag?frag=${frag}`)
.then(response => response.text())
.then(html => {
console.log({ fragment: html });
this.htmlVar = html;
});
}
}">
{% component 'inner' variable='foo' / %}
<div id="target" x-html="htmlVar">OLD</div>
<button id="loader" @click="loadFragment">
Click me!
</button>
{% component_js_dependencies %}
</body>
</html>
"""
template = Template(template_str)
frag = request.GET["frag"]
rendered_raw = template.render(Context({"frag": frag}))
rendered = render_dependencies(rendered_raw)
return HttpResponse(rendered)
# HTML into which a fragment will be loaded using HTMX
def fragment_base_htmx_view(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_raw = template.render(Context({"frag": frag}))
rendered = render_dependencies(rendered_raw)
return HttpResponse(rendered)
def fragment_view(request):
fragment_type = request.GET["frag"]
if fragment_type == "comp":
return FragComp.render_to_response(type="fragment")
elif fragment_type == "media":
return FragMedia.render_to_response(type="fragment")
else:
raise ValueError("Invalid fragment type")
def alpine_in_head_view(request):
template_str: types.django_html = """
{% load component_tags %}

View file

@ -49,7 +49,7 @@ class InlineComponentTest(BaseTestCase):
rendered,
)
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&#x27;HTML and JS only&#x27;);`))</script>",
"<script>console.log('HTML and JS only');</script>",
rendered,
)
@ -106,7 +106,7 @@ class ComponentMediaTests(BaseTestCase):
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<link"), 0)
self.assertEqual(rendered.count("<script"), 2) # 2 Boilerplate scripts
self.assertEqual(rendered.count("<script"), 1) # 1 Boilerplate script
def test_css_js_as_lists(self):
class SimpleComponent(Component):

View file

@ -68,9 +68,7 @@ class RenderDependenciesTests(BaseTestCase):
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
@ -90,9 +88,7 @@ class RenderDependenciesTests(BaseTestCase):
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
self.assertEqual(rendered.count("<link"), 1)
@ -119,9 +115,7 @@ class RenderDependenciesTests(BaseTestCase):
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
self.assertEqual(rendered.count("<link"), 1)
@ -157,7 +151,7 @@ class RenderDependenciesTests(BaseTestCase):
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered_raw, count=0) # Media.css
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>",
'<script>console.log("xyz");</script>',
rendered_raw,
count=0,
) # Inlined JS
@ -184,9 +178,7 @@ class RenderDependenciesTests(BaseTestCase):
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
self.assertEqual(rendered.count('<link href="style.css" media="all" rel="stylesheet">'), 1) # Media.css
self.assertEqual(rendered.count("<link"), 1)
@ -234,7 +226,7 @@ class RenderDependenciesTests(BaseTestCase):
count=1,
)
self.assertInHTML(
"""<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>""",
'<script>console.log("xyz");</script>',
rendered_body,
count=1,
)
@ -286,7 +278,7 @@ class RenderDependenciesTests(BaseTestCase):
count=1,
)
self.assertInHTML(
"""<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>""",
'<script>console.log("xyz");</script>',
rendered_head,
count=1,
)
@ -401,6 +393,11 @@ class RenderDependenciesTests(BaseTestCase):
rendered_raw = Template(template_str).render(Context({"formset": [1]}))
rendered = render_dependencies(rendered_raw, type="fragment")
# Base64 encodings:
# `PGxpbmsgaHJlZj0ic3R5bGUuY3NzIiBtZWRpYT0iYWxsIiByZWw9InN0eWxlc2hlZXQiPg==` -> `<link href="style.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>`
# `PHNjcmlwdCBzcmM9Ii9jb21wb25lbnRzL2NhY2hlL1NpbXBsZUNvbXBvbmVudF8zMTEwOTcuanMiPjwvc2NyaXB0Pg==` -> `<script src="/components/cache/SimpleComponent_311097.js"></script>` # noqa: E501
expected = """
<table class="table-auto border-collapse divide-y divide-x divide-slate-300 w-full">
<!-- Table head -->
@ -423,10 +420,49 @@ class RenderDependenciesTests(BaseTestCase):
</tr>
</tbody>
</table>
"""
<script type="application/json" data-djc>
{"loadedCssUrls": [],
"loadedJsUrls": [],
"toLoadCssTags": ["PGxpbmsgaHJlZj0ic3R5bGUuY3NzIiBtZWRpYT0iYWxsIiByZWw9InN0eWxlc2hlZXQiPg==",
"PGxpbmsgaHJlZj0iL2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50XzMxMTA5Ny5jc3MiIG1lZGlhPSJhbGwiIHJlbD0ic3R5bGVzaGVldCI+"],
"toLoadJsTags": ["PHNjcmlwdCBzcmM9InNjcmlwdC5qcyI+PC9zY3JpcHQ+",
"PHNjcmlwdCBzcmM9Ii9jb21wb25lbnRzL2NhY2hlL1NpbXBsZUNvbXBvbmVudF8zMTEwOTcuanMiPjwvc2NyaXB0Pg=="]}
</script>
""" # noqa: E501
self.assertHTMLEqual(expected, rendered)
def test_raises_if_script_end_tag_inside_component_js(self):
class ComponentWithScript(SimpleComponent):
js: types.js = """
console.log("</script >");
"""
registry.register(name="test", component=ComponentWithScript)
with self.assertRaisesMessage(
RuntimeError,
"Content of `Component.js` for component 'ComponentWithScript' contains '</script>' end tag.",
):
ComponentWithScript.render(kwargs={"variable": "foo"})
def test_raises_if_script_end_tag_inside_component_css(self):
class ComponentWithScript(SimpleComponent):
css: types.css = """
/* </style > */
.xyz {
color: red;
}
"""
registry.register(name="test", component=ComponentWithScript)
with self.assertRaisesMessage(
RuntimeError,
"Content of `Component.css` for component 'ComponentWithScript' contains '</style>' end tag.",
):
ComponentWithScript.render(kwargs={"variable": "foo"})
class MiddlewareTests(BaseTestCase):
def test_middleware_response_without_content_type(self):
@ -462,9 +498,7 @@ class MiddlewareTests(BaseTestCase):
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
# Inlined JS
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
)
self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1)
# Inlined CSS
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1)
# Media.css

View file

@ -69,7 +69,6 @@ class DependencyManagerTests(_BaseDepManagerTestCase):
"loadJs",
"loadCss",
"markScriptLoaded",
"_loadComponentScripts",
],
)

View file

@ -123,23 +123,14 @@ class DependencyRenderingTests(BaseTestCase):
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertEqual(rendered.count("<script"), 2) # Two 2 scripts belong to the boilerplate
self.assertEqual(rendered.count("<script"), 1) # 1 boilerplate script
self.assertEqual(rendered.count("<link"), 0) # No CSS
self.assertEqual(rendered.count("<style"), 0)
# We expect to find this:
# ```js
# Components.manager._loadComponentScripts({
# loadedCssUrls: [],
# loadedJsUrls: [],
# toLoadCssTags: [],
# toLoadJsTags: [],
# });
# ```
self.assertEqual(rendered.count("loadedJsUrls: [],"), 1)
self.assertEqual(rendered.count("loadedCssUrls: [],"), 1)
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
self.assertNotIn("loadedJsUrls", rendered)
self.assertNotIn("loadedCssUrls", rendered)
self.assertNotIn("toLoadJsTags", rendered)
self.assertNotIn("toLoadCssTags", rendered)
def test_no_js_dependencies_when_no_components_used(self):
registry.register(name="test", component=SimpleComponent)
@ -153,23 +144,14 @@ class DependencyRenderingTests(BaseTestCase):
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertEqual(rendered.count("<script"), 2) # Two 2 scripts belong to the boilerplate
self.assertEqual(rendered.count("<script"), 1) # 1 boilerplate script
self.assertEqual(rendered.count("<link"), 0) # No CSS
self.assertEqual(rendered.count("<style"), 0)
# We expect to find this:
# ```js
# Components.manager._loadComponentScripts({
# loadedCssUrls: [],
# loadedJsUrls: [],
# toLoadCssTags: [],
# toLoadJsTags: [],
# });
# ```
self.assertEqual(rendered.count("loadedJsUrls: [],"), 1)
self.assertEqual(rendered.count("loadedCssUrls: [],"), 1)
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
self.assertNotIn("loadedJsUrls", rendered)
self.assertNotIn("loadedCssUrls", rendered)
self.assertNotIn("toLoadJsTags", rendered)
self.assertNotIn("toLoadCssTags", rendered)
def test_no_css_dependencies_when_no_components_used(self):
registry.register(name="test", component=SimpleComponent)
@ -204,19 +186,20 @@ class DependencyRenderingTests(BaseTestCase):
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<script"), 3)
# We expect to find this:
# ```js
# Components.manager._loadComponentScripts({
# loadedCssUrls: [&quot;style.css&quot;],
# loadedJsUrls: [&quot;script.js&quot;],
# toLoadCssTags: [],
# toLoadJsTags: [],
# });
# ```
self.assertEqual(rendered.count("loadedJsUrls: [&quot;script.js&quot;],"), 1)
self.assertEqual(rendered.count("loadedCssUrls: [&quot;style.css&quot;],"), 1)
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
# `c3R5bGUuY3Nz` is base64 encoded `style.css`
# `c2NyaXB0Lmpz` is base64 encoded `style.js`
self.assertInHTML(
"""
<script type="application/json" data-djc>
{"loadedCssUrls": ["c3R5bGUuY3Nz"],
"loadedJsUrls": ["c2NyaXB0Lmpz"],
"toLoadCssTags": [],
"toLoadJsTags": []}
</script>
""",
rendered,
count=1,
)
def test_single_component_with_dash_or_slash_in_name(self):
registry.register(name="te-s/t", component=SimpleComponent)
@ -238,19 +221,20 @@ class DependencyRenderingTests(BaseTestCase):
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<script"), 3)
# We expect to find this:
# ```js
# Components.manager._loadComponentScripts({
# loadedCssUrls: [&quot;style.css&quot;],
# loadedJsUrls: [&quot;script.js&quot;],
# toLoadCssTags: [],
# toLoadJsTags: [],
# });
# ```
self.assertEqual(rendered.count("loadedJsUrls: [&quot;script.js&quot;],"), 1)
self.assertEqual(rendered.count("loadedCssUrls: [&quot;style.css&quot;],"), 1)
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
# `c3R5bGUuY3Nz` is base64 encoded `style.css`
# `c2NyaXB0Lmpz` is base64 encoded `style.js`
self.assertInHTML(
"""
<script type="application/json" data-djc>
{"loadedCssUrls": ["c3R5bGUuY3Nz"],
"loadedJsUrls": ["c2NyaXB0Lmpz"],
"toLoadCssTags": [],
"toLoadJsTags": []}
</script>
""",
rendered,
count=1,
)
def test_single_component_placeholder_removed(self):
registry.register(name="test", component=SimpleComponent)
@ -303,19 +287,20 @@ class DependencyRenderingTests(BaseTestCase):
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<script"), 3)
# We expect to find this:
# ```js
# Components.manager._loadComponentScripts({
# loadedCssUrls: [&quot;style.css&quot;],
# loadedJsUrls: [&quot;script.js&quot;],
# toLoadCssTags: [],
# toLoadJsTags: [],
# });
# ```
self.assertEqual(rendered.count("loadedJsUrls: [&quot;script.js&quot;],"), 1)
self.assertEqual(rendered.count("loadedCssUrls: [&quot;style.css&quot;],"), 1)
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
# `c3R5bGUuY3Nz` is base64 encoded `style.css`
# `c2NyaXB0Lmpz` is base64 encoded `style.js`
self.assertInHTML(
"""
<script type="application/json" data-djc>
{"loadedCssUrls": ["c3R5bGUuY3Nz"],
"loadedJsUrls": ["c2NyaXB0Lmpz"],
"toLoadCssTags": [],
"toLoadJsTags": []}
</script>
""",
rendered,
count=1,
)
def test_all_dependencies_are_rendered_for_component_with_multiple_dependencies(
self,
@ -357,19 +342,23 @@ class DependencyRenderingTests(BaseTestCase):
count=1,
)
# We expect to find this:
# ```js
# Components.manager._loadComponentScripts({
# loadedCssUrls: [&quot;style.css&quot;, &quot;style2.css&quot;],
# loadedJsUrls: [&quot;script.js&quot;, &quot;script2.js&quot;],
# toLoadCssTags: [],
# toLoadJsTags: [],
# });
# ```
self.assertEqual(rendered.count("loadedCssUrls: [&quot;style.css&quot;, &quot;style2.css&quot;],"), 1)
self.assertEqual(rendered.count("loadedJsUrls: [&quot;script.js&quot;, &quot;script2.js&quot;"), 1)
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
# Base64 encoding:
# `c3R5bGUuY3Nz` -> `style.css`
# `c3R5bGUyLmNzcw==` -> `style2.css`
# `c2NyaXB0Lmpz` -> `script.js`
# `c2NyaXB0Mi5qcw==` -> `script2.js`
self.assertInHTML(
"""
<script type="application/json" data-djc>
{"loadedCssUrls": ["c3R5bGUuY3Nz", "c3R5bGUyLmNzcw=="],
"loadedJsUrls": ["c2NyaXB0Lmpz", "c2NyaXB0Mi5qcw=="],
"toLoadCssTags": [],
"toLoadJsTags": []}
</script>
""",
rendered,
count=1,
)
def test_no_dependencies_with_multiple_unused_components(self):
registry.register(name="inner", component=SimpleComponent)
@ -386,23 +375,14 @@ class DependencyRenderingTests(BaseTestCase):
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertEqual(rendered.count("<script"), 2) # 2 scripts belong to the boilerplate
self.assertEqual(rendered.count("<script"), 1) # 1 boilerplate script
self.assertEqual(rendered.count("<link"), 0) # No CSS
self.assertEqual(rendered.count("<style"), 0)
# We expect to find this:
# ```js
# Components.manager._loadComponentScripts({
# loadedCssUrls: [],
# loadedJsUrls: [],
# toLoadCssTags: [],
# toLoadJsTags: [],
# });
# ```
self.assertEqual(rendered.count("loadedJsUrls: [],"), 1)
self.assertEqual(rendered.count("loadedCssUrls: [],"), 1)
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
self.assertNotIn("loadedJsUrls", rendered)
self.assertNotIn("loadedCssUrls", rendered)
self.assertNotIn("toLoadJsTags", rendered)
self.assertNotIn("toLoadCssTags", rendered)
def test_multiple_components_dependencies(self):
registry.register(name="inner", component=SimpleComponent)
@ -464,36 +444,36 @@ class DependencyRenderingTests(BaseTestCase):
<script src="script2.js"></script>
<script src="script.js"></script>
<script src="xyz1.js"></script>
<script>eval(Components.unescapeJs(`console.log(&quot;Hello&quot;);`))</script>
<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>
<script>console.log("Hello");</script>
<script>console.log("xyz");</script>
""",
rendered,
count=1,
)
# We expect to find this:
# ```js
# Components.manager._loadComponentScripts({
# loadedCssUrls: [&quot;/components/cache/OtherComponent_6329ae.css/&quot;, &quot;/components/cache/SimpleComponentNested_f02d32.css/&quot;, &quot;style.css&quot;, &quot;style2.css&quot;, &quot;xyz1.css&quot;],
# loadedJsUrls: [&quot;/components/cache/OtherComponent_6329ae.js/&quot;, &quot;/components/cache/SimpleComponentNested_f02d32.js/&quot;, &quot;script.js&quot;, &quot;script2.js&quot;, &quot;xyz1.js&quot;],
# toLoadCssTags: [],
# toLoadJsTags: [],
# });
# ```
self.assertEqual(
rendered.count(
"loadedJsUrls: [&quot;/components/cache/OtherComponent_6329ae.js/&quot;, &quot;/components/cache/SimpleComponentNested_f02d32.js/&quot;, &quot;script.js&quot;, &quot;script2.js&quot;, &quot;xyz1.js&quot;],"
),
1,
# Base64 encoding:
# `c3R5bGUuY3Nz` -> `style.css`
# `c3R5bGUyLmNzcw==` -> `style2.css`
# `eHl6MS5jc3M=` -> `xyz1.css`
# `L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmNzcw==` -> `/components/cache/OtherComponent_6329ae.css`
# `L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5jc3M=` -> `/components/cache/SimpleComponentNested_f02d32.css`
# `L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmpz` -> `/components/cache/OtherComponent_6329ae.js`
# `L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5qcw==` -> `/components/cache/SimpleComponentNested_f02d32.js`
# `c2NyaXB0Lmpz` -> `script.js`
# `c2NyaXB0Mi5qcw==` -> `script2.js`
# `eHl6MS5qcw==` -> `xyz1.js`
self.assertInHTML(
"""
<script type="application/json" data-djc>
{"loadedCssUrls": ["L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmNzcw==", "L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5jc3M=", "c3R5bGUuY3Nz", "c3R5bGUyLmNzcw==", "eHl6MS5jc3M="],
"loadedJsUrls": ["L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmpz", "L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5qcw==", "c2NyaXB0Lmpz", "c2NyaXB0Mi5qcw==", "eHl6MS5qcw=="],
"toLoadCssTags": [],
"toLoadJsTags": []}
</script>
""",
rendered,
count=1,
)
self.assertEqual(
rendered.count(
"loadedCssUrls: [&quot;/components/cache/OtherComponent_6329ae.css/&quot;, &quot;/components/cache/SimpleComponentNested_f02d32.css/&quot;, &quot;style.css&quot;, &quot;style2.css&quot;, &quot;xyz1.css&quot;],"
),
1,
)
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
def test_multiple_components_all_placeholders_removed(self):
registry.register(name="inner", component=SimpleComponent)

View file

@ -297,6 +297,214 @@ class E2eDependencyRenderingTests(BaseTestCase):
await page.close()
# Fragment where JS and CSS is defined on Component class
@with_playwright
async def test_fragment_comp(self):
page: Page = await self.browser.new_page()
await page.goto(f"{TEST_SERVER_URL}/fragment/base/js?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)
self.assertEqual(data_before["targetHtml"], '<div id="target">OLD</div>')
self.assertEqual(data_before["fragHtml"], 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)
self.assertEqual(data["targetHtml"], None)
self.assertHTMLEqual('<div class="frag"> 123 <span id="frag-text">xxx</span></div>', data["fragHtml"])
self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue'
await page.close()
# Fragment where JS and CSS is defined on Media class
@with_playwright
async def test_fragment_media(self):
page: Page = await self.browser.new_page()
await page.goto(f"{TEST_SERVER_URL}/fragment/base/js?frag=media")
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)
self.assertEqual(data_before["targetHtml"], '<div id="target">OLD</div>')
self.assertEqual(data_before["fragHtml"], 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=\"/static/fragment.css\"')")
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)
self.assertEqual(data["targetHtml"], None)
self.assertHTMLEqual('<div class="frag"> 123 <span id="frag-text">xxx</span></div>', data["fragHtml"])
self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue'
await page.close()
# Fragment loaded by AlpineJS
@with_playwright
async def test_fragment_alpine(self):
page: Page = await self.browser.new_page()
await page.goto(f"{TEST_SERVER_URL}/fragment/base/alpine?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)
self.assertEqual(data_before["targetHtml"], '<div id="target" x-html="htmlVar">OLD</div>')
self.assertEqual(data_before["fragHtml"], 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)
# NOTE: Unlike the vanilla JS tests, for the Alpine test we don't remove the targetHtml,
# but only change its contents.
self.assertInHTML(
'<div class="frag"> 123 <span id="frag-text">xxx</span></div>',
data["targetHtml"],
)
self.assertHTMLEqual(data["fragHtml"], '<div class="frag"> 123 <span id="frag-text">xxx</span></div>')
self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue'
await page.close()
# Fragment loaded by HTMX
@with_playwright
async def test_fragment_htmx(self):
page: Page = await self.browser.new_page()
await page.goto(f"{TEST_SERVER_URL}/fragment/base/htmx?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)
self.assertEqual(data_before["targetHtml"], '<div id="target">OLD</div>')
self.assertEqual(data_before["fragHtml"], 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 fragInnerHtml = fragEl ? fragEl.innerHTML : null;
// Get the stylings defined via CSS
const fragBg = fragEl ? globalThis.getComputedStyle(fragEl).getPropertyValue('background') : null;
return { targetHtml, fragInnerHtml, fragBg };
}"""
data = await page.evaluate(test_js)
self.assertEqual(data["targetHtml"], None)
# NOTE: We test only the inner HTML, because the element itself may or may not have
# extra CSS classes added by HTMX, which results in flaky tests.
self.assertHTMLEqual(
data["fragInnerHtml"],
'123 <span id="frag-text">xxx</span>',
)
self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue'
await page.close()
@with_playwright
async def test_alpine__head(self):
single_comp_url = TEST_SERVER_URL + "/alpine/head"