How to feature-detect whether a browser supports dynamic ES6 module loading?
Asked Answered
T

7

8

Background

The JavaScript ES6 specification supports module imports aka ES6 modules.

The static imports are quite obvious to use and do already have quite a good browser support, but dynamic import is still lacking behind.
So it is reasonably possible that your code uses static modules (when these would be not supported the code would not even execute), but the browser may miss support for dynamic import. Thus, detecting whether dynamic loading works (before trying to actually load code) may be useful. And as browser detection is frowned upon, of course I'd like to use feature detection.

Use cases may be to show an error, fallback to some other data/default/loading algorithm, provide developers with the advantages of dynamic loading (of data e.g. in a lazy-mode style) in a module, while allowing a fallback to passing the data directly etc. Basically, all the usual use cases where feature detection may be used.

Problem

Now, as dynamic modules are imported with import('/modules/my-module.js') one would obviously try to just detect whether the function is there, like this:

// this code does NOT work
if (import) {
    console.log("dynamic import supported")
}

I guess, for every(?) other function this would work, but the problem seems to be: As import is, respectively was, a reserved keyword in ECMAScript, and is now obviously also used for indicating the static import, it is not a real function. As MDN says it, it is "function-like".

Tries

import() results in a syntax error, so this is not really usable and import("") results in a Promise that rejects, which may be useful, but looks really hackish/like a workaround. Also, it requires an async context (await etc.) just for feature-detecting, which is not really nice.
typeeof import also fails directly, causing a syntax error, because of the keyword ("unexpected token: keyword 'import'").

So what is the best way to reliably feature-detect that a browser does support dynamic ES6 modules?

Edit: As I see some answers, please note that the solution should of course be as generally usable as possible, i.e. e.g. CSPs may prevent the use of eval and in PWAs you shall not assume you are always online, so just trying a request for some abitrary file may cause incorrect results.

Thibault answered 20/2, 2020 at 9:58 Comment(0)
W
1

My own solution, it requires 1 extra request but without globals, without eval, and strict CSP compliant:

Into your HTML

<script type="module">
import('./isDynamic.js')
</script>
<script src="/main.js" type="module"></script>

isDynamic.js

let value

export const then = () => (value = value === Boolean(value))

main.js

import { then } from './isDynamic.js'

console.log(then())

Alternative

Without extra request, nor eval/globals (of course), just needing the DataURI support:

<script type="module">
import('data:text/javascript,let value;export const then = () => (value = value === Boolean(value))')
</script>
<script src="/main.js" type="module"></script>
import { then } from 'data:text/javascript,let value;export const then = () => (value = value === Boolean(value))'

console.log(then())

How it works? Pretty simple, since it's the same URL, called twice, it only invokes the dynamic module once... and since the dynamic import resolves the thenable objects, it resolves the then() call alone.

Thanks to Guy Bedford for his idea about the { then } export

Wiretap answered 3/1, 2022 at 20:20 Comment(0)
B
8

The following code detects dynamic import support without false positives. The function actually loads a valid module from a data uri (so that it works even when offline).

The function hasDynamicImport returns a Promise, hence it requires either native or polyfilled Promise support. On the other hand, dynamic import returns a Promise. Hence there is no point in checking for dynamic import support, if Promise is not supported.

function hasDynamicImport() {
  try {
    return new Function("return import('data:text/javascript;base64,Cg==').then(r => true)")();
  } catch(e) {
    return Promise.resolve(false);
  }
}

hasDynamicImport()
  .then((support) => console.log('Dynamic import is ' + (support ? '' : 'not ') + 'supported'))
  • Advantage - No false positives
  • Disadvantage - Uses eval

This has been tested on latest Chrome, Chrome 62 and IE 11 (with polyfilled Promise).

