diff --git a/README.md b/README.md index 01de2ee..dca4d26 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ With this approach you'll ship many small JavaScript files instead of one big JavaScript file. Thanks to HTTP/2 that no longer carries a material performance penalty during the initial transport, and in fact offers substantial benefits over the long run due to better caching dynamics. Whereas before any change to any JavaScript file included in your big bundle would invalidate the cache for the whole bundle, now only the cache for that single file is invalidated. -There's [native support for import maps in Chrome/Edge 89+/Firefox 108+](https://caniuse.com/?search=importmap), and [a shim available](https://github.com/guybedford/es-module-shims) for any browser with basic ESM support. So your app will be able to work with all the evergreen browsers. +[Import maps are supported natively in all major, modern browsers](https://caniuse.com/?search=importmap). If you need to work with legacy browsers without native support, you can explore using [the shim available](https://github.com/guybedford/es-module-shims). ## Installation @@ -57,7 +57,7 @@ import React from "react" The import map is setup through `Rails.application.importmap` via the configuration in `config/importmap.rb`. This file is automatically reloaded in development upon changes, but note that you must restart the server if you remove pins and need them gone from the rendered importmap or list of preloads. -This import map is inlined in the `` of your application layout using `<%= javascript_importmap_tags %>`, which will setup the JSON configuration inside a ``. That logical entrypoint, `application`, is mapped in the importmap script tag to the file `app/javascript/application.js`. +This import map is inlined in the `` of your application layout using `<%= javascript_importmap_tags %>`, which will setup the JSON configuration inside a ``. That logical entrypoint, `application`, is mapped in the importmap script tag to the file `app/javascript/application.js`. It's in `app/javascript/application.js` you setup your application by importing any of the modules that have been defined in the import map. You can use the full ESM functionality of importing any particular export of the modules or everything. @@ -310,16 +310,6 @@ module MyEngine end ``` -## Expected errors from using the es-module-shim - -While import maps are native in Chrome, Edge, and Firefox, they need a shim in other browsers that'll produce a JavaScript console error like `TypeError: Module specifier, 'application' does not start with "/", "./", or "../".`. This error is normal and does not have any user-facing consequences. - -In Firefox. when opening the browser console, the asm.js module lexer build will run in unoptimized mode due to the debugger attaching. This gives a warning message `"asm.js type error: Disabled because no suitable wasm compiler is available"` which is as expected. When the console is closed again, the asm.js optimizations are fully applied, and this can even be verified with the console open by disabling the debugger in `about:config` and reloading the page. - -## Turning off the shim - -Under certain circumstances, like running system tests using chromedriver under CI (which may be resource constrained and trigger errors in certain cases), you may want to explicitly turn off including the shim. You can do this by calling the bulk tag helper with `javascript_importmap_tags("application", shim: false)`. Thus you can pass in something like `shim: !ENV["CI"]`. If you want, and are sure you're not doing any full-page caching, you can also connect this directive to a user agent check (using a gem like `useragent`) to check whether the browser is chrome/edge 89+/firefox 108+. But you really shouldn't have to, as the shim is designed to gracefully work with natively compatible drivers. - ## Checking for outdated or vulnerable packages Importmap for Rails provides two commands to check your pinned packages: diff --git a/app/assets/javascripts/es-module-shims.js b/app/assets/javascripts/es-module-shims.js deleted file mode 100644 index 97c4eb2..0000000 --- a/app/assets/javascripts/es-module-shims.js +++ /dev/null @@ -1,926 +0,0 @@ -/* ES Module Shims 1.7.2 */ -(function () { - - const hasWindow = typeof window !== 'undefined'; - const hasDocument = typeof document !== 'undefined'; - - const noop = () => {}; - - const optionsScript = hasDocument ? document.querySelector('script[type=esms-options]') : undefined; - - const esmsInitOptions = optionsScript ? JSON.parse(optionsScript.innerHTML) : {}; - Object.assign(esmsInitOptions, self.esmsInitOptions || {}); - - let shimMode = hasDocument ? !!esmsInitOptions.shimMode : true; - - const importHook = globalHook(shimMode && esmsInitOptions.onimport); - const resolveHook = globalHook(shimMode && esmsInitOptions.resolve); - let fetchHook = esmsInitOptions.fetch ? globalHook(esmsInitOptions.fetch) : fetch; - const metaHook = esmsInitOptions.meta ? globalHook(shimMode && esmsInitOptions.meta) : noop; - - const mapOverrides = esmsInitOptions.mapOverrides; - - let nonce = esmsInitOptions.nonce; - if (!nonce && hasDocument) { - const nonceElement = document.querySelector('script[nonce]'); - if (nonceElement) - nonce = nonceElement.nonce || nonceElement.getAttribute('nonce'); - } - - const onerror = globalHook(esmsInitOptions.onerror || noop); - const onpolyfill = esmsInitOptions.onpolyfill ? globalHook(esmsInitOptions.onpolyfill) : () => { - console.log('%c^^ Module TypeError above is polyfilled and can be ignored ^^', 'font-weight:900;color:#391'); - }; - - const { revokeBlobURLs, noLoadEventRetriggers, enforceIntegrity } = esmsInitOptions; - - function globalHook (name) { - return typeof name === 'string' ? self[name] : name; - } - - const enable = Array.isArray(esmsInitOptions.polyfillEnable) ? esmsInitOptions.polyfillEnable : []; - const cssModulesEnabled = enable.includes('css-modules'); - const jsonModulesEnabled = enable.includes('json-modules'); - - const edge = !navigator.userAgentData && !!navigator.userAgent.match(/Edge\/\d+\.\d+/); - - const baseUrl = hasDocument - ? document.baseURI - : `${location.protocol}//${location.host}${location.pathname.includes('/') - ? location.pathname.slice(0, location.pathname.lastIndexOf('/') + 1) - : location.pathname}`; - - const createBlob = (source, type = 'text/javascript') => URL.createObjectURL(new Blob([source], { type })); - let { skip } = esmsInitOptions; - if (Array.isArray(skip)) { - const l = skip.map(s => new URL(s, baseUrl).href); - skip = s => l.some(i => i[i.length - 1] === '/' && s.startsWith(i) || s === i); - } - else if (typeof skip === 'string') { - const r = new RegExp(skip); - skip = s => r.test(s); - } - - const eoop = err => setTimeout(() => { throw err }); - - const throwError = err => { (self.reportError || hasWindow && window.safari && console.error || eoop)(err), void onerror(err); }; - - function fromParent (parent) { - return parent ? ` imported from ${parent}` : ''; - } - - let importMapSrcOrLazy = false; - - function setImportMapSrcOrLazy () { - importMapSrcOrLazy = true; - } - - // shim mode is determined on initialization, no late shim mode - if (!shimMode) { - if (document.querySelectorAll('script[type=module-shim],script[type=importmap-shim],link[rel=modulepreload-shim]').length) { - shimMode = true; - } - else { - let seenScript = false; - for (const script of document.querySelectorAll('script[type=module],script[type=importmap]')) { - if (!seenScript) { - if (script.type === 'module' && !script.ep) - seenScript = true; - } - else if (script.type === 'importmap' && seenScript) { - importMapSrcOrLazy = true; - break; - } - } - } - } - - const backslashRegEx = /\\/g; - - function isURL (url) { - if (url.indexOf(':') === -1) return false; - try { - new URL(url); - return true; - } - catch (_) { - return false; - } - } - - function resolveUrl (relUrl, parentUrl) { - return resolveIfNotPlainOrUrl(relUrl, parentUrl) || (isURL(relUrl) ? relUrl : resolveIfNotPlainOrUrl('./' + relUrl, parentUrl)); - } - - function resolveIfNotPlainOrUrl (relUrl, parentUrl) { - const hIdx = parentUrl.indexOf('#'), qIdx = parentUrl.indexOf('?'); - if (hIdx + qIdx > -2) - parentUrl = parentUrl.slice(0, hIdx === -1 ? qIdx : qIdx === -1 || qIdx > hIdx ? hIdx : qIdx); - if (relUrl.indexOf('\\') !== -1) - relUrl = relUrl.replace(backslashRegEx, '/'); - // protocol-relative - if (relUrl[0] === '/' && relUrl[1] === '/') { - return parentUrl.slice(0, parentUrl.indexOf(':') + 1) + relUrl; - } - // relative-url - else if (relUrl[0] === '.' && (relUrl[1] === '/' || relUrl[1] === '.' && (relUrl[2] === '/' || relUrl.length === 2 && (relUrl += '/')) || - relUrl.length === 1 && (relUrl += '/')) || - relUrl[0] === '/') { - const parentProtocol = parentUrl.slice(0, parentUrl.indexOf(':') + 1); - // Disabled, but these cases will give inconsistent results for deep backtracking - //if (parentUrl[parentProtocol.length] !== '/') - // throw new Error('Cannot resolve'); - // read pathname from parent URL - // pathname taken to be part after leading "/" - let pathname; - if (parentUrl[parentProtocol.length + 1] === '/') { - // resolving to a :// so we need to read out the auth and host - if (parentProtocol !== 'file:') { - pathname = parentUrl.slice(parentProtocol.length + 2); - pathname = pathname.slice(pathname.indexOf('/') + 1); - } - else { - pathname = parentUrl.slice(8); - } - } - else { - // resolving to :/ so pathname is the /... part - pathname = parentUrl.slice(parentProtocol.length + (parentUrl[parentProtocol.length] === '/')); - } - - if (relUrl[0] === '/') - return parentUrl.slice(0, parentUrl.length - pathname.length - 1) + relUrl; - - // join together and split for removal of .. and . segments - // looping the string instead of anything fancy for perf reasons - // '../../../../../z' resolved to 'x/y' is just 'z' - const segmented = pathname.slice(0, pathname.lastIndexOf('/') + 1) + relUrl; - - const output = []; - let segmentIndex = -1; - for (let i = 0; i < segmented.length; i++) { - // busy reading a segment - only terminate on '/' - if (segmentIndex !== -1) { - if (segmented[i] === '/') { - output.push(segmented.slice(segmentIndex, i + 1)); - segmentIndex = -1; - } - continue; - } - // new segment - check if it is relative - else if (segmented[i] === '.') { - // ../ segment - if (segmented[i + 1] === '.' && (segmented[i + 2] === '/' || i + 2 === segmented.length)) { - output.pop(); - i += 2; - continue; - } - // ./ segment - else if (segmented[i + 1] === '/' || i + 1 === segmented.length) { - i += 1; - continue; - } - } - // it is the start of a new segment - while (segmented[i] === '/') i++; - segmentIndex = i; - } - // finish reading out the last segment - if (segmentIndex !== -1) - output.push(segmented.slice(segmentIndex)); - return parentUrl.slice(0, parentUrl.length - pathname.length) + output.join(''); - } - } - - function resolveAndComposeImportMap (json, baseUrl, parentMap) { - const outMap = { imports: Object.assign({}, parentMap.imports), scopes: Object.assign({}, parentMap.scopes) }; - - if (json.imports) - resolveAndComposePackages(json.imports, outMap.imports, baseUrl, parentMap); - - if (json.scopes) - for (let s in json.scopes) { - const resolvedScope = resolveUrl(s, baseUrl); - resolveAndComposePackages(json.scopes[s], outMap.scopes[resolvedScope] || (outMap.scopes[resolvedScope] = {}), baseUrl, parentMap); - } - - return outMap; - } - - function getMatch (path, matchObj) { - if (matchObj[path]) - return path; - let sepIndex = path.length; - do { - const segment = path.slice(0, sepIndex + 1); - if (segment in matchObj) - return segment; - } while ((sepIndex = path.lastIndexOf('/', sepIndex - 1)) !== -1) - } - - function applyPackages (id, packages) { - const pkgName = getMatch(id, packages); - if (pkgName) { - const pkg = packages[pkgName]; - if (pkg === null) return; - return pkg + id.slice(pkgName.length); - } - } - - - function resolveImportMap (importMap, resolvedOrPlain, parentUrl) { - let scopeUrl = parentUrl && getMatch(parentUrl, importMap.scopes); - while (scopeUrl) { - const packageResolution = applyPackages(resolvedOrPlain, importMap.scopes[scopeUrl]); - if (packageResolution) - return packageResolution; - scopeUrl = getMatch(scopeUrl.slice(0, scopeUrl.lastIndexOf('/')), importMap.scopes); - } - return applyPackages(resolvedOrPlain, importMap.imports) || resolvedOrPlain.indexOf(':') !== -1 && resolvedOrPlain; - } - - function resolveAndComposePackages (packages, outPackages, baseUrl, parentMap) { - for (let p in packages) { - const resolvedLhs = resolveIfNotPlainOrUrl(p, baseUrl) || p; - if ((!shimMode || !mapOverrides) && outPackages[resolvedLhs] && (outPackages[resolvedLhs] !== packages[resolvedLhs])) { - throw Error(`Rejected map override "${resolvedLhs}" from ${outPackages[resolvedLhs]} to ${packages[resolvedLhs]}.`); - } - let target = packages[p]; - if (typeof target !== 'string') - continue; - const mapped = resolveImportMap(parentMap, resolveIfNotPlainOrUrl(target, baseUrl) || target, baseUrl); - if (mapped) { - outPackages[resolvedLhs] = mapped; - continue; - } - console.warn(`Mapping "${p}" -> "${packages[p]}" does not resolve`); - } - } - - let dynamicImport = !hasDocument && (0, eval)('u=>import(u)'); - - let supportsDynamicImport; - - const dynamicImportCheck = hasDocument && new Promise(resolve => { - const s = Object.assign(document.createElement('script'), { - src: createBlob('self._d=u=>import(u)'), - ep: true - }); - s.setAttribute('nonce', nonce); - s.addEventListener('load', () => { - if (!(supportsDynamicImport = !!(dynamicImport = self._d))) { - let err; - window.addEventListener('error', _err => err = _err); - dynamicImport = (url, opts) => new Promise((resolve, reject) => { - const s = Object.assign(document.createElement('script'), { - type: 'module', - src: createBlob(`import*as m from'${url}';self._esmsi=m`) - }); - err = undefined; - s.ep = true; - if (nonce) - s.setAttribute('nonce', nonce); - // Safari is unique in supporting module script error events - s.addEventListener('error', cb); - s.addEventListener('load', cb); - function cb (_err) { - document.head.removeChild(s); - if (self._esmsi) { - resolve(self._esmsi, baseUrl); - self._esmsi = undefined; - } - else { - reject(!(_err instanceof Event) && _err || err && err.error || new Error(`Error loading ${opts && opts.errUrl || url} (${s.src}).`)); - err = undefined; - } - } - document.head.appendChild(s); - }); - } - document.head.removeChild(s); - delete self._d; - resolve(); - }); - document.head.appendChild(s); - }); - - // support browsers without dynamic import support (eg Firefox 6x) - let supportsJsonAssertions = false; - let supportsCssAssertions = false; - - const supports = hasDocument && HTMLScriptElement.supports; - - let supportsImportMaps = supports && supports.name === 'supports' && supports('importmap'); - let supportsImportMeta = supportsDynamicImport; - - const importMetaCheck = 'import.meta'; - const cssModulesCheck = `import"x"assert{type:"css"}`; - const jsonModulesCheck = `import"x"assert{type:"json"}`; - - let featureDetectionPromise = Promise.resolve(dynamicImportCheck).then(() => { - if (!supportsDynamicImport) - return; - - if (!hasDocument) - return Promise.all([ - supportsImportMaps || dynamicImport(createBlob(importMetaCheck)).then(() => supportsImportMeta = true, noop), - cssModulesEnabled && dynamicImport(createBlob(cssModulesCheck.replace('x', createBlob('', 'text/css')))).then(() => supportsCssAssertions = true, noop), - jsonModulesEnabled && dynamicImport(createBlob(jsonModulescheck.replace('x', createBlob('{}', 'text/json')))).then(() => supportsJsonAssertions = true, noop), - ]); - - return new Promise(resolve => { - const iframe = document.createElement('iframe'); - iframe.style.display = 'none'; - iframe.setAttribute('nonce', nonce); - function cb ({ data }) { - const isFeatureDetectionMessage = Array.isArray(data) && data[0] === 'esms'; - if (!isFeatureDetectionMessage) { - return; - } - supportsImportMaps = data[1]; - supportsImportMeta = data[2]; - supportsCssAssertions = data[3]; - supportsJsonAssertions = data[4]; - resolve(); - document.head.removeChild(iframe); - window.removeEventListener('message', cb, false); - } - window.addEventListener('message', cb, false); - - const importMapTest = `}, @@ -48,8 +43,6 @@ def content_security_policy_nonce @request = FakeRequest.new("iyhD0Yc0W+c=") assert_match /nonce="iyhD0Yc0W\+c="/, javascript_inline_importmap_tag - assert_match /nonce="iyhD0Yc0W\+c="/, javascript_importmap_shim_nonce_configuration_tag - assert_match /nonce="iyhD0Yc0W\+c="/, javascript_importmap_shim_tag assert_match /nonce="iyhD0Yc0W\+c="/, javascript_import_module_tag("application") assert_match /nonce="iyhD0Yc0W\+c="/, javascript_importmap_module_preload_tags ensure