Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. ?mock and ?scenario are read once, at boot, and neither is validated.
  2. Settings are seeded with configZod.parse({}), and the mocked /widget/{id} returns that object as the widget’s stored settings — so options.<field> reads are defined instead of crashing. The parse is wrapped in a try/catch that falls back to {}, so a schema with a required field and no default silently seeds nothing.
  3. React Query retries are off. Dev only. Without it the error scenario looks like a long spinner while the default retry backoff runs.
  4. 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:

ScenarioBehaviourRows the built-in generators return
defaulta healthy, representative payload6
emptyzero results — empty states, placeholders0
singleexactly one result — single-item layouts1
largea big page — overflow, pagination, virtualization48 (of a reported 240 total)
loadingthe request never resolves — spinners, skeletons
errorthe 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 keyEndpointFactory arguments
feedDetailsGET /{feedId}/details(feedId)
widgetGET /widget/{widgetId}(widgetId, widgetSettings)
feedWidgetsGET /{feedId}/feed/widgetsnone
propertiesGET /{feedId}/feed/properties, GET /{platform}/properties(request)
reviewsGET /reviews(request)
propertyFullGET /property/{id}/fullnone
calendarGET /property-calendar, GET /{uuid}/calendarnone

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 gateloading 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. MockFixtures has an index signature, so TypeScript accepts extra keys — but createMockHandlers reads 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.

ControlWhat it does
scenariocontrol.setScenario(s), then a targeted query reset (below)
themelight / dark / auto → writes colorScheme into mock branding and the widget settings, then dispatches options-updated
latencycontrol.setDelay(ms), 0–2000 in steps of 100, applied to every mocked response
settingsa 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 settings and re-run through getFinalSettings, 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. viteHomerunnerWidget reads every NEXT_PUBLIC_* key from .env / .env.local and defines it as a process.env.X literal in the dev build only; production reads the same value from window.HRWidgetRuntime.apiBaseUrl. A key that works here can be undefined at SSR time.
  • With no .env.local you hit the production fallback, not staging. Set the variable.
  • Change the mount id. dev-widget:1 exists on no real feed; the widget-config fetch fails and the host gets data-hr-error="widget-fetch-failed". Put a real {widgetId}:{feedId} in index.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. startMockWorker is 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 one settings object for every widget on it. The scaffold seeds those settings with the union of every widget’s configZod.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:ssr renders 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. LayoutCsrRenderer resolves 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:vm sandbox, no require whitelist, no getInitialData / 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-id hydration bugs cannot appear here.
  • No externalFetch enforcement. A cross-origin call your manifest never declares succeeds locally and is blocked on the server: Component and SSR module.
  • No dashboard form. Your configSchema is 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 as renderedSlots, 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.