Skip to content

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.

function createOnDemandFeature(
options: CreateOnDemandFeatureOptions
): OnDemandFeature;

You need either moduleUrl (the recommended zero-config path) or an explicit loadModule.

FieldTypeRequiredNotes
moduleIdstringyesStable identifier. Used as the module-cache key and the data-cache key prefix.
moduleUrlstringyes¹URL to the widget bundle. Drives auto-loading and is forwarded to the adapter so shadow-DOM CSS adoption works without extra wiring.
assetOptionsCssAutoLoadOptionsnoForwarded 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>noOptional data fetch. Cached by getCacheKey.
getCacheKey(ctx: FeatureContext) => stringnoOverride the default cache-key builder.
render(args: RenderArgs) => voidnoOverride 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.

{
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()
}
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.

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>;
}

The high-level wiring. Sets up the trigger, calls mount / unmount for you, returns a cleanup function.

FieldTypeDefaultNotes
triggerHTMLElementrequiredThe element the user interacts with.
mountHTMLElementtriggerWhere to render. Defaults to the trigger element itself.
contextPartial<FeatureContext> or () => Partial<FeatureContext>Static or dynamic context.
propsRecord<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 fourUsed with activateOn: "url-change".
preloadOnMediaQuerystringRequired when preloadOn: "media".
activateOnMediaQuerystringRequired when activateOn: "media".
idleTimeoutnumberPassed to requestIdleCallback({ timeout }) for idle triggers.
viewportRootMarginstring"0px"Forwarded to IntersectionObserver for viewport triggers.
togglebooleantrueA second activate event unmounts.
onMount({ unmount }) => voidFires after each successful mount.
onUnmount() => voidFires after each unmount.
onError(err: unknown) => voidFires on any thrown error.

Returns a cleanup function that detaches the trigger and unmounts any active mount.

Begins module fetch (and loadData if ctx is provided). Idempotent — calling it twice while preloading shares one promise.

Ensures module + data are ready. Cheaper than mount because it doesn’t call render. Useful for “load now, render later” flows.

The full path. Resolves to { unmount } for that container only.

Live prop update. Calls mod.update() if defined; otherwise re-renders via render(...). No-op if the container isn’t currently mounted.

Aborts any in-flight preload / activate. State transitions to "aborted". The next preload / activate / mount recovers automatically.

Returns the current FeatureState:

type FeatureState =
| "idle"
| "preloading"
| "preloaded"
| "activating"
| "activated"
| "mounted"
| "aborted";

Returns a read-only array of currently-mounted containers. Useful for debugging or for invalidation logic.

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.

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 click
const 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();
});

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" });
FieldTypeNotes
moduleUrlstring (required)URL of the bundle’s JS entry. Loaded once; shared across every feature(...) call.
assetOptionsCssAutoLoadOptionsDefault { 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.

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.

  • Lifecycle — the state diagram.
  • Triggers — what preloadOn / activateOn map to.
  • Caching — how loadData and getCacheKey interact.
  • Styling for how shadow-DOM and noscript styling fit together.