1import * as Sentry from '@sentry/browser'; 2import React from 'react'; 3 4import { VERSIONS } from '~/constants/versions'; 5 6const REDIRECT_SUFFIX = '?redirected'; 7 8type State = { 9 notFound: boolean; 10 redirectPath?: string; 11 redirectFailed: boolean; 12}; 13export default class Error extends React.Component<object, State> { 14 state: State = { 15 notFound: false, 16 redirectPath: undefined, 17 redirectFailed: false, 18 }; 19 20 componentDidMount() { 21 this._maybeRedirect(); 22 } 23 24 _maybeRedirect = () => { 25 if (typeof window === 'undefined') { 26 return; 27 } 28 29 const { pathname } = window.location; 30 31 if (window.location.search === REDIRECT_SUFFIX) { 32 Sentry.captureMessage(`Redirect failed`); 33 this.setState({ redirectFailed: true }); 34 return; 35 } 36 37 let redirectPath = pathname; 38 39 // index.html is no longer a thing in our docs 40 if (pathIncludesIndexHtml(redirectPath)) { 41 redirectPath = redirectPath.replace('index.html', ''); 42 } 43 44 // Remove the .html extension if it is included in the path 45 if (pathIncludesHtmlExtension(redirectPath)) { 46 redirectPath = redirectPath.replace('.html', ''); 47 } 48 49 // Unsure why this is happening, but sometimes URLs end up with /null in 50 // the last path part 51 // https://docs.expo.io/versions/latest/sdk/overview/null 52 if (endsInNull(redirectPath)) { 53 redirectPath = redirectPath.replace(/null$/, ''); 54 } 55 56 // Add a trailing slash if there is not one 57 if (redirectPath[redirectPath.length - 1] !== '/') { 58 redirectPath = `${redirectPath}/`; 59 } 60 61 // A list of pages we know are renamed and can redirect 62 if (RENAMED_PAGES[redirectPath]) { 63 redirectPath = RENAMED_PAGES[redirectPath]; 64 } 65 66 // Catch any unversioned paths which are also renamed 67 if (redirectPath.startsWith('/versions/latest/')) { 68 const unversionedPath = redirectPath.replace('/versions/latest', ''); 69 if (RENAMED_PAGES[unversionedPath]) { 70 redirectPath = RENAMED_PAGES[unversionedPath]; 71 } 72 } 73 74 // Check if the version is documented, replace it with latest if not 75 if (!isVersionDocumented(redirectPath)) { 76 redirectPath = replaceVersionWithLatest(redirectPath); 77 } 78 79 // Remove versioning from path if this section is no longer versioned 80 if (isVersionedPath(redirectPath) && !pathRequiresVersioning(redirectPath)) { 81 redirectPath = removeVersionFromPath(redirectPath); 82 } 83 84 // Catch any redirects to sdk paths without versions and send to the latest version 85 if (redirectPath.startsWith('/sdk/')) { 86 redirectPath = `/versions/latest${redirectPath}`; 87 } 88 89 // If a page is missing for react-native paths we redirect to react-native docs 90 if (redirectPath.match(/\/versions\/.*\/react-native\//)) { 91 const pathParts = redirectPath.split('/'); 92 const page = pathParts[pathParts.length - 2]; 93 redirectPath = `https://reactnative.dev/docs/${page}`; 94 } 95 96 // Remove version from path if the version is still supported, to redirect to the root 97 if (isVersionedPath(redirectPath) && isVersionDocumented(redirectPath)) { 98 redirectPath = `/versions/${getVersionFromPath(redirectPath)}/`; 99 } 100 101 if (redirectPath !== pathname) { 102 this.setState({ redirectPath }); 103 return; 104 } 105 106 // We are confident now that we can render a not found error 107 this.setState({ notFound: true }); 108 Sentry.captureMessage(`Page not found (404)`); 109 }; 110 111 componentDidUpdate(prevProps: object, prevState: State) { 112 if (prevState.redirectPath !== this.state.redirectPath && typeof window !== 'undefined') { 113 // Let people actually read the carefully crafted message and absorb the 114 // cool emoji selection, they can just click through if they want speed 115 setTimeout(() => { 116 window.location.href = `${this.state.redirectPath}?redirected`; 117 }, 1200); 118 } 119 } 120 121 render() { 122 return ( 123 <div 124 style={{ 125 display: 'flex', 126 flex: 1, 127 alignItems: 'center', 128 justifyContent: 'center', 129 height: '100vh', 130 flexDirection: 'column', 131 }}> 132 {this._renderContents()} 133 </div> 134 ); 135 } 136 137 _renderContents = () => { 138 const styles = { 139 description: { 140 textAlign: 'center' as const, 141 maxWidth: 450, 142 marginHorizontal: 30, 143 lineHeight: '1.7em', 144 }, 145 link: { 146 textAlign: 'center' as const, 147 marginTop: 20, 148 }, 149 }; 150 151 if (this.state.redirectPath) { 152 return ( 153 <> 154 <h1>️♀️️</h1> 155 <p style={styles.description}> 156 Hold tight, we are redirecting you to where we think this URL was intended to take you! 157 </p> 158 <p style={styles.link}> 159 <a id="redirect-link" href={this.state.redirectPath}> 160 Click here to possibly go there more quickly! 161 </a> 162 </p> 163 </> 164 ); 165 } else if (this.state.redirectFailed) { 166 return ( 167 <> 168 <h1>️</h1> 169 <p style={styles.description} id="__redirect_failed"> 170 We took an educated guess and tried to direct you to the right page, but it seems that 171 did not work out! Maybe it doesn't exist anymore! 172 </p> 173 <p style={styles.link}> 174 <a href="/">Go to the Expo documentation, you can try searching there</a> 175 </p> 176 </> 177 ); 178 } else if (this.state.notFound) { 179 return ( 180 <> 181 <h1></h1> 182 <p style={styles.description} id="__not_found"> 183 <strong style={{ fontWeight: 'bold' }}>Uh oh, we couldn't find this page!</strong> We've 184 made note of this and will investigate, but it's possible that the page you're looking 185 for no longer exists! 186 </p> 187 <p style={styles.link}> 188 <a href="/">Go to the Expo documentation, you can try searching there</a> 189 </p> 190 </> 191 ); 192 } else { 193 // Render nothing statically 194 } 195 }; 196} 197 198function getVersionFromPath(path: string) { 199 const pathParts = path.split(/\//); 200 // eg: ["", "versions", "v32.0.0", ""] 201 return pathParts[2]; 202} 203 204// Filter unversioned and latest out, so we end up with v34, etc. 205const supportedVersions = VERSIONS.filter(v => v.match(/^v/)); 206 207// Return true if the version is still included in documentation 208function isVersionDocumented(path: string) { 209 return supportedVersions.includes(getVersionFromPath(path)); 210} 211 212function pathIncludesHtmlExtension(path: string) { 213 return !!path.match(/\.html$/); 214} 215 216function pathIncludesIndexHtml(path: string) { 217 return !!path.match(/index\.html$/); 218} 219 220const VERSION_PART_PATTERN = `(latest|unversioned|v\\d+\\.\\d+.\\d+)`; 221const VERSIONED_PATH_PATTERN = `^\\/versions\\/${VERSION_PART_PATTERN}`; 222const SDK_PATH_PATTERN = `${VERSIONED_PATH_PATTERN}/sdk`; 223const REACT_NATIVE_PATH_PATTERN = `${VERSIONED_PATH_PATTERN}/react-native`; 224 225// Check if path is valid (matches /versions/some-valid-version-here/) 226function isVersionedPath(path: string) { 227 return !!path.match(new RegExp(VERSIONED_PATH_PATTERN)); 228} 229 230// Replace an unsupported SDK version with latest 231function replaceVersionWithLatest(path: string) { 232 return path.replace(new RegExp(VERSION_PART_PATTERN), 'latest'); 233} 234 235/** 236 * Determine if the path requires versioning, if not we can remove the versioned prefix from the path. 237 * The following paths require versioning: 238 * - `/versions/<version>/sdk/**`, pages within the Expo SDK docs. 239 * - `/versions/<version>/react-native/**`, pages within the React Native API docs. 240 * - `/versions/<version>/`, the index of a specific Expo SDK version. 241 * All other paths shouldn't require versioning, some of them are: 242 * - `/versions/<version>/workflow/expo-cli/, moved outside versioned folders. 243 * - `/versions/<version>/guides/assets/, moved outside versioned folders. 244 */ 245function pathRequiresVersioning(path: string) { 246 const isExpoSdkPage = path.match(new RegExp(SDK_PATH_PATTERN)); 247 const isExpoSdkIndexPage = path.match(new RegExp(VERSIONED_PATH_PATTERN + '/$')); 248 const isReactNativeApiPage = path.match(new RegExp(REACT_NATIVE_PATH_PATTERN)); 249 250 return isExpoSdkIndexPage || isExpoSdkPage || isReactNativeApiPage; 251} 252 253function removeVersionFromPath(path: string) { 254 return path.replace(new RegExp(VERSIONED_PATH_PATTERN), ''); 255} 256 257// Not sure why this happens but sometimes the URL ends in /null 258function endsInNull(path: string) { 259 return !!path.match(/\/null$/); 260} 261 262// Simple remapping of renamed pages, similar to in deploy.sh but in some cases, 263// for reasons I'm not totally clear on, those redirects do not work 264const RENAMED_PAGES: Record<string, string> = { 265 '/introduction/project-lifecycle/': '/introduction/managed-vs-bare/', 266 '/guides/': '/workflow/exploring-managed-workflow/', 267 '/versions/latest/sdk/': '/versions/latest/', 268 '/versions/latest/sdk/overview/': '/versions/latest/', 269 '/guides/building-standalone-apps/': '/distribution/building-standalone-apps/', 270 '/guides/genymotion/': '/workflow/android-studio-emulator/', 271 '/workflow/upgrading-expo/': '/workflow/upgrading-expo-sdk-walkthrough/', 272 '/workflow/create-react-native-app/': '/workflow/glossary-of-terms/#create-react-native-app', 273 '/expokit/': '/expokit/overview/', 274 '/guides/detach/': '/expokit/eject/', 275 '/expokit/detach/': '/expokit/eject/', 276 277 // Lots of old links pointing to guides when they have moved elsewhere 278 '/guides/configuration/': '/workflow/configuration/', 279 '/guides/expokit/': '/expokit/overview/', 280 '/guides/publishing/': '/workflow/publishing/', 281 '/guides/linking/': '/workflow/linking/', 282 '/guides/up-and-running/': '/get-started/installation/', 283 '/guides/debugging/': '/workflow/debugging/', 284 '/guides/logging/': '/workflow/logging/', 285 '/introduction/troubleshooting-proxies/': '/guides/troubleshooting-proxies/', 286 '/introduction/running-in-the-browser/': '/guides/running-in-the-browser/', 287 288 // Changes from redoing the getting started workflow, SDK35+ 289 '/workflow/up-and-running/': '/get-started/installation/', 290 '/introduction/additional-resources/': '/next-steps/additional-resources/', 291 '/introduction/already-used-react-native/': '/workflow/already-used-react-native/', 292 '/introduction/community/': '/next-steps/community/', 293 '/introduction/installation/': '/get-started/installation/', 294 '/versions/latest/overview/': '/versions/latest/', 295 '/versions/latest/introduction/installation/': '/get-started/installation/', 296 '/workflow/exploring-managed-workflow/': '/introduction/walkthrough/', 297 298 // Move overview to index 299 '/versions/v37.0.0/sdk/overview/': '/versions/v37.0.0/', 300 301 // Errors and debugging is better suited for getting started than tutorial 302 '/tutorial/errors/': '/get-started/errors/', 303 304 // Additional redirects based on Sentry (04/28/2020) 305 '/next-steps/installation/': '/get-started/installation/', 306 '/guides/release-channels/': '/distribution/release-channels/', 307 308 // Redirects based on Next 9 upgrade (09/11/2020) 309 '/api/': '/versions/latest/', 310 311 // Redirect to expand Expo Accounts and permissions 312 '/guides/account-permissions/': '/accounts/personal/', 313 314 // Redirects based on Sentry (11/26/2020) 315 '/guides/push-notifications/': '/push-notifications/overview/', 316 '/guides/using-fcm/': '/push-notifications/using-fcm/', 317 318 // Renaming a submit section 319 '/submit/submit-ios': '/submit/ios/', 320 '/submit/submit-android': '/submit/android/', 321 322 // Fundamentals had too many things 323 '/workflow/linking/': '/guides/linking/', 324 '/workflow/how-expo-works/': '/guides/how-expo-works/', 325 326 // Archive unused pages 327 '/guides/notification-channels/': '/archived/notification-channels/', 328}; 329