diff --git a/src/__tests__/react-plotly.test.js b/src/__tests__/react-plotly.test.js index 153b920..070aca1 100644 --- a/src/__tests__/react-plotly.test.js +++ b/src/__tests__/react-plotly.test.js @@ -7,27 +7,27 @@ import once from 'onetime'; describe('', () => { let Plotly, PlotComponent; - // Mirrors enzyme's `mount(...).setProps(...)` / `.instance()` interface so the - // existing tests can keep their shape. `setProps` re-renders via a hook-driven - // wrapper; `instance` exposes the class component via a ref. + // `setProps` re-renders via a hook-driven wrapper; `gd` exposes the rendered + // DOM element (which the mocked Plotly.react augments to an EventEmitter, + // so tests can simulate plotly events directly). function createPlot(props) { return new Promise((resolve, reject) => { let setProps; - let instance; + let gd; const Wrapper = () => { const [currentProps, setCurrentProps] = useState(props); setProps = (next) => act(() => setCurrentProps((prev) => ({...prev, ...next}))); return ( { - instance = r; + ref={(el) => { + gd = el; }} onInitialized={() => resolve({ setProps, - get instance() { - return instance; + get gd() { + return gd; }, get props() { return currentProps; @@ -61,12 +61,6 @@ describe('', () => { beforeEach(() => { Plotly = jest.requireMock('../__mocks__/plotly.js').default; PlotComponent = createComponent(Plotly); - - // Override the parent element size: - PlotComponent.prototype.getParentSize = () => ({ - width: 123, - height: 456, - }); }); describe('initialization', function () { @@ -193,16 +187,21 @@ describe('', () => { describe('manging event handlers', () => { test('should add an event handler when one does not already exist', (done) => { - const onRelayout = () => {}; - - createPlot({onRelayout}).then((plot) => { - const {handlers} = plot.instance; - - expect(plot.props.onRelayout).toBe(onRelayout); - expect(handlers.Relayout).toBe(onRelayout); + let received; + const onRelayout = (evt) => { + received = evt; + }; - done(); - }); + createPlot({onRelayout}) + .then((plot) => { + expect(plot.props.onRelayout).toBe(onRelayout); + // The mocked Plotly.react makes gd an EventEmitter. Fire the + // event and verify the handler was wired through. + plot.gd.emit('plotly_relayout', {hello: 'world'}); + expect(received).toEqual({hello: 'world'}); + done(); + }) + .catch((err) => done(err)); }); }); }); diff --git a/src/factory.js b/src/factory.js index c8770ef..6f88955 100644 --- a/src/factory.js +++ b/src/factory.js @@ -1,4 +1,4 @@ -import React, {Component} from 'react'; +import React, {forwardRef, useCallback, useEffect, useRef} from 'react'; // The naming convention is: // - events are attached as `'plotly_' + eventName.toLowerCase()` @@ -53,215 +53,221 @@ const updateEvents = [ // breaks unnecessarily if you try to use it server-side. const isBrowser = typeof window !== 'undefined'; +// Module-level frozen defaults so identity stays stable across renders when a +// consumer omits the prop. Without this, the `===` checks in the update +// effect would always see "changed" and trigger a needless Plotly.react. +const DEFAULT_DATA = Object.freeze([]); +const DEFAULT_STYLE = Object.freeze({position: 'relative', display: 'inline-block'}); + +function getPlotlyEventName(eventName) { + return 'plotly_' + eventName.toLowerCase(); +} + export default function plotComponentFactory(Plotly) { - class PlotlyComponent extends Component { - constructor(props) { - super(props); + return forwardRef(function PlotlyComponent( + { + data = DEFAULT_DATA, + layout, + config, + frames, + revision, + onInitialized, + onUpdate, + onPurge, + onError, + debug = false, + style = DEFAULT_STYLE, + className, + useResizeHandler = false, + divId, + ...eventProps + }, + forwardedRef + ) { + const elRef = useRef(null); + const promiseRef = useRef(Promise.resolve()); + const handlersRef = useRef({}); + const resizeHandlerRef = useRef(null); + const unmountingRef = useRef(false); + const prevRef = useRef(null); + + // Refs that mirror the latest values of callbacks consumed by listeners + // attached during mount (which must keep stable identity across renders). + const onUpdateRef = useRef(onUpdate); + onUpdateRef.current = onUpdate; + const onPurgeRef = useRef(onPurge); + onPurgeRef.current = onPurge; + + // Stable closure for plotly update events. Reads latest `onUpdate` via the + // ref so the listener identity stays the same across renders — we need the + // same function reference for both `.on(...)` and `.removeListener(...)`. + const handleUpdate = useRef(() => { + const cb = onUpdateRef.current; + if (typeof cb !== 'function' || !elRef.current) { + return; + } + const el = elRef.current; + const frames = el._transitionData ? el._transitionData._frames : null; + cb({data: el.data, layout: el.layout, frames}, el); + }).current; + + const setRef = useCallback( + (el) => { + elRef.current = el; + if (typeof forwardedRef === 'function') { + forwardedRef(el); + } else if (forwardedRef) { + forwardedRef.current = el; + } + if (debug && isBrowser && el) { + window.gd = el; + } + }, + [forwardedRef, debug] + ); + + // Mount + update effect: no deps array, so it runs after every render — + // mirroring the original componentDidMount + componentDidUpdate flow. + useEffect(() => { + unmountingRef.current = false; + + if (prevRef.current === null) { + prevRef.current = {data, layout, config, frames, revision}; + runPlotlyReact(true, onInitialized, true); + return; + } - this.p = Promise.resolve(); - this.resizeHandler = null; - this.handlers = {}; + const prev = prevRef.current; + const numPrev = prev.frames && prev.frames.length ? prev.frames.length : 0; + const numNext = frames && frames.length ? frames.length : 0; - this.syncWindowResize = this.syncWindowResize.bind(this); - this.syncEventHandlers = this.syncEventHandlers.bind(this); - this.attachUpdateEvents = this.attachUpdateEvents.bind(this); - this.getRef = this.getRef.bind(this); - this.handleUpdate = this.handleUpdate.bind(this); - this.figureCallback = this.figureCallback.bind(this); - this.updatePlotly = this.updatePlotly.bind(this); - } + const figureChanged = !( + prev.layout === layout && + prev.data === data && + prev.config === config && + numPrev === numNext + ); + const revisionWasDefined = prev.revision !== void 0; + const revisionChanged = prev.revision !== revision; + + prevRef.current = {data, layout, config, frames, revision}; + + if (!figureChanged && (!revisionWasDefined || !revisionChanged)) { + return; + } - updatePlotly(shouldInvokeResizeHandler, figureCallbackFunction, shouldAttachUpdateEvents) { - this.p = this.p + runPlotlyReact(false, onUpdate, false); + }); + + // Cleanup effect — runs on unmount only. + useEffect(() => { + return () => { + unmountingRef.current = true; + const el = elRef.current; + if (el) { + if (typeof onPurgeRef.current === 'function') { + const frames = el._transitionData ? el._transitionData._frames : null; + onPurgeRef.current({data: el.data, layout: el.layout, frames}, el); + } + if (el.removeListener) { + updateEvents.forEach((evt) => el.removeListener(evt, handleUpdate)); + } + Plotly.purge(el); + } + if (resizeHandlerRef.current && isBrowser) { + window.removeEventListener('resize', resizeHandlerRef.current); + resizeHandlerRef.current = null; + } + }; + }, []); + + function runPlotlyReact(shouldInvokeResize, figureCallback, shouldAttachUpdateEvents) { + promiseRef.current = promiseRef.current .then(() => { - if (this.unmounting) { - return; + if (unmountingRef.current) { + return void 0; } - if (!this.el) { + if (!elRef.current) { throw new Error('Missing element reference'); } - // eslint-disable-next-line consistent-return - return Plotly.react(this.el, { - data: this.props.data, - layout: this.props.layout, - config: this.props.config, - frames: this.props.frames, - }); + return Plotly.react(elRef.current, {data, layout, config, frames}); }) .then(() => { - if (this.unmounting) { + if (unmountingRef.current) { return; } - this.syncWindowResize(shouldInvokeResizeHandler); - this.syncEventHandlers(); - this.figureCallback(figureCallbackFunction); + syncWindowResize(shouldInvokeResize); + syncEventHandlers(); + invokeFigureCallback(figureCallback); if (shouldAttachUpdateEvents) { - this.attachUpdateEvents(); + attachUpdateEvents(); } }) .catch((err) => { - if (this.props.onError) { - this.props.onError(err); + if (typeof onError === 'function') { + onError(err); } }); } - componentDidMount() { - this.unmounting = false; - - this.updatePlotly(true, this.props.onInitialized, true); - } - - componentDidUpdate(prevProps) { - this.unmounting = false; - - // frames *always* changes identity so fall back to check length only :( - const numPrevFrames = - prevProps.frames && prevProps.frames.length ? prevProps.frames.length : 0; - const numNextFrames = - this.props.frames && this.props.frames.length ? this.props.frames.length : 0; - - const figureChanged = !( - prevProps.layout === this.props.layout && - prevProps.data === this.props.data && - prevProps.config === this.props.config && - numNextFrames === numPrevFrames - ); - const revisionDefined = prevProps.revision !== void 0; - const revisionChanged = prevProps.revision !== this.props.revision; - - if (!figureChanged && (!revisionDefined || (revisionDefined && !revisionChanged))) { + function invokeFigureCallback(callback) { + if (typeof callback !== 'function' || !elRef.current) { return; } - - this.updatePlotly(false, this.props.onUpdate, false); + const el = elRef.current; + const f = el._transitionData ? el._transitionData._frames : null; + callback({data: el.data, layout: el.layout, frames: f}, el); } - componentWillUnmount() { - this.unmounting = true; - - this.figureCallback(this.props.onPurge); - - if (this.resizeHandler && isBrowser) { - window.removeEventListener('resize', this.resizeHandler); - this.resizeHandler = null; - } - - this.removeUpdateEvents(); - - Plotly.purge(this.el); - } - - attachUpdateEvents() { - if (!this.el || !this.el.removeListener) { - return; - } - - updateEvents.forEach((updateEvent) => { - this.el.on(updateEvent, this.handleUpdate); - }); - } - - removeUpdateEvents() { - if (!this.el || !this.el.removeListener) { + function attachUpdateEvents() { + if (!elRef.current || !elRef.current.removeListener) { return; } - - updateEvents.forEach((updateEvent) => { - this.el.removeListener(updateEvent, this.handleUpdate); - }); - } - - handleUpdate() { - this.figureCallback(this.props.onUpdate); + updateEvents.forEach((evt) => elRef.current.on(evt, handleUpdate)); } - figureCallback(callback) { - if (typeof callback === 'function') { - const {data, layout} = this.el; - const frames = this.el._transitionData ? this.el._transitionData._frames : null; - const figure = {data, layout, frames}; - callback(figure, this.el); - } - } - - syncWindowResize(invoke) { + function syncWindowResize(invoke) { if (!isBrowser) { return; } - - if (this.props.useResizeHandler && !this.resizeHandler) { - this.resizeHandler = () => Plotly.Plots.resize(this.el); - window.addEventListener('resize', this.resizeHandler); + if (useResizeHandler && !resizeHandlerRef.current) { + resizeHandlerRef.current = () => Plotly.Plots.resize(elRef.current); + window.addEventListener('resize', resizeHandlerRef.current); if (invoke) { - this.resizeHandler(); + resizeHandlerRef.current(); } - } else if (!this.props.useResizeHandler && this.resizeHandler) { - window.removeEventListener('resize', this.resizeHandler); - this.resizeHandler = null; - } - } - - getRef(el) { - this.el = el; - - if (this.props.debug && isBrowser) { - window.gd = this.el; + } else if (!useResizeHandler && resizeHandlerRef.current) { + window.removeEventListener('resize', resizeHandlerRef.current); + resizeHandlerRef.current = null; } } - // Attach and remove event handlers as they're added or removed from props: - syncEventHandlers() { + function syncEventHandlers() { eventNames.forEach((eventName) => { - const prop = this.props['on' + eventName]; - const handler = this.handlers[eventName]; + const prop = eventProps['on' + eventName]; + const handler = handlersRef.current[eventName]; const hasHandler = Boolean(handler); - if (prop && !hasHandler) { - this.addEventHandler(eventName, prop); + addEventHandler(eventName, prop); } else if (!prop && hasHandler) { - // Needs to be removed: - this.removeEventHandler(eventName); + removeEventHandler(eventName); } else if (prop && hasHandler && prop !== handler) { - // replace the handler - this.removeEventHandler(eventName); - this.addEventHandler(eventName, prop); + removeEventHandler(eventName); + addEventHandler(eventName, prop); } }); } - addEventHandler(eventName, prop) { - this.handlers[eventName] = prop; - this.el.on(this.getPlotlyEventName(eventName), this.handlers[eventName]); - } - - removeEventHandler(eventName) { - this.el.removeListener(this.getPlotlyEventName(eventName), this.handlers[eventName]); - delete this.handlers[eventName]; + function addEventHandler(eventName, prop) { + handlersRef.current[eventName] = prop; + elRef.current.on(getPlotlyEventName(eventName), prop); } - getPlotlyEventName(eventName) { - return 'plotly_' + eventName.toLowerCase(); + function removeEventHandler(eventName) { + elRef.current.removeListener(getPlotlyEventName(eventName), handlersRef.current[eventName]); + delete handlersRef.current[eventName]; } - render() { - return ( -
- ); - } - } - - PlotlyComponent.defaultProps = { - debug: false, - useResizeHandler: false, - data: [], - style: {position: 'relative', display: 'inline-block'}, - }; - - return PlotlyComponent; + return
; + }); }