Skip to content

Commit

Permalink
feat: still polyfill when early module loads stop import maps (#201)
Browse files Browse the repository at this point in the history
  • Loading branch information
guybedford authored Sep 28, 2021
1 parent 2b75634 commit 59c072d
Show file tree
Hide file tree
Showing 6 changed files with 116 additions and 52 deletions.
46 changes: 37 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Then there are two ways to use ES Module Shims: Polyfill Mode and [Shim Mode](#s

### Polyfill Mode

Just write your HTML modules like you would in the latest Chrome:
Write your HTML modules like you would in the latest Chrome:

```html
<script type="importmap">
Expand All @@ -60,7 +60,7 @@ and ES Module Shims will make it work in [all browsers with any ES Module Suppor
> `<script type="importmap">` should always be placed before any `<script type="module">` as per native support in browsers.
You will typically see a console error in browsers without import maps support like:
You will see a console error in browsers without import maps support like:
```
Uncaught TypeError: Failed to resolve module specifier "app". Relative references must start with either "/", "./", or "../".
Expand All @@ -69,14 +69,18 @@ Uncaught TypeError: Failed to resolve module specifier "app". Relative reference
This execution failure is a feature - it avoids the polyfill causing double execution. The first import being a bare specifier in the pattern above is important to ensure this.
This is because the polyfill cannot disable the native loader - instead it can only execute modules that would otherwise fail instantiation while avoiding duplicate fetches or executions.
This is because the polyfill cannot disable the native loader - instead it will only execute modules that would otherwise fail resolving or parsing to avoid duplicate fetches or executions that would cause performance and reliability issues.
If using CSS modules or JSON modules, since these features are relatively new, they require manually enabling using the initialization option:
```html
<script>window.esmsInitOptions = { enable: ['css-modules', 'json-modules'] }</script>
<script>
window.esmsInitOptions = { enable: ['css-modules', 'json-modules'] }
</script>
```

To verify when the polyfill is actively engaging as opposed to relying on the native loader, [an `onpolyfill` hook](#onpolyfill-hook) is provided.

See the [Polyfill Mode Details](#polyfill-mode-details) section for more information about how the polyfill works and what options are available.

### Shim Mode
Expand All @@ -85,7 +89,7 @@ Shim mode is an alternative to polyfill mode and doesn't rely on native modules

In shim mode, normal module scripts and import maps are entirely ignored and only the above shim tags will be parsed and executed by ES Module Shims instead.

Shim mode also provides some additional features that aren't yet natively supported such as [external import maps](#external-import-maps) with a `"src"` attribute or [dynamicallly injecting import maps](#dynamic-import-maps), which can be useful in certain applications.
Shim mode also provides some additional features that aren't yet natively supported such as supporting multiple import maps, [external import maps](#external-import-maps) with a `"src"` attribute or [dynamicallly injecting import maps](#dynamic-import-maps), which can be useful in certain applications.

## Features

Expand Down Expand Up @@ -363,7 +367,7 @@ Adding the `"noshim"` attribute to the script tag will also ensure that ES Modul
## Init Options

Provide a `esmsInitOptions` on the global scope before `es-module-shims` is loaded to configure various aspects of the module loading process:
o

```html
<script>
window.esmsInitOptions = {
Expand All @@ -373,6 +377,7 @@ window.esmsInitOptions = {
noLoadEventRetriggers: true, // default false
skip: /^https:\/\/cdn\.com/, // defaults to null
onerror: (e) => { /*...*/ }, // default noop
onpolyfill: () => {},
resolve: (id, parentUrl, resolve) => resolve(id, parentUrl), // default is spec resolution
fetch: (url) => fetch(url), // default is native
revokeBlobURLs: true, // default false
Expand All @@ -388,12 +393,15 @@ If only setting JSON-compatible options, the `<script type="esms-options">` can
{
"shimMode": true,
"polyfillEnable": ["css-modules", "json-modules"],
"nonce": "n0nce"
"nonce": "n0nce",
"onpolyfill": "polyfill"
}
</script>
```

This can be convenient when using the [CSP build](#csp-build).
This can be convenient when using a CSP policy.

Function strings correspond to global function names.

See below for a detailed description of each of these options.

Expand Down Expand Up @@ -468,12 +476,32 @@ This can be configured by providing a URL regular expression for the `skip` opti
```js
<script type="esms-options">
{
skip: "/^https?:\/\/(cdn\.skypack\.dev|jspm\.dev)\//`
"skip": "/^https?:\/\/(cdn\.skypack\.dev|jspm\.dev)\//`
}
</script>
<script async src="es-module-shims.js"></script>
```
#### Polyfill hook
The polyfill hook is called when running in polyfill mode and the polyfill is kicking in instead of passing through to the native loader.
This can be a useful way to verify that the native passthrough is working correctly in latest browsers for performance, while also allowing eg the ability to analyze or get metrics reports of how many users are getting the polyfill actively applying to their browser application loads.
```js
<script>
window.polyfilling = () => console.log('The polyfill is actively applying');
</script>
<script type="esms-options">
{
"onpolyfill": "polyfilling"
}
</script>
```
In the above, running in latest Chromium browsers, nothing will be logged, while running in an older browser that does not support newer features
like import maps the console log will be output.
#### Error hook
You can provide a function to handle errors during the module loading process by providing an `onerror` option:
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
"footprint": "npm run build && cat dist/es-module-shims.js | brotli | wc -c",
"footprint:csp": "npm run build && cat dist/es-module-shims.csp.js | brotli | wc -c",
"prepublishOnly": "npm run build",
"test": "npm run test:test-base-href && npm run test:test-csp && npm run test:test-polyfill && npm run test:test-polyfill-wasm && npm run test:test-preload-case && npm run test:test-revoke-blob-urls && npm run test:test-shim",
"test": "npm run test:test-base-href && npm run test:test-csp && npm run test:test-early-module-load && npm run test:test-polyfill && npm run test:test-polyfill-wasm && npm run test:test-preload-case && npm run test:test-revoke-blob-urls && npm run test:test-shim",
"test:test-base-href": "cross-env TEST_NAME=test-base-href node test/server.mjs",
"test:test-csp": "cross-env TEST_NAME=test-csp node test/server.mjs",
"test:test-early-module-load": "cross-env TEST_NAME=test-early-module-load node test/server.mjs",
"test:test-polyfill": "cross-env TEST_NAME=test-polyfill node test/server.mjs",
"test:test-polyfill-wasm": "cross-env TEST_NAME=test-polyfill-wasm node test/server.mjs",
"test:test-preload-case": "cross-env TEST_NAME=test-preload-case node test/server.mjs",
Expand Down Expand Up @@ -51,5 +52,8 @@
"bugs": {
"url": "https://github.com/guybedford/es-module-shims/issues"
},
"homepage": "https://github.com/guybedford/es-module-shims#readme"
"homepage": "https://github.com/guybedford/es-module-shims#readme",
"dependencies": {
"rimraf": "^3.0.2"
}
}
70 changes: 38 additions & 32 deletions src/es-module-shims.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
noLoadEventRetriggers,
cssModulesEnabled,
jsonModulesEnabled,
onpolyfill,
} from './options.js';
import { dynamicImport } from './dynamic-import-csp.js';
import {
Expand Down Expand Up @@ -67,10 +68,24 @@ let importMapSrcOrLazy = false;
let baselinePassthrough;

const initPromise = featureDetectionPromise.then(() => {
baselinePassthrough = supportsDynamicImport && supportsImportMeta && supportsImportMaps && (!jsonModulesEnabled || supportsJsonAssertions) && (!cssModulesEnabled || supportsCssAssertions) && !importMapSrcOrLazy && !self.ESMS_DEBUG;
// shim mode is determined on initialization, no late shim mode
if (!shimMode && document.querySelectorAll('script[type="module-shim"],script[type="importmap-shim"]').length)
setShimMode();
if (!shimMode) {
let seenScript = false;
for (const script of document.querySelectorAll('script[type="module-shim"],script[type="importmap-shim"],script[type="module"],script[type="importmap"]')) {
if (!seenScript && script.type === 'module')
seenScript = true;
if (script.type.endsWith('-shim')) {
setShimMode();
break;
}
if (seenScript && script.type === 'importmap') {
importMapSrcOrLazy = true;
break;
}
}
}
baselinePassthrough = supportsDynamicImport && supportsImportMeta && supportsImportMaps && (!jsonModulesEnabled || supportsJsonAssertions) && (!cssModulesEnabled || supportsCssAssertions) && !importMapSrcOrLazy && !self.ESMS_DEBUG;
if (!baselinePassthrough) onpolyfill();
if (shimMode || !baselinePassthrough) {
new MutationObserver(mutations => {
for (const mutation of mutations) {
Expand All @@ -95,16 +110,9 @@ const initPromise = featureDetectionPromise.then(() => {
let importMapPromise = initPromise;

let acceptingImportMaps = true;
let nativeAcceptingImportMaps = true;
async function topLevelLoad (url, fetchOpts, source, nativelyLoaded, lastStaticLoadPromise) {
if (acceptingImportMaps) {
if (!shimMode) {
acceptingImportMaps = false;
}
else {
nativeAcceptingImportMaps = false;
}
}
if (!shimMode)
acceptingImportMaps = false;
await importMapPromise;
// early analysis opt-out - no need to even fetch if we have feature support
if (!shimMode && baselinePassthrough) {
Expand Down Expand Up @@ -157,12 +165,8 @@ async function importShim (id, parentUrl = pageBaseUrl, _assertion) {
await initPromise;
if (acceptingImportMaps || shimMode || !baselinePassthrough) {
processImportMaps();
if (!shimMode) {
if (!shimMode)
acceptingImportMaps = false;
}
else {
nativeAcceptingImportMaps = false;
}
}
await importMapPromise;
return topLevelLoad((await resolve(id, parentUrl)).r || throwUnresolved(id, parentUrl), { credentials: 'same-origin' });
Expand Down Expand Up @@ -375,7 +379,7 @@ function getOrCreateLoad (url, fetchOpts, source) {
load.n = true;
if (!n) return;
const { r, b } = await resolve(n, load.r || load.u);
if (b && !supportsImportMaps)
if (b && (!supportsImportMaps || importMapSrcOrLazy))
load.n = true;
if (d !== -1) return;
if (!r)
Expand Down Expand Up @@ -435,42 +439,41 @@ document.addEventListener('DOMContentLoaded', async () => {
});

let readyStateCompleteCnt = 1;
if (document.readyState === 'complete')
if (document.readyState === 'complete') {
readyStateCompleteCheck();
else
}
else {
document.addEventListener('readystatechange', async () => {
processImportMaps();
await initPromise;
readyStateCompleteCheck();
});
}
function readyStateCompleteCheck () {
if (--readyStateCompleteCnt === 0 && !noLoadEventRetriggers)
document.dispatchEvent(new Event('readystatechange'));
}

function processImportMap (script) {
if (!acceptingImportMaps)
return;
if (script.ep) // ep marker = script processed
return;
// empty inline scripts sometimes show before domready
if (!script.src && !script.innerHTML)
return;
script.ep = true;
// we dont currently support multiple, external or dynamic imports maps in polyfill mode to match native
if (script.src || !nativeAcceptingImportMaps) {
if (script.src) {
if (!shimMode)
return;
importMapSrcOrLazy = true;
}
if (!shimMode) {
acceptingImportMaps = false;
}
else {
nativeAcceptingImportMaps = false;
if (acceptingImportMaps) {
importMapPromise = importMapPromise.then(async () => {
importMap = resolveAndComposeImportMap(script.src ? await (await fetchHook(script.src)).json() : JSON.parse(script.innerHTML), script.src || pageBaseUrl, importMap);
});
if (!shimMode)
acceptingImportMaps = false;
}
importMapPromise = importMapPromise.then(async () => {
importMap = resolveAndComposeImportMap(script.src ? await (await fetchHook(script.src)).json() : JSON.parse(script.innerHTML), script.src || pageBaseUrl, importMap);
});
}

function processScript (script) {
Expand All @@ -488,7 +491,10 @@ function processScript (script) {
const isDomContentLoadedScript = domContentLoadedCnt > 0;
if (isReadyScript) readyStateCompleteCnt++;
if (isDomContentLoadedScript) domContentLoadedCnt++;
const loadPromise = topLevelLoad(script.src || `${pageBaseUrl}?${id++}`, getFetchOpts(script), !script.src && script.innerHTML, !shimMode, isReadyScript && lastStaticLoadPromise).catch(onerror);
const loadPromise = topLevelLoad(script.src || `${pageBaseUrl}?${id++}`, getFetchOpts(script), !script.src && script.innerHTML, !shimMode, isReadyScript && lastStaticLoadPromise).catch(e => {
setTimeout(() => { throw e });
onerror(e);
});
if (isReadyScript)
lastStaticLoadPromise = loadPromise.then(readyStateCompleteCheck);
if (isDomContentLoadedScript)
Expand Down
4 changes: 2 additions & 2 deletions src/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ export const featureDetectionPromise = Promise.resolve(supportsDynamicImportChec
jsonModulesEnabled && dynamicImport(createBlob('import"data:text/json,{}"assert{type:"json"}')).then(() => supportsJsonAssertions = true, noop),
new Promise(resolve => {
self._$s = v => {
document.body.removeChild(iframe);
document.head.removeChild(iframe);
if (v) supportsImportMaps = true;
delete self._$s;
resolve();
};
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.body.appendChild(iframe);
document.head.appendChild(iframe);
// we use document.write here because eg Weixin built-in browser doesn't support setting srcdoc
iframe.contentWindow.document.write(`<script type=importmap nonce="${nonce}">{"imports":{"x":"data:text/javascript,"}}<${''}/script><script nonce="${nonce}">import('x').then(()=>1,()=>0).then(v=>parent._$s(v))<${''}/script>`);
})
Expand Down
18 changes: 11 additions & 7 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const optionsScript = document.querySelector('script[type=esms-options]');
const esmsInitOptions = optionsScript ? JSON.parse(optionsScript.innerHTML) : self.esmsInitOptions ? self.esmsInitOptions : {};

export let shimMode = !!esmsInitOptions.shimMode;
export const resolveHook = shimMode && esmsInitOptions.resolve;
export const resolveHook = globalHook(shimMode && esmsInitOptions.resolve);

export const skip = esmsInitOptions.skip ? new RegExp(esmsInitOptions.skip) : null;

Expand All @@ -17,12 +17,16 @@ if (!nonce) {
nonce = nonceElement.getAttribute('nonce');
}

export const {
fetchHook = fetch,
onerror = noop,
revokeBlobURLs,
noLoadEventRetriggers,
} = esmsInitOptions;
export const onerror = globalHook(esmsInitOptions.onerror || noop);
export const onpolyfill = globalHook(esmsInitOptions.onpolyfill || noop);

export const { revokeBlobURLs, noLoadEventRetriggers } = esmsInitOptions;

export const fetchHook = esmsInitOptions.fetchHook ? globalHook(esmsInitOptions.fetchHook) : fetch;

function globalHook (name) {
return typeof name === 'string' ? self[name] : name;
}

const enable = Array.isArray(esmsInitOptions.polyfillEnable) ? esmsInitOptions.polyfillEnable : [];
export const cssModulesEnabled = enable.includes('css-modules');
Expand Down
22 changes: 22 additions & 0 deletions test/test-early-module-load.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!doctype html>
<script>
window.polyfill = () => {
window.calledPolyfillHook = true;
};
</script>
<script type="esms-options">
{
"onpolyfill": "polyfill"
}
</script>
<script async type="module" src="../src/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"app": "data:text/javascript,if (calledPolyfillHook) fetch('/done')"
}
}
</script>
<script type="module">
import 'app';
</script>

0 comments on commit 59c072d

Please sign in to comment.