diff --git a/.babelrc b/.babelrc index eadc4af1b3..a4c8c269ee 100755 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,12 @@ { - "presets": [["@babel/preset-env", { "targets": { "browsers": ["ie >= 11", "safari > 9"] } }], "@babel/preset-react", "@babel/preset-typescript"], - "plugins": ["react-hot-loader/babel", "@babel/plugin-proposal-class-properties", "transform-optional-chaining"] + "presets": [ + [ + "@babel/preset-env", + { + "targets": "> 0.5%, last 2 versions, Firefox ESR, not dead" + } + ], + ["@babel/preset-react", { "runtime": "automatic" }], + "@babel/preset-typescript" + ] } diff --git a/.circleci/config.yml b/.circleci/config.yml index 0d558e5b38..70383bca10 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,38 +1,55 @@ -# Javascript Node CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-javascript/ for more details -# -version: 2 +version: 2.1 + jobs: - build: - docker: - # specify the version you desire here - - image: circleci/node:7.10 - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - # - image: circleci/mongo:3.4.4 - - working_directory: ~/repo - - steps: - - checkout - - # Download and cache dependencies - - restore_cache: - keys: - - v1-dependencies-{{ checksum "package.json" }} - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - run: yarn install - - - save_cache: - paths: - - node_modules - key: v1-dependencies-{{ checksum "package.json" }} - - # run tests! - - run: yarn test + build: + docker: + - image: cimg/node:24.18.0 + + working_directory: ~/repo + + steps: + - checkout + + - run: + name: Pin Yarn + command: | + corepack enable + corepack prepare yarn@1.22.22 --activate + + - restore_cache: + keys: + - v2-yarn-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }} + - v2-yarn- + + - run: + name: Install dependencies + command: yarn install --frozen-lockfile + + - save_cache: + key: v2-yarn-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }} + paths: + - ~/.cache/yarn + + - run: + name: Test + command: yarn test + + - run: + name: Quality ratchets + command: yarn quality + + - run: + name: Production build + command: | + mkdir -p /tmp/build-reports + yarn build 2>&1 | tee /tmp/build-reports/build-output.txt + + - run: + name: Report artifact size + command: | + node scripts/bundle-report.mjs --out /tmp/build-reports/bundle-report.json + cat /tmp/build-reports/bundle-report.json + - store_artifacts: + path: /tmp/build-reports + destination: build-reports diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 44eef6358e..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,21 +0,0 @@ -node_modules -parse/processed -parse/raw -parse/cache -convertBibleQuote/new -convertBibleQuote/bible -/.idea -/electron/app -/android/app -/www/built -/www/service-worker.* -/www/version -/www/index.html -.gradle -TWA/build/ -TWA/app -TWA/assetlinks.json -TWA/local.properties -android.keystore -app-release-* -.idea diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index fe4b95045f..0000000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,159 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -const fs = require('fs'); -const path = require('path'); - -const prettierOptions = JSON.parse(fs.readFileSync(path.resolve(__dirname, '.prettierrc'), 'utf8')); - -// 0: off, 1: warn, 2: error - -module.exports = { - parser: '@typescript-eslint/parser', - extends: [ - 'standard', - 'standard-jsx', - 'prettier', - 'prettier/react', - 'plugin:eslint-comments/recommended', - 'plugin:import/errors', - 'plugin:import/warnings', - 'plugin:prettier/recommended', - 'plugin:@typescript-eslint/eslint-recommended', - 'plugin:@typescript-eslint/recommended', - ], - plugins: ['@typescript-eslint', 'prettier', 'react', 'react-hooks', 'jsx-a11y', 'promise'], - env: { - jest: true, - browser: true, - node: true, - es6: true, - }, - parserOptions: { - ecmaVersion: 6, - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - }, - overrides: [ - { - files: ['*.ts', '*.tsx'], - parser: '@typescript-eslint/parser', - extends: [ - 'standard-with-typescript', - 'plugin:@typescript-eslint/recommended', - 'plugin:@typescript-eslint/recommended-requiring-type-checking', - 'prettier/@typescript-eslint', - ], - plugins: ['@typescript-eslint'], - rules: { - 'no-extra-boolean-cast': 0, - 'comma-dangle': [2, 'only-multiline', { functions: 'never' }], - 'no-return-await': 0, - '@typescript-eslint/interface-name-prefix': 0, - '@typescript-eslint/member-ordering': 0, - '@typescript-eslint/no-unused-vars': 2, - '@typescript-eslint/naming-convention': 0, - '@typescript-eslint/member-delimiter-style': 0, - '@typescript-eslint/strict-boolean-expressions': 0, - '@typescript-eslint/prefer-nullish-coalescing': 0, - '@typescript-eslint/return-await': 0, - '@typescript-eslint/restrict-template-expressions': 0, - '@typescript-eslint/explicit-module-boundary-types': 0, - '@typescript-eslint/explicit-function-return-type': 0, - }, - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - }, - }, - ], - settings: { - 'import/resolver': { - node: { - extensions: ['.js', '.jsx', '.ts', '.tsx'], - }, - }, - react: { - version: 'detect', - }, - }, - globals: { - React: 'writable', - JSX: 'writable', - }, - rules: { - 'prettier/prettier': [2, prettierOptions], - 'no-underscore-dangle': 0, - 'arrow-body-style': [2, 'as-needed'], - 'class-methods-use-this': 0, - 'no-multiple-empty-lines': 2, - 'comma-dangle': [2, 'only-multiline', { functions: 'never' }], - 'import/no-unresolved': 2, - 'import/order': [ - 2, - { - 'newlines-between': 'always', - pathGroupsExcludedImportTypes: ['builtin'], - }, - ], - 'jsx-a11y/aria-props': 2, - 'jsx-a11y/heading-has-content': 0, - 'jsx-a11y/label-has-associated-control': [ - 2, - { - // NOTE: If this error triggers, either disable it or add - // your custom components, labels and attributes via these options - // See https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/label-has-associated-control.md - controlComponents: ['Input'], - }, - ], - 'jsx-a11y/label-has-for': 0, - 'jsx-a11y/mouse-events-have-key-events': 2, - 'jsx-a11y/role-has-required-aria-props': 2, - 'jsx-a11y/role-supports-aria-props': 2, - 'max-len': 0, - 'newline-per-chained-call': 0, - 'no-confusing-arrow': 0, - 'no-console': 1, - 'no-debugger': 1, - 'no-extra-boolean-cast': 0, - 'no-nested-ternary': 0, - 'no-param-reassign': 2, - 'no-shadow': 0, - 'no-var': 2, - 'no-void': [2, { allowAsStatement: true }], - 'no-empty': 2, - 'no-empty-function': 2, - 'no-unused-vars': [2, { argsIgnorePattern: '^_', args: 'after-used', ignoreRestSiblings: true }], - 'no-use-before-define': 0, - 'prefer-template': 2, - quotes: [2, 'single', { avoidEscape: true, allowTemplateLiterals: false }], - 'react/destructuring-assignment': 0, - 'react/jsx-closing-tag-location': 0, - 'react/forbid-prop-types': 0, - 'react/jsx-boolean-value': 0, - 'react/jsx-first-prop-new-line': [2, 'multiline'], - 'react/jsx-filename-extension': 0, - 'react/jsx-handler-names': 0, - 'react/jsx-no-target-blank': 0, - 'react/jsx-uses-vars': 2, - 'react/require-default-props': 0, - 'react/require-extension': 0, - 'react/self-closing-comp': 0, - 'react/sort-comp': 0, - 'react/react-in-jsx-scope': 0, - 'require-yield': 0, - 'max-classes-per-file': 0, - 'react/static-property-placement': 0, - 'react/state-in-constructor': 0, - 'react/jsx-props-no-spreading': 0, - 'react/display-name': 0, - 'sort-keys': 0, - 'no-restricted-globals': 0, - camelcase: [2, { allow: ['^UNSAFE_', '_UNSTABLE'] }], - 'eslint-comments/disable-enable-pair': 0, - 'node/no-exports-assign': 0, - 'arrow-body-style': 0, - }, -}; -/* eslint-enable @typescript-eslint/no-var-requires */ diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000000..fc9e6be125 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,56 @@ +name: Frontend verification + +on: + pull_request: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: frontend-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + + - name: Pin Yarn + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Install Chromium + run: yarn playwright install --with-deps chromium + + - name: Run frontend tests + run: yarn test + + - name: Run quality ratchets + run: yarn quality + + - name: Validate performance harness + if: ${{ hashFiles('scripts/performance/experience-*.test.mjs') != '' }} + run: yarn test:performance + + - name: Build production assets + run: yarn build + + - name: Report bundle and precache sizes + run: yarn analyze:bundle + + - name: Run browser and offline journeys + run: yarn test:e2e diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..ca5c350055 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.18.0 diff --git a/Dockerfile b/Dockerfile index 5b3ff5b08e..1f34e3c365 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,15 @@ -FROM node:10-alpine +FROM node:24-alpine -# Create app directory WORKDIR /usr/src/app -COPY yarn.lock ./ -COPY package*.json ./ +RUN corepack enable -RUN yarn +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile COPY . . RUN yarn build EXPOSE 80 -ENTRYPOINT yarn serve +CMD ["yarn", "serve"] diff --git a/README.md b/README.md index 6163b098ff..e04bf348ea 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,46 @@ # Orthodox Calendar -## Installation +## Frontend setup -First get this up and running (see its readme): -https://github.com/dimaip/calendar-backend - -Then: +The supported frontend toolchain is Node 24.18.0 and Yarn 1.22.22. With `nvm`: +```sh +nvm use +corepack enable +corepack prepare yarn@1.22.22 --activate +yarn install --frozen-lockfile ``` -git clone https://github.com/dimaip/calendar -cd calendar -yarn + +Start the backend described in the +[calendar-backend README](https://github.com/dimaip/calendar-backend), then run: + +```sh yarn start ``` -Or `yarn build` for production build. +## Verification + +```sh +yarn test +yarn quality +yarn build +yarn analyze:bundle +``` + +`yarn test` discovers every frontend `*.test.mjs` file below `app`. The full historical +lint and TypeScript backlogs remain available through `yarn lint`; `yarn quality` +uses per-file diagnostic ratchets so that existing debt can decrease without +blocking unrelated work. Run `yarn quality:baseline` only when intentionally +accepting a reviewed baseline change. + +`yarn analyze:bundle` reads the latest production build and reports initial raw +and gzip bytes, total JavaScript, and the generated precache footprint. + +## Browser support + +The web build targets maintained browsers with more than 0.5% global usage, the +latest two releases, and Firefox ESR. Browsers that their vendors no longer +support are excluded. This matches React 18's modern-browser baseline and avoids +shipping legacy ES5 polyfills in every initial load. Capacitor and TWA targets +remain unchanged and are verified through the production build and offline smoke +suite. diff --git a/app/Routes.tsx b/app/Routes.tsx index 0a3695f204..e474c28623 100644 --- a/app/Routes.tsx +++ b/app/Routes.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Route, Switch, Redirect, useHistory, useParams } from 'react-router-dom'; +import React, { Suspense, useEffect, useRef } from 'react'; +import { Navigate, Route, Routes as RouterRoutes, useLocation, useParams } from 'react-router-dom'; import Main from 'containers/Main/Main'; import NotFound from 'components/NotFound/NotFound'; import Readings from 'containers/Readings/Readings'; @@ -8,11 +8,10 @@ import Sermon from 'containers/Sermon/Sermon'; import Saint from 'containers/Saint/Saint'; import ThisDay from 'containers/ThisDay/ThisDay'; import Service from 'containers/Service/Service'; -import { Global, css as rcss } from '@emotion/react'; +import { Global, ThemeProvider, css as rcss } from '@emotion/react'; +import { css } from '@emotion/css'; import useDay from 'hooks/useDay'; import getTheme from 'styles/getTheme'; -import { ThemeProvider } from 'emotion-theming'; -import { css } from 'emotion'; import langState from 'state/langState'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { LangContext } from 'containers/Service/LangContext'; @@ -20,22 +19,42 @@ import pendingUpdateState from 'state/pendingUpdateState'; import UpdatePrompt from 'components/UpdatePrompt/UpdatePrompt'; import isParallelState from 'state/isParallel'; import { Promo } from 'components/Promo/Promo'; -import { Hymns } from 'containers/Hymns/Hymns'; -import { Hymn } from 'containers/Hymns/Hymn'; -import Profile from 'containers/Profile/Profile'; -import AddSharedVersion from 'containers/AddSharedVersion/AddSharedVersion'; import Loader from 'components/Loader/Loader'; import themeState from 'state/themeState'; -import SermonListContainer from 'containers/SermonList/SermonList'; -import SermonDetail from 'containers/SermonDetail/SermonDetail'; import SettingsMenu from 'containers/Main/SettingsMenu'; -import Updates from 'containers/Updates/Updates'; -import UpdatesAdmin from 'containers/Updates/UpdatesAdmin'; import checkVersion from './checkVersion'; +const Hymns = React.lazy(async () => { + const module = await import(/* webpackChunkName: "route-hymns" */ 'containers/Hymns/Hymns'); + return { default: module.Hymns }; +}); +const Hymn = React.lazy(async () => { + const module = await import(/* webpackChunkName: "route-hymns" */ 'containers/Hymns/Hymn'); + return { default: module.Hymn }; +}); +const Profile = React.lazy( + async () => await import(/* webpackChunkName: "route-profile" */ 'containers/Profile/Profile') +); +const AddSharedVersion = React.lazy( + async () => + await import(/* webpackChunkName: "route-shared-version" */ 'containers/AddSharedVersion/AddSharedVersion') +); +const SermonListContainer = React.lazy( + async () => await import(/* webpackChunkName: "route-sermons" */ 'containers/SermonList/SermonList') +); +const SermonDetail = React.lazy( + async () => await import(/* webpackChunkName: "route-sermons" */ 'containers/SermonDetail/SermonDetail') +); +const Updates = React.lazy( + async () => await import(/* webpackChunkName: "route-updates" */ 'containers/Updates/Updates') +); +const UpdatesAdmin = React.lazy( + async () => await import(/* webpackChunkName: "route-updates-admin" */ 'containers/Updates/UpdatesAdmin') +); + const DateRoutes = () => { - const { date } = useParams(); + const { date = '' } = useParams<'date'>(); const themeStateValue = useRecoilValue(themeState); const tomorrowDateObj = new Date(date); @@ -49,35 +68,17 @@ const DateRoutes = () => { return ( - - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* */} @@ -85,16 +86,29 @@ const DateRoutes = () => { ); }; -export default () => { +const Routes = () => { const langStateValue = useRecoilValue(langState); - const history = useHistory(); + const location = useLocation(); + const previousLocationKey = useRef(location.key); const setPendingUpdate = useSetRecoilState(pendingUpdateState); - history.listen(async () => { - const newVersion = await checkVersion(); - if (newVersion) { - setPendingUpdate(newVersion); + + useEffect(() => { + if (previousLocationKey.current === location.key) { + return undefined; } - }); + previousLocationKey.current = location.key; + + let active = true; + void checkVersion().then((newVersion) => { + if (active && newVersion) { + setPendingUpdate(newVersion); + } + }); + + return () => { + active = false; + }; + }, [location.key, setPendingUpdate]); const isParallel = useRecoilValue(isParallelState); const themeStateValue = useRecoilValue(themeState); const theme = getTheme(undefined, themeStateValue); @@ -125,54 +139,32 @@ export default () => { `} > - - { - const date = dateFormat(new Date(), 'yyyy-mm-dd'); - return ; - }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + }> + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); }; + +export default Routes; diff --git a/app/client.tsx b/app/client.tsx index dd2860236f..b35883f19f 100644 --- a/app/client.tsx +++ b/app/client.tsx @@ -1,14 +1,7 @@ // Signals to script in index.html that the app assets have been loaded -// import './wdyr'; -import './forEachPolyfill'; -import 'array-flat-polyfill'; -import 'unfetch/polyfill/index.js'; -import 'element-closest-polyfill'; -import 'regenerator-runtime/runtime.js'; import './sharePolyfill'; import React from 'react'; import { createRoot } from 'react-dom/client'; -import { AppContainer } from 'react-hot-loader'; import App from 'containers/App'; import { RecoilRoot } from 'recoil'; import TagManager from 'react-gtm-module'; @@ -21,7 +14,6 @@ import Worker from './precache.worker.js'; import './redirectToHome'; import { isCapacitor } from 'utils/deviceInfo'; import precache from 'precache.ts'; -import { SyncWithDB } from 'containers/RecoilSync'; window.APP_LOADED = true; const isProd = process.env.NODE_ENV === 'production'; @@ -46,33 +38,13 @@ if (isProd) { }); } -let preloadedState = {}; - -try { - preloadedState = localStorage.getItem('persistedState') ? JSON.parse(localStorage.getItem('persistedState')) : {}; -} catch (e) { - console.warn(e); -} - const rootElement = document.getElementById('react-root'); const root = createRoot(rootElement); -const render = (Component) => { - return root.render( - - - - - - ); -}; - -render(App); - -if (module.hot) { - module.hot.accept('containers/App', () => { - render(App); - }); -} +root.render( + + + +); if (isProd) { serviceWorker.register(); diff --git a/app/components/BottomNav/BottomNav.tsx b/app/components/BottomNav/BottomNav.tsx index 97237bd2cd..5845871fcc 100644 --- a/app/components/BottomNav/BottomNav.tsx +++ b/app/components/BottomNav/BottomNav.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { css } from 'emotion'; +import { css } from '@emotion/css'; import { Link, useParams } from 'react-router-dom'; -import { useTheme } from 'emotion-theming'; +import { useTheme } from '@emotion/react'; import CrossIcon from 'components/svgs/CrossIcon'; import Bible from 'components/svgs/Bible'; import Prayer from 'components/svgs/Prayer'; diff --git a/app/components/Button/Button.tsx b/app/components/Button/Button.tsx index 3e280c2408..f97b527579 100644 --- a/app/components/Button/Button.tsx +++ b/app/components/Button/Button.tsx @@ -1,5 +1,5 @@ import React, { forwardRef } from 'react'; -import { css } from 'emotion'; +import { css } from '@emotion/css'; const Button = forwardRef((props, ref) => ( - - - ); }; diff --git a/app/components/RouteErrorBoundary/RouteErrorBoundary.css b/app/components/RouteErrorBoundary/RouteErrorBoundary.css new file mode 100644 index 0000000000..0a89f24cc9 --- /dev/null +++ b/app/components/RouteErrorBoundary/RouteErrorBoundary.css @@ -0,0 +1,4 @@ +.route-error-boundary__button:focus { + outline: 2px solid currentColor; + outline-offset: 3px; +} diff --git a/app/components/RouteErrorBoundary/RouteErrorBoundary.tsx b/app/components/RouteErrorBoundary/RouteErrorBoundary.tsx new file mode 100644 index 0000000000..6e02368c1d --- /dev/null +++ b/app/components/RouteErrorBoundary/RouteErrorBoundary.tsx @@ -0,0 +1,127 @@ +import React, { Component, createRef } from 'react'; +import * as Sentry from '@sentry/react'; +import { useLocation } from 'react-router-dom'; + +import './RouteErrorBoundary.css'; + +const pageStyles: React.CSSProperties = { + alignItems: 'center', + boxSizing: 'border-box', + display: 'flex', + justifyContent: 'center', + minHeight: '100vh', + padding: '24px', +}; + +const messageStyles: React.CSSProperties = { + fontFamily: "'PTRootUI', 'Helvetica', sans-serif", + maxWidth: '520px', +}; + +const headingStyles: React.CSSProperties = { + fontSize: '24px', + marginBottom: '12px', +}; + +const actionsStyles: React.CSSProperties = { + display: 'flex', + flexWrap: 'wrap', + gap: '12px', + marginTop: '24px', +}; + +const buttonStyles: React.CSSProperties = { + border: '1px solid currentColor', + borderRadius: '4px', + cursor: 'pointer', + font: 'inherit', + padding: '10px 14px', +}; + +interface ApplicationErrorBoundaryProps { + children: React.ReactNode; + resetKey: string; +} + +interface ApplicationErrorBoundaryState { + error: Error | null; +} + +class ApplicationErrorBoundary extends Component { + state: ApplicationErrorBoundaryState = { + error: null, + }; + + private readonly headingRef = createRef(); + + static getDerivedStateFromError(error: Error): ApplicationErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + Sentry.captureException?.(error, { + extra: { + componentStack: errorInfo.componentStack, + }, + }); + this.headingRef.current?.focus(); + } + + componentDidUpdate(previousProps: ApplicationErrorBoundaryProps): void { + if (this.state.error && previousProps.resetKey !== this.props.resetKey) { + this.setState({ error: null }); + } + } + + private readonly retry = (): void => { + this.setState({ error: null }); + }; + + private readonly reload = (): void => { + window.location.reload(); + }; + + render(): React.ReactNode { + if (!this.state.error) { + return this.props.children; + } + + return ( +
+
+

