docs/solutions/ui-bugs/panel-scheduled-but-never-primed-shows-loading-radar-for-a-full-interval.md
Adding a panel to src/App.ts's refreshScheduler.scheduleRefresh(...) looks like it wires up data loading. It does not. scheduleRefresh schedules the next tick, not an immediate one, so a panel registered only there sits at its loading radar until a full interval elapses — six hours for the slow economic panels.
enabled: false) are only ever seen after a user enables them mid-session, which is exactly the path with no initial fetch.scheduleRefresh('fx', () => panel.fetchData(), REFRESH_INTERVALS.fx, () => this.isPanelNearViewport('fx')) reads like a complete wiring. The gap is invisible at that call site — it is an absence somewhere else in the file.panel.fetchData() directly inside their mount helper. That is a call site production never reaches on first mount, so no amount of panel-level testing can see this.PanelLayoutManager.applyPanelSettings() → async mount → afterPanelMounted() hands off to primeVisiblePanelData() — which is the very table missing the entry. enablePanelById's own direct fetchData() call reads ctx.panels[id] synchronously, before the lazy import resolves, so it is a no-op on first enable too.A panel with its own fetchData() needs two registrations in src/App.ts, not one:
// 1. Periodic refresh — schedules the NEXT tick, not an immediate one.
this.refreshScheduler.scheduleRefresh(
'fx',
() => (this.state.panels['fx'] as FxPanel).fetchData(),
REFRESH_INTERVALS.fx,
() => this.isPanelNearViewport('fx'),
);
// 2. Initial-load kick, inside primeVisiblePanelData() — THIS is what makes
// the panel populate on first mount.
if (shouldPrime('fx')) {
const panel = this.state.panels['fx'] as FxPanel | undefined;
if (panel) primeTask('fx', () => panel.fetchData());
}
{ runImmediately: true } on scheduleRefresh also fires an initial fetch, but it bypasses the visiblePanelPrimed / inFlight dedupe the rest of the prime table relies on. Prefer the primeTask block — it is what all ~27 sibling panels do.
Three mechanisms have to line up, and only the prime table closes the gap:
Panel's constructor calls showLoading() — so a freshly mounted panel is always in the loading state until something calls its fetch.scheduleRefresh passes runImmediately: options.runImmediately ?? false into the poll loop, which therefore takes its scheduleNext() branch. The first fire is one full interval away.primeVisiblePanelData() is the sole near-viewport kickoff path, and it is keyed by an explicit per-panel entry.The repo already documents this in src/App.ts, in a comment written after the same bug hit the Energy Atlas panels:
primeTask wires the panels sit at showLoading() forever because Panel's constructor calls showLoading() but nothing else triggers fetchData() on attach — App.ts's primeTask table is the sole near-viewport kickoff path.
The comment existed and the trap still recurred, which is the argument for a mechanical guard rather than prose.
The invariant is checkable and held for all 27 timer-refreshed panels, so it is now a build failure. Added to tests/panel-config-guardrails.test.mjs:
it('every panel refreshed on a timer is also primed on first mount', () => {
const scheduled = [...appSrc.matchAll(
/scheduleRefresh\(\s*'([a-z0-9-]+)',\s*\(\)\s*=>\s*\(this\.state\.panels\['[a-z0-9-]+'\][^)]*\)\.fetchData\(\)/g,
)].map((m) => m[1]);
const primed = new Set([...appSrc.matchAll(/primeTask\('([a-z0-9-]+)'/g)].map((m) => m[1]));
// Sanity floors: a source-regex guard whose pattern silently stops matching
// passes vacuously, which is the failure mode it exists to prevent.
assert.ok(scheduled.length >= 20, `matched only ${scheduled.length} scheduled panels — the scheduleRefresh pattern broke`);
assert.ok(primed.size >= 20, `matched only ${primed.size} primeTask entries — the primeTask pattern broke`);
assert.deepStrictEqual(scheduled.filter((id) => !primed.has(id)).sort(), [], '...');
});
Two things make this guard trustworthy rather than decorative:
shouldPrime('fx') block turns the test red with the offending panel named. A guard that has never been observed failing is not yet a guard.Review notes for this class of bug:
bigmac, fuel-prices, consumer-prices) and list every place the sibling appears but the new panel does not. Three independent reviewers found this gap that way; none found it by reading the new code alone.