Repository navigation
Add support for <Link unstable_mask> #14716
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 f4af4a7
Data mode playground domonstrating link rewrite
brophdawg11 3a07402
Update playground
brophdawg11 f66a533
Clear rewritten loctions on SSR reloads
brophdawg11 527ec48
Move rewrite storage out of user state and onto location
brophdawg11 40212f0
Move demo to playground/framework for now, to test with and without ssr
brophdawg11 4d3497b
Remove data m,ode playground (cherry picked to dev)
brophdawg11 71f848c
Add unstable_ prefix
brophdawg11 298dd54
Few missed renames
brophdawg11 3fe2758
Add changeset
brophdawg11 ce02b17
Fix tests
brophdawg11 3d54c92
A few missed renames
brophdawg11 aa6c85b
Update to fix useLocation + relative routing
brophdawg11 b4362ba
Switch from rewrite to mask
brophdawg11 48f8143
Update tests
brophdawg11 f887449
Remove uneeded test helper update
brophdawg11 11e3773
Update jsdocs, cleanup tests
brophdawg11 d65facc
Refactor/cleanup
brophdawg11 0fba513
Merge branch 'dev' into brophdawg11/link-rewrite
brophdawg11 1e9683d
Reset framework playground to dev version
brophdawg11 8523a03
More playground reverts
brophdawg11 ae9330c
Revert pnpm-lock changes
brophdawg11 e067e5e
More cleanup
brophdawg11 8d55503
Add version of modal exmaple using route masking
brophdawg11 56b7227
Update docs and switch to masked field on history.state
brophdawg11 b305a78
Update example to latest version
brophdawg11 6d53007
Update SSR reset to use new masked field
brophdawg11 c50fa92
Consoliodate logic for spa links
brophdawg11 4296cc5
Move masked ssr removal to HydratedRouter
brophdawg11 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| ```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} | ||
| </> | ||
| ); | ||
| } | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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