diff --git a/website/package.json b/website/package.json index b81d1c1879..968b16a5ed 100644 --- a/website/package.json +++ b/website/package.json @@ -56,7 +56,6 @@ "@types/react-loadable": "5.5.3", "@types/react-modal": "3.10.6", "@types/react-redux": "7.1.9", - "@types/react-router-dom": "5.1.6", "@types/react-scrollspy": "3.3.3", "@types/redux-mock-store": "1.0.2", "@types/webpack-env": "1.15.3", @@ -141,6 +140,7 @@ "core-js": "3.6.5", "date-fns": "2.16.1", "downshift": "5.4.7", + "history": "^5.0.0", "ical-generator": "https://github.com/ZhangYiJiang/ical-generator.git#ed6928fe", "immer": "7.0.9", "json2mq": "0.2.0", @@ -151,9 +151,9 @@ "no-scroll": "2.1.1", "nusmoderator": "3.0.0", "query-string": "5.0.0", - "react": "16.14.0", + "react": "^0.0.0-experimental-4ead6b530", "react-beautiful-dnd": "13.0.0", - "react-dom": "16.14.0", + "react-dom": "^0.0.0-experimental-4ead6b530", "react-feather": "2.0.8", "react-helmet": "6.1.0", "react-hot-loader": "4.13.0", @@ -164,7 +164,7 @@ "react-modal": "3.11.2", "react-qr-svg": "2.2.2", "react-redux": "7.2.1", - "react-router-dom": "5.2.0", + "react-router-dom": "^0.0.0-experimental-ffd8c7d0", "react-scrollspy": "3.4.3", "redux": "4.0.5", "redux-persist": "6.0.0", diff --git a/website/src/bootstrapping/configure-store.ts b/website/src/bootstrapping/configure-store.ts index 2a49e1d0c6..90072b6b8a 100644 --- a/website/src/bootstrapping/configure-store.ts +++ b/website/src/bootstrapping/configure-store.ts @@ -27,20 +27,20 @@ export default function configureStore(defaultState?: State) { const middlewares = [ravenMiddleware, thunk, requestsMiddleware]; - if (process.env.NODE_ENV === 'development') { - // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-extraneous-dependencies - const { createLogger } = require('redux-logger'); - const logger = createLogger({ - level: 'info', - collapsed: true, - duration: true, - diff: true, - // Avoid diffing actions that insert a lot of stuff into the state to prevent console from lagging - diffPredicate: (getState: GetState, action: Actions) => - !action.type.startsWith('FETCH_MODULE_LIST') && !action.type.startsWith('persist/'), - }); - middlewares.push(logger); - } + // if (process.env.NODE_ENV === 'development') { + // // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-extraneous-dependencies + // const { createLogger } = require('redux-logger'); + // const logger = createLogger({ + // level: 'info', + // collapsed: true, + // duration: true, + // diff: true, + // // Avoid diffing actions that insert a lot of stuff into the state to prevent console from lagging + // diffPredicate: (getState: GetState, action: Actions) => + // !action.type.startsWith('FETCH_MODULE_LIST') && !action.type.startsWith('persist/'), + // }); + // middlewares.push(logger); + // } const storeEnhancer = applyMiddleware(...middlewares); diff --git a/website/src/bootstrapping/matomo.ts b/website/src/bootstrapping/matomo.ts index 4a97ade899..53d767a5ea 100644 --- a/website/src/bootstrapping/matomo.ts +++ b/website/src/bootstrapping/matomo.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { History } from 'history'; import { each } from 'lodash'; @@ -68,7 +67,7 @@ export function setCustomDimensions(dimensions: { [id: number]: string }) { } export function trackPageView(history: History) { - history.listen((location, action) => { + return history.listen(({ action }) => { if (action === 'PUSH') { // Wait a bit for the page title to update setTimeout(() => { diff --git a/website/src/bootstrapping/sentry.ts b/website/src/bootstrapping/sentry.ts index 93304f8c6d..4aae9671bd 100644 --- a/website/src/bootstrapping/sentry.ts +++ b/website/src/bootstrapping/sentry.ts @@ -4,7 +4,8 @@ import * as Sentry from '@sentry/browser'; import { isBrowserSupported } from './browser'; // Configure Raven - the client for Sentry, which we use to handle errors -const loadRaven = process.env.NODE_ENV === 'production'; +// const loadRaven = process.env.NODE_ENV === 'production'; +const loadRaven = false; // TODO: Reenable before merging if (loadRaven) { Sentry.init({ dsn: 'https://4b4fe71954424fd39ac88a4f889ffe20@sentry.io/213986', diff --git a/website/src/entry/App.tsx b/website/src/entry/App.tsx index dbb6bc3475..2ba3c50d66 100644 --- a/website/src/entry/App.tsx +++ b/website/src/entry/App.tsx @@ -2,17 +2,19 @@ import { Store } from 'redux'; import { State } from 'types/state'; import { Persistor } from 'storage/persistReducer'; -import React from 'react'; +import React, { Suspense, useCallback, useMemo } from 'react'; import { hot } from 'react-hot-loader/root'; import { BrowserRouter as Router } from 'react-router-dom'; import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; -import AppShell from 'views/AppShell'; +// import AppShell from 'views/AppShell'; import Routes from 'views/routes/Routes'; import { DIMENSIONS, setCustomDimensions } from 'bootstrapping/matomo'; import ErrorBoundary from 'views/errors/ErrorBoundary'; import ErrorPage from 'views/errors/ErrorPage'; +import LoadingSpinner from 'views/components/LoadingSpinner'; +import ApiError from 'views/errors/ApiError'; type Props = { store: Store; @@ -20,23 +22,28 @@ type Props = { }; const App: React.FC = ({ store, persistor }) => { - const onBeforeLift = () => { + const onBeforeLift = useCallback(() => { const { theme, settings } = store.getState(); setCustomDimensions({ [DIMENSIONS.theme]: theme.id, [DIMENSIONS.beta]: String(!!settings.beta), }); - }; + }, [store]); + // + // + // + // + // return ( }> - + }> - + diff --git a/website/src/entry/main.tsx b/website/src/entry/main.tsx index eb146b829c..43410d7b50 100644 --- a/website/src/entry/main.tsx +++ b/website/src/entry/main.tsx @@ -7,7 +7,9 @@ import 'bootstrapping/sentry'; import 'core-js/es/promise/finally'; import React from 'react'; -import ReactDOM from 'react-dom'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { unstable_createRoot as createRoot } from 'react-dom'; import ReactModal from 'react-modal'; import configureStore from 'bootstrapping/configure-store'; @@ -26,7 +28,8 @@ subscribeOnlineEvents(store); // Initialize ReactModal ReactModal.setAppElement('#app'); -ReactDOM.render(, document.getElementById('app')); +// ReactDOM.render(, document.getElementById('app')); +createRoot(document.getElementById('app')).render(); if ( ('serviceWorker' in navigator && diff --git a/website/src/utils/JSResource.ts b/website/src/utils/JSResource.ts new file mode 100644 index 0000000000..c20c0b88d3 --- /dev/null +++ b/website/src/utils/JSResource.ts @@ -0,0 +1,169 @@ +type Module = { default: T }; +type Loader = () => Promise>; + +export interface JSResourceReference { + /** + * Returns the module's id + */ + getModuleId(): string; + + /** + * Gets a module if it is already loaded, undefined otherwise. + */ + getModuleIfRequired(): T | undefined; + + /** + * Loads the resource if necessary + */ + preload(): Promise; + + preloadOrReloadIfError(): void; + + /** + * This is the key method for integrating with React Suspense. Read will: + * - "Suspend" if the resource is still pending (currently implemented as + * throwing a Promise, though this is subject to change in future + * versions of React) + * - Throw an error if the resource failed to load. + * - Return the data of the resource if available. + */ + read(): T; + + /** + * Convenience function that preloads and reads this resource. + */ + fetch(): T; +} + +/** + * A cache of resources to avoid loading the same module twice. This is important + * because Webpack dynamic imports only expose an asynchronous API for loading + * modules, so to be able to access already-loaded modules synchronously we + * must have stored the previous result somewhere. + */ +const resourceMap = new Map>(); + +/** + * A generic resource: given some method to asynchronously load a value -- the + * loader() argument -- it allows accessing the state of the resource. + * + * The main differences between this and `Resource` are: + * - `JSResourceImpl` returns a promise on preload. + * - `Resource` allows a single resource to load values for different keys. + */ +class JSResourceImpl implements JSResourceReference { + private error: Error | undefined; + + private promise: Promise | undefined; + + private result: T | undefined; + + private moduleId: string; + + private loader: Loader; + + constructor(moduleId: string, loader: Loader) { + this.moduleId = moduleId; + this.loader = loader; + } + + getModuleId() { + return this.moduleId; + } + + getModuleIfRequired() { + return this.result; + } + + /** + * Loads the resource if necessary. + */ + preload() { + let { promise } = this; + if (promise == null) { + promise = this.loader() + .then(({ default: result }) => { + this.result = result; + return result; + }) + .catch((error) => { + this.error = error; + throw error; + }); + this.promise = promise; + } + return promise; + } + + preloadOrReloadIfError() { + if (this.error !== undefined) { + this.error = undefined; + this.promise = undefined; + this.result = undefined; + } + this.preload(); + } + + /** + * Returns the result, if available. This can be useful to check if the value + * is resolved yet. + */ + get() { + if (this.result !== undefined) { + return this.result; + } + return undefined; + } + + /** + * This is the key method for integrating with React Suspense. Read will: + * - "Suspend" if the resource is still pending (currently implemented as + * throwing a Promise, though this is subject to change in future + * versions of React) + * - Throw an error if the resource failed to load. + * - Return the data of the resource if available. + */ + read() { + if (this.result !== undefined) { + return this.result; + } + if (this.error !== undefined) { + throw this.error; + } + if (this.promise === undefined) { + throw new Error('preload() must be called before read().'); + } + throw this.promise; + } + + fetch() { + this.preload(); + return this.read(); + } +} + +/** + * A helper method to create a resource, intended for dynamically loading code. + * + * Example: + * ``` + * // Before rendering, ie in an event handler: + * const resource = JSResource('Foo', () => import('./Foo.js)); + * resource.load(); + * + * // in a React component: + * const Foo = resource.read(); + * return ; + * ``` + * + * @param {*} moduleId A globally unique identifier for the resource used for caching + * @param {*} loader A method to load the resource's data if necessary + */ +export function JSResource(moduleId: string, loader: Loader): JSResourceReference { + let resource = resourceMap.get(moduleId); + if (resource == null) { + resource = new JSResourceImpl(moduleId, loader); + resourceMap.set(moduleId, resource); + } + return resource as JSResourceReference; +} diff --git a/website/src/utils/Resource.ts b/website/src/utils/Resource.ts new file mode 100644 index 0000000000..3171e5d0e8 --- /dev/null +++ b/website/src/utils/Resource.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// Cache implementation was forked from the React repo: +// https://github.com/facebook/react/blob/4e5d7faf54b38ebfc7a2dcadbd09a25d6f330ac0/packages/react-devtools-shared/src/devtools/cache.js +// which was forked from: +// https://github.com/facebook/react/blob/4e5d7faf54b38ebfc7a2dcadbd09a25d6f330ac0/packages/react-cache/src/ReactCacheOld.js +// +// This cache is simpler than react-cache in that: +// 1. Individual items don't need to be invalidated. +// 2. We didn't need the added overhead of an LRU cache. + +type PendingResult = { + status: 0; + value: Promise; +}; + +type ResolvedResult = { + status: 1; + value: Value; +}; + +type RejectedResult = { + status: 2; + value: unknown; +}; + +type Result = PendingResult | ResolvedResult | RejectedResult; + +// TODO: Reduce API surface area? +export type Resource = { + clear(): void; + invalidate(input: Input): void; + + /** + * Returns the result, if available. This can be useful to check if the value + * is resolved yet. + */ + get(input: Input): Value | undefined; + + /** + * This is the key method for integrating with React Suspense. Read will: + * - "Suspend" if the resource is still pending (currently implemented as + * throwing a Promise, though this is subject to change in future + * versions of React) + * - Throw an error if the resource failed to load. + * - Return the data of the resource if available. + */ + read(input: Input): Value; + + /** + * Loads the resource if necessary. + */ + preload(input: Input): void; + + write(key: Key, value: Value): void; +}; + +const Pending = 0; +const Resolved = 1; +const Rejected = 2; + +type Config = { + useWeakMap?: boolean; +}; + +const entries: Map, Map | WeakMap> = new Map(); +const resourceConfigs: Map, Config> = new Map(); + +function getEntriesForResource(resource: any): Map | WeakMap { + let entriesForResource = (entries.get(resource) as any) as Map | WeakMap; + if (entriesForResource === undefined) { + const config = resourceConfigs.get(resource); + entriesForResource = config !== undefined && config.useWeakMap ? new WeakMap() : new Map(); + entries.set(resource, entriesForResource); + } + return entriesForResource; +} + +function accessResult( + resource: any, + fetch: (fetchInput: Input) => Promise, + input: Input, + key: Key, +): Result { + const entriesForResource = getEntriesForResource(resource); + const entry = entriesForResource.get(key); + + if (entry === undefined) { + const thenable = fetch(input); + thenable.then( + (value) => { + if (newResult.status === Pending) { + const resolvedResult: ResolvedResult = newResult as any; + resolvedResult.status = Resolved; + resolvedResult.value = value; + } + }, + (error) => { + if (newResult.status === Pending) { + const rejectedResult: RejectedResult = newResult as any; + rejectedResult.status = Rejected; + rejectedResult.value = error; + } + }, + ); + const newResult: PendingResult = { + status: Pending, + value: thenable, + }; + entriesForResource.set(key, newResult); + return newResult; + } + + return entry; +} + +export function createResource( + fetch: (input: Input) => Promise, + hashInput: (input: Input) => Key, + config: Config = {}, +): Resource { + const resource = { + clear(): void { + entries.delete(resource); + }, + + invalidate(input: Input): void { + const entriesForResource = getEntriesForResource(resource); + const key = hashInput(input); + entriesForResource.delete(key); + }, + + get(input: Input): Value | undefined { + const key = hashInput(input); + const result: Result = accessResult(resource, fetch, input, key); + switch (result.status) { + case Resolved: { + const { value } = result; + return value; + } + default: + return undefined; + } + }, + + read(input: Input): Value { + const key = hashInput(input); + const result: Result = accessResult(resource, fetch, input, key); + switch (result.status) { + case Pending: { + const suspender = result.value; + throw suspender; + } + case Resolved: { + const { value } = result; + return value; + } + case Rejected: { + const error = result.value; + throw error; + } + default: + // Should be unreachable + return undefined as any; + } + }, + + preload(input: Input): void { + const key = hashInput(input); + accessResult(resource, fetch, input, key); + }, + + write(key: Key, value: Value): void { + const entriesForResource = getEntriesForResource(resource); + + const resolvedResult: ResolvedResult = { + status: Resolved, + value, + }; + + entriesForResource.set(key, resolvedResult); + }, + }; + + resourceConfigs.set(resource, config); + + return resource; +} + +export function invalidateResources(): void { + entries.clear(); +} diff --git a/website/src/views/AppShell.entrypoint.ts b/website/src/views/AppShell.entrypoint.ts new file mode 100644 index 0000000000..b526f2a328 --- /dev/null +++ b/website/src/views/AppShell.entrypoint.ts @@ -0,0 +1,51 @@ +import { captureException } from '@sentry/browser'; +import { fetchModuleList } from 'actions/moduleBank'; +import { JSResource } from 'utils/JSResource'; +import { Resource, createResource } from 'utils/Resource'; +import type { EntryPoint } from 'views/routes/types'; + +export type PreparedProps = { + moduleList: Resource; + moduleListPromise: Promise; +}; + +// HACK: Cache the promise so that we can feed a constant value to to +// fetch timetable modules after timetables have been fetched from localStorage. +// Typed as unknown because we don't actually need the output +let cachedModuleListPromise: Promise; + +let moduleListResource: Resource; + +const entryPoint: EntryPoint = { + component: JSResource( + 'AppShell', + () => import(/* webpackChunkName: "AppShell" */ 'views/AppShell'), + ), + getPreparedProps(_params, dispatch) { + if (!moduleListResource) { + moduleListResource = createResource( + () => { + cachedModuleListPromise = (async () => { + try { + // TODO: Defer to an idle callback? + return (dispatch(fetchModuleList()) as unknown) as Promise; + } catch (error) { + captureException(error); + throw error; + } + })(); + return cachedModuleListPromise; + }, + () => 'moduleList', + ); + } + + moduleListResource.preload(); + return { + moduleList: moduleListResource, + moduleListPromise: cachedModuleListPromise, + }; + }, +}; + +export default entryPoint; diff --git a/website/src/views/AppShell.tsx b/website/src/views/AppShell.tsx index 4e67b5d66e..a9c56f458d 100644 --- a/website/src/views/AppShell.tsx +++ b/website/src/views/AppShell.tsx @@ -1,189 +1,204 @@ -import React from 'react'; +import React, { Suspense, useCallback, useContext, useEffect } from 'react'; import { SemTimetableConfig, TimetableConfig } from 'types/timetables'; import { ModuleList, NotificationOptions } from 'types/reducers'; import { Semester } from 'types/modules'; import { DARK_MODE, Mode } from 'types/settings'; import { Helmet } from 'react-helmet'; -import { NavLink, RouteComponentProps, withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; import classnames from 'classnames'; import { each } from 'lodash'; +import { useLocation } from 'react-router-dom'; import weekText from 'utils/weekText'; import { captureException } from 'utils/error'; -import { openNotification } from 'actions/app'; -import { fetchModuleList } from 'actions/moduleBank'; -import { fetchTimetableModules, setTimetable, validateTimetable } from 'actions/timetables'; +import { openNotification as openNotificationAction } from 'actions/app'; +import { + fetchTimetableModules as fetchTimetableModulesAction, + validateTimetable as validateTimetableAction, +} from 'actions/timetables'; import Footer from 'views/layout/Footer'; import Navtabs from 'views/layout/Navtabs'; import GlobalSearchContainer from 'views/layout/GlobalSearchContainer'; import Notification from 'views/components/notfications/Notification'; import ErrorBoundary from 'views/errors/ErrorBoundary'; +import { PreloadingNavLink } from 'views/routes/PreloadingLink'; import ErrorPage from 'views/errors/ErrorPage'; import ApiError from 'views/errors/ApiError'; import { trackPageView } from 'bootstrapping/matomo'; import { isIOS } from 'bootstrapping/browser'; import Logo from 'img/nusmods-logo.svg'; import { State as StoreState } from 'types/state'; +import type { Resource } from 'utils/Resource'; import LoadingSpinner from './components/LoadingSpinner'; import FeedbackModal from './components/FeedbackModal'; import KeyboardShortcuts from './components/KeyboardShortcuts'; import styles from './AppShell.scss'; -type Props = RouteComponentProps & { +type Props = { children: React.ReactNode; + // From router + prepared: { + moduleList: Resource; + moduleListPromise: Promise; + }; + // From Redux state moduleList: ModuleList; timetables: TimetableConfig; theme: string; mode: Mode; - activeSemester: Semester; // From Redux actions - fetchModuleList: () => Promise; // Typed as unknown because we don't actually need the output - fetchTimetableModules: (semTimetableConfig: SemTimetableConfig[]) => Promise; - setTimetable: (semester: Semester, semTimetableConfig: SemTimetableConfig) => void; + fetchTimetableModulesProp: (semTimetableConfig: SemTimetableConfig[]) => Promise; validateTimetable: (semester: Semester) => void; openNotification: (str: string, notificationOptions?: NotificationOptions) => void; }; -type State = { - moduleListError?: Error; -}; - -export class AppShellComponent extends React.Component { - state: State = {}; - - componentDidMount() { - const { timetables } = this.props; - - // Retrieve module list - const moduleList = this.fetchModuleList(); - - // Fetch the module data of the existing modules in the timetable and ensure all - // lessons are filled - each(timetables, (timetable, semesterString) => { - const semester = Number(semesterString); - moduleList.then(() => { - // Wait for module list to be fetched before trying to fetch timetable modules - // TODO: There may be a more optimal way to do this - this.fetchTimetableModules(timetable, semester); - }); - }); - - // Enable Matomo analytics - trackPageView(this.props.history); - } - - fetchModuleList = () => - // TODO: This always re-fetch the entire modules list. Consider a better strategy for this - this.props.fetchModuleList().catch((error) => { - captureException(error); - this.setState({ moduleListError: error }); - }); - - fetchTimetableModules = (timetable: SemTimetableConfig, semester: Semester) => { - this.props - .fetchTimetableModules([timetable]) - .then(() => this.props.validateTimetable(semester)) - .catch((error) => { - captureException(error); - this.props.openNotification('Data for some modules failed to load', { - action: { - text: 'Retry', - handler: () => this.fetchTimetableModules(timetable, semester), - }, +export const AppShellComponent: React.FC = ({ + children, + + prepared, + + moduleList, + timetables, + theme, + mode, + + fetchTimetableModulesProp, + validateTimetable, + openNotification, +}) => { + const location = useLocation(); + + // Enable Matomo analytics + // const router = useContext(RoutingContext); + // useEffect(() => { + // if (router) { + // // Unsubscribe when router changes or on unmount + // return trackPageView(router.history); + // } + // return undefined; + // }, [router]); + + const fetchTimetableModules = useCallback( + (timetable: SemTimetableConfig, semester: Semester) => { + fetchTimetableModulesProp([timetable]) + .then(() => validateTimetable(semester)) + .catch((error) => { + captureException(error); + openNotification('Data for some modules failed to load', { + action: { + text: 'Retry', + handler: () => fetchTimetableModules(timetable, semester), + }, + }); + }); + }, + [fetchTimetableModulesProp, openNotification, validateTimetable], + ); + + useEffect( + () => { + // Fetch the module data of the existing modules in the timetable and ensure all + // lessons are filled + // TODO: Defer to an idle callback? + each(timetables, (timetable, semesterString) => { + const semester = Number(semesterString); + prepared.moduleListPromise.then(() => { + // Wait for module list to be fetched before trying to fetch timetable modules + // TODO: There may be a more optimal way to do this + fetchTimetableModules(timetable, semester); }); }); - }; - - render() { - const isModuleListReady = this.props.moduleList.length; - const isDarkMode = this.props.mode === DARK_MODE; - - if (!isModuleListReady && this.state.moduleListError) { - return ; - } - - return ( -
- - - - - - -
- - -
- {isModuleListReady ? ( - }> - {this.props.children} - - ) : ( - - )} -
+ }, + // Only run this once, on component mount. Don't care if props change after mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const isModuleListReady = moduleList.length; + const isDarkMode = mode === DARK_MODE; + + return ( +
+ + + + + + +
+ + +
+ {/* FIXME: Create error page that switches between network errors + and other (unexpected) errors. */} + {isModuleListReady ? ( + } + > + }>{children} + + ) : ( + + )} +
+
- - - + + + - - - + + + - -
- + +
+ - - - -
- ); - } -} + + + +
+ ); +}; const mapStateToProps = (state: StoreState) => ({ moduleList: state.moduleBank.moduleList, timetables: state.timetables.lessons, theme: state.theme.id, mode: state.settings.mode, - activeSemester: state.app.activeSemester, }); const connectedAppShell = connect( mapStateToProps, { - fetchModuleList, - fetchTimetableModules, - setTimetable, - validateTimetable, - openNotification, + fetchTimetableModulesProp: fetchTimetableModulesAction, + validateTimetable: validateTimetableAction, + openNotification: openNotificationAction, }, // TODO: Patch types for Redux for request-middleware // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -193,4 +208,5 @@ const connectedAppShell = connect( // connect implements shouldComponentUpdate based purely on props. If it // is removed, connect not detect prop changes when route is changed and // thus the pages are not re-rendered -export default withRouter(connectedAppShell); +// export default withRouter(connectedAppShell); +export default connectedAppShell; diff --git a/website/src/views/components/KeyboardShortcuts.tsx b/website/src/views/components/KeyboardShortcuts.tsx index 34a7128791..1480729bad 100644 --- a/website/src/views/components/KeyboardShortcuts.tsx +++ b/website/src/views/components/KeyboardShortcuts.tsx @@ -1,32 +1,20 @@ -import * as React from 'react'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; -import { Dispatch } from 'redux'; -import { connect } from 'react-redux'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; import Mousetrap from 'mousetrap'; import { groupBy, map } from 'lodash'; -import { Mode, ThemeId, DARK_MODE } from 'types/settings'; -import { Actions } from 'types/actions'; +import { DARK_MODE } from 'types/settings'; import themes from 'data/themes.json'; import { cycleTheme, toggleTimetableOrientation } from 'actions/theme'; import { openNotification } from 'actions/app'; import { toggleMode } from 'actions/settings'; import { intersperse } from 'utils/array'; import ComponentMap from 'utils/ComponentMap'; -import { State as StoreState } from 'types/state'; +import type { State } from 'types/state'; import Modal from './Modal'; import styles from './KeyboardShortcuts.scss'; -type Props = RouteComponentProps & { - dispatch: Dispatch; - theme: ThemeId; - mode: Mode; -}; - -type State = { - helpShown: boolean; -}; - type Section = 'Appearance' | 'Navigation' | 'Timetable'; const APPEARANCE: Section = 'Appearance'; const NAVIGATION: Section = 'Navigation'; @@ -41,38 +29,52 @@ type KeyBinding = { const THEME_NOTIFICATION_TIMEOUT = 1000; -export class KeyboardShortcutsComponent extends React.PureComponent { - state = { - helpShown: false, - }; +const KeyboardShortcuts: React.FC = () => { + const [helpShown, setHelpShown] = useState(false); + const closeModal = useCallback(() => setHelpShown(false), []); - shortcuts: KeyBinding[] = []; + const mode = useSelector(({ settings }: State) => settings.mode); + const themeId = useSelector(({ theme }: State) => theme.id); + const dispatch = useDispatch(); - componentDidMount() { - const { dispatch, history } = this.props; + const navigate = useNavigate(); + + // NB: Because this is a ref, updates to `shortcuts` will not trigger a render. + const shortcuts = useRef([]); + + useEffect(() => { + function bind( + key: Shortcut, + section: Section, + description: string, + action: (e: Event) => void, + ) { + shortcuts.current.push({ key, description, section }); + Mousetrap.bind(key, action); + } // Navigation - this.bind('y', NAVIGATION, 'Go to today', () => { - history.push('/today'); + bind('y', NAVIGATION, 'Go to today', () => { + navigate('/today'); }); - this.bind('t', NAVIGATION, 'Go to timetable', () => { - history.push('/timetable'); + bind('t', NAVIGATION, 'Go to timetable', () => { + navigate('/timetable'); }); - this.bind('m', NAVIGATION, 'Go to module finder', () => { - history.push('/modules'); + bind('m', NAVIGATION, 'Go to module finder', () => { + navigate('/modules'); }); - this.bind('v', NAVIGATION, 'Go to venues page', () => { - history.push('/venues'); + bind('v', NAVIGATION, 'Go to venues page', () => { + navigate('/venues'); }); - this.bind('s', NAVIGATION, 'Go to settings', () => { - history.push('/settings'); + bind('s', NAVIGATION, 'Go to settings', () => { + navigate('/settings'); }); - this.bind('/', NAVIGATION, 'Open global search', (e) => { + bind('/', NAVIGATION, 'Open global search', (e) => { if (ComponentMap.globalSearchInput) { ComponentMap.globalSearchInput.focus(); @@ -81,16 +83,14 @@ export class KeyboardShortcutsComponent extends React.PureComponent - this.setState((state) => ({ helpShown: !state.helpShown })), - ); + bind('?', NAVIGATION, 'Show this help', () => setHelpShown(!helpShown)); // Timetable shortcuts - this.bind('o', TIMETABLE, 'Switch timetable orientation', () => { + bind('o', TIMETABLE, 'Switch timetable orientation', () => { dispatch(toggleTimetableOrientation()); }); - this.bind('d', TIMETABLE, 'Open download timetable menu', () => { + bind('d', TIMETABLE, 'Open download timetable menu', () => { const button = ComponentMap.downloadButton; if (button) { button.focus(); @@ -99,101 +99,83 @@ export class KeyboardShortcutsComponent extends React.PureComponent { - this.props.dispatch(toggleMode()); + bind('x', APPEARANCE, 'Toggle Night Mode', () => { + dispatch(toggleMode()); dispatch( - openNotification(`Night mode ${this.props.mode === DARK_MODE ? 'on' : 'off'}`, { + openNotification(`Night mode ${mode === DARK_MODE ? 'on' : 'off'}`, { overwritable: true, }), ); }); // Cycle through themes - this.bind('z', APPEARANCE, 'Previous Theme', () => { + function notifyThemeChange() { + const theme = themes.find((t) => t.id === themeId); + if (theme) { + dispatch( + openNotification(`Theme switched to ${theme.name}`, { + timeout: THEME_NOTIFICATION_TIMEOUT, + overwritable: true, + }), + ); + } + } + + bind('z', APPEARANCE, 'Previous Theme', () => { dispatch(cycleTheme(-1)); - this.notifyThemeChange(); + notifyThemeChange(); }); - this.bind('c', APPEARANCE, 'Next Theme', () => { + bind(['c', '0'], APPEARANCE, 'Next Theme', () => { dispatch(cycleTheme(1)); - this.notifyThemeChange(); + notifyThemeChange(); }); // ??? Mousetrap.bind('up up down down left right left right b a', () => { - history.push('/tetris'); + navigate('/tetris'); }); - } - closeModal = () => this.setState({ helpShown: false }); - - bind(key: Shortcut, section: Section, description: string, action: (e: Event) => void) { - this.shortcuts.push({ key, description, section }); - - Mousetrap.bind(key, action); - } + return () => { + shortcuts.current.forEach(({ key }) => Mousetrap.unbind(key)); + shortcuts.current = []; + }; + }, [dispatch, helpShown, mode, navigate, themeId]); - notifyThemeChange() { - const themeId = this.props.theme; - const theme = themes.find((t) => t.id === themeId); - - if (theme) { - this.props.dispatch( - openNotification(`Theme switched to ${theme.name}`, { - timeout: THEME_NOTIFICATION_TIMEOUT, - overwritable: true, - }), - ); - } - } - - renderShortcut = (shortcut: Shortcut): React.ReactNode => { + function renderShortcut(shortcut: Shortcut): React.ReactNode { if (typeof shortcut === 'string') { const capitalized = shortcut.replace(/\b([a-z])/, (c) => c.toUpperCase()); return {capitalized}; } + return intersperse(shortcut.map(renderShortcut), ' or '); + } - return intersperse(shortcut.map(this.renderShortcut), ' or '); - }; - - render() { - const sections = groupBy(this.shortcuts, (shortcut) => shortcut.section); - - return ( - -

Keyboard shortcuts

- - - {map(sections, (shortcuts, heading) => ( - - - - + const sections = groupBy(shortcuts.current, (shortcut) => shortcut.section); - {shortcuts.map((shortcut) => ( - - - - - ))} - - ))} -
- {heading}
{this.renderShortcut(shortcut.key)}{shortcut.description}
-
- ); - } -} + return ( + +

Keyboard shortcuts

-const KeyboardShortcutsConnected = connect((state: StoreState) => ({ - mode: state.settings.mode, - theme: state.theme.id, -}))(KeyboardShortcutsComponent); + + {map(sections, (shortcutsInSection, heading) => ( + + + + + + {shortcutsInSection.map((shortcut) => ( + + + + + ))} + + ))} +
+ {heading}
{renderShortcut(shortcut.key)}{shortcut.description}
+
+ ); +}; -export default withRouter(KeyboardShortcutsConnected); +export default memo(KeyboardShortcuts); diff --git a/website/src/views/components/LinkModuleCodes.test.tsx b/website/src/views/components/LinkModuleCodes.test.tsx index 043eacb549..7bbc09a016 100644 --- a/website/src/views/components/LinkModuleCodes.test.tsx +++ b/website/src/views/components/LinkModuleCodes.test.tsx @@ -59,7 +59,7 @@ describe(LinkModuleCodesComponent, () => { test('should convert module codes to links', () => { const component = create('CS3216, CS1010FC, ACC1002, BMA 5000A', testModules); - const links = component.find('Link'); + const links = component.find('PreloadingLink'); expect(links).toHaveLength(4); const moduleEntries = entries(testModules); @@ -85,7 +85,7 @@ describe(LinkModuleCodesComponent, () => { 'CS1010FCThis teCS1010FCxt contains module codes in wordsACC1010FC', testModules, ); - expect(component.find('Link')).toHaveLength(0); + expect(component.find('PreloadingLink')).toHaveLength(0); }); test('should ignore modules that are not available', () => { @@ -97,8 +97,8 @@ describe(LinkModuleCodesComponent, () => { }, }); - expect(component.find('Link')).toHaveLength(1); - const ACC1002 = component.find('Link').at(0); + expect(component.find('PreloadingLink')).toHaveLength(1); + const ACC1002 = component.find('PreloadingLink').at(0); expect(ACC1002.text()).toEqual('ACC1002'); }); }); diff --git a/website/src/views/components/LinkModuleCodes.tsx b/website/src/views/components/LinkModuleCodes.tsx index 828f30b74c..51f55d8f97 100644 --- a/website/src/views/components/LinkModuleCodes.tsx +++ b/website/src/views/components/LinkModuleCodes.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import { ModuleCode, ModuleCondensed } from 'types/modules'; import { State } from 'types/state'; @@ -35,9 +35,9 @@ export const LinkModuleCodesComponent: React.FC = (props) => { return ( - + {part} - + ); }); diff --git a/website/src/views/components/ScrollToTop.tsx b/website/src/views/components/ScrollToTop.tsx index 104ed4ef61..2c69c4096d 100644 --- a/website/src/views/components/ScrollToTop.tsx +++ b/website/src/views/components/ScrollToTop.tsx @@ -1,50 +1,44 @@ -import * as React from 'react'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; +import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import { scrollToHash } from 'utils/react'; -export type Props = RouteComponentProps & { - onComponentDidMount?: boolean; - onPathChange?: boolean; - scrollToHash?: boolean; -}; - function scrollToTop() { window.scrollTo(0, 0); } -export class ScrollToTopComponent extends React.Component { - static defaultProps = { - onComponentDidMount: false, - onPathChange: false, - scrollToHash: true, - }; - - componentDidMount() { - if (this.props.onComponentDidMount && !window.location.hash) { - scrollToTop(); - } else if (this.props.scrollToHash) { - scrollToHash(); - } - } - - componentDidUpdate(prevProps: Props) { - const { - onPathChange, - location: { pathname, hash }, - } = this.props; +export type Props = { + onComponentDidMount?: boolean; + onPathChange?: boolean; + shouldScrollToHash?: boolean; +}; - if ( - onPathChange && - pathname !== prevProps.location.pathname && - hash === prevProps.location.hash - ) { +const ScrollToTop: React.FC = ({ + onComponentDidMount = false, + onPathChange = false, + shouldScrollToHash = true, +}) => { + useEffect( + () => { + if (onComponentDidMount && !window.location.hash) { + scrollToTop(); + } else if (shouldScrollToHash) { + scrollToHash(); + } + }, + // This effect should only be run on component mount; don't care if props + // change afterwards. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const location = useLocation(); + useEffect(() => { + if (onPathChange) { scrollToTop(); } - } + }, [onPathChange, location.pathname, location.hash]); - render() { - return null; - } -} + return null; +}; -export default withRouter(ScrollToTopComponent); +export default ScrollToTop; diff --git a/website/src/views/components/map/venueLocationResource.ts b/website/src/views/components/map/venueLocationResource.ts new file mode 100644 index 0000000000..a89b99acf2 --- /dev/null +++ b/website/src/views/components/map/venueLocationResource.ts @@ -0,0 +1,10 @@ +import { createResource } from 'utils/Resource'; +import { getVenueLocations } from 'apis/github'; +import type { VenueLocationMap } from 'types/venues'; + +// FIXME: Disable getVenueLocations' built in promise memoization as it's +// already memoized in Resource. Breaks resource reloads. +export default createResource( + () => getVenueLocations(), + () => 'venueLocationResource', +); diff --git a/website/src/views/components/map/withVenueLocations.tsx b/website/src/views/components/map/withVenueLocations.tsx index c716bcc39f..e39fa99c84 100644 --- a/website/src/views/components/map/withVenueLocations.tsx +++ b/website/src/views/components/map/withVenueLocations.tsx @@ -30,6 +30,7 @@ const defaultLoadingComponent = () => ; * Defaults to * @param Loading Component shown while the data is loading * Defaults to + * @deprecated Use `venueLocationResource` instead. */ export default function withVenueLocations( getComponent: () => Promise>, diff --git a/website/src/views/components/module-info/LessonTimetable.tsx b/website/src/views/components/module-info/LessonTimetable.tsx index 024c98ef17..d66ab3eb24 100644 --- a/website/src/views/components/module-info/LessonTimetable.tsx +++ b/website/src/views/components/module-info/LessonTimetable.tsx @@ -73,4 +73,5 @@ export class LessonTimetableComponent extends React.PureComponent } } -export default withRouter(LessonTimetableComponent); +// export default withRouter(LessonTimetableComponent); +export default LessonTimetableComponent; diff --git a/website/src/views/components/notfications/HacktoberfestBanner.tsx b/website/src/views/components/notfications/HacktoberfestBanner.tsx index 2a8cb472ce..caf736bcc0 100644 --- a/website/src/views/components/notfications/HacktoberfestBanner.tsx +++ b/website/src/views/components/notfications/HacktoberfestBanner.tsx @@ -1,6 +1,6 @@ import React from 'react'; import classnames from 'classnames'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import type { EmptyProps } from 'types/utils'; import storage from 'storage'; @@ -45,9 +45,9 @@ export default class HacktoberfestBanner extends React.PureComponent
- + Find out more - +
diff --git a/website/src/views/components/notfications/ModRegNotification.tsx b/website/src/views/components/notfications/ModRegNotification.tsx index a64e469e82..4e52586fb9 100644 --- a/website/src/views/components/notfications/ModRegNotification.tsx +++ b/website/src/views/components/notfications/ModRegNotification.tsx @@ -95,5 +95,6 @@ const withStoreModRegNotification = connect(mapStateToProps, { openNotification, })(React.memo(ModRegNotificationComponent)); -const ModRegNotification = withRouter(withStoreModRegNotification); +// const ModRegNotification = withRouter(withStoreModRegNotification); +const ModRegNotification = withStoreModRegNotification; export default ModRegNotification; diff --git a/website/src/views/contribute/ContributeContainer/ContributeContainer.tsx b/website/src/views/contribute/ContributeContainer/ContributeContainer.tsx index 92d11a6ee9..37e9e92c90 100644 --- a/website/src/views/contribute/ContributeContainer/ContributeContainer.tsx +++ b/website/src/views/contribute/ContributeContainer/ContributeContainer.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { connect } from 'react-redux'; import classnames from 'classnames'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import { flatten, map, mapValues, values } from 'lodash'; import { ModuleCondensed } from 'types/modules'; @@ -83,14 +83,14 @@ const ContributeContainer = React.memo(({ modules, beta, ...props }) => (

{config.semesterNames[semester]}

{moduleCondensed.map(({ moduleCode, title }) => ( - Review {moduleCode} {title} - + ))}
@@ -145,8 +145,8 @@ const ContributeContainer = React.memo(({ modules, beta, ...props }) => (

- Go to settings if you wish to stop using NUSMods - Beta. + Go to settings if you wish to + stop using NUSMods Beta.

) : ( @@ -297,9 +297,9 @@ const ContributeContainer = React.memo(({ modules, beta, ...props }) => (

- + View all contributors → - +

diff --git a/website/src/views/errors/ModuleNotFoundPage.test.tsx b/website/src/views/errors/ModuleNotFoundPage.test.tsx index 937f10772d..7b4ebdd2fc 100644 --- a/website/src/views/errors/ModuleNotFoundPage.test.tsx +++ b/website/src/views/errors/ModuleNotFoundPage.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { shallow } from 'enzyme'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'react-router-dom'; import LoadingSpinner from 'views/components/LoadingSpinner'; import { moduleArchive } from 'views/routes/paths'; import { ModuleNotFoundPageComponent } from './ModuleNotFoundPage'; @@ -75,7 +75,7 @@ test('should suggest archive pages if they are available', () => { />, ); - const links = wrapper.find(Link).map((link) => link.prop('to')); + const links = wrapper.find(PreloadingLink).map((link) => link.prop('to')); expect(links).toEqual( expect.arrayContaining([ diff --git a/website/src/views/errors/ModuleNotFoundPage.tsx b/website/src/views/errors/ModuleNotFoundPage.tsx index 719c5fc5d9..aabd8648c4 100644 --- a/website/src/views/errors/ModuleNotFoundPage.tsx +++ b/website/src/views/errors/ModuleNotFoundPage.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import { connect } from 'react-redux'; import classnames from 'classnames'; import * as Sentry from '@sentry/browser'; @@ -63,21 +63,21 @@ export class ModuleNotFoundPageComponent extends React.PureComponent {
{this.props.availableArchive.map((year) => ( - AY {year} archive - + ))}

Otherwise, if this is not what you are looking for,{' '} - go back to nusmods.com or{' '} - try the module finder. + go back to nusmods.com or{' '} + try the module finder.

) : ( @@ -103,9 +103,9 @@ export class ModuleNotFoundPageComponent extends React.PureComponent { > {moduleCode} should be here - + Bring me home - +
)} diff --git a/website/src/views/hooks/useMemoCompare.ts b/website/src/views/hooks/useMemoCompare.ts new file mode 100644 index 0000000000..606063c621 --- /dev/null +++ b/website/src/views/hooks/useMemoCompare.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react'; + +// Source: https://usehooks.com/useMemoCompare/ +export default function useMemoCompare( + next: T, + compare: (previousValue: T | undefined, proposedNextValue: T) => boolean, +): T { + // Ref for storing previous value + const previousRef = useRef(); + const previous = previousRef.current; + + // Pass previous and next value to compare function + // to determine whether to consider them equal. + const equal = compare(previous, next); + + // If not equal update previousRef to next value. + // We only update if not equal (or if never been set) so that this hook + // continues to return the same old value if compare keeps returning true. + useEffect(() => { + if (!equal || previousRef.current === undefined) { + previousRef.current = next; + } + }); + + return !equal || previous === undefined ? next : previous; +} diff --git a/website/src/views/layout/Footer.tsx b/website/src/views/layout/Footer.tsx index 1badba93d5..07fb3459f7 100644 --- a/website/src/views/layout/Footer.tsx +++ b/website/src/views/layout/Footer.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import ExternalLink from 'views/components/ExternalLink'; import config from 'config'; @@ -61,41 +61,41 @@ export const FooterComponent: React.FC = (props) => { API
  • - Apps + Apps
  • - About + About
  • - Team + Team
  • - Contributors + Contributors
  • - FAQ + FAQ
  • {/* new Date().getMonth() === 9 && (
  • - + Hacktoberfest! - +
  • ) */}
  • - + Contribute to NUSMods! - +
  • {lastUpdatedText}

    Designed and built with all the love in the world by{' '} @nusmodifications. Maintained - by the core team with the help of{' '} - our contributors. + by the core team with the help of{' '} + our contributors.

    Copyright © 2014 - Present, NUSModifications. All rights reserved. {versionSpan}

    diff --git a/website/src/views/layout/GlobalSearchContainer.tsx b/website/src/views/layout/GlobalSearchContainer.tsx index bb3cfd8e07..9c2060e609 100644 --- a/website/src/views/layout/GlobalSearchContainer.tsx +++ b/website/src/views/layout/GlobalSearchContainer.tsx @@ -106,7 +106,8 @@ export class SearchContainerComponent extends React.Component { } } -const routedSearchContainer = withRouter(SearchContainerComponent); +// const routedSearchContainer = withRouter(SearchContainerComponent); +const routedSearchContainer = SearchContainerComponent; const connectedSearchContainer = connect( (state: State) => ({ moduleList: state.moduleBank.moduleList, diff --git a/website/src/views/layout/Navtabs.tsx b/website/src/views/layout/Navtabs.tsx index bde2fb499a..11a9436d7c 100644 --- a/website/src/views/layout/Navtabs.tsx +++ b/website/src/views/layout/Navtabs.tsx @@ -1,92 +1,94 @@ -import * as React from 'react'; +import React, { memo } from 'react'; import { connect } from 'react-redux'; -import { NavLink, RouteComponentProps, withRouter } from 'react-router-dom'; import classnames from 'classnames'; import { BookOpen, Calendar, Clock, Heart, Map, Settings, Star, Trello } from 'react-feather'; import { Semester } from 'types/modules'; import ExternalLink from 'views/components/ExternalLink'; import { timetablePage } from 'views/routes/paths'; -import { preload as preloadToday } from 'views/today/TodayContainer'; import { preload as preloadVenues } from 'views/venues/VenuesContainer'; import { preload as preloadContribute } from 'views/contribute/ContributeContainer'; +import { PreloadingNavLink } from 'views/routes/PreloadingLink'; import { State } from 'types/state'; import styles from './Navtabs.scss'; export const NAVTAB_HEIGHT = 48; -type Props = RouteComponentProps & { +type Props = { activeSemester: Semester; beta: boolean; }; -export const NavtabsComponent: React.FC = (props) => { - const tabProps = { - className: styles.link, - activeClassName: styles.linkActive, - }; +const tabProps = { + className: styles.link, + activeClassName: styles.linkActive, +}; - return ( - +)); const connectedNavtabs = connect((state: State) => ({ activeSemester: state.app.activeSemester, beta: !!state.settings.beta, }))(NavtabsComponent); -export default withRouter(connectedNavtabs); +export default connectedNavtabs; diff --git a/website/src/views/modules/ModuleArchive.entrypoint.ts b/website/src/views/modules/ModuleArchive.entrypoint.ts new file mode 100644 index 0000000000..6778de4f50 --- /dev/null +++ b/website/src/views/modules/ModuleArchive.entrypoint.ts @@ -0,0 +1,70 @@ +import type { Params } from 'react-router'; +import { fetchModuleArchive } from 'actions/moduleBank'; +import { captureException } from 'utils/error'; +import { Resource, createResource } from 'utils/Resource'; +import { JSResource } from 'utils/JSResource'; +import type { Module, ModuleCode } from 'types/modules'; +import type { EntryPoint } from 'views/routes/types'; + +type ModuleArchiveResource = Resource< + { moduleCode: ModuleCode; archiveYear: string }, + string, + Module +>; + +export type PreparedProps = { + moduleResource: ModuleArchiveResource; + moduleCode: ModuleCode; + archiveYear: string; +}; + +const getPropsFromParams = (params: Params) => { + const { archiveYear = '', moduleCode = '' } = params; + return { + moduleCode: moduleCode.toUpperCase(), + archiveYear: archiveYear.replace('-', '/'), + }; +}; + +let moduleResource: ModuleArchiveResource; + +/** + * This is very similar to ModulePage.entrypoint except it is used for the + * archive page, so it uses different code paths for data fetching. + */ +const entryPoint: EntryPoint = { + component: JSResource( + 'ModuleArchiveContainer', + () => import(/* webpackChunkName: "ModuleArchiveContainer" */ './ModuleArchiveContainer'), + ), + getPreparedProps(params, dispatch) { + if (!moduleResource) { + moduleResource = createResource( + ({ moduleCode, archiveYear }) => + dispatch(fetchModuleArchive(moduleCode, archiveYear)).catch((error) => { + captureException(error); + // TODO: If there is an error but module data can still be found, we + // can assume module has been loaded at some point, so we can just show + // that instead + throw error; + }), + ({ moduleCode, archiveYear }) => + `ModuleArchiveContainer-module-${moduleCode}-${archiveYear}`, + ); + } + + const { moduleCode, archiveYear } = getPropsFromParams(params); + moduleResource.preload({ moduleCode, archiveYear }); + return { + moduleResource, + moduleCode, + archiveYear, + }; + }, + disposePreparedProps(params) { + const { moduleCode, archiveYear } = getPropsFromParams(params); + moduleResource.invalidate({ moduleCode, archiveYear }); + }, +}; + +export default entryPoint; diff --git a/website/src/views/modules/ModuleArchiveContainer.test.tsx b/website/src/views/modules/ModuleArchiveContainer.test.tsx index f33f44f3e1..43b142b3f5 100644 --- a/website/src/views/modules/ModuleArchiveContainer.test.tsx +++ b/website/src/views/modules/ModuleArchiveContainer.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { Redirect } from 'react-router-dom'; +import { Navigate } from 'react-router-dom'; import { Module, ModuleCode } from 'types/modules'; import ModuleNotFoundPage from 'views/errors/ModuleNotFoundPage'; @@ -36,7 +36,7 @@ function make(moduleCode: ModuleCode, url: string, options: Partial; + moduleCode: ModuleCode; + archiveYear: string; }; -type OwnProps = RouteComponentProps; - -type DispatchProps = { - fetchModule: () => Promise; -}; +type Props = EntryPointComponentProps; -type Props = OwnProps & - DispatchProps & { - module: Module | null; - moduleCode: ModuleCode; - archiveYear: string; - }; +// TODO: Generalize this smart error fallback for other error boundaries +const ErrorFallback: React.FC<{ + error: Error; + moduleCode: string; +}> = ({ error, moduleCode }) => { + if (get(error, ['response', 'status'], 200) === 404) { + return ; + } -type State = { - ModulePageContent: React.ComponentType | null; - error?: Error; + // TODO: Handle other errors; we can't just assume everything's a load error + return ; }; /** * Wrapper component for the archive page that handles data fetching and error handling. * This is very similar to ModulePageContainer except it is used for the archive - * page, so it uses different code paths for canonical URL, data fetching and - * error handling - the normal page tries to check the archives if this year's - * API returns 404, while this page doesn't. + * page, so it uses different code paths for canonical URL and error handling - + * the normal page tries to check the archives if this year's API returns 404, + * while this page doesn't. */ -export class ModuleArchiveContainerComponent extends React.PureComponent { - state: State = { - ModulePageContent: null, - }; - - componentDidMount() { - this.fetchModule(); - this.fetchPageImport(); - } - - componentDidUpdate(prevProps: Props) { - if ( - prevProps.moduleCode !== this.props.moduleCode || - prevProps.archiveYear !== this.props.archiveYear - ) { - this.fetchModule(); - } - } - - fetchModule = () => { - this.setState({ error: undefined }); - this.props.fetchModule().catch(this.handleFetchError); - }; - - fetchPageImport() { - // Try importing ModulePageContent thrice if we're online and - // getting the "Loading chunk x failed." error. - retryImport(() => import(/* webpackChunkName: "module" */ 'views/modules/ModulePageContent')) - .then((module) => this.setState({ ModulePageContent: module.default })) - .catch(this.handleFetchError); - } - - handleFetchError = (error: AxiosError) => { - this.setState({ error }); - captureException(error); - }; - - render() { - const { ModulePageContent, error } = this.state; - const { module, moduleCode, match, location, archiveYear } = this.props; - - if (get(error, ['response', 'status'], 200) === 404) { - return ; +export const ModuleArchiveContainerComponent: React.FC = ({ + prepared: { moduleResource, moduleCode, archiveYear }, +}) => { + const location = useLocation(); + const navigate = useNavigate(); + + // If module already exists within our Redux store, use it instead of the + // preloaded resource (which writes to the Redux store anyway). + const module = useSelector((state: State) => + get(state.moduleBank.moduleArchive, [moduleCode, archiveYear], undefined), + ); + + useEffect(() => { + // Navigate to canonical URL + const canonicalUrl = moduleArchive(moduleCode, archiveYear, module?.title); + if (module && location.pathname !== canonicalUrl) { + navigate({ ...location, pathname: canonicalUrl }, { replace: true }); } - - // If there is an error but module data can still be found, we assume module has - // been loaded at some point, so we just show that instead - if (error && !module) { - return ; - } - - // Redirect to canonical URL - if (module) { - const canonicalUrl = moduleArchive(moduleCode, archiveYear, module.title); - if (match.url !== canonicalUrl) { - return ; - } - } - - if (module && ModulePageContent) { - // Unique key forces component to remount whenever the user moves to - // a new module. This allows the internal state (eg. currently selected - // timetable semester) of to be consistent - return ( + }, [archiveYear, location, module, moduleCode, navigate]); + + return ( + } + > + }> - ); - } - - return ; - } -} - -const getPropsFromMatch = (match: Match) => { - const { year = '', moduleCode = '' } = match.params; - return { - moduleCode: moduleCode.toUpperCase(), - year: year.replace('-', '/'), - }; -}; - -const mapStateToProps = ({ moduleBank }: StoreState, ownProps: OwnProps) => { - const { moduleCode, year } = getPropsFromMatch(ownProps.match); - return { - moduleCode, - archiveYear: year, - module: get(moduleBank.moduleArchive, [moduleCode, year], null), - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => { - const { moduleCode, year } = getPropsFromMatch(ownProps.match); - return { - fetchModule: () => dispatch(fetchModuleArchive(moduleCode, year)), - }; + + + ); }; -const connectedModuleArchiveContainer = connect( - mapStateToProps, - // Cast required because the version of Dispatch defined by connect does not have the extensions defined - // in our Dispatch - mapDispatchToProps as MapDispatchToPropsNonObject, -)(ModuleArchiveContainerComponent); -const routedModuleArchiveContainer = withRouter(connectedModuleArchiveContainer); -export default deferComponentRender(routedModuleArchiveContainer); +export default ModuleArchiveContainerComponent; diff --git a/website/src/views/modules/ModuleFinder.entrypoint.ts b/website/src/views/modules/ModuleFinder.entrypoint.ts new file mode 100644 index 0000000000..123420a30c --- /dev/null +++ b/website/src/views/modules/ModuleFinder.entrypoint.ts @@ -0,0 +1,16 @@ +import { JSResource } from 'utils/JSResource'; +import { EntryPoint } from 'views/routes/types'; + +export type PreparedProps = unknown; + +const entryPoint: EntryPoint = { + component: JSResource( + 'ModuleFinderContainer', + () => import(/* webpackChunkName: "ModuleFinderContainer" */ './ModuleFinderContainer'), + ), + getPreparedProps() { + return {}; + }, +}; + +export default entryPoint; diff --git a/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.tsx b/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.tsx index 2adbef8866..6e3349769d 100644 --- a/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.tsx +++ b/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.tsx @@ -13,6 +13,7 @@ import { import classnames from 'classnames'; import { hot } from 'react-hot-loader/root'; +import type { EntryPointComponentProps } from 'views/routes/types'; import { ElasticSearchResult } from 'types/vendor/elastic-search'; import { ModuleInformation } from 'types/modules'; @@ -30,6 +31,8 @@ import { HIGHLIGHT_OPTIONS } from 'utils/elasticSearch'; import config from 'config'; import styles from './ModuleFinderContainer.scss'; +type Props = EntryPointComponentProps; + const esIndex = 'modules_v2'; const esHostUrl = `${forceElasticsearchHost() || config.elasticsearchBaseUrl}/${esIndex}`; const searchkit = new SearchkitManager(esHostUrl, { @@ -56,7 +59,7 @@ const ModuleInformationListComponent: React.FC = ({ hits }) => ( ); -const ModuleFinderContainer: React.FC = () => { +const ModuleFinderContainer: React.FC = () => { return (
    {pageHead} diff --git a/website/src/views/modules/ModuleFinderItem.tsx b/website/src/views/modules/ModuleFinderItem.tsx index 172e21979e..7324e7fc87 100644 --- a/website/src/views/modules/ModuleFinderItem.tsx +++ b/website/src/views/modules/ModuleFinderItem.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import classnames from 'classnames'; import { ModuleInformation } from 'types/modules'; @@ -32,7 +32,7 @@ const ModuleFinderItem: React.FC = ({ module, highlight = {} }) => {

    - + = ({ module, highlight = {} }) => { - +

    {intersperse( diff --git a/website/src/views/modules/ModulePage.entrypoint.ts b/website/src/views/modules/ModulePage.entrypoint.ts new file mode 100644 index 0000000000..ce36e5395d --- /dev/null +++ b/website/src/views/modules/ModulePage.entrypoint.ts @@ -0,0 +1,55 @@ +import type { Params } from 'react-router'; +import { fetchModule } from 'actions/moduleBank'; +import { captureException } from 'utils/error'; +import { Resource, createResource } from 'utils/Resource'; +import { JSResource } from 'utils/JSResource'; +import type { Module, ModuleCode } from 'types/modules'; +import type { EntryPoint } from 'views/routes/types'; + +type ModuleResource = Resource<{ moduleCode: ModuleCode }, string, Module>; + +export type PreparedProps = { + moduleResource: ModuleResource; + moduleCode: ModuleCode; +}; + +const getPropsFromParams = (params: Params) => ({ + moduleCode: (params.moduleCode ?? '').toUpperCase(), +}); + +let moduleResource: ModuleResource; + +const entryPoint: EntryPoint = { + component: JSResource( + 'ModulePageContainer', + () => import(/* webpackChunkName: "ModulePageContainer" */ './ModulePageContainer'), + ), + getPreparedProps(params, dispatch) { + if (!moduleResource) { + moduleResource = createResource( + ({ moduleCode }) => + dispatch(fetchModule(moduleCode)).catch((error) => { + captureException(error); + // TODO: If there is an error but module data can still be found, we + // can assume module has been loaded at some point, so we can just show + // that instead + throw error; + }), + ({ moduleCode }) => `ModulePageContainer-module-${moduleCode}`, + ); + } + + const { moduleCode } = getPropsFromParams(params); + moduleResource.preload({ moduleCode }); + return { + moduleResource, + moduleCode, + }; + }, + disposePreparedProps(params) { + const { moduleCode } = getPropsFromParams(params); + moduleResource.invalidate({ moduleCode }); + }, +}; + +export default entryPoint; diff --git a/website/src/views/modules/ModulePageContainer.test.tsx b/website/src/views/modules/ModulePageContainer.test.tsx index 8a736b98c4..2d0b6956d0 100644 --- a/website/src/views/modules/ModulePageContainer.test.tsx +++ b/website/src/views/modules/ModulePageContainer.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { Redirect } from 'react-router-dom'; +import { Navigate } from 'react-router-dom'; import { Module, ModuleCode } from 'types/modules'; import ModuleNotFoundPage from 'views/errors/ModuleNotFoundPage'; @@ -33,7 +33,7 @@ function make(moduleCode: ModuleCode, url: string, options: Partial; + moduleCode: ModuleCode; }; -type OwnProps = RouteComponentProps; +type Props = EntryPointComponentProps; -type DispatchProps = { - fetchModule: () => Promise; -}; - -type Props = OwnProps & - DispatchProps & { - module: Module | null; - moduleCode: ModuleCode; - fetchModule: () => Promise; - }; +// TODO: Generalize this smart error fallback for other error boundaries +const ErrorFallback: React.FC<{ + error: Error; + moduleCode: ModuleCode; +}> = ({ error, moduleCode }) => { + if (get(error, ['response', 'status'], 200) === 404) { + return ; + } -type State = { - ModulePageContent: React.ComponentType | null; - error?: Error; + // TODO: Handle other errors; we can't just assume everything's a load error + return ; }; /** + * TODO: Update docstring * Wrapper component that loads both module data and the module page component * simultaneously, and displays the correct component depending on the state. * @@ -55,94 +53,41 @@ type State = { * - Loading: Either requests are pending * - Loaded: Both requests are successfully loaded */ -export class ModulePageContainerComponent extends React.PureComponent { - state: State = { - ModulePageContent: null, - }; - - componentDidMount() { - this.fetchModule(); - this.fetchPageImport(); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.moduleCode !== this.props.moduleCode) { - this.fetchModule(); - } - } - - fetchModule = () => { - this.setState({ error: undefined }); - this.props.fetchModule().catch(this.handleFetchError); - }; - - fetchPageImport() { - // Try importing ModulePageContent thrice if we're online and - // getting the "Loading chunk x failed." error. - retryImport(() => import(/* webpackChunkName: "module" */ 'views/modules/ModulePageContent')) - .then((module) => this.setState({ ModulePageContent: module.default })) - .catch(this.handleFetchError); - } - - handleFetchError = (error: AxiosError) => { - this.setState({ error }); - captureException(error); - }; - - render() { - const { ModulePageContent, error } = this.state; - const { module, moduleCode, match, location } = this.props; - - if (get(error, ['response', 'status'], 200) === 404) { - return ; - } - - // If there is an error but module data can still be found, we assume module has - // been loaded at some point, so we just show that instead - if (error && !module) { - return ; - } - - // Redirect to canonical URL - if (module && match.url !== modulePage(moduleCode, module.title)) { - return ; - } - - if (module && ModulePageContent) { - // Unique key forces component to remount whenever the user moves to - // a new module. This allows the internal state (eg. currently selected - // timetable semester) of to be consistent - return ; +export const ModulePageContainerComponent: React.FC = ({ + prepared: { moduleResource, moduleCode }, +}) => { + const location = useLocation(); + const navigate = useNavigate(); + + // If module already exists within our Redux store, use it instead of the + // preloaded resource (which writes to the Redux store anyway). + const module = useSelector((state: State) => state.moduleBank.modules[moduleCode]); + + useEffect(() => { + // Navigate to canonical URL + const canonicalUrl = modulePage(moduleCode, module?.title); + if (module && location.pathname !== canonicalUrl) { + navigate({ ...location, pathname: canonicalUrl }, { replace: true }); } - - return ; - } -} - -const getPropsFromMatch = (match: Match) => ({ - moduleCode: (match.params.moduleCode ?? '').toUpperCase(), -}); - -const mapStateToProps = ({ moduleBank }: StoreState, ownProps: OwnProps) => { - const { moduleCode } = getPropsFromMatch(ownProps.match); - return { - moduleCode, - module: moduleBank.modules[moduleCode], - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => { - const { moduleCode } = getPropsFromMatch(ownProps.match); - return { - fetchModule: () => dispatch(fetchModule(moduleCode)), - }; + }, [location, module, moduleCode, navigate]); + + // Unique key forces component to remount whenever the user moves to + // a new module. This allows the internal state (eg. currently selected + // timetable semester) of to be consistent + return ( + } + > + }> + + + + ); }; -const connectedModulePageContainer = connect( - mapStateToProps, - // Cast required because the version of Dispatch defined by connect does not have the extensions defined - // in our Dispatch - mapDispatchToProps as MapDispatchToPropsNonObject, -)(ModulePageContainerComponent); -const routedModulePageContainer = withRouter(connectedModulePageContainer); -export default deferComponentRender(routedModulePageContainer); +export default ModulePageContainerComponent; diff --git a/website/src/views/modules/ModulePageContent.tsx b/website/src/views/modules/ModulePageContent.tsx index 5ad3851515..c7674a0474 100644 --- a/website/src/views/modules/ModulePageContent.tsx +++ b/website/src/views/modules/ModulePageContent.tsx @@ -4,7 +4,7 @@ import ScrollSpy from 'react-scrollspy'; import { kebabCase, map, mapValues, values, sortBy } from 'lodash'; import { hot } from 'react-hot-loader/root'; -import { Module, NUSModuleAttributes, attributeDescription } from 'types/modules'; +import { Module, NUSModuleAttributes, attributeDescription, ModuleCode } from 'types/modules'; import config from 'config'; import { getSemestersOffered, isOffered, renderMCs } from 'utils/modules'; @@ -29,12 +29,17 @@ import Title from 'views/components/Title'; import ScrollToTop from 'views/components/ScrollToTop'; import { Archive, Check } from 'react-feather'; import ErrorBoundary from 'views/errors/ErrorBoundary'; +import type { Resource } from 'utils/Resource'; import styles from './ModulePageContent.scss'; import ReportError from './ReportError'; export type Props = { - module: Module; + /** Module, if we already have it. */ + module: Module | undefined; + /** Resource to suspend on if `module` is falsy. */ + moduleResource: Resource<{ moduleCode: ModuleCode; archiveYear?: string }, string, Module>; + moduleCode: ModuleCode; archiveYear?: string; }; @@ -59,8 +64,9 @@ class ModulePageContent extends React.Component { toggleMenu = (isMenuOpen: boolean) => this.setState({ isMenuOpen }); render() { - const { module, archiveYear } = this.props; - const { moduleCode, title } = module; + const { module: moduleProp, moduleResource, moduleCode, archiveYear } = this.props; + const module = moduleProp ?? moduleResource.read({ moduleCode, archiveYear }); + const { title } = module; const pageTitle = `${moduleCode} ${title}`; const semesters = getSemestersOffered(module); @@ -82,7 +88,7 @@ class ModulePageContent extends React.Component { - + {isArchive && (

    diff --git a/website/src/views/planner/PlannerModule.tsx b/website/src/views/planner/PlannerModule.tsx index c73e56cb7c..6eaf8795c2 100644 --- a/website/src/views/planner/PlannerModule.tsx +++ b/website/src/views/planner/PlannerModule.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Draggable } from 'react-beautiful-dnd'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import { format } from 'date-fns'; import classnames from 'classnames'; @@ -139,7 +139,7 @@ const PlannerModule = React.memo((props) => { {moduleCode || 'Select Module'} {' '} {moduleCode && moduleTitle && ( - {moduleTitle} + {moduleTitle} )} ); @@ -193,9 +193,9 @@ const PlannerModule = React.memo((props) => { ) : ( moduleCode && ( - + {moduleCode} {moduleTitle} - + ) )}
    diff --git a/website/src/views/routes/EntryPointContainer.tsx b/website/src/views/routes/EntryPointContainer.tsx new file mode 100644 index 0000000000..6e3eb9ef79 --- /dev/null +++ b/website/src/views/routes/EntryPointContainer.tsx @@ -0,0 +1,38 @@ +import React, { memo, useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { Outlet, useParams } from 'react-router-dom'; +import { isEqual } from 'lodash'; +import useMemoCompare from 'views/hooks/useMemoCompare'; +import type { Dispatch } from 'types/redux'; +import type { EntryPoint } from './types'; + +type Props = { + entryPoint: EntryPoint; +}; + +const EntryPointContainer: React.FC = ({ entryPoint }) => { + const EntryPointComponent = entryPoint.component.read(); + const params = useParams(); + const dispatch = useDispatch(); + + // Use a memoized copy of params so that we don't cause unnecessary + // renders/disposes if params didn't actually change. + const stableParams = useMemoCompare(params, isEqual); + + const prepared = useMemo(() => { + return entryPoint.getPreparedProps(stableParams, dispatch); + }, [stableParams, entryPoint, dispatch]); + + // Dispose of any prepared props if they're no longer usable. + useEffect(() => { + return () => entryPoint.disposePreparedProps?.(stableParams); + }, [entryPoint, prepared, stableParams]); + + return ( + + + + ); +}; + +export default memo(EntryPointContainer); diff --git a/website/src/views/routes/PreloadingLink.tsx b/website/src/views/routes/PreloadingLink.tsx new file mode 100644 index 0000000000..c453438c06 --- /dev/null +++ b/website/src/views/routes/PreloadingLink.tsx @@ -0,0 +1,42 @@ +import React, { forwardRef, useCallback, useContext } from 'react'; +import { LinkProps, Link, NavLink, NavLinkProps } from 'react-router-dom'; +import { RoutePreloaderContext } from './RoutePreloaderContext'; + +function usePreloadCallbacks({ onMouseOver, onFocus, to }: LinkProps | NavLinkProps) { + const preloaderContext = useContext(RoutePreloaderContext); + if (!preloaderContext) { + throw new Error('Preloading link components cannot be used outside RoutePreloaderContext.'); + } + const preloadCode = useCallback(() => preloaderContext.preloadCode(to), [preloaderContext, to]); + + const combinedOnMouseOver = useCallback( + (e) => { + onMouseOver?.(e); + preloadCode(); + }, + [onMouseOver, preloadCode], + ); + const combinedOnFocus = useCallback( + (e) => { + onFocus?.(e); + preloadCode(); + }, + [onFocus, preloadCode], + ); + + return { onMouseOver: combinedOnMouseOver, onFocus: combinedOnFocus }; +} + +export const PreloadingLink = forwardRef( + function PreloadingLinkComponent(props, ref) { + const preloadCallbacks = usePreloadCallbacks(props); + return ; + }, +); + +export const PreloadingNavLink = forwardRef( + function PreloadingNavLinkComponent(props, ref) { + const preloadCallbacks = usePreloadCallbacks(props); + return ; + }, +); diff --git a/website/src/views/routes/RoutePreloaderContext.tsx b/website/src/views/routes/RoutePreloaderContext.tsx new file mode 100644 index 0000000000..65af39208d --- /dev/null +++ b/website/src/views/routes/RoutePreloaderContext.tsx @@ -0,0 +1,76 @@ +import React, { createContext, useCallback } from 'react'; +import { PartialLocation } from 'history'; +import { matchRoutes } from 'react-router-dom'; +import type { RouteMatch } from 'react-router'; +import { EntryPointRouteObject } from './types'; + +export type RoutePreloaderContextType = Readonly<{ + preloadCode(location: string | PartialLocation): void; +}>; + +export const RoutePreloaderContext = createContext(null); +// TODO: Wrap this in if (__DEV__) +RoutePreloaderContext.displayName = 'RoutePreloaderContext'; + +interface EntryPointRouteMatch extends RouteMatch { + route: EntryPointRouteObject; +} + +/** + * Match the current location to the corresponding route entry. + */ +function matchEntryPointRoutes( + routes: EntryPointRouteObject[], + location: string | PartialLocation, +): EntryPointRouteMatch[] | null { + return matchRoutes(routes, location); +} + +export const RoutePreloaderProvider: React.FC<{ + routes: EntryPointRouteObject[]; +}> = ({ routes, children }) => { + const preloadCode = useCallback( + (location: string | PartialLocation) => { + // preload just the code for a route + const matches = matchEntryPointRoutes(routes, location); + if (matches) { + matches.forEach(({ route }) => route.preloadCode?.()); + } + }, + [routes], + ); + + // const preload = useCallback( + // (location: string | PartialLocation) => { + // // preload the code and data for a route, without storing the result + // const matches = matchEntryPointRoutes(routes, location); + // // TODO: Fix preload param. Check React Router implementation? + // matches.forEach(({ route, params, pathname }) => + // route.preload?.( + // params, + // { + // state: {}, + // pathname, + // search: '<>', + // hash: '<>', + // key: '<>', + // }, + // 0, + // ), + // ); + // }, + // [routes], + // ); + + return ( + + {children} + + ); +}; + +export default RoutePreloaderContext; diff --git a/website/src/views/routes/Routes.tsx b/website/src/views/routes/Routes.tsx index ad9f8017c7..9c6e929803 100644 --- a/website/src/views/routes/Routes.tsx +++ b/website/src/views/routes/Routes.tsx @@ -1,57 +1,160 @@ -import * as React from 'react'; -import { Redirect, Route, Switch } from 'react-router-dom'; - -import TimetableContainer from 'views/timetable/TimetableContainer'; -import ModulePageContainer from 'views/modules/ModulePageContainer'; -import ModuleArchiveContainer from 'views/modules/ModuleArchiveContainer'; -import ModuleFinderContainer from 'views/modules/ModuleFinderContainer'; -import VenuesContainer from 'views/venues/VenuesContainer'; -import SettingsContainer from 'views/settings/SettingsContainer'; -import AboutContainer from 'views/static/AboutContainer'; -import ContributeContainer from 'views/contribute/ContributeContainer'; -import TeamContainer from 'views/static/TeamContainer'; -import ContributorsContainer from 'views/static/ContributorsContainer'; -import FaqContainer from 'views/static/FaqContainer'; -import AppsContainer from 'views/static/AppsContainer'; +import React, { memo, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { Navigate, Outlet, useRoutes } from 'react-router-dom'; import NotFoundPage from 'views/errors/NotFoundPage'; -import TodayContainer from 'views/today/TodayContainer'; -import PlannerContainer from 'views/planner/PlannerContainer'; -import TetrisContainer from 'views/tetris/TetrisContainer'; -import ExternalRedirect from './ExternalRedirect'; - -// IMPORTANT: Remember to update any route changes on the sitemap -const Routes: React.FC = () => ( - - - - - - - - - - - - - - - - - - - - {/* v2 routes */} - - - - - - - - - {/* 404 page */} - - -); - -export default Routes; +import TimetableRootRedirector from 'views/timetable/TimetableRootRedirector'; + +import AppShellEntryPoint from 'views/AppShell.entrypoint'; +import TimetableEntryPoint from 'views/timetable/Timetable.entrypoint'; +import TodayEntryPoint from 'views/today/Today.entrypoint'; +import ModuleFinderEntryPoint from 'views/modules/ModuleFinder.entrypoint'; +import ModulePageEntryPoint from 'views/modules/ModulePage.entrypoint'; +import ModuleArchiveEntryPoint from 'views/modules/ModuleArchive.entrypoint'; +import TetrisContainerEntryPoint from 'views/tetris/TetrisContainer.entrypoint'; + +import type { Dispatch } from 'types/redux'; +import type { EntryPoint, EntryPointPartialRouteObject, EntryPointRouteObject } from './types'; + +// import VenuesContainer from 'views/venues/VenuesContainer'; +// import SettingsContainer from 'views/settings/SettingsContainer'; +// import AboutContainer from 'views/static/AboutContainer'; +// import ContributeContainer from 'views/contribute/ContributeContainer'; +// import TeamContainer from 'views/static/TeamContainer'; +// import ContributorsContainer from 'views/static/ContributorsContainer'; +// import FaqContainer from 'views/static/FaqContainer'; +// import AppsContainer from 'views/static/AppsContainer'; +// import TodayContainer from 'views/today/TodayContainer'; +// import PlannerContainer from 'views/planner/PlannerContainer'; + +import EntryPointContainer from './EntryPointContainer'; +import { RoutePreloaderProvider } from './RoutePreloaderContext'; + +function entryPointRoute( + entryPoint: EntryPoint, + dispatch: Dispatch, +): EntryPointPartialRouteObject { + return { + element: , + preloadCode: () => entryPoint.component.preloadOrReloadIfError(), + preload(params) { + entryPoint.component.preloadOrReloadIfError(); + entryPoint.getPreparedProps(params, dispatch); + }, + }; +} + +// +// today +// +// tetris + +// +// +// +// +// +// +// + +function createPartialRoutes(dispatch: Dispatch): EntryPointPartialRouteObject[] { + // IMPORTANT: Remember to update any route changes on the sitemap + return [ + // v2 routes + { path: '/venueavailability', element: }, + { path: '/contribute/developers', element: }, + { path: '/contact', element: }, + { path: '/help', element: }, + // { + // path: '/news/nusdiscount', + // element: , + // }, + // { + // path: '/news/bareNUS', + // element: , + // }, + // { path: '/api', element: }, + + { + ...entryPointRoute(AppShellEntryPoint, dispatch), + children: [ + { + path: '/', + element: , + }, + { + path: '/timetable', + children: [ + { + path: ':semester/*', + ...entryPointRoute(TimetableEntryPoint, dispatch), + }, + { + path: '/', + element: , + }, + ], + }, + { + path: '/today', + ...entryPointRoute(TodayEntryPoint, dispatch), + }, + { + path: '/modules', + ...entryPointRoute(ModuleFinderEntryPoint, dispatch), + }, + { + path: '/modules/:moduleCode/*', + ...entryPointRoute(ModulePageEntryPoint, dispatch), + }, + { + path: '/archive/:moduleCode/:archiveYear/*', + ...entryPointRoute(ModuleArchiveEntryPoint, dispatch), + }, + { + path: '/tetris', + ...entryPointRoute(TetrisContainerEntryPoint, dispatch), + }, + + // 404 page + { + path: '*', + element: , + }, + ], + }, + ]; +} + +/** + * Creates a route config from an array of JavaScript objects. + * + * Forked from an original React Router implementation to support entry points. + * + * @see https://reactrouter.com/api/createRoutesFromArray + */ +function createRoutesFromArray(array: EntryPointPartialRouteObject[]): EntryPointRouteObject[] { + return array.map((partialRoute) => { + const route: EntryPointRouteObject = { + path: partialRoute.path || '/', + caseSensitive: partialRoute.caseSensitive === true, + element: partialRoute.element || , + preload: partialRoute.preload, + preloadCode: partialRoute.preloadCode, + }; + + if (partialRoute.children) { + route.children = createRoutesFromArray(partialRoute.children); + } + + return route; + }); +} + +const Routes: React.FC = () => { + const dispatch = useDispatch(); + const routes = useMemo(() => createRoutesFromArray(createPartialRoutes(dispatch)), [dispatch]); + const element = useRoutes(routes); + return {element}; +}; + +export default memo(Routes); diff --git a/website/src/views/routes/types.ts b/website/src/views/routes/types.ts new file mode 100644 index 0000000000..b586c3fd1c --- /dev/null +++ b/website/src/views/routes/types.ts @@ -0,0 +1,35 @@ +import type { Params, PartialRouteObject, RouteObject } from 'react-router'; +import type { JSResourceReference } from 'utils/JSResource'; +import type { Dispatch } from 'types/redux'; + +export type EntryPointComponentProps = React.PropsWithChildren<{ + prepared: PreparedProps; +}>; + +export type EntryPoint> = { + /** + * A reference to the root React component of this entry point. + */ + component: JSResourceReference>; + /** + * Should be idempotent as there is a good chance it'll be called multiple + * times in quick succession on a single page navigation. + */ + getPreparedProps: (params: Params, dispatch: Dispatch) => PreparedProps; + /** + * Optionally invalidate prepared props. Typically done so that the next call + * to getPreparedProps can prepare a fresh set of data or retry any failed + * requests. + */ + disposePreparedProps?: (params: Params) => void; +}; + +export interface EntryPointRouteObject extends RouteObject { + preloadCode?: () => void; + children?: EntryPointRouteObject[]; +} + +export interface EntryPointPartialRouteObject extends PartialRouteObject { + preloadCode?: () => void; + children?: EntryPointPartialRouteObject[]; +} diff --git a/website/src/views/static/FaqContainer.tsx b/website/src/views/static/FaqContainer.tsx index f1540c582b..e4e3bd790f 100644 --- a/website/src/views/static/FaqContainer.tsx +++ b/website/src/views/static/FaqContainer.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import config from 'config'; import ExternalLink from 'views/components/ExternalLink'; import StaticPage from './StaticPage'; @@ -181,7 +181,8 @@ const FaqContainer: React.FC = () => ( Congratulations on making it to the end! If you still want to contact us, you may reach us via email at nusmods{at}googlegroups[dot]com or via{' '} Messenger. Please allow up to - 90 working days for a reply. We are busy students as well! + 90 working days for a reply. We are busy{' '} + students as well!

    diff --git a/website/src/views/tetris/TetrisContainer.entrypoint.ts b/website/src/views/tetris/TetrisContainer.entrypoint.ts new file mode 100644 index 0000000000..57fa3d65e1 --- /dev/null +++ b/website/src/views/tetris/TetrisContainer.entrypoint.ts @@ -0,0 +1,16 @@ +import { JSResource } from 'utils/JSResource'; +import { EntryPoint } from 'views/routes/types'; + +export type PreparedProps = unknown; + +const entryPoint: EntryPoint = { + component: JSResource( + 'TetrisContainer', + () => import(/* webpackChunkName: "TetrisContainer" */ './TetrisContainer'), + ), + getPreparedProps() { + return {}; + }, +}; + +export default entryPoint; diff --git a/website/src/views/tetris/TetrisContainer.tsx b/website/src/views/tetris/TetrisContainer.tsx index b4c1fd6328..b01c9bff95 100644 --- a/website/src/views/tetris/TetrisContainer.tsx +++ b/website/src/views/tetris/TetrisContainer.tsx @@ -1,59 +1,14 @@ -import * as React from 'react'; -import Loadable, { LoadingComponentProps } from 'react-loadable'; - -import type { EmptyProps } from 'types/utils'; -import ApiError from 'views/errors/ApiError'; -import LoadingSpinner from 'views/components/LoadingSpinner'; -import type { Props as TetrisGameProps } from './TetrisGame'; - -type Props = { - readonly TetrisGame: React.ComponentType; -}; - -type State = { - readonly game: number; -}; - -/** - * Wrapper around TetrisGame which resets the game's internal state and components - * by forcing a remount after each game via the key prop - */ -class TetrisContainer extends React.PureComponent { - state = { - game: 0, - }; - - onResetGame = () => { - this.setState((state) => ({ game: state.game + 1 })); - }; - - render() { - const { TetrisGame } = this.props; - // Force a re-mount of the component, resetting its state by changing its key - return ; - } -} +import React, { useCallback, useState } from 'react'; +import TetrisGame from './TetrisGame'; /** - * Lazy load the TetrisGame component and pass it down to TetrisContainer + * Wrapper around TetrisGame which resets the game's internal state and + * components by forcing a remount after each game via the key prop. */ -type Export = { TetrisGame: { default: React.ComponentType } }; -export default Loadable.Map({ - loader: { - TetrisGame: () => import(/* webpackChunkName: "tetris" */ './TetrisGame'), - }, - loading: (props: LoadingComponentProps) => { - if (props.error) { - return ; - } - - if (props.pastDelay) { - return ; - } +const TetrisContainer: React.FC = () => { + const [game, setGame] = useState(0); + const onResetGame = useCallback(() => setGame(game + 1), [game]); + return ; +}; - return null; - }, - render(loaded, props) { - return ; - }, -}); +export default TetrisContainer; diff --git a/website/src/views/timetable/ExamCalendar.test.tsx b/website/src/views/timetable/ExamCalendar.test.tsx index f73ccdfbcb..0a43fd6466 100644 --- a/website/src/views/timetable/ExamCalendar.test.tsx +++ b/website/src/views/timetable/ExamCalendar.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { mount } from 'enzyme'; import _ from 'lodash'; -import { Link, MemoryRouter } from 'react-router-dom'; +import { PreloadingLink, MemoryRouter } from 'react-router-dom'; import { ModuleWithColor } from 'types/views'; import mockModules from '__mocks__/modules'; @@ -72,7 +72,7 @@ describe(ExamCalendar, () => { expect( wrapper - .find(Link) + .find(PreloadingLink) .map((element) => element.find(`.${styles.moduleCode}`).text()) .sort(), ).toEqual(['ACC2002', 'CS1010S', 'GES1021', 'PC1222']); @@ -81,7 +81,7 @@ describe(ExamCalendar, () => { test('show modules outside the two week exam period', () => { const wrapper = make([(GER1000 as unknown) as ModuleWithColor]); - expect(wrapper.find(Link)).toHaveLength(1); + expect(wrapper.find(PreloadingLink)).toHaveLength(1); expect(wrapper.find('tbody tr')).toHaveLength(TR_PER_WEEK); }); @@ -90,7 +90,7 @@ describe(ExamCalendar, () => { modules[0].hiddenInTimetable = true; const wrapper = make(modules); - expect(wrapper.find(Link)).toHaveLength(3); + expect(wrapper.find(PreloadingLink)).toHaveLength(3); }); }); diff --git a/website/src/views/timetable/ExamWeek.tsx b/website/src/views/timetable/ExamWeek.tsx index 138c18f6b3..8ebe92f181 100644 --- a/website/src/views/timetable/ExamWeek.tsx +++ b/website/src/views/timetable/ExamWeek.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import { range } from 'lodash'; import { isSameDay, addDays } from 'date-fns'; @@ -25,13 +25,13 @@ function getExamDate(date: Date): string { } const ExamModule: React.FC<{ module: ModuleWithColor }> = ({ module }) => ( -
    {module.moduleCode}
    {module.title}
    - + ); const ExamWeekComponent: React.FC = (props) => { diff --git a/website/src/views/timetable/ExportMenu.tsx b/website/src/views/timetable/ExportMenu.tsx index cc1481b90e..97e9cd1447 100644 --- a/website/src/views/timetable/ExportMenu.tsx +++ b/website/src/views/timetable/ExportMenu.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import Downshift, { ChildrenFunction } from 'downshift'; import { connect } from 'react-redux'; import classnames from 'classnames'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import { AlertTriangle, Calendar, ChevronDown, Download, FileText, Image } from 'react-feather'; import { Semester } from 'types/modules'; @@ -132,9 +132,9 @@ export class ExportMenuComponent extends React.PureComponent {

    The calendar you have just downloaded may not work with the macOS Calendar app.

    - + Find out more - + diff --git a/website/src/views/timetable/Timetable.entrypoint.ts b/website/src/views/timetable/Timetable.entrypoint.ts new file mode 100644 index 0000000000..723ebfb4b3 --- /dev/null +++ b/website/src/views/timetable/Timetable.entrypoint.ts @@ -0,0 +1,14 @@ +import { JSResource } from 'utils/JSResource'; +import { EntryPoint } from 'views/routes/types'; + +const entryPoint: EntryPoint = { + component: JSResource( + 'TimetableContainer', + () => import(/* webpackChunkName: "TimetableContainer" */ 'views/timetable/TimetableContainer'), + ), + getPreparedProps() { + return {}; + }, +}; + +export default entryPoint; diff --git a/website/src/views/timetable/TimetableContainer.test.tsx b/website/src/views/timetable/TimetableContainer.test.tsx index 42e53a6355..045bafb994 100644 --- a/website/src/views/timetable/TimetableContainer.test.tsx +++ b/website/src/views/timetable/TimetableContainer.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Redirect } from 'react-router-dom'; +import { Navigate } from 'react-router-dom'; import { shallow, ShallowWrapper } from 'enzyme'; import { SemTimetableConfig } from 'types/timetables'; import { ModulesMap } from 'types/reducers'; @@ -44,12 +44,12 @@ describe(TimetableContainerComponent, () => { timetable={timetable} colors={{}} modules={modules} - selectSemester={selectSemester} - setTimetable={setTimetable} - fetchTimetableModules={fetchTimetableModules} - openNotification={openNotification} + selectSemesterProp={selectSemester} + setTimetableProp={setTimetable} + fetchTimetableModulesProp={fetchTimetableModules} + openNotificationProp={openNotification} isValidModule={isModuleValid} - undo={undo} + undoProp={undo} {...router} />, ), @@ -67,7 +67,7 @@ describe(TimetableContainerComponent, () => { } function expectRedirect(wrapper: ShallowWrapper, to = timetablePage(1)) { - expect(wrapper.type()).toEqual(Redirect); + expect(wrapper.type()).toEqual(Navigate); expect(wrapper.prop('to')).toEqual(to); } diff --git a/website/src/views/timetable/TimetableContainer.tsx b/website/src/views/timetable/TimetableContainer.tsx index 06c2a9267a..885b25d080 100644 --- a/website/src/views/timetable/TimetableContainer.tsx +++ b/website/src/views/timetable/TimetableContainer.tsx @@ -1,60 +1,40 @@ -import * as React from 'react'; -import { connect } from 'react-redux'; -import { Redirect, RouteComponentProps, withRouter } from 'react-router-dom'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Navigate, useParams, useNavigate, useLocation } from 'react-router-dom'; import classnames from 'classnames'; import { ModuleCode, Semester } from 'types/modules'; import { SemTimetableConfig } from 'types/timetables'; -import { ColorMapping, ModulesMap, NotificationOptions } from 'types/reducers'; -import { selectSemester } from 'actions/settings'; +import { selectSemester as selectSemesterAction } from 'actions/settings'; import { getSemesterTimetable } from 'selectors/timetables'; -import { fetchTimetableModules, setTimetable } from 'actions/timetables'; -import { openNotification } from 'actions/app'; -import { undo } from 'actions/undoHistory'; +import { + fetchTimetableModules as fetchTimetableModulesAction, + setTimetable as setTimetableAction, +} from 'actions/timetables'; +import { openNotification as openNotificationAction } from 'actions/app'; +import { undo as undoAction } from 'actions/undoHistory'; import { getModuleCondensed } from 'selectors/moduleBank'; import { deserializeTimetable } from 'utils/timetables'; import { fillColorMapping } from 'utils/colors'; import { semesterForTimetablePage, TIMETABLE_SHARE, timetablePage } from 'views/routes/paths'; -import deferComponentRender from 'views/hocs/deferComponentRender'; import { Repeat } from 'react-feather'; import SemesterSwitcher from 'views/components/semester-switcher/SemesterSwitcher'; import LoadingSpinner from 'views/components/LoadingSpinner'; import ScrollToTop from 'views/components/ScrollToTop'; -import { State as StoreState } from 'types/state'; +import { State } from 'types/state'; +import type { EntryPointComponentProps } from 'views/routes/types'; +import type { Dispatch } from 'types/redux'; import TimetableContent from './TimetableContent'; import styles from './TimetableContainer.scss'; export type QueryParam = { - action: string; + '*': string | null; // action semester: string; }; -type OwnProps = RouteComponentProps; - -type Props = OwnProps & { - modules: ModulesMap; - semester: Semester | null; - activeSemester: Semester; - timetable: SemTimetableConfig; - colors: ColorMapping; - - isValidModule: (moduleCode: ModuleCode) => boolean; - selectSemester: (semester: Semester) => void; - setTimetable: ( - semester: Semester, - semTimetableConfig: SemTimetableConfig, - colorMapping: ColorMapping, - ) => void; - fetchTimetableModules: (semTimetableConfig: SemTimetableConfig[]) => void; - openNotification: (str: string, notificationOptions: NotificationOptions) => void; - undo: () => void; -}; - -type State = { - importedTimetable: SemTimetableConfig | null; -}; +type Props = EntryPointComponentProps; /** * Manages semester switching and sync/shared timetables @@ -62,191 +42,180 @@ type State = { * - Import timetable data from query string if action is defined * - Create the UI for the user to confirm their actions */ -export class TimetableContainerComponent extends React.PureComponent { - constructor(props: Props) { - super(props); - - const { semester, match, location } = props; - const importedTimetable = - semester && match.params.action ? deserializeTimetable(location.search) : null; - - this.state = { - importedTimetable, - }; - } - - componentDidMount() { - if (this.state.importedTimetable) { - this.props.fetchTimetableModules([this.state.importedTimetable]); +export const TimetableContainerComponent: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const params = useParams() as QueryParam; + const action = params['*']; + + const semester = useMemo(() => semesterForTimetablePage(params.semester), [params.semester]); + + const { timetable, colors } = useSelector((state: State) => + semester ? getSemesterTimetable(semester, state.timetables) : { timetable: {}, colors: {} }, + ); + const getModule = useSelector((state: State) => getModuleCondensed(state.moduleBank)); + const isValidModule = useCallback((moduleCode: ModuleCode) => !!getModule(moduleCode), [ + getModule, + ]); + const modules = useSelector((state: State) => state.moduleBank.modules); + const activeSemester = useSelector((state: State) => state.app.activeSemester); + + const dispatch = useDispatch(); + + const [importedTimetable, setImportedTimetable] = useState(() => + semester && action ? deserializeTimetable(location.search) : null, + ); + + useEffect(() => { + // TODO: Preload this + if (importedTimetable) { + dispatch(fetchTimetableModulesAction([importedTimetable])); } - } + }, [dispatch, importedTimetable]); - selectSemester = (semester: Semester) => { - this.props.selectSemester(semester); + const selectSemester = useCallback( + (selectedSemester: Semester) => { + dispatch(selectSemesterAction(selectedSemester)); - this.props.history.push({ - ...this.props.history.location, - pathname: timetablePage(semester), - }); - }; + navigate({ + ...location, + pathname: timetablePage(selectedSemester), + }); + }, + [dispatch, location, navigate], + ); - isLoading() { + const isLoading = useMemo(() => { // Check that all modules are fully loaded into the ModuleBank - const { modules, timetable } = this.props; - const { importedTimetable } = this.state; - const moduleCodes = new Set(Object.keys(timetable)); if (importedTimetable) { Object.keys(importedTimetable) - .filter(this.props.isValidModule) + .filter(isValidModule) .forEach((moduleCode) => moduleCodes.add(moduleCode)); } // TODO: Account for loading error return Array.from(moduleCodes).some((moduleCode) => !modules[moduleCode]); - } - - importTimetable(semester: Semester, timetable: SemTimetableConfig) { - const colors = fillColorMapping(timetable, this.props.colors); - this.props.setTimetable(semester, timetable, colors); - this.clearImportedTimetable(); - - this.props.openNotification('Timetable imported', { - timeout: 12000, - overwritable: true, - action: { - text: 'Undo', - handler: this.props.undo, - }, - }); - } + }, [importedTimetable, isValidModule, modules, timetable]); - clearImportedTimetable = () => { - const { semester } = this.props; + const clearImportedTimetable = useCallback(() => { if (semester) { - this.setState({ importedTimetable: null }, () => - this.props.history.push(timetablePage(semester)), - ); + setImportedTimetable(null); + navigate(timetablePage(semester)); } - }; - - sharingHeader(semester: Semester, timetable: SemTimetableConfig) { - return ( -
    - - -
    -
    -

    This timetable was shared with you

    -

    - Clicking import will replace your saved timetable with the one below. -

    -
    - -
    - - + }, [navigate, semester]); + + const importTimetable = useCallback( + (guaranteedSemester: Semester, sharedTimetable: SemTimetableConfig) => { + const filledColors = fillColorMapping(sharedTimetable, colors); + dispatch(setTimetableAction(guaranteedSemester, sharedTimetable, filledColors)); + clearImportedTimetable(); + + dispatch( + openNotificationAction('Timetable imported', { + timeout: 12000, + overwritable: true, + action: { + text: 'Undo', + handler: () => !!dispatch(undoAction), + }, + }), + ); + }, + [clearImportedTimetable, colors, dispatch], + ); + + const renderSharingHeader = useCallback( + (guaranteedSemester: Semester, sharedTimetable: SemTimetableConfig) => { + return ( +
    + + +
    +
    +

    This timetable was shared with you

    +

    + Clicking import will replace your saved timetable with the one + below. +

    +
    + +
    + + +
    -
    - ); - } + ); + }, + [clearImportedTimetable, importTimetable], + ); + + const renderTimetableHeader = useCallback( + (guaranteedSemester: Semester, readOnly?: boolean) => { + return ( + + ); + }, + [selectSemester], + ); - timetableHeader(semester: Semester, readOnly?: boolean) { - return ( - - ); + // 1. If the URL doesn't look correct, we'll direct the user to the home page + if (semester == null || (action && action !== TIMETABLE_SHARE)) { + return ; } - render() { - const { timetable, semester, activeSemester, match } = this.props; - const { importedTimetable } = this.state; - const { action } = match.params; - - // 1. If the URL doesn't look correct, we'll direct the user to the home page - if (semester == null || (action && action !== TIMETABLE_SHARE)) { - return ; - } - - // 2. If we are importing a timetable, check that all imported modules are - // loaded first, and display a spinner if they're not. - if (this.isLoading()) { - return ; - } - - // 3. Construct the color map - const displayedTimetable = importedTimetable || timetable; - const colors = fillColorMapping(displayedTimetable, this.props.colors); - - // 4. If there is an imported timetable, we show the sharing header which - // asks the user if they want to import the shared timetable - const header = importedTimetable ? ( - <> - {this.sharingHeader(semester, importedTimetable)} - {this.timetableHeader(semester, true)} - - ) : ( - this.timetableHeader(semester) - ); - - return ( -
    - - - -
    - ); + // 2. If we are importing a timetable, check that all imported modules are + // loaded first, and display a spinner if they're not. + if (isLoading) { + return ; } -} - -const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { - const semester = semesterForTimetablePage(ownProps.match.params.semester); - const { timetable, colors } = semester - ? getSemesterTimetable(semester, state.timetables) - : { timetable: {}, colors: {} }; - const getModule = getModuleCondensed(state.moduleBank); - - return { - semester, - timetable, - colors, - isValidModule: (moduleCode: ModuleCode) => !!getModule(moduleCode), - modules: state.moduleBank.modules, - activeSemester: state.app.activeSemester, - }; + + // 3. Construct the color map + const displayedTimetable = importedTimetable || timetable; + const filledColors = fillColorMapping(displayedTimetable, colors); + + // 4. If there is an imported timetable, we show the sharing header which + // asks the user if they want to import the shared timetable + const header = importedTimetable ? ( + <> + {renderSharingHeader(semester, importedTimetable)} + {renderTimetableHeader(semester, true)} + + ) : ( + renderTimetableHeader(semester) + ); + + return ( +
    + + + +
    + ); }; -// Explicitly declare top level components for React hot reloading to work. -const connectedTimetableContainer = connect(mapStateToProps, { - selectSemester, - setTimetable, - fetchTimetableModules, - openNotification, - undo, -})(TimetableContainerComponent); - -const routedTimetableContainer = withRouter(connectedTimetableContainer); -export default deferComponentRender(routedTimetableContainer); +export default memo(TimetableContainerComponent); diff --git a/website/src/views/timetable/TimetableModulesTable.tsx b/website/src/views/timetable/TimetableModulesTable.tsx index 651fa36f5b..df5ff9f186 100644 --- a/website/src/views/timetable/TimetableModulesTable.tsx +++ b/website/src/views/timetable/TimetableModulesTable.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import classnames from 'classnames'; import { sortBy } from 'lodash'; import produce from 'immer'; @@ -121,9 +121,9 @@ export const TimetableModulesTableComponent: React.FC = (props) => {
    {!readOnly && renderModuleActions(module)} - + {module.moduleCode} {module.title} - +
    {intersperse(secondRowText, BULLET_NBSP)}
    diff --git a/website/src/views/timetable/TimetableRootRedirector.tsx b/website/src/views/timetable/TimetableRootRedirector.tsx new file mode 100644 index 0000000000..b741e904a0 --- /dev/null +++ b/website/src/views/timetable/TimetableRootRedirector.tsx @@ -0,0 +1,18 @@ +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import type { State } from 'types/state'; +import { timetablePage } from 'views/routes/paths'; + +const TimetableRootRedirector: React.FC = () => { + const navigate = useNavigate(); + const activeSemester = useSelector((state: State) => state.app.activeSemester); + + useEffect(() => { + navigate(timetablePage(activeSemester)); + }, [activeSemester, navigate]); + + return null; +}; + +export default TimetableRootRedirector; diff --git a/website/src/views/today/BeforeLessonCard.tsx b/website/src/views/today/BeforeLessonCard.tsx index 4febc37810..f04920e69b 100644 --- a/website/src/views/today/BeforeLessonCard.tsx +++ b/website/src/views/today/BeforeLessonCard.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import classnames from 'classnames'; -import { Link } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import { Lesson } from 'types/timetables'; import { getStartTimeAsDate } from 'utils/timetables'; @@ -19,7 +19,7 @@ type Props = { const freeRoomMessage = ( <> Need help finding a free classroom to study in? Check out our{' '} - free room finder. + free room finder. ); diff --git a/website/src/views/today/DayEvents.tsx b/website/src/views/today/DayEvents.tsx index 28159f1e3f..6b720691d8 100644 --- a/website/src/views/today/DayEvents.tsx +++ b/website/src/views/today/DayEvents.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React, { memo, Suspense } from 'react'; import { AcadWeekInfo } from 'nusmoderator'; import { isSameDay } from 'date-fns'; import classnames from 'classnames'; @@ -21,7 +21,7 @@ type Props = { readonly onOpenLesson: (date: Date, lesson: Lesson) => void; }; -const DayEvents = React.memo((props) => { +const DayEvents: React.FC = (props) => { const renderLesson = (lesson: ColoredLesson, i: number) => { const { openLesson, onOpenLesson, marker, date } = props; @@ -48,14 +48,16 @@ const DayEvents = React.memo((props) => {

    {' '} {lesson.venue.startsWith('E-Learn') ? 'E-Learning' : lesson.venue} -
    - onOpenLesson(date, lesson)} - /> -
    + +
    + onOpenLesson(date, lesson)} + /> +
    +
    ); @@ -71,6 +73,6 @@ const DayEvents = React.memo((props) => { }); return
    {sortedLessons.map(renderLesson)}
    ; -}); +}; -export default DayEvents; +export default memo(DayEvents); diff --git a/website/src/views/today/EventMap/EventMap.scss b/website/src/views/today/EventMap.scss similarity index 100% rename from website/src/views/today/EventMap/EventMap.scss rename to website/src/views/today/EventMap.scss diff --git a/website/src/views/today/EventMap/EventMap.tsx b/website/src/views/today/EventMap.tsx similarity index 59% rename from website/src/views/today/EventMap/EventMap.tsx rename to website/src/views/today/EventMap.tsx index 89b2b52d07..287b9bad2b 100644 --- a/website/src/views/today/EventMap/EventMap.tsx +++ b/website/src/views/today/EventMap.tsx @@ -1,21 +1,19 @@ -import * as React from 'react'; +import React, { memo } from 'react'; -import { Venue, VenueLocationMap } from 'types/venues'; +import { Venue } from 'types/venues'; import LocationMap from 'views/components/map/LocationMap'; import { Map } from 'react-feather'; +import venueLocationResource from 'views/components/map/venueLocationResource'; import styles from './EventMap.scss'; -export type OwnProps = { - readonly venue: Venue | null; +export type Props = { + venue: Venue | null; }; -export type Props = OwnProps & - Readonly<{ - venueLocations: VenueLocationMap; - }>; +const EventMap: React.FC = ({ venue }) => { + const venueLocations = venueLocationResource.read(); -const EventMap: React.FC = (props) => { - if (!props.venue) { + if (!venue) { return (
    @@ -24,7 +22,7 @@ const EventMap: React.FC = (props) => { ); } - const venueLocation = props.venueLocations[props.venue]; + const venueLocation = venueLocations[venue]; if (!venueLocation || !venueLocation.location) { return

    We don't have information about this venue :(

    ; } @@ -33,4 +31,4 @@ const EventMap: React.FC = (props) => { return ; }; -export default EventMap; +export default memo(EventMap); diff --git a/website/src/views/today/EventMap/index.tsx b/website/src/views/today/EventMap/index.tsx deleted file mode 100644 index 11e352417b..0000000000 --- a/website/src/views/today/EventMap/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import withVenueLocations from 'views/components/map/withVenueLocations'; -import { Props } from './EventMap'; - -const EventMap = withVenueLocations(() => - // TypeScript is sad about resolving dynamic import - import(/* webpackChunkName: "venue" */ './EventMap').then((module) => module.default), -); - -export default EventMap; diff --git a/website/src/views/today/EventMapInline/EventMapInline.scss b/website/src/views/today/EventMapInline.scss similarity index 100% rename from website/src/views/today/EventMapInline/EventMapInline.scss rename to website/src/views/today/EventMapInline.scss diff --git a/website/src/views/today/EventMapInline/EventMapInline.tsx b/website/src/views/today/EventMapInline.tsx similarity index 66% rename from website/src/views/today/EventMapInline/EventMapInline.tsx rename to website/src/views/today/EventMapInline.tsx index dabc664fd5..f4c1af2cde 100644 --- a/website/src/views/today/EventMapInline/EventMapInline.tsx +++ b/website/src/views/today/EventMapInline.tsx @@ -1,29 +1,22 @@ -import React from 'react'; +import React, { memo } from 'react'; import classnames from 'classnames'; -import { LatLngTuple, Venue, VenueLocation, VenueLocationMap } from 'types/venues'; +import { LatLngTuple, Venue, VenueLocation } from 'types/venues'; import LocationMap from 'views/components/map/LocationMap'; +import venueLocationResource from 'views/components/map/venueLocationResource'; import styles from './EventMapInline.scss'; -export type OwnProps = Readonly<{ +export type Props = { isOpen: boolean; className?: string; venue: Venue; toggleOpen: () => void; -}>; - -export type Props = OwnProps & { - readonly venueLocations: VenueLocationMap; }; -const EventMapInline: React.FunctionComponent = ({ - venue, - isOpen, - className, - toggleOpen, - venueLocations, -}) => { +const EventMapInline: React.FC = ({ venue, isOpen, className, toggleOpen }) => { + const venueLocations = venueLocationResource.read(); + const venueLocation: VenueLocation = venueLocations[venue]; if (!venueLocation || !venueLocation.location) { return null; @@ -47,4 +40,4 @@ const EventMapInline: React.FunctionComponent = ({ ); }; -export default EventMapInline; +export default memo(EventMapInline); diff --git a/website/src/views/today/EventMapInline/index.tsx b/website/src/views/today/EventMapInline/index.tsx deleted file mode 100644 index d4e02c0059..0000000000 --- a/website/src/views/today/EventMapInline/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import withVenueLocations from 'views/components/map/withVenueLocations'; -import { Props } from './EventMapInline'; - -export default withVenueLocations( - () => import(/* webpackChunkName: "venue" */ './EventMapInline').then((module) => module.default), - // Don't show spinner or errors inline - { Loading: () => null, Error: () => null }, -); diff --git a/website/src/views/today/Today.entrypoint.ts b/website/src/views/today/Today.entrypoint.ts new file mode 100644 index 0000000000..141fdd7659 --- /dev/null +++ b/website/src/views/today/Today.entrypoint.ts @@ -0,0 +1,19 @@ +import { JSResource } from 'utils/JSResource'; +import venueLocationResource from 'views/components/map/venueLocationResource'; +import { EntryPoint } from 'views/routes/types'; + +export type PreparedProps = unknown; + +const entryPoint: EntryPoint = { + component: JSResource( + 'TodayContainer', + () => import(/* webpackChunkName: "TodayContainer" */ './TodayContainer'), + ), + getPreparedProps() { + // Preload EventMap/EventMapInline data requirements + venueLocationResource.preload(); + return {}; + }, +}; + +export default entryPoint; diff --git a/website/src/views/today/TodayContainer/TodayContainer.scss b/website/src/views/today/TodayContainer.scss similarity index 100% rename from website/src/views/today/TodayContainer/TodayContainer.scss rename to website/src/views/today/TodayContainer.scss diff --git a/website/src/views/today/TodayContainer/TodayContainer.test.tsx b/website/src/views/today/TodayContainer.test.tsx similarity index 98% rename from website/src/views/today/TodayContainer/TodayContainer.test.tsx rename to website/src/views/today/TodayContainer.test.tsx index 82bb3eec9b..2f5bd3d4bd 100644 --- a/website/src/views/today/TodayContainer/TodayContainer.test.tsx +++ b/website/src/views/today/TodayContainer.test.tsx @@ -9,8 +9,8 @@ import { captureException } from 'utils/error'; import { Props, DaySection, TodayContainerComponent, mapStateToProps } from './TodayContainer'; import forecasts from './__mocks__/forecasts.json'; -import DayEvents from '../DayEvents'; -import styles from '../DayEvents.scss'; +import DayEvents from './DayEvents'; +import styles from './DayEvents.scss'; /* eslint-disable no-useless-computed-key */ diff --git a/website/src/views/today/TodayContainer/TodayContainer.tsx b/website/src/views/today/TodayContainer.tsx similarity index 94% rename from website/src/views/today/TodayContainer/TodayContainer.tsx rename to website/src/views/today/TodayContainer.tsx index 41aa27ec49..6c1e2fbf94 100644 --- a/website/src/views/today/TodayContainer/TodayContainer.tsx +++ b/website/src/views/today/TodayContainer.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React, { Suspense } from 'react'; import { connect } from 'react-redux'; import { get, minBy, range } from 'lodash'; import NUSModerator, { AcadWeekInfo } from 'nusmoderator'; @@ -40,12 +40,14 @@ import MapContext from 'views/components/map/MapContext'; import { formatTime, getDayIndex } from 'utils/timify'; import { breakpointUp } from 'utils/css'; import { State as StoreState } from 'types/state'; - -import DayEvents from '../DayEvents'; -import DayHeader from '../DayHeader'; -import EmptyLessonGroup from '../EmptyLessonGroup'; -import BeforeLessonCard from '../BeforeLessonCard'; -import EventMap from '../EventMap'; +import type { EntryPointComponentProps } from 'views/routes/types'; +import LoadingSpinner from 'views/components/LoadingSpinner'; + +import DayEvents from './DayEvents'; +import DayHeader from './DayHeader'; +import EmptyLessonGroup from './EmptyLessonGroup'; +import BeforeLessonCard from './BeforeLessonCard'; +import EventMap from './EventMap'; import styles from './TodayContainer.scss'; const EMPTY_LESSONS: ColoredLesson[] = []; @@ -58,7 +60,7 @@ const semesterNameMap: Record = { 'Special Sem 2': 4, }; -export type OwnProps = TimerData; +export type OwnProps = TimerData & EntryPointComponentProps; export type Props = OwnProps & Readonly<{ @@ -333,7 +335,9 @@ export class TodayContainerComponent extends React.PureComponent { [styles.expanded]: this.state.isMapExpanded, })} > - + }> + +
    diff --git a/website/src/views/today/TodayContainer/index.tsx b/website/src/views/today/TodayContainer/index.tsx deleted file mode 100644 index 1237b2f905..0000000000 --- a/website/src/views/today/TodayContainer/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react'; -import Loadable, { LoadingComponentProps } from 'react-loadable'; - -import LoadingSpinner from 'views/components/LoadingSpinner'; -import ApiError from 'views/errors/ApiError'; -import { retryImport } from 'utils/error'; -import EventMapInline from '../EventMapInline'; -import EventMap from '../EventMap'; - -const AsyncTodayContainer = Loadable({ - loader: () => retryImport(() => import(/* webpackChunkName: "today" */ './TodayContainer')), - loading: (props: LoadingComponentProps) => { - if (props.error) { - return ; - } - - if (props.pastDelay) { - return ; - } - - return null; - }, -}); - -export default AsyncTodayContainer; - -export function preload() { - AsyncTodayContainer.preload(); - EventMapInline.preload(); - EventMap.preload(); -} diff --git a/website/src/views/today/TodayContainer/__mocks__/forecasts.json b/website/src/views/today/__mocks__/forecasts.json similarity index 100% rename from website/src/views/today/TodayContainer/__mocks__/forecasts.json rename to website/src/views/today/__mocks__/forecasts.json diff --git a/website/src/views/venues/VenueDetails.tsx b/website/src/views/venues/VenueDetails.tsx index 52a41f78b5..c07ab4e539 100644 --- a/website/src/views/venues/VenueDetails.tsx +++ b/website/src/views/venues/VenueDetails.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; -import { Link, RouteComponentProps, withRouter } from 'react-router-dom'; +import { RouteComponentProps } from 'react-router-dom'; import classnames from 'classnames'; import { flatMap } from 'lodash'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import { DayAvailability, TimePeriod, Venue } from 'types/venues'; import { Lesson } from 'types/timetables'; @@ -50,7 +51,7 @@ export const VenueDetailsComponent: React.FC = (props) => { {`${venue} - Venues`}
    - = (props) => { }} > {previous} - +

    {venue}

    - = (props) => { }} > {next} - +
    @@ -97,4 +98,5 @@ const ResponsiveVenueDetails = makeResponsive( React.memo(VenueDetailsComponent), breakpointDown('lg'), ); -export default withRouter(ResponsiveVenueDetails); +export default ResponsiveVenueDetails; +// export default withRouter(ResponsiveVenueDetails); diff --git a/website/src/views/venues/VenueList.tsx b/website/src/views/venues/VenueList.tsx index 3f027c2118..f294c711df 100644 --- a/website/src/views/venues/VenueList.tsx +++ b/website/src/views/venues/VenueList.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import classnames from 'classnames'; import { groupBy, toPairs, sortBy } from 'lodash'; -import { Link, LinkProps } from 'react-router-dom'; +import type { LinkProps } from 'react-router-dom'; +import { PreloadingLink } from 'views/routes/PreloadingLink'; import { Venue } from 'types/venues'; import { venuePage } from 'views/routes/paths'; @@ -29,7 +30,7 @@ const VenueList: React.FC = (props) => {
      {venues.map((venue) => (
    • - = (props) => { {...props.linkProps} > {venue} - +
    • ))}
    diff --git a/website/src/views/venues/VenuesContainer.tsx b/website/src/views/venues/VenuesContainer.tsx index 24fd9e0ab8..508c5eac29 100644 --- a/website/src/views/venues/VenuesContainer.tsx +++ b/website/src/views/venues/VenuesContainer.tsx @@ -368,7 +368,8 @@ export class VenuesContainerComponent extends React.Component { // Explicitly declare top level components for React hot reloading to work. const ResponsiveVenuesContainer = makeResponsive(VenuesContainerComponent, breakpointDown('sm')); -const RoutedVenuesContainer = withRouter(ResponsiveVenuesContainer); +// const RoutedVenuesContainer = withRouter(ResponsiveVenuesContainer); +const RoutedVenuesContainer = ResponsiveVenuesContainer; const AsyncVenuesContainer = Loadable.Map, { venues: AxiosResponse }>({ loader: { venues: () => axios.get(nusmods.venuesUrl(config.semester)), diff --git a/website/webpack/webpack.config.dev.js b/website/webpack/webpack.config.dev.js index 396732373c..5f9b97dad8 100644 --- a/website/webpack/webpack.config.dev.js +++ b/website/webpack/webpack.config.dev.js @@ -26,12 +26,12 @@ const developmentConfig = merge([ 'webpack/hot/only-dev-server', 'entry/main', ], - resolve: { - alias: { - // Replace React DOM with the hot reload patched version in development - 'react-dom': '@hot-loader/react-dom', - }, - }, + // resolve: { + // alias: { + // // Replace React DOM with the hot reload patched version in development + // 'react-dom': '@hot-loader/react-dom', + // }, + // }, plugins: [ new HtmlWebpackPlugin({ template: path.join(parts.PATHS.src, 'index.html'), diff --git a/website/yarn.lock b/website/yarn.lock index 119dc5c283..9008b81a81 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -2030,13 +2030,6 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.1.2": - version "7.3.4" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.4.tgz#73d12ba819e365fcf7fd152aed56d6df97d21c83" - integrity sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g== - dependencies: - regenerator-runtime "^0.12.0" - "@babel/runtime@^7.10.2": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.4.tgz#a6724f1a6b8d2f6ea5236dbfe58c7d7ea9c5eb99" @@ -2058,6 +2051,13 @@ dependencies: regenerator-runtime "^0.13.2" +"@babel/runtime@^7.7.6": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.1.tgz#b4116a6b6711d010b2dad3b7b6e43bf1b9954740" + integrity sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/runtime@^7.8.4": version "7.8.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.7.tgz#8fefce9802db54881ba59f90bb28719b4996324d" @@ -2990,7 +2990,7 @@ dependencies: "@types/node" "*" -"@types/history@*", "@types/history@^4.6.0": +"@types/history@^4.6.0": version "4.7.2" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.2.tgz#0e670ea254d559241b6eeb3894f8754991e73220" integrity sha512-ui3WwXmjTaY73fOQ3/m3nnajU/Orhi6cEu5rzX+BrAAJxa3eITXZ5ch9suPqtM03OWhAHhPSyBGCN4UKoxO20Q== @@ -3218,23 +3218,6 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" -"@types/react-router-dom@5.1.6": - version "5.1.6" - resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.6.tgz#07b14e7ab1893a837c8565634960dc398564b1fb" - integrity sha512-gjrxYqxz37zWEdMVvQtWPFMFj1dRDb4TGOcgyOfSXTrEXdF92L00WE3C471O3TV/RF1oskcStkXsOU0Ete4s/g== - dependencies: - "@types/history" "*" - "@types/react" "*" - "@types/react-router" "*" - -"@types/react-router@*": - version "5.0.3" - resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.0.3.tgz#855a1606e62de3f4d69ea34fb3c0e50e98e964d5" - integrity sha512-j2Gge5cvxca+5lK9wxovmGPgpVJMwjyu5lTA/Cd6fLGoPq7FXcUE1jFkEdxeyqGGz8VfHYSHCn5Lcn24BzaNKA== - dependencies: - "@types/history" "*" - "@types/react" "*" - "@types/react-scrollspy@3.3.3": version "3.3.3" resolved "https://registry.yarnpkg.com/@types/react-scrollspy/-/react-scrollspy-3.3.3.tgz#ebf82b20f0a14cf2db175f6b3d65f2e3d1acb911" @@ -8115,17 +8098,12 @@ history@4.7.2: value-equal "^0.4.0" warning "^3.0.0" -history@^4.9.0: - version "4.9.0" - resolved "https://registry.yarnpkg.com/history/-/history-4.9.0.tgz#84587c2068039ead8af769e9d6a6860a14fa1bca" - integrity sha512-H2DkjCjXf0Op9OAr6nJ56fcRkTSNrUiv41vNJ6IswJjif6wlpZK0BTfFbi7qK9dXLSYZxkq5lBsj3vUjlYBYZA== +history@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08" + integrity sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg== dependencies: - "@babel/runtime" "^7.1.2" - loose-envify "^1.2.0" - resolve-pathname "^2.2.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - value-equal "^0.4.0" + "@babel/runtime" "^7.7.6" hmac-drbg@^1.0.0: version "1.0.1" @@ -8136,7 +8114,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" integrity sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA== @@ -10168,7 +10146,7 @@ longest-streak@^2.0.1: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.2.tgz#2421b6ba939a443bb9ffebf596585a50b4c38e2e" integrity sha512-TmYTeEYxiAmSVdpbnQDXGtvYOIRsCMg89CVZzwzc2o7GFL1CjoiRPjH5ec0NFAVlAx3fVof9dX/t6KKRAo2OWA== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -10524,14 +10502,6 @@ min-indent@^1.0.0: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= -mini-create-react-context@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz#df60501c83151db69e28eac0ef08b4002efab040" - integrity sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA== - dependencies: - "@babel/runtime" "^7.5.5" - tiny-warning "^1.0.3" - mini-css-extract-plugin@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.0.0.tgz#4afb39f3d97b1b92eacb1ac45025416089f831bd" @@ -11795,13 +11765,6 @@ path-to-regexp@0.1.7: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= -path-to-regexp@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d" - integrity sha1-Wf3g9DW62suhA6hOnTvGTpa5k30= - dependencies: - isarray "0.0.1" - path-type@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -12863,15 +12826,14 @@ react-dev-utils@10.2.1: strip-ansi "6.0.0" text-table "0.2.0" -react-dom@16.14.0: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" - integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== +react-dom@^0.0.0-experimental-4ead6b530: + version "0.0.0-experimental-4ead6b530" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-0.0.0-experimental-4ead6b530.tgz#6ca831f5aa7ab86f7299b9a2f7b81dffabfa4eb4" + integrity sha512-a03ptS8lhhEENNgne6zQMXQWX/Z6WMEBGJQY0laOC0NgJywidePYpgkiE72fUAaj/r7t9a6XsdVyqx4UsEZijg== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.19.1" + scheduler "0.0.0-experimental-4ead6b530" react-error-overlay@^6.0.7: version "6.0.7" @@ -12938,16 +12900,16 @@ react-is@^16.13.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^16.6.0, react-is@^16.8.6: - version "16.8.6" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" - integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== - react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.3: version "16.8.3" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.3.tgz#4ad8b029c2a718fc0cfc746c8d4e1b7221e5387d" integrity sha512-Y4rC1ZJmsxxkkPuMLwvKvlL1Zfpbcu+Bf4ZigkHup3v9EfdYhAlWAaVyA19olXq2o2mGn0w+dFKvk3pVVlYcIA== +react-is@^16.8.6: + version "16.8.6" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" + integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== + react-is@^16.9.0: version "16.9.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb" @@ -13028,34 +12990,20 @@ react-redux@^7.1.1: prop-types "^15.7.2" react-is "^16.9.0" -react-router-dom@5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" - integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== +react-router-dom@^0.0.0-experimental-ffd8c7d0: + version "0.0.0-experimental-ffd8c7d0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-0.0.0-experimental-ffd8c7d0.tgz#8742c2c7dbbc01237ea2fb97005ec3ace4be6937" + integrity sha512-tu8XLvu7ZpOeCP6xO4lNGzf+Iy9G8ElsiU/UlC/T4gb5ZbfxB/IAfZIKSzgqn8+Oh/dGca+1Xr1HZbKrib2Hfw== dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - loose-envify "^1.3.1" - prop-types "^15.6.2" - react-router "5.2.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" + prop-types "^15.7.2" + react-router "0.0.0-experimental-ffd8c7d0" -react-router@5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" - integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - hoist-non-react-statics "^3.1.0" - loose-envify "^1.3.1" - mini-create-react-context "^0.4.0" - path-to-regexp "^1.7.0" - prop-types "^15.6.2" - react-is "^16.6.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" +react-router@0.0.0-experimental-ffd8c7d0: + version "0.0.0-experimental-ffd8c7d0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-0.0.0-experimental-ffd8c7d0.tgz#ccc6187d554a4082bc503ad392318d407ea652ac" + integrity sha512-DMEkLwdFfpKnPaQi2M739iIW4erffoAF/fkbxE8j9c/uIqyX+Sy632DEGt8UsoCJEGNfrkaIUFmNqfTSz2JBzA== + dependencies: + prop-types "^15.7.2" react-scrollspy@3.4.3: version "3.4.3" @@ -13091,14 +13039,13 @@ react-test-renderer@^16.0.0-0: react-is "^16.8.3" scheduler "^0.13.3" -react@16.14.0: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" - integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== +react@^0.0.0-experimental-4ead6b530: + version "0.0.0-experimental-4ead6b530" + resolved "https://registry.yarnpkg.com/react/-/react-0.0.0-experimental-4ead6b530.tgz#88cdae012012a758dd039a63104758c6351115df" + integrity sha512-tpbYm6FEuC1L6tCVXIKYAhgGAkS8DShzKpmXosowZvLqeByeLQQe77Ef6bi5HdEkFm2v0lZffLWckSM8R4TToA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" read-pkg-up@^1.0.1: version "1.0.1" @@ -13323,11 +13270,6 @@ regenerator-runtime@^0.11.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.12.0: - version "0.12.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" - integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg== - regenerator-runtime@^0.13.2: version "0.13.2" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz#32e59c9a6fb9b1a4aff09b4930ca2d4477343447" @@ -13850,6 +13792,14 @@ saxes@^5.0.0: dependencies: xmlchars "^2.2.0" +scheduler@0.0.0-experimental-4ead6b530: + version "0.0.0-experimental-4ead6b530" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.0.0-experimental-4ead6b530.tgz#0dca3287308d34caed0651941f1ce7c9d64a0824" + integrity sha512-AzUR6EiDuY32oAnfELgVFPasfovJw4+NtRy7RIam0IUOSgNZKcazqcHzsoW1zDw3AzIBlD1VlRvl5SPJRSlTPg== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.13.3: version "0.13.3" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.3.tgz#bed3c5850f62ea9c716a4d781f9daeb9b2a58896" @@ -15271,11 +15221,6 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= -tiny-invariant@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.4.tgz#346b5415fd93cb696b0c4e8a96697ff590f92463" - integrity sha512-lMhRd/djQJ3MoaHEBrw8e2/uM4rs9YMNk0iOr8rHQ0QdbM7D4l0gFl3szKdeixrlyfm9Zqi4dxHCM2qVG8ND5g== - tiny-invariant@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" @@ -15286,16 +15231,6 @@ tiny-json-http@^7.1.2: resolved "https://registry.yarnpkg.com/tiny-json-http/-/tiny-json-http-7.1.2.tgz#620e189849bab08992ec23fada7b48c7c61637b4" integrity sha512-XB9Bu+ohdQso6ziPFNVqK+pcTt0l8BSRkW/CCBq0pUVlLxcYDsorpo7ae5yPhu2CF1xYgJuKVLF7cfOGeLCTlA== -tiny-warning@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.2.tgz#1dfae771ee1a04396bdfde27a3adcebc6b648b28" - integrity sha512-rru86D9CpQRLvsFG5XFdy0KdLAvjdQDyZCsRcuu60WtzFylDM3eAWSxEVz5kzL2Gp544XiUvPbVKtOA/txLi9Q== - -tiny-warning@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" - integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== - tippy.js@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-5.1.1.tgz#2a85cf3fb302ddc5ba1fca944e1f39bec62cb7b6"