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:
| Kind | Declared as | Server render | Browser |
|---|---|---|---|
| Content widget with SSR | ssr: { url: … } — Both shapes | Yes, in an isolated React root | Hydrates that markup |
| CSR-only content widget | ssr: false — Suite only (widget-core 0.11.0+) | No — an empty host div | Fetches its own settings and renders |
| Layout widget | category: "layout" + ssr.url — Suite only (0.11.0+) | Yes, composing its slot children first | Nothing — 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:
- Props travel in a registry, not in attributes. Each host carries a small
data-hr-ssr-idkey; 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. - 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.
- 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.HRWidgetRuntimeat execution time. Your IIFE then registers your component and callsmount().
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:
| Condition | Owned 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: false | Manifest: root fields |
The build stamped runtime.widgetCore at 0.10.0 or newer — which is also the publish floor | Manifest: 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
shadowDOMand what your bootstrap passes tomount()must agree. Declare shadow DOM and mount withshadowDOM: falseand 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 (
resolvedThemeis effectively alwayslightin 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 singlerenderToString, 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:
- Mount through widget-core’s
mount(). Never callhydrateRootyourself; you cannot reproduce the wrapper and prefix from outside. - Render deterministically. No
Math.random(), noDate.now(), no readingwindow, and no readingprops.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. - Do not fetch by suspending during render.
renderToStringdoes not await; a suspended boundary ships its fallback into the HTML. Fetch ingetInitialDataordehydrateState.
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 broke | Blast radius |
|---|---|
| Your keyword does not resolve, your SSR bundle 404s or has no default export | That widget only — replaced by a hidden breadcrumb, page marked degraded |
| A data hook or a content component throws on the server | That widget only |
| A data hook throws on a layout | That 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 browser | That widget only — the render boundary catches it |
One embed’s mount() throws | That embed only — every other embed on the page still mounts |
| A layout component throws during the server render | The 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.
Related
- Exports, props, the sandbox and the hydration contract: Component and SSR module
mount(), DOM attributes, shadow-DOM lifecycle: Runtime, mount and the DOM contract- Slots,
renderedSlots, page assignment: Layout widgets - Why
optionsis raw and what merged it: Settings and options - Dark mode at first paint, shadow-safe CSS: Styling and theming
- Running the server path on your machine: Previewing SSR locally