1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148
|
commit 55170f27ecacdb0dc3afc300af2b899cd2cd7658
Author: Dash <dash@wind.garden>
Date: Sun Aug 17 17:45:12 2025 +0200
Defer rendering collected assets
Resolves https://github.com/jpsca/jinjax/issues/120.
diff --git a/src/jinjax/catalog.py b/src/jinjax/catalog.py
index 67d0050..fb6984e 100644
--- a/src/jinjax/catalog.py
+++ b/src/jinjax/catalog.py
@@ -155,6 +155,9 @@ class Catalog:
"use_cache",
"_cache",
"_key",
+ # placeholders for delayed asset injection
+ "_assets_placeholder",
+ "_emit_assets_later",
)
def __init__(
@@ -211,6 +214,9 @@ class Catalog:
self._cache: dict[str, dict] = {}
self._key = id(self)
+ # prepare delayed asset injection: placeholder and flag
+ self._assets_placeholder = f"@@jinjax_assets_{self._key}@@"
+ self._emit_assets_later = False
def __del__(self) -> None:
# Safely clean up context variables associated with this catalog
@@ -425,7 +431,11 @@ class Catalog:
self.collected_css = []
self.collected_js = []
self.tmpl_globals = kw.pop("_globals", kw.pop("__globals", None)) or {}
- return self.irender(__name, caller=caller, **kw)
+ out = self.irender(__name, caller=caller, **kw)
+ if self._emit_assets_later:
+ # inject full assets bundle in place of the placeholder
+ out = self._finalize_assets(out)
+ return out
def irender(
self,
@@ -564,16 +574,24 @@ class Catalog:
def render_assets(self) -> str:
"""
- Uses the `collected_css` and `collected_js` lists to generate
- an HTML fragment with `<link rel="stylesheet" href="{url}">`
- and `<script type="module" src="{url}"></script>` tags.
+ Placeholder for assets injection. During rendering this emits a
+ unique token; after the full template is rendered, the token is
+ replaced with all collected CSS/JS asset tags, regardless of
+ ordering in the template.
+ """
+ self._emit_assets_later = True
+ return self._assets_placeholder
+
+ def _format_collected_assets(self) -> Markup:
+ """
+ Internal helper to format collected_css and collected_js into
+ HTML <link> and <script> tags.
The URLs are prepended by `root_url` unless they begin with
"http://" or "https://".
"""
- html_css = []
- # Use a set to track rendered URLs to avoid duplicates
- rendered_urls = set()
+ html_css: list[str] = []
+ rendered_urls: set[str] = set()
for url in self.collected_css:
if not url.startswith(("http://", "https://")):
@@ -585,7 +603,7 @@ class Catalog:
html_css.append(f'<link rel="stylesheet" href="{full_url}">')
rendered_urls.add(full_url)
- html_js = []
+ html_js: list[str] = []
for url in self.collected_js:
if not url.startswith(("http://", "https://")):
full_url = f"{self.root_url}{url}"
@@ -598,6 +616,19 @@ class Catalog:
return Markup("\n".join(html_css + html_js))
+ def _finalize_assets(self, html: str) -> str:
+ """
+ Replace the placeholder token in the rendered HTML with the fully
+ formatted asset tags, then reset asset state for the next render.
+ """
+ # format assets fragment and reset state
+ assets_html = str(self._format_collected_assets())
+ self._emit_assets_later = False
+ self.collected_css = []
+ self.collected_js = []
+ # coerce to plain str before replace to avoid Markup.replace escaping
+ return str(html).replace(self._assets_placeholder, assets_html)
+
# Private
def _fingerprint(self, root: Path, filename: str) -> str:
diff --git a/tests/test_render_assets.py b/tests/test_render_assets.py
index d494aac..f8c3970 100644
--- a/tests/test_render_assets.py
+++ b/tests/test_render_assets.py
@@ -248,3 +248,38 @@ def test_auto_load_assets_for_kebab_cased_names(catalog, folder, autoescape, und
assert "/static/components/my-component.css" in html
assert "/static/components/my-component.js" in html
+
+
+@pytest.mark.parametrize("undefined", [jinja2.Undefined, jinja2.StrictUndefined])
+@pytest.mark.parametrize("autoescape", [True, False])
+def test_render_assets_before_component_usage_injects_assets(catalog, folder, autoescape, undefined):
+ """
+ Ensure that calling render_assets() before any component usage still
+ collects and renders the component assets, irrespective of ordering
+ in the template.
+ """
+ catalog.jinja_env.autoescape = autoescape
+ catalog.jinja_env.undefined = undefined
+
+ # Define a simple component with associated JS asset
+ (folder / "TestComponent.jinja").write_text("<div>Test</div>")
+ (folder / "TestComponent.js").touch()
+
+ # Template invokes render_assets() before using the component
+ (folder / "IssueExample.jinja").write_text(
+ """
+<!DOCTYPE html>
+<html>
+<head>
+ {{ catalog.render_assets() }}
+</head>
+<body>
+ <TestComponent />
+</body>
+</html>
+"""
+ )
+
+ html = catalog.render("IssueExample")
+ # Assets are injected even though render_assets() appears before the component
+ assert "/static/components/TestComponent.js" in html
|