Limitations and traps
Traps first: behaviour that is deliberate and documented in the reference, but easy to
trip over because the mistake looks like ordinary HTML. Nothing in html-modules fails
silently (a mistake is an exception, a rejection or an error event), but several of
these fail somewhere you might not be looking. The permanent limitations, which follow
from the custom elements platform, come after. What the library does and does not protect you from is on
Security model.
Writing modules
Section titled “Writing modules”name=""is a default export. A bare or emptynamemeans the default, so a templating variable that renders asname=""silently turns a named export into the default one. Writename="default"when you mean it. Thedefaultattribute on its own (<html-export default>) is an error that points toname="default".- A default-only export has no identity and is not registered by
as=. Its definition’snameisnulland it is not in thecomponentsmanifest; the importer must name it with<html-binding export="default" element="…">. </script>ends a script element, even inside a JSON string. A data export containing{"s": "</script>"}breaks the element and surfaces as an “invalid JSON”SyntaxError. Write"<\/script>", which is valid JSON.<style>beside the template is shared;<style>inside it is copied. Styles beside the<template>become one constructed stylesheet per definition, adopted by every instance; styles inside the template are cloned into each instance’s shadow root.- A module’s imports are private. A stylesheet a module adopts applies inside that
module’s components only, never to the page, and a module’s own lazy import cannot
adoptat all.
Importing and settings
Section titled “Importing and settings”ui.htmlis a bare specifier. Exactly as in JavaScript, asrcwithout./goes to the import map (import.meta.resolve), not to the document’s folder, and fails withTypeError: Unable to resolve bare specifierwhen the map has no entry. Write./ui.html.- Settings must come first, and a late one is ignored. An
<html-import-settings>after an<html-import>, inserted after imports started, or a second one in the same document fires anerrorevent on itself and changes nothing. Inside a module, the same mistakes are aSyntaxError. - An invalid
<html-import-settings>fails every import of its page, rather than letting them run with other options. baseis document-level only, and ignores<base href>.baseon an<html-import>is aSyntaxError; on<html-import-settings>it is resolved against the document’s own URL, not its<base href>, and it only affects HTML-module specifiers.- Page settings and instance options never reach inside a module. A module’s own
<html-import>s use--and the built-in defaults unless the module says otherwise, so a page-widedelimiter="-"does not change the tags a module’s templates use. - Document settings never apply to the JavaScript API.
HTMLModules.import()andbind()are not in any document: only their call options and the instance options apply. AndHTMLModules.import()never throws for bad options; it returns a rejected promise. HTMLModules.import(src, { load: 'lazy' })returns a handle, not a promise:{ ready, load(), cancel(), state }. Awaithandle.ready.- The compiler never lazy-loads. Compiled dependencies are static
imports;load="lazy"is carried along but does not make compiled code lazy. - The configuration elements render nothing but are not hidden.
<html-import>,<html-binding>,<html-import-settings>and<html-module-settings>have no default style, so inside a grid or flex container (a<body>that isdisplay: grid, say) each one becomes a layout item and can leave a gap. Hide them:html-import, html-binding, html-import-settings, html-module-settings { display: none }. (The library adds no stylesheet of its own, which is what keeps it usable under a strictstyle-src.) - A sanitized module cannot import JavaScript, and loses more than scripts. With a
sanitizehook,<html-import src="*.js">in a sanitized module is refused (import()would run it with the page’s authority). Under safe-fragment’s profiles a template also loses<style>(a non-goal there: put the CSS in<html-export><style>, which the sanitizer never sees), forms and their controls (so aform-associatedcomponent loses its control), SVG,style="",data-*beyond what you list andhttp:links; its ids are kept inside the shadow root. The native Sanitizer API does not report what it strips by itself (<script>,<iframe>, handlers,javascript:), so on Chromium asanitizeevent lists only what the profile removed. See Security model and what a template loses. npx html-moduleoutside a project that has the package installed looks for an npm package namedhtml-module, which is not this one. Usenpx -p @johnhenry/html-modules html-module ….
Limitations
Section titled “Limitations”- Custom element names are global and permanent; scoped registries only help inside modules, where supported. Once a
tag is defined in a window it cannot be undefined or redefined: removing an
<html-import>unregisters nothing (it only un-adopts the stylesheets itsadoptbindings adopted), and a second version of a library needs its own namespace (orconflict="reuse", which keeps the first).registry="scoped"in a module lets two versions use the same inner tags (see Scoped registries), but the tags a page uses are always global, and the option needs a browser with scoped registries (it falls back, with a warning, elsewhere). - Lazy loading only sees trees it can observe. It watches the document and the
shadow roots html-modules itself creates (open or closed). A tag used inside a shadow
root made by other code (a JS component’s own
attachShadow()), in another document (an iframe), in<template>content not yet cloned into a watched tree, or in an element created but never inserted does not trigger the load. Callel.load()(or the handle’sload()) for those; this is a property ofMutationObserver, which cannot see into shadow roots it was not given. - A runtime copy and a compiled copy of the same module conflict. Loading
ui.htmlat runtime and importing a compiledui.jsgives two different definitions, so registering both under the same tags fails withCannot bind <…>: it is already defined …unless one import saysconflict="reuse"(the first definition wins) or they use different namespaces. Relatedly, load one copy of the runtime per page: two copies ofruntime.jsat different URLs (say, the page’s fromnode_modulesand a compiled module’s from a CDN) still recognize each other’s definitions, but keep separate registration bookkeeping, lazy-loading watchers and stylesheet caches, so conflict messages lose the “defined by” detail and lazy imports stop seeing the other copy’s shadow roots. Map@johnhenry/html-modules/runtimeto the same file the bootstrap uses (see Getting started). - Relative URLs in a template resolve against the page. A module’s
<style>resolvesurl(...)against the module (html-modules rewrites relativeurl()s to absolute ones against the module’s URL), but its<template>is stamped into the page, so<img src="./logo.png">is relative to the page, not the module, and html-modules does not rewrite it. Use absolute URLs for assets of a module served from elsewhere.@importin a<style>is rejected (constructed stylesheets drop it silently). - Server-side rendering is supported by declarative shadow DOM, not by running the library on the server.
renderDeclarative(def, innerHTML)returns the<template shadowrootmode>markup for a component (styles included) to put inside its host tag; when the element upgrades, the runtime keeps that shadow root (open or closed), adopts the component’s sheets and does not stamp the template again. It does not render nested components or run any script. Ashadow="closed"component’s base class callsattachInternals()(to see a closed declarative root); the call is memoized, so a subclass can callthis.attachInternals()and gets the same object. - The
.(and_) delimiter cannot name one-word exports..is a legal custom element name character, butui.cardhas no hyphen, so binding a one-word export underdelimiter="."is aSyntaxErrornaming the tag; a namespace import checks every tag before registering any, so it fails whole. Two-word exports (ui.custom-card) work.-always works but makes tags ambiguous to read back (the runtime records{ tag, namespace, export }instead of parsing).--has neither problem, which is why it is the default.
Non-goals
Section titled “Non-goals”From the PRD: no custom JavaScript module loader; no direct import … from "./ui.html"
in JavaScript (use HTMLModules.load() or the compiler); no
service workers; no bundler requirement; no framework; import maps are not responsible
for HTML. Package and CDN routing (version ranges, mirrors, lockfiles) is out of scope:
use mport, and point a page import map at what it
resolves.
Deferred PRD items (HTML Include, further export metadata, a bundle compiler format) are listed with reasons in
PRD coverage and extensions.