Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5b25fcd
POC of link rewrite
brophdawg11 Dec 22, 2025
f4af4a7
Data mode playground domonstrating link rewrite
brophdawg11 Dec 22, 2025
3a07402
Update playground
brophdawg11 Jan 7, 2026
f66a533
Clear rewritten loctions on SSR reloads
brophdawg11 Jan 8, 2026
527ec48
Move rewrite storage out of user state and onto location
brophdawg11 Jan 8, 2026
40212f0
Move demo to playground/framework for now, to test with and without ssr
brophdawg11 Jan 8, 2026
4d3497b
Remove data m,ode playground (cherry picked to dev)
brophdawg11 Jan 8, 2026
71f848c
Add unstable_ prefix
brophdawg11 Jan 8, 2026
298dd54
Few missed renames
brophdawg11 Jan 8, 2026
3fe2758
Add changeset
brophdawg11 Jan 8, 2026
ce02b17
Fix tests
brophdawg11 Jan 8, 2026
3d54c92
A few missed renames
brophdawg11 Jan 9, 2026
aa6c85b
Update to fix useLocation + relative routing
brophdawg11 Jan 12, 2026
b4362ba
Switch from rewrite to mask
brophdawg11 Jan 26, 2026
48f8143
Update tests
brophdawg11 Jan 26, 2026
f887449
Remove uneeded test helper update
brophdawg11 Jan 26, 2026
11e3773
Update jsdocs, cleanup tests
brophdawg11 Jan 26, 2026
d65facc
Refactor/cleanup
brophdawg11 Feb 10, 2026
0fba513
Merge branch 'dev' into brophdawg11/link-rewrite
brophdawg11 Feb 10, 2026
1e9683d
Reset framework playground to dev version
brophdawg11 Feb 10, 2026
8523a03
More playground reverts
brophdawg11 Feb 10, 2026
ae9330c
Revert pnpm-lock changes
brophdawg11 Feb 10, 2026
e067e5e
More cleanup
brophdawg11 Feb 10, 2026
8d55503
Add version of modal exmaple using route masking
brophdawg11 Feb 12, 2026
56b7227
Update docs and switch to masked field on history.state
brophdawg11 Feb 12, 2026
b305a78
Update example to latest version
brophdawg11 Feb 12, 2026
6d53007
Update SSR reset to use new masked field
brophdawg11 Feb 12, 2026
c50fa92
Consoliodate logic for spa links
brophdawg11 Feb 12, 2026
4296cc5
Move masked ssr removal to HydratedRouter
brophdawg11 Feb 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/silly-badgers-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"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
Copy Markdown
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


```tsx
// 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}
</>
);
}
```
309 changes: 309 additions & 0 deletions packages/react-router/__tests__/router/rewrite-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
import { cleanup, setup } from "./utils/data-router-setup";
import { createFormData } from "./utils/utils";