Barbera answered 29/2, 2020 at 9:48 Comment(5)
Note Edge 41.16299.15.0 (EdgeHTML 16) accepts the import() syntax, fooling the eval or Function constructor detection methods!Chrysanthemum
@Chrysanthemum It may accept the the import statement, but unless it returns a Promise,(which I believe it doesn't), the above code will still work correctly. So, in EdgeHTML16, it should throw an exception due to the .then chain, which will be caught by the catch block.Barbera
You are also feature-detecting arrow-functions (it's supposed to detect import only). Also, it's a promise. Can't be used with sync code.Sweeney
It is trivial to rewrite the code with normal callback function, instead of arrow function. The lack of sync support is a disadvantage, but dynamic import itself is async. I use babel to re-write import(url) to my_custom_import(url). my_custom_import function will perform the feature detection and load ES6 version of the module with import, or ES5 version of the module with AMD.Barbera
Throws an unhandled exception in Firefox worker context. Should return false.Larrainelarrie
K
4

Three ways come to mind, all relying on getting a syntax error using import():

  • In eval (but runs afoul some CSPs)
  • Inserting a script tag
  • Using multiple static script tags

Common bits

You have the import() use foo or some such. That's an invalid module specifier unless it's in your import map, so shouldn't cause a network request. Use a catch handler to catch the load error, and a try/catch around the import() "call" just to catch any synchronous errors regarding the module specifier, to avoid cluttering up your error console. Note that on browsers that don't support it, I don't think you can avoid the syntax error in the console (at least, window.onerror didn't for me on Legacy Edge).

With eval

...since eval isn't necessarily evil; e.g., if guaranteed to use your own content (but, again, CSPs may limit):

let supported = false;
try {
    eval("try { import('foo').catch(() => {}); } catch (e) { }");
    supported = true;
} catch (e) {
}
document.body.insertAdjacentHTML("beforeend", `Supported: ${supported}`);

Inserting a script

let supported = false;
const script = document.createElement("script");
script.textContent = "try { import('foo').catch(() => { }); } catch (e) { } supported = true;";
document.body.appendChild(script);
document.body.insertAdjacentHTML("beforeend", `Supported: ${supported}`);

Using multiple static script tags

<script>
let supported = false;
</script>
<script>
try {
    import("foo").catch(() => {});
} catch (e) {
}
supported = true;
</script>
<script>
document.body.insertAdjacentHTML("beforeend", `Supported: ${supported}`);
</script>

Those all silently report true on Chrome, Chromium Edge, Firefox, etc.; and false on Legacy Edge (with a syntax error).

Karlenekarlens answered 20/2, 2020 at 10:3 Comment(6)
Okay, so this has the disadvantage of doing a network request, anyway, (that takes time/causes latency; what happens if the server is temporarily offline in PWA contexts e.g.?) and it also uses eval, which we know is evil.Thibault
@Thibault - I can't think of a way to do it without the network request (a blob maybe?). eval is not evil when you control the content, that's just common hyperbole. :-)Karlenekarlens
Eval is simply not possible e.g. for websites that do use a CSP. Anyway, arguing does not matter, it's certainly a disadvantage.Thibault
@Thibault - Okay granted about some CSPs. :) The second approach doesn't use eval, and I found a way around the network request (note that the request didn't cause any delays or latency, though; it runs in parallel to the detection; detection is immediate). Updated the answer.Karlenekarlens
Well, what you do there is effectively bypassing the CSPs "unsafe-eval" restriction... 🤔Thibault
@Thibault - Okay, you can do it with three script tags. (Updated.) Or are you going to add a further restriction?Karlenekarlens
T
2

During more research I've found this gist script, with this essential piece of JS for dynamic feature detection:

function supportsDynamicImport() {
  try {
    new Function('import("")');
    return true;
  } catch (err) {
    return false;
  }
}
document.body.textContent = `supports dynamic loading: ${supportsDynamicImport()}`;

All credit for this goes to @ebidel from GitHub!

Anyway, this has two problems:

  • it uses eval, which is evil, especially for websites that use a CSP.
  • according to the comments in the gist, it does have false-positives in (some version of) Chrome and Edge. (i.e. it returns true altghough these browser do not actually support it)
Thibault answered 20/2, 2020 at 10:7 Comment(3)
That doesn't seem right, correct me if I am wrong, but that will always return true, I mean try renaming "import" to "foobar" and it will still return true, even though "foobar" doesn't exist. It doesn't actually execute the script, does it?Pedagogue
I was wrong, apparently import is a reserved keyword on the browsers that doesn't support them and would in fact throw, indicating that it's not available.Pedagogue
Incorrectly returns true in Firefox worker context.Larrainelarrie
S
2

You can do this synchronously, without promise.
I just tested in Chrome, Firefox, and IE 11,10,9,8, 7 and 5.
It works like this:

function isImportSupported()
{
    var supported = false; // do NOT use let
    try 
    {
        // No arrow-functions here either ...
        new Function("try { import('data:text/javascript;base64,Cg==').catch(function() {}); } catch (e) { }")();
        
        supported = true;
    } 
    catch (e) { }
    
    return supported;
}

Note

Apparently, this doesn't work in Firefox worker process, probably because import throws an error at runtime.

