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 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: 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 title: Registering components
weight: 4 weight: 5
--- ---
In previous examples you could repeatedly see us using `@register()` to "register" In previous examples you could repeatedly see us using `@register()` to "register"

View file

@ -1,6 +1,6 @@
--- ---
title: Lifecycle hooks title: Lifecycle hooks
weight: 3 weight: 4
--- ---
_New in version 0.96_ _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 title: Prop drilling and provide / inject
weight: 2 weight: 3
--- ---
_New in version 0.80_: _New in version 0.80_:

View file

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

View file

@ -1,6 +1,6 @@
--- ---
title: Typing and validation title: Typing and validation
weight: 5 weight: 6
--- ---
## Adding type hints with Generics ## 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.calendar.calendar import Calendar, CalendarRelative
from components.fragment import FragAlpine, FragJs, FragmentBaseAlpine, FragmentBaseHtmx, FragmentBaseJs
from components.greeting import Greeting from components.greeting import Greeting
from components.nested.calendar.calendar import CalendarNested from components.nested.calendar.calendar import CalendarNested
from django.urls import path from django.urls import path
@ -8,4 +9,9 @@ urlpatterns = [
path("calendar/", Calendar.as_view(), name="calendar"), path("calendar/", Calendar.as_view(), name="calendar"),
path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"), path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"),
path("calendar-nested/", CalendarNested.as_view(), name="calendar-nested"), 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)""" """All code related to management of component dependencies (JS and CSS scripts)"""
import base64
import json import json
import re import re
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from functools import lru_cache from functools import lru_cache
from hashlib import md5 from hashlib import md5
from textwrap import dedent
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Callable, Callable,
@ -34,9 +34,8 @@ from django.urls import path, reverse
from django.utils.decorators import sync_and_async_middleware from django.utils.decorators import sync_and_async_middleware
from django.utils.safestring import SafeString, mark_safe 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.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: if TYPE_CHECKING:
from django_components.component import Component from django_components.component import Component
@ -325,6 +324,9 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
return HttpResponse(processed_html) return HttpResponse(processed_html)
``` ```
""" """
if type not in ("document", "fragment"):
raise ValueError(f"Invalid type '{type}'")
is_safestring = isinstance(content, SafeString) is_safestring = isinstance(content, SafeString)
if isinstance(content, str): 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) content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, type)
# Replace the placeholders with the actual content # 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_js_placeholder = False
did_find_css_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: def on_replace_match(match: "re.Match[bytes]") -> bytes:
nonlocal did_find_css_placeholder nonlocal did_find_css_placeholder
nonlocal did_find_js_placeholder nonlocal did_find_js_placeholder
if match[0] == CSS_PLACEHOLDER_BYTES: if match[0] == CSS_PLACEHOLDER_BYTES:
replacement = css_dependencies replacement = css_replacement
did_find_css_placeholder = True did_find_css_placeholder = True
elif match[0] == JS_PLACEHOLDER_BYTES: elif match[0] == JS_PLACEHOLDER_BYTES:
replacement = js_dependencies replacement = js_replacement
did_find_js_placeholder = True did_find_js_placeholder = True
else: else:
raise RuntimeError( raise RuntimeError(
@ -370,6 +378,10 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
if maybe_transformed is not None: if maybe_transformed is not None:
content_ = maybe_transformed.encode() 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 # Return the same type as we were given
output = content_.decode() if isinstance(content, str) else content_ output = content_.decode() if isinstance(content, str) else content_
output = mark_safe(output) if is_safestring else output 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 scripts without which the rest wouldn't work
core_script_tags = Media( 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() ).render_js()
final_script_tags = "".join( 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], *[tag for tag in core_script_tags],
# Make calls to the JS dependency manager # Make calls to the JS dependency manager
# Loads JS from `Media.js` and `Component.js` if fragment # Loads JS from `Media.js` and `Component.js` if fragment
exec_script, *([exec_script] if exec_script else []),
# JS from `Media.js` # JS from `Media.js`
# NOTE: When rendering a document, the initial JS is inserted directly into the HTML # 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 # 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)) to_load_js_urls.append(get_script_url("js", comp_cls))
if _is_nonempty_str(comp_cls.css): 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 ( return (
to_load_js_urls, to_load_js_urls,
@ -650,9 +663,20 @@ def _get_script_tag(
script = get_script_content(script_type, comp_cls) script = get_script_content(script_type, comp_cls)
if script_type == "js": 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": 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 f"<style>{script}</style>"
return script return script
@ -678,51 +702,33 @@ def _gen_exec_script(
to_load_css_tags: List[str], to_load_css_tags: List[str],
loaded_js_urls: List[str], loaded_js_urls: List[str],
loaded_css_urls: List[str], loaded_css_urls: List[str],
) -> str: ) -> Optional[str]:
# Generate JS expression like so: if not to_load_js_tags and not to_load_css_tags and not loaded_css_urls and not loaded_js_urls:
# ```js return None
# Promise.all([
# Components.manager.loadJs('<script src="/abc/def1">...</script>'), def map_to_base64(lst: List[str]) -> List[str]:
# Components.manager.loadJs('<script src="/abc/def2">...</script>'), return [base64.b64encode(tag.encode()).decode() for tag in lst]
# Components.manager.loadCss('<link href="/abc/def3">'),
# ]); # Generate JSON that will tell the JS dependency manager which JS and CSS to load
# ```
# #
# or # NOTE: It would be simpler to pass only the URL itself for `loadJs/loadCss`, instead of a whole tag.
#
# ```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.
# But because we allow users to specify the Media class, and thus users can # 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. # 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) # NOTE: This data is embedded into the HTML as JSON. It is the responsibility of
def js_arr(lst: List) -> str: # the client-side code to detect that this script was inserted, and to load the
return "[" + ", ".join(lst) + "]" # corresponding assets
# See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#embedding_data_in_html
# NOTE: Wrap the body in self-executing function to avoid polluting the global scope. exec_script = json.dumps(exec_script_data)
exec_script: types.js = f""" exec_script = f'<script type="application/json" data-djc>{exec_script}</script>'
(() => {{
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>"
return exec_script return exec_script
@ -807,8 +813,8 @@ def cached_script_view(
urlpatterns = [ urlpatterns = [
# E.g. `/components/cache/table.js/` # E.g. `/components/cache/table.js`
path("cache/<str:comp_cls_hash>.<str:script_type>/", cached_script_view, name=CACHE_ENDPOINT_NAME), 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 import re
from typing import Any, Callable, List, Optional, Type, TypeVar from typing import Any, Callable, List, Optional, Type, TypeVar
from django.template.defaultfilters import escape
from django_components.util.nanoid import generate from django_components.util.nanoid import generate
T = TypeVar("T") T = TypeVar("T")
@ -52,22 +50,6 @@ def get_import_path(cls_or_fn: Type[Any]) -> str:
return module + "." + cls_or_fn.__qualname__ 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: def default(val: Optional[T], default: T) -> T:
return val if val is not None else default 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): if key(item):
return len(lst) - 1 - index return len(lst) - 1 - index
return None 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. */ /** This file defines the API of the JS code. */
import { createComponentsManager } from './manager'; import { createComponentsManager } from './manager';
import { unescapeJs } from './utils';
export type * from './manager'; export type * from './manager';
export const Components = (() => { export const Components = {
const manager = createComponentsManager(); manager: createComponentsManager(),
createComponentsManager,
/** Unescape JS that was escaped in Django side with `escape_js` */ unescapeJs,
const unescapeJs = (escapedJs: string) => { };
return new DOMParser().parseFromString(escapedJs, 'text/html').documentElement.textContent;
};
return {
manager,
createComponentsManager,
unescapeJs,
};
})();
// In browser, this is accessed as `Components.manager`, etc // In browser, this is accessed as `Components.manager`, etc
globalThis.Components = Components; globalThis.Components = Components;

View file

@ -1,5 +1,7 @@
/** The actual code of the JS dependency manager */ /** The actual code of the JS dependency manager */
import { callWithAsyncErrorHandling } from './errorHandling'; import { callWithAsyncErrorHandling } from './errorHandling';
import { observeScriptTag } from './mutationObserver';
import { unescapeJs } from './utils';
type MaybePromise<T> = Promise<T> | T; type MaybePromise<T> = Promise<T> | T;
@ -233,24 +235,35 @@ export const createComponentsManager = () => {
toLoadCssTags: string[]; toLoadCssTags: string[];
toLoadJsTags: 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. // Mark as loaded the CSS that WAS inlined into the HTML.
inputs.loadedCssUrls.forEach((s) => markScriptLoaded("css", s)); loadedCssUrls.forEach((s) => markScriptLoaded("css", s));
inputs.loadedJsUrls.forEach((s) => markScriptLoaded("js", s)); loadedJsUrls.forEach((s) => markScriptLoaded("js", s));
// Load CSS that was not inlined into the HTML // Load CSS that was not inlined into the HTML
// NOTE: We don't need to wait for CSS to load // NOTE: We don't need to wait for CSS to load
Promise Promise
.all(inputs.toLoadCssTags.map((s) => loadCss(s))) .all(toLoadCssTags.map((s) => loadCss(s)))
.catch(console.error); .catch(console.error);
// Load JS that was not inlined into the HTML // Load JS that was not inlined into the HTML
const jsScriptsPromise = Promise const jsScriptsPromise = Promise
// NOTE: Interestingly enough, when we insert scripts into the DOM programmatically, // NOTE: Interestingly enough, when we insert scripts into the DOM programmatically,
// the order of execution is the same as the order of insertion. // 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); .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 { return {
callComponent, callComponent,
registerComponent, registerComponent,
@ -258,6 +271,5 @@ export const createComponentsManager = () => {
loadJs, loadJs,
loadCss, loadCss,
markScriptLoaded, 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 // See https://github.com/vuejs/core/blob/91112520427ff55941a1c759d7d60a0811ff4a61/packages/shared/src/general.ts#L105
// ////////////////////////////////////////////////////////
export const isArray = Array.isArray; 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> => { 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> => { export const isPromise = <T = any>(val: unknown): val is Promise<T> => {
return ( return (

View file

@ -111,6 +111,74 @@ class CheckScriptOrderInMedia(Component):
js = "check_script_order.js" 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") @register("alpine_test_in_media")
class AlpineCompInMedia(Component): class AlpineCompInMedia(Component):
template: types.django_html = """ 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_js_view,
check_js_order_in_media_view, check_js_order_in_media_view,
check_js_order_vars_not_available_before_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, multiple_components_view,
single_component_view, single_component_view,
) )
@ -22,6 +26,10 @@ urlpatterns = [
path("js-order/js", check_js_order_in_js_view), path("js-order/js", check_js_order_in_js_view),
path("js-order/media", check_js_order_in_media_view), path("js-order/media", check_js_order_in_media_view),
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/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/head", alpine_in_head_view),
path("alpine/body", alpine_in_body_view), path("alpine/body", alpine_in_body_view),
path("alpine/body2", alpine_in_body_view_2), path("alpine/body2", alpine_in_body_view_2),

View file

@ -1,5 +1,6 @@
from django.http import HttpResponse from django.http import HttpResponse
from django.template import Context, Template from django.template import Context, Template
from testserver.components import FragComp, FragMedia
from django_components import render_dependencies, types from django_components import render_dependencies, types
@ -126,6 +127,136 @@ def check_js_order_vars_not_available_before_view(request):
return HttpResponse(rendered) 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): def alpine_in_head_view(request):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}

View file

@ -49,7 +49,7 @@ class InlineComponentTest(BaseTestCase):
rendered, rendered,
) )
self.assertInHTML( self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&#x27;HTML and JS only&#x27;);`))</script>", "<script>console.log('HTML and JS only');</script>",
rendered, rendered,
) )
@ -106,7 +106,7 @@ class ComponentMediaTests(BaseTestCase):
self.assertEqual(rendered.count("<style"), 0) self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<link"), 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): def test_css_js_as_lists(self):
class SimpleComponent(Component): 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('<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("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML( self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css 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('<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("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML( self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
self.assertEqual(rendered.count("<link"), 1) 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('<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("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML( self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
self.assertEqual(rendered.count("<link"), 1) 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('<link href="style.css" media="all" rel="stylesheet">', rendered_raw, count=0) # Media.css
self.assertInHTML( self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", '<script>console.log("xyz");</script>',
rendered_raw, rendered_raw,
count=0, count=0,
) # Inlined JS ) # 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('<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("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML( self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</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 href="style.css" media="all" rel="stylesheet">'), 1) # Media.css
self.assertEqual(rendered.count("<link"), 1) self.assertEqual(rendered.count("<link"), 1)
@ -234,7 +226,7 @@ class RenderDependenciesTests(BaseTestCase):
count=1, count=1,
) )
self.assertInHTML( self.assertInHTML(
"""<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>""", '<script>console.log("xyz");</script>',
rendered_body, rendered_body,
count=1, count=1,
) )
@ -286,7 +278,7 @@ class RenderDependenciesTests(BaseTestCase):
count=1, count=1,
) )
self.assertInHTML( self.assertInHTML(
"""<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>""", '<script>console.log("xyz");</script>',
rendered_head, rendered_head,
count=1, count=1,
) )
@ -401,6 +393,11 @@ class RenderDependenciesTests(BaseTestCase):
rendered_raw = Template(template_str).render(Context({"formset": [1]})) rendered_raw = Template(template_str).render(Context({"formset": [1]}))
rendered = render_dependencies(rendered_raw, type="fragment") 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 = """ 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 -->
@ -423,10 +420,49 @@ class RenderDependenciesTests(BaseTestCase):
</tr> </tr>
</tbody> </tbody>
</table> </table>
""" <script type="application/json" data-djc>
{"loadedCssUrls": [],
"loadedJsUrls": [],
"toLoadCssTags": ["PGxpbmsgaHJlZj0ic3R5bGUuY3NzIiBtZWRpYT0iYWxsIiByZWw9InN0eWxlc2hlZXQiPg==",
"PGxpbmsgaHJlZj0iL2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50XzMxMTA5Ny5jc3MiIG1lZGlhPSJhbGwiIHJlbD0ic3R5bGVzaGVldCI+"],
"toLoadJsTags": ["PHNjcmlwdCBzcmM9InNjcmlwdC5qcyI+PC9zY3JpcHQ+",
"PHNjcmlwdCBzcmM9Ii9jb21wb25lbnRzL2NhY2hlL1NpbXBsZUNvbXBvbmVudF8zMTEwOTcuanMiPjwvc2NyaXB0Pg=="]}
</script>
""" # noqa: E501
self.assertHTMLEqual(expected, rendered) 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): class MiddlewareTests(BaseTestCase):
def test_middleware_response_without_content_type(self): 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) self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
# Inlined JS # Inlined JS
self.assertInHTML( self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1)
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
)
# Inlined CSS # Inlined CSS
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1)
# Media.css # Media.css

View file

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

View file

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

View file

@ -297,6 +297,214 @@ class E2eDependencyRenderingTests(BaseTestCase):
await page.close() 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 @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"