Dev sandbox and mocking
npm run dev is fully offline. The scaffold starts a Vite server on :3001, mounts your
widget exactly the way a customer page does, and answers every HomeRunner public-api call
from an MSW service worker fed by local fixtures. You never need a feed, an account or a
network connection to drive the UI through every data state. Server rendering is a separate
harness — see Previewing SSR locally.
Both shapes. create-hr-plugin generates a sandbox for each: --template single (the
default) the one described below, --template suite a version that mounts every widget of
the suite on one page and composes the layout — see Suites. Everything in
between the two sections applies to both.
What npm run dev actually does
index.html loads exactly one script, /src/dev/main.tsx. That file is dev-only — the
production client entries are src/index.tsx (single-widget) or
src/widgets/{slug}/index.tsx (suite), and neither they nor any ssr-entry.ts ever import
src/dev/ or mocks/, so MSW never reaches a published bundle.
// packages/create-hr-plugin/template/src/dev/main.tsx:39-70 (abridged) — single-widget.
// The suite template's src/dev/main.tsx has the same four steps; see "Suites" below.
const params = new URLSearchParams(window.location.search);
const mockEnabled = params.get("mock") !== "off";
if (mockEnabled) {
await startMockWorker(KEYWORD, {
scenario: (params.get("scenario") as MockScenario) || "default",
fixtures,
widgetSettings: defaultSettings(), // configZod.parse({}), try/catch → {}
delayMs: 300,
});
getSharedQueryClient().setDefaultOptions({ queries: { retry: false } });
}
registerPlugin(SLUG, Widget);
// …then mount() every [data-hr-widget-container] holding a [data-hr-widget="{KEYWORD}"]…
if (mockEnabled) mountDevPanel(KEYWORD);
Four of those steps have consequences you will meet:
?mockand?scenarioare read once, at boot, and neither is validated.- Settings are seeded with
configZod.parse({}), and the mocked/widget/{id}returns that object as the widget’s storedsettings— sooptions.<field>reads are defined instead of crashing. The parse is wrapped in atry/catchthat falls back to{}, so a schema with a required field and no default silently seeds nothing. - React Query retries are off. Dev only. Without it the
errorscenario looks like a long spinner while the default retry backoff runs. mount()is the real one — shadow root, theme controller,ClientWrapperForWidget, error boundary: Runtime, mount and the DOM contract.
The mount host is <div data-hr-widget="plugin:{slug}" id="dev-widget:1"> — one per
namespaced keyword in a suite, with its own id (frame-1:1, intro-1:1, facts-1:1).
That id is the colon-joined {widgetId}:{feedId} pair mount splits to build its config
fetch, and the single-widget default matches the mock defaults
(widgetId: "dev-widget", feedId: 1). The built-in /widget/{id} route matches any
id, so you can change it freely — the data-hr-widget keyword is the part that must stay
in sync with what you pass to mount(). In a suite the ids matter for a second reason:
mocks/fixtures.ts keys each one to its own keyword (Suites).
The two switches are ?scenario=default|empty|single|large|loading|error and ?mock=off
(real staging data). ?scenario= is cast, not validated: an unknown
value is stored verbatim, behaves like default, and leaves every panel button unhighlighted
— that unlit button row is the tell.
Scenarios
Six named data states, defined by the SDK and exported as MOCK_SCENARIOS:
| Scenario | Behaviour | Rows the built-in generators return |
|---|---|---|
default | a healthy, representative payload | 6 |
empty | zero results — empty states, placeholders | 0 |
single | exactly one result — single-item layouts | 1 |
large | a big page — overflow, pagination, virtualization | 48 (of a reported 240 total) |
loading | the request never resolves — spinners, skeletons | — |
error | the request 500s — error states, fallbacks | — |
loading is MSW’s delay("infinite"), so the promise genuinely never settles; error
returns a 500 after the configured latency; everything else applies the latency and falls
through to the data path.
Why the widget always mounts
Both shapes. The three config endpoints — /{feedId}/details, /widget/{widgetId}
and /{feedId}/feed/widgets — are deliberately not scenario-gated; they apply the
latency and nothing else. mount() cannot render until the widget’s feed and settings
resolve, so gating them would turn loading and error into a blank page with nothing to
look at. The handler file says so in as many words: “These are NOT gated by loading/error so
the widget always mounts”. The gated data endpoints are /{feedId}/feed/properties,
/{platform}/properties, /reviews, /property/{id}/full, /property-calendar and
/{uuid}/calendar — so the scenarios exercise your data code, never the bootstrap.
Fixtures — mocks/fixtures.ts
Anything you do not override uses the SDK’s built-in generators, scaled by the active scenario. Override an endpoint only when your widget needs a specific shape.
| Fixture key | Endpoint | Factory arguments |
|---|---|---|
feedDetails | GET /{feedId}/details | (feedId) |
widget | GET /widget/{widgetId} | (widgetId, widgetSettings) |
feedWidgets | GET /{feedId}/feed/widgets | none |
properties | GET /{feedId}/feed/properties, GET /{platform}/properties | (request) |
reviews | GET /reviews | (request) |
propertyFull | GET /property/{id}/full | none |
calendar | GET /property-calendar, GET /{uuid}/calendar | none |
Each value is either the raw JSON the endpoint returns, or a (…) => json factory called at
request time — a factory is how you vary the response by request:
// packages/create-hr-plugin/template/mocks/fixtures.ts — the commented example, uncommented
export const fixtures: MockFixtures = {
reviews: (request) => {
const url = new URL((request as Request).url);
const perPage = Number(url.searchParams.get("per_page") ?? 10);
return { status: "ok", result: [], pagination: { total_items: 0, average_rating: 0 } };
},
};
Two rules that catch people out. An override replaces the generator, not the gate —
loading and error still short-circuit before your fixture runs, so an error-scenario
screenshot is the platform’s 500, never your rows. And envelopes are not uniform: the
generators mirror the real API, where /{feedId}/details returns {status, status_code, result} while /widget/{id} returns {success, status_code, data, meta}. Match the
endpoint you are overriding — Public API.
Handlers match any origin (*/public-api/v1/…), so they work whatever
NEXT_PUBLIC_HOMERUNNER_BASE_URL points at. Anything they do not match falls through
untouched (onUnhandledRequest: "bypass") — Vite HMR, images, your own third-party APIs —
which is also why a mistyped public-api path silently hits the network instead of erroring.
Not supported yet.
MockFixtureshas an index signature, so TypeScript accepts extra keys — butcreateMockHandlersreads only the seven above, and an extra key is inert. To mock an endpoint that is not in the table, take the running worker off the control handle and add your own MSW handler:window.__HR_MOCK__.worker.use(…).
The dev panel
A plain-DOM bar pinned to the bottom of the page, mounted only when mocking is on. It drives
window.__HR_MOCK__, the control handle startMockWorker publishes.
| Control | What it does |
|---|---|
| scenario | control.setScenario(s), then a targeted query reset (below) |
| theme | light / dark / auto → writes colorScheme into mock branding and the widget settings, then dispatches options-updated |
| latency | control.setDelay(ms), 0–2000 in steps of 100, applied to every mocked response |
| settings | a JSON textarea → control.setSettings(obj) + the same options-updated push |
The reset-versus-invalidate rule
Both shapes. This is the one piece of panel behaviour you have to know, because it makes
the console and the buttons behave differently. Every scenario button runs this after
setScenario:
// packages/create-hr-plugin/template/src/dev/DevPanel.ts:44-52 (try/catch elided)
getSharedQueryClient().resetQueries({ predicate: (q) => q.queryKey?.[0] !== "widget" });
reset, not invalidate: invalidating keeps the previous data cached, so isLoading stays
false and a switch to loading / error / empty would keep painting the old rows. The
predicate spares every ["widget", …] key — the feed-and-settings query
ClientWrapperForWidget owns — so the widget re-runs its data queries without unmounting.
That reset lives in the panel, not in the mock layer, so the console equivalent needs both
calls (the shared client is a page singleton on window.HRWidget.__QUERY_CLIENT__):
__HR_MOCK__.setScenario("error") // alone: stale data stays on screen
HRWidget.__QUERY_CLIENT__.resetQueries({ predicate: (q) => q.queryKey?.[0] !== "widget" })
If your own queries use a key starting with the literal "widget" the predicate spares them
too, and the scenario buttons will appear not to work. Prefix your keys with your plugin slug,
as the scaffold’s queryKeys factory does — Fetching data.
Dark mode works here
The theme select writes colorScheme into both the mocked feed branding and the pushed
settings, and the CSR mount() path resolves resolvedTheme from that value: dark and
light pass through, auto follows your OS prefers-color-scheme, and light is only the
fallback when nothing is set anywhere. Older documentation claimed CSR mount always passed
resolvedTheme: "light" and sent you to the SSR harness for dark mode — that was wrong.
What is still true is that resolvedTheme is effectively always light in production
SSR, which is why dark styling belongs in CSS —
Styling and theming.
Testing options-updated
The settings editor is your test harness for the live-settings channel — the same
CustomEvent the dashboard playground dispatches while a customer types in the config form.
Paste an options object, hit apply settings, and the mounted widget re-renders without a
remount. The listener table, and the fact that a hydrated SSR widget has none, is in
Runtime, mount and the DOM contract. Two mechanics to
know before you trust what you see:
- The event payload replaces the widget’s settings wholesale. It is treated as the
widget’s complete stored
settingsand re-run throughgetFinalSettings, so paste a partial object and every other field falls back to feed branding or disappears. Test the payload your form would actually send — see Settings and options. - The panel’s own copy of the settings merges instead.
control.setSettings(obj)patches the mock store, while the dispatched event carries exactly what you typed. After a partial apply, the mocked/widget/{id}and the mounted widget hold different objects; a reload resolves to the merged one. Apply a complete object and the two never diverge.
Use it to prove your parse is real: clear a field to null or "" and confirm the component
still renders, because parseWidgetConfig strips those leaves rather than throwing. A widget
that reads props.options directly flips to data-hr-error="render-failed" here — exactly
the failure a customer would see.
Real staging data
Both shapes. ?mock=off skips the worker and the panel entirely, and your fetches go out
for real. Copy .env.example to .env.local, set
NEXT_PUBLIC_HOMERUNNER_BASE_URL=https://beta.homerunner.io, then open
http://localhost:3001/?mock=off. That file is optional for mocked development and only
consulted here and by npm run dev:ssr. Three things to get right:
- The env injection is dev-only.
viteHomerunnerWidgetreads everyNEXT_PUBLIC_*key from.env/.env.localand defines it as aprocess.env.Xliteral in the dev build only; production reads the same value fromwindow.HRWidgetRuntime.apiBaseUrl. A key that works here can be undefined at SSR time. - With no
.env.localyou hit the production fallback, not staging. Set the variable. - Change the mount
id.dev-widget:1exists on no real feed; the widget-config fetch fails and the host getsdata-hr-error="widget-fetch-failed". Put a real{widgetId}:{feedId}inindex.html. The public API needs no key but is rate limited per IP — Public API.
With no panel there is nothing to flip: this mode checks real response shapes against your fixtures, it does not drive states.
Suites
npx create-hr-plugin <slug> --template suite generates a sandbox that mounts every
widget of the suite on one page and composes the layout (create-hr-plugin 0.8.0+).
index.html carries one host per namespaced keyword, src/dev/main.tsx imports each
widget’s own src/widgets/{slug}/index.tsx — which registers and auto-mounts it, exactly
as its built IIFE does in production — and the layout’s mocked settings carry slots, so
it resolves the other two as real slot children through LayoutCsrRenderer. That is the
whole client half of layout composition, offline. The server half is not reproducible
locally: Previewing SSR locally.
Not supported yet.
startMockWorkeris a page singleton bound to ONE keyword — it returns the existing control on every repeat call and bakes that keyword into the handlers. So one page has one scenario, one latency, one branding and onesettingsobject for every widget on it. The scaffold seeds those settings with the union of every widget’sconfigZod.parse({})and relies on each widget’s zod stripping the keys that are not its own; give two widgets a field with the same name and they share its value here. (npm run dev:ssrrenders one widget, so it seeds only that widget’s defaults and has no such collision.)
What the singleton would break, and what stops it:
- Every content widget still mounts.
mount()renders the component you hand it and never routes on the keyword the mocked/widget/{id}echoes back. - A CSR layout’s slots would not work.
LayoutCsrRendererresolves each binding by fetching/widget/{childId}, and the built-in handler hands every id the one baked keyword — so every slot child would resolve to the layout itself.
The escape hatch is the widget fixture factory, which receives the requested id. The
suite template ships it, and it is not optional in a suite:
// packages/create-hr-plugin/template-suite/mocks/fixtures.ts (abridged) —
// per-id keywords, which is what lets three widgets coexist on one page.
const KEYWORD_BY_ID: Record<string, string> = {
"frame-1": `plugin:${SLUG}:page-frame`,
"intro-1": `plugin:${SLUG}:intro-card`,
"facts-1": `plugin:${SLUG}:quick-facts`,
};
export const fixtures: MockFixtures = {
widget: (widgetId: unknown, settings: unknown) => ({
success: true, status_code: 200, meta: [],
data: {
id: String(widgetId), status: true, feed_id: 1, user_id: 1, display_name: "Dev Widget",
keyword: KEYWORD_BY_ID[String(widgetId)] ?? `plugin:${SLUG}:intro-card`,
settings: settings ?? {},
},
}),
};
The bindings the layout opens with are a plain Record<slotName, widgetId[]> — the same
shape the dashboard’s Slot Configuration panel writes
(Layout widgets) — exported from the same file and merged into
the seeded settings under slots:
// packages/create-hr-plugin/template-suite/mocks/fixtures.ts — DEV_SLOTS
export const DEV_SLOTS: Record<string, string[]> = {
hero: ["intro-1"], main: ["facts-1"], sidebar: [], bottom: [],
};
// packages/create-hr-plugin/template-suite/src/dev/main.tsx (abridged) —
// import the entries AFTER the worker resolves, or the first fetch escapes unmocked.
await startMockWorker(KEYWORDS[0], { fixtures, widgetSettings: defaultSettings(), delayMs: 300 });
getSharedQueryClient().setDefaultOptions({ queries: { retry: false } });
await import("../widgets/page-frame/index");
await import("../widgets/intro-card/index");
await import("../widgets/quick-facts/index");
mountDevPanel(KEYWORDS); // a LIST of keywords — it pushes settings to all of them
The keyword passed to startMockWorker is only the fallback the built-in /widget/{id}
response echoes back; the factory above overrides it per id. mountDevPanel takes the
keyword list in the suite template and targets
[data-hr-widget="<keyword>"] for each, so one apply settings click reaches every
mounted widget — with the one shared object described above.
Converting an existing single-widget sandbox. Three files change, and nothing else:
index.html grows one [data-hr-widget-container] host per namespaced keyword with
distinct ids; src/dev/main.tsx drops its static ../widget / ../config imports for one
dynamic import() per src/widgets/{slug}/index, and passes a keyword list to
mountDevPanel; mocks/fixtures.ts gains the widget factory above. The build side needs
nothing — Vite’s CSS middleware already serves each widget’s transformed stylesheet at
/dist/{plugin}/{widget}/{widget}.css, walking that widget’s own entry so the sheet carries
only its imports, and the hr-widget-css-update HMR event cache-busts the <link> inside
that widget’s shadow root. The full project conversion is in
Build a suite.
The worker file
The sandbox registers /mockServiceWorker.js, which ships in public/ and is regenerated by
npx msw init public (both templates record msw.workerDirectory for exactly that) — do
that if the sandbox stops intercepting after an msw upgrade. Never delete it: it is
what makes npm run dev work.
Vite copies publicDir into outDir on every build, which used to publish that dev-only
file inside dist/ and list it in dist/manifest.json. Both scaffold templates now switch
publicDir off for builds and leave it on for dev (create-hr-plugin 0.8.0+):
// packages/create-hr-plugin/template/vite.config.ts and template-suite/vite.config.ts
publicDir: command === "build" ? false : "public",
A project scaffolded before 0.8.0 does not have that line — add it, or keep living with a
harmless extra file in your published prefix. Either way it is your build config that
decides, never the file’s presence in public/.
What the sandbox does not cover
It is a real mount() against fake data, and nothing more:
- No server render — no
node:vmsandbox, norequirewhitelist, nogetInitialData/dehydrateState, no hydration: Previewing SSR locally. - No Declarative Shadow DOM and no props registry. The sandbox always takes the CSR
branch, so
data-hr-ssr-idhydration bugs cannot appear here. - No
externalFetchenforcement. A cross-origin call your manifest never declares succeeds locally and is blocked on the server: Component and SSR module. - No dashboard form. Your
configSchemais never converted back to zod here, so a round-trip breaker renders fine locally and empties the customer’s config panel: Config schema and UI schema. - No server-side layout composition. The suite sandbox composes a layout’s slots the
client way, from bindings you wrote into
mocks/fixtures.ts. On a server-rendered page the renderer renders the bound children and hands them to the shell asrenderedSlots, in light DOM, with the client mount a deliberate no-op — a different code path with no local equivalent: Layout widgets.
Walk all six scenarios plus a dark-theme pass, then verify the server render. Symptoms and messages are indexed in Troubleshooting.