createOnDemandFeature
import { createOnDemandFeature } from "mountly";Builds an OnDemandFeature — a widget plus the lifecycle, caching, and trigger setup. This is the layer you wire to the page.
Signature
Section titled “Signature”function createOnDemandFeature( options: CreateOnDemandFeatureOptions): OnDemandFeature;CreateOnDemandFeatureOptions
Section titled “CreateOnDemandFeatureOptions”You need either moduleUrl (the recommended zero-config path) or an explicit loadModule.
| Field | Type | Required | Notes |
|---|---|---|---|
moduleId | string | yes | Stable identifier. Used as the module-cache key and the data-cache key prefix. |
moduleUrl | string | yes¹ | URL to the widget bundle. Drives auto-loading and is forwarded to the adapter so shadow-DOM CSS adoption works without extra wiring. |
assetOptions | CssAutoLoadOptions | no | Forwarded to createModuleLoader when moduleUrl is set. Default { css: "none" }. Set { css: "auto" } if you want a global <link> for light-DOM mounts. |
loadModule | () => Promise<FeatureModule> | yes¹ | Override of the auto-derived loader. Use when you need a custom import (e.g. a function that adds telemetry). |
loadData | (ctx: FeatureContext) => Promise<unknown> | no | Optional data fetch. Cached by getCacheKey. |
getCacheKey | (ctx: FeatureContext) => string | no | Override the default cache-key builder. |
render | (args: RenderArgs) => void | no | Override the default render. The default mounts with the supplied props plus the moduleUrl so the adapter can adopt the sibling stylesheet. |
¹ At least one of moduleUrl / loadModule is required. Setting both is allowed; an explicit loadModule wins.
RenderArgs
Section titled “RenderArgs”{ mod: FeatureModule; // resolved loadModule() data: unknown; // resolved loadData() or null context: FeatureContext; // current activation context container: HTMLElement; // mount target props: Record<string, unknown>; // props from mount() / attach()}FeatureModule
Section titled “FeatureModule”interface FeatureModule { mount(container: HTMLElement, props: Record<string, unknown>): void; unmount?(container: HTMLElement): void; update?(container: HTMLElement, props: Record<string, unknown>): void;}Anything returned by mountly-react / mountly-vue / mountly-svelte’s createWidget() matches this shape.
Returned OnDemandFeature
Section titled “Returned OnDemandFeature”interface OnDemandFeature { readonly id: string;
preload(context?: Partial<FeatureContext>): Promise<void>; activate(context?: Partial<FeatureContext>): Promise<void>; mount(container: HTMLElement, context?, props?): Promise<{ unmount: () => void }>; update(container: HTMLElement, props, context?): Promise<void>; attach(options: AttachOptions): () => void; abort(): void;
getState(): FeatureState; isAborted(): boolean; getMounts(): ReadonlyArray<HTMLElement>;}attach(options)
Section titled “attach(options)”The high-level wiring. Sets up the trigger, calls mount / unmount for you, returns a cleanup function.
| Field | Type | Default | Notes |
|---|---|---|---|
trigger | HTMLElement | required | The element the user interacts with. |
mount | HTMLElement | trigger | Where to render. Defaults to the trigger element itself. |
context | Partial<FeatureContext> or () => Partial<FeatureContext> | — | Static or dynamic context. |
props | Record<string, unknown> or () => Record<string, unknown> | — | Static or dynamic props. |
preloadOn | "hover" · "viewport" · "idle" · "media" · false | "hover" | When to start preloading. |
activateOn | "click" · "hover" · "focus" · "viewport" · "idle" · "media" · "url-change" | "click" | When to mount. |
activateOnUrlEvents | ("popstate" | "hashchange" | "pushstate" | "replacestate")[] | all four | Used with activateOn: "url-change". |
preloadOnMediaQuery | string | — | Required when preloadOn: "media". |
activateOnMediaQuery | string | — | Required when activateOn: "media". |
idleTimeout | number | — | Passed to requestIdleCallback({ timeout }) for idle triggers. |
viewportRootMargin | string | "0px" | Forwarded to IntersectionObserver for viewport triggers. |
toggle | boolean | true | A second activate event unmounts. |
onMount | ({ unmount }) => void | — | Fires after each successful mount. |
onUnmount | () => void | — | Fires after each unmount. |
onError | (err: unknown) => void | — | Fires on any thrown error. |
Returns a cleanup function that detaches the trigger and unmounts any active mount.
preload(ctx?)
Section titled “preload(ctx?)”Begins module fetch (and loadData if ctx is provided). Idempotent — calling it twice while preloading shares one promise.
activate(ctx?)
Section titled “activate(ctx?)”Ensures module + data are ready. Cheaper than mount because it doesn’t call render. Useful for “load now, render later” flows.
mount(container, ctx?, props?)
Section titled “mount(container, ctx?, props?)”The full path. Resolves to { unmount } for that container only.
update(container, props, ctx?)
Section titled “update(container, props, ctx?)”Live prop update. Calls mod.update() if defined; otherwise re-renders via render(...). No-op if the container isn’t currently mounted.
abort()
Section titled “abort()”Aborts any in-flight preload / activate. State transitions to "aborted". The next preload / activate / mount recovers automatically.
getState()
Section titled “getState()”Returns the current FeatureState:
type FeatureState = | "idle" | "preloading" | "preloaded" | "activating" | "activated" | "mounted" | "aborted";getMounts()
Section titled “getMounts()”Returns a read-only array of currently-mounted containers. Useful for debugging or for invalidation logic.
Zero-config (recommended)
Section titled “Zero-config (recommended)”For the common case (load a widget bundle and let its companion CSS apply automatically), pass moduleUrl and you’re done:
import { createOnDemandFeature } from "mountly";
const signup = createOnDemandFeature({ moduleId: "signup-card", moduleUrl: "/widgets/signup-card/dist/index.js",});
signup.attach({ trigger: btn, preloadOn: "hover", activateOn: "click" });mountly synthesises loadModule (via createModuleLoader) and a default render that forwards moduleUrl into the adapter’s mount props. The adapter fetches dist/index.css (sibling of the JS bundle), sends Accept: text/css so dev servers like Vite return raw CSS rather than HMR-wrapped JS, and applies it before render (injected globally for the default light-DOM mount, or adopted into the shadow root when shadow: true). See Styling for the full picture.
Worked example with overrides
Section titled “Worked example with overrides”When you need bespoke loading or rendering, all the original hooks are still available:
import { createOnDemandFeature } from "mountly";
const payment = createOnDemandFeature({ moduleId: "payment-breakdown",
loadModule: () => import("./payment-breakdown/dist/peer.js"),
loadData: async (ctx) => { return fetch(`/api/quote?plan=${ctx.plan}`).then((r) => r.json()); },
getCacheKey: (ctx) => `payment:${ctx.plan ?? "free"}`,
render: ({ mod, data, container, props }) => { mod.mount(container, { ...props, ...data }); },});
// Attach to a button — preload on hover, mount on clickconst detach = payment.attach({ trigger: document.querySelector("#buy")!, mount: document.querySelector("#modal-root")!, context: () => ({ plan: getCurrentPlan() }), props: () => ({ currency: "GBP" }), preloadOn: "hover", activateOn: "click", toggle: true, onError: (err) => Sentry.captureException(err),});
// Astro-style media hydration parity:payment.attach({ trigger: document.querySelector("#responsive-slot")!, preloadOn: false, activateOn: "media", activateOnMediaQuery: "(max-width: 50em)",});
// Astro-style visible rootMargin parity:payment.attach({ trigger: document.querySelector("#below-fold")!, preloadOn: "viewport", activateOn: "viewport", viewportRootMargin: "200px",});
// On route change:window.addEventListener("popstate", () => { payment.abort(); detach();});createWidgetBundle
Section titled “createWidgetBundle”Sibling primitive for the multi-widget case: one bundle, many features, shared loading.
import { createWidgetBundle } from "mountly";
const dashboard = createWidgetBundle({ moduleUrl: "/widgets/dashboard/dist/index.js", // assetOptions?: CssAutoLoadOptions // forwarded to createModuleLoader});
const counter = dashboard.feature({ moduleId: "counter", export: "counter" });const clock = dashboard.feature({ moduleId: "clock", export: "clock" });| Field | Type | Notes |
|---|---|---|
moduleUrl | string (required) | URL of the bundle’s JS entry. Loaded once; shared across every feature(...) call. |
assetOptions | CssAutoLoadOptions | Default { css: "none" }. Set { css: "auto" } to inject the bundle’s CSS via a global <link> (useful for default light-DOM bundles served from a different origin). |
feature(opts) accepts the same fields as createOnDemandFeature (minus moduleUrl/loadModule) plus an export name. The bundle’s URL is automatically threaded into mount props so framework adapters apply the sibling .css (injected globally in default light DOM, or adopted into the shadow root when shadow: true).
See Multi-widget bundles for when to use it and the test suite (tests/multi-widget-bundles.spec.ts) for the asserted guarantees: one JS fetch, one CSS fetch, one shared CSSStyleSheet instance across all features.
Errors
Section titled “Errors”loadModule failures that look like import-map misconfigurations get wrapped with a hint:
[mountly] loadModule for "payment-breakdown" failed to resolve.If you're in plain HTML, check that your <script type="importmap"> maps the bare specifier …Other errors propagate unchanged. onError (in attach) catches both.