diff --git a/example-plugin-app/src/PluginOne.jsx b/example-plugin-app/src/PluginOne.jsx index 9737b1f08..3a461ee15 100644 --- a/example-plugin-app/src/PluginOne.jsx +++ b/example-plugin-app/src/PluginOne.jsx @@ -1,13 +1,14 @@ import React from 'react'; import { Plugin } from '@edx/frontend-platform/plugins'; +function Greeting({subject}) { + return
Hello {subject.toUpperCase()}
+} + export default function PluginOne() { return ( - -
-

Site Maintenance

-

The site will be going down for maintenance soon.

-
+ + ); } diff --git a/package-lock.json b/package-lock.json index aacaa448e..84ed01af5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "lodash.merge": "4.6.2", "lodash.snakecase": "4.1.1", "pubsub-js": "1.9.4", + "react-error-boundary": "^4.0.11", "react-intl": "^5.25.0", "universal-cookie": "4.0.4" }, @@ -5729,6 +5730,22 @@ } } }, + "node_modules/@testing-library/react-hooks/node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -20056,17 +20073,12 @@ } }, "node_modules/react-error-boundary": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", - "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", - "dev": true, + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.11.tgz", + "integrity": "sha512-U13ul67aP5DOSPNSCWQ/eO0AQEYzEFkVljULQIjMV0KlffTAhxuDoBKdO0pb/JZ8mDhMKFZ9NZi0BmLGUiNphw==", "dependencies": { "@babel/runtime": "^7.12.5" }, - "engines": { - "node": ">=10", - "npm": ">=6" - }, "peerDependencies": { "react": ">=16.13.1" } diff --git a/package.json b/package.json index 7b50e1786..946eb8869 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "lodash.merge": "4.6.2", "lodash.snakecase": "4.1.1", "pubsub-js": "1.9.4", + "react-error-boundary": "^4.0.11", "react-intl": "^5.25.0", "universal-cookie": "4.0.4" }, diff --git a/src/plugins/Plugin.jsx b/src/plugins/Plugin.jsx index 00b22237e..b194da2bc 100644 --- a/src/plugins/Plugin.jsx +++ b/src/plugins/Plugin.jsx @@ -1,10 +1,28 @@ +'use client'; + import React, { useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; +import { ErrorBoundary } from 'react-error-boundary'; import { dispatchMountedEvent, dispatchReadyEvent, dispatchUnmountedEvent, useHostEvent, } from './data/hooks'; import { PLUGIN_RESIZE } from './data/constants'; +function ErrorFallback(error) { + // we can customize the UI as we want + return ( +
+

+ Oops! An error occurred +
+
+ {error.message} +

+ {/* Additional custom error handling */} +
+ ); +} + export default function Plugin({ children, className, style, ready, }) { @@ -13,6 +31,20 @@ export default function Plugin({ height: null, }); + const [errorMessage, setErrorMessage] = useState(''); + const handleResetError = () => { + console.log('Error boundary reset'); + setErrorMessage(''); + // additional logic to perform code cleanup and state update actions + }; + + // Error logging function + function logErrorToService(error) { + // Use your preferred error logging service + setErrorMessage(error); + console.error('Caught an error:', errorMessage, error); + } + const finalStyle = useMemo(() => ({ ...dimensions, ...style, @@ -41,7 +73,13 @@ export default function Plugin({ return (
- {children} + logErrorToService()} + onReset={handleResetError} + > + {children} +
); } diff --git a/src/plugins/Plugin.test.jsx b/src/plugins/Plugin.test.jsx index b3e5a3904..65420b2c7 100644 --- a/src/plugins/Plugin.test.jsx +++ b/src/plugins/Plugin.test.jsx @@ -1,9 +1,11 @@ import React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; import { render } from '@testing-library/react'; import { fireEvent } from '@testing-library/dom'; import '@testing-library/jest-dom'; import PluginContainer from './PluginContainer'; +import Plugin from './Plugin'; import { IFRAME_PLUGIN, PLUGIN_MOUNTED, PLUGIN_READY, PLUGIN_RESIZE, } from './data/constants'; @@ -32,6 +34,10 @@ describe('PluginContainer', () => { expect(container.firstChild).toBeNull(); }); + it('should render the desired fallback when the iframe fails to render', () => { + + }); + it('should render a PluginIFrame when given an iFrame config', async () => { const title = 'test plugin'; const component = ( @@ -90,3 +96,21 @@ describe('PluginContainer', () => { expect(iframeElement.attributes.getNamedItem('class').value).toEqual('border border-0'); }); }); + +describe('Plugin', () => { + const breakingArray = null; + const failingMap = () => breakingArray.map(a => a); + it('should render the desired fallback when the error boundary receives a React error', () => { + const component = ( + Something went wrong}> + + { failingMap } + + + ); + + const { container } = render(component); + console.log(container.children); + expect(container.firstChild).toHaveTextContent('Something went wrong'); + }); +}); diff --git a/src/plugins/PluginContainer.jsx b/src/plugins/PluginContainer.jsx index 9cc95c1f7..4d889d7ec 100644 --- a/src/plugins/PluginContainer.jsx +++ b/src/plugins/PluginContainer.jsx @@ -1,7 +1,9 @@ +'use client'; + import React from 'react'; +// eslint-disable-next-line import/no-extraneous-dependencies import PluginContainerIframe from './PluginContainerIframe'; -import PluginErrorBoundary from './PluginErrorBoundary'; import { IFRAME_PLUGIN, @@ -13,6 +15,7 @@ export default function PluginContainer({ config, ...props }) { return null; } + // this will allow for future plugin types to be inserted in the PluginErrorBoundary let renderer = null; switch (config.type) { case IFRAME_PLUGIN: @@ -25,9 +28,7 @@ export default function PluginContainer({ config, ...props }) { } return ( - - {renderer} - + renderer ); }