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;
FieldTypeRequiredNotes
moduleIdstringyesStable identifier. Used as the module-cache key and the data-cache key prefix.
loadModule() => Promise<FeatureModule>yesCalled once per page (deduplicated). Should resolve to the widget’s module.
loadData(ctx: FeatureContext) => Promise<unknown>noOptional data fetch. Cached by getCacheKey.
getCacheKey(ctx: FeatureContext) => stringnoOverride the default cache-key builder.
render(args: RenderArgs) => voidyesCalled on each mount. Responsible for rendering the widget.
{
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.

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

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.