To check this, you actually would have to call the function with await in an async function (fun fact, if you use async, you will break ipad and internet exploder):

async function isImportSupported()
{
    var AsyncFunction, fn1, fn2;

    try
    {
        // https://davidwalsh.name/async-function-class
        // const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
        // const fetchPage = new AsyncFunction("url", "return await fetch(url);");
        AsyncFunction = new Function("return Object.getPrototypeOf(async function () { }).constructor;")
    }
    catch (err)
    {
        return false;
    }

    try
    {
        fn1 = new Function("try { import('data:text/javascript;base64,Cg==').catch(function() {}); return true; } catch (e) { }; return false; ");
        fn2 = new AsyncFunction("try { await import('data:text/javascript;base64,Cg=='); return true; } catch (e) { }; return false; ");
        fn1();
        fn2();
        // all the above, you can do synchronously. 
        // But this line can only be done in an async function
        await fn2();
    }
    catch (err)
    {
        return false;
    }

    return true;
}


await isImportSupported()

Here's the typescript-definition of IAsyncFunction and IAsyncFunctionConstructor, in case anybody wants it

interface IAsyncFunction
{
    (...args: any[]): Promise<any>;

    apply(this: IAsyncFunction, thisArg: any, argArray?: any): Promise<any>;
    call(this: IAsyncFunction, thisArg: any, ...argArray: any[]): Promise<any>;
    bind(this: IAsyncFunction, thisArg: any, ...argArray: any[]): IAsyncFunction;
    toString(): string;
    prototype: Promise<any>;
    readonly length: number;
    // Non-standard extensions
    arguments: any;
    caller: IAsyncFunction;
}

interface IAsyncFunctionConstructor
{
    new(...args: string[]): IAsyncFunction;
    (...args: string[]): IAsyncFunction;
    readonly prototype: IAsyncFunction;
}

The simple truth is, if you want to support those old browsers, don't use ecma modules, and write your own import function.

However, since old browsers don't support promises or async-await, you'll have to polyfill all that, and transpile down to ES5.

My advice is to give up on old browsers and not do anything like this, unless the effort is worth the investment of time (e.g. if you are amazon). If you are not amazon, simply tell your clients to use a recent browser. You can block old browsers at the middleware level (e.g. a "sorry your browser is no longer supported" page).

The firefox problem is probably an import implementation along the lines of:

function workerContextImport(data) 
{
    return new Promise(
        function (resolve, reject) 
        {
            let wait = setTimeout(function ()
            {
                clearTimeout(wait);
                if(workerContext)
                    reject(new Error("Not allowed in worker context!"));
                else 
                    resolve(data);
            }, 10);
        });
}

Here's transpiled ES5 code btw:

var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
var __generator = (this && this.__generator) || function (thisArg, body) {
    var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
    return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
    function verb(n) { return function (v) { return step([n, v]); }; }
    function step(op) {
        if (f) throw new TypeError("Generator is already executing.");
        while (_) try {
            if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
            if (y = 0, t) op = [op[0] & 2, t.value];
            switch (op[0]) {
                case 0: case 1: t = op; break;
                case 4: _.label++; return { value: op[1], done: false };
                case 5: _.label++; y = op[1]; op = [0]; continue;
                case 7: op = _.ops.pop(); _.trys.pop(); continue;
                default:
                    if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
                    if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
                    if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
                    if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
                    if (t[2]) _.ops.pop();
                    _.trys.pop(); continue;
            }
            op = body.call(thisArg, _);
        } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
        if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
    }
};

