ES Module Shims brings some new ES Module Shims features together along with hot reloading on a standards-compatible browser workflow. The CSS, JSON and TypeScript module import features from 2.0 are now automatically enabled by default without needing separate configurations, and we also have a brand new Import Defer polyfill for the upcoming TC39 proposal being implemented.

Raising the Baseline

One of the major features of ES Module Shims is to try and not do anything at all in modern browsers. We achieve this by defining baseline feature support and then feature detecting these baseline features. When all baseline features are supported, we don't need to do any further analysis of the modules on the page as we know the browser supports the features we are targetting.

ES Module Shims has now added CSS Modules and JSON Modules to its baseline support. These features have 75% and 85% browser adoption respectively, making them finally ready for baseline inclusion.

I've been writing more web applications using native CSS Module imports and it's actually a really great modular CSS dev experience being able to include styles from module scripts:


import style from './style.css' with { type: 'css' };
document.adoptedStyleSheets.push(style);

// ...component code...

Zero Configuration TypeScript

In ES Module Shims 2.5, TypeScript is now enabled automatically, so that no configuration is required to load native TypeScript apps in the browser for simple development workflows.

While not formally a baseline feature, since importing TypeScript will always break (not due to syntax, but due to MIME type), we can support TypeScript just like a baseline feature. In order to avoid fetching modules to analyze them, TypeScript is hinted with the lang="ts" attribute on the top-level module script. When TypeScript is found, the Amaro type stripping build of SWC will be loaded dynamically. We now also output a console warning that TypeScript is being type stripped in the browser to help avoid any mistakes where this gets into production code.

Hot Reloading

Comprehensive hot reloading is now supported in both Shim Mode and Polyfill Mode when enabled, allowing modules to be selectively reloaded without reloading the entire page:


<script type="esms-options">
{
  "hotReload": true
}
</script>

When enabled, modules will be loaded with the import.meta.hot object available exactly following Vite's hot reloading API.

To initiate a reload of a module, call the importShim.hotReload(url) API. Calling it many times will batch updates together to an interval set by the hotReloadInterval configuration option.

When hot reloading requires a module to be reinstanced, ES Module Shims will append a `?v=2` query parameter suffix to the fetch request and module registry import. Short of a little module graph work, it really is that simple.

Reload-based hot reloading is automatically enabled for all module types: CSS module imports, JSON module imports and TypeScript.

Here's a full end to end example application from the 2.0 release post running with zero configuration with hot reloading:

 

index.html

<!doctype html>

<!-- Load ES Module Shims from your CDN of choice -->
<script async src="https://ga.jspm.io/npm:es-module-shims@2.5.0/dist/es-module-shims.js"></script>

<!-- Enable hot reloading -->
<script type="esms-options">{ "hotReload": true }</script>

<!-- Set dependencies in import map -->
<script type="importmap">
  {
    "imports": {
      "vue": "https://ga.jspm.io/npm:vue@3.5.13/dist/vue.esm-browser.prod.js"
    }
  }
</script>
 
<!-- ES Module Shims will find this and handle the rest -->
<script type="module" lang="ts" src="app.ts"></script>

<div id="app"></div>

App can be defined to support hot reloading using standard Vue techniques:


import { createApp } from 'vue';
import UserCard, { type User } from './user-card.ts';

let app;
export function initApp () {
  app = createApp({
    setup() {
      const users: User[] = [
        { name: 'Alice', age: 35 },
        { name:'Bob', age: 30 }
      ];
      return { users };
    },
    template: `<user-card v-for="user in users" :key="user.name" :user="user" />`
  });
  app.component('user-card', UserCard);
  return app;
}

if (!import.meta.hot?.data?.loaded) {
  app = initApp();
  app.mount('#app');
}

if (import.meta.hot) {
  import.meta.hot.data.loaded = true;
  import.meta.hot.accept((newApp) => {
    app.unmount();
    app = newApp.initApp();
    app.mount('#app');
  });
}

Any event source can then be attached to drive change requests via calls like importShim.hotReload('./app.ts') or importShim.hotReload('./user-card.css').

The full example is available here.

Import Defer

If you're not aware of the Import Defer proposal, it fills a needed gap from what CommonJS modules provide, namely lazy module initialization. It's a common pattern in Node.js applications to move require() statements into functions when wanting to reduce startup time:

CommonJS without Lazy Initialization

const foo = require('foo');

module.exports = function bar() {
  return foo();
}

CommonJS with Lazy Initialization

module.exports = function bar() {
  const foo = require('foo');
  return foo();
}

With the change in CommonJS, if you never use the function bar(), then you never have to pay the loading cost for importing foo(). But when it comes to ES Modules, we don't have a synchronous import (well, not yet) so we can't do the same thing:


import foo from 'foo';

export default function bar() {
  return foo();
}
          

With import defer we have the ability to do something similar for ES Modules:


import defer * as fooDeferred from 'foo';

export default function bar() {
  return fooDeferred.foo();
}

In the above, the module namespace fooDeferred is a new Deferred Module Namespace, which represents a top-level link (fetching and resolving all dependencies) of the imported graph, but without the usual top-level evaluation step. Object access on the deferred namespace (fooDeferred.foo) is what causes the lazy top-level execution, which can then be encapsulated synchronously inside of the bar() function, provided there is no top-level await, achieving a similar initialization savings.

Import Defer Polyfill

ES Module Shims now fully supports stripping defer syntax, although this is not part of the baseline yet so must be enabled via:


<script type="esms-options">
{
  "polyfillEnable": ["import-defer"]
}
</script>

There's a viable polyfill path here though, made possible since as a syntax feature it will statically break module loads in browsers that don't support it. This exactly fits the defininition of the ES Module Shims polyfill semantics, in that we only polyfill modules that statically break in the native loader, so that native applications can take advantage of the performance benefits, and the polyfill the breaking cases to ensure that those native modules still work in older browsers that don't support the syntax (even if they don't get real lazy loading).

When the feature reaches above 50% support, we'll likely re-examine including this feature in the baseline then to allow shipping production applications using import defer syntax.

Supporting Native Modules Workflows

ES Module Shims is growing to be more than just an import maps polyfill, but an opportunity to continue follow the module features polyfill baseline, allowing wider use of modern native modules features. See the 2.0 release post for more details on the full 2.0 feature set.