diff --git a/frontend/package.json b/frontend/package.json index 97df19b91..405f57179 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,7 +46,7 @@ "react-dom": "^18.2.0", "react-i18next": "^13.3.1", "react-redux": "^8.1.3", - "react-router-dom": "6.18.0", + "react-router-dom": "6.13.0", "redux-saga": "^1.2.3" }, "devDependencies": { @@ -110,14 +110,7 @@ "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1" }, - "@availableBiggerUpdates": [ - "loader-utils", - "@mui/x-date-pickers", - "i18next", - "i18next-browser-languagedetector", - "react-router-dom", - "prettier + eslint-plugin-prettier", - "stylelint + configs", - "typescript" - ] + "@comments": { + "react-router-dom": "Do not update past 6.13.0 until useBlocker is fixed, see https://github.com/remix-run/react-router/issues/11155" + } } diff --git a/frontend/packages/akr/package.json b/frontend/packages/akr/package.json index 498591adf..98e182159 100644 --- a/frontend/packages/akr/package.json +++ b/frontend/packages/akr/package.json @@ -22,6 +22,6 @@ "akr:tslint": "yarn g:tsc --pretty --noEmit" }, "dependencies": { - "shared": "npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.9.31" + "shared": "npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.9.32" } } diff --git a/frontend/packages/otr/package.json b/frontend/packages/otr/package.json index 6d794546f..791806436 100644 --- a/frontend/packages/otr/package.json +++ b/frontend/packages/otr/package.json @@ -25,6 +25,6 @@ "otr:tslint": "yarn g:tsc --pretty --noEmit" }, "dependencies": { - "shared": "npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.9.31" + "shared": "npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.9.32" } } diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index bd16984a2..c511cd03a 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@opetushallitus/kieli-ja-kaantajatutkinnot.shared", - "version": "1.9.31", + "version": "1.9.32", "description": "Shared Frontend Package", "exports": { "./components": "./src/components/index.tsx", diff --git a/frontend/packages/shared/src/components/ScrollToTop/ScrollToTop.tsx b/frontend/packages/shared/src/components/ScrollToTop/ScrollToTop.tsx index fa101a7df..c5b06772a 100644 --- a/frontend/packages/shared/src/components/ScrollToTop/ScrollToTop.tsx +++ b/frontend/packages/shared/src/components/ScrollToTop/ScrollToTop.tsx @@ -1,20 +1,12 @@ -import { History } from 'history'; -import { useContext, useLayoutEffect } from 'react'; -import { UNSAFE_NavigationContext } from 'react-router-dom'; +import { useLayoutEffect } from 'react'; +import { useLocation } from 'react-router-dom'; export const ScrollToTop = () => { - // FIXME: UNSAFE_NavigationContext is no longer allowed in react router 6 - /* - const navigator = useContext(UNSAFE_NavigationContext).navigator as History; + const location = useLocation(); + useLayoutEffect(() => { - const unlisten = navigator.listen(({ action }) => { - if (action !== 'POP') { - window.scrollTo({ left: 0, top: 0 }); - } - }); + window.scrollTo({ left: 0, top: 0 }); + }, [location]); - return unlisten; - }, [navigator]); - */ return null; }; diff --git a/frontend/packages/shared/src/hooks/useNavigationProtection/useCallbackPrompt.ts b/frontend/packages/shared/src/hooks/useNavigationProtection/useCallbackPrompt.ts index 4b8f0f573..87b16f11c 100644 --- a/frontend/packages/shared/src/hooks/useNavigationProtection/useCallbackPrompt.ts +++ b/frontend/packages/shared/src/hooks/useNavigationProtection/useCallbackPrompt.ts @@ -1,10 +1,8 @@ -import { Blocker, Transition } from 'history'; import { useCallback, useEffect, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router'; - -import { useBlocker } from './useBlocker'; export const useCallbackPrompt = (when: boolean, baseUrl?: string) => { + + /* const navigate = useNavigate(); const location = useLocation(); const [showPrompt, setShowPrompt] = useState(false); @@ -65,6 +63,6 @@ export const useCallbackPrompt = (when: boolean, baseUrl?: string) => { }, [isNavigationConfirmed, blockedTransition, navigate, isWithinBaseUrl]); useBlocker(handleBlockedNavigation, when, baseUrl); - + */ return { showPrompt, confirmNavigation, cancelNavigation }; }; diff --git a/frontend/packages/shared/src/hooks/useNavigationProtection/useNavigationProtection.ts b/frontend/packages/shared/src/hooks/useNavigationProtection/useNavigationProtection.ts index 29888d3d1..45f0b2ff8 100644 --- a/frontend/packages/shared/src/hooks/useNavigationProtection/useNavigationProtection.ts +++ b/frontend/packages/shared/src/hooks/useNavigationProtection/useNavigationProtection.ts @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; +import { useEffect, useCallback } from 'react'; +import { flushSync } from "react-dom"; -import { useCallbackPrompt } from './useCallbackPrompt'; +import { unstable_useBlocker as useBlocker } from 'react-router-dom'; export const useNavigationProtection = ( when: boolean, @@ -10,14 +11,26 @@ export const useNavigationProtection = ( ) => void, baseUrl?: string ) => { - const { showPrompt, confirmNavigation, cancelNavigation } = useCallbackPrompt( - when, - baseUrl - ); + + const shouldBlock = + ({ currentLocation, nextLocation, historyAction }) => + when && + !nextLocation.pathname.includes(baseUrl) && + currentLocation.pathname !== nextLocation.pathname; + + const blocker = useBlocker(shouldBlock); + + const confirmNavigation = useCallback(() => { + blocker.proceed(); + }, [blocker]); + + const cancelNavigation = useCallback(() => { + blocker.reset(); + }, [blocker]); useEffect(() => { - if (showPrompt) { + if (blocker.state === "blocked") { showConfirmationDialog(confirmNavigation, cancelNavigation); } - }, [showPrompt, confirmNavigation, cancelNavigation, showConfirmationDialog]); + }, [blocker, confirmNavigation, cancelNavigation, showConfirmationDialog]); }; diff --git a/frontend/packages/vkt/package.json b/frontend/packages/vkt/package.json index 1914e87a4..b74b72d7b 100644 --- a/frontend/packages/vkt/package.json +++ b/frontend/packages/vkt/package.json @@ -26,6 +26,6 @@ }, "dependencies": { "reduxjs-toolkit-persist": "^7.2.1", - "shared": "npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.9.31" + "shared": "npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.9.32" } } diff --git a/frontend/packages/vkt/public/mockServiceWorker.js b/frontend/packages/vkt/public/mockServiceWorker.js index 51d85eeeb..78933b6fe 100644 --- a/frontend/packages/vkt/public/mockServiceWorker.js +++ b/frontend/packages/vkt/public/mockServiceWorker.js @@ -2,13 +2,14 @@ /* tslint:disable */ /** - * Mock Service Worker (1.3.2). + * Mock Service Worker (2.0.4). * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. */ -const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const INTEGRITY_CHECKSUM = '0877fcdc026242810f5bfde0d7178db4' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() self.addEventListener('install', function () { @@ -86,12 +87,6 @@ self.addEventListener('message', async function (event) { self.addEventListener('fetch', function (event) { const { request } = event - const accept = request.headers.get('accept') || '' - - // Bypass server-sent events. - if (accept.includes('text/event-stream')) { - return - } // Bypass navigation requests. if (request.mode === 'navigate') { @@ -112,29 +107,8 @@ self.addEventListener('fetch', function (event) { } // Generate unique request ID. - const requestId = Math.random().toString(16).slice(2) - - event.respondWith( - handleRequest(event, requestId).catch((error) => { - if (error.name === 'NetworkError') { - console.warn( - '[MSW] Successfully emulated a network error for the "%s %s" request.', - request.method, - request.url, - ) - return - } - - // At this point, any exception indicates an issue with the original request/response. - console.error( - `\ -[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, - request.method, - request.url, - `${error.name}: ${error.message}`, - ) - }), - ) + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) }) async function handleRequest(event, requestId) { @@ -146,21 +120,29 @@ async function handleRequest(event, requestId) { // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { ;(async function () { - const clonedResponse = response.clone() - sendToClient(client, { - type: 'RESPONSE', - payload: { - requestId, - type: clonedResponse.type, - ok: clonedResponse.ok, - status: clonedResponse.status, - statusText: clonedResponse.statusText, - body: - clonedResponse.body === null ? null : await clonedResponse.text(), - headers: Object.fromEntries(clonedResponse.headers.entries()), - redirected: clonedResponse.redirected, + const responseClone = response.clone() + // When performing original requests, response body will + // always be a ReadableStream, even for 204 responses. + // But when creating a new Response instance on the client, + // the body for a 204 response must be null. + const responseBody = response.status === 204 ? null : responseClone.body + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseBody, + headers: Object.fromEntries(responseClone.headers.entries()), + }, }, - }) + [responseBody], + ) })() } @@ -196,20 +178,20 @@ async function resolveMainClient(event) { async function getResponse(event, client, requestId) { const { request } = event - const clonedRequest = request.clone() + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() function passthrough() { - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const headers = Object.fromEntries(clonedRequest.headers.entries()) + const headers = Object.fromEntries(requestClone.headers.entries()) - // Remove MSW-specific request headers so the bypassed requests - // comply with the server's CORS preflight check. - // Operate with the headers as an object because request "Headers" - // are immutable. - delete headers['x-msw-bypass'] + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention'] - return fetch(clonedRequest, { headers }) + return fetch(requestClone, { headers }) } // Bypass mocking when the client is not active. @@ -227,31 +209,36 @@ async function getResponse(event, client, requestId) { // Bypass requests with the explicit bypass header. // Such requests can be issued by "ctx.fetch()". - if (request.headers.get('x-msw-bypass') === 'true') { + const mswIntention = request.headers.get('x-msw-intention') + if (['bypass', 'passthrough'].includes(mswIntention)) { return passthrough() } // Notify the client that a request has been intercepted. - const clientMessage = await sendToClient(client, { - type: 'REQUEST', - payload: { - id: requestId, - url: request.url, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - mode: request.mode, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: await request.text(), - bodyUsed: request.bodyUsed, - keepalive: request.keepalive, + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, }, - }) + [requestBuffer], + ) switch (clientMessage.type) { case 'MOCK_RESPONSE': { @@ -261,21 +248,12 @@ async function getResponse(event, client, requestId) { case 'MOCK_NOT_FOUND': { return passthrough() } - - case 'NETWORK_ERROR': { - const { name, message } = clientMessage.data - const networkError = new Error(message) - networkError.name = name - - // Rejecting a "respondWith" promise emulates a network error. - throw networkError - } } return passthrough() } -function sendToClient(client, message) { +function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { const channel = new MessageChannel() @@ -287,17 +265,28 @@ function sendToClient(client, message) { resolve(event.data) } - client.postMessage(message, [channel.port2]) + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) }) } -function sleep(timeMs) { - return new Promise((resolve) => { - setTimeout(resolve, timeMs) +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, }) -} -async function respondWithMock(response) { - await sleep(response.delay) - return new Response(response.body, response) + return mockedResponse } diff --git a/frontend/packages/vkt/src/App.tsx b/frontend/packages/vkt/src/App.tsx index 94fbbaf02..b4f636e84 100644 --- a/frontend/packages/vkt/src/App.tsx +++ b/frontend/packages/vkt/src/App.tsx @@ -1,6 +1,6 @@ import { ThemeProvider } from '@mui/material/styles'; import { Provider } from 'react-redux'; -import { NotifierContextProvider, StyleCacheProvider } from 'shared/components'; +import { StyleCacheProvider } from 'shared/components'; import { theme } from 'shared/configs'; import { initI18n } from 'configs/i18n'; @@ -16,9 +16,7 @@ export const App = () => ( - - - + diff --git a/frontend/packages/vkt/src/routers/AppRouter.tsx b/frontend/packages/vkt/src/routers/AppRouter.tsx index 26dffb7d3..d56045b36 100644 --- a/frontend/packages/vkt/src/routers/AppRouter.tsx +++ b/frontend/packages/vkt/src/routers/AppRouter.tsx @@ -1,7 +1,17 @@ import { FC } from 'react'; -import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { + createBrowserRouter, + createRoutesFromElements, + Outlet, + Route, + RouterProvider, +} from 'react-router-dom'; import { PersistGate } from 'reduxjs-toolkit-persist/integration/react'; -import { Notifier, ScrollToTop } from 'shared/components'; +import { + Notifier, + NotifierContextProvider, + ScrollToTop, +} from 'shared/components'; import { TitlePage } from 'shared/utils'; import { Footer } from 'components/layouts/Footer'; @@ -31,165 +41,172 @@ export const AppRouter: FC = () => { const createTitle = (title: string) => translateCommon('pageTitle.' + title) + ' - ' + appTitle; - const FrontPage = ( - - - - ); - - return ( - -
+ const Root = ( +
+
- - - - - - - - } + +
+
+
+ +
+ ); + + const FrontPage = ( + + + + ); + + const router = createBrowserRouter( + createRoutesFromElements( + + + + + - - - - } + + } + /> + + - - - - } + + } + /> + + - - - - } + + } + /> + + - - - - } + + } + /> + + - - - - } + + } + /> + + - - - - } + + } + /> + + - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - -
- -