1/** 2 * Copyright (c) 650 Industries. 3 * Copyright (c) Meta Platforms, Inc. and affiliates. 4 * 5 * This source code is licensed under the MIT license found in the 6 * LICENSE file in the root directory of this source tree. 7 * 8 * Based on this but with web support: 9 * https://github.com/facebook/react-native/blob/086714b02b0fb838dee5a66c5bcefe73b53cf3df/Libraries/Utilities/HMRClient.js 10 */ 11import prettyFormat, { plugins } from 'pretty-format'; 12 13import LoadingView from './LoadingView'; 14import LogBox from './error-overlay/LogBox'; 15import getDevServer from './getDevServer'; 16 17const MetroHMRClient = require('metro-runtime/src/modules/HMRClient'); 18const pendingEntryPoints: string[] = []; 19 20type HMRClientType = { 21 send: (msg: string) => void; 22 isEnabled: () => boolean; 23 disable: () => void; 24 enable: () => void; 25 hasPendingUpdates: () => boolean; 26}; 27 28let hmrClient: HMRClientType | null = null; 29let hmrUnavailableReason: string | null = null; 30let currentCompileErrorMessage: string | null = null; 31let didConnect: boolean = false; 32const pendingLogs: [LogLevel, any[]][] = []; 33 34type LogLevel = 35 | 'trace' 36 | 'info' 37 | 'warn' 38 | 'error' 39 | 'log' 40 | 'group' 41 | 'groupCollapsed' 42 | 'groupEnd' 43 | 'debug'; 44 45export type HMRClientNativeInterface = { 46 enable(): void; 47 disable(): void; 48 registerBundle(requestUrl: string): void; 49 log(level: LogLevel, data: any[]): void; 50 setup(props: { isEnabled: boolean }): void; 51}; 52 53function assert(foo: any, msg: string): asserts foo { 54 if (!foo) throw new Error(msg); 55} 56 57/** 58 * HMR Client that receives from the server HMR updates and propagates them 59 * runtime to reflects those changes. 60 */ 61const HMRClient: HMRClientNativeInterface = { 62 enable() { 63 if (hmrUnavailableReason !== null) { 64 // If HMR became unavailable while you weren't using it, 65 // explain why when you try to turn it on. 66 // This is an error (and not a warning) because it is shown 67 // in response to a direct user action. 68 throw new Error(hmrUnavailableReason); 69 } 70 71 assert(hmrClient, 'Expected HMRClient.setup() call at startup.'); 72 73 // We use this for internal logging only. 74 // It doesn't affect the logic. 75 hmrClient.send(JSON.stringify({ type: 'log-opt-in' })); 76 77 // When toggling Fast Refresh on, we might already have some stashed updates. 78 // Since they'll get applied now, we'll show a banner. 79 const hasUpdates = hmrClient!.hasPendingUpdates(); 80 81 if (hasUpdates) { 82 LoadingView.showMessage('Refreshing...', 'refresh'); 83 } 84 try { 85 hmrClient.enable(); 86 } finally { 87 if (hasUpdates) { 88 LoadingView.hide(); 89 } 90 } 91 92 // There could be a compile error while Fast Refresh was off, 93 // but we ignored it at the time. Show it now. 94 showCompileError(); 95 }, 96 97 disable() { 98 assert(hmrClient, 'Expected HMRClient.setup() call at startup.'); 99 hmrClient.disable(); 100 }, 101 102 registerBundle(requestUrl: string) { 103 assert(hmrClient, 'Expected HMRClient.setup() call at startup.'); 104 pendingEntryPoints.push(requestUrl); 105 registerBundleEntryPoints(hmrClient); 106 }, 107 108 log(level: LogLevel, data: any[]) { 109 if (!hmrClient) { 110 // Catch a reasonable number of early logs 111 // in case hmrClient gets initialized later. 112 pendingLogs.push([level, data]); 113 if (pendingLogs.length > 100) { 114 pendingLogs.shift(); 115 } 116 return; 117 } 118 try { 119 hmrClient.send( 120 JSON.stringify({ 121 type: 'log', 122 level, 123 mode: 'BRIDGE', 124 data: data.map((item) => 125 typeof item === 'string' 126 ? item 127 : prettyFormat(item, { 128 escapeString: true, 129 highlight: true, 130 maxDepth: 3, 131 min: true, 132 plugins: [plugins.ReactElement], 133 }) 134 ), 135 }) 136 ); 137 } catch { 138 // If sending logs causes any failures we want to silently ignore them 139 // to ensure we do not cause infinite-logging loops. 140 } 141 }, 142 143 // Called once by the bridge on startup, even if Fast Refresh is off. 144 // It creates the HMR client but doesn't actually set up the socket yet. 145 setup({ isEnabled }: { isEnabled: boolean }) { 146 assert(!hmrClient, 'Cannot initialize hmrClient twice'); 147 148 const serverScheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; 149 const client = new MetroHMRClient(`${serverScheme}://${window.location.host}/hot`); 150 hmrClient = client; 151 152 const { fullBundleUrl } = getDevServer(); 153 pendingEntryPoints.push( 154 // HMRServer understands regular bundle URLs, so prefer that in case 155 // there are any important URL parameters we can't reconstruct from 156 // `setup()`'s arguments. 157 fullBundleUrl 158 ); 159 160 client.on('connection-error', (e: Error) => { 161 let error = `Cannot connect to Metro. 162 163 Try the following to fix the issue: 164 - Ensure the Metro dev server is running and available on the same network as this device`; 165 error += ` 166 167 URL: ${window.location.host} 168 169 Error: ${e.message}`; 170 171 setHMRUnavailableReason(error); 172 }); 173 174 client.on('update-start', ({ isInitialUpdate }: { isInitialUpdate?: boolean }) => { 175 currentCompileErrorMessage = null; 176 didConnect = true; 177 178 if (client.isEnabled() && !isInitialUpdate) { 179 LoadingView.showMessage('Refreshing...', 'refresh'); 180 } 181 }); 182 183 client.on('update', ({ isInitialUpdate }: { isInitialUpdate?: boolean }) => { 184 if (client.isEnabled() && !isInitialUpdate) { 185 dismissRedbox(); 186 LogBox.clearAllLogs(); 187 } 188 }); 189 190 client.on('update-done', () => { 191 LoadingView.hide(); 192 }); 193 194 client.on('error', (data: { type: string; message: string }) => { 195 LoadingView.hide(); 196 197 if (data.type === 'GraphNotFoundError') { 198 client.close(); 199 setHMRUnavailableReason('Metro has restarted since the last edit. Reload to reconnect.'); 200 } else if (data.type === 'RevisionNotFoundError') { 201 client.close(); 202 setHMRUnavailableReason('Metro and the client are out of sync. Reload to reconnect.'); 203 } else { 204 currentCompileErrorMessage = `${data.type} ${data.message}`; 205 if (client.isEnabled()) { 206 showCompileError(); 207 } 208 } 209 }); 210 211 client.on('close', (closeEvent: { code: number; reason: string }) => { 212 LoadingView.hide(); 213 214 // https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1 215 // https://www.rfc-editor.org/rfc/rfc6455.html#section-7.1.5 216 const isNormalOrUnsetCloseReason = 217 closeEvent == null || 218 closeEvent.code === 1000 || 219 closeEvent.code === 1005 || 220 closeEvent.code == null; 221 222 setHMRUnavailableReason( 223 `${ 224 isNormalOrUnsetCloseReason 225 ? 'Disconnected from Metro.' 226 : `Disconnected from Metro (${closeEvent.code}: "${closeEvent.reason}").` 227 } 228 229To reconnect: 230- Ensure that Metro is running and available on the same network 231- Reload this app (will trigger further help if Metro cannot be connected to) 232 ` 233 ); 234 }); 235 236 if (isEnabled) { 237 HMRClient.enable(); 238 } else { 239 HMRClient.disable(); 240 } 241 242 registerBundleEntryPoints(hmrClient); 243 flushEarlyLogs(); 244 }, 245}; 246 247function setHMRUnavailableReason(reason: string) { 248 assert(hmrClient, 'Expected HMRClient.setup() call at startup.'); 249 if (hmrUnavailableReason !== null) { 250 // Don't show more than one warning. 251 return; 252 } 253 hmrUnavailableReason = reason; 254 255 // We only want to show a warning if Fast Refresh is on *and* if we ever 256 // previously managed to connect successfully. We don't want to show 257 // the warning to native engineers who use cached bundles without Metro. 258 if (hmrClient.isEnabled() && didConnect) { 259 console.warn(reason); 260 // (Not using the `warning` module to prevent a Buck cycle.) 261 } 262} 263 264function registerBundleEntryPoints(client: HMRClientType | null) { 265 if (hmrUnavailableReason != null) { 266 // "Bundle Splitting – Metro disconnected" 267 window.location.reload(); 268 return; 269 } 270 271 if (pendingEntryPoints.length > 0) { 272 client?.send( 273 JSON.stringify({ 274 type: 'register-entrypoints', 275 entryPoints: pendingEntryPoints, 276 }) 277 ); 278 pendingEntryPoints.length = 0; 279 } 280} 281 282function flushEarlyLogs() { 283 try { 284 pendingLogs.forEach(([level, data]) => { 285 HMRClient.log(level, data); 286 }); 287 } finally { 288 pendingLogs.length = 0; 289 } 290} 291 292function dismissRedbox() { 293 // TODO(EvanBacon): Error overlay for web. 294} 295 296function showCompileError() { 297 if (currentCompileErrorMessage === null) { 298 return; 299 } 300 301 // Even if there is already a redbox, syntax errors are more important. 302 // Otherwise you risk seeing a stale runtime error while a syntax error is more recent. 303 dismissRedbox(); 304 305 const message = currentCompileErrorMessage; 306 currentCompileErrorMessage = null; 307 308 const error = new Error(message); 309 // Symbolicating compile errors is wasted effort 310 // because the stack trace is meaningless: 311 // @ts-expect-error 312 error.preventSymbolication = true; 313 throw error; 314} 315 316export default HMRClient; 317