Skip to content

Conversation

@brophdawg11
Copy link
Contributor

RFC: #9864

Add support for <Link unstable_rewrite> which allows users to navigate to one URL in the browser but "rewrite" the url that is processed by the router, permitting contextual routing usages such as displaying an image in a model on top of a gallery.

This brings the long-standing example of doing this manually in declarative mode into Data/Framework Mode for client side navigations.

// routes/gallery.tsx
export function clientLoader({ request }: Route.LoaderArgs) {
  let sp = new URL(request.url).searchParams;
  return {
    images: getImages(),
    modalImage: sp.has("image") ? getImage(sp.get("image")!) : null,
  };
}

export default function Gallery({ loaderData }: Route.ComponentProps) {
  return (
    <>
      <GalleryGrid>
       {loaderData.images.map((image) => (
         <Link
           key={image.id}
           to={`/images/${image.id}`}
           rewrite={`/gallery?image=${image.id}`}
         >
           <img src={image.url} alt={image.alt} />
         </Link>
       ))}
      </GalleryGrid>

      {data.modalImage ? (
        <dialog open>
          <img src={data.modalImage.url} alt={data.modalImage.alt} />
        </dialog>
      ) : null}
    </>
  );
}

@changeset-bot
Copy link

changeset-bot bot commented Jan 8, 2026

🦋 Changeset detected

Latest commit: 11e3773

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

This PR includes changesets to release 11 packages
Name Type
react-router Patch
@react-router/architect Patch
@react-router/cloudflare Patch
@react-router/dev Patch
react-router-dom Patch
@react-router/express Patch
@react-router/node Patch
@react-router/serve Patch
@react-router/fs-routes Patch
@react-router/remix-routes-option-adapter Patch
create-react-router 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

"window.history.replaceState({ ...window.history.state, rewrite: undefined }, null);",
"}",
].join("")
: "";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since we cannot support history.state-driven rewrites during SSR, we just clear out the rewrite location on SSR renders so the client just works with the normal browser URL location to avoid hydration issues.

push(to, state) {
action = Action.Push;
let nextLocation = createMemoryLocation(to, state);
let nextLocation = isLocation(to) ? to : createMemoryLocation(to, state);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Accept Locations in our history.push/replace API so that we can just proxy the rewrite along in the location

...initialLocation,
...initialLocation.rewrite,
};
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hydrate the rewrite location if it exists

? // `matchRoutes()` has already been called if we're in here via `router.initialize()`
state.matches
: matchRoutes(routesToUse, location, basename);
: matchRoutes(routesToUse, routerPath, basename);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Matching/data loading operate against the routerPath, which is the rewrite if it exists

