diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bf0122f07..691c21249f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: id: node_modules with: path: node_modules - key: node_modules5 + key: node_modules6 - run: yarn install - name: Find Deployment id: find-deployment @@ -62,7 +62,7 @@ jobs: id: node_modules with: path: node_modules - key: node_modules5 + key: node_modules6 - run: yarn install - run: ./script/validate-modified-books.bash env: @@ -86,7 +86,7 @@ jobs: id: node_modules with: path: node_modules - key: node_modules5 + key: node_modules6 - run: yarn install - run: yarn ci:test:${{ matrix.suite }} - uses: actions/upload-artifact@v2 @@ -107,6 +107,6 @@ jobs: id: node_modules with: path: node_modules - key: node_modules5 + key: node_modules6 - run: yarn install - run: yarn lint diff --git a/craco.config.js b/craco.config.js new file mode 100644 index 0000000000..dc66a50ec2 --- /dev/null +++ b/craco.config.js @@ -0,0 +1,39 @@ +const path = require('path'); + +module.exports = { + plugins: [{ + plugin: { + // Based on https://github.com/kevinsperrine/craco-workbox/blob/master/lib/index.js + overrideWebpackConfig: ({ webpackConfig, context: { env, paths } }) => { + if (env === "production") { + try { + const workboxConfig = require(path.join( + paths.appPath, + "workbox.config.js" + )); + + webpackConfig.plugins.forEach(plugin => { + if (plugin.constructor.name === "InjectManifest") { + plugin.config = workboxConfig(plugin.config); + } + }); + } catch (error) { + console.log("[craco.config.js - overrideWebpackConfig]"); + console.log(error.stack); + process.exit(1); + } + } + + return webpackConfig; + } + }, + }], + style: { + css: { + loaderOptions: { + // https://github.com/openstax/unified/issues/1469 + url: false, + }, + }, + }, +}; diff --git a/package.json b/package.json index 4ca8c291c7..b27199162d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@craco/craco": "^6.1.2", "@formatjs/intl-pluralrules": "^4.0.6", "@openstax/highlighter": "^1.9.0", "@openstax/open-search-client": "0.1.0", @@ -27,7 +28,7 @@ "react-intl": "^5.12.0", "react-loadable": "^5.5.0", "react-redux": "^7.1.0", - "react-scripts": "3.4.4", + "react-scripts": "^4.0.3", "redux": "^4.0.1", "redux-sentry-middleware": "^0.2.2", "reselect": "^4.0.0", @@ -36,10 +37,15 @@ "styled-components": "^4.3.2", "styled-icons": "^8.1.0", "typesafe-actions": "^4.4.2", - "typescript": "3.9.6", + "typescript": "^4.2.0", "url": "^0.11.0", "uuid": "^3.4.0", - "weak-map": "^1.0.5" + "weak-map": "^1.0.5", + "workbox-core": "^5.1.3", + "workbox-expiration": "^5.1.3", + "workbox-precaching": "^5.1.3", + "workbox-routing": "^5.1.3", + "workbox-strategies": "^5.1.3" }, "scripts": { "trust-localhost": "./script/trust-localhost.bash", @@ -49,12 +55,12 @@ "lint:css": "stylelint 'src/**/*.tsx'", "lint:bash": "shellcheck $(find . -type f \\( -iname '*\\.sh' -or -iname '*\\.bash' \\) | grep -v 'node_modules' )", "prestart": "npm run-script build:css", - "start": "HTTPS=${HTTPS:-true} react-scripts start", + "start": "HTTPS=${HTTPS:-true} craco start", "start:static": "export REACT_APP_ENV=${REACT_APP_ENV:-test} && npm run-script build && npm run-script prerender && npm run-script server", "clean": "rm -rf ./build", - "build": "npm run-script build:css && npm run-script build:js && ./script/doctor-workbox.bash", + "build": "npm run-script build:css && npm run-script build:js", "build:css": "lessc --source-map --source-map-include-source ./generic-styles/index.less ./src/content.css", - "build:js": "export REACT_APP_ENV=${REACT_APP_ENV:-production} && GENERATE_SOURCEMAP=${GENERATE_SOURCEMAP:-true} react-scripts build", + "build:js": "export REACT_APP_ENV=${REACT_APP_ENV:-production} && GENERATE_SOURCEMAP=${GENERATE_SOURCEMAP:-true} craco build", "build:clean": "npm run-script clean && npm run-script build", "prerender": "REACT_APP_ENV=${REACT_APP_ENV:-production} node ./script/entry prerender/index", "server": "REACT_APP_ENV=${REACT_APP_ENV:-development} node ./script/entry server/cli", @@ -76,7 +82,7 @@ "test:prerender:browser": "REACT_APP_ENV=test SERVER_MODE=built jest --testPathPattern=\"(\\.|/)browserspec\\.tsx?\" --config jest-puppeteer.config.json -i", "test:prerender:screenshots": "REACT_APP_ENV=test SERVER_MODE=built jest --testPathPattern=\"(\\.|/)screenshotspec\\.tsx?\" --config jest-puppeteer.config.json", "test:lighthouse-manual": "REACT_APP_ENV=test lighthouse --view --config-path=./src/test/audits/index.js", - "analyze:bundle": "react-scripts build && source-map-explorer 'build/static/js/*.js' -m", + "analyze:bundle": "craco build && source-map-explorer 'build/static/js/*.js' -m", "analyze:dom": "node ./script/entry.js domVisitor", "heroku-postbuild": "npm run-script build:clean" }, @@ -93,12 +99,13 @@ "@babel/core": "^7.0.0-0", "@babel/plugin-proposal-class-properties": "^7.1.0", "@babel/plugin-proposal-object-rest-spread": "^7.0.0", + "@babel/plugin-proposal-optional-chaining": "^7.13.12", "@babel/plugin-transform-runtime": "^7.1.0", "@babel/preset-env": "^7.1.6", "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.1.0", "@babel/register": "^7.0.0", - "@openstax/types": "^1.0.3", + "@openstax/types": "^2.0.0", "@types/color": "^3.0.0", "@types/express": "^4.16.0", "@types/flat": "^0.0.28", diff --git a/script/doctor-workbox.bash b/script/doctor-workbox.bash deleted file mode 100755 index 88ab278ade..0000000000 --- a/script/doctor-workbox.bash +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC2016 -# sed expression needs $s -set -e - -echo "HACKING WORKBOX SCRIPT WITH STRING REPLACEMENT" -echo "remove this when offical solution is merged https://github.com/facebook/create-react-app/pull/5369" - -function abs_path { - (cd "$(dirname '$1')" &>/dev/null && printf "%s/%s" "$PWD" "${1##*/}") -} - -build=$(abs_path "${BASH_SOURCE%/*}/../build") -worker="$build"/service-worker.js - -# add cache behaviors for 100% offline load -cp "$worker" "$worker".bak -head -n 39 "$worker".bak > "$worker" - -cat >> "$worker" <<- EOM -workbox.routing.registerRoute( - /https:\/\/buyprint\.openstax\.org/, - new workbox.strategies.StaleWhileRevalidate({ // can't use cachefirst because responses are opaque - cacheName: 'buy-print', - plugins: [ - new workbox.expiration.Plugin({ - maxEntries: 20, - }), - ], - }) -); - -workbox.routing.registerRoute( - /https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/mathjax\//, - new workbox.strategies.StaleWhileRevalidate({ // can't use cachefirst because responses are opaque - cacheName: 'cdn-assets', - plugins: [ - new workbox.expiration.Plugin({ - maxEntries: 100, // mathjax loads a lot of files - }), - ], - }) -); - -workbox.routing.registerRoute( - /\/cms\/assets\//, - new workbox.strategies.StaleWhileRevalidate({ - cacheName: 'cms-assets', - plugins: [ - new workbox.expiration.Plugin({ - maxEntries: 20, - }), - ], - }) -); - -workbox.routing.registerRoute( - /\/contents|resources|extras\//, - new workbox.strategies.CacheFirst({ - cacheName: 'book-content', - plugins: [ - new workbox.expiration.Plugin({ - maxEntries: 300, - }), - ], - }) -); - -workbox.routing.registerRoute( - /\/apps\/cms\/api\//, - new workbox.strategies.StaleWhileRevalidate({ - cacheName: 'cms-api', - plugins: [ - new workbox.expiration.Plugin({ - maxEntries: 20, - }), - ], - }) -); -EOM - -mkdir "$build"/books - -mv "$worker" "$build"/books/ diff --git a/src/app/components/Dropdown.tsx b/src/app/components/Dropdown.tsx index 7456035b6b..cc2532dccd 100644 --- a/src/app/components/Dropdown.tsx +++ b/src/app/components/Dropdown.tsx @@ -75,7 +75,7 @@ const TabHiddenDropDown = styled(( const container = React.useRef(null); const toggleElement = React.useRef(null); - useFocusLost(container, isOpen, () => setOpen(false)); + useFocusLost(container, isOpen, React.useCallback(() => setOpen(false), [setOpen])); useOnEsc(container, isOpen, () => { setOpen(false); if (toggleElement.current) { toggleElement.current.focus(); } diff --git a/src/app/content/components/BookBanner.spec.tsx b/src/app/content/components/BookBanner.spec.tsx index 29bd1a9d4a..6b39b02aed 100644 --- a/src/app/content/components/BookBanner.spec.tsx +++ b/src/app/content/components/BookBanner.spec.tsx @@ -24,7 +24,7 @@ describe('BookBanner', () => { resetModules(); window = assertWindow(); - delete window.location; + delete (window as any).location; window.location = { assign: jest.fn(), diff --git a/src/app/content/components/NudgeStudyTools/index.tsx b/src/app/content/components/NudgeStudyTools/index.tsx index e7c10b4c50..9f13cbda5e 100644 --- a/src/app/content/components/NudgeStudyTools/index.tsx +++ b/src/app/content/components/NudgeStudyTools/index.tsx @@ -48,7 +48,7 @@ const NudgeStudyTools = () => { if (positions) { document.body.style.overflow = 'hidden'; } - return () => { document.body.style.overflow = null; }; + return () => { document.body.style.overflow = ''; }; // document will not change // eslint-disable-next-line react-hooks/exhaustive-deps }, [positions]); diff --git a/src/app/content/components/NudgeStudyTools/utils.spec.tsx b/src/app/content/components/NudgeStudyTools/utils.spec.tsx index b56dd031e4..f4fdbff911 100644 --- a/src/app/content/components/NudgeStudyTools/utils.spec.tsx +++ b/src/app/content/components/NudgeStudyTools/utils.spec.tsx @@ -1,4 +1,4 @@ -import { ClientRect, HTMLElement } from '@openstax/types/lib.dom'; +import { DOMRect, HTMLElement } from '@openstax/types/lib.dom'; import * as Cookies from 'js-cookie'; import React from 'react'; import { Provider } from 'react-redux'; @@ -17,7 +17,7 @@ describe('usePositions', () => { let store: Store; // tslint:disable-next-line: variable-name let Component: (props: { isMobile: boolean }) => JSX.Element; - const mockRect = { bottom: 228, height: 25, left: 951, right: 1233, top: 203, width: 282 } as any as ClientRect; + const mockRect = { bottom: 228, height: 25, left: 951, right: 1233, top: 203, width: 282 } as any as DOMRect; beforeEach(() => { const document = assertDocument(); diff --git a/src/app/content/components/Page.spec.tsx b/src/app/content/components/Page.spec.tsx index 2e30689098..2317b39893 100644 --- a/src/app/content/components/Page.spec.tsx +++ b/src/app/content/components/Page.spec.tsx @@ -701,7 +701,7 @@ describe('Page', () => { event.initMouseEvent('click', event.cancelBubble, event.cancelable, - event.view, + assertWindow(), event.detail, event.screenX, event.screenY, diff --git a/src/app/content/components/Page/contentLinkHandler.ts b/src/app/content/components/Page/contentLinkHandler.ts index bcd712a33f..100437118b 100644 --- a/src/app/content/components/Page/contentLinkHandler.ts +++ b/src/app/content/components/Page/contentLinkHandler.ts @@ -88,7 +88,7 @@ export const contentLinkHandler = (anchor: HTMLAnchorElement, getProps: () => Co return; } - const base = new URL(assertWindow().location); + const base = new URL(assertWindow().location.href); base.hash = ''; base.search = ''; diff --git a/src/app/content/components/Page/highlightManager.spec.tsx b/src/app/content/components/Page/highlightManager.spec.tsx index aa8a76362c..f3cc924a07 100644 --- a/src/app/content/components/Page/highlightManager.spec.tsx +++ b/src/app/content/components/Page/highlightManager.spec.tsx @@ -70,7 +70,7 @@ describe('highlightManager', () => { }); afterEach(() => { - delete window.document.getSelection; + delete (window as any).document.getSelection; }); it('CardList is rendered initially', () => { diff --git a/src/app/content/components/Page/highlightManager.ts b/src/app/content/components/Page/highlightManager.ts index cbfb8eb4af..085f57198e 100644 --- a/src/app/content/components/Page/highlightManager.ts +++ b/src/app/content/components/Page/highlightManager.ts @@ -89,7 +89,7 @@ const onSelectHighlight = ( } if (services.getProp().hasUnsavedHighlight && !await showConfirmation()) { - assertWindow().getSelection().removeAllRanges(); + assertWindow().getSelection()?.removeAllRanges(); return; } diff --git a/src/app/content/components/utils/allImagesLoaded.spec.ts b/src/app/content/components/utils/allImagesLoaded.spec.ts index a1eadb9c03..a37cff07f9 100644 --- a/src/app/content/components/utils/allImagesLoaded.spec.ts +++ b/src/app/content/components/utils/allImagesLoaded.spec.ts @@ -14,6 +14,8 @@ describe('allImagesLoaded', () => { finished = false; element = assertDocument().createElement('div'); img = assertDocument().createElement('img'); + + Object.defineProperty(img, 'complete', { value: false, writable: true }); }); it('resolves when passed an element with no images', async() => { diff --git a/src/app/content/content.prerenderspec.ts b/src/app/content/content.prerenderspec.ts index 0210f75324..e0a49f04d4 100644 --- a/src/app/content/content.prerenderspec.ts +++ b/src/app/content/content.prerenderspec.ts @@ -35,6 +35,8 @@ describe('content', () => { ['[data-testid="toc"]', 'style'], ['[data-testid="search-results-sidebar"]', 'style'], ['[data-testid="loader"] path', 'style'], + // img src is changed from data:image/svg+xml;base64... to static path + ['[data-testid="navbar"] img', 'src'], ].forEach(([selector, attribute]) => { root.querySelectorAll(selector).forEach((element) => element.removeAttribute(attribute) diff --git a/src/app/content/highlights/components/CardWrapper.spec.tsx b/src/app/content/highlights/components/CardWrapper.spec.tsx index 0c3eab81d4..4c81681352 100644 --- a/src/app/content/highlights/components/CardWrapper.spec.tsx +++ b/src/app/content/highlights/components/CardWrapper.spec.tsx @@ -21,8 +21,7 @@ const dispatchKeyDownEvent = ( key: string, target?: HTMLElement ) => { - const keyboardEvent = window.document.createEvent('KeyboardEvent'); - keyboardEvent.initKeyboardEvent('keydown', true, true, window, key, 0, '', false, ''); + const keyboardEvent = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key, view: window }); if (target) { Object.defineProperty(keyboardEvent, 'target', { value: target }); } @@ -178,8 +177,8 @@ describe('CardWrapper', () => { // Update state with a height renderer.act(() => { - card1.props.onHeightChange({ current: { offsetHeight: 100 }}); - card2.props.onHeightChange({ current: { offsetHeight: 100 }}); + card1.props.onHeightChange({ current: { offsetHeight: 100 } }); + card2.props.onHeightChange({ current: { offsetHeight: 100 } }); }); // We are starting at 100 because of getHighlightTopOffset mock expect(card1.props.topOffset).toEqual(100); @@ -187,14 +186,14 @@ describe('CardWrapper', () => { // Noops when height is the same renderer.act(() => { - card1.props.onHeightChange({ current: { offsetHeight: 100 }}); + card1.props.onHeightChange({ current: { offsetHeight: 100 } }); }); expect(card1.props.topOffset).toEqual(100); expect(card2.props.topOffset).toEqual(100 + 100 + remsToPx(cardMarginBottom)); // Handle null value renderer.act(() => { - card1.props.onHeightChange({ current: { offsetHeight: null }}); + card1.props.onHeightChange({ current: { offsetHeight: null } }); }); // First card have null height so secondcard starts at the highlight top offset expect(card1.props.topOffset).toEqual(100); @@ -228,10 +227,10 @@ describe('CardWrapper', () => { // Update state with a height renderer.act(() => { - card1.props.onHeightChange({ current: { offsetHeight: 50 }}); - card2.props.onHeightChange({ current: { offsetHeight: 50 }}); - card3.props.onHeightChange({ current: { offsetHeight: 50 }}); - card4.props.onHeightChange({ current: { offsetHeight: 50 }}); + card1.props.onHeightChange({ current: { offsetHeight: 50 } }); + card2.props.onHeightChange({ current: { offsetHeight: 50 } }); + card3.props.onHeightChange({ current: { offsetHeight: 50 } }); + card4.props.onHeightChange({ current: { offsetHeight: 50 } }); }); expect(card1.props.topOffset).toEqual(0); // first card have only 50px height + 20px margin bottom expect(card2.props.topOffset).toEqual(100); // so the second cards starts and the highlight level which is 100 diff --git a/src/app/content/highlights/components/CardWrapper.tsx b/src/app/content/highlights/components/CardWrapper.tsx index 8675dfefdc..61ae47a144 100644 --- a/src/app/content/highlights/components/CardWrapper.tsx +++ b/src/app/content/highlights/components/CardWrapper.tsx @@ -58,7 +58,7 @@ const Wrapper = ({highlights, className, container, highlighter}: WrapperProps) // Clear shouldFocusCard when focus is lost from the CardWrapper. // If we don't do this then card related for the focused highlight will be focused automatically. - useFocusLost(element, shouldFocusCard, () => setShouldFocusCard(false)); + useFocusLost(element, shouldFocusCard, React.useCallback(() => setShouldFocusCard(false), [setShouldFocusCard])); const onHeightChange = React.useCallback((id: string, ref: React.RefObject) => { const height = ref.current && ref.current.offsetHeight; diff --git a/src/app/content/highlights/components/EditCard.tsx b/src/app/content/highlights/components/EditCard.tsx index 22d7bad287..e1aa437431 100644 --- a/src/app/content/highlights/components/EditCard.tsx +++ b/src/app/content/highlights/components/EditCard.tsx @@ -115,7 +115,7 @@ const EditCard = React.forwardRef((props, ref) => { })); trackEditNoteColor(color); } else { - assertWindow().getSelection().removeAllRanges(); + assertWindow().getSelection()?.removeAllRanges(); props.onCreate(); trackCreateNote(isDefault ? 'default' : color); } diff --git a/src/app/content/highlights/components/Note.tsx b/src/app/content/highlights/components/Note.tsx index aa0833bece..674fdc17ef 100644 --- a/src/app/content/highlights/components/Note.tsx +++ b/src/app/content/highlights/components/Note.tsx @@ -36,7 +36,7 @@ const TextArea = styled.textarea` // tslint:disable-next-line:variable-name const Note = ({onChange, onFocus, note, textareaRef}: Props) => { - const setTextAreaHeight = () => { + const setTextAreaHeight = React.useCallback(() => { const element = textareaRef.current; if (!element) { return; @@ -45,9 +45,9 @@ const Note = ({onChange, onFocus, note, textareaRef}: Props) => { if (element.scrollHeight > element.offsetHeight) { element.style.height = `${element.scrollHeight + 5}px`; } - }; + }, [textareaRef]); - React.useEffect(setTextAreaHeight, [note]); + React.useEffect(setTextAreaHeight, [note, setTextAreaHeight]); return