function isImportSupported() {
    return __awaiter(this, void 0, void 0, function () {
        var AsyncFunction, fn1, fn2, err_1;
        return __generator(this, function (_a) {
            switch (_a.label) {
                case 0:
                    try {
                        AsyncFunction = new Function("return Object.getPrototypeOf(async function () { }).constructor;");
                    }
                    catch (err) {
                        return [2, false];
                    }
                    _a.label = 1;
                case 1:
                    _a.trys.push([1, 3, , 4]);
                    fn1 = new Function("try { import('data:text/javascript;base64,Cg==').catch(function() {}); return true; } catch (e) { }; return false; ");
                    fn2 = new AsyncFunction("try { await import('data:text/javascript;base64,Cg=='); return true; } catch (e) { }; return false; ");
                    fn1();
                    fn2();
                    return [4, fn2()];
                case 2:
                    _a.sent();
                    return [3, 4];
                case 3:
                    err_1 = _a.sent();
                    return [2, false];
                case 4: return [2, true];
            }
        });
    });
}
Sweeney answered 8/7, 2022 at 8:7 Comment(6)
Incorrectly returns true in Firefox Worker context.Larrainelarrie
@LoneSpawn: Wrong, correctly returns true, it's just that firefox is buggy. github.com/mdn/browser-compat-data/issues/18506 bugzilla.mozilla.org/show_bug.cgi?id=1540913Sweeney
How is true correct when Firefox DOES NOT support import in workers? Your logic circuit is broken as is your code. Your own link confirms your code gives the incorrect result. If you actually tried your code in a Firefox Worker, like I did, you would see you code does not work correctly. Firefox does NOT support import in Workers, your code returns true in a Firefox worker, therefore your code is flawed.Larrainelarrie
@LoneSpawn; Yea exactly, if Firefox wasn't buggy, it would return false. But it is buggy, so it returns true. The effect of a bug in the JS engine is that the JS does not return the correct values. It's not a bug in my code, though. The problem that the result is incorrect, on the other hand, will be the problem of anyone who uses that function. When a javascript engine returns false for 1===1, then it's not the code who's at fault. However, this will of course trigger malfunctions in your code.Sweeney
@LoneSpawn; Updated to check the firefox worker. However, now it has to use the async keyword, and this breaks InternetExploder and old versions of Safari. Pick your poison.Sweeney
@LoneSpawn; The problem is probably that import only returns an error on a call to import (=at runtime), not on declaration in syntax. Updated the code to check the firefox worker. However, now it has to use the async keyword, and this breaks InternetExploder and old versions of Safari. So you see, pick your poison. You can't check if an async function is rejecting the promise without awaiting the promise result, which cannot be done synchronously. This is wrong. If it doesn't support import, it should throw already once it is used in syntax in a worker instead of just rejecting the promise.Sweeney
W
1

My own solution, it requires 1 extra request but without globals, without eval, and strict CSP compliant:

Into your HTML

<script type="module">
import('./isDynamic.js')
</script>
<script src="/main.js" type="module"></script>

isDynamic.js

let value

export const then = () => (value = value === Boolean(value))

main.js

import { then } from './isDynamic.js'

console.log(then())

Alternative

Without extra request, nor eval/globals (of course), just needing the DataURI support:

<script type="module">
import('data:text/javascript,let value;export const then = () => (value = value === Boolean(value))')
</script>
<script src="/main.js" type="module"></script>
import { then } from 'data:text/javascript,let value;export const then = () => (value = value === Boolean(value))'

console.log(then())

How it works? Pretty simple, since it's the same URL, called twice, it only invokes the dynamic module once... and since the dynamic import resolves the thenable objects, it resolves the then() call alone.

Thanks to Guy Bedford for his idea about the { then } export

Wiretap answered 3/1, 2022 at 20:20 Comment(0)
L
0

Firefox, and possibly some other browsers, do not support import in Workers and SharedWorkers. I needed to detect import support in workers to determine if I needed to on the fly patch some Javascript libraries (not mine) before I loaded them.

None of the existing answers worked correctly in a Firefox Worker. The below version works as expected.

async function hasDynamicImport() {
    try {
        await import('data:text/javascript;base64,Cg==');
        return true;
    } catch (e) {
        return false;
    }
}

Thank you Joyce Babu for pointing me in the right direction.

I am using this for a Blazor WASM module. I can assume async is supported and I do not need to support IE as it does not support WASM anyways.

Larrainelarrie answered 21/4, 2023 at 15:29 Comment(0)
S
-2

How about loading your JS within a type='module' script, otherwise load the next script with a nomodule attribute, like so:

<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

Browsers that understand type="module" ignore scripts with a nomodule attribute.

Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import & https://v8.dev/features/modules#browser

Soar answered 2/4, 2020 at 0:35 Comment(3)
This does detect whether the browser supports modules at all. However, the question was about dynamic modules, i.e. these imported via import. Static module support can be checked in the way you do, that's easy. And as you can see in the linked MDN doc, the browser support for static vs dynamic modules varies a lot, partially…Thibault
Correct, the idea was that if modules are supported then support for static AND dynamic imports can be checked using type="module" because both features came to be supported at relatively the same time, here is an example with dynamic imports: v8.dev/features/dynamic-import#dynamicSoar
@beto Not sure that's valid. Some browsers support ESModules, but do not support dynamic import. Safari 10.1 (10.3 on iOS) support module but does not support ESModules. This is a poor way of distinguishing between these two types OP is looking for.Pedagogue

© 2022 - 2024 — McMap. All rights reserved.