-
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.35% 42.24% -0.12%
==========================================
Files 575 575
Lines 24396 24474 +78
Branches 8069 7198 -871
==========================================
+ Hits 10334 10339 +5
- Misses 13398 14089 +691
+ Partials 664 46 -618 ☔ 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.
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. |
Vrt/feature/react-router-v7
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.
Very nearly there! Just some small comments on your most recent changes now.
| <Route path="/pages/:pageId" element={<Generic />} /> | ||
| <Route path="/concepts/:conceptId" element={<Concept />} /> | ||
| <Route path="/questions/:questionId" element={<RequireAuth auth={isNotPartiallyLoggedIn} element={<Question />} />} /> | ||
| <Route path="/questions/:questionId" element={<RequireAuth auth={isTeacherPending} element={<Question />} />} /> |
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 stopping ALL users from accessing questions!
| <Route path="/questions/:questionId" element={<RequireAuth auth={isTeacherPending} element={<Question />} />} /> | |
| <Route path="/questions/:questionId" element={<RequireAuth auth={(user) => !isTeacherPending(user)} element={<Question />} />} /> |
Although this does raise an interesting point that RequireAuth already has its own isTeacherPending check that kicks in before this auth ever does. I don't think there's actually much benefit in having an auth-less RequireAuth, so just food for thought
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.
Ah, missed a few of these negations. I was changing both what the function did and negating its name, got a little confusing 😔. Thanks for spotting.
Your second point is interesting. The overall effect is essentially the same as there being an implicit <RequireAuth auth={(user) => !isTeacherPending(user)} element={...} /> wrapped around every other <RequireAuth /> currently, so I suppose one way we could restructure the routes to get around this is to use the nested-auth approach mentioned at the bottom of the original comment on this PR to make this implicit check explicit, then remove the !isTeacherPending check from inside the <RequireAuth /> component.
It's quite a big change given we need to move all the requiring-auth routes together in IsaacApp, so I'd probably want to do it separately to this PR (if we even want to). For now, I think I prefer your suggestion here, since even though this logic is never used, it makes it obvious why we need this route to require authentication, which is something we don't get with an auth-element-less <RequireAuth />.
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.
Hrm, this is tricky. There are a couple of alternative ways of doing this that I could think of but I'm not particularly fond of any of them.
I too am surprised that the navigateComponentless function seems to be just working (and I think we should keep an eye on it, in case something does start going weird) but I'm happy with that! I would probably prefer moving all of the RootLayout + roots + navigateComponentless to a new file in the navigate folder, but practically speaking that doesn't make much difference.
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.
Ah fair enough. I will admit I hadn't actually checked whether what I had written worked.
I'm much happier with this new approach not having the questionable type guard and inconsistent language, so I certainly won't complain (except a little about the associated comment, see below).
Apply suggestions from code review Co-authored-by: Sol <94075844+sjd210@users.noreply.github.com>
(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:
logging in requires a reload to show content? (ada)thank you Sol!blocking on page leave - see TODOs(these are small! reviewing what is here is possible in the meantime)