From 2e1d84b88d352fdc849233f587b23919b9973341 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 15 Jun 2026 08:32:16 -0600 Subject: [PATCH 1/2] fix: Save ref at higher scope than useEffect cleanup function --- src/__tests__/react-plotly.test.js | 38 ++++++++++++++++++++++++++++++ src/factory.js | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/__tests__/react-plotly.test.js b/src/__tests__/react-plotly.test.js index 070aca1..e1bf4d9 100644 --- a/src/__tests__/react-plotly.test.js +++ b/src/__tests__/react-plotly.test.js @@ -204,5 +204,43 @@ describe('', () => { .catch((err) => done(err)); }); }); + + describe('unmount', () => { + // Regression: React detaches callback refs before useEffect cleanups run, + // so reading the ref from cleanup sees `null`. The cleanup effect must + // capture the element at setup time so onPurge/Plotly.purge still fire. + test('fires onPurge and Plotly.purge on unmount', (done) => { + const purgeCalls = []; + let gd; + let resolveInit; + const initialized = new Promise((resolve) => { + resolveInit = resolve; + }); + + const result = render( + { + gd = el; + }} + onPurge={(figure, el) => purgeCalls.push({figure, gd: el})} + onInitialized={once(resolveInit)} + onError={(err) => done(err)} + /> + ); + + initialized + .then(() => { + // Capture before unmount — our ref callback nulls `gd` on detach. + const capturedGd = gd; + act(() => result.unmount()); + expect(Plotly.purge).toHaveBeenCalledWith(capturedGd); + expect(purgeCalls).toHaveLength(1); + expect(purgeCalls[0].gd).toBe(capturedGd); + done(); + }) + .catch(done); + }); + }); }); }); diff --git a/src/factory.js b/src/factory.js index 6f88955..0f881e1 100644 --- a/src/factory.js +++ b/src/factory.js @@ -161,9 +161,9 @@ export default function plotComponentFactory(Plotly) { // Cleanup effect — runs on unmount only. useEffect(() => { + const el = elRef.current; return () => { unmountingRef.current = true; - const el = elRef.current; if (el) { if (typeof onPurgeRef.current === 'function') { const frames = el._transitionData ? el._transitionData._frames : null; From 61fabeaceb6fe3f9fc7c5da27d853bdb030087b8 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 15 Jun 2026 17:42:15 -0600 Subject: [PATCH 2/2] Add fix for StrictMode destroying plot --- src/__tests__/react-plotly.test.js | 39 +++++++++++++++++++++++++++++- src/factory.js | 4 +++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/__tests__/react-plotly.test.js b/src/__tests__/react-plotly.test.js index e1bf4d9..bace500 100644 --- a/src/__tests__/react-plotly.test.js +++ b/src/__tests__/react-plotly.test.js @@ -1,5 +1,5 @@ /** @jest-environment jsdom */ -import React, {useState} from 'react'; +import React, {StrictMode, useState} from 'react'; import {act, render} from '@testing-library/react'; import createComponent from '../factory'; import once from 'onetime'; @@ -205,6 +205,43 @@ describe('', () => { }); }); + describe('StrictMode', () => { + // Regression: in dev StrictMode, React runs effects setup-cleanup-setup + // to surface missing cleanup. Our cleanup calls Plotly.purge, so the + // simulated re-setup must re-initialize. Without resetting prevRef in + // cleanup, the mount/update effect skips re-init and the chart is dead. + test('re-initializes plot after simulated remount', (done) => { + Plotly.react.mockClear(); + Plotly.purge.mockClear(); + + let initCount = 0; + render( + + { + initCount++; + }} + onError={(err) => done(err)} + /> + + ); + + setTimeout(() => { + try { + // Purge ran (StrictMode simulated unmount). React must run again + // afterwards to bring the plot back. + expect(Plotly.purge).toHaveBeenCalledTimes(1); + expect(Plotly.react.mock.calls.length).toBeGreaterThan(Plotly.purge.mock.calls.length); + expect(initCount).toBeGreaterThanOrEqual(1); + done(); + } catch (e) { + done(e); + } + }, 50); + }); + }); + describe('unmount', () => { // Regression: React detaches callback refs before useEffect cleanups run, // so reading the ref from cleanup sees `null`. The cleanup effect must diff --git a/src/factory.js b/src/factory.js index 0f881e1..99c4860 100644 --- a/src/factory.js +++ b/src/factory.js @@ -178,6 +178,10 @@ export default function plotComponentFactory(Plotly) { window.removeEventListener('resize', resizeHandlerRef.current); resizeHandlerRef.current = null; } + // Reset refs so StrictMode's re-setup looks like a fresh mount + prevRef.current = null; + promiseRef.current = Promise.resolve(); + handlersRef.current = {}; }; }, []);