diff --git a/CHANGELOG.md b/CHANGELOG.md index d752b883a1..6c59b95cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dash/dash-renderer/src/components/core/Reloader.react.js b/dash/dash-renderer/src/components/core/Reloader.react.js index 5149558d71..8dfc1073b3 100644 --- a/dash/dash-renderer/src/components/core/Reloader.react.js +++ b/dash/dash-renderer/src/components/core/Reloader.react.js @@ -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 = []; diff --git a/dash/dash.py b/dash/dash.py index f2cd2e4c3d..d632d71321 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -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: @@ -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"]) @@ -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 @@ -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: @@ -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) diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index 7ca8509389..df37ef1a50 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -1,4 +1,5 @@ import os +import re import sys import mock @@ -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 '', 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 == []