Repository: plotly/dash

Issue:
[Feature Request] Import javascript files as modules
I am attempting to write some larger clientside callbacks that share logic between them. To keep the code clean, I am separating callbacks into separate .js files. For the callbacks, there is some common logic that I would like to keep in a third file e.g. common.js, that I would import as `import * as common from './common.js';`. 

Unfortunately, this causes an "Uncaught SyntaxError: Cannot use import statement outside a module" when the scripts are loaded in the default way i.e. by storing them in the assets folder.

The issue would be resolved if the scripts were loaded as modules, i.e. 
`<script type="module" src="/assets/foo.js></script>`
rather than
`<script src="/assets/foo.js></script>`.

This is currently possible to do for external scripts, since those allow you to provide a dict containing tag attributes. Would it be possible to enable something similar for scripts hosted in assets?

[Feature Request] Import javascript files as modules
I am attempting to write some larger clientside callbacks that share logic between them. To keep the code clean, I am separating callbacks into separate .js files. For the callbacks, there is some common logic that I would like to keep in a third file e.g. common.js, that I would import as `import * as common from './common.js';`. 

Unfortunately, this causes an "Uncaught SyntaxError: Cannot use import statement outside a module" when the scripts are loaded in the default way i.e. by storing them in the assets folder.

The issue would be resolved if the scripts were loaded as modules, i.e. 
`<script type="module" src="/assets/foo.js></script>`
rather than
`<script src="/assets/foo.js></script>`.

This is currently possible to do for external scripts, since those allow you to provide a dict containing tag attributes. Would it be possible to enable something similar for scripts hosted in assets?



Discussion:

Resolving patch:
diff --git a/dash/dash.py b/dash/dash.py
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -855,9 +855,20 @@ def _relative_url_path(relative_package_path="", namespace=""):
                 raise Exception("Serving files from absolute_path isn't supported yet")
             elif "asset_path" in resource:
                 static_url = self.get_asset_url(resource["asset_path"])
-                # Add a cache-busting query param
-                static_url += f"?m={resource['ts']}"
-                srcs.append(static_url)
+                # Import .mjs files with type=module script tag
+                if static_url.endswith(".mjs"):
+                    srcs.append(
+                        {
+                            "src": static_url
+                            + f"?m={resource['ts']}",  # Add a cache-busting query param
+                            "type": "module",
+                        }
+                    )
+                else:
+                    srcs.append(
+                        static_url + f"?m={resource['ts']}"
+                    )  # Add a cache-busting query param
+
         return srcs
 
     def _generate_css_dist_html(self):


Test patch:
diff --git a/tests/integration/clientside/assets/clientside.mjs b/tests/integration/clientside/assets/clientside.mjs
new file mode 100644
--- /dev/null
+++ b/tests/integration/clientside/assets/clientside.mjs
@@ -0,0 +1,6 @@
+import {display} from "./clientsideModule.mjs";
+
+window.dash_clientside = window.dash_clientside || {};
+window.dash_clientside.clientside_module = Object.assign({}, window.dash_clientside.clientside_module, {
+	display
+});
diff --git a/tests/integration/clientside/assets/clientsideModule.mjs b/tests/integration/clientside/assets/clientsideModule.mjs
new file mode 100644
--- /dev/null
+++ b/tests/integration/clientside/assets/clientsideModule.mjs
@@ -0,0 +1,3 @@
+const display = (value) => 'Client says "' + value + '"';
+
+export {display};
diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py
--- a/tests/integration/clientside/test_clientside.py
+++ b/tests/integration/clientside/test_clientside.py
@@ -868,3 +868,36 @@ def test_clsd020_clientside_callback_context_triggered_id(dash_duo):
     dash_duo.find_element("button[id*='btn1\":2']").click()
 
     dash_duo.wait_for_text_to_equal("#output-clientside", "2")
+
+
+def test_clsd021_simple_clientside_module_serverside_callback(dash_duo):
+    app = Dash(__name__, assets_folder="assets")
+
+    app.layout = html.Div(
+        [
+            dcc.Input(id="input"),
+            html.Div(id="output-clientside"),
+            html.Div(id="output-serverside"),
+        ]
+    )
+
+    @app.callback(Output("output-serverside", "children"), [Input("input", "value")])
+    def update_output(value):
+        return 'Server says "{}"'.format(value)
+
+    app.clientside_callback(
+        ClientsideFunction(namespace="clientside_module", function_name="display"),
+        Output("output-clientside", "children"),
+        [Input("input", "value")],
+    )
+
+    dash_duo.start_server(app)
+
+    assert dash_duo.find_element("body > footer > script[type=module]")
+
+    dash_duo.wait_for_text_to_equal("#output-serverside", 'Server says "None"')
+    dash_duo.wait_for_text_to_equal("#output-clientside", 'Client says "undefined"')
+
+    dash_duo.find_element("#input").send_keys("hello world")
+    dash_duo.wait_for_text_to_equal("#output-serverside", 'Server says "hello world"')
+    dash_duo.wait_for_text_to_equal("#output-clientside", 'Client says "hello world"')