Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket).
of the breadcrumbs' left-edge alignment) — palettes selected in
`app/globals.css` via `:root:has(#rf-theme-…:checked)` +
`prefers-color-scheme`; an inline pre-paint script in `app/layout.jsx`
only restores/persists the choice (CSP-hashed by the build). Guards:
only restores/persists the choice (CSP-hashed by the build). Chart
legends are canvas-drawn, so they can't follow the CSS switch:
`resolveLegendTextColor`/`bindThemeChartRedraw` in `lib/report-view.js`
resolve a per-theme legend color (>= 4.5:1 on each `--bg-color`) and
redraw live charts on radio/`prefers-color-scheme` changes. Guards:
`tests/unit/theme-css.test.js` (the two dark blocks must stay
byte-identical; all hex colors live in the palette blocks; dark palette
passes the same WCAG AA math as `tests/unit/css-a11y.test.js`)
Expand Down
17 changes: 15 additions & 2 deletions components/report-view.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import {
enhanceReport,
bindPopupHandlers,
stashStatefulDom,
graftStatefulDom
graftStatefulDom,
destroyBubbleCharts
} from '../lib/report-view';
import { enhanceTables } from '../lib/table-enhancer';
import { renderErrorPage, logError } from '../lib/error-handler';
Expand Down Expand Up @@ -101,6 +102,12 @@ export default function ReportView({
const controller = new AbortController();
const container = containerRef.current;

// Every fetch cycle replaces the report markup — loading placeholder,
// then the fresh render — so release the previous cycle's Chart.js
// instances before any of that happens; enhanceReport builds new charts
// for the new canvases once the payload arrives.
destroyBubbleCharts();

/** Fetches the trusted template and the selected repository report. */
async function run() {
setPayload(null);
Expand Down Expand Up @@ -141,7 +148,13 @@ export default function ReportView({
}

run();
return () => controller.abort();
return () => {
controller.abort();
// Unmounting (or a dependency change) throws the report's canvases
// away with the markup; release their charts alongside the aborted
// request instead of leaking live instances on dead canvases.
destroyBubbleCharts();
};
}, [username, repository, branch, environmentProp, platformBaseUrlProp, attempt]);

// Render effect: re-renders the report whenever the payload arrives or the
Expand Down
139 changes: 138 additions & 1 deletion lib/report-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,13 +367,137 @@ export function bindSectionNavLinks(root) {
// Chart.js bubble charts per disharmony.
// ---------------------------------------------------------------------------

// Legend text colors mirroring --muted-color of each palette in
// app/globals.css. The chart canvas is transparent, so legend text sits on
// --bg-color (#ffffff light / #10161d dark); each value keeps >= 4.5:1
// contrast against its own background (WCAG 1.4.3), which no single color
// can achieve on both.
const CHART_LEGEND_TEXT = Object.freeze({
light: '#5c6b7a', // 5.5:1 on #ffffff
dark: '#9fb0c0' // 8.2:1 on #10161d
});

/**
* Returns 'light' or 'dark' for the active palette, mirroring the pure-CSS
* selection in app/globals.css: the explicit radios win, the system radio
* defers to prefers-color-scheme, and anything else is light.
*
* @returns {'light'|'dark'} Active palette name.
*/
export function activeChartTheme() {
const doc = typeof document === 'undefined' ? null : document;
if (doc) {
const dark = doc.getElementById('rf-theme-dark');
const light = doc.getElementById('rf-theme-light');
if (dark && dark.checked) return 'dark';
if (light && light.checked) return 'light';
}
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
try {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
} catch {
// Media query evaluation unavailable: fall through to light.
}
}
return 'light';
}

/**
* Legend text color for the currently active theme (>= 4.5:1 against the
* theme's --bg-color). Resolved at draw time so a redraw picks up theme
* switches.
*
* @returns {string} Hex color for legend item text.
*/
export function resolveLegendTextColor() {
return CHART_LEGEND_TEXT[activeChartTheme()] || CHART_LEGEND_TEXT.light;
}

// Live Chart.js instances by canvas, so theme changes can redraw the
// canvas-drawn legend text (the palette switch itself is pure CSS).
const bubbleCharts = new Map();

/**
* Redraws every live bubble chart so legend text re-resolves against the
* newly active theme. Entries whose canvas left the DOM (report re-rendered
* or torn down) are destroyed — releasing their Chart.js resources — and
* pruned instead of redrawn.
*
* @returns {void}
*/
function redrawChartsForTheme() {
for (const [canvas, chart] of bubbleCharts) {
if (!canvas.isConnected) {
if (chart && typeof chart.destroy === 'function') chart.destroy();
bubbleCharts.delete(canvas);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
continue;
}
if (chart && typeof chart.update === 'function') chart.update();
}
}

/**
* Destroys every live bubble chart and clears the registry. The report
* component calls this before a payload change replaces the report markup
* (container.innerHTML), so no Chart.js instance outlives its removed
* canvas; enhanceReport then builds fresh charts for the new canvases.
*
* @returns {void}
*/
export function destroyBubbleCharts() {
for (const chart of bubbleCharts.values()) {
if (chart && typeof chart.destroy === 'function') chart.destroy();
}
bubbleCharts.clear();
}

// The MediaQueryList for prefers-color-scheme, retained so the listener
// (and the redraw callback it holds) stays reachable for the page lifetime.
let systemThemeMediaQuery = null;

/**
* Binds one delegated listener redrawing charts on theme changes: the
* rf-theme radios (components/theme-toggle.jsx) for explicit switches and
* prefers-color-scheme for the system radio. Idempotent per document.
*
* @returns {void}
*/
export function bindThemeChartRedraw() {
const doc = typeof document === 'undefined' ? null : document;
if (!doc || doc.documentElement.hasAttribute('data-rf-theme-redraw-bound')) return;
doc.documentElement.setAttribute('data-rf-theme-redraw-bound', '');
doc.addEventListener('change', event => {
const target = event.target;
if (target && target.name === 'rf-theme') redrawChartsForTheme();
});
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
try {
// Retain the MediaQueryList and subscribe with the best listener API
// it exposes: addEventListener, or the deprecated addListener on
// engines that never grew the modern one.
systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
if (systemThemeMediaQuery && typeof systemThemeMediaQuery.addEventListener === 'function') {
systemThemeMediaQuery.addEventListener('change', redrawChartsForTheme);
} else if (systemThemeMediaQuery && typeof systemThemeMediaQuery.addListener === 'function') {
systemThemeMediaQuery.addListener(redrawChartsForTheme);
}
} catch {
// Older engines without MQL listeners: explicit radios still redraw.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function initBubbleChart(canvas, title, chartData) {
const ChartLib = window.Chart;
if (!ChartLib) {
console.warn('Chart.js not loaded');
return;
}
new ChartLib(canvas.getContext('2d'), {
// Chart.js refuses a canvas that still backs a live instance; drop the
// previous chart (and its registry entry) before re-initializing.
const previous = bubbleCharts.get(canvas);
if (previous && typeof previous.destroy === 'function') previous.destroy();
const chart = new ChartLib(canvas.getContext('2d'), {
type: 'bubble',
data: {
datasets: [{
Expand All @@ -396,11 +520,20 @@ export function initBubbleChart(canvas, title, chartData) {
padding: 15,
boxWidth: 20,
boxHeight: 20,
// Chart.js renders each legend item's text from
// item.fontColor (plugin.legend.js); labels.color only
// feeds the DEFAULT generateLabels, which this chart
// overrides below. The color is resolved per draw so
// the theme-change redraw (bindThemeChartRedraw)
// re-syncs it; each palette's value keeps >= 4.5:1
// against its --bg-color (#ffffff light / #10161d
// dark), which no single color can do for both.
generateLabels: () =>
['High Priority (1)', 'Medium Priority', 'Low Priority (max)'].map((label, i) => ({
text: label,
fillStyle: ['rgb(235, 64, 52)', 'rgb(137, 119, 74)', 'rgb(39, 174, 96)'][i],
strokeStyle: ['rgb(235, 64, 52)', 'rgb(137, 119, 74)', 'rgb(39, 174, 96)'][i],
fontColor: resolveLegendTextColor(),
lineWidth: 1,
hidden: false,
index: i
Expand Down Expand Up @@ -433,6 +566,7 @@ export function initBubbleChart(canvas, title, chartData) {
}
}
});
bubbleCharts.set(canvas, chart);
}

export function initDisharmonyCharts(disharmonies) {
Expand Down Expand Up @@ -587,6 +721,9 @@ export async function enhanceReport(root, data) {
if (event.key === 'Escape') hidePopup();
});
}
// Theme switches are pure CSS, but chart legends are canvas-drawn: bind
// their redraw once so the legend color keeps >= 4.5:1 in every palette.
bindThemeChartRedraw();
bindPopupHandlers(root);

// Menu links: explicit scrolling because the report renders too late for
Expand Down
116 changes: 116 additions & 0 deletions tests/integration/report-view.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,122 @@ describe('enhanced report tables: stateful widgets survive interactions', () =>
expect(document.getElementById('overlay').style.display).toBe('block');
hideAllPopups();
});

test('a payload change destroys the previous charts before the loading markup and re-creates them', async () => {
// Every Chart.js construction/teardown, in order, so the test can pin
// that the old instances are released before the fetch effect clears
// the payload (loading placeholder) and before the replacement render
// builds fresh charts on the new canvases.
const events = [];
const instances = [];
window.Chart = function () {
const chart = {
destroyed: false,
update() {},
destroy() {
chart.destroyed = true;
const canvas = document.querySelector('canvas#chart_GOD');
events.push(canvas && canvas.isConnected ? 'destroy:attached' : 'destroy:detached');
}
};
instances.push(chart);
events.push('create');
return chart;
};
markWidgetReady('chart');
respondJsonFor(sampleJson);
const utils = await renderReport();
const firstCanvas = utils.container.querySelector('canvas#chart_GOD');
const firstRenderCreates = instances.length;
expect(firstRenderCreates).toBeGreaterThan(0);

// Switching branches re-runs the fetch effect: it must release the old
// Chart.js instances (they would otherwise outlive their removed
// canvases) before the loading placeholder replaces the report markup,
// and the fresh render then builds new charts for the new canvases.
utils.rerender(_jsx(ReportView, {
username: 'junit-team',
repository: 'junit4',
branch: 'develop',
widgetSettleMs: 25
}));
// The rerender passes through loading/graft phases that reuse or drop
// the old canvas node; the destroys happen up front at the fetch
// cycle's start, so wait on them and then on the fresh render.
await waitFor(() => {
expect(events.filter(e => e !== 'create').length).toBe(firstRenderCreates);
});
await waitFor(() => {
expect(instances.length).toBe(2 * firstRenderCreates);
});
expect(utils.container.querySelector('canvas#chart_GOD')).not.toBe(firstCanvas);

expect(instances.slice(0, firstRenderCreates).every(c => c.destroyed)).toBe(true);
// Destroyed while the canvases were still attached: before the loading
// markup swap, not after the report markup was thrown away.
const destroys = events.filter(e => e !== 'create');
expect(destroys.every(e => e === 'destroy:attached')).toBe(true);
expect(events).not.toContain('destroy:detached');
// ...and all destroys precede every replacement construction.
expect(events.findIndex(e => e !== 'create')).toBeLessThan(events.lastIndexOf('create'));
// enhanceReport still builds charts for the replacement canvases.
expect(instances.length).toBe(2 * firstRenderCreates);
}, SLOW_TEST_MS);

test('unmounting the report destroys its live charts', async () => {
const instances = [];
window.Chart = function () {
const chart = {
destroyed: false,
update() {},
destroy() { chart.destroyed = true; }
};
instances.push(chart);
return chart;
};
markWidgetReady('chart');
respondJsonFor(sampleJson);
const utils = await renderReport();
expect(instances.length).toBeGreaterThan(0);
expect(instances.every(c => c.destroyed)).toBe(false);

// The fetch effect's cleanup aborts the in-flight request; it must
// release the report's Chart.js instances as well, since the canvases
// go away with the unmounted container.
utils.unmount();
expect(instances.every(c => c.destroyed)).toBe(true);
});

test('a refetch destroys the previous charts up front, even if it never delivers a payload', async () => {
const instances = [];
window.Chart = function () {
const chart = {
destroyed: false,
update() {},
destroy() { chart.destroyed = true; }
};
instances.push(chart);
return chart;
};
markWidgetReady('chart');
respondJsonFor(sampleJson);
const utils = await renderReport();
expect(instances.length).toBeGreaterThan(0);
expect(instances.every(c => c.destroyed)).toBe(false);

// A branch switch whose fetch never resolves: the old Chart.js
// instances must be released when the fetch cycle starts — before the
// payload is cleared and the loading markup replaces the report — not
// wait for a replacement payload that may never arrive.
mockFetch.mockImplementation(() => new Promise(() => {}));
utils.rerender(_jsx(ReportView, {
username: 'junit-team',
repository: 'junit4',
branch: 'develop',
widgetSettleMs: 25
}));
expect(instances.every(c => c.destroyed)).toBe(true);
});
});

async function hideAllPopups() {
Expand Down
Loading
Loading