ExternalComponentWrapper.jsx (4188B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import React from "react"; 6 import { useSelector } from "react-redux"; 7 8 /** 9 * A React component that dynamically loads and embeds external custom elements 10 * into the newtab page. 11 * 12 * This component serves as a bridge between React's declarative rendering and 13 * browser-native custom elements that are registered and managed outside of 14 * React's control. It: 15 * 16 * 1. Looks up the component configuration by type from the ExternalComponents 17 * registry 18 * 2. Dynamically imports the component's script module (which registers the 19 * custom element) 20 * 3. Creates an instance of the custom element using imperative DOM APIs 21 * 4. Appends it to a React-managed container div 22 * 5. Cleans up the custom element on unmount 23 * 24 * This approach is necessary because: 25 * - Custom elements have their own lifecycle separate from React 26 * - They need to be created imperatively (document.createElement) rather than 27 * declaratively (JSX) 28 * - React shouldn't try to diff/reconcile their internal DOM, as they manage 29 * their own shadow DOM 30 * - We need manual cleanup to prevent memory leaks when the component unmounts 31 * 32 * @param {object} props 33 * @param {string} props.type - The component type to load (e.g., "SEARCH") 34 * @param {string} props.className - CSS class name(s) to apply to the wrapper div 35 * @param {Function} props.importModule - Function to import modules (for testing) 36 */ 37 function ExternalComponentWrapper({ 38 type, 39 className, 40 // importFunction is declared as an arrow function here purely so that we can 41 // override it for testing. 42 // eslint-disable-next-line no-unsanitized/method 43 importModule = url => import(/* webpackIgnore: true */ url), 44 }) { 45 const containerRef = React.useRef(null); 46 const customElementRef = React.useRef(null); 47 const l10nLinksRef = React.useRef([]); 48 const [error, setError] = React.useState(null); 49 const { components } = useSelector(state => state.ExternalComponents); 50 51 React.useEffect(() => { 52 const container = containerRef.current; 53 54 const loadComponent = async () => { 55 try { 56 const config = components.find(c => c.type === type); 57 58 if (!config) { 59 console.warn( 60 `No external component configuration found for type: ${type}` 61 ); 62 return; 63 } 64 65 await importModule(config.componentURL); 66 67 l10nLinksRef.current = []; 68 for (let l10nURL of config.l10nURLs) { 69 const l10nEl = document.createElement("link"); 70 l10nEl.rel = "localization"; 71 l10nEl.href = l10nURL; 72 document.head.appendChild(l10nEl); 73 l10nLinksRef.current.push(l10nEl); 74 } 75 76 if (containerRef.current && !customElementRef.current) { 77 const element = document.createElement(config.tagName); 78 79 if (config.attributes) { 80 for (const [key, value] of Object.entries(config.attributes)) { 81 element.setAttribute(key, value); 82 } 83 } 84 85 if (config.cssVariables) { 86 for (const [variable, style] of Object.entries( 87 config.cssVariables 88 )) { 89 element.style.setProperty(variable, style); 90 } 91 } 92 93 customElementRef.current = element; 94 containerRef.current.appendChild(element); 95 } 96 } catch (err) { 97 console.error( 98 `Failed to load external component for type ${type}:`, 99 err 100 ); 101 setError(err); 102 } 103 }; 104 105 loadComponent(); 106 107 return () => { 108 if (customElementRef.current && container) { 109 container.removeChild(customElementRef.current); 110 customElementRef.current = null; 111 } 112 113 for (const link of l10nLinksRef.current) { 114 link.remove(); 115 } 116 l10nLinksRef.current = []; 117 }; 118 }, [type, components, importModule]); 119 120 if (error) { 121 return null; 122 } 123 124 return <div ref={containerRef} className={className} />; 125 } 126 127 export { ExternalComponentWrapper };