Add support for <Link unstable_mask>#14716
Conversation
🦋 Changeset detectedLatest commit: 4296cc5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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("") | ||
| : ""; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Accept Locations in our history.push/replace API so that we can just proxy the mask along in the location
|
@brophdawg11 Just tested it for the Unsplash asset page modal and it seems to work perfectly. 😘 |
|
An interesting edge case I just encountered:
const location = useLocation();
const [searchParams] = useSearchParams();
searchParams.set('foo', 'bar');
return <Link to={{ pathname: location.pathname, search: searchParams.toString() }}>
Add query param
</Link>Result: The old modal setup (using location state) didn't have this problem. The URL in this case would be 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 |
|
Something else I noticed is that the location exposed by https://stackblitz.com/edit/github-p8mgs2ph?file=src%2Fapp.tsx
|
|
nvm got my wires crossed. That's because of this quick hack: #14716 (comment) Just to clarify - do you want |
I expected both the hook and |
|
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 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. 🚢🚢🚢🚢 |
|
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 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". |
|
At the end of the day it's all stored in the same 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 |
|
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: react-router/examples/modal/src/App.tsx Lines 56 to 70 in 379945d With the old approach, a component could simply use 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 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. |
|
@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 |
|
Ah nice catch. I'm gonna try to get this cleaned up and merged for the next release 👍 |
|
@brophdawg11 Hey, could you ship another experimental release so I can test this again? |
319e77b to
4296cc5
Compare
|
I'm gonna cut a prerelease shortly and will have this included in there 👍 |
|
@OliverJAsh |
|
🤖 Hello there, We just published version Thanks! |
|
🤖 Hello there, We just published version Thanks! |

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).