From 16b9f1033c5ec5c5e46fa92b1ba5eee5777fd447 Mon Sep 17 00:00:00 2001 From: Yashvardhan Thanvi Date: Sun, 1 Nov 2026 19:17:40 +0530 Subject: [PATCH 1/3] Only link .css files in assets as stylesheets The assets folder treated any file ending in "css" as a stylesheet, so .scss files were linked in the page head and hot-reloaded as CSS. Check for the .css extension in the initial walk and in hot reload. Fixes #1684. --- dash/dash.py | 8 ++++---- tests/unit/test_resources.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index f2cd2e4c3d..465b9cd67d 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -2033,7 +2033,7 @@ def _walk_assets_directory(self): self.scripts.append_script( self._add_assets_resource(path, full) ) - elif f.endswith("css"): + elif f.endswith(".css"): self.css.append_css(self._add_assets_resource(path, full)) # type: ignore[reportArgumentType] elif f == "favicon.ico": self._favicon = path @@ -2534,7 +2534,7 @@ def _on_assets_change(self, filename, modified, deleted): { "url": self.get_asset_url(asset_path), "modified": int(modified), - "is_css": filename.endswith("css"), + "is_css": filename.endswith(".css"), } ) @@ -2542,7 +2542,7 @@ def _on_assets_change(self, filename, modified, deleted): res = self._add_assets_resource(asset_path, filename) if filename.endswith("js"): self.scripts.append_script(res) - elif filename.endswith("css"): + elif filename.endswith(".css"): self.css.append_css(res) # type: ignore[reportArgumentType] if deleted: @@ -2561,7 +2561,7 @@ def delete_resource(resources): if filename.endswith("js"): # pylint: disable=protected-access delete_resource(self.scripts._resources._resources) - elif filename.endswith("css"): + elif filename.endswith(".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..c19a4fb4a1 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -286,3 +286,35 @@ 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_links_only_css_assets_as_stylesheets(tmp_path): + assets = tmp_path / "assets" + assets.mkdir() + (assets / "style.css").write_text("") + (assets / "theme.scss").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 "/assets/style.css?m=" in body + assert "theme.scss" not in body + + +def test_hot_reload_treats_only_css_assets_as_css(tmp_path): + assets = tmp_path / "assets" + assets.mkdir() + app = dash.Dash(__name__, assets_folder=str(assets)) + + for name in ("style.css", "theme.scss"): + path = assets / name + path.write_text("") + app._on_assets_change(str(path), path.stat().st_mtime, False) + + assert [ + (asset["url"].split("?")[0], asset["is_css"]) + for asset in app._hot_reload.changed_assets + ] == [("/assets/style.css", True), ("/assets/theme.scss", False)] + assert [r["asset_path"] for r in app.css._resources._resources] == ["style.css"] From 050adc72225d66b85e0274abdefa92256e5be8ab Mon Sep 17 00:00:00 2001 From: Yashvardhan Thanvi Date: Sun, 1 Nov 2026 19:18:12 +0530 Subject: [PATCH 2/3] Add CHANGELOG entry for #4105 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d752b883a1..93fad1b86a 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) Only link `.css` files in the assets folder as stylesheets, so `.scss` and other names ending in "css" are no longer linked or hot-reloaded as CSS. Fixes [#1684](https://github.com/plotly/dash/issues/1684). + ## [4.5.0] - 2026-10-29 ### Added From ff7e2936019b1843658b409dc2ed4341923b80bd Mon Sep 17 00:00:00 2001 From: Yashvardhan Thanvi Date: Tue, 3 Nov 2026 21:38:27 +0530 Subject: [PATCH 3/3] Load assets by extension through one helper _asset_type() maps a file's extension (.js, .mjs, .css) to how Dash loads it. The assets walk, the .mjs check at render time and both hot reload branches use it, so files like nodejs or bundle.cjs are no longer added as scripts. The hot reload payload now sends that type instead of is_css, and the renderer checks for type 'css'. --- CHANGELOG.md | 2 +- .../src/components/core/Reloader.react.js | 2 +- dash/dash.py | 24 +++++++---- tests/unit/test_resources.py | 41 ++++++++++++++----- 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fad1b86a..6c59b95cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Fixed -- [#4105](https://github.com/plotly/dash/pull/4105) Only link `.css` files in the assets folder as stylesheets, so `.scss` and other names ending in "css" are no longer linked or hot-reloaded as CSS. Fixes [#1684](https://github.com/plotly/dash/issues/1684). +- [#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 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 465b9cd67d..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 c19a4fb4a1..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 @@ -288,33 +289,53 @@ def test_index_with_windows_extended_length_paths(tmp_path, monkeypatch): assert "/assets/icons/favicon.ico?m=" in body -def test_index_links_only_css_assets_as_stylesheets(tmp_path): +def test_index_loads_assets_by_extension(tmp_path): assets = tmp_path / "assets" assets.mkdir() - (assets / "style.css").write_text("") - (assets / "theme.scss").write_text("") + 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 "/assets/style.css?m=" in body - assert "theme.scss" not in body + assert '', body) + for name in ("theme.scss", "bundle.cjs", "/assets/nodejs"): + assert name not in body -def test_hot_reload_treats_only_css_assets_as_css(tmp_path): +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"): + 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"].split("?")[0], asset["is_css"]) - for asset in app._hot_reload.changed_assets - ] == [("/assets/style.css", True), ("/assets/theme.scss", False)] + (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 == []