Add content hash to JS includes (closes #2714)

The CSS link in base.html already carries `?{{ app_css_hash }}` so that
browsers refetch when the bundled file changes. The five first-party JS
files shipped with datasette did not. Cache-busting JS the same way
matches the existing CSS pattern and uses the static_hash() helper that
already powers app_css_hash().

Files updated:
- datasette/app.py: expose static_hash as a callable in template context.
- datasette/handle_exception.py: include static_hash in the error-page
  template context (mirrors the existing app_css_hash entry there).
- datasette/templates/base.html: hash datasette-manager.js and
  navigation-search.js.
- datasette/templates/table.html: hash column-chooser.js, table.js, and
  mobile-column-actions.js.
- tests/test_html.py: new test_js_content_hash parametrized across all
  five files; existing test_navigation_menu_links updated to expect the
  new query string.

Vendored libraries (cm-editor-6.0.1.bundle.js, sql-formatter-2.3.3.min.js,
json-format-highlight-1.0.1.js) already carry a version in the filename
and were left unchanged.
This commit is contained in:
Matt Van Horn 2026-05-26 00:14:15 -07:00
commit 5f83d94119
No known key found for this signature in database
5 changed files with 30 additions and 6 deletions

View file

@ -2052,6 +2052,7 @@ class Datasette:
and "ds_actor" in request.cookies
and request.actor,
"app_css_hash": self.app_css_hash(),
"static_hash": self.static_hash,
"zip": zip,
"body_scripts": body_scripts,
"format_bytes": format_bytes,

View file

@ -67,6 +67,7 @@ def handle_exception(datasette, request, exception):
info,
urls=datasette.urls,
app_css_hash=datasette.app_css_hash(),
static_hash=datasette.static_hash,
menu_links=lambda: [],
)
),

View file

@ -8,7 +8,7 @@
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
<script src="{{ urls.static('datasette-manager.js') }}?{{ static_hash('datasette-manager.js') }}" defer></script>
{% for url in extra_js_urls %}
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
{% endfor %}
@ -70,7 +70,7 @@
{% endfor %}
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
<script src="{{ urls.static('navigation-search.js') }}?{{ static_hash('navigation-search.js') }}" defer></script>
<navigation-search url="/-/jump"></navigation-search>
</body>
</html>

View file

@ -4,9 +4,9 @@
{% block extra_head %}
{{- super() -}}
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
<script src="{{ urls.static('table.js') }}" defer></script>
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
<script src="{{ urls.static('column-chooser.js') }}?{{ static_hash('column-chooser.js') }}" defer></script>
<script src="{{ urls.static('table.js') }}?{{ static_hash('table.js') }}" defer></script>
<script src="{{ urls.static('mobile-column-actions.js') }}?{{ static_hash('mobile-column-actions.js') }}" defer></script>
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
<style>
@media only screen and (max-width: 576px) {

View file

@ -604,6 +604,26 @@ async def test_404(ds_client, path):
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"filename,template_path",
[
("datasette-manager.js", "/"),
("navigation-search.js", "/"),
("table.js", "/fixtures/facetable"),
("column-chooser.js", "/fixtures/facetable"),
("mobile-column-actions.js", "/fixtures/facetable"),
],
)
async def test_js_content_hash(ds_client, filename, template_path):
response = await ds_client.get(template_path)
assert response.status_code == 200
expected = (
f'<script src="/-/static/{filename}?{ds_client.ds.static_hash(filename)}"'
)
assert expected in response.text
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,expected_redirect",
@ -1038,7 +1058,9 @@ async def test_navigation_menu_links(
navigation_search_script = soup.find(
"script", {"src": re.compile(r"navigation-search\.js")}
)
assert navigation_search_script["src"] == "/-/static/navigation-search.js"
assert navigation_search_script["src"] == (
f"/-/static/navigation-search.js?{ds_client.ds.static_hash('navigation-search.js')}"
)
assert details.find("li").find("button") == search_button
if not actor_id:
# The app menu is always visible, but anonymous users do not see logout