Skip to content
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

Fix inadvertent support for partial dynamic parameters #9506

Merged
merged 10 commits into from Nov 30, 2022

Conversation

brophdawg11
Copy link
Contributor

The intention was never to match partial dynamic or splat parameters, but we had some bugs and inconsistencies in the implementation:

  • matchPath would not match partial splat parameters (/prefix*), and would warn + treat them as /prefix/*
  • However, generatePath would match partial splat parameters
  • Both matchPath/generatePath would match partial named parameters (/prefix:param)

This PR removes the buggy support for partials matching such that we always require dynamic/splat params to be an entire URL segment. This will simplify our future plans to support optional params as well as the intended optimization of the path-matching algorithm.

If an application happened to be relying on this, then the suggestion should be to extract the static prefix at the useParams call-site:

// Old behavior at URL /prefix-123
<Route path="prefix-:id" element={<Comp /> }>

function Comp() {
  let params = useParams(); // { id: '123' }
  let id = params.id; // "123"
  ...
}

// New behavior at URL /prefix-123
<Route path=":id" element={<Comp /> }>

function Comp() {
  let params = useParams(); // { id: 'prefix-123' }
  let id = params.id.replace(/^prefix-/, ''); // "123"
  ...
}

Replaces #9238

@changeset-bot
Copy link

changeset-bot bot commented Oct 26, 2022

🦋 Changeset detected

Latest commit: db7a8e6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
react-router Patch
@remix-run/router Patch
react-router-dom Patch
react-router-dom-v5-compat Patch
react-router-native Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

<NullRenderer routes={routes} location={{ pathname: "/three" }} />
<NullRenderer
routes={routes}
location={{ pathname: "/three", search: "", hash: "" }}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lack of search/hash in this test case was causing weird console.warn of /threeundefinedundefined

`always follow a \`/\` in the pattern. To get rid of this warning, ` +
`please change the route path to "${path.replace(/\*$/, "/*")}".`
);
path = path.replace(/\*$/, "/*") as Path;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same logic as we use in compilePath for partial splats

Comment on lines -537 to +556
.replace(/:(\w+)/g, (_, key: PathParam<Path>) => {
.replace(/^:(\w+)/g, (_, key: PathParam<Path>) => {
invariant(params[key] != null, `Missing ":${key}" param`);
return params[key]!;
})
.replace(/\/:(\w+)/g, (_, key: PathParam<Path>) => {
invariant(params[key] != null, `Missing ":${key}" param`);
return `/${params[key]!}`;
})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generatePath does 2 separate checks here to avoid a nasty regex to differentiate :param from /thing/:param

Comment on lines -679 to +697
.replace(/:(\w+)/g, (_: string, paramName: string) => {
.replace(/\/:(\w+)/g, (_: string, paramName: string) => {
paramNames.push(paramName);
return "([^\\/]+)";
return "/([^\\/]+)";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only match params that are at the start of a URL segment. We do not need to handle ^: here since line 693 ensures our regexpSource always has a leading slash by this point. So we just only collect params when they come immediately following a slash

@pcattori
Copy link
Contributor

pcattori commented Nov 15, 2022

For matching types in TS, I think it'd be good to replace Path extends `${string}:${infer Param}` with Path extends `:${infer Param)` (so not expecting the leading string before :) in the implementation of _PathParam

Copy link
Member

@mjackson mjackson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks, @brophdawg11

@YousefED
Copy link

@brophdawg11 I'm looking at your suggested workaround for when this functionality is required, but I don't understand how the workaround would work with the rest of the Routing system.

E.g.: I want to "route" requests to /@<username> to ProfilePage

According to your workaround, I need to create a route for /:username. But then it would catch everything. How do I tie it in to the rest of the routing system, e.g.: I would like a request to /undefined-url result in a 404 catch all handler

@brophdawg11
Copy link
Contributor Author

If you're using RouterProvider, you can detect and throw in the loader to render the errorElement:

let router = createBrowserRouter([{
  path: ":username",
  loader({ params }) {
    if (!params.username.startsWith('@')) {
      throw new Response("Not Found", { status: 404 });
    }
    let user = await getUser(params.username);
    return { user };
  },
  element: <ProfilePage />,
  errorElement: <NotFound />
}]);

If you're using <BrowserRouter> then you can conditionally render in your element:

// <Route path=":username" element={<GuardedProfilePage />} >

function GuardedProfilePage() {
  let params = useParams();
  if (!params.username.startsWith("@")) {
    return <NotFound />
  }

  return <ProfilePage username={params.username} />
}

@YousefED
Copy link

@brophdawg11 Thanks.

The use-case I'd like to solve is this:

<Route path="@:userParam" element={<ProfilePage/>} />
<Route path="*" element={<Fallback>} />

Now, this is currently not possible, because @:userParam is invalid.

So the next step would be:

<Route path=":userParam" element={<ProfilePage/>} />
<Route path="*" element={<Fallback>} />

This would mean Fallback never gets triggered. So I either need to put the Profile route within the Fallback, or the other way around. It's doable, but imo it couples unrelated components of my app together

@brophdawg11
Copy link
Contributor Author

The second example above is what you want, instead of splitting that logic across two ambiguous routes, you just fork in the :username element when the username is not the pattern you want. You don't need to touch your current implementations of ProfilePage or Fallback.

// <Route path=":userParam" element={<ProfilePageElement />} />

function ProfilePageElement() {
  let { userParam } = useParams();
  return userParam.startsWith("@") ? <ProfilePage /> : <Fallback>;
}

felipe-lee added a commit to transcom/mymove that referenced this pull request Jun 5, 2023
Needed for the react-router update because of [ #9506 - Fix inadvertent support for partial dynamic parameters](remix-run/react-router#9506).

JIRA Ticket: MB-15974
Co-authored-by: Kyle Hill <kyle@truss.works>
felipe-lee added a commit to transcom/mymove that referenced this pull request Jun 6, 2023
Needed for the react-router update because of [ #9506 - Fix inadvertent support for partial dynamic parameters](remix-run/react-router#9506).

JIRA Ticket: MB-15974
Co-authored-by: Kyle Hill <kyle@truss.works>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants