Runtime, mount and the DOM contract
Normative reference for what mount() does, every DOM attribute it reads and writes, and how
your component reaches the browser. Almost everything here is runtime behaviour — observable
on a customer page, not checked before you ship — so the failure modes are silent by default.
Publish-time rules live in packaging-and-publishing.md; the
canonical index of every number and message is limits-and-errors.md.
Enforcement levels used below follow the legend in limits-and-errors.md. Runtime rules are additionally tagged:
- runtime throw (isolated) — throws, is caught by
mount, marks that one embed, other embeds continue. - silent runtime — no error, the widget just behaves differently than you expected.
- advisory — nothing checks it; getting it wrong is a bug in your plugin only.
Boot order
Two scripts, in this order, per page:
<!-- src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx:63-86 -->
<script src="https://assets.homerunner.io/w/runtime.iife.js" crossorigin="anonymous"></script>
<!-- single-widget plugin -->
<script src="https://assets.homerunner.io/p/my-plugin/my-plugin.iife.js"></script>
<!-- one widget of a suite -->
<script src="https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js"></script>
Never add crossorigin="anonymous" to a /p/ script tag — see
assets-and-urls.md.
runtime.iife.js is the host page’s responsibility. On platform-composed pages it is emitted for
you, in this order, by getDefaultJsUrls(). In a hand-written embed you must load it yourself,
first.
Both shapes. Since widget-core 0.10.0 your client IIFE does not bundle React, React DOM, React Query or the widget-core runtime. The Vite preset externalizes them onto the runtime global:
// packages/homerunner-widget-core/src/vite/index.ts:141-153 — RUNTIME_GLOBALS
{
react: "HRWidgetRuntime.React",
"react/jsx-runtime": "HRWidgetRuntime.jsxRuntime",
"react-dom": "HRWidgetRuntime.ReactDOM",
"react-dom/client": "HRWidgetRuntime",
"@tanstack/react-query": "HRWidgetRuntime",
"@homerunner-next/widget-core/runtime": "HRWidgetRuntime.widgetCore",
}
Consequences you must design around:
| Fact | Consequence | Enforcement |
|---|---|---|
mount is HRWidgetRuntime.widgetCore.mount, not your copy | Mount fixes reach your published bundle without a rebuild. The behaviour on this page is the fleet’s, not the version in your node_modules. | silent runtime |
| The runtime script must execute first | Without it every externalized symbol is undefined. getRuntime() throws [widget-core] window.HRWidgetRuntime is not available. Make sure the runtime.iife.js <script> tag loads before your widget entry. | runtime throw |
Only /runtime is externalized | /globals, /schema, /plugin-schema, /urls, /spacing, /utils stay bundled in your IIFE. | — |
react-dom/server is not on the runtime | Server APIs exist only inside the SSR sandbox (component-and-ssr-module.md). | runtime throw |
| Externalization is a build-only transform | vite dev resolves all of it from node_modules, so dev cannot catch a runtime-version skew. | silent runtime |
Not supported yet. The
HRWidgetRuntimeTypeScript interface inpackages/homerunner-widget-core/src/globals.ts:15-69does not declarewidgetCore(noruseQueries/keepPreviousData, which the live runtime IIFE does export). The build maps them regardless — the type is behind the runtime. Cast if you must read them directly.
Registration
Registration writes a page-global registry. It is what the dashboard playground, the CSR layout
child loader and mount’s category fallback all read.
// packages/homerunner-widget-core/src/globals.ts:149-177
registerPlugin(key: string, component: ComponentType<any>, meta?: { category?: "content" | "layout" }): void
registerPluginWidget(pluginSlug: string, widgetSlug: string, component: ComponentType<any>, meta?): void
registerPluginWidget(p, w, C, meta) is exactly registerPlugin("{p}:{w}", C, meta) — same
registry, different spelling.
| Shape | Keyword | Registry key | Call |
|---|---|---|---|
| Single-widget (v1) | plugin:my-plugin | my-plugin | registerPlugin("my-plugin", W) |
| Suite (0.11.0+) | plugin:pdp-suite:stay-hero | pdp-suite:stay-hero | registerPluginWidget("pdp-suite", "stay-hero", W) |
The registry key is everything after the plugin: prefix — there is no separate namespace.
Both write window.HRPlugins[key] = { component, category? }; category is omitted entirely
when meta.category is falsy. Registration is idempotent (last write wins) and is a no-op when
window is undefined, so importing your entry in a Node test is safe.
| Rule | Shape | Enforcement |
|---|---|---|
| Every client bundle must register | Both | publish ERROR — the built-zip audit greps each assets.js bundle for the literal string HRPlugins and refuses a bundle that never registers (packaging-and-publishing.md) |
The registry key must equal the keyword minus plugin:, or nothing resolves your component | Both | silent runtime |
Layout widgets must pass { category: "layout" } | Suite | silent runtime — a layout with no recorded category still routes correctly if the bootstrap passes category to mount; get both wrong and the CSR path renders a content widget with no slots |
| One widget per entry file, per IIFE | Suite | advisory — hr-widget-build builds one entry per widgets[] slug, so a second registerPlugin in the same file has no manifest entry and no bundle of its own |
The renderer itself never reads HRPlugins — it loads your SSR bundle directly. The registry is
what the dashboard playground preview and a CSR layout’s slot-child loader read, and it is what the
publish audit proves is present. Note that the audit only checks the string: a bundle that
registers under the wrong key passes publish and fails silently at runtime.
mount(container, config)
// packages/homerunner-widget-core/src/runtime/mount.tsx:251
export function mount(container: HTMLElement, config: MountConfig): void
Returns void. There is no unmount return value — the handle is on the DOM element (see The unmount handle).
mount selects container.querySelectorAll('[data-hr-widget="${config.widgetType}"]') and
processes each match independently. container itself is never a match, so you always pass the
wrapper ([data-hr-widget-container]), not the host div.
MountConfig — every field
// packages/homerunner-widget-core/src/runtime/mount.tsx:42-88
interface MountConfig {
widget: ComponentType<any>;
widgetType: string;
category?: "content" | "layout";
resolveChildJsUrl?: (keyword: string) => string;
cssUrls?: string[];
extraCssUrls?: string[];
resolveDefaultCssUrls?: (widgetType: string) => string[];
shadowDOM?: boolean;
}
| Field | Type | Required (shape) | Rule | On violation |
|---|---|---|---|---|
widget | ComponentType<any> | Required — Both | Your default export. Receives the props described in component-and-ssr-module.md. | React throws during render → caught by the render boundary, host gets data-hr-error="render-failed" |
widgetType | string | Required — Both | The full keyword: plugin:{slug} or plugin:{slug}:{widget}. Doubles as the mount selector and the default CSS-URL seed. | A wrong value simply matches nothing — mount returns silently having done nothing (silent runtime) |
category | "content" | "layout" | Optional — Suite (0.11.0+) | "layout" routes the CSR path through LayoutCsrRenderer and makes the SSR path a no-op. Falls back to the registry’s category for plugin: keywords. | Omitted on both mount and registerPluginWidget → a layout takes the content path and renders with no renderedSlots (silent runtime) |
resolveChildJsUrl | (keyword) => string | Optional — Suite (layouts only) | How a CSR layout finds each slot child’s IIFE. Defaults to window.HRWidgetRuntime.resolveChildJsUrl. | Unresolvable → that child logs and its slot stays empty |
cssUrls | string[] | Optional — Both | Full override of the shadow-root stylesheet list. Beats the page-supplied URLs. | Overriding on an SSR page replaces the page’s content-hashed links with your guesses and forces a re-download (silent runtime) |
extraCssUrls | string[] | Optional — Both | Prepended to whichever base wins; use this instead of cssUrls for an extra sheet. Deduped against the base by stylesheet identity. | — |
resolveDefaultCssUrls | (widgetType) => string[] | Optional — Both | Consulted only when neither cssUrls nor the page provided any CSS. Defaults to the plugin resolver below. | — |
shadowDOM | boolean | Optional — Both, default true | false renders straight into the host, no shadow root, no portal container. | Must mirror the manifest’s shadowDOM — see When shadowDOM disagrees |
The bootstrap
Verbatim from the published suite. Copy it per widget; the only per-widget edits are the imports,
the two keyword strings, and category on a layout.
// pdp-suite (the published reference suite) — src/widgets/pdp-frame/index.tsx
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPluginWidget } from "@homerunner-next/widget-core/globals";
import PdpFrame from "./widget";
import "./pdp-frame.css";
registerPluginWidget("pdp-suite", "pdp-frame", PdpFrame, { category: "layout" });
function doMount() {
document
.querySelectorAll<HTMLElement>("[data-hr-widget-container]")
.forEach((container) => {
if (container.querySelector('[data-hr-widget="plugin:pdp-suite:pdp-frame"]')) {
mount(container, {
widget: PdpFrame,
widgetType: "plugin:pdp-suite:pdp-frame",
category: "layout",
});
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", doMount);
} else {
doMount();
}
A content widget of the same suite drops category from both calls:
// pdp-suite (the published reference suite) — src/widgets/stay-hero/index.tsx
registerPluginWidget("pdp-suite", "stay-hero", StayHero);
// …
mount(container, { widget: StayHero, widgetType: "plugin:pdp-suite:stay-hero" });
Scoping the scan to [data-hr-widget-container] matters. A server-rendered page emits exactly one
<div data-hr-page="…" data-hr-widget-container> wrapping every widget node
(packages/homerunner-renderer/pages/api/render/[feedId]/property/[slug].tsx:462), and a CSR
embed emits one per widget. Scanning document.body instead would work but breaks the containment
the platform relies on for slot children.
The DOM contract
The embed shape
<!-- src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx:45-47 -->
<div data-hr-widget-container>
<div id="kua7xey91zqkpfjr8uveq1z1:90" data-hr-widget="plugin:pdp-suite:stay-facts"
data-hr-min-height="140"></div>
</div>
Attributes mount reads
| Attribute | On | Required (shape) | Rule | On violation |
|---|---|---|---|---|
data-hr-widget | host | Required — Both | Must equal config.widgetType exactly. It is the selector. | No match — mount does nothing, no log (silent runtime) |
id | host | Required — Both | Colon-joined "{widgetId}:{feedId}". Split by extractWidgetId; feedId goes through Number(). | Missing → throws Widget ID is required (runtime throw, isolated). Present but not colon-joined → feedId is NaN, so the CSR feed fetch 404s and the host lands on data-hr-error="widget-fetch-failed" |
data-hr-ssr-id | host | Server-written — Both | Key into window.HRWidget.__WIDGET_PROPS__, and the identifierPrefix passed to hydrateRoot. | Absent on a legacy cached page → hydration falls back to React’s default prefix |
data-hr-ssr-props | host | Legacy fallback — Both | JSON props, used only when the registry has no entry. | Malformed JSON yields {} — which is truthy, so the widget still takes the hydrate path and hydrates against empty props |
data-hr-options | host | Optional — Both | JSON per-embed options override, merged at top priority by getFinalSettings. Also how a CSR layout pushes state onto its children. | Malformed JSON yields {} (silent runtime) |
data-hr-css | host | Legacy fallback — Both | JSON array of stylesheet URLs. | Non-array or malformed → [], so the default resolver wins |
data-hr-min-height | host | Optional — Both | Anti-CLS reservation. Bare number = px; anything else = a raw CSS length; 0/off/none/false opts out. | Unparseable values are passed to CSS verbatim and simply do not apply (silent runtime) |
data-hr-mounted | host | Written by mount — Both | Presence means “already mounted”; mount returns immediately. | See Idempotency |
template[shadowrootmode] | direct child of host | Server-written — Both | The Declarative Shadow DOM payload. Adopted before anything else. | — |
Resolution order for props, options and CSS is registry first, attributes second:
<!-- packages/homerunner-renderer/lib/render-utils.tsx:334-343 — one script per page -->
<script>(window.HRWidget = window.HRWidget || {}).__WIDGET_PROPS__ =
Object.assign(window.HRWidget.__WIDGET_PROPS__ || {},
{"{widgetId}:{feedId}:{instanceKey}": {props: {…}, options: {…}, css: ["…"]}});</script>
If that inline script is blocked (CSP, an ad blocker, an HTML-rewriting CDN) the markup is present
but the props are not, so mount cannot hydrate and falls through to the CSR path — which is why
the visibility gate is cleared on both paths.
Attributes and properties mount writes
| Written | On | When |
|---|---|---|
data-hr-mounted="1" | host | Synchronously, before any async work |
data-hr-error | host | On failure — see the vocabulary |
data-hr-theme="light|dark" | shadow host, else host | Once the colour scheme is known; kept live across OS flips for auto/global |
removes data-hr-scheme | shadow host, else host | The moment JS takes over theming, so the server’s parse-time media-query fallback cannot fight the React tree |
data-hr-widget-category="layout" | shadow host, else host | CSR layout embeds only; the SSR shell already carries it from the server |
data-hr-preloading="1", data-hr-skeleton | host | While a CSR reservation is held |
inline min-height, transition | host | While a reservation is held; both restored on release |
el.__hrWidgetUnmount | host (JS property) | Always |
div.hr-react-root, div.hr-portal-container, <link rel="stylesheet"> | inside the shadow root | When shadow DOM is on |
<style id="hr-widget-preload-style"> | document.head | Once per document, when any reservation is applied |
The shadow host is normally the [data-hr-widget] element itself. If that element’s tag cannot
take a shadow root, setupShadowDOM inserts a wrapper <div> around it and the wrapper becomes
the host — so data-hr-theme lands on the wrapper, not on [data-hr-widget]. Allowed tags:
ARTICLE ASIDE BLOCKQUOTE BODY DIV FOOTER H1–H6 HEADER MAIN NAV P SECTION SPAN, plus any
hyphenated custom element.
data-hr-error(runtime, on the host) is a different attribute fromdata-hr-widget-error(server, on a hidden failure breadcrumb the renderer emits in place of a widget). Grep for both — see ../troubleshooting.md.
Idempotency and per-embed isolation
Both shapes. mount guards on the data-hr-mounted DOM attribute, not a module-level
Set. A module Set cannot work: your IIFE, a system widget’s IIFE and a layout’s IIFE each hold
their own module instances, and only a shared DOM marker dedupes across them. The attribute is set
synchronously so two near-simultaneous callers cannot both pass.
Each element is mounted inside its own try. A throw is caught, logged, and marks that element:
// packages/homerunner-widget-core/src/runtime/mount.tsx:258-271
allRootEls.forEach((rootEl) => {
try {
mountOne(rootEl, container, config, resolveDefaults);
} catch (error) {
console.error(
`[hr-widget] "${config.widgetType}" mount failed for one embed — other embeds continue.`,
error,
);
rootEl.setAttribute("data-hr-error", "mount-failed");
}
});
Nothing propagates to your caller — not even a missing id. mount never rejects and never
rethrows, so wrapping the call in your own try tells you nothing. Read the console and the
data-hr-error attribute instead.
One consequence to know: data-hr-mounted is set before the id check, so a failed embed
keeps the marker. Calling mount again after fixing the DOM will skip it. Remove
data-hr-mounted (or call el.__hrWidgetUnmount()) first.
Asynchronous failures after the CSS gate — a rejected cssReady, a throw inside hydrateRoot —
are caught separately and produce the same marker with a different line:
[hr-widget] "<widgetType>" deferred mount step failed (widget <widgetId>, feed <feedId>).
Shadow DOM lifecycle
Both shapes, when config.shadowDOM !== false and HTMLElement.prototype.attachShadow
exists. mount tries three things in order.
- Adopt a declarative shadow root. If the host already has a
shadowRoot(native DSD — the browser attached and styled it at parse), adoption returns immediately withcssPending: false: no hide, no migrate, no CSS wait, no mount blink. If instead an inerttemplate[shadowrootmode]sits in the light DOM, the ponyfill attaches the root itself and moves the template content in; its<link>s only connect now, socssPending: trueand the react-root is hidden until they apply. - Otherwise
setupShadowDOM(rootEl, cssUrls), which attaches{ mode: "open" }, appends one<link rel="stylesheet">per URL, thendiv.hr-react-rootanddiv.hr-portal-container. - Then migrate, if there was server markup:
migrateSSRContentmoves the host’s child nodes into the react-root withappendChild. It never re-serialises throughinnerHTML— that would destroy the identity hydration matches against.
Adoption looks for the class names hr-react-root and hr-portal-container, not data
attributes. If hr-react-root is absent, adoption returns null and mount falls through to
step 2. A missing hr-portal-container is created on the fly.
The visibility gate is shadowSetup.reactRoot.style.visibility = "hidden" — on the react-root,
never on the host. It is cleared by both the hydrate path and the CSR render path, so a widget
that looked server-rendered and then fell through to CSR still becomes visible.
cssReady resolves when every shadow <link> has a .sheet, on load/error, or after a hard
3000 ms timeout (it polls every 16 ms, because cached sheets often never fire load). A
failed sheet logs [HRWidget] Failed to load CSS in shadow DOM: <href> and resolves anyway — CSS
never blocks hydration.
Not supported yet.
adoptDeclarativeShadowRoot,waitForShadowLinksandhasRenderedChildrenexist in source but are not exported from@homerunner-next/widget-core/runtime. You cannot reimplement this lifecycle; usemount.setupShadowDOM,migrateSSRContent,getCSSUrlsFromElementandisShadowDOMSupportedare exported — see sdk-exports.md.
Not supported yet. A widget whose entire server output is a hoistable tag (
<style>,<link>,<script>,<meta>,<title>,<base>) reads as no server markup and client-renders instead of hydrating. Always nest server output inside a real element.
Why you must not call hydrateRoot yourself
The server renders each widget as an isolated root inside WidgetHydrationTree with
identifierPrefix set to the data-hr-ssr-id. useId values derive from tree position plus
that prefix, so any drift makes React regenerate the tree and discard every byte of your SSR DOM.
mount mirrors the wrapper shape and the prefix exactly:
// packages/homerunner-widget-core/src/runtime/mount.tsx:507-525
<WidgetHydrationTree queryClient={getSharedQueryClient()}
portalContainer={portalContainer} shadowHost={shadowHost}>
{withRenderBoundary(<Widget {...ssrProps} />)}
</WidgetHydrationTree>
// hydrateRoot(renderTarget, ssrNode, { identifierPrefix: ssrIdAttr })
When shadowDOM disagrees
Both shapes. The root manifest’s shadowDOM flag (manifest-root.md) gates
whether the renderer emits a DSD template at all. It must mirror what every one of your bootstraps
passes to mount.
| Manifest | mount() | Result |
|---|---|---|
true (default) | default / true | Correct. DSD adopted, hydrated in place. |
false | false | Correct. Light-DOM SSR, no isolation. |
true | false | Broken on any DSD-capable request. The browser attaches the server shadow root at parse; mount renders into the light DOM, which a host with a shadow root never displays. hasRenderedChildren reads false, so it takes the CSR path. Symptom: visible server content that never hydrates, sitting under an invisible second render — and it looks fine on a browser that missed the DSD bucket, which is what makes it so hard to spot. |
false | true | A shadow root is created around light-DOM SSR markup, which is then migrated into it. Works, but you lose the parse-time styling DSD buys. |
Enforcement: silent runtime. Nothing at publish compares the two.
CSS URL resolution
// packages/homerunner-widget-core/src/runtime/mount.tsx:202-213
const base = config.cssUrls ?? pageProvided; // registry css, else data-hr-css
const resolvedBase = base.length > 0 ? base : resolveDefaults(widgetType);
const extras = (config.extraCssUrls ?? []).filter((u) => !baseKeys.has(cssIdentityKey(u)));
return [...extras, ...resolvedBase];
Precedence: cssUrls → page-provided (registry css, else data-hr-css) → resolveDefaultCssUrls.
extraCssUrls are prepended to whichever base wins, so build-time extras keep their cascade
position without displacing the page’s content-hashed URLs.
Dedupe is by stylesheet identity, not string equality: the pathname with a leading /w/ or
/dist/ stripped and an 8-hex content hash removed. /w/stay-hero/stay-hero.css,
/dist/stay-hero/stay-hero-1a2b3c4d.css and /dist/stay-hero/stay-hero.css are one sheet.
The default resolver for a plugin: keyword:
| Environment | Single-widget | Suite |
|---|---|---|
Vite dev (__HR_WIDGET_DEV__ === true) | /dist/{slug}/{slug}.css | /dist/{slug}/{widget}/{widget}.css |
| Production | {proxy}/p/{slug}/{slug}.css | {proxy}/p/{slug}/{widget}/{widget}.css |
{proxy} is window.HRWidgetRuntime.proxyBaseUrl when the host page sets it, else
https://assets.homerunner.io. Non-plugin keywords default to []. Full URL rules:
assets-and-urls.md.
Layout widgets
Suite only (widget-core 0.11.0+). The complete layout contract — slots, prefill, presets,
page assignment — is layouts.md. What mount itself does:
// packages/homerunner-widget-core/src/runtime/mount.tsx:297-337
const effectiveCategory =
config.category ??
(config.widgetType.startsWith("plugin:")
? getRegisteredWidget(config.widgetType)?.category
: undefined);
// …
if (effectiveCategory === "layout" && hasSSR) {
return; // deliberate no-op
}
- Category fallback.
config.categorywins; for aplugin:keyword an omitted value falls back to whatregisterPluginWidget(..., { category: "layout" })recorded. The registry write is in the same IIFE, so it always precedes the mount. - SSR pages: the mount is a deliberate no-op. The shell is static server HTML and each slot child self-hydrates through its own IIFE. Running the CSR path here would throw away the server-rendered slots and re-fetch everything. This is correct behaviour, not a bug.
- CSR embeds:
LayoutCsrRenderer. An empty layout host (no server markup) falls through and the layout rebuilds its own slot tree: fetch the layout widget, readsettings.slotsasRecord<slotName, childWidgetId[]>, fetch each child row, load each child IIFE viaresolveChildJsUrl+loadScriptOnce, wait for registration, render childless host nodes, andmounteach child imperatively after commit.
What a CSR layout pushes onto every slot child, as a data-hr-options JSON string:
// packages/homerunner-widget-core/src/runtime/layout-csr-renderer.tsx:87-102
{
__parentColorScheme: layoutScheme ?? "light",
// only when the layout resolved a property:
filter: { feed_id, property, platform: null },
}
platform: null is load-bearing, not tidiness: a child’s stored platform would AND-filter it to
zero results when the pushed property belongs to a different connection, and undefined would be
skipped by the merge.
Slot-child host nodes are keyed by ${childId}:${optionsAttr}, so a live colour-scheme edit
recreates the node — children read __parentColorScheme once, at mount, and data-hr-mounted
blocks a re-mount.
Per-child failures are contained and logged; the layout still renders:
| Console line | Cause |
|---|---|
[layout-csr] Slot "<name>" child <id> failed to resolve; skipping. | The child widget row could not be fetched |
[layout-csr] Child widget "<keyword>" failed to load/register; its slots stay empty. | IIFE load failed, or registration timed out after 10 000 ms ([widget-core] widget "<type>" did not register within 10000ms) |
[layout-csr] Skipping nested layout child "<keyword>" (<id>) in slot "<slot>". | A system layout (keyword ends -layout) bound into a slot |
[layout-csr] Skipping nested plugin layout child "<keyword>" — layouts cannot nest. | A plugin layout, caught by its registered category |
[layout-csr] Could not verify property "<slug>"; rendering the layout anyway. | The property check failed with a 5xx or a network blip — never a reason to blank a working layout |
[layout-csr] Property "<slug>" was not found in feed <n>. Check the layout's Property setting, or the data-hr-options filter.property on the embed. | The one whole-layout failure. A permanent 4xx on the property check renders data-hr-error="property-not-found" instead of the slots, so a bad slug is diagnosed once rather than 404ing in every slot at the same time |
[widget-core] failed to load script: <src> | loadScriptOnce could not fetch a child IIFE |
Not supported yet. A plugin layout on a server-rendered page can never be interactive. The renderer emits its shell with no
data-hr-ssr-idand no props-registry entry, and the client mount no-ops — so the shell is static HTML forever. Its slot children hydrate normally and are fully interactive. Put interactivity in a content widget, not the shell.
Live preview: the options-updated event
Both shapes. A CustomEvent named options-updated, dispatched on the host element, whose
detail is the complete options object:
// packages/create-hr-plugin/template/src/dev/DevPanel.ts:25-29
el.dispatchEvent(new CustomEvent("options-updated", { detail: settings }));
| Path | Listener | Effect |
|---|---|---|
| CSR content widget | ClientWrapperForWidget | Re-runs getFinalSettings with the payload as the widget’s settings, so colorScheme: "global" still resolves against feed branding. Re-themes the host. Also resets a latched render failure. |
| CSR layout | LayoutCsrRenderer | Re-resolves slot bindings and layout settings live |
| Hydrated SSR widget | none | Nothing happens |
Not supported yet. The hydrated SSR path installs no
options-updatedlistener. A widget on a server-rendered page cannot be live-edited through this channel.
This is also the reason the unmount handle clears leftover hoistable tags: React does not claim them during hydration, so an unmount → re-mount cycle would otherwise still read as “has server markup”, re-hydrate, and silently have no live-preview listener.
The unmount handle
mount sets rootEl.__hrWidgetUnmount — a plain JS property, because mount may be a different
module instance in each IIFE and a DOM property is the only shared channel. Calling it:
- cancels any pending CSS-deferred first render,
- disposes the preload reservation and the theme controllers,
root.unmount(),- empties the shadow react-root (removing React’s unclaimed hoistables),
- resets
visibility— mandatory, or a mount torn down during the CSS window leaves a permanently hidden root, - removes
data-hr-mountedand deletes itself.
LayoutCsrRenderer calls it on slot children it discards; if a child has no handle (a stale bundle
or a hand-rolled mount) it warns:
[HRWidget] discarded slot child "<type>" (id <id>) has no unmount handle
(stale bundle or custom mount) — its widget tree may stay orphaned.
The type UnmountableEl is not exported from /runtime; declare it yourself if you need it.
Anti-CLS preload reservation
Both shapes. Applied only when the embed has no server content and is not a layout —
i.e. CSR embeds and ssr: false widgets. Layout shells defer to their children’s own
reservations.
Height source, in order:
data-hr-min-heighton the host. Bare number → px; anything else → raw CSS length;0/off/none/false→ opt out.preloadMinHeightHint(widgetType, options).
// packages/homerunner-widget-core/src/utils/preload-hints.ts:65-89
const DEFAULT_MIN_HEIGHTS = { explorer: …, "search-bar": …, booking: …, calendar: …,
"multi-calendar": …, "property-card": …, "related-properties": …, gallery: … };
Plugin widget types have no entry in that table. preloadMinHeightHint returns null for
every plugin:* keyword, so a plugin reserves nothing unless you supply a height. Two channels:
| Channel | Shape | Effect | Enforcement |
|---|---|---|---|
expectedHeight on the widget summary (widget-summary.md) | Suite | The renderer stamps data-hr-min-height="{n}" on a CSR-only host, and the dashboard bakes it into the generated embed snippet | advisory — nothing warns if you omit it |
data-hr-min-height on the embed | Both | Direct, wins over everything | advisory |
While reserved, the host carries data-hr-preloading="1" (and data-hr-skeleton when a shaped
silhouette exists) and an inline min-height. It is released the moment real content lands in the
render target, via a MutationObserver, with a 250 ms min-height ease; a hard 10 000 ms
max-hold collapses it honestly if the widget never paints. A host that already carries its own
inline min-height keeps it — the reservation manages the attribute only.
The data-hr-error vocabulary
Two places carry this attribute: the host element (a QA/support marker) and the rendered
div.hr-widget-error-state (the visible fallback).
| Value | On | Meaning | Cleared when |
|---|---|---|---|
mount-failed | host | A throw inside mountOne (classically a missing id), or a rejection in the CSS-deferred hydrate/render step | Never automatically |
render-failed | host + error element | WidgetRenderBoundary caught a synchronous render throw — most often configZod.parse() on a stored null/'' because the component skipped parseWidgetConfig | A changed resetKey (the next options-updated payload) |
rate-limited | host + error element | The widget-config fetch returned 429 | A successful retry |
widget-fetch-failed | host + error element | Any other widget-config fetch failure | A successful retry |
property-not-found | error element only | A CSR layout was pointed at a property slug that does not exist in the feed | — |
WidgetRenderBoundary also logs, and best-effort stamps data-hr-theme from
prefers-color-scheme when nothing else has, so the error card is not a glaring white box on a
dark page:
[hr-widget] "<widgetType>" render failed (widget <widgetId>, feed <feedId>)
— rendering the error state instead of a blank node.
The error element also carries role="alert", class="hr-widget-error-state",
data-hr-feed-id and data-hr-widget-id.
Runtime bounds this page depends on
Canonical index: limits-and-errors.md.
| Bound | Value | What it governs |
|---|---|---|
| Shadow CSS gate | 3000 ms | cssReady resolves regardless; CSS never blocks hydration |
| Slot-child registration | 10 000 ms | whenWidgetRegistered rejects; that slot child is skipped |
| Preload max hold | 10 000 ms | The reservation collapses even if the widget never paints |
| Preload release ease | 250 ms | min-height transition, skipped under prefers-reduced-motion |
Related
- What each render path passes your component: component-and-ssr-module.md
- Why
props.optionsis raw andparseWidgetConfigis mandatory: ../concepts/settings-and-options.md - The whole picture, server to screen: ../concepts/how-a-widget-renders.md
- Theming off
data-hr-theme/data-hr-scheme: ../recipes/styling-and-theming.md hr-react-root/hr-portal-containerand dialogs: ../recipes/portals-and-dialogs.md- Bundle and stylesheet URLs: assets-and-urls.md
- Slots,
prefill, presets and page assignment: layouts.md - Every export named here: sdk-exports.md
- Which SDK version has which of these features, and what npm actually serves today: ../get-started/01-install-and-versions.md