-
Notifications
You must be signed in to change notification settings - Fork 6
Upgrade to React Router v7 #1898
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
RR6+ only allows <Route/> and <React.Fragment/> as children of a <Routes/> block, even if the component returns one of these. As such, I have moved the tracking part of tracked routes into onPageLoad, the figure numbering into a parent of the router (TODO: test this works), and replaced StaticPageRoutes and ExternalRedirects with <Route/>-based equivalents.
The browser managed to detect and stop this when going to the page, but the jest 'browser' does not have this feature
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1898 +/- ##
==========================================
- Coverage 42.93% 42.65% -0.28%
==========================================
Files 572 572
Lines 23971 24039 +68
Branches 7877 7052 -825
==========================================
- Hits 10291 10255 -36
- Misses 13041 13738 +697
+ Partials 639 46 -593 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
A `<BrowserRouter/>` is apparently a different, simpler type of router to that generated via `createBrowserRouter` (the latter being a Data Router). Data Routers allow navigation blocking, so this change requires a mini overhaul of how routes are loaded into the app.
sjd210
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I spent a while looking at this branch, but annoyingly half the issues I found were caused by a merge conflict that was my fault rather than anything to do with your changes 🙃.
Anyway, the branch is mostly pretty good. I quite like the changes that React Router makes from v5 -> v6 in general and TrackedRoute was already quite bloated by including things other than tracking so I like it being separated out anyway.
The most significant thing that's actually not working is the various history.pushStates around the code. The rest is just some of my style preferences, or errors in tests from your last-minute page-blocking change. Nothing too much :)
src/app/services/user.ts
Outdated
| * can only ever be partially logged-in. | ||
| */ | ||
| export function isNotPartiallyLoggedIn(user?: {readonly role?: UserRole, readonly loggedIn?: boolean, readonly teacherAccountPending?: boolean, readonly EMAIL_VERIFICATION_REQUIRED?: boolean} | null): boolean { | ||
| export function isNotPartiallyLoggedIn(user?: {readonly role?: UserRole, readonly loggedIn?: boolean, readonly teacherAccountPending?: boolean, readonly EMAIL_VERIFICATION_REQUIRED?: boolean} | null): user is LoggedInUser { // TODO this type guard is questionable, as non-logged in users pass this check. it seems to only be used as a "fully logged in" check though. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This annoys me, and it already did before the type guard.
- Firstly, if we're only using it as a "fully logged in" check then we should actually enforce that here. I don't see any reason we'd want to specifically check for a user who is either fully logged in or not at all (and if so we should be more explicit than just using this function) - and changing it means then this new type guard would match.
- Secondly, "partially logged in" is a term used in the backend to refer to users needing verification OR in the middle of the MFA process. This isn't currently consistent with the MFA part.
- Thirdly, I just find it a little odd style-wise to have a negative asserting function among otherwise only positive ones.
I'd much prefer something similar like this, which addresses all of those points:
export function isFullyLoggedIn(user?: {readonly role?: UserRole, readonly loggedIn?: boolean, readonly teacherAccountPending?: boolean, readonly EMAIL_VERIFICATION_REQUIRED?: boolean} | null): user is LoggedInUser {
return isLoggedIn(user) && !(isAda && (isTeacherAccountPending(user) || user?.EMAIL_VERIFICATION_REQUIRED));
}(+ this allows us to slightly simplify some expressions in the rest of the code by removing the extra logged in check)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The type doesn't work in the one you've provided as the user roles are inconsistent. I've done some cleanup to make it so we can use this but it's not as easy as I was hoping 😣
src/app/state/actions/index.tsx
Outdated
| target = "/dashboard"; | ||
| } | ||
| history.push(target); | ||
| history.pushState(undefined, "", target); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is what's causing the page content to be unable to load after logging in. history.pushState is able to set the browser's url state to the target url but doesn't actually do anything to load the associated page (at least not without manually reloading, or something like fireEvent(window, new PopStateEvent('popstate')); as we have in test files).
This is causing issues for most if not all of the history.pushStates that you add in this PR - superseded questions are quite an easy one to test it on. I think that we should just be using a void navigate in each of these scenarios, even if that means tunneling the navigate through from a component using the hook as we do for handleProviderCallback above. I'd also just prefer that for consistency anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While I agree with the latter point in principle, and while a couple of the remaining instances of pushState are probably doable with some horrible drilling, I'm not sure it's actually possible to get a valid navigate instance inside the RTK slices, which use navigation here and here. The types and fn arguments etc. all appear to come from the RTK library itself. So I'm fairly sure we need some other way of doing it, regrettably.
For your first point, yep, that makes sense. fireEvent is a RTL thing so we can't use it outside of tests, but I suppose a window.refresh() or something would be equivalent. So having one of those after each is (a horrible) option #1.
As an alternative, I've found that the router object returned by createBrowserRouter actually does provide a .navigate function directly which we can use. Unfortunately owing to its position in the import hierarchy, we can't just import { router } from ... without creating circular dependencies. I've done something a little horrible and put a navigateComponentless function on window – initially as a proof of concept and now because I can't think of anything better – when the router gets created. This... seems to work! I really don't like where it is and would much prefer importing it from somewhere but I can't think of anywhere more suitable. Any ideas?
The AuthenticationResponseDTO type from the backend was quite incorrect, and the code was expecting a EMAIL_VERIFICATION_REQUIRED field on a (full) user object when this can never happen. I've adjusted the code to improve this.
I was misinformed of what responses could be generated 😔
you know it would be so great if eslint gave me all the warnings in one go
|
There's a single test still failing on this, but I have to run now. Feel free to pick up if anyone can, I'll take a look on Monday if not. |
(n.b. this branch is over the top of the Vite one. we should rebase if we want to merge this in first)
Upgrades React Router to v7.11.0. The main changes come from v5 => v6; you can read migration guides for v5 => v6 and v6 => v7 in detail on the RR docs. For us, the main changes are:
useHistory
The docs are far more complete than what I have written. Read if needed.
useHistoryhas been replaced byuseNavigate. To me, it seems like the aim is to better distinguishhistoryinto getting information about the current URL (for whichuseLocationcan still be used) and push/replace things on the history stack (for whichuseNavigateis now used).Also, some other things navigate can do:
Note that
stateis in the options param in the last one.Top-level
historyThere is no longer an accessible top-level, out-of-component
historyobject that is shared by the router.window.historystill exists but is the HTML5 API as opposed to the old History object, so has different syntax. From my testing, it seems like state pushed to the HTML5 one is not detectable by the router'suseLocationhook, so we should always prefer useNavigate over anyhistory.pushState, and it is required to do so when state is involved. There are some situations where replacement is not easy owing touseNavigatebeing a hook, so not all have been replaced yet – but this is something to work towards.Routes and TrackedRoutes
RR6+ allows only
<Route/>objects inside the<Routes/>(previously<Switch/>) component. This is strict – not even components that produce a<Route/>are allowed. As such,<TrackedRoute />has been entirely removed.<TrackedRoute/>as a component did 3 things on top of a regular<Route/>, all of which have necessarily moved:FigureNumberingContextto child components.1. Tracking
The tracking inside each
TrackedRouteessentially boiled down to this:All this does is track a page view each time the location changes. We already have an
OnPageLoadcomponent which does things like manage scrolling to the top or resetting focus when the location changes, so I have moved the logic for this there. The downside(?) of this approach is that we can't 'pick' which pages get tracked and which don't – everything does, always. I'm not certain whether there was a reason we weren't doing this for all routes before, but this is a noticeable difference.2. Authentication
The authentication logic has been moved into a new component
<RequireAuth />; this needs to exist inside the replacement<Route />'selementprop, where theauthprop is the same as the oldifUserprop. e.g:becomes
This seems overcomplicated (why are we introducing a new component for this?), but bear with me. RR is moving towards a "element-first" approach (see this for their explanation), i.e. they prefer
element={<MyAccount />}overcomponent={MyAccount}. The main advantage is being able to pass props directly, rather than requiring the old-stylecomponentPropsfunctionality. Unfortunately, this throws a bit of a spanner in our type system. Before, sinceuserwas validated to aLoggedInUserinside the auth logic, we could pass a validated user to a component there, meaning the user type was known to never be undefined / null / not logged in. With the new approach, we need the user at the top level, since we want to pass it in when we create the element:This is the advantage of having a separate authentication component:
<RequireAuth />allows elements to be defined as a function, producing the result given the authenticated user:<RequireAuth />then runs the authentication logic and, if it passes, renders this component with the correctly-typed user. If it does not pass, the usual redirects are used instead.3. FigureNumberingContext
The last feature that
TrackedRouteprovided was wrapping each page in a fresh figure numbering context. Since we want this on every page and putting it inside theRoutes where they were before requires pasting it into each component separately, I have moved the logic above the routes, wrapping the entire<Routes/>component in one context and resetting it on location change. It's worth testing that this still works in all cases.Redirects
In short,
<Redirect to="..." />s are replaced with<Navigate to="..."/>. A minor difference is that Redirects used to work as top-level<Route />components, whereas now since every top-level component in a<Routes/>block must be a<Route/>,<Navigate />s must be wrapped inside a<Route/>. The oldfromprop inside theRedirectnow exists as theRoute'spathprop.Note the
replace– the default behaviour in RR5 was to replace a history entry. In RR7, the default behaviour is to push a new one, so if we want to maintain the old behaviour we need thereplaceprop.isRole function types
These functions haven't changed, but I have replaced the
=> booleantypes withuser is LoggedInUser & {readonly role: "ROLE"}type guards. This enables the correct typing ofauthUserwhen used as theauthprop in<RequireAuth />. The& {role...}type is simply to prevent the following from inferringuseras typenever:To consider:
<Routes />by authentication requirement using an<Outlet />as the return of<RequireAuth />, which would limit the number of<RequireAuth />s to as many roles as there are across the entire app – see this SO answer. Thoughts?To fix:
blocking on page leave - see TODOs(these are small! reviewing what is here is possible in the meantime)