describe("rewrite", () => {
// Detect any failures inside the router navigate code
afterEach(() => cleanup());

it("loads data from the rewrite location but updates browser URL to the to location", async () => {
let t = setup({
routes: [
{ path: "/" },
{ id: "gallery", path: "/gallery", loader: true },
{ id: "images", path: "/images/:id", loader: true },
],
initialEntries: ["/"],
});

// Navigate to /images/123 but load data from /gallery?photo=123
let A = await t.navigate("/images/123", {
unstable_rewrite: "/gallery?photo=123",
});

// Should call the gallery loader, not the images loader
expect(A.loaders.gallery).toBeDefined();
expect(A.loaders.images).toBeUndefined();

// The loader should receive the rewrite URL in the request
expect(A.loaders.gallery.stub.mock.calls[0][0].request.url).toMatch(
/\/gallery\?photo=123$/,
);

await A.loaders.gallery.resolve("GALLERY DATA");

// The browser location should be /images/123, not /gallery?photo=123
expect(t.router.state.location.pathname).toBe("/images/123");
expect(t.router.state.location.search).toBe("");

// The location state should contain the rewrite information
expect(t.router.state.location.state?._rewrite).toEqual({
pathname: "/gallery",
search: "?photo=123",
hash: "",
});

// The matched routes should be for the rewrite location
expect(t.router.state.matches).toHaveLength(1);
expect(t.router.state.matches[0].route.id).toBe("gallery");

// The loader data should be present
expect(t.router.state.loaderData.gallery).toBe("GALLERY DATA");

t.router.dispose();
});

it("preserves rewrite on POP navigation", async () => {
let t = setup({
routes: [
{ path: "/" },
{ id: "gallery", path: "/gallery", loader: true },
{ id: "images", path: "/images/:id", loader: true },
],
initialEntries: ["/"],
});

// Navigate to /images/123 with rewrite
let A = await t.navigate("/images/123", {
unstable_rewrite: "/gallery?photo=123",
});
await A.loaders.gallery.resolve("GALLERY DATA");

// Navigate to home
let B = await t.navigate("/");
expect(t.router.state.location.pathname).toBe("/");

// Go back - should use the rewrite
let promise = t.router.navigate(-1);

// Wait for navigation to complete
await promise;

// The browser location should be /images/123
expect(t.router.state.location.pathname).toBe("/images/123");

// The rewrite should still be in the state
expect(t.router.state.location.state?._rewrite).toEqual({
pathname: "/gallery",
search: "?photo=123",
hash: "",
});

// The matched routes should be for the rewrite location
expect(t.router.state.matches).toHaveLength(1);
expect(t.router.state.matches[0].route.id).toBe("gallery");

t.router.dispose();
});

it("supports rewrite with replace", async () => {
let t = setup({
routes: [
{ path: "/" },
{ id: "gallery", path: "/gallery", loader: true },
{ id: "images", path: "/images/:id", loader: true },
],
initialEntries: ["/"],
});

let A = await t.navigate("/images/123", {
unstable_rewrite: "/gallery?photo=123",
replace: true,
});

expect(A.loaders.gallery).toBeDefined();
await A.loaders.gallery.resolve("GALLERY DATA");

expect(t.router.state.location.pathname).toBe("/images/123");
expect(t.router.state.location.state?._rewrite).toEqual({
pathname: "/gallery",
search: "?photo=123",
hash: "",
});

t.router.dispose();
});

it("supports rewrite with hash", async () => {
let t = setup({
routes: [
{ path: "/" },
{ id: "gallery", path: "/gallery", loader: true },
{ id: "images", path: "/images/:id", loader: true },
],
initialEntries: ["/"],
});

let A = await t.navigate("/images/123#preview", {
unstable_rewrite: "/gallery?photo=123#header",
});

expect(A.loaders.gallery).toBeDefined();

// The loader should receive the rewrite URL (hash is not preserved in Request URL)
expect(A.loaders.gallery.stub.mock.calls[0][0].request.url).toMatch(
/\/gallery\?photo=123$/,
);

await A.loaders.gallery.resolve("GALLERY DATA");

// The browser location should have the "to" hash
expect(t.router.state.location.pathname).toBe("/images/123");
expect(t.router.state.location.hash).toBe("#preview");

// The rewrite should have the rewrite hash
expect(t.router.state.location.state?._rewrite).toEqual({
pathname: "/gallery",
search: "?photo=123",
hash: "#header",
});

t.router.dispose();
});

it("supports rewrite with state", async () => {
let t = setup({
routes: [
{ path: "/" },
{ id: "gallery", path: "/gallery", loader: true },
{ id: "images", path: "/images/:id", loader: true },
],
initialEntries: ["/"],
});

let A = await t.navigate("/images/123", {
unstable_rewrite: "/gallery?photo=123",
state: { customData: "test" },
});

await A.loaders.gallery.resolve("GALLERY DATA");

// Both custom state and _rewrite should be present
expect(t.router.state.location.state).toEqual({
customData: "test",
_rewrite: {
pathname: "/gallery",
search: "?photo=123",
hash: "",
},
});

t.router.dispose();
});

it("supports rewrite with action submission", async () => {
let t = setup({
routes: [
{ path: "/" },
{ id: "gallery", path: "/gallery", action: true, loader: true },
{ id: "images", path: "/images/:id", action: true },
],
initialEntries: ["/"],
});

let A = await t.navigate("/images/123", {
unstable_rewrite: "/gallery?photo=123",
formMethod: "post",
formData: createFormData({ test: "value" }),
});

// Should call the gallery action, not the images action
expect(A.actions.gallery).toBeDefined();
expect(A.actions.images).toBeUndefined();

// The action should receive the rewrite URL
expect(A.actions.gallery.stub.mock.calls[0][0].request.url).toMatch(
/\/gallery\?photo=123$/,
);

await A.actions.gallery.resolve("ACTION DATA");

// Should then call the gallery loader
expect(A.loaders.gallery).toBeDefined();
await A.loaders.gallery.resolve("LOADER DATA");

// The browser location should be /images/123
expect(t.router.state.location.pathname).toBe("/images/123");
expect(t.router.state.location.state?._rewrite).toEqual({
pathname: "/gallery",
search: "?photo=123",
hash: "",
});

t.router.dispose();
});

it("handles rewrite with relative paths", async () => {
let t = setup({
routes: [
{
path: "/",
children: [
{ id: "gallery", path: "gallery", loader: true },
{ id: "images", path: "images/:id", loader: true },
],
},
],
initialEntries: ["/"],
});

let A = await t.navigate("/images/123", {
unstable_rewrite: "/gallery?photo=123",
});

expect(A.loaders.gallery).toBeDefined();
await A.loaders.gallery.resolve("GALLERY DATA");

expect(t.router.state.location.pathname).toBe("/images/123");
expect(t.router.state.matches[1].route.id).toBe("gallery");

t.router.dispose();
});

it("works without rewrite option (normal navigation)", async () => {
let t = setup({
routes: [
{ path: "/" },
{ id: "gallery", path: "/gallery", loader: true },
{ id: "images", path: "/images/:id", loader: true },
],
initialEntries: ["/"],
});

// Normal navigation without rewrite
let A = await t.navigate("/images/123");

// Should call the images loader
expect(A.loaders.images).toBeDefined();
expect(A.loaders.gallery).toBeUndefined();

await A.loaders.images.resolve("IMAGES DATA");

expect(t.router.state.location.pathname).toBe("/images/123");
expect(t.router.state.location.state?._rewrite).toBeUndefined();
expect(t.router.state.matches).toHaveLength(1);
expect(t.router.state.matches[0].route.id).toBe("images");

t.router.dispose();
});

it("handles 404 on rewrite location", async () => {
let t = setup({
routes: [
{ path: "/" },
{ id: "images", path: "/images/:id", loader: true },
],
initialEntries: ["/"],
});

// Rewrite to a non-existent route
let A = await t.navigate("/images/123", {
unstable_rewrite: "/nonexistent",
});

// Should get a 404 error
expect(t.router.state.errors).toBeDefined();
expect(t.router.state.location.pathname).toBe("/images/123");

t.router.dispose();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,15 @@ export function setup({
return helpers;
}

helpers = getNavigationHelpers(href, navigationId);
helpers = getNavigationHelpers(
// @ts-expect-error - rewrite is a new option
opts?.unstable_rewrite
? typeof opts.unstable_rewrite === "string"
? opts.unstable_rewrite
: ""
: href,
navigationId,
);
shims?.forEach((routeId) =>
shimHelper(helpers.loaders, "navigation", "loader", routeId),
);
Expand Down
Loading
Loading