Skip to content

Instantly share code, notes, and snippets.

@Nabrok
Last active December 8, 2024 14:43
Show Gist options
  • Save Nabrok/73197ac33e3dd92749821bab2bd8b797 to your computer and use it in GitHub Desktop.
Save Nabrok/73197ac33e3dd92749821bab2bd8b797 to your computer and use it in GitHub Desktop.

Notes on migrating from RouterProvider to framework

Official documentation: https://reactrouter.com/upgrading/router-provider

Issues

  1. Vite config. If the vite config contains a root property it needs to be removed. If you are running vitest coverage reports you may want to move it to test.root or it will show coverage for the entire project. Any paths relative to root need to be adjusted as well, e.g. if you have publicDir set to ../public it should be set to just public (or removed as that is the default).

  2. Vitest. You don't want the reactRouter plugin running for tests, so setup as

{
	plugins: [
		! process.env.VITEST && reactRouter()
	]
}
  1. Imports. Even if you're running with ssr disabled, the bundling process still needs everything to be compatible with ssr. If you're seeing the error Directory import ... is not supported you can fix this by adding to ssr.noExternal in the vite config, e.g.
{
	ssr {
		noExternal: ['@apollo/client']
	}
}

Similarly if you have any libraries that include css files imported by components within the library they will need to be added here as well.

  1. More Imports. If you're still having trouble importing libraries, the only solution I found that worked was to lazy load.
import { lazy, Suspense } from "react";

const SimpleMDE = lazy(() => import('react-simplemde-editor').then(pkg => ({ default: pkg.SimpleMdeReact })));

function Editor() {
	return <Suspense><SimpleMDE /></Suspense>;
}
  1. Route Modules. When creating the route module files I initially exported the component like this ...
export { default } from "./Component";

This works while still using useLoaderData, but fails to pass in the props when you switch to using the type system. To fix this it needs to be imported and then exported.

import Component from "./Component";
export default Component;
  1. Basename. If you need a basename it has to end with a "/" otherwise the asset links come out as /fooassets/* instead of /foo/assets/*. Unfortunately this also prevents requests to /foo from working. I wrote a vite plugin to fix the links.
function fixAssetPaths() {
	let base;
	let dir;
	return {
		name: 'fix-asset-paths',
		outputOptions: (options) => {
			// The react-router vite plugin will move the index from server to client when done, so we need to adjust the path.
			dir = options.dir.replace(/server$/, 'client');
		},
		configResolved: config => {
			base = config.rawBase;
		},
		closeBundle: async () => {
			if (! dir) return;
			if (base.endsWith('/')) return;

			const index_file = path.join(dir, 'index.html');
			if (! fs.existsSync(index_file)) return;

			// Include a trailing slash in the match in case it does exist.
			// With react-router 7.0.2 the initial issue was fixed but instead it just ignores base and sets everything to "/assets", to fix have set the base as optional and included the word assets in the regexp.
			const base_re = new RegExp(`"(${base})?/?assets`, 'g');

			const index = await fs.promises.readFile(index_file, 'utf-8');
			const fixed_index = index.replace(base_re, `"${base}/assets`);
			let fix_applied = index !== fixed_index;
			await fs.promises.writeFile(index_file, fixed_index, 'utf-8');

			const manifest_files = (await fs.promises.readdir(path.join(dir, 'assets'))).filter(n => n.startsWith('manifest'));
			for (const file of manifest_files) {
				const file_path = path.join(dir, 'assets', file);
				const manifest = await fs.promises.readFile(file_path, 'utf-8');
				const fixed_manifest = manifest.replace(base_re, `"${base}/assets`);
				fix_applied = fix_applied || manifest !== fixed_manifest;
				await fs.promises.writeFile(file_path, fixed_manifest, 'utf-8');
			}
			if (fix_applied) {
				console.log("Asset links fixed.");
			} else {
				console.log("Asset links did not need fixing, you can remove this plugin.");
			}
		}
	};
}
  1. Testing. With RouterProvider I would wrap my test components in a memory router that was passed the route objects I was interested in testing. This is no longer feasible using the framework as the route objects have been replaced with modules.

For testing loader, component, and action a utility function like this was helpful:

import { ComponentType } from 'react';
import { ActionFunctionArgs, ClientActionFunctionArgs, ClientLoaderFunctionArgs, createRoutesStub, LoaderFunctionArgs, UIMatch, useActionData, useLoaderData, useParams } from 'react-router';

export function createModuleStub<Props extends {
	params: Params
	loaderData: LData
	actionData: AData
	// eslint-disable-next-line @typescript-eslint/no-explicit-any
	matches: any
}, Params extends Record<string, string>, LData = undefined, AData = undefined>(
	Component: ComponentType<Props>,
	options: {
		path?: string
		loader?: (args: LoaderFunctionArgs & {
			params: Params
			serverLoader: ClientLoaderFunctionArgs['serverLoader']
		}) => Promise<LData> | LData
		action?: (args: ActionFunctionArgs & {
			params: Params
			serverAction: ClientActionFunctionArgs['serverAction']
		}) => Promise<AData> | AData
		matches?: UIMatch[]
		ErrorBoundary?: ComponentType
		children?: Parameters<typeof createRoutesStub>[0]
		routes?: Parameters<typeof createRoutesStub>[0]
	} = {}
) {
	return createRoutesStub([
		{
			path: options.path ?? '/',
			loader: (args) => {
				const { params, ...rest } = args;
				return options.loader?.({
					...rest,
					params: params as Params,
					serverLoader: (async () => null) as ClientLoaderFunctionArgs['serverLoader']
				}) || null;
			},
			action: (args) => {
				const { params, ...rest } = args;
				return options.action?.({
					...rest,
					params: params as Params,
					serverAction: (async () => null) as ClientActionFunctionArgs['serverAction']
				}) || null;
			},
			children: options.children || [],
			ErrorBoundary: options.ErrorBoundary,
			Component: () => {
				const params = useParams();
				const loaderData = useLoaderData();
				const actionData = useActionData();

				const props = {
					params,
					loaderData,
					actionData,
					matches: options.matches || []
				} as Props;

				return <Component {...props} />;
			}
		},
		...options.routes || []
	]);
}
import { createModuleStub } from "./test-utils";
import ComponentToTest, { clientAction, clientLoader } from "./route";

const Stub = createModuleStub(ComponentToTest, {
  loader: clientLoader,
  action: clientAction
});

test('it works', () => {
  render(<Stub />);
  // Test stuff
});

For tests that involve multiple pages using browser tests may be more appropriate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment