Troubleshooting
Organised by what you observe, not by the phase you were in. Every entry is symptom → cause → fix → the page that owns the rule.
Nothing on this page is normative. Every hard number, every enforcement level and every message the platform can emit lives once in Limits and error index; this page routes you into it and into the contract that explains the fix.
Paste your error here
Search this table for a fragment of what you actually saw. {…} marks an interpolated value.
| Message contains | Emitted by | Go to |
|---|---|---|
No matching version found for @homerunner-next/widget-core@ · npm error code ETARGET | npm | Install and scaffold |
Incompatible React versions | react-dom | Install and scaffold |
Error: directory "{slug}" already exists. · npm install failed — you can run it manually. | create-hr-plugin | Install and scaffold · index §11 |
Error: invalid plugin slug · is reserved — it collides with · Error: unknown template · Error: expected a plugin slug first | create-hr-plugin | Install and scaffold · index §11 |
hr-widget-build: {anything} | the SDK build CLI | Build · index §11 |
[viteHomerunnerWidget] This project declares manifest.widgets[] | the Vite preset | Build |
Cannot find module '@homerunner-next/widget-core/schema' | TypeScript | Build |
__HR_WIDGET_DEV__ is not defined | your bundle at runtime | Build |
dist/manifest.json (build manifest with {buildHash, files}) is missing | built-zip audit | Build · index §3 |
Missing {file} — run `npm run build` first. · a red SSR render failed: panel | the local SSR preview | Server rendering |
No widget selected — manifest.json declares · Unknown widget "{w}" — manifest.json declares · is CSR-only (`"ssr": false`) | the local SSR preview, at boot | Server rendering · index §11 |
ReferenceError: window is not defined · document is not defined | the SSR sandbox | Server rendering |
ReferenceError: crypto is not defined · TextEncoder is not defined · Response is not defined | the SSR sandbox | Server rendering · index §7 |
Plugin cannot require "{mod}" (not in shared modules whitelist) | the SSR sandbox | Server rendering · index §7 |
[plugin] fetch to "{host}" blocked · [plugin] fetch blocked — more than 5 redirects. | the SSR sandbox | Server rendering · index §7 |
[plugin] SSR bundle has no default export: {url} | the renderer | Server rendering |
HTTP {status} redirect refused — SSR bundles must be served directly from their published URL | the renderer | Server rendering |
data-hr-widget-error= in the page source | the renderer | Blank widgets · index §6 |
SSR data prep failed: {msg} | the renderer | Server rendering · index §6 |
layout SSR data prep failed: {msg} · plugin layout widget rendered as content | the renderer | Layout problems |
keyword does not resolve against the plugin manifest | the renderer | Blank widgets |
Widget ID is required | mount() | Blank widgets |
[widget-core] window.HRWidgetRuntime is not available. | the SDK runtime | Blank widgets |
[hr-widget] "{type}" mount failed for one embed · deferred mount step failed | mount() | Blank widgets · index §8 |
[hr-widget] "{type}" render failed · data-hr-error="render-failed" | the render boundary | Blank widgets |
[widget-core] widget "{type}" did not register within 10000ms · [layout-csr] … | a CSR layout | Layout problems |
[page-layouts] feed {id} {page}: {reason} — using Automatic | the renderer | Layout problems |
Unsupported schema type: [object Object] | the dashboard config panel | Config panel |
No configSchema for this widget. · sub-manifest fetch failed (HTTP {status}) | the dashboard config panel | Config panel · index §10 |
Plugin "{slug}" not enabled for this feed. · Widget "{keyword}" is not declared by plugin "{slug}". | the dashboard | Config panel |
{"error":"Asset not in plugin manifest"} · {"error":"Plugin not found"} | the /p/ proxy | Works locally, not live · index §9 |
VERSION_NOT_GREATER · SLUG_TAKEN · SLUG_RESERVED · INVALID_VERSION · INVALID_MANIFEST | central, at submit | Submit and publish · index §2 |
MANIFEST_SHAPE_CHANGED · changes the plugin between single-widget and multi-widget shapes | central, at publish | Submit and publish |
MANIFEST_WIDGET_CORE_MISSING · MANIFEST_WIDGET_CORE_TOO_OLD · predates the evergreen runtime | publish gates | Submit and publish |
already exists on the CDN — published versions are immutable | the dashboard uploader | Submit and publish · index §5 |
Two attributes are easy to confuse when you grep: data-hr-widget-error is a hidden
breadcrumb the server leaves where a widget should have been, and data-hr-error is a
marker the browser runtime puts on a live host element. Look for both.
Install and scaffold
| Symptom | Cause | Fix |
|---|---|---|
npm install fails with npm error code ETARGET / No matching version found for @homerunner-next/widget-core@… | A pin your project asks for does not exist on the registry — usually a hand-edited range, or a private/stale registry in .npmrc. Every version this book documents is published | Install and versions — check npm view @homerunner-next/widget-core versions against your pin |
hr-widget-build: command not found, or ls node_modules/.bin/hr-widget-build is empty | Your project resolves a widget-core older than 0.11.0 — before 0.11 the package declared no bin at all | Bump the pin and reinstall. On a pre-0.11 SDK the scaffold’s three-step build:manifest && build:iife && build:ssr is the only build path |
Error: directory "{slug}" already exists. (exit 1) | The scaffold refuses to write into an existing directory | Remove it or pick another name |
npm install failed — you can run it manually. and the CLI still exits 0 | Install failure is deliberately non-fatal | cd {slug} && npm install |
Error: expected a plugin slug first — got "{arg}". | argv[0] is always the slug; flags must come after it | Scaffold a project |
Error: invalid plugin slug "{slug}" — lowercase letters, digits and hyphens only… · Error: plugin slug "{slug}" is reserved… | The slug goes verbatim into manifest.id, every keyword and every CDN path, so the scaffolder applies the publish audit’s own rule up front | Rename — Manifest: root fields |
Error: unknown template "{t}" — expected "single" or "suite". | --template (or --template=) with anything else, or with nothing usable after it | The two shapes are single and suite — The two manifest shapes |
Incompatible React versions: The "react" and "react-dom" packages must have the exact same version. | A caret range on react / react-dom. Your client bundle externalises React against the page’s runtime, and the SSR sandbox hands you the renderer’s copies | Pin react, react-dom and @tanstack/react-query exactly — The React-family pins |
npm run dev:ssr starts fine but ignores every variable in .env.local | Node below 20.12 has no process.loadEnvFile, and the harness swallows the TypeError | Upgrade Node — Node |
| You wanted a suite and got one flat widget | You omitted --template suite (the default is single), or you passed --template=suite to create-hr-plugin 0.8.0, which parsed only the space-separated form and silently scaffolded single | Re-scaffold with --template suite on 0.8.1+, or convert before your first publish — Build a suite. The shape is frozen after that (The two manifest shapes) |
Build
Run npm run build. It is two steps — schema injection, then the SDK’s hr-widget-build
bin — not the three-step chain older guides describe; hr-widget-build runs the iife and
BUILD_SSR=1 passes for you and finalizes exactly once. Single-widget: the scaffold still
declares build:iife and build:ssr as escape hatches, and a hand-run pass never reaches the
finalize step. Suite: --template suite ships neither script, and a bare vite build
fails by design. See Build for the
single-widget shape and Build for a suite.
| Symptom | Cause | Fix |
|---|---|---|
hr-widget-build: no manifest.json in the current directory. | Run from somewhere other than the project root | cd to the root |
hr-widget-build: vite is not installed in this project. | Missing dev dependency | npm install |
hr-widget-build: invalid widget slug in manifest.widgets: {json} · widget slug "{s}" is reserved. · is declared more than once. | A widgets[] slug fails the slug pattern, is dist/widgets/media, or repeats | Manifest: widget summary |
hr-widget-build: missing entry for widget "{s}" ({path}). | No src/widgets/{slug}/index.tsx for a declared summary | Create the entry, or drop the summary — Build a suite |
[viteHomerunnerWidget] This project declares manifest.widgets[] — build it with `hr-widget-build` (which loops BUILD_WIDGET per widget), not a bare `vite build`. | A suite built through a bare vite build — usually the leftover build:iife / build:ssr scripts | Use npm run build; delete those two scripts from a suite |
dist/manifest.json missing, or the publish gate says dist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build. | Only the last Vite pass hashes dist/, writes the build manifest and stamps runtime.widgetCore. A hand-run single pass never gets there | Re-run the whole npm run build. Never patch dist/manifest.json by hand |
| A rebuilt widget still serves last build’s file | Per-widget passes run with emptyOutDir: false, so a hand-run pass leaves stale output beside the new files | Full npm run build — it clears dist/ once, up front |
manifest.json shows up dirty in git after every build | By design: schema injection then the runtime.widgetCore stamp both rewrite the tracked file | Commit it. The stamp is a publish gate — Packaging and publishing rules |
Cannot find module '@homerunner-next/widget-core/schema' (or /runtime, /plugin-schema, …) from tsc, while Vite builds fine | Your tsconfig.json resolves modules the pre-exports way, so the package’s subpath map is invisible | Set "moduleResolution": "bundler" (the scaffold’s value). The subpath list is in SDK reference |
__HR_WIDGET_DEV__ is not defined at runtime | The bundle was built without viteHomerunnerWidget, which defines that literal | Build through the preset — /vite and hr-widget-build |
mockServiceWorker.js appears in your published dist/ | Vite copies publicDir into outDir on every build. Both scaffold templates turn it off for builds since create-hr-plugin 0.8.0; a project scaffolded earlier does not have that line | Add publicDir: command === "build" ? false : "public" to vite.config.ts — but do not delete the file, the dev sandbox needs it. The worker file |
npm run preview shows a heading and an empty box | The dev page’s only script was the Vite dev entry, which the static server 404s, so nothing ever evaluated. Fixed in create-hr-plugin 0.8.0: the entry now carries an onerror that boots the runtime IIFE and your built bundle instead | Re-scaffold, or copy the bootPreview() block and the /p/{slug}/{file} route out of the current template’s index.html / serve.js. Note preview talks to live Central with no mocks, so point the host id="{widgetId}:{feedId}" at a real pair |
Before you zip, run the publish gates you can run locally: the script in
Run the reviewer’s gate yourself
checks declared assets, hashed twins, the HRPlugins marker, base-schema extension and
declared media/ files.
Dev server
Everything here is npm run dev — the offline Vite sandbox on :3001 with MSW-mocked data.
It is documented in Dev sandbox and mocking.
| Symptom | Cause | Fix |
|---|---|---|
Vite reports Port 3001 is in use, trying another one | Another dev server holds the port | Harmless — the dev CSS routes are root-relative, so they follow the new origin. Just use the port Vite prints |
| The widget mounts unstyled | Your CSS import is redirected to a no-op virtual module in dev and served at a URL instead; check /dist/{slug}/{slug}.css (single-widget) or /dist/{slug}/{widget}/{widget}.css (suite) actually returns your rules | Dev-server URLs |
Saving a .css file changes nothing | The server emits a custom hr-widget-css-update event that cache-busts the <link> inside the shadow root; a hard reload confirms whether the build is stale | Dev-server URLs |
| Every request escapes to the network | The MSW worker did not install, usually after an msw upgrade | npx msw init public. Never delete public/mockServiceWorker.js — The worker file |
| The scenario buttons do nothing and none of them lights up | ?scenario= is cast, never validated: an unknown value behaves like default and leaves the row unlit | Scenarios |
| Scenario buttons switch but the old rows stay on screen | The panel resets every query whose key does not start with "widget". Your own keys starting with "widget" are spared | Namespace query keys with your plugin slug — Query-key discipline |
?mock=off gives data-hr-error="widget-fetch-failed" | dev-widget:1 exists on no real feed | Put a real {widgetId}:{feedId} in index.html — Real staging data |
| In a suite, every layout slot child resolves to the layout itself | startMockWorker is a page singleton bound to ONE keyword, and the built-in /widget/{id} handler echoes it for every id | Use the widget fixture factory, keyed by the requested id — what --template suite ships. Suites |
| In a suite, two widgets show the same value for a setting | One page, one mock settings object — the suite sandbox seeds it with the union of every widget’s schema defaults, so same-named fields collide | Rename the field, or check that widget on its own in npm run dev:ssr -- --widget {slug}, which seeds only its schema — Suites |
The error scenario shows your fixture, not an error | Scenario gating short-circuits before your fixture runs, and only the data endpoints are gated | Why the widget always mounts |
Server rendering
The renderer evaluates your built SSR bundle in a node:vm realm with no DOM. The
contract is Component and SSR module; the local
harness and its divergences are Previewing SSR locally.
ReferenceError: window is not defined (or document, navigator, localStorage).
The sandbox seeds those seven globals as inert Proxy stubs, and a property read off a stub
returns another stub, which is truthy. So if (document) and if (window.matchMedia) both
pass on the server and then blow up on the real call. Only one guard form works:
// Verified in packages/homerunner-renderer/lib/plugin-federation.ts (makeBrowserGlobalStub)
// and the SSR build's typeof rewrite.
if (typeof window === "undefined") return; // rewritten to "undefined" at build, then DCE'd
The SSR build rewrites typeof on window, document, self, navigator, localStorage
and sessionStorage to the literal "undefined", so esbuild deletes the branch and the
imports inside it. Truthiness guards keep the dependency in the bundle. Full table:
Browser-global stubs, and the guard that actually works.
| Symptom | Cause | Fix |
|---|---|---|
ReferenceError: crypto is not defined (also TextEncoder, TextDecoder, Buffer, Response, structuredClone, queueMicrotask, performance) | Not seeded. There is no Web Crypto and no crypto.randomUUID() in the sandbox | Bundle a pure-JS replacement, or move the work into the browser. Inventory: Globals |
It works in npm run dev:ssr and throws TextEncoder is not defined on the first customer page | You are on widget-core below 0.12.2, which seeded TextEncoder/TextDecoder in the harness while the renderer never had them. 0.12.2 removed both, so the throw now happens locally | Upgrade the SDK, then bundle a pure-JS replacement — Where the preview differs from production |
Plugin cannot require "{mod}" (not in shared modules whitelist) | Exactly seven specifiers resolve; everything else must be bundled into your SSR file | Shared modules (7) |
That same throw for @homerunner-next/widget-core/runtime, but only locally | You are on widget-core below 0.12.2, which omitted /runtime from the harness’s whitelist while the renderer shared it. Fixed in 0.12.2 | Upgrade the SDK. Keeping server code off /runtime — a browser surface — is still the safer habit |
[plugin] fetch to "{host}" blocked — not declared in the widget's externalFetch manifest field. | Server-side fetch to a host outside Central ∪ your declared list. Nothing validates that list at publish, so a typo only shows up on a live page | externalFetch |
[plugin] fetch to "{host}" blocked — private/metadata hosts are never allowed. | Loopback, private ranges, *.local, link-local and metadata hosts are refused even when declared | Same |
[plugin] fetch blocked — more than 5 redirects. · [plugin] fetch blocked — unparseable URL: {raw} | Redirect chain too long, or a non-URL passed to fetch | Same |
| A widget renders stale server data for up to a minute | ok GET responses are cached across renders outside development mode | Bound in Runtime bounds; bypass with ?dev=1 |
HTTP {status} redirect refused — SSR bundles must be served directly from their published URL · a bare HTTP {status} | Your ssr.url 3xx’d or returned non-2xx | Execution bounds |
[plugin] SSR bundle has no default export: {url} | ssr-entry.ts exports no default. Mandatory for every widget, layouts included | The entry file |
SSR bundle failed to load from {url} with nothing else in the log | Often the module-scope execution timeout: synchronous top-level work exceeded the vm bound. Async work is not bounded by it | Move work into getInitialData; give every request an AbortSignal.timeout(…). Bound: Runtime bounds |
SSR data prep failed: {msg} | A throw in getInitialData, dehydrateState, getStaticAssets or your component’s server render — all four sit in one try | Parse ctx.options with parseWidgetConfig and default everything: Failure semantics |
[widget] SSR prefetch FAILED — shipping loading markup. and the widget renders a spinner | prefetchQuery swallows errors by design; the page is marked degraded | Render a real empty state, not a spinner, when a query has no data — Prefetch errors only warn |
Missing {file} — run `npm run build` first. or a red SSR render failed: panel locally | npm run dev:ssr reads dist/ and compiles nothing. {file} is dist-relative, so a suite’s reads {widget}/{widget}-ssr.umd.js | Build first — Running it |
npm run dev:ssr fails at boot with No widget selected… or Unknown widget "{w}"… | A suite has no default widget, and the selection is validated against manifest.json before the server starts | Name one: npm run dev:ssr -- --widget {slug} — Naming a widget |
npm run dev:ssr fails at boot saying the widget is CSR-only | The correct outcome: a widget whose summary declares "ssr": false has no SSR bundle and no ssr-entry.ts. Exact string in index §11 | Check it in the browser sandbox instead — Dev sandbox and mocking |
| A layout previews with all its slots empty | Not a bug and not fixable locally: renderPluginSSR passes no renderedSlots, and only the platform renders a layout’s bound children | Verify composition on a real feed — Suites: one widget at a time |
When a widget is simply absent from a rendered page, view-source and search for
data-hr-widget-error. The renderer leaves a hidden node whose HTML comment carries the
reason; each reason maps to a cause in
index §6. A page containing one is
flagged degraded and its cache TTL collapses, so it is not stuck — it re-renders shortly.
Hydration mismatches
React discards the whole server DOM on a mismatch and re-renders client-side. The symptom is almost never an error: it is a visible flash, an interaction that only works after a beat, or SSR markup that is silently thrown away. The contract is The hydration contract.
| Symptom | Cause | Fix |
|---|---|---|
Server HTML is replaced wholesale on hydrate; useId-derived ids (Radix aria-*, form ids) differ | The server renders each widget as an isolated root inside WidgetHydrationTree with identifierPrefix = the host’s data-hr-ssr-id. Any drift in wrapper shape or prefix regenerates the tree | Never call hydrateRoot yourself — Why you must not call hydrateRoot yourself |
| Output differs between server and client for no obvious reason | props.widgetType is absent on the server and on the hydrate path, and present only on pure CSR. Same for Math.random(), Date.now(), any window read | Render deterministically — What each path actually passes |
| Dark mode is correct on CSR and light forever on a server-rendered page | resolvedTheme derives from a cookie the edge worker never forwards, so it is "light" on every production SSR — and mount() hydrates with the server’s props, so it stays light | Branch in CSS off the host attribute — The theme contract |
| Visible server content that never becomes interactive, sitting over an invisible second render — and it looks fine in some browsers | The manifest says shadowDOM: true and your mount() passes false. The browser attaches the server shadow root at parse; your light-DOM render is never displayed | Make the two agree — When shadowDOM disagrees |
| The widget client-renders even though the server clearly emitted markup | A component whose entire server output is a hoistable tag (<style>, <link>, <script>, <meta>, <title>, <base>) reads as “no server markup” | Nest server output inside a real element — Shadow DOM lifecycle |
[widget] SSR state could not be inflated; widgets fetch client-side. | The page’s dehydrated-state block was malformed or blocked; the page degrades to client fetching rather than breaking | Check the page’s inline scripts are not being rewritten or CSP-blocked — The DOM contract |
data your getInitialData returned arrives mangled | It travels as JSON in an inline script; Date, Map and functions do not survive | Return JSON-serialisable values only |
| You cannot reproduce a hydration bug locally | npm run dev never takes the SSR branch at all. npm run dev:ssr in hydrate mode does apply WidgetHydrationTree and the identifier prefix since widget-core 0.12.2 — below that it rendered bare with no prefix, so useId drift was neither reproducible nor trustworthy | Upgrade the SDK and run HR_SSR_HYDRATE=1 npm run dev:ssr; the remaining gap is Declarative Shadow DOM and the props registry — Where the preview differs |
Blank widgets and error states
Start by reading the host element. data-hr-error is set by the browser runtime; its complete
vocabulary and what clears each value is
The data-hr-error vocabulary.
| Value on the host | What it means | Where to look |
|---|---|---|
render-failed | Your component threw during render. By far the most common cause is reading props.options without parseWidgetConfig | Options arrive RAW |
mount-failed | A throw inside mountOne — classically a missing or non-colon-joined id — or a rejected deferred hydrate/render step | Idempotency and per-embed isolation |
widget-fetch-failed | The widget-config fetch failed. A non-colon-joined id makes feedId NaN and 404s the fetch | Attributes mount reads |
rate-limited | The config fetch returned 429 | Rate limit |
property-not-found | A CSR layout was pointed at a property slug that does not exist on the feed | Layout problems |
The raw-options trap, spelled out. props.options arrives unparsed on both the server
and the client — plugins are deliberately excluded from the renderer’s settings parse. Raw
settings carry null for a cleared field, [] for an emptied map, stringified numbers and a
legacy width object. Calling configZod.parse() on that throws; parseWidgetConfig strips,
coerces and lifts first. Call it in the component and in every SSR export:
// pdp-suite/src/widgets/stay-hero/widget.tsx (real, published)
const cfg = parseWidgetConfig(configZod, props.options ?? {});
Reproduce it locally by clearing a field to null or "" in the dev panel’s settings editor
— see Testing options-updated.
| Symptom | Cause | Fix |
|---|---|---|
Nothing happens at all: no error, no data-hr-mounted, no console line | mount selects on [data-hr-widget="{widgetType}"] inside the container you pass. A wrong keyword matches nothing and returns silently | Check the keyword on the host equals config.widgetType exactly — mount(container, config) |
[widget-core] window.HRWidgetRuntime is not available. Make sure the runtime.iife.js <script> tag loads before your widget entry. | Script order on a hand-written embed | Runtime first, then your bundle — Boot order |
| The bundle 200s in the network tab and never executes | crossorigin="anonymous" on a /p/ script tag. /p/ 302s off-host to a target with no ACAO, so the browser rejects the final response in CORS mode | Drop the attribute — The crossorigin trap |
| The bundle executes but the component never renders | The registry key must be the keyword minus plugin:. The publish audit only greps for the literal HRPlugins, so a bundle registered under the wrong key passes and fails silently | Registration |
Widget ID is required | The host has no id. It must be the colon-joined "{widgetId}:{feedId}" | The embed shape |
Fixing the DOM and calling mount again does nothing | data-hr-mounted is written before the id check, so a failed embed keeps the marker | Remove the attribute, or call el.__hrWidgetUnmount() — The unmount handle |
The failure node’s reason is keyword does not resolve against the plugin manifest | The stored keyword does not match the manifest shape: a bare plugin:{slug} against a suite, a widget slug against a v1 manifest, or an unknown/kill-switched slug | Shape mismatches fail closed |
| A live edit in the dashboard preview does nothing | Only the CSR paths listen for options-updated; a hydrated SSR widget installs no listener | Live preview |
| One embed broke and the others are fine | Working as designed — each element mounts inside its own try | Idempotency and per-embed isolation |
| A CSR-only widget leaves a tall gap, or none at all | preloadMinHeightHint returns null for every plugin:* keyword, so nothing is reserved unless you supply a height | Set expectedHeight (Manifest: widget summary) or data-hr-min-height — Anti-CLS preload reservation |
Empty or wrong config panel
| Symptom | Cause | Fix |
|---|---|---|
An amber banner: No configSchema for this widget. Ask the plugin developer to ship one via zodToManifestSchema(widgetSchema.extend({...})) in the widget's config.ts. | The sub-manifest is missing from the CDN, 404’d, timed out, or carries no configSchema. It also appears when the browser cannot rebuild a zod object from your JSON Schema ([plugin-playground] jsonSchemaToZod failed: in the console). This degrades; it is never an error | Per-widget sub-manifests |
sub-manifest fetch failed (HTTP {status}) in the console | The declared widgets/{slug}.manifest.json is not at that path under the published version prefix | It must be committed and shipped in both zips — What must be committed |
The whole config page crashes with Unsupported schema type: [object Object] | z.any(), z.unknown(), z.literal(), z.tuple(), z.intersection() or z.discriminatedUnion() survived the JSON-Schema round trip into the dashboard’s unguarded default-value walker | Never ship those six. Model variants as a z.enum() discriminator plus flat optional fields — Breaks the whole panel |
Plugin "{slug}" not enabled for this feed. | The plugin is not installed on that feed | Install, customers and kill switches |
Widget "{keyword}" is not declared by plugin "{slug}". | A stored keyword the current manifest no longer resolves — usually a slug you renamed or removed | Deprecate instead of deleting — deprecated and replacedBy |
| A built-in panel (Filter, Spacing, Section…) lost its controls | You re-declared a base key in your uiSchema. The adapter replaces a top-level key wholesale — it does not deep-merge | Never re-declare a base key |
Fields inside a panel are labelled translations.viewDetails | The default label is the full dotted path; there is no humanisation | Declare label on every nested field — The uiSchema |
Your uiSchema key order is ignored | Plugins cannot reorder anything; order is dropped | Move the field in the zod schema — The uiSchema |
| Everything the customer types into a record field fails your runtime parse | The key/value editor writes strings | Keep record values z.string() — Degrades silently |
A cross-field .refine() never fires in the dashboard | zodToJsonSchema erases refinements; the form’s validation is cosmetic anyway | Enforce it in parseWidgetConfig and degrade rather than throw — Reading options back |
| A changed schema default does not reach existing widgets | The seeded object is autosaved on the customer’s first edit, so your defaults became stored values | Defaults at widget creation |
| The panel is fine but standard theme/spacing controls are missing | Your configSchema does not extend the base widgetSchema. This is a publish warning, visible only to the reviewer | Build config.ts as widgetSchema.extend({...}) — The base schema |
Layout problems
Layouts are suite-only and need widget-core 0.11.0+. The full contract is Layout widgets.
| Symptom | Cause | Fix |
|---|---|---|
| Your layout is not offered under Feed → Page URLs → Page layouts | It appears only when the plugin is approved and enabled on that feed, the widget is not kill-switched, and the summary declares that page in pages | pages and page assignment |
| The customer’s slot picks vanish on save | There is nowhere to store them: your zod config has no slots object field | Add it, keys matching the manifest slot names exactly — The mandatory slots config field |
| One slot renders and another never does | Slot names must match character for character between the manifest summary, the zod field and (for presets) the preset’s slots keys. Preset slot keys are never validated | settings.slots · Presets |
A slot is empty and renderedSlots.hero is undefined | An unbound slot is absent, not empty. On an assigned page only the keys present in the stored bindings are rendered — nothing is backfilled for a plugin layout | Always props.renderedSlots?.[name] ?? null — The component contract |
| The layout shell is inert on a server-rendered page: no clicks, no state | Known gap. The renderer emits no data-hr-ssr-id and no props-registry entry for a shell, and the client mount is a deliberate no-op — this is correct behaviour, not a broken mount | Put interactivity in a content widget bound into a slot — SSR: the server composes your children |
| The whole customer page 500s | A layout component that throws during render is not contained: the shell is rendered later, in the page-level renderToString, which has no try | Never throw in a layout’s render path. Parse defensively, default every field and every slot — Failure semantics |
A breadcrumb reason of plugin layout widget rendered as content — needs a layout entry with slots | A category: "layout" widget was placed as ordinary content | Manifest: category |
layout SSR data prep failed: plugin layout widgets require an SSR bundle (`ssr: false` is content-only) | The summary declares "ssr": false. The publish audit lets that through; the renderer does not | An SSR bundle is mandatory for a layout — Requirements at a glance |
| The server render replaces itself with an empty client render | category: "layout" was omitted from both registerPluginWidget and mount | Registration and mount |
| The layout stylesheet restyles the customer’s whole page | On a server-rendered page the shell is light DOM and its CSS is loaded document-level | Class-namespace every selector, never rely on :host, never paint a background — Layout CSS |
A CSR slot child stays empty; [widget-core] widget "{type}" did not register within 10000ms | Its IIFE 404’d or never registered. The layout still renders | CSR: the layout composes its own children |
[layout-csr] Skipping nested plugin layout child "{keyword}" — layouts cannot nest. | A layout was bound into a slot. Refused on both render paths | Layouts cannot nest |
[layout-csr] Property "{slug}" was not found in feed {id}. and data-hr-error="property-not-found" | The one whole-layout failure: a permanent 4xx on the property check | Fix the layout’s Property setting or the embed’s data-hr-options filter |
accepts: ["layout"] matches nothing in the slot picker | Known gap — the picker excludes layouts before accepts is evaluated, and accepts is unvalidated at publish | Known gaps, collected |
A listings / checkout / confirmation page 404s outright | Those pages pass only a slot named content, and 404 when it resolves to no live widget | Name a slot exactly content — What the renderer does with an assignment |
[page-layouts] feed {id} {page}: {reason} — using Automatic | The renderer could not honour the assignment and fell back rather than failing | Read the reason — Degrading to Automatic |
| You deprecated an assigned layout and it keeps rendering | Known gap: the dashboard flags it, the renderer does not check deprecated | Ask the customer to switch that page back to Automatic |
| A dialog inside a layout shell never opens | On a server-rendered page there is no shadow root and no portal container in a shell | Layout widgets have no portal container |
Styling and dark mode
| Symptom | Cause | Fix |
|---|---|---|
| The widget renders with no styles at all on a live page | Your assets.css never resolved — a /p/ 404, a wrong dist-relative path, or a crossorigin attribute on the tag | Two serving paths · The crossorigin trap |
| A widget briefly renders unstyled, then styles | The shadow CSS gate resolves regardless after its timeout; CSS never blocks hydration. A failed sheet logs [HRWidget] Failed to load CSS in shadow DOM: <href> | Shadow DOM lifecycle |
| The customer’s page loses all its margins, padding and borders | Tailwind preflight. On a server-rendered page your stylesheet is also appended to <head>, so its universal selector resets their whole document | Import Tailwind without preflight — The Tailwind stack |
Your .card / .title rules collide with the host page | Same reason: on a composed page every selector you ship is a global selector | Namespace every class — Where your CSS actually lands |
| Dark mode follows the visitor’s OS instead of the configured colour scheme | Tailwind’s dark: variant compiles to @media (prefers-color-scheme: dark) | Key off the host attribute instead — The theme contract |
| Your dark arm silently never matches | :host([data-hr-scheme="auto"]):not([data-hr-theme]) parses fine and matches no shadow host. The :not() must be inside :host(...) | Four footguns |
| The dark rule loses to the light rule | :where() holds specificity at zero, so the dark selector has to string-match the light one | Same |
auto still flashes light before hydrating | You did not declare mediaSafeAuto, or the request was not in a DSD bucket | Dark at first paint · mediaSafeAuto |
| A dialog opens as unstyled browser-default HTML over the page | The portal target fell through to document.body, which is outside your shadow root | Two portal targets |
| A dialog is almost right — a white panel over a dark widget | The elevated portal clones only link[rel="stylesheet"]. Everything you rendered as an inline <style> (accent, font, customCss) is absent, so every token falls back | Build the token string once and pass it as cssString too — The token trap |
Your @font-face is ignored | @font-face does not work inside a shadow root | Declare the stylesheet in assets.fonts, which is injected at document level — The two injection paths |
| Declared fonts arrive after first paint on a server-rendered page | You export getStaticAssets, and declared fonts are appended only on the manifest path | Omit getStaticAssets unless you truly need it — Component and SSR module |
A font you edited into manifest.json is not loading | The build bakes the font list into the client banner at config time | Rebuild after editing — Web fonts |
An imported image ships as a huge data: URL | Vite library mode inlines everything unless tagged ?no-inline; the preset only tags listed extensions | Keep unusual asset types off the import graph — Bundled assets |
spacing, font, accent colours or customCss have no effect | The platform applies none of the base fields for you | Render them yourself — Rendering the base fields yourself |
Submit and publish rejections
Three gates, and you only watch two of them: Preflight and submit walks all three, and every code and message is in index §1–§5.
| Symptom | Cause | Fix |
|---|---|---|
| The uploader lists zip problems and never transmits | The source-zip audit runs in your browser: structure, entries, sizes, and the manifest’s shape | The source zip · index §1 |
VERSION_NOT_GREATER after a clean browser audit | The browser checks the version’s shape, never its ordering against the live version | Version rules |
INVALID_VERSION on something a looser parser accepts, e.g. 1.0 | Central demands exactly three numeric parts | Same |
SLUG_TAKEN · SLUG_RESERVED | Another author owns the slug, or it is an unowned legacy row | Checked after the bytes are in the bucket |
INVALID_MANIFEST for a manifest your browser accepted | Central applies rules the browser does not — length caps on id, name, version, description, author, plus the widget count ceiling | Manifest: root fields |
MANIFEST_SHAPE_CHANGED — at publish, after review passed | A live plugin can never flip between the single-widget and suite shapes. It is not checked at submit or at review | Publish the new shape under a new slug — The manifest shape is frozen at first publish |
MANIFEST_WIDGET_CORE_MISSING / _TOO_OLD, or manifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime. | The build’s stamp is missing or below the floor. It is written by the last build pass; never by hand | Update widget-core, rebuild, commit manifest.json — runtime |
Declared icon {ref} is missing from the zip. (also cover, screenshot, README, sub-manifest) | media/ and widgets/*.manifest.json are authored content. Nothing generates them, and the reviewer only runs your build | Commit them into the source zip — Media references |
{slug}@{version} already exists on the CDN — published versions are immutable. | Version prefixes can never be overwritten | Resubmit with a bumped version — Immutability and retry safety |
| A file you shipped is simply not on the CDN | Anything more than one directory deep under dist/, widgets/ or media/ is dropped silently; an unusual file name is dropped with a reviewer-only warning | Keep the SDK’s output layout — The publishable surface |
| Your plugin shipped with an empty settings form and nobody told you | Known gap: built-zip warnings are visible only in the reviewer’s console | Run the local gate script before you zip — Run the reviewer’s gate yourself |
| Silence after submitting | Known gap: there is no notification of any review outcome, ever | Poll Dashboard ▸ Plugins ▸ My plugins — From your laptop to a customer page |
| Everything published, and every cache optimisation is gone | You published a pre-release version like 1.0.0-rc.1. Every immutability optimisation keys off a bare X.Y.Z segment | Use plain three-part versions — Known gaps |
It works locally but not on the customer site
The single most productive question: which of the two harnesses did you verify in, and what does it not simulate?
| It works in… | …and breaks live because | Read |
|---|---|---|
npm run dev | The sandbox seeds settings with configZod.parse({}), so every field is present and typed. Customers’ stored settings arrive raw | Options arrive RAW |
npm run dev | It never converts your configSchema back to zod, so a round-trip breaker looks fine here and empties the customer’s panel | What survives the manifest round-trip |
npm run dev | It always takes the CSR branch: no DSD, no props registry, no hydration | What the sandbox does not cover |
npm run dev on a suite | It composes a layout’s slots the client way, from bindings in mocks/fixtures.ts. A server-rendered page composes them in the renderer and hands the shell renderedSlots in light DOM | Suites |
npm run dev:ssr | The harness pre-parses options, points __HR_PLUGIN_ASSET_BASE__ at localhost, emits props as an attribute rather than the page registry, and never composes a layout’s slots. On widget-core below 0.12.2, add: seeded TextEncoder/TextDecoder, no /runtime, no externalFetch enforcement, no execution timeout | Where the preview differs from production |
| Either harness | Neither emits Declarative Shadow DOM or the page-level props registry | Hydration mismatches |
| A CSR embed | On a composed page your stylesheet is also in <head>, unisolated | Where your CSS actually lands |
| A CSR embed of a layout | The CSR layout gets a shadow root and full providers; the server-rendered shell gets neither, and its mount no-ops | How a layout renders |
And when the plugin is live but a page still disagrees with you:
| Symptom | Cause | Fix |
|---|---|---|
{"error":"Asset not in plugin manifest"} from /p/ | The file is neither a manifest-declared asset nor a key of your dist/manifest.json. In a suite it must also sit under a directory named for a declared widget slug | The allow-list |
{"error":"Plugin not found"} from /p/ | Unknown slug, or the plugin is not approved — a suspension 404s every file | The three kill switches |
| One widget of your suite stopped rendering everywhere, with no message anywhere | A platform per-widget kill switch strips its summary from every public surface and 404s its entire dist/{widget}/ directory | Same page |
| One widget stopped rendering on one feed only | The customer’s per-feed toggle. Assets keep serving; only that feed’s instances stop resolving | Same page |
| A widget you retired keeps rendering on existing pages | That is what deprecated does — it hides the widget from the picker and leaves placements alone | deprecated is not a kill switch |
| You published and customers still see the old build | Cache, not publishing. The publish fan-out purges what it can reach inside its budget; anything it misses waits out the page TTL | How an update reaches a customer |
| A page mixes new markup with an old bundle | It cannot. Server-rendered pages embed content-addressed URLs, so a cached page keeps loading exactly the bundles it was rendered with | A stale page is stale consistently |
| A live page broke after old version prefixes were pruned | Deleting an old prefix is the one operation that can break a cached page | What holds a copy of your old build |
| A 429, or data that stops arriving under load | The public API is rate limited per IP, shared across every endpoint | Rate limit |
Older versions of this guide told you to call a
refresh_manifestAPI to clear a stale manifest. Do not look for it: it is an administrator-only action with no author-facing surface. Publishing records the manifest and triggers the warm-and-purge fan-out; there is nothing for you to invalidate.
Seeing what the server actually did
Four signals, all owned by
Seeing what the server actually did on a real page:
development mode (?dev=1, which bypasses caching for a feed), the <!-- hr-render … -->
stamp at the end of the page source, the x-hr-render-* headers, and view-source for
data-hr-widget-error.
Two more things worth knowing while you read a live page:
- Your own
console.*calls inside the SSR bundle go to the renderer’s stdout, not to the browser. Ask an operator to grep the[plugin],[widget]and[layout]prefixes listed in index §8. - Check what is actually live before you debug anything else:
Checking a release yourself
reads your published
manifest.jsonanddist/manifest.jsonstraight off the CDN.
Still stuck
- Unfamiliar word? Glossary.
- Exact message, exact number, exact enforcement level? Limits and error index.
- Not sure which layer even owns the behaviour? How a widget renders draws all three render paths and From your laptop to a customer page draws the pipeline and says who is blocked at each step.