Skip to content

Add support for <Link unstable_mask> - #14716

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

brophdawg11 merged 29 commits into
devfrom
brophdawg11/link-rewrite

Conversation

@brophdawg11

@brophdawg11 brophdawg11 commented Jan 8, 2026 •

Copy link
Copy Markdown
Contributor

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

changeset-bot Bot commented Jan 8, 2026 •

Copy link
Copy Markdown

🦋 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
Copy Markdown
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);

@brophdawg11 brophdawg11 Jan 8, 2026 •

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

Comment thread packages/react-router/lib/router/router.ts Outdated
Comment thread packages/react-router/lib/router/router.ts Outdated
Comment thread packages/react-router/lib/router/router.ts Outdated
Comment thread packages/react-router/lib/components.tsx Outdated
Comment thread playground/framework/app/routes/_index.tsx Outdated
@OliverJAsh

Copy link
Copy Markdown

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

@OliverJAsh

Copy link
Copy Markdown

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
Copy Markdown

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

brophdawg11 commented Jan 9, 2026 •

Copy link
Copy Markdown
Contributor Author

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

OliverJAsh commented Jan 9, 2026 •

Copy link
Copy Markdown

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
Copy Markdown

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 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
Copy Markdown

@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
Copy Markdown
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
Copy Markdown
Contributor Author

@OliverJAsh react-router@7.13.1-pre.0 should publish shortly with this included

@github-actions

Copy link
Copy Markdown
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
Copy Markdown
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!

@IanGraingerGMSL

Copy link
Copy Markdown

Just FYI - this change has broken our tests because its added a new unstable_mask property to location.state. I'm not sure there's anything you could/should have done differently to avoid that happening - but thought it worth mentioning!

@jroru

jroru commented Mar 19, 2026 •

Copy link
Copy Markdown

+1, this caused test failure for us due to the types being modeled as:

unstable_mask: Path | undefined

Wouldn't cause failure if modeled as:

unstable_mask?: Path

@brophdawg11

Copy link
Copy Markdown
Contributor Author

yeah that would probably be fine - want to open a PR?

@brophdawg11 brophdawg11 added awaiting release This issue has been fixed and will be released soon and removed awaiting release This issue has been fixed and will be released soon labels Apr 21, 2026
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.

4 participants