Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
All notable changes to `dash` will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed
- [#4105](https://github.com/plotly/dash/pull/4105) Load files in the assets folder by their extension: only `.js` and `.mjs` files as scripts and only `.css` files as stylesheets, at startup and on hot reload. Files such as `theme.scss` or `bundle.cjs` are no longer loaded as CSS or JavaScript. Fixes [#1684](https://github.com/plotly/dash/issues/1684).

## [4.5.0] - 2026-10-29

### Added
Expand Down
2 changes: 1 addition & 1 deletion dash/dash-renderer/src/components/core/Reloader.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ class Reloader extends React.Component {
let was_css = false;
// eslint-disable-next-line prefer-const
for (let a of reloadRequest.content.files) {
if (a.is_css) {
if (a.type === 'css') {
was_css = true;
const nodesToDisable = [];

Expand Down
24 changes: 16 additions & 8 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@
except: # noqa: E722
page_container = None

_ASSET_TYPES = {".js": "js", ".mjs": "mjs", ".css": "css"}


def _asset_type(filename):
return _ASSET_TYPES.get(os.path.splitext(filename)[1])


def _get_traceback(secret, error: Exception):
try:
Expand Down Expand Up @@ -1428,7 +1434,7 @@ def _relative_url_path(relative_package_path="", namespace=""):
static_url = self.get_asset_url(resource["asset_path"])
url_with_cache = static_url + f"?m={resource['ts']}"
# Import .mjs files with type=module script tag
if static_url.endswith(".mjs"):
if _asset_type(resource["asset_path"]) == "mjs":
attrs = {url_attr: url_with_cache, "type": "module"}
if "attributes" in resource:
attrs.update(resource["attributes"])
Expand Down Expand Up @@ -2028,12 +2034,13 @@ def _walk_assets_directory(self):
path = "/".join([base, f]) if base else f

full = os.path.join(current, f)
asset_type = _asset_type(f)

if f.endswith("js"):
if asset_type in ("js", "mjs"):
self.scripts.append_script(
self._add_assets_resource(path, full)
)
elif f.endswith("css"):
elif asset_type == "css":
self.css.append_css(self._add_assets_resource(path, full)) # type: ignore[reportArgumentType]
elif f == "favicon.ico":
self._favicon = path
Expand Down Expand Up @@ -2529,20 +2536,21 @@ def _on_assets_change(self, filename, modified, deleted):
.replace("\\", "/")
.lstrip("/")
)
asset_type = _asset_type(filename)

_reload.changed_assets.append(
{
"url": self.get_asset_url(asset_path),
"modified": int(modified),
"is_css": filename.endswith("css"),
"type": asset_type,
}
)

if filename not in self._assets_files and not deleted:
res = self._add_assets_resource(asset_path, filename)
if filename.endswith("js"):
if asset_type in ("js", "mjs"):
self.scripts.append_script(res)
elif filename.endswith("css"):
elif asset_type == "css":
self.css.append_css(res) # type: ignore[reportArgumentType]

if deleted:
Expand All @@ -2558,10 +2566,10 @@ def delete_resource(resources):
if to_delete:
resources.remove(to_delete)

if filename.endswith("js"):
if asset_type in ("js", "mjs"):
# pylint: disable=protected-access
delete_resource(self.scripts._resources._resources)
elif filename.endswith("css"):
elif asset_type == "css":
# pylint: disable=protected-access
delete_resource(self.css._resources._resources)

Expand Down
53 changes: 53 additions & 0 deletions tests/unit/test_resources.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import re
import sys

import mock
Expand Down Expand Up @@ -286,3 +287,55 @@ def test_index_with_windows_extended_length_paths(tmp_path, monkeypatch):
assert "/_dash-component-suites/dash/deps/polyfill@" in body
assert "dash-stream-worker.v" in body
assert "/assets/icons/favicon.ico?m=" in body


def test_index_loads_assets_by_extension(tmp_path):
assets = tmp_path / "assets"
assets.mkdir()
for name in (
"style.css",
"theme.scss",
"app.js",
"app.mjs",
"bundle.cjs",
"nodejs",
):
(assets / name).write_text("")

app = dash.Dash(__name__, assets_folder=str(assets))
app.layout = html.Div()

body = app.server.test_client().get("/").get_data(as_text=True)

assert '<link rel="stylesheet" href="/assets/style.css?m=' in body
assert '<script src="/assets/app.js?m=' in body
assert re.search(r'<script src="/assets/app\.mjs\?m=[^"]*" type="module">', body)
for name in ("theme.scss", "bundle.cjs", "/assets/nodejs"):
assert name not in body


def test_hot_reload_uses_asset_type(tmp_path):
assets = tmp_path / "assets"
assets.mkdir()
app = dash.Dash(__name__, assets_folder=str(assets))

for name in ("style.css", "theme.scss", "app.mjs", "bundle.cjs"):
path = assets / name
path.write_text("")
app._on_assets_change(str(path), path.stat().st_mtime, False)

assert [
(asset["url"], asset["type"]) for asset in app._hot_reload.changed_assets
] == [
("/assets/style.css", "css"),
("/assets/theme.scss", None),
("/assets/app.mjs", "mjs"),
("/assets/bundle.cjs", None),
]
assert [r["asset_path"] for r in app.css._resources._resources] == ["style.css"]
assert [r["asset_path"] for r in app.scripts._resources._resources] == ["app.mjs"]

for name in ("style.css", "app.mjs"):
app._on_assets_change(str(assets / name), 0, True)
assert app.css._resources._resources == []
assert app.scripts._resources._resources == []