!(opts && opts.submission && isMutationMethod(opts.submission.formMethod))
) {
completeNavigation(location, { matches }, { flushSync });
completeNavigation(externalLocation, { matches }, { flushSync });
Copy link
Contributor Author

Choose a reason for hiding this comment

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

But when we complete the navigation, we do it to the external/user-facing to location

search: state.location.rewrite.search,
hash: state.location.rewrite.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.

TODO: I think this should probably move deeper now that rewrite lives on location and isn't just a magic field in location.state

@@ -1,9 +1,25 @@
import { useLoaderData, useLocation } from "react-router";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

playground/framework is updated with an example usage - can toggle between ssr:true/ssr:false to see the differences in behavior on hard reloads

Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: Revert these playground changes before merging

@OliverJAsh
Copy link

@brophdawg11 Just tested it for the Unsplash asset page modal and it seems to work perfectly. 😘

@OliverJAsh
Copy link

An interesting edge case I just encountered:

  1. User navigates to a link that has a rewritten URL e.g. /?page_modal=a rewritten as /photos/a.
  2. That page has another link which simply adds another query param to the current URL e.g. foo=bar.
  const location = useLocation();
  const [searchParams] = useSearchParams();
  searchParams.set('foo', 'bar');

  return <Link to={{ pathname: location.pathname, search: searchParams.toString() }}>
    Add query param
  </Link>

Result: /?page_modal=a&foo=bar. We lose the "route masking".

The old modal setup (using location state) didn't have this problem. The URL in this case would be /photos/a?foo=bar.

TanStack has declarative route masking. I wonder if this would help: https://tanstack.com/router/v1/docs/framework/react/guide/route-masking##declarative-route-masking

For context, where this shows up in Unsplash is our (nested) modals:

Screen.Recording.2026-01-09.at.16.13.58.mov

@OliverJAsh
Copy link

Something else I noticed is that the location exposed by router.subscribe doesn't seem to use the rewritten location, unlike the location exposed by useLocation. I'm not sure if this is intentional? Reduced test case:

https://stackblitz.com/edit/github-p8mgs2ph?file=src%2Fapp.tsx

image

@brophdawg11
Copy link
Contributor Author

brophdawg11 commented Jan 9, 2026

yeah that's part of the quick nature of this POC - we just stick the rewrite field on there. It sounds like you'd prefer that useLocation returns the rewritten location? Would you want/need access to the "URL location" via useLocation as well?

nvm got my wires crossed. That's because of this quick hack: #14716 (comment)

Just to clarify - do you want useLocation to expose the URL location or the rewritten location? And do you want/need the other exposed as well?

@OliverJAsh
Copy link

OliverJAsh commented Jan 9, 2026

Just to clarify - do you want useLocation to expose the URL location or the rewritten location? And do you want/need the other exposed as well?

I expected both the hook and router.subscribe to expose the same representation, i.e. the URL that is being rendered (unstable_rewrite) rather than the one in the address bar (to). But it might be good to have the address bar URL in there as well? Just not as the main value?

@OliverJAsh
Copy link

Side note: I wonder if it's easier to think about this feature in terms of "route masking" (like TanStack Router) rather than "rewriting". It flips them around:

Rewriting:

<Link to="/photos/abc" rewrite="/?photo=abc" />

Route masking:

<Link to="/?photo=abc" mask="/photos/abc" />

@brophdawg11 brophdawg11 marked this pull request as draft January 12, 2026 17:19
@brophdawg11
Copy link
Contributor Author

@OliverJAsh I haven't forgotten about this - been noodling on it and playing with it a bit on and off in between some other work. I think you're right that what we're doing here is more "masking" then "rewriting". The router continues to operate on the to location throughout (data loading, useLocation, relative routing, etc.) - it's just at the very end when we interface with history that we use the masked value.

The old modal setup (using location state) didn't have this problem. The URL in this case would be /photos/a?foo=bar.

This makes sense and I think is fixed if we invert to a "mask" approach.

To clarify, in the old setup - you had to manually proxy along state.backgroundLocation right? I assume you're doing this with a custom Link component/useNavigate hook to avoid having to do that logic on every Link/useNavigate?

@OliverJAsh
Copy link

OliverJAsh commented Jan 23, 2026

To clarify, in the old setup - you had to manually proxy along state.backgroundLocation right? I assume you're doing this with a custom Link component/useNavigate hook to avoid having to do that logic on every Link/useNavigate?

Yeah exactly.

The old modal setup (using location state) didn't have this problem. The URL in this case would be /photos/a?foo=bar.

This makes sense and I think is fixed if we invert to a "mask" approach.

I'm curious how the "mask" approach would solve this. The link is relative to the current location, so how would you carry along the mask?

If the user is on the page /?photo=abc masked as /photos/abc, this would link to /?photo=abc&foo=bar. There'd be no mask anymore? I guess we'd have to manually carry it across like we do currently for state.backgroundLocation?

  const location = useLocation();
  const [searchParams] = useSearchParams();
  searchParams.set('foo', 'bar');

  return <Link to={{ pathname: location.pathname, search: searchParams.toString() }}>
    Add query param
  </Link>

"react-router": patch
---

[UNSTABLE] Add support for `<Link unstable_rewrite>` which allows users to navigate to one URL in the browser but "rewrite" the url that is processed by the router, permitting contextual routing usages such as displaying an image in a model on top of a gallery
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: Needs to be updated for inverted mask approach

relative?: RelativeRoutingType;

/**
* Rewrite path to "rewrite" the URL the router navigates to internally, while
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: Update for inverted mask approach

@brophdawg11
Copy link
Contributor Author

yeah I think I got my wires crossed - that's what I get trying to close the loop on something too quickly at the end of the day on a Friday 🤦‍♂️

The old modal setup (using location state) didn't have this problem. The URL in this case would be /photos/a?foo=bar.

I think in the end I was confused by this statement. I thought this meant that in your old setup, if the params got added to the correct (browser) URL then the masking/background location automatically carried along. But as I was digging into our old v6 declarative modal example I realized that wouldn't be possible either - which is why I was trying to confirm the manual proxying along of the background location.

I guess we'd have to manually carry it across like we do currently for state.backgroundLocation?

I think that's where I'm landing. I don't think there's any way for us to intelligently decide which location (router or masked) any new to locations should apply to, as it would seemingly make <Link>'s less portable across masked/unmasked scenarios? I think new new approach would just be a bit of an inversion of what you do today -instead of proxying along the backgroundLocation, you'd proxy along the mask?

I pushed up the changes to invert this to <Link to=".?photo=1" unstable_mask="/photo/1"> if you want to try out the inverted approach. I have a very basic sample of how this might work with a sample <MaskedLink> component in the playgrounds/framework in this branch - you should be able to run that if you clone the repo then run:

pnpm i
pnpm build
cd playgrounds/framework
pnpm dev 

@brophdawg11
Copy link
Contributor Author

Oops - forgot to do an updated experimental from this branch: 0.0.0-experimental-f887449bb

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.

3 participants