Skip to content

Commit cbfab5b

Browse files
Updated per CodeRabbit review
1 parent 3d3357e commit cbfab5b

2 files changed

Lines changed: 93 additions & 16 deletions

File tree

‎components/report-view.jsx‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ export default function ReportView({
102102
const controller = new AbortController();
103103
const container = containerRef.current;
104104

105+
// Every fetch cycle replaces the report markup — loading placeholder,
106+
// then the fresh render — so release the previous cycle's Chart.js
107+
// instances before any of that happens; enhanceReport builds new charts
108+
// for the new canvases once the payload arrives.
109+
destroyBubbleCharts();
110+
105111
/** Fetches the trusted template and the selected repository report. */
106112
async function run() {
107113
setPayload(null);
@@ -142,7 +148,13 @@ export default function ReportView({
142148
}
143149

144150
run();
145-
return () => controller.abort();
151+
return () => {
152+
controller.abort();
153+
// Unmounting (or a dependency change) throws the report's canvases
154+
// away with the markup; release their charts alongside the aborted
155+
// request instead of leaking live instances on dead canvases.
156+
destroyBubbleCharts();
157+
};
146158
}, [username, repository, branch, environmentProp, platformBaseUrlProp, attempt]);
147159

148160
// Render effect: re-renders the report whenever the payload arrives or the
@@ -177,10 +189,6 @@ export default function ReportView({
177189
if (cancelled) return;
178190

179191
const payloadChanged = lastEnhancedPayloadRef.current !== payload;
180-
// The replacement render throws the old canvases away with the
181-
// markup: destroy their Chart.js instances first so none outlives
182-
// its canvas (enhanceReport builds fresh charts for the new DOM).
183-
if (payloadChanged) destroyBubbleCharts();
184192
// Stash live chart canvases and rendered graphs before the innerHTML
185193
// swap throws the old DOM away (no-op on first render — there is
186194
// nothing stateful in the loading placeholder).

‎tests/integration/report-view.test.jsx‎

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -694,17 +694,22 @@ describe('enhanced report tables: stateful widgets survive interactions', () =>
694694
hideAllPopups();
695695
});
696696

697-
test('a payload change destroys the previous charts before the markup swap and re-creates them', async () => {
697+
test('a payload change destroys the previous charts before the loading markup and re-creates them', async () => {
698698
// Every Chart.js construction/teardown, in order, so the test can pin
699-
// that the old instances are released before the replacement render
699+
// that the old instances are released before the fetch effect clears
700+
// the payload (loading placeholder) and before the replacement render
700701
// builds fresh charts on the new canvases.
701702
const events = [];
702703
const instances = [];
703704
window.Chart = function () {
704705
const chart = {
705706
destroyed: false,
706707
update() {},
707-
destroy() { chart.destroyed = true; events.push('destroy'); }
708+
destroy() {
709+
chart.destroyed = true;
710+
const canvas = document.querySelector('canvas#chart_GOD');
711+
events.push(canvas && canvas.isConnected ? 'destroy:attached' : 'destroy:detached');
712+
}
708713
};
709714
instances.push(chart);
710715
events.push('create');
@@ -717,29 +722,93 @@ describe('enhanced report tables: stateful widgets survive interactions', () =>
717722
const firstRenderCreates = instances.length;
718723
expect(firstRenderCreates).toBeGreaterThan(0);
719724

720-
// Switching branches fetches a new payload: the replacement render must
721-
// tear the old Chart.js instances down (they would otherwise outlive
722-
// their removed canvases) and then re-create charts for the fresh DOM.
725+
// Switching branches re-runs the fetch effect: it must release the old
726+
// Chart.js instances (they would otherwise outlive their removed
727+
// canvases) before the loading placeholder replaces the report markup,
728+
// and the fresh render then builds new charts for the new canvases.
723729
utils.rerender(_jsx(ReportView, {
724730
username: 'junit-team',
725731
repository: 'junit4',
726732
branch: 'develop',
727733
widgetSettleMs: 25
728734
}));
729735
// The rerender passes through loading/graft phases that reuse or drop
730-
// the old canvas node, so wait on the destruction itself settling.
736+
// the old canvas node; the destroys happen up front at the fetch
737+
// cycle's start, so wait on them and then on the fresh render.
738+
await waitFor(() => {
739+
expect(events.filter(e => e !== 'create').length).toBe(firstRenderCreates);
740+
});
731741
await waitFor(() => {
732-
expect(events.filter(e => e === 'destroy').length).toBe(firstRenderCreates);
742+
expect(instances.length).toBe(2 * firstRenderCreates);
733743
});
734744
expect(utils.container.querySelector('canvas#chart_GOD')).not.toBe(firstCanvas);
735745

736746
expect(instances.slice(0, firstRenderCreates).every(c => c.destroyed)).toBe(true);
737-
// All destroys precede every replacement construction.
738-
const destroys = events.slice(firstRenderCreates, instances.length);
739-
expect(destroys.every(e => e === 'destroy')).toBe(true);
747+
// Destroyed while the canvases were still attached: before the loading
748+
// markup swap, not after the report markup was thrown away.
749+
const destroys = events.filter(e => e !== 'create');
750+
expect(destroys.every(e => e === 'destroy:attached')).toBe(true);
751+
expect(events).not.toContain('destroy:detached');
752+
// ...and all destroys precede every replacement construction.
753+
expect(events.findIndex(e => e !== 'create')).toBeLessThan(events.lastIndexOf('create'));
740754
// enhanceReport still builds charts for the replacement canvases.
741755
expect(instances.length).toBe(2 * firstRenderCreates);
742756
}, SLOW_TEST_MS);
757+
758+
test('unmounting the report destroys its live charts', async () => {
759+
const instances = [];
760+
window.Chart = function () {
761+
const chart = {
762+
destroyed: false,
763+
update() {},
764+
destroy() { chart.destroyed = true; }
765+
};
766+
instances.push(chart);
767+
return chart;
768+
};
769+
markWidgetReady('chart');
770+
respondJsonFor(sampleJson);
771+
const utils = await renderReport();
772+
expect(instances.length).toBeGreaterThan(0);
773+
expect(instances.every(c => c.destroyed)).toBe(false);
774+
775+
// The fetch effect's cleanup aborts the in-flight request; it must
776+
// release the report's Chart.js instances as well, since the canvases
777+
// go away with the unmounted container.
778+
utils.unmount();
779+
expect(instances.every(c => c.destroyed)).toBe(true);
780+
});
781+
782+
test('a refetch destroys the previous charts up front, even if it never delivers a payload', async () => {
783+
const instances = [];
784+
window.Chart = function () {
785+
const chart = {
786+
destroyed: false,
787+
update() {},
788+
destroy() { chart.destroyed = true; }
789+
};
790+
instances.push(chart);
791+
return chart;
792+
};
793+
markWidgetReady('chart');
794+
respondJsonFor(sampleJson);
795+
const utils = await renderReport();
796+
expect(instances.length).toBeGreaterThan(0);
797+
expect(instances.every(c => c.destroyed)).toBe(false);
798+
799+
// A branch switch whose fetch never resolves: the old Chart.js
800+
// instances must be released when the fetch cycle starts — before the
801+
// payload is cleared and the loading markup replaces the report — not
802+
// wait for a replacement payload that may never arrive.
803+
mockFetch.mockImplementation(() => new Promise(() => {}));
804+
utils.rerender(_jsx(ReportView, {
805+
username: 'junit-team',
806+
repository: 'junit4',
807+
branch: 'develop',
808+
widgetSettleMs: 25
809+
}));
810+
expect(instances.every(c => c.destroyed)).toBe(true);
811+
});
743812
});
744813

745814
async function hideAllPopups() {

0 commit comments

Comments
 (0)