router icon indicating copy to clipboard operation
router copied to clipboard

Different dynamic route loading with "*" not possible (loader output is cached)

Open Zauberfisch opened this issue 3 years ago • 5 comments

Describe the bug Sometimes it may be desired/required to have routes defined outside of react-router or the javascript app or they are just not all known to the app. For example asking the backend if it has something to serve for this route.
One of my usecases is content served from an API with thousands of routes, some of which are only available to certain users and deciding which user can access what route is done on-the-fly by the backend.

This use-case can be served by adding a route like this (where fetchRoute performs a request to the backend):

{
	path: "*",
	loader: ({pathname}) => (
		queryClient.getQueryData(["route", pathname]) ?? queryClient.fetchQuery(["route", pathname], () => fetchRoute(pathname))
	),
	element: <RouteComponent />,
}

Unfortunately, a "*" route currently has the same cache key regardless of the pathname, and therefore will fetch the correct content the first route and then return the same content for all following routes.

(Using a route parameter ":foobar" will work for top-level routes, but does not cover n levels of nested routes.)

To Reproduce I don't think an example is necessary right now, but I'd be happy to create one if needed.

Expected behavior I think we should consider both the route path "*" aswell as the matched pathname for the cache key and re-run the loader for every pathname.

Workaround My currently working workaround:

	const loader = React.useCallback(({pathname}) => (
		queryClient.getQueryData(["route", pathname]) ?? queryClient.fetchQuery(["route", pathname], () => fetchRoute(pathname))
	), [])
	const routes = React.useMemo(() => {
		const _routes = []
		const routePaths = ["/"]
		let lastPath = ""
		Array(26).fill().map((element, index) => String.fromCharCode("a".charCodeAt(0) + index)).map(char => {
			lastPath = `${lastPath}/:${char}`
			routePaths.push(`${lastPath}/`)
		})
		routePaths.reverse().forEach((path) => {
			_routes.push({
				loader,
				element: <RouteComponent />,
				path,
			})
		})
		return _routes
	}, [])

This will produce the following 27 routes, all with the same loader and element:

  • /
  • /:a/
  • /:a/:b/
  • /:a/:b/:c/
  • ...
  • /:a/:b/:c/:d/:e/:f/:g/:h/:i/:j/:k/:l/:m/:n/:o/:p/:q/:r/:s/:t/:u/:v/:w/:x/:y/:z/

Zauberfisch avatar Jan 18 '22 16:01 Zauberfisch

There might be an API that you can use to just watch the route match wildcard in an effect and re-trigger the loader when it changes. Would that work?

tannerlinsley avatar Jan 18 '22 17:01 tannerlinsley

Yeah, I think that could work too. As long as we have some method to invalidate the cache. I also did some more thinking, so I see 3 possible solutions:

  1. your idea

  2. per route cacheKey callback Another idea is (I think I saw that on some other react-routing lib or something): Adding a callback to calculate the cache key. Right now a route with path: "*" probably has the cache key "*". What if we make a callback like this:

    {
    	path: "*",
    	loader: ({pathname}) => (
    		queryClient.getQueryData(["route", pathname]) ?? queryClient.fetchQuery(["route", pathname], () => fetchRoute(pathname))
    	),
    	//cacheKey: (path, match) => path, // DEFAULT behavior, so it returns `*` 
    	cacheKey: (path, match) => `${path}|${match.pathname}`, // my desired behavior, so it returns `*|/foo/bar/baz` or whatever
    	element: <RouteComponent />,
    }
    
  3. allow a callback for path (like search works?) How about making path a callback that can do it's own matching and return route parameters (like :a/:b would). This way people could also do regex stuff (which would address #77 ). Kind of like how I think search works. Here's an example that would cover the usecase from #77 (/:segment(a|b|c)/:id(\\d+)):

    {
    	path: (locationMatch) => {
    		const regexMatch = locationMatch.pathname.match(/\/(a|b|c)/(\d+)/?/)
    		if (!regexMatch) return false;
    		return {
    			segment: regexMatch[1], // a, b, or c
    			id: regexMatch[2], // number
    		}
    	},
    	loader: ({pathname}) => (
    		queryClient.getQueryData(["route", pathname]) ?? queryClient.fetchQuery(["route", pathname], () => fetchRoute(pathname))
    	),
    	element: <RouteComponent />,
    }
    

Right now, I think I like (3) the best. But any would be fine for me.

Zauberfisch avatar Jan 18 '22 18:01 Zauberfisch

This could be a cool addition. Would you like to explore adding it?

tannerlinsley avatar Feb 03 '22 17:02 tannerlinsley

I surely am willing to create a PR, but I doubt I'll have time in the next few weeks, so might take a while.

Zauberfisch avatar Feb 10 '22 13:02 Zauberfisch

Facing same problem here.

Having a catch-all route is a very common pattern.

Would be great having some control over the cache key for path '*', or allowing providing a regex for the route param, à la express:

{
    path: ':slug(.*)',
}

Also tried with this, but didn't work:

import { ReactLocationSimpleCache } from '@tanstack/react-location-simple-cache'
...


const routeCache = new ReactLocationSimpleCache()

const routes=[
    {
      path: '*',
      loader: routeCache.createLoader(
        async (match: any) => ({
          data: `Definition for page ${match.params['*']}`,
        }),
        {
          key: (match) => match.params['*'] || '',
        },
      ),
      element: async () => import('./components/Default').then(module => <module.default />),
    },
]

manelio avatar Aug 21 '22 15:08 manelio

This is both stale and fix in the latest Router version.

tannerlinsley avatar Nov 10 '22 14:11 tannerlinsley