diff --git a/.changeset/chilly-bottles-grab.md b/.changeset/chilly-bottles-grab.md
new file mode 100644
index 000000000000..f551fcb2e6da
--- /dev/null
+++ b/.changeset/chilly-bottles-grab.md
@@ -0,0 +1,43 @@
+---
+"@refinedev/core": minor
+---
+
+feat: add [``](https://refine.dev/docs/routing/components/link/) component to navigate to a resource with a specific action. Under the hood, It uses [`useGo`](https://refine.dev/docs/routing/hooks/use-go/) to generate the URL.
+
+## Usage
+
+```tsx
+import { Link } from "@refinedev/core";
+
+const MyComponent = () => {
+ return (
+ <>
+ {/* simple usage, navigates to `/posts` */}
+ Posts
+ {/* complex usage with more control, navigates to `/posts` with query filters */}
+
+ Posts
+
+ >
+ );
+};
+```
+
+[Fixes #6329](https://github.com/refinedev/refine/issues/6329)
diff --git a/.changeset/itchy-moose-reflect.md b/.changeset/itchy-moose-reflect.md
new file mode 100644
index 000000000000..0003c67fc500
--- /dev/null
+++ b/.changeset/itchy-moose-reflect.md
@@ -0,0 +1,9 @@
+---
+"@refinedev/core": minor
+---
+
+chore: From now on, [`useLink`](https://refine.dev/docs/routing/hooks/use-link/) returns [``](https://refine.dev/docs/routing/components/link/) component instead of returning [`routerProvider.Link`](https://refine.dev/docs/routing/router-provider/#link).
+
+Since the `` component uses `routerProvider.Link` under the hood with leveraging `useGo` hook to generate the URL there is no breaking change. It's recommended to use the `` component from the `@refinedev/core` package instead of `useLink` hook. This hook is used mostly for internal purposes and is only exposed for customization needs.
+
+[Fixes #6329](https://github.com/refinedev/refine/issues/6329)
diff --git a/documentation/docs/routing/components/link/index.md b/documentation/docs/routing/components/link/index.md
new file mode 100644
index 000000000000..10ed841dbfc3
--- /dev/null
+++ b/documentation/docs/routing/components/link/index.md
@@ -0,0 +1,97 @@
+---
+title:
+---
+
+`` is a component that is used to navigate to different pages in your application.
+
+It uses [`routerProvider.Link`](/docs/routing/router-provider/#link) under the hood, if [`routerProvider`](/docs/routing/router-provider) is not provided, it will be use [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) HTML element.
+
+## Usage
+
+```tsx
+import { Link } from "@refinedev/core";
+
+const MyComponent = () => {
+ return (
+ <>
+ {/* simple usage, navigates to `/posts` */}
+ Posts
+ {/* complex usage with more control, navigates to `/posts` with query filters */}
+
+ Posts
+
+ >
+ );
+};
+```
+
+## Props
+
+The `` component takes all the props from the [`routerProvider.Link`](/docs/routing/router-provider/#link) and the props that an `` HTML element uses. In addition to these props, it also accepts the `go`
+and `to` props to navigate to a specific `resource` defined in the `` component.
+
+### go
+
+When `go` prop is provided, this component will use [`useGo`](/docs/routing/hooks/use-go/) to create the URL to navigate to. It's accepts all the props that `useGo.go` accepts.
+
+It's useful to use this prop when you want to navigate to a resource with a specific action.
+
+:::caution
+
+- `routerProvider` is required to use this prop.
+- When `to` prop is provided, `go` will be ignored.
+
+:::
+
+### to
+
+The URL to navigate to.
+
+## Type support with generics
+
+`` works with any routing library because it uses [`routerProvider.Link`](/docs/routing/router-provider/#link) internally. However, when importing it from `@refinedev/core`, it doesn't provide type support for your specific routing library. To enable full type support, you can use generics.
+
+```tsx
+import type { LinkProps } from "react-router-dom";
+import { Link } from "@refinedev/core";
+
+const MyComponent = () => {
+ return (
+ // Omit 'to' prop from LinkProps (required by react-router-dom) since we use the 'go' prop
+ >
+ // Props from "react-router-dom"
+ // highlight-start
+ replace={true}
+ unstable_viewTransition={true}
+ preventScrollReset={true}
+ // highlight-end
+ // Props from "@refinedev/core"
+ go={{
+ to: {
+ resource: "posts",
+ action: "list",
+ },
+ }}
+ >
+ Posts
+
+ );
+};
+```
diff --git a/documentation/docs/routing/hooks/use-link/index.md b/documentation/docs/routing/hooks/use-link/index.md
index af8e9f253ef5..ff7b0d678af6 100644
--- a/documentation/docs/routing/hooks/use-link/index.md
+++ b/documentation/docs/routing/hooks/use-link/index.md
@@ -2,13 +2,11 @@
title: useLink
---
-`useLink` is a hook that leverages the `Link` property of the [`routerProvider`][routerprovider] to create links compatible with the user's router library.
+`useLink` is a hook that returns [``](/docs/routing/components/link/) component. It is used to navigate to different pages in your application.
:::simple Good to know
-It's recommended to use the `Link` component from your router library instead of this hook. This hook is used mostly for internal purposes and is only exposed for customization needs.
-
-The `Link` components or the equivalents from the router libraries has better type support and lets you use the full power of the router library.
+- It's recommended to use the `` component from the `@refinedev/core` package instead of this hook. This hook is used mostly for internal purposes and is only exposed for customization needs.
:::
@@ -20,14 +18,21 @@ import { useLink } from "@refinedev/core";
const MyComponent = () => {
const Link = useLink();
- return Posts;
+ return (
+ <>
+ Posts
+ {/* or */}
+
+ Posts
+
+ >
+ );
};
```
-
-## Parameters
-
-### to
-
-This is the path that the link will navigate to. It should be a string.
-
-[routerprovider]: /docs/routing/router-provider
diff --git a/documentation/sidebars.js b/documentation/sidebars.js
index 61f1ed177791..bcfc6fc1adeb 100644
--- a/documentation/sidebars.js
+++ b/documentation/sidebars.js
@@ -222,6 +222,12 @@ module.exports = {
"routing/integrations/remix/index",
],
},
+ {
+ type: "category",
+ collapsed: false,
+ label: "Components",
+ items: ["routing/components/link/index"],
+ },
{
type: "category",
collapsed: false,
diff --git a/packages/cli/src/commands/add/sub-commands/resource/index.test.ts b/packages/cli/src/commands/add/sub-commands/resource/index.test.ts
index cc91d7aabf35..4ae48aafc5de 100644
--- a/packages/cli/src/commands/add/sub-commands/resource/index.test.ts
+++ b/packages/cli/src/commands/add/sub-commands/resource/index.test.ts
@@ -7,7 +7,7 @@ const srcDirPath = `${__dirname}/../../../..`;
describe("add", () => {
beforeAll(() => {
- // usefull for speed up the tests.
+ // useful for speed up the tests.
jest.spyOn(console, "log").mockImplementation();
jest.spyOn(testTargetModule, "installInferencer").mockImplementation();
diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts
index faa4f1688dcc..b1791650414b 100644
--- a/packages/core/src/components/index.ts
+++ b/packages/core/src/components/index.ts
@@ -8,3 +8,4 @@ export { RouteChangeHandler } from "./routeChangeHandler";
export { CanAccess, CanAccessProps } from "./canAccess";
export { GitHubBanner } from "./gh-banner";
export { AutoSaveIndicator, AutoSaveIndicatorProps } from "./autoSaveIndicator";
+export { Link, LinkProps } from "./link";
diff --git a/packages/core/src/components/link/index.spec.tsx b/packages/core/src/components/link/index.spec.tsx
new file mode 100644
index 000000000000..5084e20523c7
--- /dev/null
+++ b/packages/core/src/components/link/index.spec.tsx
@@ -0,0 +1,112 @@
+import React from "react";
+import { TestWrapper, render } from "@test/index";
+import { Link } from "./index";
+
+describe("Link", () => {
+ describe("with `to`", () => {
+ it("should render a tag without router provider", () => {
+ const { getByText } = render(Test);
+
+ const link = getByText("Test");
+ expect(link.tagName).toBe("A");
+ expect(link.getAttribute("href")).toBe("/test");
+ });
+
+ it("should render a tag with router provider", () => {
+ const { getByTestId } = render(
+ foo="bar" to="/test" aria-label="test-label">
+ Test
+ ,
+ {
+ wrapper: TestWrapper({
+ routerProvider: {
+ Link: ({ to, children, ...props }) => (
+
+ {children}
+
+ ),
+ },
+ }),
+ },
+ );
+
+ const link = getByTestId("test-link");
+ expect(link.tagName).toBe("A");
+ expect(link.getAttribute("href")).toBe("/test");
+ expect(link.getAttribute("aria-label")).toBe("test-label");
+ expect(link.getAttribute("foo")).toBe("bar");
+ });
+ });
+
+ describe("with `go`", () => {
+ it("should render a tag go.to as object", () => {
+ const { getByTestId } = render(
+
+ Test
+ ,
+ {
+ wrapper: TestWrapper({
+ resources: [{ name: "test", show: "/test/:id" }],
+ routerProvider: {
+ go: () => () => {
+ return "/test/1";
+ },
+ Link: ({ to, children, ...props }) => (
+
+ {children}
+
+ ),
+ },
+ }),
+ },
+ );
+
+ const link = getByTestId("test-link");
+ expect(link.tagName).toBe("A");
+ expect(link.getAttribute("href")).toBe("/test/1");
+ expect(link.getAttribute("aria-label")).toBe("test-label");
+ });
+
+ it("should render a tag go.to as string", () => {
+ const { getByTestId } = render(
+
+ Test
+ ,
+ {
+ wrapper: TestWrapper({
+ routerProvider: {
+ go: () => () => {
+ return "/test/1";
+ },
+ Link: ({ to, children, ...props }) => (
+
+ {children}
+
+ ),
+ },
+ }),
+ },
+ );
+
+ const link = getByTestId("test-link");
+ expect(link.tagName).toBe("A");
+ expect(link.getAttribute("href")).toBe("/test/1");
+ expect(link.getAttribute("aria-label")).toBe("test-label");
+ });
+ });
+});
diff --git a/packages/core/src/components/link/index.tsx b/packages/core/src/components/link/index.tsx
new file mode 100644
index 000000000000..5b4f922501f7
--- /dev/null
+++ b/packages/core/src/components/link/index.tsx
@@ -0,0 +1,74 @@
+import React, { type Ref, forwardRef, useContext } from "react";
+import { useGo } from "@hooks/router";
+import { RouterContext } from "@contexts/router";
+import type { GoConfigWithResource } from "../../hooks/router/use-go";
+import warnOnce from "warn-once";
+
+type LinkPropsWithGo = {
+ go: Omit;
+};
+
+type LinkPropsWithTo = {
+ to: string;
+};
+
+export type LinkProps = React.PropsWithChildren<
+ (LinkPropsWithGo | LinkPropsWithTo) &
+ React.AnchorHTMLAttributes &
+ TProps
+>;
+
+/**
+ * @param to The path to navigate to.
+ * @param go The useGo.go params to navigate to. If `to` provided, this will be ignored.
+ * @returns routerProvider.Link if it is provided, otherwise an anchor tag.
+ */
+const LinkComponent = (
+ props: LinkProps,
+ ref: Ref,
+) => {
+ const routerContext = useContext(RouterContext);
+ const LinkFromContext = routerContext?.Link;
+
+ const goFunction = useGo();
+
+ let resolvedTo = "";
+ if ("to" in props) {
+ resolvedTo = props.to;
+ }
+ if ("go" in props) {
+ if (!routerContext?.go) {
+ warnOnce(
+ true,
+ "[Link]: `routerProvider` is not found. To use `go`, Please make sure that you have provided the `routerProvider` for `` https://refine.dev/docs/routing/router-provider/ \n",
+ );
+ }
+ resolvedTo = goFunction({ ...props.go, type: "path" }) as string;
+ }
+
+ if (LinkFromContext) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+};
+
+export const Link = forwardRef(LinkComponent) as (
+ props: LinkProps & { ref?: Ref },
+) => ReturnType;
diff --git a/packages/core/src/hooks/router/use-link/index.spec.tsx b/packages/core/src/hooks/router/use-link/index.spec.tsx
index 022e036310e6..e3aaa1be935a 100644
--- a/packages/core/src/hooks/router/use-link/index.spec.tsx
+++ b/packages/core/src/hooks/router/use-link/index.spec.tsx
@@ -1,63 +1,32 @@
import React from "react";
-import { render, renderHook } from "@testing-library/react";
-
-import { MockJSONServer, TestWrapper, mockRouterProvider } from "@test";
-
+import {
+ MockJSONServer,
+ TestWrapper,
+ mockRouterProvider,
+ renderHook,
+ render,
+} from "@test";
import { useLink } from "./";
+import "../../../components/link";
-describe("useLink Hook", () => {
- it("should return routerProvider Link compotent", () => {
- const mockLink = jest.fn();
-
- const { result } = renderHook(() => useLink(), {
- wrapper: TestWrapper({
- resources: [{ name: "posts" }],
- dataProvider: MockJSONServer,
- routerProvider: mockRouterProvider({
- fns: {
- Link: mockLink,
- },
- }),
- }),
- });
-
- expect(result.current).toEqual(mockLink);
- });
+jest.mock("../../../components/link", () => ({
+ Link: jest.fn().mockReturnValue(),
+}));
- it("if routerProvider go function is not defined, should return undefined", () => {
+describe("useLink Hook", () => {
+ it("should return Link component", () => {
const { result } = renderHook(() => useLink(), {
wrapper: TestWrapper({
resources: [{ name: "posts" }],
dataProvider: MockJSONServer,
- routerProvider: mockRouterProvider({
- fns: {
- Link: undefined,
- },
- }),
+ routerProvider: mockRouterProvider(),
}),
});
- const Link = result.current;
-
- const { container } = render();
-
- expect(container.querySelector("a")?.getAttribute("href")).toEqual(
- "/posts",
- );
- });
-
- it("if it is used outside of router provider, should return undefined", () => {
- jest.spyOn(React, "useContext").mockReturnValue(undefined);
-
- const { result } = renderHook(() => useLink());
-
- const Link = result.current;
-
- const { container } = render();
+ const Component = result.current;
- expect(container.querySelector("a")?.getAttribute("href")).toEqual(
- "/posts",
- );
+ const { getByTestId } = render();
+ expect(getByTestId("mocked-link")).toBeInTheDocument();
});
});
diff --git a/packages/core/src/hooks/router/use-link/index.tsx b/packages/core/src/hooks/router/use-link/index.tsx
index e1e1b58cc050..5615442a9033 100644
--- a/packages/core/src/hooks/router/use-link/index.tsx
+++ b/packages/core/src/hooks/router/use-link/index.tsx
@@ -1,17 +1,5 @@
-import { RouterContext } from "@contexts/router";
-import React, { useContext } from "react";
+import { Link } from "../../../components/link";
export const useLink = () => {
- const routerContext = useContext(RouterContext);
-
- if (routerContext?.Link) {
- return routerContext.Link;
- }
-
- const FallbackLink: Required["Link"] = ({
- to,
- ...rest
- }) => ;
-
- return FallbackLink;
+ return Link;
};