Skip to content

Add support for <Link unstable_mask>#14716

Merged
brophdawg11 merged 29 commits intodevfrom
brophdawg11/link-rewrite
Feb 18, 2026
Merged

Add support for <Link unstable_mask>#14716
brophdawg11 merged 29 commits intodevfrom
brophdawg11/link-rewrite

Conversation

@brophdawg11
Copy link
Contributor

@brophdawg11 brophdawg11 commented Jan 8, 2026

RFC: #9864

Add support for <Link unstable_mask> which allows users to navigate the router to one location while "masking" the url that is displayed in the browser, 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 (a new example has been added in this PR in examples/modal-data-router).

// routes/gallery.tsx
export function clientLoader({ request }: Route.LoaderArgs) {
 let sp = new URL(request.url).searchParams;
 return {
   images: getImages(),
   // When the router location has the image param, load the modal data
   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}
           {/* Navigate the router to /galley?image=N */}}
           to={`/gallery?image=${image.id}`}
           {/* But display /images/N in the URL bar */}}
           unstable_mask={`/images/${image.id}`}
         >
           <img src={image.url} alt={image.alt} />
         </Link>
       ))}
     </GalleryGrid>

     {/* When the modal data exists, display the modal */}
     {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: 4296cc5

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

@brophdawg11 brophdawg11 Jan 8, 2026

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 mask along in the location

@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" />

@OliverJAsh
Copy link

@brophdawg11 I just updated my branch to use the latest version, with masking instead of rewriting. It seems to work well! I don't have any more feedback at this point. 🚢🚢🚢🚢

@OliverJAsh
Copy link

OliverJAsh commented Feb 2, 2026

Something that just came to mind: with the previous approach, it was possible to have two copies of the location state—one for the foreground (in location.state) and one for the background (in location.state.backgroundLocation.state). With route masking, there's only one location, so there can only be one version of the location state.

At Unsplash we store page level data in the location state, so both the foreground and background routes need their own location state. I guess this is why Next.js models modals using "parallel routes".

@brophdawg11
Copy link
Contributor Author

At the end of the day it's all stored in the same history.state under the hood., Would it be a potential approach for your custom link component to manually merge it together? Maybe something like:

function MaskedLink({ to, state, maskState, children }) {
  // ...
  return (
    <Link 
      to={...} 
      unstable_mask={...} 
      state={state || maskState ? { ...state, maskState } : undefined}
    >
      {children}
    </Link>
  );
}

I'm not sure how often folks using this background location pattern also use the dual state approach, so I'm not sure if we want to expand the API to do something like <Link to state mask maskState> this in the first pass. We'd be doing the same thing under the hood to merge the states into the singular history.state entry.

@OliverJAsh
Copy link

OliverJAsh commented Feb 3, 2026

The problem is less about how to store both location states but more about how to read the relevant location state in an ergonomic way. Consider how this would work in the old approach:

<Routes location={state?.backgroundLocation || location}>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="gallery" element={<Gallery />} />
<Route path="/img/:id" element={<ImageView />} />
<Route path="*" element={<NoMatch />} />
</Route>
</Routes>
{/* Show the modal when a `backgroundLocation` is set */}
{state?.backgroundLocation && (
<Routes>
<Route path="/img/:id" element={<Modal />} />
</Routes>
)}

With the old approach, a component could simply use useLocation().state without worrying about whether it was the background (behind modal) or foreground (modal) location. The location in React context would be different depending on whether in the tree the component was rendered, so it just worked.

Thinking out loud, I guess we could do this:

function PhotoLink({ state }) {
  return (
    <Link 
      to={{ search: '?photo=abc' }}
      unstable_mask="/photo/abc"
      state={{ ...state, backgroundState: location.state }}
    >
      {children}
    </Link>
  );
}

(I'm not sure I would call it maskState because it's more about the "background location" than the "mask". In my mind, the mask is only used for the address bar.)

Then, to read the location state:

const useLocationState = () => {
  // ModalContext wraps the modal component tree
  const isModal = use(ModalContext);

  return isModal ?
    ? location.state
    : (location.state.backgroundState ?? location.state);
}

… but it would be easy to forget to use this custom hook.

I don't see this as a blocker though. I imagine most people won't need to worry about this. It would be great to ship what you have already.

@OliverJAsh
Copy link

@brophdawg11 I think I found an issue:

diff --git a/playground/framework/app/routes/_index.tsx b/playground/framework/app/routes/_index.tsx
index 53292ba52..bfbd961f2 100644
--- a/playground/framework/app/routes/_index.tsx
+++ b/playground/framework/app/routes/_index.tsx
@@ -30,6 +30,7 @@ export default function Index({ loaderData }: Route.ComponentProps) {
           <PhotoDisplay photo={data.photo} />
           <br />
           <MaskedLink search="?foo=bar">Add query (masked)</MaskedLink>
+          <Link to={{}} unstable_mask="/test">Test</Link>
         </dialog>
       )}
     </>

After clicking the link I expect the address bar to show /test but it actually shows /test?photo=1. It seems the new mask is inheriting the previous unmasked location's search?

@brophdawg11
Copy link
Contributor Author

Ah nice catch. I'm gonna try to get this cleaned up and merged for the next release 👍

@brophdawg11 brophdawg11 changed the title Add support for <Link unstable_rewrite> Add support for <Link unstable_mask> Feb 12, 2026
@brophdawg11 brophdawg11 marked this pull request as ready for review February 12, 2026 16:29
@OliverJAsh
Copy link

@brophdawg11 Hey, could you ship another experimental release so I can test this again?

@brophdawg11 brophdawg11 force-pushed the brophdawg11/link-rewrite branch from 319e77b to 4296cc5 Compare February 18, 2026 14:50
@brophdawg11
Copy link
Contributor Author

I'm gonna cut a prerelease shortly and will have this included in there 👍

@brophdawg11 brophdawg11 merged commit 2994019 into dev Feb 18, 2026
5 checks passed
@brophdawg11 brophdawg11 deleted the brophdawg11/link-rewrite branch February 18, 2026 15:38
@brophdawg11
Copy link
Contributor Author

@OliverJAsh [email protected] should publish shortly with this included

@github-actions
Copy link
Contributor

🤖 Hello there,

We just published version 7.13.1-pre.0 which includes this pull request. If you'd like to take it for a test run please try it out and let us know what you think!

Thanks!

@github-actions
Copy link
Contributor

🤖 Hello there,

We just published version 7.13.1 which includes this pull request. If you'd like to take it for a test run please try it out and let us know what you think!

Thanks!

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.

2 participants