+ Что-то пошло не так +

+

Не удалось открыть эту страницу. Можно попробовать ещё раз или перезагрузить приложение.

+
+ + +
+
+
+ ); + } +} + +const RouteErrorBoundary = ({ children }: { children: React.ReactNode }) => { + const location = useLocation(); + const resetKey = `${location.key}:${location.pathname}${location.search}${location.hash}`; + + return {children}; +}; + +export default RouteErrorBoundary; diff --git a/app/components/RteText/RteText.tsx b/app/components/RteText/RteText.tsx index 34722870b5..ac1aaa8c73 100644 --- a/app/components/RteText/RteText.tsx +++ b/app/components/RteText/RteText.tsx @@ -1,10 +1,15 @@ import React, { useRef } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import useAudio from 'hooks/useAudio'; -const RteText = React.forwardRef(({ html = '', className = '' }, ref) => { - const localRef = useRef(); +interface RteTextProps { + html?: string; + className?: string; +} + +const RteText = React.forwardRef(({ html = '', className = '' }, ref) => { + const localRef = useRef(null); const effectiveRef = ref || localRef; const theme = useTheme(); const htmlWithStrongSlashes = html diff --git a/app/components/ScriptEditor/ScriptEditorInput.tsx b/app/components/ScriptEditor/ScriptEditorInput.tsx index 0270f5e35c..1f58d01277 100644 --- a/app/components/ScriptEditor/ScriptEditorInput.tsx +++ b/app/components/ScriptEditor/ScriptEditorInput.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import Cross from 'components/svgs/Cross'; import Button from 'components/Button/Button'; import Pencil from 'components/svgs/Pencil'; diff --git a/app/components/ScriptEditor/ScriptEditorToggle.tsx b/app/components/ScriptEditor/ScriptEditorToggle.tsx index bbd6ce5be5..e720ab196a 100644 --- a/app/components/ScriptEditor/ScriptEditorToggle.tsx +++ b/app/components/ScriptEditor/ScriptEditorToggle.tsx @@ -1,12 +1,12 @@ import React from 'react'; import Button from 'components/Button/Button'; -import { css } from 'emotion'; +import { css } from '@emotion/css'; import { useRecoilState } from 'recoil'; import scriptEditorIsActiveState from 'state/scriptEditorIsActiveState'; import scriptVersionsState from 'state/scriptVersionsState'; import currentScriptVersionState from 'state/currentScriptVersion'; import Cross from 'components/svgs/Cross'; -import { useTheme } from 'emotion-theming'; +import { useTheme } from '@emotion/react'; import Pencil from 'components/svgs/Pencil'; const ScriptEditorToggle = ({ serviceId }) => { diff --git a/app/components/ScriptEditorPromo/ScriptEditorPromo.tsx b/app/components/ScriptEditorPromo/ScriptEditorPromo.tsx index 50422c41ac..9aef928c7e 100644 --- a/app/components/ScriptEditorPromo/ScriptEditorPromo.tsx +++ b/app/components/ScriptEditorPromo/ScriptEditorPromo.tsx @@ -1,14 +1,14 @@ import React from 'react'; -import { css } from 'emotion'; -import Button from '@material-ui/core/Button'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import Button from '@mui/material/Button'; +import { useTheme } from '@emotion/react'; import { useRecoilState } from 'recoil'; import TagManager from 'react-gtm-module'; import scriptEditorPromoDismissedState from 'state/scriptEditorPromoDismissedState'; -import { useHistory, useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; export const ScriptEditorPromo = (): JSX.Element | null => { - const history = useHistory(); + const navigate = useNavigate(); const { date } = useParams(); const theme = useTheme(); @@ -68,7 +68,7 @@ export const ScriptEditorPromo = (): JSX.Element | null => { setTimeout(() => { setPrompoDismissed(true); }, 0); - history.push(`/date/${date}/service/${new Date().getHours() < 13 ? 'matins' : 'vespers'}`); + void navigate(`/date/${date}/service/${new Date().getHours() < 13 ? 'matins' : 'vespers'}`); }} size="large" style={{ diff --git a/app/components/ScriptVersionSelector/DuplicateButton.tsx b/app/components/ScriptVersionSelector/DuplicateButton.tsx index 3c6910b8c8..431b3ca311 100644 --- a/app/components/ScriptVersionSelector/DuplicateButton.tsx +++ b/app/components/ScriptVersionSelector/DuplicateButton.tsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import Button from 'components/Button/Button'; import { Version } from 'state/scriptVersionsState'; import Duplicate from 'components/svgs/Duplicate'; diff --git a/app/components/ScriptVersionSelector/ScriptVersion.tsx b/app/components/ScriptVersionSelector/ScriptVersion.tsx index a2ba1507e7..06db1227d6 100644 --- a/app/components/ScriptVersionSelector/ScriptVersion.tsx +++ b/app/components/ScriptVersionSelector/ScriptVersion.tsx @@ -1,7 +1,7 @@ import scriptVersionsState, { Version } from 'state/scriptVersionsState'; import React, { useState } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import { useRecoilState } from 'recoil'; import { useSession } from 'containers/AuthProvider'; import Button from 'components/Button/Button'; diff --git a/app/components/ScriptVersionSelector/ScriptVersionSelector.tsx b/app/components/ScriptVersionSelector/ScriptVersionSelector.tsx index 10a02b8ff7..5b68737e82 100644 --- a/app/components/ScriptVersionSelector/ScriptVersionSelector.tsx +++ b/app/components/ScriptVersionSelector/ScriptVersionSelector.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import { useRecoilState } from 'recoil'; import Button from 'components/Button/Button'; import Drawer from 'components/Drawer/Drawer'; diff --git a/app/components/ScrollRestoration/ScrollRestoration.tsx b/app/components/ScrollRestoration/ScrollRestoration.tsx index 0dbe4962a4..1579e525fd 100644 --- a/app/components/ScrollRestoration/ScrollRestoration.tsx +++ b/app/components/ScrollRestoration/ScrollRestoration.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, memo, useRef } from 'react'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; +import React, { useEffect, useLayoutEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; export const scrollTo = (scrollnumber = 0): number => window.requestAnimationFrame(() => { @@ -14,57 +14,49 @@ export const getScrollPage = (): number => { return window.pageYOffset || docScrollTop; }; -interface Props { - visitedUrl: Map; -} +export default function ScrollRestoration(): null { + const location = useLocation(); + const locationRef = useRef(location); + const previousLocationRef = useRef(location); + const visitedUrlRef = useRef(new Map()); + + useLayoutEffect(() => { + const previousLocation = previousLocationRef.current; + const locationChanged = + (location.pathname !== previousLocation.pathname || location.search !== previousLocation.search) && + location.hash === ''; + + if (locationChanged) { + visitedUrlRef.current.set(previousLocation.pathname, getScrollPage()); + } -function ScrollRestoration({ history, visitedUrl }: RouteComponentProps & Props): null { - const handlePopStateChange = (): void => { - setTimeout(() => { - const { location } = history; - const { pathname } = location; - const existingRecord = visitedUrl.get(pathname); - scrollTo(existingRecord || 0); - }, 100); - }; + locationRef.current = location; + previousLocationRef.current = location; + }, [location]); useEffect(() => { + const pendingTimeouts = new Set(); + const handlePopStateChange = (): void => { + const timeoutId = window.setTimeout(() => { + pendingTimeouts.delete(timeoutId); + const existingRecord = visitedUrlRef.current.get(locationRef.current.pathname); + scrollTo(existingRecord || 0); + }, 100); + pendingTimeouts.add(timeoutId); + }; + window.addEventListener('popstate', handlePopStateChange); window.addEventListener('pushstate', handlePopStateChange); window.addEventListener('replacestate', handlePopStateChange); + return () => { window.removeEventListener('popstate', handlePopStateChange); window.removeEventListener('pushstate', handlePopStateChange); window.removeEventListener('replacestate', handlePopStateChange); + pendingTimeouts.forEach((timeoutId) => window.clearTimeout(timeoutId)); + pendingTimeouts.clear(); }; }, []); return null; } - -const ScrollRestorationWithRouter = withRouter( - memo(ScrollRestoration, (prevProps, nextProps) => { - const { location: prevLoaction, visitedUrl } = prevProps; - const { location: nextLoaction } = nextProps; - - const key = prevLoaction.pathname; - - const locationChanged = - (nextLoaction.pathname !== prevLoaction.pathname || nextLoaction.search !== prevLoaction.search) && - nextLoaction.hash === ''; - - const scroll = getScrollPage(); - - if (locationChanged) { - visitedUrl.set(key, scroll); - } - - return false; - }) -); - -export default function Wrapper(): JSX.Element { - const visitedUrl = useRef(new Map()); - - return ; -} diff --git a/app/components/SelectBox/SelectBox.tsx b/app/components/SelectBox/SelectBox.tsx index d696941ade..ae1763eed2 100644 --- a/app/components/SelectBox/SelectBox.tsx +++ b/app/components/SelectBox/SelectBox.tsx @@ -1,11 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import { useSelect } from 'downshift'; import Loader from 'components/Loader/Loader'; -import ExpandLess from '@material-ui/icons/ExpandLess'; -import ExpandMore from '@material-ui/icons/ExpandMore'; +import ExpandLess from '@mui/icons-material/ExpandLess'; +import ExpandMore from '@mui/icons-material/ExpandMore'; import { truncateText } from 'utils/truncateText'; import { truthy } from 'utils/truthy'; import Cross from 'components/svgs/Cross'; diff --git a/app/components/SermonList/SermonList.tsx b/app/components/SermonList/SermonList.tsx index 6b7a2244b6..a649805a84 100644 --- a/app/components/SermonList/SermonList.tsx +++ b/app/components/SermonList/SermonList.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import Loader from 'components/Loader/Loader'; import ErrorMessage500 from 'components/ErrorMessage500/ErrorMessage500'; import useFilteredSermons from 'hooks/useFilteredSermons'; @@ -26,7 +26,7 @@ export const SermonList = ({ authorId, themeId, limit }: { authorId?: string; th limit ? undefined : offset ); - const isLoading = sermonsStatus === 'loading' && offset === 0; + const isLoading = sermonsStatus === 'pending' && offset === 0; const isError = sermonsStatus === 'error'; // Reset when filters change diff --git a/app/components/SermonSmall/SermonSmall.tsx b/app/components/SermonSmall/SermonSmall.tsx index d6bf7d7660..91a53d357c 100644 --- a/app/components/SermonSmall/SermonSmall.tsx +++ b/app/components/SermonSmall/SermonSmall.tsx @@ -2,8 +2,8 @@ import React from 'react'; import ButtonBox from 'components/ButtonBox/ButtonBox'; import RightIcon from 'components/svgs/RightIcon'; import SwipeableLink from 'components/SwipeableLink/SwipeableLink'; -import { css } from 'emotion'; -import { useTheme } from 'emotion-theming'; +import { css } from '@emotion/css'; +import { useTheme } from '@emotion/react'; import { useLocation } from 'react-router-dom'; export interface Sermon { @@ -18,13 +18,7 @@ export const SermonSmall = ({ sermon, horizontal }: { sermon: Sermon; horizontal const theme = useTheme(); const location = useLocation(); return ( - + { const { toggleOpen } = useContext(DotsMenuContext); - const history = useHistory(); + const navigate = useNavigate(); return (