CustomRenderer (Next)
CustomRenderer is an escape hatch, not a product default.
- Prefer a built-in layout component (
MapDisplay,ComposableChart,StatsSummary, and so on) wired to$heat-dataservicechannels from thedashboard-v2node (or the Legacy dashboard facade on v1). - Use CustomRenderer only for short-lived prototypes, one-off operator layouts, or migration bridges while a proper node template and widget exist.
- In production, use
renderMode: "sandbox"(the default). Do not useinlineunless the template is fully trusted and you accept full access to the parent dashboard page (tokens, DOM, storage). - Custom HTML/JS is not reviewed by HEAT at runtime: template authors are trusted, but mistakes can break the session page or leak data in inline mode.
CustomRenderer renders arbitrary HTML, CSS, and JS from layout JSON (customRendererItem). It does not subscribe to $heat-dataservice channels the way MapDisplayDS or ComposableChartDS do.
On session analysis pages, scripts can read window.$session, including inside the sandboxed iframe (see Does $session work in sandbox?).
When to use something else
| Your goal | Recommended approach |
|---|---|
| Time-series chart, map, stats, playback | Built-in component + channels per dashboard-v2 upstream contract |
| Stable contract for integrators | New or existing node template + runner output + documented channel shape |
| Read simulation metrics in the UI | Bind configuration.channels on a standard widget; let the data service handle sampling |
| Quick spike / internal demo | CustomRenderer in sandbox, keep JS small, branch on $session.generation |
| Access parent dashboard auth or DOM | Avoid CustomRenderer; if unavoidable, inline mode on trusted templates only (still discouraged) |
If the dashboard-v2 node already publishes data, the sensible path is almost always: add or reuse channels in $heat-dataservice, then pick a manifest widget that declares the right channelShapes. CustomRenderer should not replace that pipeline.
How this relates to the dashboard node
The dashboard-v2 node (Legacy: dashboard node) does two jobs that matter here:
- Layout , merged
layoutConfiguration(rows,componentids, per-widgetconfiguration). This becomes$session.layout/$session.layoutConfigurationon the session page. - Session payload , the JSON blob the runner stored for the dimension (channel data, scores, legacy keys, and so on). On the session page this is exposed as
$session.payloadwithout HEAT rewriting its shape.
| Data | Built-in widget (e.g. MapDisplay) | CustomRenderer |
|---|---|---|
| Layout metadata | configuration on the layout row | Same row’s configuration + customRendererItem |
| Metric / series data | $heat-dataservice channels via useDataServiceData | $session.payload only (opaque snapshot) |
| Playback clock | Page / widget integration with data service | Not on $session (use built-in widgets or inline + window.heat on Next) |
Next (generation: "next"): $session.payload is the last raw session API object captured before channel ingest. It often contains $heat-dataservice (canonical realms/channels) but may still include legacy top-level keys if the runner publishes them. The data service also ingests channels for built-in widgets; CustomRenderer does not automatically use that ingest path.
Legacy (generation: "legacy"): $session.payload is the v1 dashboard facade blob (dashboardData: keys such as Map, combined-custom-charts, ImpactHeatmap, and so on). Built-in Legacy widgets still fetch their own API slices; CustomRenderer only sees what you read from $session.payload in script.
Rule of thumb: if the data you need is already a channel in $heat-dataservice, do not scrape it from $session.payload in CustomRenderer. Add a proper widget binding instead. Use $session.payload when you truly need ad hoc access to the stored runner JSON (debug panels, migration shims, prototypes).
Dashboard generation
| Value | |
|---|---|
| Frontend | ui/dashboard (CustomRendererDS → CustomRenderer) |
| Legacy | ui/legacy thin wrapper importing CustomRenderer from heat-next (npm run prepare:heat-next in legacy after dashboard build:lib) |
| Channel binding | None , channels: [] in layout |
| Session context | window.$session on session analysis routes |
Layout component identifier
CustomRenderer , cols[].component in v2 layout rows (Legacy mount id matches).
Layout configuration
| Field | Description |
|---|---|
component | "CustomRenderer" |
name | Internal widget name |
titleContent | Panel title (shown when useCard is true) |
channels | Usually [] |
customRendererItem | HTML/CSS/JS payload and render options |
customRendererItem
| Field | Required | Default | Description |
|---|---|---|---|
html | yes | , | HTML fragment in the widget body |
css | no | "" | CSS rules injected into the render context |
js | no | "" | JavaScript run after HTML is mounted |
useCard | no | true | When true, wrap with title + ComponentContainer |
renderMode | no | "sandbox" | "sandbox" or "inline" (see security) |
height | no | 12rem min-height | CSS length for the render area |
Sandbox example (recommended)
{
"component": "CustomRenderer",
"colspan": 6,
"configuration": {
"name": "custom-demo",
"titleContent": "Custom panel",
"channels": [],
"customRendererItem": {
"html": "<div id=\"root\"></div>",
"css": "#root { font-family: sans-serif; padding: 1rem; }",
"js": "var r = document.getElementById('root'); r.textContent = $session.title + ' (' + $session.generation + ')';",
"useCard": true,
"renderMode": "sandbox"
}
}
}Inline example (discouraged except trusted internal templates)
{
"component": "CustomRenderer",
"colspan": 12,
"configuration": {
"name": "custom-inline",
"titleContent": "Trusted only",
"channels": [],
"customRendererItem": {
"html": "<p id=\"x\">Inline</p>",
"js": "document.getElementById('x').textContent = $session.query('scenarioInstance.scenarioName') || 'n/a';",
"renderMode": "inline",
"useCard": true
}
}
}Set useCard: false when the layout grid cell already provides chrome and you want edge-to-edge content.
window.$session
On session analysis routes, SessionPageContextProvider sets window.$session (Next and Legacy share the same top-level fields). CustomRenderer scripts read it without React hooks.
Does $session work in sandbox?
Yes. With the default renderMode: "sandbox", the dashboard builds the iframe srcDoc with a bootstrap script that runs before your css and js. That script assigns a frozen copy of $session on the iframe’s window, including query(path).
- Your layout
jsruns inside the iframe and can use$sessionand$session.query(...)the same way as in inline mode. - The copy is a snapshot at iframe render time. When the user changes realm, or layout/payload updates, the iframe remounts with a new snapshot (not live
postMessageyet). - The sandbox uses
sandbox="allow-scripts"withoutallow-same-origin, so iframe script cannot read the parent dashboard DOM, cookies, orlocalStorage.
Inline mode uses the parent page’s window.$session directly (live reference, full page access).
API reference
| Field | Description |
|---|---|
version | Always 1 |
generation | "next" or "legacy" , branch before assuming payload shape |
dimensionId | Route/query dimension or scenario instance id |
title | Plain-text session title (simulation and scenario names) |
sessionDate, fromTime, sessionDuration | Same values as the session header |
scenarioInstance | { scenarioName, from, to, scenarioInstanceId, id?, … } (ISO date strings when available) |
dimension | Serialized dimension record on Next; usually null on Legacy |
layout | Full layout object for the page |
layoutConfiguration | Top-level layout.configuration when present (playback flags, default realm, and so on) |
selectedRealm | Active realm on Next; null on Legacy |
payload | Opaque runner/session blob (see below) |
query(path) | Dot-path lookup on the data object, e.g. query('layout.realms') |
payload by generation (not normalized)
generation | Typical payload contents | How it was produced |
|---|---|---|
legacy | v1 facade object: Map, combined-custom-charts, ImpactHeatmap, … | getDashboardData / dashboard node output for Legacy |
next | Raw pre-ingest session JSON | Often includes $heat-dataservice (version, groups, realms[] with channels[]) plus any other keys the runner stored |
HEAT does not map Legacy keys into Next shapes inside $session.payload. Inspect defensively:
if ($session.generation === "next" && $session.payload && $session.payload["$heat-dataservice"]) {
var realms = $session.payload["$heat-dataservice"].realms || [];
// prototype-only: list realm names
} else if ($session.generation === "legacy" && $session.payload) {
var keys = Object.keys($session.payload);
}For production analytics UI, prefer layout channels + a built-in widget consuming the ingested data service, not manual payload parsing in CustomRenderer.
Practical authoring workflow
- Confirm a built-in widget cannot do the job (see When to use something else).
- Ship the dashboard node so layout and payload exist; open a session analysis page in the target generation (Next vs Legacy).
- Inspect in the browser console (same generation as your users):
$session.generation,$session.title,$session.query('layoutConfiguration')Object.keys($session.payload || {})- On Next:
$session.query('payload.$heat-dataservice.realms')if present
- Prototype in sandbox with small
html/css/jsincustomRendererItem; keeprenderMode: "sandbox". - Harden or replace with a node template + channel + manifest widget before calling the layout production-ready.
Script examples
Header line from session metadata (sandbox or inline):
document.getElementById("hdr").textContent =
$session.title + " , " + ($session.query("scenarioInstance.scenarioName") || "");List realm names from stored Next payload (sandbox):
var ds = $session.query("payload.$heat-dataservice");
var names = (ds && ds.realms) ? ds.realms.map(function (r) { return r.name; }) : [];
document.getElementById("realms").textContent = names.join(", ") || "(none)";Read layout playback setting:
var enabled = $session.query("layoutConfiguration.enableGlobalPlaybackControls");Limitations
| Topic | Behaviour |
|---|---|
| Sandbox snapshot | Iframe $session updates when layout, realm, or payload snapshot changes (iframe remount), not on every parent render tick |
| Payload size | Large payload in sandbox increases srcDoc size and load cost |
| Playback | No scrubber time on $session; built-in widgets sync via data service clocks |
| Channels | CustomRenderer ignores configuration.channels; no useDataServiceData |
| Theming | Sandbox iframe does not inherit dashboard CSS variables unless you copy rules into css |
| Air-gap | No external <script src="https://…">; inline all assets in layout JSON |
Security matrix
Layout JSON is trusted-author input. There is no HTML sanitizer: isolation mode is the control.
renderMode | Where code runs | Parent dashboard access | Recommendation |
|---|---|---|---|
sandbox (default) | Sandboxed iframe srcDoc | Isolated | Use for any non-trivial custom UI |
inline | Parent document (innerHTML + new Function) | Full (tokens, DOM, storage) | Avoid; internal trusted templates only |
Air-gapped deployments: embed CSS and JS in layout JSON. CDN scripts will not load without outbound network.
Inline mode logs a dev-only console warning. Script errors show an in-widget error state instead of crashing the whole session page.
Implementation notes
- Session analysis pages wrap the layout grid with
SessionPageContextProvider. - Sandbox
srcDocorder: session bootstrap →css→htmlbody → yourjs. - Closing
</script>in layoutjsis escaped insrcDocso the document stays well-formed. - Prefer small layout JSON; large scripts are hard to review and slow to load.
Related
- Next dashboard components index
- Dataservice migration status , layout-only widget
- dashboard-v2 upstream contract , canonical channel shapes for built-in widgets
- Legacy dashboard components , mount ids and v1 payload keys
- Reference JSON:
tools/arbex/reference/next/CustomRenderer.json - Arbex RAG:
tools/arbex/rag/reference/components/CustomRenderer.md - Layout schema:
ui/dashboard/src/lib/data-service/data-lib/heat-client/docs/heat-layout-schema.json