Upgrading React Router 7 to 8 in a small server side application
What I had to change in R.O.B.O. Host when I moved it from React Router 7 to 8: the load context, meta, and the request which loaders get.
R.O.B.O. Host, the server from the previous post, uses React Router in framework mode. This week I moved it from version 7.18 to 8.4. I was a little afraid of it, but my own code needed only about 40 lines of changes in 8 files. I am not sure that my case is typical, because I use only a part of React Router, so please read this as notes from one project.
I did not use any future flags in version 7, so all the changes came at the same moment. There were three.
The load context
The server passes the address of the device to loaders and actions. In version 7 the context was a plain object, and its type came from module augmentation of AppLoadContext:
getLoadContext: (_req, client) => ({ client: client.address }),In version 8 it is a RouterContextProvider, and every value has its own key from createContext:
const context = new RouterContextProvider();
context.set(clientContext, client.address);
return context;A route reads it with context.get(clientContext).
There was one detail which is maybe special to my build. The server entry and the React Router build are two separate bundles, and each of them has its own copy of the module with the key. So createContext would run two times, and the server and the routes would use two different keys. I keep the key on globalThis:
const CLIENT = Symbol.for("robo.clientContext");
export const clientContext = (globalThis[CLIENT] ??= createContext<string>());Symbol.for gives the same symbol in both bundles. If you have one bundle, I think you will not see this problem.
meta gets loaderData
In meta, the loader's result is now loaderData and not data. The same in matches:
matches.find((m) => m?.id === "root")?.loaderDataThis part was easy. After react-router typegen, tsc showed all three places as errors.
The request in a loader
This change is not visible in the types. When a person moves between pages, the browser asks only for the data, with an address like /notes.data?_routes=routes%2Fnotes. In version 7 a loader got the page address, /notes. In version 8 it gets the request as it came.
My app uses the address in two places: the next parameter for the sign-in page, and the check for the forced password change. With the raw address, after the sign-in the browser would open the data and not the page. So I take the page address back from the request:
if (url.pathname === "/_root.data") url.pathname = "/";
else if (url.pathname.endsWith(".data")) url.pathname = url.pathname.slice(0, -5);
url.searchParams.delete("_routes");
url.searchParams.delete("index");The root page is a special case, /_root.data. I am not completely sure that this list is full. It works for my routes, but if somebody knows a better way, I would be glad to hear it.
How I checked it
Everything else stayed the same: routes, components, Form, useFetcher, actions, createRequestListener. The 15 end to end test suites with Playwright passed. Then I checked sign-in, page titles and the Back button on the test tablet in its own Chrome.
I think the tests were more useful than the types here. The third change would be very easy to miss without them.