Component and SSR module
Everything your widget module exports, and everything it receives — on the renderer’s server sandbox and in the browser.
Two files carry the contract:
| File | Shape | Exists when | Consumed by |
|---|---|---|---|
src/widget.tsx | Both | Always | The client IIFE (src/index.tsx registers it) and the SSR bundle |
src/ssr-entry.ts | Single-widget | The manifest declares ssr.url | The build’s SSR pass only |
src/widgets/{slug}/ssr-entry.ts | Suite | That widget’s summary declares ssr.url | The build’s SSR pass only |
ssr-entry.ts exists if and only if the widget’s summary declares ssr.url. A widget
declaring "ssr": false is CSR-only and has no SSR entry file at all — hr-widget-build
skips its SSR pass entirely, so the file would never be compiled. See
Manifest: widget summary for the ssr field.
How rules on this page are enforced. Almost all of them are enforced at runtime by the renderer, not at publish. The dashboard’s built-zip audit checks that your SSR file exists and has a hashed twin; it never opens the bundle or inspects its exports. Where a publish gate does exist it is named with the standard tags defined in Limits and error index. Runtime outcomes use these terms:
| Tag | Meaning |
|---|---|
| runtime — widget removed | Your widget’s markup is replaced by a hidden breadcrumb node and the page is marked degraded (60 s cache TTL). Every other widget still renders. |
| runtime — page 500 | The render endpoint throws. The customer’s page has no HTML at all. |
| runtime — throws into your code | You get an ordinary JS exception you can catch. |
Props
Import the prop types; do not hand-write them. The scaffold’s inline WidgetProps
interface is a simplification and omits fields.
// @homerunner-next/widget-core/src/contracts.ts — import from
// "@homerunner-next/widget-core/contracts"
export interface WidgetProps<
T extends WidgetSchemaType = WidgetSchemaType,
D extends Record<string, any> | undefined = undefined,
> {
options: T;
target?: HTMLElement;
widgetId: string;
feedId: number;
widgetType: string;
resolvedTheme?: "light" | "dark";
data: D;
}
export interface LayoutWidgetProps<
T extends WidgetSchemaType = WidgetSchemaType,
D extends Record<string, any> | undefined = undefined,
> extends WidgetProps<T, D> {
/** Pre-rendered React nodes for each slot. Keys are slot names. */
renderedSlots: Record<string, ReactNode>;
}
Type your component WidgetProps<PluginConfig> (or
WidgetProps<PluginConfig, GetAwaitedFuncReturnType<typeof getInitialData>> when you
export one) and a layout LayoutWidgetProps<PluginConfig>. Both types are exported from
/contracts; WidgetSchemaType comes from /schema. See
SDK reference.
| Prop | Type | Shape | Rule | On violation |
|---|---|---|---|---|
options | T (declared) — RAW at runtime | Both | Always present. Never read a field off it directly; run parseWidgetConfig first. | A stored null/'' throws inside your render → widget removed (server) or data-hr-error="render-failed" (client) |
widgetId | string | Both | The widget instance id. Stable across server and client. | — |
feedId | number | Both | The feed this instance belongs to. | — |
widgetType | string | Both | The keyword, e.g. plugin:pdp-suite:stay-hero. Not supplied on the SSR/hydrate path. | Reading it during render produces different output on server and client → hydration mismatch |
resolvedTheme | "light" | "dark" | Both | Optional. Effectively always "light" in production. Never branch first paint on it. | Dark-mode content flashes light, or mismatches on hydrate |
data | D | Both | Whatever getInitialData returned. undefined on every client-only path. Must be JSON-serialisable. | A Date/Map/function silently becomes a string, {} or is dropped |
target | HTMLElement | Both | Declared in the type. Never supplied. | — |
renderedSlots | Record<string, ReactNode> | Suite (layouts) | Layout widgets only. Keys are the slot names present in the stored bindings. A slot that was never bound is absent, not empty — always ?? {} and default each region. | slots.hero is undefined; rendering it is fine, destructuring it is not |
Not supported yet.
targetis part of the exportedWidgetPropstype but no render path in the platform passes it — not the renderer, notmount()’s hydrate path, not its CSR path. UseuseShadowHost()from@homerunner-next/widget-core/runtimeif you need the host element. See SDK reference.
What each path actually passes
| Path | options | widgetId | feedId | widgetType | resolvedTheme | data | renderedSlots |
|---|---|---|---|---|---|---|---|
Server render (content, ssr.url) | RAW merged settings | ✔ | ✔ | absent | cookie-derived, else "light" | getInitialData result | — |
| Client hydrate of that markup | RAW, identical bytes | ✔ | ✔ | absent | replayed from the props registry | replayed from the props registry | — |
Server render (content, ssr: false) | your component is never invoked — the renderer emits an empty host div | n/a | n/a | n/a | n/a | n/a | n/a |
| Client CSR (content) | resolved settings from the config fetch | ✔ | ✔ | ✔ | live matchMedia result | undefined | — |
| Server render (layout) | RAW merged settings | ✔ | ✔ | absent | cookie-derived, else "light" | getInitialData result | server-rendered child nodes |
| Client CSR (layout) | resolved settings | ✔ | ✔ | ✔ | live | undefined | childless host divs it mounts into after commit |
Three consequences worth internalising:
widgetTypeis a hydration trap. The server props object is{resolvedTheme, options, feedId, widgetId, data}— it has nowidgetTypekey. The browser replays that same object verbatim when hydrating. Only the pure-CSR path addswidgetType. If your render output depends on it, the SSR HTML and the hydrated tree disagree and React discards the server DOM.resolvedThemeis effectively always"light"in production. It derives from anhr:widget:system_themecookie, and the Cloudflare worker in front of the renderer never forwards cookies. Dark-at-first-paint must come from CSS keyed off host attributes — see Styling and theming.- The hydrate path replays the server’s
data. It travels as JSON in a page-level inline script. Non-JSON values do not survive.
Options arrive RAW
Plugin widgets deliberately receive unparsed merged settings on both the server and the client. The renderer parses settings only for first-party widgets:
// Platform source: homerunner-renderer/lib/render-utils.tsx
const parsedSettings = widgetConfig.type.startsWith('plugin:')
? finalSettings // plugins: RAW
: await parseSettingsForSsr(widgetConfig.type, finalSettings);
Raw settings contain artifacts your schema rejects: a cleared dashboard field arrives as
null, an emptied map as [], numbers may arrive stringified, and pre-0.9.0 saves carry a
legacy width object instead of spacing. Calling .parse() on that throws.
The rule (Both): call parseWidgetConfig(configZod, props.options ?? {}) in your
component and in every SSR export before reading a single field. Enforcement: runtime —
throws into your code, which then becomes widget removed on the server or
data-hr-error="render-failed" in the browser.
// pdp-suite/src/widgets/stay-hero/widget.tsx (real, published)
import React from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { configZod } from "./config";
import heroBg from "./hero-bg.png";
import "./stay-hero.css";
/** The property slug the platform injects when a parent layout resolves one. */
function injectedProperty(options?: Record<string, unknown>): string | null {
const filter = (options as { filter?: { property?: unknown } } | undefined)?.filter;
return typeof filter?.property === "string" && filter.property ? filter.property : null;
}
export default function StayHero(props: { options?: Record<string, unknown> }) {
const cfg = parseWidgetConfig(configZod, props.options ?? {});
const property = injectedProperty(props.options);
// …
}
parseWidgetConfig(schema, options, pre?) strips null/'' leaves the schema rejects,
coerces stringified scalars, lifts legacy width onto spacing, then parses. The full
merge chain that produces those raw settings is described in
Settings and options; the base schema fields are in
Config schema and UI schema.
Memoise it in a component that re-renders (useMemo on props.options) — it is a full
zod parse, not a cheap read.
The SSR module
The renderer fetches your built SSR bundle, evaluates it in a node:vm context, and reads
exactly four exports off the resulting module:
// Platform source: homerunner-renderer/lib/plugin-federation.ts:317-322
interface PluginSSRModule {
default: React.ComponentType<any>;
getInitialData?: (ctx: any) => Promise<any>;
dehydrateState?: (qc: any, ctx: any) => Promise<void>;
getStaticAssets?: (settings: any) => Promise<{ css: string[]; js: string[] }>;
}
| Export | Type | Required | Rule | On violation |
|---|---|---|---|---|
default | React.ComponentType | Yes (Both, including layouts) | The loader takes module.exports if it has .default, else exports if it has .default, else module.exports. | Logs [plugin] SSR bundle has no default export: <url>, the loader returns null → runtime — widget removed |
getInitialData | (ctx) => Promise<any> | No (Both) | Return value becomes props.data. Must be JSON-serialisable. | A throw → runtime — widget removed |
dehydrateState | (queryClient, ctx) => Promise<void> | No (Both) | Prefetch into the page’s shared React Query client. | A throw → runtime — widget removed. A throw inside a queryFn is swallowed — see below |
getStaticAssets | (settings) => Promise<{css: string[]; js: string[]}> | No (Both) | Omitting it is the recommended default. | A throw → runtime — widget removed |
Nothing about these exports is checked before publish — see
Packaging and publishing rules for what the audit does
check. A bundle with no default export publishes cleanly and fails on the first customer
page render.
The entry file
ssr-entry.ts is a re-export barrel. The build compiles exactly this path — nothing else —
into the SSR bundle:
// pdp-suite/src/widgets/pdp-frame/ssr-entry.ts (real, published — a layout)
// SSR entry — the renderer hands `renderedSlots` to the default export.
export { default } from "./widget";
// create-hr-plugin template: src/ssr-entry.ts (single-widget shape, content widget)
export { default, getInitialData } from "./widget";
export { dehydrateState, getStaticAssets } from "./ssr";
Suite widgets in practice export only default — pdp-suite’s stay-hero and pdp-frame
both do. Add the data hooks only when you need server-side data.
Call order and context
For every server-rendered widget the renderer runs, in this order, inside one try:
# Platform source: homerunner-renderer/lib/render-utils.tsx (getWidgetMarkup)
getInitialData(ctx) → dehydrateState(queryClient, ctx) → getStaticAssets(settings)
→ renderToString(<WidgetHydrationTree><Default {...props} /></WidgetHydrationTree>)
For a layout, all slot children are rendered first (concurrently, each a complete island), and only then does the layout’s own bundle load and run the same four steps. The whole pipeline, end to end, is drawn in How a widget renders; the slot rules are in Layout widgets.
ctx — for getInitialData and dehydrateState — is exactly three keys:
| Key | Type | Value |
|---|---|---|
options | Record<string, unknown> | RAW merged settings. Parse them. |
feedId | number | widget.feed_id |
widgetId | string | The widget instance id |
getStaticAssets receives only the settings object as its first positional argument —
there is no ctx, no feedId, no widgetId.
Not supported yet.
GetInitialDataCtxin@homerunner-next/widget-core/contractsdeclareswidgetType,isSSR,cookies,headersandqueryin addition to the three keys above, and typesoptionsas parsed. For plugins the renderer populates none of the extra fields and never parsesoptions. Type your ctx as the three-key object shown above; readingctx.cookiesorctx.headersgets youundefined.
The shared query client
dehydrateState receives the page’s QueryClient — one instance shared by every widget
on the page, first-party and plugin alike. The dehydrated cache is serialised into the HTML
and rehydrated into one shared browser-side client.
- Namespace your query keys. A colliding key silently serves another widget’s data.
Prefix with your plugin slug:
["pdp-suite", "stay-hero", feedId, …]. - Two keys are reserved and pre-seeded by the renderer before your hook runs:
["widget", <widgetId>]and["widget", <feedId>, <widgetId>](the widget + feed config pair thatuseFetchWidgetreads). Do not write to them. - Prefetch errors are swallowed by design; see Failure semantics.
Data-fetching patterns, the error taxonomy and retry helpers live in Fetching data; endpoint shapes in Public API.
getStaticAssets — prefer omitting it
When you do not export it, the renderer builds the asset list from your manifest instead:
assets.js, assets.css and assets.fonts. That is almost always what you want.
When you do export it, every URL you return is rewritten. The renderer reduces each
entry to its dist-relative name (https://cdn.example.com/dist/hero/hero.css →
hero/hero.css, anything else → the basename) and re-resolves it against the published
version’s build manifest, falling back to the /p/ proxy. Absolute URLs are not passed
through.
| Rule | Level | On violation |
|---|---|---|
| Returned URLs are reduced to a dist-relative name and re-resolved | runtime, always | You cannot self-host assets from getStaticAssets |
Declared assets.fonts are appended only on the manifest path | runtime, silent | Exporting getStaticAssets drops your fonts from the server-rendered <head>; the client IIFE still injects them, so they arrive after first paint instead of with it |
Multi-widget paths keep the widget directory: hero/hero.css, not hero.css | runtime | The build-manifest lookup misses and you fall back to the /p/ proxy |
The full resolution algorithm, the /p/ proxy and font rules belong to
Keywords, assets and URLs.
// create-hr-plugin template: src/ssr.ts — corrected for the suite layout.
// A suite emits dist/{widget}/{widget}.css, not dist/{slug}.css.
export async function getStaticAssets(
_options: Record<string, unknown>,
): Promise<{ css: string[]; js: string[] }> {
return {
css: ["dist/stay-hero/stay-hero.css"],
js: ["dist/stay-hero/stay-hero.iife.js"],
};
}
The server sandbox
Your SSR bundle is a CommonJS file (formats: ["cjs"], exports: "named",
inlineDynamicImports: true) named dist/{widget}/{widget}-ssr.umd.js in a suite or
dist/{slug}-ssr.umd.js in the single-widget shape, despite the .umd.js suffix. Every
dynamic import() is inlined at build time — there is no module loader in the sandbox at
runtime.
The context is a fresh V8 realm. ECMAScript intrinsics (RegExp, Function,
Reflect, parseInt, Intl, globalThis, …) exist because the realm creates them.
Every Web or Node API must be explicitly seeded, and only the following are.
Shared modules (7)
require() resolves exactly seven specifiers:
// Platform source: homerunner-renderer/lib/plugin-federation.ts:25-38
const SHARED_MODULES: Record<string, unknown> = {
react: React,
"react/jsx-runtime": ReactJsxRuntime,
"react-dom": ReactDOM,
"react-dom/client": ReactDOMClient,
"react-dom/server": ReactDOMServer,
"@tanstack/react-query": ReactQuery,
// SDK >= 0.10.0 externalizes widget-core's runtime in the SSR umd too.
"@homerunner-next/widget-core/runtime": WidgetCoreRuntime,
};
Anything else throws, verbatim:
Plugin cannot require "<mod>" (not in shared modules whitelist)
Enforcement: runtime — widget removed (the throw happens during module evaluation, so the bundle never loads).
@homerunner-next/widget-core/runtime is the evergreen entry (widget-core 0.10.0+):
the build externalises it, and the renderer supplies its own current copy. Your server
renders therefore use the platform’s live runtime helpers, not a copy frozen at your
npm install. The other widget-core subpaths — /globals, /schema, /plugin-schema,
/utils, /urls, /spacing, /contracts — stay bundled into your SSR file, so they
are whatever version you built with.
Everything else you import — React libraries, date utilities, your own code — is bundled. Keep the bundle small: it is fetched, evaluated and cached per renderer instance.
Globals
| Present | Notes |
|---|---|
React | Also reachable via require("react") |
require, module, exports | The CJS shim above |
process | Only process.env, and only three keys (below) |
__HR_PLUGIN_ASSET_BASE__ | The bundle’s own …/dist/ base — how bundled-asset imports resolve server-side |
fetch | Wrapped by the externalFetch allowlist (below) |
console | The renderer’s console. Your console.log lands in renderer stdout |
setTimeout, clearTimeout, setInterval, clearInterval | |
URL, URLSearchParams, AbortController, AbortSignal | |
Promise, Date, Math, JSON, Object, Array, String, Number, Boolean, Error, TypeError, RangeError, Symbol, Map, Set, WeakMap, WeakSet | Seeded from the host realm |
window, document, self, navigator, location, localStorage, sessionStorage | Inert Proxy stubs — see below |
Absent — any reference is a ReferenceError |
|---|
crypto (no Web Crypto, no crypto.randomUUID), TextEncoder, TextDecoder, Buffer |
Response, Request, Headers, FormData, Blob, atob, btoa |
structuredClone, queueMicrotask, performance, setImmediate |
Every Node builtin: fs, path, os, child_process, node:crypto, … |
The rest of process: no argv, cwd(), version, platform |
globalThis does exist and resolves to the sandbox object itself, not the host realm’s
global. The build’s bundled-asset banner relies on that
(globalThis.__HR_PLUGIN_ASSET_BASE__ is its server-side fallback).
fetch returns a Response created in the host realm. Its methods work; res instanceof Response is a ReferenceError because Response is not bound in the sandbox.
process.env exposes exactly three keys, and nothing else — reading any other name gives
undefined:
| Key | Value |
|---|---|
NODE_ENV | The renderer’s own |
NEXT_PUBLIC_HOMERUNNER_BASE_URL | The resolved Central base URL |
NEXT_PUBLIC_WIDGET_ASSET_BASE_URL | The asset base, when configured |
Browser-global stubs, and the guard that actually works
The seven browser globals are new Proxy(function(){}, {get, set, has, apply, construct})
where get returns another stub. That stub is an object, so it is truthy:
// Platform source: homerunner-renderer/lib/plugin-federation.ts:306-315
function makeBrowserGlobalStub(): unknown {
const handler: ProxyHandler<object> = {
get: () => makeBrowserGlobalStub(), // truthy!
set: () => true, // writes silently dropped
has: () => false, // `"x" in window` is false
apply: () => makeBrowserGlobalStub(),
construct: () => ({}),
};
return new Proxy(function () {}, handler);
}
Consequences:
| Guard | Server behaviour | Verdict |
|---|---|---|
if (document) { … } | Passes — document is a truthy stub | Broken |
if (window.matchMedia) { … } | Passes — the property read returns a stub | Broken |
if ("matchMedia" in window) | Fails — the has trap returns false | Works, but by accident |
if (typeof window === "undefined") return; | The SSR build rewrites typeof window to the literal "undefined" before bundling, so the branch is dead-code-eliminated | Correct |
The SSR build rewrites typeof on window, document, self, navigator,
localStorage and sessionStorage to "undefined". Only that form is tree-shaken; the
proxy is a safety net for whatever escapes, not a substitute.
// widget-core README.md — the canonical browser-only side effect
useEffect(() => {
if (typeof window === "undefined") return;
import("webfontloader").then(({ load }) => load(opts));
}, [opts]);
// The SSR build rewrites `typeof window` to "undefined", esbuild DCEs everything
// after the unconditional return, and the dependency never lands in the SSR umd.
useEffect never runs on the server anyway, but the import would still be bundled
without the guard.
Execution bounds
| Bound | Value | Rule | On violation |
|---|---|---|---|
| Bundle fetch redirects | redirect: "manual" | The SSR URL must serve the bytes directly | Any 3xx throws HTTP <status> redirect refused — SSR bundles must be served directly from their published URL → runtime — widget removed |
| Bundle fetch status | must be 2xx | HTTP <status> → runtime — widget removed | |
| Module-scope evaluation | 5000 ms default (PLUGIN_SSR_EXEC_TIMEOUT_MS, floor 250 ms) | Bounds synchronous top-level work only | The vm throws and the bundle never loads → runtime — widget removed |
| Async work | unbounded | Timers, microtasks and everything inside getInitialData/dehydrateState are outside the vm timeout | A hanging fetch stalls the whole page render |
| Top-level declarations | wrapped as (function(){ … })() before compiling | Prevents a minified top-level const gc colliding with the Lambda runtime’s non-configurable globals | — |
Give every outbound request in your data hooks an explicit AbortSignal.timeout(…). The
sandbox provides AbortController/AbortSignal for exactly this reason, and nothing else
will stop a slow upstream from holding the page.
The vm is not a security boundary
The context is seeded with host-realm objects, so a determined bundle can walk back into the renderer’s realm. The real gate is human source review plus an admin-side build — see From your laptop to a customer page. Write as if the sandbox were airtight anyway; a reviewer reads your source.
externalFetch
The sandbox’s fetch is wrapped in an allowlist derived from the widget’s externalFetch
manifest field.
| Rule | Level | On violation |
|---|---|---|
| Allowed hosts = HomeRunner Central’s host ∪ your declared entries | runtime | [plugin] fetch to "<host>" blocked — not declared in the widget's externalFetch manifest field. |
An entry is host (any port) or host:port (that port only) — hostname only, no scheme, no path | runtime | Same message as above; the comparison is a lowercased exact match |
| Private, loopback, link-local and metadata hosts are refused even when declared | runtime | [plugin] fetch to "<host>" blocked — private/metadata hosts are never allowed. |
| Redirects are followed manually and every hop is re-validated; max 5 | runtime | [plugin] fetch blocked — more than 5 redirects. |
| The URL must parse | runtime | [plugin] fetch blocked — unparseable URL: <raw> |
externalFetch is never validated at publish | advisory (unvalidated) | A typo’d host is discovered only when the widget renders on a real page |
The refused-host set is textual (no DNS resolution): localhost, *.localhost, *.local,
0.0.0.0, ::1, 127.*, 10.*, 192.168.*, 172.16–31.*, 169.254.* and
metadata.google.internal.
// Platform source: homerunner-renderer/lib/plugin-federation.ts:386-399
const validate = (u: URL): void => {
const host = u.hostname.toLowerCase();
const hostPort = u.port ? `${host}:${u.port}` : host;
const allowed = host === centralHost() || declared.has(host) || declared.has(hostPort);
if (!allowed) {
throw new Error(
`[plugin] fetch to "${host}" blocked — not declared in the widget's externalFetch manifest field.`,
);
}
if (!platformIsLocalRig() && host !== centralHost() && isForbiddenHost(host)) {
throw new Error(`[plugin] fetch to "${host}" blocked — private/metadata hosts are never allowed.`);
}
};
The throw surfaces inside your hook, so it is runtime — throws into your code. Let it propagate and the widget is removed; catch it and you can degrade gracefully. Catching is usually right for optional enrichment data, wrong for the widget’s primary content.
Declaration lives on the widget summary in a suite, or at the manifest root in the single-widget shape:
// manifest.json — suite shape, inside widgets[]
{ "slug": "stay-facts", "externalFetch": ["api.weather.example", "cdn.example.com:8443"] }
The root-level form is read by the resolver for single-widget manifests, but it is not
declared on the published PluginManifest type and is not covered by any publish
validator. Treat it as working but undocumented, and say so in your submission notes so
the reviewer knows to look for it.
Two behaviours that surprise people
A 60-second server-side GET cache. Outside development mode, ok GET responses are
cached for 60 s across renders (LRU, 50 entries), so dehydrateState may legitimately see
data up to a minute stale. The key is the URL plus your request headers, so two calls
that differ by a single header never share a response — and a request that carries
authorization, proxy-authorization or cookie, uses credentials: "include", sets
cache: "no-store"/"reload", sends a body, or is not a GET is never cached at all.
Null-body answers (204, 205) are passed straight through and not cached either. This exists because Central’s public API is rate-limited per IP
and a purge fan-out multiplies renders. Development renders bypass the cache but stay
enforced — you see a block locally, not in review.
The allowlist is frozen into the cached module. The evaluated bundle is cached with its
enforced-fetch closure already bound, so the first load’s allowlist is the one that
sticks for that module’s lifetime. For pipeline-published plugins this never bites: a new
version is a new URL, hence a new module. For a legacy author-hosted plugin, editing
externalFetch in a mutable manifest takes up to 5 minutes to apply.
Failure semantics
| Where it throws | Widget kind | Outcome | Breadcrumb reason |
|---|---|---|---|
| Module evaluation (top-level) | any | runtime — widget removed | SSR bundle failed to load from <url> |
No default export | any | runtime — widget removed | SSR bundle failed to load from <url> |
getInitialData | content | runtime — widget removed | SSR data prep failed: <message> |
dehydrateState | content | runtime — widget removed | SSR data prep failed: <message> |
getStaticAssets | content | runtime — widget removed | SSR data prep failed: <message> |
| Component render on the server | content | runtime — widget removed (the render is inside the same try) | SSR data prep failed: <message> |
| Any of the three hooks | layout | runtime — widget removed; the layout and every already-rendered slot child are dropped | layout SSR data prep failed: <message> |
| Layout component render | layout | runtime — page 500 | none — there is no HTML |
Inside a queryFn you prefetch | any | Swallowed by TanStack Query. Warned, page degraded, widget ships its loading markup | none |
| Component render in the browser | any | WidgetRenderBoundary catches it | data-hr-error="render-failed" on the host |
A removed widget leaves this in the page source, so you can find it with view-source or a
curl:
<!-- Emitted by widgetFailureNode — homerunner-renderer/lib/render-utils.tsx:132-145 -->
<div data-hr-widget-error="plugin:pdp-suite:stay-hero" hidden>
<!-- HR widget failed — type="plugin:pdp-suite:stay-hero" id="42": SSR data prep failed: Cannot read properties of null -->
</div>
Any failure node (or any errored prefetch) marks the whole page degraded, which drops its cache TTL from 24 hours to 60 seconds at every layer. A degraded page is not a broken page — it is a page that will re-render a minute later.
Your console.* calls and the renderer’s own lines go to renderer stdout. The ones worth
grepping when an SSR module misbehaves:
# Platform source: homerunner-renderer/lib/plugin-federation.ts + lib/render-utils.tsx
[plugin] Fetching SSR bundle: <url>
[plugin] Failed to load SSR bundle from <url>: <message>
[plugin] SSR bundle has no default export: <url>
[plugin] Loaded "<keyword>" (<id>) via SSR bundle
[plugin] No manifest entry for "<keyword>". Skipping.
[widget] Failed to prepare "<keyword>" (<id>): <message>
[layout] Failed to prepare "<keyword>" (<id>): <message>
[widget] SSR prefetch FAILED — shipping loading markup. key=<queryHash> error=<message> (caught during …)
More diagnosis routes are in Troubleshooting.
The layout asymmetry
A content widget’s server render happens inside getWidgetMarkup’s try. A layout’s
component is returned as an un-rendered React element and rendered later, in the page’s
single renderToString, which has no try. A layout component that throws during render
takes the entire customer page down with a 500.
Layout authors: parse defensively, default every slot, and never index into
renderedSlots without a fallback.
// pdp-suite/src/widgets/pdp-frame/widget.tsx (real, published)
const cfg = parseWidgetConfig(configZod, props.options ?? {});
const slots = props.renderedSlots ?? {};
const region = (name: string, node: ReactNode) => (
<section className={`pdpf-region pdpf-${name}`} data-testid={`pdpf-${name}`}>
{cfg.showSlotLabels ? <span className="pdpf-label">{name}</span> : null}
{node ?? (cfg.showSlotLabels ? <div className="pdpf-empty">empty “{name}” slot</div> : null)}
</section>
);
Prefetch errors only warn
queryClient.prefetchQuery swallows failures by design. The renderer diffs the shared
cache’s error set around your dehydrateState and logs:
[widget] SSR prefetch FAILED — shipping loading markup. key=<queryHash> error=<message>
Your widget still renders — with whatever it renders when the query has no data. If that is a spinner, crawlers see a spinner. Prefer returning a real empty state.
In the browser
Every render site in mount() wraps your component in WidgetRenderBoundary, inside
the providers. On catch it logs
[hr-widget] "<widgetType>" render failed (widget <id>, feed <id>) — rendering the error state instead of a blank node.
sets data-hr-error="render-failed" on the host element, best-effort stamps a
data-hr-theme if nothing else did, and renders the shared neutral error state. A latched
failure clears when resetKey changes — which is how a bad live-edit payload in the
dashboard preview recovers without a reload. The complete data-hr-error vocabulary is in
Runtime, mount and the DOM contract.
The hydration contract
The renderer renders each content widget as an isolated React root:
// Platform source: homerunner-renderer/lib/render-utils.tsx:715, 733-744
const ssrId = `${widgetConfig.id}:${widgetConfig.feed.id}:${widgetConfig.instanceKey ?? '0'}`;
const widgetHtml = renderToString(
<WidgetHydrationTree queryClient={queryClient}>
<WidgetComponent {...props} />
</WidgetHydrationTree>,
{ identifierPrefix: ssrId },
);
mount() hydrates with the identical wrapper shape and the identical
identifierPrefix, read back from the host’s data-hr-ssr-id attribute. React derives
useId values from a component’s position in the tree plus that prefix, so any drift —
an extra provider, a different prefix, a hand-rolled hydrateRoot — makes every
useId consumer (Radix Dialog/Popover aria attributes, form ids) mismatch. React’s
response is to regenerate the whole tree and silently discard your server DOM.
This is why you must mount through mount() from
@homerunner-next/widget-core/runtime and never call hydrateRoot yourself. The
wrapper is PortalContainerProvider → ShadowHostProvider → QueryClientProvider; provider
values do not affect useId, only the shape does, which is why the server can pass
undefined and the client the live shadow elements. See
Runtime, mount and the DOM contract.
Two more hydration rules:
- Render deterministically. No
Math.random(), noDate.now(), no readingprops.widgetType(absent on the server), no readingwindow. Anything that differs between the two passes costs you the SSR DOM. renderToStringdoes not await. A suspended boundary ships its fallback into the HTML. Fetch ingetInitialData/dehydrateState, not by suspending during render.
Layout modules
Plugin layout widgets require widget-core 0.11.0+ and the suite manifest shape.
A layout widget’s SSR module follows the same contract with these differences:
| Content | Layout | |
|---|---|---|
| SSR bundle | Optional — "ssr": false is legal | Mandatory — a layout declaring "ssr": false fails at render; the exact message is in Layout widgets |
| Extra prop | — | renderedSlots: Record<string, ReactNode> |
| Server DOM | Isolated root, usually inside a declarative shadow root | Static light DOM, no shadow root, no DSD template |
| Client mount | Hydrates | Deliberate no-op on server-rendered pages |
| Render throw | Widget removed | Page 500 |
Because the shell is light DOM on server-rendered pages, its stylesheet is loaded
document-level and applies to the whole customer page. Namespace every class (pdp-suite
uses .pdpf-…), never rely on :host, and never paint a background. The same layout in a
single-snippet CSR embed does get a shadow root, so the CSS must work both ways.
Not supported yet. A plugin layout on a server-rendered page can never be interactive: the renderer emits no
data-hr-ssr-idand no registry entry for the shell, andmount()returns early for it. Its slot children hydrate themselves normally. Put interactivity in a content widget bound into a slot, not in the shell.
Layouts may still export getInitialData, dehydrateState and getStaticAssets — the
renderer calls all three. pdp-suite’s layout exports none. Slot mechanics, slots,
accepts, prefill, presets and pages are all in Layout widgets.
Where the local SSR preview differs from production
The local harness (renderPluginSSR / createSSRDevServer, see
Previewing SSR locally) is a faithful port of the renderer’s
sandbox. The sandbox itself now matches (widget-core 0.12.2+):
@homerunner-next/widget-core/runtime resolves as a shared module, TextEncoder /
TextDecoder are absent from both, __HR_PLUGIN_ASSET_BASE__ is defined, externalFetch is
enforced from the manifest, module scope runs under the same 5000 ms budget, and the render
goes through WidgetHydrationTree with identifierPrefix set — so useId drift reproduces
locally instead of first appearing on a customer’s page. On an SDK below 0.12.2 all six of
those diverge, and code that passes locally fails on the renderer.
What still differs is the environment around the sandbox, not the sandbox. This is the summary; Previewing SSR locally works through what each row does to you.
| Local preview | Renderer | |
|---|---|---|
__HR_PLUGIN_ASSET_BASE__ | http://localhost:{port}/dist/ | the bundle’s CDN base |
Private/loopback hosts in externalFetch | refused, unless apiBaseUrl is itself loopback | always refused |
props.options | seeded from configZod.parse({}) by the scaffold — fully shaped | the customer’s raw stored settings |
| Hydration envelope | data-hr-ssr-props on the host, never a declarative shadow root | a page-level props registry, DSD |
renderedSlots on a layout | always empty | the layout’s bound children |
Server-side fetch | your msw fixtures; unmatched requests reach the real network | the live feed |
Not supported yet. No local harness composes a layout’s slots.
renderPluginSSRbuilds the props itself and passes norenderedSlots, so a layout previews as its shell with every slot empty. That still proves the shell evaluates and renders without throwing — the failure that 500s a whole customer page — but it is not a preview of the composed result.
A suite widget previews like any other: createSSRDevServer takes the widget you name and
resolves dist/{widget}/{widget}-ssr.umd.js under plugin:{slug}:{widget}
(widget-core 0.12.2+; the suite scaffold’s dev:ssr script needs create-hr-plugin 0.8.1+).
Checklist
parseWidgetConfig(configZod, props.options ?? {})inwidget.tsxand in every SSR hook.- Types imported from
@homerunner-next/widget-core/contracts, not hand-written. - No
props.widgetType,Math.random(),Date.now()orwindowread during render. - Only
typeof window === "undefined"guards for browser-only code. - Query keys prefixed with your plugin slug;
["widget", …]left alone. - Every declared
externalFetchhost spelled exactly, with an explicit request timeout. getStaticAssetsomitted unless you genuinely need to vary assets by settings.- Layout components that cannot throw: defaults for every slot and every config field.