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

How a widget renders

Your plugin is React code that runs in two very different places: a server sandbox with no DOM, and a customer’s browser. Which of those actually run — and in which order — depends entirely on what your manifest declares. There are exactly three paths:

KindDeclared asServer renderBrowser
Content widget with SSRssr: { url: … } — Both shapesYes, in an isolated React rootHydrates that markup
CSR-only content widgetssr: false — Suite only (widget-core 0.11.0+)No — an empty host divFetches its own settings and renders
Layout widgetcategory: "layout" + ssr.url — Suite only (0.11.0+)Yes, composing its slot children firstNothing — the mount is a no-op

The field rules behind that table belong to Manifest: widget summary and Layout widgets. This page is about what each path does.

The page your widget lands on

A customer’s page is not “your widget”. It is a composed document that may carry a dozen widgets — first-party and plugin, in any mix — sharing one React Query cache and one runtime bundle.

customer's domain
  └─ Cloudflare worker (reverse proxy in front of the customer's site)
       └─ renderer  GET /api/render/{feedId}/property/{slug}
            ├─ resolves the page schema: which widgets, in what order
            ├─ renders every top-level entry CONCURRENTLY
            └─ returns JSON { html, assets, dehydratedState, meta }
       └─ worker splices that html + assets into the site shell
  → browser

The renderer never sends a whole page. It sends a fragment plus an asset list, and the worker composes. That is why your CSS and JS arrive as page-level <link> and <script> tags rather than anything you control, at URLs the platform derives — see Keywords, assets and URLs.

What reaches the browser, in document order:

<!-- Shape derived from packages/homerunner-renderer/lib/render-utils.tsx
     and src/lib/homerunner/cf-worker.ts (page composition). -->
<head>
  <link rel="stylesheet" href="…">          <!-- one per entry in assets.css -->
  <link rel="preload" as="script" href="…"> <!-- one per entry in assets.js -->
  <link rel="preconnect" href="{central api origin}" crossorigin>
</head>
<body>
  <div data-hr-page="…" data-hr-widget-container>
    <div data-hr-widget="plugin:pdp-suite:stay-hero" id="…" data-hr-ssr-id="…">…</div>
    <div data-hr-widget="plugin:pdp-suite:stay-facts" id="…" data-hr-min-height="140"></div>
  </div>
  <script>window.HRWidget.__WIDGET_PROPS__ = { "<ssr id>": { props, options, css } }</script>
  <script>window.HRWidget.__REACT_QUERY_STATE__ = { … }</script>
  <script src="…/w/runtime.iife.js" defer></script>
  <script src="…/p/pdp-suite/stay-hero/stay-hero.iife.js" defer></script>
</body>

Three page-level facts follow from that shape, and every path below depends on them:

  1. Props travel in a registry, not in attributes. Each host carries a small data-hr-ssr-id key; one inline script holds every widget’s props, per-embed options and stylesheet list. If that script is blocked — a strict CSP, an ad blocker, an HTML-rewriting CDN — the markup is there but the props are not, and your widget falls through to the client-render path instead of hydrating.
  2. One shared React Query cache. Everything the server prefetched is serialised once and rehydrated into a single browser-side client that every widget on the page shares. Namespace your query keys or you will read someone else’s data.
  3. The runtime IIFE loads before yours. Since widget-core 0.10.0 your bundle contains no React, no React DOM, no React Query and no widget-core runtime — they resolve off window.HRWidgetRuntime at execution time. Your IIFE then registers your component and calls mount().

The complete attribute contract, the boot order and the registry lookup rules are in Runtime, mount and the DOM contract.

Path 1 — a content widget with SSR

What runs on the server

The renderer fetches your published SSR bundle, evaluates it once in a node:vm sandbox (cached afterwards), then, inside a single try:

# packages/homerunner-renderer/lib/render-utils.tsx (getWidgetMarkup)
getInitialData(ctx) → dehydrateState(sharedQueryClient, ctx) → getStaticAssets(settings)
  → renderToString(<WidgetHydrationTree><YourComponent {...props} /></WidgetHydrationTree>,
                   { identifierPrefix: ssrId })

Two things about that sandbox shape the code you can write. It is a bare V8 realm — only a short, fixed list of modules and globals is seeded, so crypto, TextEncoder and Buffer simply are not there. And window/document exist as inert stubs whose property reads return another truthy stub, so if (document) passes on the server. Only typeof window === "undefined" guards are rewritten and tree-shaken out of the SSR bundle. The exact module list, globals, externalFetch rules and execution bounds are in Component and SSR module.

The props you receive are raw — plugin settings are deliberately not parsed for you, on either side. parseWidgetConfig(configZod, props.options ?? {}) is mandatory in the component and in every SSR export. Why, and what raw actually looks like, is Settings and options.

What the customer’s page contains

Your rendered HTML goes inside one host <div>. When the request is Declarative-Shadow-DOM capable, the renderer wraps it in a shadow template instead of emitting it bare:

<!-- packages/homerunner-renderer/lib/render-utils.tsx — DSD-capable request -->
<div data-hr-widget="plugin:pdp-suite:stay-hero" id="{widgetId}:{feedId}" data-hr-ssr-id="…">
  <template shadowrootmode="open">
    <link rel="stylesheet" href="…">        <!-- your CSS, plus any declared fonts -->
    <div class="hr-react-root">…your markup…</div>
    <div class="hr-portal-container"></div>
  </template>
</div>

What runs in the browser

Your IIFE registers your component, then mount() finds the host, adopts the shadow root, and hydrates the existing DOM in place. Nothing re-renders from scratch and no data is re-fetched — the query cache already holds what the server prefetched.

Where your code can fail

Four distinct ways, and they behave very differently. A throw in any data hook or in your component during the server render is contained the same way: your widget is replaced by a hidden breadcrumb node, the rest of the page renders, and the page is marked degraded — its cache lifetime collapses at every layer so it re-renders soon rather than serving a broken version for a day. A rejected prefetch inside a queryFn is quieter still: it is swallowed by design, logged, and your widget ships whatever it renders with no data (if that is a spinner, crawlers see a spinner). A throw in the browser is caught by the render boundary, which marks the host and shows a neutral error card. And output that merely differs between the two passes does not throw at all — React silently discards your server DOM; see Hydration.

The full failure table, the breadcrumb markup and the log lines to grep are in Component and SSR module; the numbers are in Limits and error index.

Declarative Shadow DOM, conceptually

Every content widget renders inside a shadow root: it is what stops the customer’s site CSS from repainting your widget and your CSS from leaking into their page. The only question is when that root is created.

Without DSD, the browser parses your markup into the light DOM, and JavaScript creates the shadow root, moves the markup into it, attaches the stylesheets and waits for them. Your widget is briefly visible unstyled, or briefly hidden — the mount blink.

With DSD, the server ships a <template shadowrootmode="open"> and the browser attaches and styles the root while parsing. By the time your JavaScript runs there is nothing to build and nothing to wait for. No blink, correct paint on the very first frame, and — with the right CSS — correct dark mode before any script executes.

A plugin earns a DSD template only when all of these hold:

ConditionOwned by
The renderer’s DSD emission is on (it is, by default) and the request’s browser supports it
The root manifest does not set shadowDOM: falseManifest: root fields
The build stamped runtime.widgetCore at 0.10.0 or newer — which is also the publish floorManifest: root fields

mount() then takes one of three branches, in order:

# packages/homerunner-widget-core/src/runtime/mount.tsx
1. ADOPT   host.shadowRoot already exists (native DSD) → already styled, hydrate now
2. ADOPT   an inert <template shadowrootmode> in light DOM → attach it, then wait for CSS
3. BUILD   no template at all → create the shadow root, move the server markup into it

Branch 3 is the migrate path: it moves nodes with appendChild rather than re-serialising through innerHTML, because hydration matches on node identity. That is one of several reasons you must never call hydrateRoot yourself.

Two conceptual traps worth carrying with you:

  • Your manifest’s shadowDOM and what your bootstrap passes to mount() must agree. Declare shadow DOM and mount with shadowDOM: false and the browser attaches the server’s shadow root at parse while your code renders into the light DOM, which is never displayed: visible server content that never hydrates. Nothing checks this before you ship, and it looks fine on a browser that missed the DSD bucket. The full matrix is in Runtime, mount and the DOM contract.
  • DSD is what makes correct dark-at-first-paint possible at all. The server cannot know the visitor’s colour scheme (resolvedTheme is effectively always light in production), so first-paint dark has to come from CSS inside that template. The recipe is in Styling and theming.

Path 2 — a CSR-only content widget

Suite only (widget-core 0.11.0+). Declaring "ssr": false on a widget summary opts it out of the server pipeline entirely. It has no ssr-entry.ts, no SSR bundle, and its component is never invoked on the server. This is a suite-shape feature: a single-widget manifest with no ssr.url is a failure, not a CSR-only widget — the renderer drops it.

What the renderer still does: emit an empty host div, add your JS and CSS to the page’s asset list, add a registry entry carrying the per-embed options, and — when the summary declares expectedHeight — stamp a minimum height on the host so the page does not jump when the widget finally paints.

<!-- packages/homerunner-renderer/lib/render-utils.tsx — the ssr:false branch -->
<div data-hr-widget="plugin:pdp-suite:stay-facts" id="{widgetId}:{feedId}"
     data-hr-ssr-id="…" data-hr-min-height="140"></div>

In the browser, mount() sees a childless host, takes the client-render path, and your component is rendered by a wrapper that first resolves settings: it fetches the feed and the widget row, merges feed branding, feed globals, the stored settings and the per-embed override, and hands you the result. So on this path your options are resolved in the browser from a live config fetch rather than baked into the HTML — still not schema-parsed, so parseWidgetConfig remains mandatory — widgetType is supplied (it is not on the SSR path), and data is undefined because no getInitialData ever ran.

On a server-rendered page that config query is pre-seeded by the renderer and travels in the dehydrated state, so it resolves from cache with no network round trip. In a hand-written CSR embed on a page the renderer never touched, it is two live API calls before anything paints.

Where your code can fail here: a render throw is caught by the render boundary and the host is marked; a failed config fetch renders the shared error state instead of your widget. Both markers, and the full vocabulary, are in Runtime, mount and the DOM contract.

Choose CSR-only deliberately. You lose server HTML (nothing for crawlers, a later first paint, a real CLS risk unless you set expectedHeight). You gain a much simpler build, no sandbox constraints, and live editing — the dashboard’s options-updated preview channel works on this path and does not work on a hydrated SSR widget.

Path 3 — a layout widget

Suite only (widget-core 0.11.0+). A layout does not render content; it renders regions, and the platform fills them with other widgets bound into named slots. Its render path is inverted relative to everything above.

# packages/homerunner-renderer/lib/render-utils.tsx (getLayoutWidgetMarkup) — SSR
1. collect every binding across every slot (a binding to a missing widget is logged, skipped)
2. render ALL slot children CONCURRENTLY
     each child is a complete island: own SSR id, own DSD template,
     own props-registry entry, own assets
3. reassemble each slot in binding order  →  renderedSlots[slotName] = <>{…children}</>
4. load YOUR layout bundle LAST, run its optional data hooks, render the shell
5. unshift the layout's CSS and JS ahead of every child's assets

Your component receives the finished children as React nodes in renderedSlots and simply places them. The shell is then emitted as static light DOM — no shadow root, no DSD template, no SSR id, no props-registry entry.

That has three consequences that consistently read as bugs and are not:

  • Your layout CSS is document-level on every server-rendered page. Namespace every class, never write a :host-only rule for the shell, never paint a background. Details in Layout widgets and Styling and theming.
  • The client mount is a deliberate no-op. Running the client path there would throw away the server’s slot children and re-fetch everything. Each child hydrates itself through its own IIFE.
  • A layout that throws during render takes the whole page down. Content widgets render inside a try; a layout shell is returned as an un-rendered element and rendered later, in the page’s single renderToString, which has none. Parse defensively and default every slot.

Not supported yet. Because the shell has no SSR id, no registry entry and no mount, a plugin layout can never be interactive on a server-rendered page — no React tree is ever attached to it. Put interactivity in a content widget and bind it into a slot. See Layout widgets.

Not supported yet. Layouts cannot nest. A layout bound into another layout’s slot is refused on both render paths, before anything renders.

The same layout in a CSR embed is the mirror image

Drop a layout into a single-snippet embed and everything inverts: the shell does get a shadow root, and your layout mounts its own children — reading the stored slot bindings, fetching each child’s row, loading each child’s bundle, waiting for it to register, then mounting it into a host div after commit. It also pushes the parent colour scheme and, when it resolved one, the property filter onto every child.

So a layout stylesheet has to work document-level and inside a shadow root, and renderedSlots means two different things on the two paths — server-rendered nodes on one, childless host divs on the other. The shapes differ too: a slot that was never bound is absent from the object on the server but present as an empty array on the client, so slots.hero ?? fallback does not behave the same way in both places. The per-path prop table is in Layout widgets.

Hydration, in one idea

Hydration is React adopting DOM it did not create. It walks your component tree and the existing markup in lockstep and attaches event handlers to the nodes it finds. It works only while the two agree.

The platform makes them agree by rendering each widget as an isolated React root on the server, inside a fixed wrapper, with an identifier prefix derived from that widget instance — and by having mount() mirror the wrapper shape and the prefix exactly. React derives useId values from tree position plus that prefix, so any drift makes every useId consumer (Radix dialog and popover aria attributes, form ids) mismatch. React’s response is not a warning: it silently regenerates the tree and discards every byte of your server HTML.

Which gives you three rules — Both shapes, all runtime, nothing checks them before you ship:

  1. Mount through widget-core’s mount(). Never call hydrateRoot yourself; you cannot reproduce the wrapper and prefix from outside.
  2. Render deterministically. No Math.random(), no Date.now(), no reading window, and no reading props.widgetType — it is absent on the SSR/hydrate path and present on the CSR path, which makes it a hydration trap disguised as a prop.
  3. Do not fetch by suspending during render. renderToString does not await; a suspended boundary ships its fallback into the HTML. Fetch in getInitialData or dehydrateState.

The wrapper, the prefix and the exact prop differences per path are in Component and SSR module.

Failure containment, end to end

One design rule explains most of the platform’s behaviour here: a plugin must not be able to break a customer’s page. Everything except one case is contained.

What brokeBlast radius
Your keyword does not resolve, your SSR bundle 404s or has no default exportThat widget only — replaced by a hidden breadcrumb, page marked degraded
A data hook or a content component throws on the serverThat widget only
A data hook throws on a layoutThat layout and its already-rendered children — the page still responds, but an assigned page layout failing this way leaves the page empty
A component throws in the browserThat widget only — the render boundary catches it
One embed’s mount() throwsThat embed only — every other embed on the page still mounts
A layout component throws during the server renderThe whole page 500s

Every hidden breadcrumb stays in the page source, so curl plus view-source tells you which widget the platform dropped and why. Symptom-first diagnosis is in Troubleshooting.