Add centralized error boundary pattern for dashboard components#705
Add centralized error boundary pattern for dashboard components#705
Conversation
Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
There was a problem hiding this comment.
Pull request overview
Adds a shared browser-side “error boundary” + fallback UI primitives intended to isolate dashboard component failures so one broken panel doesn’t take down the full dashboard experience.
Changes:
- Introduces
renderLoadingFallback()andrenderErrorFallback()for consistent loading/error UI states. - Introduces
renderWithFallback()wrapper to orchestrate skeleton → render → error fallback + retry. - Adds a dedicated Vitest suite covering sync/async success/failure and retry behavior; re-exports new modules via the shared barrel.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| tests/error-boundary.test.ts | New unit tests for fallback UI DOM/ARIA and error-boundary success/failure/retry paths. |
| src/browser/shared/index.ts | Re-exports fallback UI + error boundary modules from the shared barrel. |
| src/browser/shared/fallback-ui.ts | Adds DOM renderers for loading skeleton and error card with optional retry button. |
| src/browser/shared/error-boundary.ts | Adds async wrapper that logs render failures and renders retryable error fallback. |
src/browser/shared/fallback-ui.ts
Outdated
| */ | ||
| export function renderLoadingFallback(container: HTMLElement): void { | ||
| container.innerHTML = ''; | ||
|
|
||
| const wrapper = document.createElement('div'); | ||
| wrapper.className = 'fallback-loading-skeleton'; | ||
| wrapper.setAttribute('role', 'status'); | ||
| wrapper.setAttribute('aria-live', 'polite'); | ||
| wrapper.setAttribute('aria-label', 'Loading…'); |
There was a problem hiding this comment.
aria-label is hard-coded to “Loading…”. For consistency with localized dashboards and accessibility text, consider allowing callers to override the loading label (or reusing an existing i18n source) so screen readers don’t announce English on non-English pages.
| */ | |
| export function renderLoadingFallback(container: HTMLElement): void { | |
| container.innerHTML = ''; | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'fallback-loading-skeleton'; | |
| wrapper.setAttribute('role', 'status'); | |
| wrapper.setAttribute('aria-live', 'polite'); | |
| wrapper.setAttribute('aria-label', 'Loading…'); | |
| * The optional `loadingLabel` allows localized ARIA text for screen readers. | |
| */ | |
| export function renderLoadingFallback( | |
| container: HTMLElement, | |
| loadingLabel: string = 'Loading…', | |
| ): void { | |
| container.innerHTML = ''; | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'fallback-loading-skeleton'; | |
| wrapper.setAttribute('role', 'status'); | |
| wrapper.setAttribute('aria-live', 'polite'); | |
| wrapper.setAttribute('aria-label', loadingLabel); |
There was a problem hiding this comment.
Fixed in ff8cb7b. renderLoadingFallback now accepts an optional loadingLabel parameter (default 'Loading…') that is forwarded directly to the aria-label attribute, so callers on non-English dashboards can pass a localised string.
src/browser/shared/error-boundary.ts
Outdated
|
|
||
| const attempt = async (): Promise<void> => { | ||
| renderLoadingFallback(container); | ||
| try { | ||
| await Promise.resolve(renderFn()); | ||
| } catch (err) { | ||
| logger.error('[ErrorBoundary] Render failed:', err); | ||
| renderErrorFallback(container, fallbackMessage, attempt); |
There was a problem hiding this comment.
Retry clicks can start multiple concurrent attempt() executions (each click schedules a new async attempt). This can lead to race conditions where slower retries overwrite newer successful renders. Consider guarding with an inFlight flag / disabling the retry button during an active attempt, and re-enabling it only after completion/failure.
| const attempt = async (): Promise<void> => { | |
| renderLoadingFallback(container); | |
| try { | |
| await Promise.resolve(renderFn()); | |
| } catch (err) { | |
| logger.error('[ErrorBoundary] Render failed:', err); | |
| renderErrorFallback(container, fallbackMessage, attempt); | |
| let inFlight = false; | |
| const attempt = async (): Promise<void> => { | |
| if (inFlight) { | |
| // Prevent overlapping attempts that could cause race conditions. | |
| return; | |
| } | |
| inFlight = true; | |
| renderLoadingFallback(container); | |
| try { | |
| await Promise.resolve(renderFn()); | |
| } catch (err) { | |
| logger.error('[ErrorBoundary] Render failed:', err); | |
| renderErrorFallback(container, fallbackMessage, attempt); | |
| } finally { | |
| inFlight = false; |
There was a problem hiding this comment.
Fixed in ff8cb7b. renderWithFallback now declares let inFlight = false and wraps the body of attempt() with a guard that returns early if inFlight is true, with inFlight = false in the finally block. A new test case ("ignores concurrent retry clicks while an attempt is already in flight") verifies the behaviour.
| export * from './fallback-ui.js'; | ||
| export * from './error-boundary.js'; |
There was a problem hiding this comment.
PR description says dashboard modules were updated to use the new error boundary, but there are no renderWithFallback call sites under src/browser/dashboards/ (verified by search). Without wiring this into the dashboards, the page-level resilience improvement described in the PR won’t actually be realized.
There was a problem hiding this comment.
Fixed in ff8cb7b. renderErrorFallback is now imported and used in the catch blocks of both party-dashboard.ts and risk-dashboard.ts, replacing the bespoke inline div/innerHTML error patterns. Both pass () => void init() as the retry callback so users can recover without a full page reload.
src/browser/shared/fallback-ui.ts
Outdated
| * Provides consistent error and loading state presentation with cyberpunk theme styling, | ||
| * retry capability, and WCAG 2.1 AA accessibility compliance. |
There was a problem hiding this comment.
The module docstring claims “cyberpunk theme styling” and WCAG 2.1 AA compliance, but there are no CSS rules in the repo for the new .fallback-* / .skeleton-bar classes (only defined here). Either add the corresponding CSS (and any required focus/contrast styles), or tone down the docstring to avoid overstating what the module provides.
| * Provides consistent error and loading state presentation with cyberpunk theme styling, | |
| * retry capability, and WCAG 2.1 AA accessibility compliance. | |
| * Provides consistent error and loading state presentation with optional retry capability. |
There was a problem hiding this comment.
Fixed in ff8cb7b. Removed the unsupported "cyberpunk theme styling" and "WCAG 2.1 AA compliance" claims from the module docstring.
| btn.className = 'fallback-retry-btn'; | ||
| btn.type = 'button'; | ||
| btn.textContent = 'Retry'; | ||
| btn.addEventListener('click', retryFn); |
There was a problem hiding this comment.
The retry button label is hard-coded to “Retry”. Since dashboards are localized (14 languages) and other parts of the UI use i18n, consider making the button label configurable (e.g., additional parameter) or have callers pass the localized string so the shared fallback UI doesn’t force English text.
There was a problem hiding this comment.
Fixed in ff8cb7b. renderErrorFallback now accepts an optional fourth parameter retryLabel (default 'Retry') that is used as the button's text content, allowing callers to pass a localised string (e.g. 'Försök igen').
|
@copilot apply changes based on the comments in this thread |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
All five review comments addressed in ff8cb7b — localisable |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
| const card = document.createElement('div'); | ||
| card.className = 'fallback-error-card'; | ||
| card.setAttribute('role', 'alert'); | ||
| card.setAttribute('aria-live', 'assertive'); | ||
|
|
||
| const icon = document.createElement('span'); | ||
| icon.className = 'fallback-icon'; | ||
| icon.setAttribute('aria-hidden', 'true'); | ||
| icon.textContent = '⚠'; | ||
|
|
||
| const text = document.createElement('p'); | ||
| text.className = 'fallback-message'; | ||
| text.textContent = message; | ||
|
|
||
| card.appendChild(icon); | ||
| card.appendChild(text); | ||
|
|
||
| if (retryFn) { | ||
| const btn = document.createElement('button'); | ||
| btn.className = 'fallback-retry-btn'; | ||
| btn.type = 'button'; | ||
| btn.textContent = retryLabel; | ||
| btn.addEventListener('click', retryFn); | ||
| card.appendChild(btn); | ||
| } | ||
|
|
||
| container.appendChild(card); | ||
| } | ||
|
|
||
| /** | ||
| * Replace the contents of `container` with a CSS-only skeleton loading animation. | ||
| * The optional `loadingLabel` allows localized ARIA text for screen readers. | ||
| */ | ||
| export function renderLoadingFallback(container: HTMLElement, loadingLabel = 'Loading…'): void { | ||
| container.innerHTML = ''; | ||
|
|
||
| const wrapper = document.createElement('div'); | ||
| wrapper.className = 'fallback-loading-skeleton'; | ||
| wrapper.setAttribute('role', 'status'); | ||
| wrapper.setAttribute('aria-live', 'polite'); | ||
| wrapper.setAttribute('aria-label', loadingLabel); | ||
|
|
||
| for (let i = 0; i < 3; i++) { | ||
| const bar = document.createElement('div'); | ||
| bar.className = 'skeleton-bar'; | ||
| wrapper.appendChild(bar); | ||
| } | ||
|
|
||
| container.appendChild(wrapper); | ||
| } |
There was a problem hiding this comment.
This module introduces several new CSS hook class names (fallback-error-card, fallback-loading-skeleton, skeleton-bar, fallback-retry-btn, etc.), but there are no corresponding style rules anywhere in the repo’s CSS. Without styles, the "skeleton loader" won’t animate/appear as intended and the error card/button may look unstyled. Add the required CSS (including visible focus styles + reduced-motion handling for animations) in the main stylesheet used by dashboards.
There was a problem hiding this comment.
Fixed in 1f7fcd4. Added CSS for all .fallback-* and .skeleton-bar classes in styles.css, including :focus-visible outline using --focus-ring, @media (prefers-reduced-motion: reduce) to disable the shimmer animation, and dark-mode contrast adjustments. The skeleton animation reuses the existing --skeleton-base / --skeleton-shine design tokens.
| inFlight = true; | ||
| renderLoadingFallback(container); | ||
|
|
||
| try { | ||
| await Promise.resolve(renderFn()); | ||
| } catch (err) { | ||
| logger.error('[ErrorBoundary] Render failed:', err); | ||
| renderErrorFallback(container, fallbackMessage, attempt); | ||
| } finally { |
There was a problem hiding this comment.
renderWithFallback() always calls renderLoadingFallback(container), which clears the container’s existing DOM. Most dashboards rely on pre-existing markup (e.g. <canvas id="partyEffectivenessChart"> in index*.html) and render functions query by ID rather than recreating elements; if callers pass a container that wraps those elements, the skeleton will delete them and retries/successful renders may silently do nothing. Consider changing the contract so loading/error states overlay without destroying required children, or snapshot/restore the container’s original markup on retry.
There was a problem hiding this comment.
Fixed in 1f7fcd4. renderWithFallback now snapshots container.innerHTML at call time and restores it (container.innerHTML = originalHTML) immediately before invoking renderFn(), so any pre-existing DOM elements (e.g. <canvas> targets) are always present when the render function runs — on the initial attempt and on every retry. Two new tests verify this behavior.
| } catch (error) { | ||
| logger.error('Error initializing party dashboard:', error); | ||
|
|
||
| // Show error message | ||
| const errorDiv = document.createElement('div'); | ||
| errorDiv.className = 'dashboard-error'; | ||
| errorDiv.style.cssText = | ||
| 'padding: 2rem; text-align: center; color: var(--danger-color);'; | ||
| errorDiv.textContent = t.errorMessage; | ||
| dashboardSection.appendChild(errorDiv); | ||
| renderErrorFallback(dashboardSection, t.errorMessage, () => { void init(); }); | ||
| } |
There was a problem hiding this comment.
renderErrorFallback(dashboardSection, ...) clears and replaces the entire #party-dashboard section. On retry, init() expects existing chart DOM (e.g., #partyEffectivenessChart, #partyComparisonChart, etc.), but those canvases were removed by the fallback, so the dashboard cannot recover without a page reload. Render the error UI into a dedicated placeholder inside the section (or preserve/restore the original section markup before retry) so a retry can actually re-render charts.
There was a problem hiding this comment.
Fixed in 1f7fcd4. The catch block now creates a div (errContainer), appends it to dashboardSection, and calls renderErrorFallback(errContainer, …). This leaves the canvas elements untouched. The retry callback removes errContainer before re-calling init(), so all expected chart targets are present for the retry render.
| // Display error message to user using the shared error boundary fallback | ||
| const alertContainer = document.getElementById('earlyWarningAlerts'); | ||
| if (alertContainer) { | ||
| alertContainer.innerHTML = ` | ||
| <div class="alert alert-danger" role="alert"> | ||
| <h4>⚠️ Data Loading Error</h4> | ||
| <p>Unable to load risk assessment data from CIA Platform.</p> | ||
| <p><strong>Error:</strong> ${(error as Error).message}</p> | ||
| <p>Please check your internet connection and try refreshing the page.</p> | ||
| <p><small>Data source: view_politician_risk_summary_sample.csv (403 politicians)</small></p> | ||
| </div> | ||
| `; | ||
| renderErrorFallback( | ||
| alertContainer, | ||
| 'Unable to load risk assessment data from CIA Platform.', | ||
| () => { void init(); }, | ||
| ); |
There was a problem hiding this comment.
The fallback is rendered into #earlyWarningAlerts, but that element does not exist in the checked-in index*.html pages (only #earlyWarnings is present), so users will still see no error UI when data loading fails. Consider rendering into an element that actually exists (e.g. #earlyWarnings or #risk-dashboard) and ensure the error UI is cleared on a subsequent successful retry.
There was a problem hiding this comment.
Fixed in 1f7fcd4. Corrected from the non-existent #earlyWarningAlerts to #risk-dashboard (which exists in all index*.html files). Uses the same child-placeholder pattern: creates a div (errContainer), prepends it to the section, and the retry removes it before re-calling init().
|
@copilot apply changes based on the comments in this thread |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
… 14 languages Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Both comments from review 3872014213 addressed in 82f162d — loading overlay is now a child element that stays visible throughout the async render, and |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
src/browser/shared/error-boundary.ts
Outdated
| export async function renderWithFallback( | ||
| container: HTMLElement, | ||
| renderFn: () => void | Promise<void>, | ||
| fallbackMessage = 'Data temporarily unavailable', | ||
| ): Promise<void> { | ||
| // Snapshot original markup so each attempt can restore pre-existing DOM | ||
| // elements (e.g. <canvas> elements) that renderFn depends on. | ||
| const originalHTML = container.innerHTML; | ||
| let inFlight = false; | ||
|
|
||
| const attempt = async (): Promise<void> => { | ||
| if (inFlight) { | ||
| // Prevent overlapping attempts that could cause race conditions. | ||
| return; | ||
| } | ||
|
|
||
| inFlight = true; | ||
|
|
||
| // Restore the original markup before handing control to renderFn so | ||
| // that any expected child elements (e.g. <canvas> targets) are present. | ||
| container.innerHTML = originalHTML; | ||
|
|
||
| // Append a dedicated loading overlay so the skeleton stays visible while | ||
| // the async render is in progress without destroying required children. | ||
| const loadingOverlay = document.createElement('div'); | ||
| loadingOverlay.setAttribute('data-error-boundary-loading', 'true'); | ||
| loadingOverlay.setAttribute('aria-busy', 'true'); | ||
| renderLoadingFallback(loadingOverlay); | ||
| container.appendChild(loadingOverlay); | ||
|
|
||
| try { | ||
| await Promise.resolve(renderFn()); | ||
| } catch (err) { | ||
| logger.error('[ErrorBoundary] Render failed:', err); | ||
| renderErrorFallback(container, fallbackMessage, attempt); |
There was a problem hiding this comment.
renderWithFallback() always uses the default English loading label (via renderLoadingFallback(loadingOverlay)) and default English retry label (since it calls renderErrorFallback(container, fallbackMessage, attempt) without a retryLabel). In a 14-language UI this makes it hard to use renderWithFallback without mixed-language accessibility text. Consider extending the API (e.g., an options param) to allow callers to pass localized loadingLabel and retryLabel (and forward them to the underlying fallback renderers).
There was a problem hiding this comment.
Fixed in d1f4a9d. Added a RenderWithFallbackOptions interface with optional loadingLabel and retryLabel fields. renderWithFallback now accepts this as an optional 4th parameter (defaults to {} for backwards compatibility) and forwards both fields to the underlying renderLoadingFallback and renderErrorFallback calls. Two new tests verify the forwarding.
| 'Unable to load risk assessment data from CIA Platform.', | ||
| () => { | ||
| errContainer.remove(); | ||
| void init(); |
There was a problem hiding this comment.
The retry callback calls void init(); without handling rejections. risk-dashboard’s init() only wraps the data-load in a try/catch; any error thrown later during chart rendering will reject the promise, and triggering it from a click handler this way can surface as an unhandled promise rejection. Consider calling init().catch((e) => logger.error('Retry failed:', e)) (or wrapping the whole init() body in a top-level try/catch) so retries can fail safely.
| void init(); | |
| init().catch((err) => | |
| logger.error('Retry failed during risk dashboard re-initialization:', err), | |
| ); |
There was a problem hiding this comment.
Fixed in d1f4a9d. Changed void init() to init().catch((err) => logger.error('Retry failed during risk dashboard re-initialization:', err)) in risk-dashboard.ts. Applied the same fix to the equivalent callback in party-dashboard.ts.
|
@copilot apply changes based on the comments in this thread |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
…th .catch() Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Both comments from review 3872072650 addressed in d1f4a9d — |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
src/browser/shared/error-boundary.ts
Outdated
| // Restore the original markup before handing control to renderFn so | ||
| // that any expected child elements (e.g. <canvas> targets) are present. | ||
| container.innerHTML = originalHTML; | ||
|
|
||
| // Append a dedicated loading overlay so the skeleton stays visible while |
There was a problem hiding this comment.
container.innerHTML = originalHTML recreates all child nodes and drops any event listeners/state (it restores markup, not the original DOM elements). This can break callers that capture references to child elements before calling renderWithFallback, and it makes the comment about preserving/restoring pre-existing elements misleading. Consider (a) only restoring on retry (not the first attempt) and/or (b) snapshotting and re-attaching the original childNodes instead of using innerHTML, or at minimum update the comments/docstring to reflect that nodes are recreated.
There was a problem hiding this comment.
Fixed in 813531d. Added an isFirstAttempt flag: the first call to attempt() now skips container.innerHTML = originalHTML entirely, so any child element references held by the caller remain valid. The innerHTML restore only runs on retry (where canvas elements must be back in the DOM after the error card was shown). The docstring now notes that post-retry child references will point to recreated nodes.
tests/error-boundary.test.ts
Outdated
| // Allow the async re-render to complete | ||
| await new Promise((r) => setTimeout(r, 0)); |
There was a problem hiding this comment.
These tests use await new Promise((r) => setTimeout(r, 0)) to wait for the async retry attempt after btn.click(). This can introduce timing flakiness in CI. Prefer flushing microtasks deterministically (or using Vitest fake timers / advanceTimersByTimeAsync) so the test waits specifically for the retry attempt to settle.
| // Allow the async re-render to complete | |
| await new Promise((r) => setTimeout(r, 0)); | |
| // Allow the async re-render (promise microtasks) to complete deterministically | |
| await Promise.resolve(); |
There was a problem hiding this comment.
Fixed in 813531d. Replaced all three await new Promise((r) => setTimeout(r, 0)) calls in the test suite with await Promise.resolve() for deterministic microtask flushing.
|
@copilot apply changes based on the comments in this thread |
…n tests Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
| // the async render is in progress without destroying required children. | ||
| const loadingOverlay = document.createElement('div'); | ||
| loadingOverlay.setAttribute('data-error-boundary-loading', 'true'); | ||
| loadingOverlay.setAttribute('aria-busy', 'true'); |
There was a problem hiding this comment.
renderWithFallback appends a div[data-error-boundary-loading] and refers to it as a “loading overlay”, but there are no CSS rules for that attribute/class, and the element also has no class name. As a result it will behave like a normal block element (likely pushing content down) rather than overlaying the existing chart/canvas area, which can make the loading state hard to notice. Consider adding a dedicated class (or CSS for [data-error-boundary-loading]) that positions the overlay over the container (and ensuring the container establishes a positioning context), so the skeleton is consistently visible without changing layout.
| loadingOverlay.setAttribute('aria-busy', 'true'); | |
| loadingOverlay.setAttribute('aria-busy', 'true'); | |
| loadingOverlay.className = 'error-boundary-loading-overlay'; | |
| // Ensure the container provides a positioning context for the overlay. | |
| const currentPosition = getComputedStyle(container).position; | |
| if (currentPosition === '' || currentPosition === 'static') { | |
| container.style.position = 'relative'; | |
| } | |
| // Style the overlay to cover the container without affecting layout. | |
| loadingOverlay.style.position = 'absolute'; | |
| loadingOverlay.style.top = '0'; | |
| loadingOverlay.style.right = '0'; | |
| loadingOverlay.style.bottom = '0'; | |
| loadingOverlay.style.left = '0'; | |
| loadingOverlay.style.display = 'flex'; | |
| loadingOverlay.style.alignItems = 'center'; | |
| loadingOverlay.style.justifyContent = 'center'; | |
| loadingOverlay.style.zIndex = '1'; | |
| loadingOverlay.style.pointerEvents = 'none'; |
| animation: skeleton-shimmer 1.5s ease-in-out infinite; | ||
| } | ||
|
|
||
| .skeleton-bar:nth-child(1) { width: 100%; } | ||
| .skeleton-bar:nth-child(2) { width: 85%; } | ||
| .skeleton-bar:nth-child(3) { width: 70%; } | ||
|
|
||
| @keyframes skeleton-shimmer { | ||
| 0% { background-position: 200% 0; } | ||
| 100% { background-position: -200% 0; } | ||
| } | ||
|
|
There was a problem hiding this comment.
This adds a second skeleton shimmer implementation (.skeleton-bar + @keyframes skeleton-shimmer) even though the stylesheet already defines a generic skeleton system (.skeleton + @keyframes shimmer) earlier in the file. Duplicating the gradient/animation makes future visual tweaks easy to miss in one place and can lead to inconsistent skeleton behavior. Consider reusing the existing .skeleton styles for these bars (e.g., apply the existing skeleton class to the generated bar elements and drop the duplicate keyframes/gradient here).
| animation: skeleton-shimmer 1.5s ease-in-out infinite; | |
| } | |
| .skeleton-bar:nth-child(1) { width: 100%; } | |
| .skeleton-bar:nth-child(2) { width: 85%; } | |
| .skeleton-bar:nth-child(3) { width: 70%; } | |
| @keyframes skeleton-shimmer { | |
| 0% { background-position: 200% 0; } | |
| 100% { background-position: -200% 0; } | |
| } | |
| animation: shimmer 1.5s ease-in-out infinite; | |
| } | |
| .skeleton-bar:nth-child(1) { width: 100%; } | |
| .skeleton-bar:nth-child(2) { width: 85%; } | |
| .skeleton-bar:nth-child(3) { width: 70%; } |
src/browser/shared/fallback-ui.tswithrenderErrorFallback()andrenderLoadingFallback()src/browser/shared/error-boundary.tswithrenderWithFallback()patternsrc/browser/shared/index.tstests/error-boundary.test.tscovering all pathsloadingLabelparam torenderLoadingFallbackfor localized ARIA textretryLabelparam torenderErrorFallbackfor localized button textinFlightguard torenderWithFallbackto prevent concurrent retry race conditionsrenderErrorFallbackintoparty-dashboard.tsandrisk-dashboard.tscatch blocks.fallback-error-card,.fallback-loading-skeleton,.skeleton-bar,.fallback-retry-btn(focus styles, reduced-motion, dark-mode)renderWithFallbackloading state is an appended child overlay so pre-existing DOM (canvas elements) stays intact throughout the render; overlay removed infinallyon both success and failureparty-dashboard.tsandrisk-dashboard.ts: error card rendered into childerrContainer; retry removes it before re-callinginit()risk-dashboard.ts: corrected from non-existent#earlyWarningAlertsto#risk-dashboardparty-dashboard.ts:retryMessageadded toLocaleTranslationsinterface and all 14 locale objectsRenderWithFallbackOptionsinterface (loadingLabel?,retryLabel?) forwarded to underlying fallback renderersvoid init()replaced withinit().catch(err => logger.error(...))in retry callbacks of both dashboardsisFirstAttemptflag: skipcontainer.innerHTML = originalHTMLon the first call so caller-held child element references remain valid; only restore on retry (where canvas elements must be re-inserted after error card)await new Promise((r) => setTimeout(r, 0))withawait Promise.resolve()in all three test cases for deterministic microtask flushingOriginal prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.