Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

FactConsequenceEnforcement
mount is HRWidgetRuntime.widgetCore.mount, not your copyMount 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 firstWithout 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 runtimeServer APIs exist only inside the SSR sandbox (component-and-ssr-module.md).runtime throw
Externalization is a build-only transformvite dev resolves all of it from node_modules, so dev cannot catch a runtime-version skew.silent runtime

Not supported yet. The HRWidgetRuntime TypeScript interface in packages/homerunner-widget-core/src/globals.ts:15-69 does not declare widgetCore (nor useQueries / 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.

ShapeKeywordRegistry keyCall
Single-widget (v1)plugin:my-pluginmy-pluginregisterPlugin("my-plugin", W)
Suite (0.11.0+)plugin:pdp-suite:stay-heropdp-suite:stay-heroregisterPluginWidget("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.

RuleShapeEnforcement
Every client bundle must registerBothpublish 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 componentBothsilent runtime
Layout widgets must pass { category: "layout" }Suitesilent 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 IIFESuiteadvisory — 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;
}
FieldTypeRequired (shape)RuleOn violation
widgetComponentType<any>Required — BothYour 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"
widgetTypestringRequired — BothThe 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) => stringOptional — 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
cssUrlsstring[]Optional — BothFull 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)
extraCssUrlsstring[]Optional — BothPrepended to whichever base wins; use this instead of cssUrls for an extra sheet. Deduped against the base by stylesheet identity.
resolveDefaultCssUrls(widgetType) => string[]Optional — BothConsulted only when neither cssUrls nor the page provided any CSS. Defaults to the plugin resolver below.
shadowDOMbooleanOptional — Both, default truefalse 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

AttributeOnRequired (shape)RuleOn violation
data-hr-widgethostRequired — BothMust equal config.widgetType exactly. It is the selector.No match — mount does nothing, no log (silent runtime)
idhostRequired — BothColon-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-idhostServer-written — BothKey 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-propshostLegacy fallback — BothJSON 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-optionshostOptional — BothJSON 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-csshostLegacy fallback — BothJSON array of stylesheet URLs.Non-array or malformed → [], so the default resolver wins
data-hr-min-heighthostOptional — BothAnti-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-mountedhostWritten by mount — BothPresence means “already mounted”; mount returns immediately.See Idempotency
template[shadowrootmode]direct child of hostServer-written — BothThe 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

WrittenOnWhen
data-hr-mounted="1"hostSynchronously, before any async work
data-hr-errorhostOn failure — see the vocabulary
data-hr-theme="light|dark"shadow host, else hostOnce the colour scheme is known; kept live across OS flips for auto/global
removes data-hr-schemeshadow host, else hostThe 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 hostCSR layout embeds only; the SSR shell already carries it from the server
data-hr-preloading="1", data-hr-skeletonhostWhile a CSR reservation is held
inline min-height, transitionhostWhile a reservation is held; both restored on release
el.__hrWidgetUnmounthost (JS property)Always
div.hr-react-root, div.hr-portal-container, <link rel="stylesheet">inside the shadow rootWhen shadow DOM is on
<style id="hr-widget-preload-style">document.headOnce 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 from data-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.

  1. 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 with cssPending: false: no hide, no migrate, no CSS wait, no mount blink. If instead an inert template[shadowrootmode] sits in the light DOM, the ponyfill attaches the root itself and moves the template content in; its <link>s only connect now, so cssPending: true and the react-root is hidden until they apply.
  2. Otherwise setupShadowDOM(rootEl, cssUrls), which attaches { mode: "open" }, appends one <link rel="stylesheet"> per URL, then div.hr-react-root and div.hr-portal-container.
  3. Then migrate, if there was server markup: migrateSSRContent moves the host’s child nodes into the react-root with appendChild. It never re-serialises through innerHTML — 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, waitForShadowLinks and hasRenderedChildren exist in source but are not exported from @homerunner-next/widget-core/runtime. You cannot reimplement this lifecycle; use mount. setupShadowDOM, migrateSSRContent, getCSSUrlsFromElement and isShadowDOMSupported are 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.

Manifestmount()Result
true (default)default / trueCorrect. DSD adopted, hydrated in place.
falsefalseCorrect. Light-DOM SSR, no isolation.
truefalseBroken 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.
falsetrueA 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:

EnvironmentSingle-widgetSuite
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
}
  1. Category fallback. config.category wins; for a plugin: keyword an omitted value falls back to what registerPluginWidget(..., { category: "layout" }) recorded. The registry write is in the same IIFE, so it always precedes the mount.
  2. 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.
  3. CSR embeds: LayoutCsrRenderer. An empty layout host (no server markup) falls through and the layout rebuilds its own slot tree: fetch the layout widget, read settings.slots as Record<slotName, childWidgetId[]>, fetch each child row, load each child IIFE via resolveChildJsUrl + loadScriptOnce, wait for registration, render childless host nodes, and mount each 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 lineCause
[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-id and 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 }));
PathListenerEffect
CSR content widgetClientWrapperForWidgetRe-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 layoutLayoutCsrRendererRe-resolves slot bindings and layout settings live
Hydrated SSR widgetnoneNothing happens

Not supported yet. The hydrated SSR path installs no options-updated listener. 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:

  1. cancels any pending CSS-deferred first render,
  2. disposes the preload reservation and the theme controllers,
  3. root.unmount(),
  4. empties the shadow react-root (removing React’s unclaimed hoistables),
  5. resets visibility — mandatory, or a mount torn down during the CSS window leaves a permanently hidden root,
  6. removes data-hr-mounted and 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:

  1. data-hr-min-height on the host. Bare number → px; anything else → raw CSS length; 0 / off / none / false → opt out.
  2. 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:

ChannelShapeEffectEnforcement
expectedHeight on the widget summary (widget-summary.md)SuiteThe renderer stamps data-hr-min-height="{n}" on a CSR-only host, and the dashboard bakes it into the generated embed snippetadvisory — nothing warns if you omit it
data-hr-min-height on the embedBothDirect, wins over everythingadvisory

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).

ValueOnMeaningCleared when
mount-failedhostA throw inside mountOne (classically a missing id), or a rejection in the CSS-deferred hydrate/render stepNever automatically
render-failedhost + error elementWidgetRenderBoundary caught a synchronous render throw — most often configZod.parse() on a stored null/'' because the component skipped parseWidgetConfigA changed resetKey (the next options-updated payload)
rate-limitedhost + error elementThe widget-config fetch returned 429A successful retry
widget-fetch-failedhost + error elementAny other widget-config fetch failureA successful retry
property-not-founderror element onlyA 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.

BoundValueWhat it governs
Shadow CSS gate3000 mscssReady resolves regardless; CSS never blocks hydration
Slot-child registration10 000 mswhenWidgetRegistered rejects; that slot child is skipped
Preload max hold10 000 msThe reservation collapses even if the widget never paints
Preload release ease250 msmin-height transition, skipped under prefers-reduced-motion