From 6ff55a393062726241a43371a2645d943f4e73e1 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Sun, 5 Nov 2023 14:37:22 +0300 Subject: [PATCH 01/18] implement asChild for link --- packages/wouter/src/index.js | 25 +++++++++++++------------ packages/wouter/types/index.d.ts | 16 ++++++++++------ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 045a444a..91b95a1a 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -183,7 +183,7 @@ export const Link = forwardRef((props, ref) => { const router = useRouter(); const [, navigate] = useLocationFromRouter(router); - const { to, href = to, children, onClick } = props; + const { to, href = to, children, asChild, onClick } = props; const handleClick = useEvent((event) => { // ignores the navigation when clicked using right mouse button or @@ -204,17 +204,18 @@ export const Link = forwardRef((props, ref) => { } }); - // wraps children in `a` if needed - const extraProps = { - // handle nested routers and absolute paths - href: href[0] === "~" ? href.slice(1) : router.base + href, - onClick: handleClick, - to: null, - ref, - }; - const jsx = isValidElement(children) ? children : h("a", props); - - return cloneElement(jsx, extraProps); + const link = href[0] === "~" ? href.slice(1) : router.base + href; + + if (asChild) { + const jsx = isValidElement(children) ? children : null; + return cloneElement(jsx, { + // handle nested routers and absolute paths + href: link, + onClick: handleClick, + }); + } + + return h("a", { ...props, href: link, onClick: handleClick, to: null, ref }); }); const flattenChildren = (children) => { diff --git a/packages/wouter/types/index.d.ts b/packages/wouter/types/index.d.ts index 849aabbf..748cc718 100644 --- a/packages/wouter/types/index.d.ts +++ b/packages/wouter/types/index.d.ts @@ -97,11 +97,6 @@ export type NavigationalProps< > = ({ to: Path; href?: never } | { href: Path; to?: never }) & HookNavigationOptions; -export type LinkProps = Omit< - AnchorHTMLAttributes, - "href" -> & - NavigationalProps; export type RedirectProps = NavigationalProps & { @@ -113,8 +108,17 @@ export function Redirect( context?: any ): ReactElement | null; +type AsChildProps = + | ({ asChild?: false } & RequiredProps & DefaultElementProps) + | ({ asChild: true; children: React.ReactNode } & RequiredProps); + +export type LinkProps = AsChildProps< + NavigationalProps, + AnchorHTMLAttributes & RefAttributes +>; + export function Link( - props: PropsWithChildren> & RefAttributes, + props: LinkProps, context?: any ): ReactElement | null; From 0e91a5759991d69eb27b2d8564f76e5585155936 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Sun, 5 Nov 2023 14:38:09 +0300 Subject: [PATCH 02/18] rewrite tests for link component --- package.json | 3 +- packages/wouter/setup-vitest.ts | 1 + packages/wouter/test/link.test.tsx | 296 +++++++++++++---------------- packages/wouter/vitest.config.ts | 1 + 4 files changed, 134 insertions(+), 167 deletions(-) create mode 100644 packages/wouter/setup-vitest.ts diff --git a/package.json b/package.json index 38d9df77..d3f83dfa 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "@rollup/plugin-node-resolve": "^15.0.2", "@rollup/plugin-replace": "^5.0.2", "@size-limit/preset-small-lib": "^10.0.1", + "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.0.0", "@types/babel__core": "^7.20.2", "@types/react": "^18.2.0", @@ -158,6 +159,6 @@ "rollup": "^3.7.4", "size-limit": "^10.0.1", "typescript": "5.0.4", - "vitest": "^0.34.3" + "vitest": "^0.34.6" } } diff --git a/packages/wouter/setup-vitest.ts b/packages/wouter/setup-vitest.ts new file mode 100644 index 00000000..f149f27a --- /dev/null +++ b/packages/wouter/setup-vitest.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index ae15cc47..ec7b3545 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -1,206 +1,170 @@ import { useRef, useEffect, PropsWithChildren, MouseEventHandler } from "react"; -import { it, expect, afterEach, vi } from "vitest"; +import { it, expect, afterEach, vi, describe } from "vitest"; import { render, cleanup, fireEvent } from "@testing-library/react"; import { Router, Link } from "wouter"; -const LinkWithForwardedRef = (props: PropsWithChildren<{}>) => { - const ref = useRef(null); - - useEffect(() => { - if (ref.current) { - ref.current.innerHTML = "Tested"; - } - }, [ref]); - - return ( - - {props.children} - - ); -}; - afterEach(cleanup); -it("renders a link with proper attributes", () => { - const { container } = render( - - Click Me - - ); - - const link = container.firstChild as HTMLAnchorElement; +describe("base link", () => { + it("renders a link with proper attributes", () => { + const { getByText } = render( + + Click Me + + ); - expect(link.tagName).toBe("A"); - expect(link.className).toBe("link--active"); - expect(link.getAttribute("href")).toBe("/about"); - expect(link.textContent).toBe("Click Me"); -}); + const element = getByText("Click Me"); -it("wraps children in an if needed", () => { - const { container } = render(Testing); - const link = container.firstChild as HTMLAnchorElement; + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute("href", "/about"); + expect(element).toHaveClass("link--active"); + }); - expect(link.tagName).toBe("A"); - expect(link.textContent).toBe("Testing"); -}); + it("passes ref to ", () => { + const refCallback = vi.fn<[HTMLAnchorElement], void>(); + const { getByText } = render( + + Testing + + ); -it("works for any other elements as well", () => { - const { container } = render( - -
Click Me
- - ); + const element = getByText("Testing"); - const link = container.firstChild as HTMLElement; + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute("href", "/"); - expect(link.tagName).toBe("DIV"); - expect(link.className).toBe("link--wannabe"); - expect(link.textContent).toBe("Click Me"); -}); - -it("passes ref to
", () => { - const { container } = render( - Testing - ); - const link = container.firstChild as HTMLAnchorElement; + expect(refCallback).toBeCalledTimes(1); + expect(refCallback).toBeCalledWith(element); + }); - expect(link.tagName).toBe("A"); - expect(link.textContent).toBe("Tested"); -}); + it("still creates a plain link when nothing is passed", () => { + const { getByTestId } = render(); -it("passes ref to any other child element", () => { - const { container } = render( - -
Testing
-
- ); - const link = container.firstChild as HTMLElement; + const element = getByTestId("link"); - expect(link.tagName).toBe("DIV"); - expect(link.textContent).toBe("Tested"); -}); + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute("href", "/about"); + expect(element).toBeEmptyDOMElement(); + }); -it("still creates a plain link when nothing is passed", () => { - const { container } = render(); - const link = container.firstChild as HTMLAnchorElement; + it("supports `to` prop as an alias to `href`", () => { + const { getByText } = render(Hello); + const element = getByText("Hello"); - expect(link.tagName).toBe("A"); - expect(link.getAttribute("href")).toBe("/about"); -}); + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute("href", "/about"); + }); -it("supports `to` prop as an alias to `href`", () => { - const { container } = render(Hello); - const link = container.firstChild as HTMLAnchorElement; + it("performs a navigation when the link is clicked", () => { + const { getByTestId } = render( + + link + + ); - expect(link.getAttribute("href")).toBe("/about"); -}); + fireEvent.click(getByTestId("link")); -it("performs a navigation when the link is clicked", () => { - const { getByTestId } = render( - -
- - ); - fireEvent.click(getByTestId("link")); - expect(location.pathname).toBe("/goo-baz"); -}); + expect(location.pathname).toBe("/goo-baz"); + }); -it("supports replace navigation", () => { - const { getByTestId } = render( - - - - ); + it("supports replace navigation", () => { + const { getByTestId } = render( + + link + + ); - const histBefore = history.length; + const histBefore = history.length; - fireEvent.click(getByTestId("link")); - expect(location.pathname).toBe("/goo-baz"); - expect(history.length).toBe(histBefore); -}); + fireEvent.click(getByTestId("link")); -it("ignores the navigation when clicked with modifiers", () => { - const { getByTestId } = render( - - click - - ); - const clickEvt = new MouseEvent("click", { - bubbles: true, - cancelable: true, - button: 0, - ctrlKey: true, + expect(location.pathname).toBe("/goo-baz"); + expect(history.length).toBe(histBefore); }); - // js-dom doesn't implement browser navigation (e.g. changing location - // when a link is clicked) so we need just ingore it to avoid warnings - clickEvt.preventDefault(); + it("ignores the navigation when clicked with modifiers", () => { + const { getByTestId } = render( + + click + + ); + const clickEvt = new MouseEvent("click", { + bubbles: true, + cancelable: true, + button: 0, + ctrlKey: true, + }); + + // js-dom doesn't implement browser navigation (e.g. changing location + // when a link is clicked) so we need just ingore it to avoid warnings + clickEvt.preventDefault(); + + fireEvent(getByTestId("link"), clickEvt); + expect(location.pathname).not.toBe("/users"); + }); - fireEvent(getByTestId("link"), clickEvt); - expect(location.pathname).not.toBe("/users"); -}); + it("ignores the navigation when event is cancelled", () => { + const clickHandler: MouseEventHandler = (e) => { + e.preventDefault(); + }; -it("ignores the navigation when event is cancelled", () => { - const clickHandler: MouseEventHandler = (e) => { - e.preventDefault(); - }; + const { getByTestId } = render( + + click + + ); - const { getByTestId } = render( - - click - - ); + fireEvent.click(getByTestId("link")); + expect(location.pathname).not.toBe("/users"); + }); - fireEvent.click(getByTestId("link")); - expect(location.pathname).not.toBe("/users"); -}); + it("accepts an `onClick` prop, fired before the navigation", () => { + const clickHandler = vi.fn(); -it("accepts an `onClick` prop, fired before the navigation", () => { - const clickHandler = vi.fn(); + const { getByTestId } = render( + + + + ); - const { getByTestId } = render( - - - - ); + fireEvent.click(getByTestId("link")); + expect(clickHandler).toHaveBeenCalledTimes(1); + }); - fireEvent.click(getByTestId("link")); - expect(clickHandler).toHaveBeenCalledTimes(1); -}); + it("renders `href` with basepath", () => { + const { getByTestId } = render( + + + + ); -it("renders `href` with basepath", () => { - const { getByTestId } = render( - - - - ); + const link = getByTestId("link"); + expect(link.getAttribute("href")).toBe("/app/dashboard"); + }); - const link = getByTestId("link"); - expect(link.getAttribute("href")).toBe("/app/dashboard"); -}); + it("renders `href` with absolute links", () => { + const { getByTestId } = render( + + + + ); -it("renders `href` with absolute links", () => { - const { getByTestId } = render( - - - - ); + const element = getByTestId("link"); + expect(element).toHaveAttribute("href", "/home"); + }); - const link = getByTestId("link"); - expect(link.getAttribute("href")).toBe("/home"); + it("supports history state", () => { + const testState = { hello: "world" }; + const { getByTestId } = render( + + link + + ); + + fireEvent.click(getByTestId("link")); + expect(location.pathname).toBe("/goo-baz"); + expect(history.state).toBe(testState); + }); }); -it("supports history state", () => { - const testState = { hello: "world" }; - const { getByTestId, unmount } = render( - - - - ); - - fireEvent.click(getByTestId("link")); - expect(location.pathname).toBe("/goo-baz"); - expect(history.state).toBe(testState); - unmount(); -}); diff --git a/packages/wouter/vitest.config.ts b/packages/wouter/vitest.config.ts index 514d6d00..0841e3da 100644 --- a/packages/wouter/vitest.config.ts +++ b/packages/wouter/vitest.config.ts @@ -5,6 +5,7 @@ export default defineProject({ plugins: [react({ jsxRuntime: "automatic" })], test: { name: "wouter-react", + setupFiles: "./setup-vitest.ts", environment: "jsdom", }, }); From 6f0cb209e7dbcc2f33803809eaf30ac13dff255f Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Sun, 5 Nov 2023 14:38:21 +0300 Subject: [PATCH 03/18] apply code style --- packages/wouter/test/link.test.tsx | 1 - packages/wouter/types/index.d.ts | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index ec7b3545..ae3633d4 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -167,4 +167,3 @@ describe("base link", () => { expect(history.state).toBe(testState); }); }); - diff --git a/packages/wouter/types/index.d.ts b/packages/wouter/types/index.d.ts index 748cc718..594c8f0d 100644 --- a/packages/wouter/types/index.d.ts +++ b/packages/wouter/types/index.d.ts @@ -97,7 +97,6 @@ export type NavigationalProps< > = ({ to: Path; href?: never } | { href: Path; to?: never }) & HookNavigationOptions; - export type RedirectProps = NavigationalProps & { children?: never; @@ -112,10 +111,11 @@ type AsChildProps = | ({ asChild?: false } & RequiredProps & DefaultElementProps) | ({ asChild: true; children: React.ReactNode } & RequiredProps); -export type LinkProps = AsChildProps< - NavigationalProps, - AnchorHTMLAttributes & RefAttributes ->; +export type LinkProps = + AsChildProps< + NavigationalProps, + AnchorHTMLAttributes & RefAttributes + >; export function Link( props: LinkProps, From 18466f5fafeaf62193296df821e35188d9bdc95d Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Thu, 9 Nov 2023 13:19:59 +0300 Subject: [PATCH 04/18] fix preact test --- packages/wouter-preact/test/preact.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/wouter-preact/test/preact.test.tsx b/packages/wouter-preact/test/preact.test.tsx index f1063c06..2f37745a 100644 --- a/packages/wouter-preact/test/preact.test.tsx +++ b/packages/wouter-preact/test/preact.test.tsx @@ -24,7 +24,8 @@ describe("Preact support", () => { The Best Albums Ever - + {/* @ts-ignore */} + Featured Now: London Calling, Clash From 4d2fbdce8c75ec7b7ff967429f51761d483c0a01 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Thu, 9 Nov 2023 13:20:29 +0300 Subject: [PATCH 05/18] fix warnings in tests --- packages/wouter/src/index.js | 10 +++++++++- packages/wouter/test/link.test.tsx | 4 +--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 91b95a1a..f5260543 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -215,7 +215,15 @@ export const Link = forwardRef((props, ref) => { }); } - return h("a", { ...props, href: link, onClick: handleClick, to: null, ref }); + return h("a", { + ...props, + href: link, + onClick: handleClick, + to: null, + replace: null, + state: null, + ref, + }); }); const flattenChildren = (children) => { diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index ae3633d4..0bba93c0 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -123,9 +123,7 @@ describe("base link", () => { const clickHandler = vi.fn(); const { getByTestId } = render( - - - + ); fireEvent.click(getByTestId("link")); From 581e8c9a05c32ef51c29fd7a158532868db5eb13 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Sat, 11 Nov 2023 16:34:13 +0300 Subject: [PATCH 06/18] test Link types --- packages/wouter/test/link.test-d.tsx | 130 +++++++++++++++++++-------- packages/wouter/test/link.test.tsx | 36 +++++++- 2 files changed, 128 insertions(+), 38 deletions(-) diff --git a/packages/wouter/test/link.test-d.tsx b/packages/wouter/test/link.test-d.tsx index 72396e47..0c012039 100644 --- a/packages/wouter/test/link.test-d.tsx +++ b/packages/wouter/test/link.test-d.tsx @@ -1,44 +1,60 @@ -import { describe, it, assertType } from "vitest"; +import { describe, it } from "vitest"; import { Link } from "wouter"; import * as React from "react"; -describe("Link types", () => { +describe(" types", () => { it("should have required prop href", () => { // @ts-expect-error - assertType(test); - assertType(test); + test; + test; }); - it("should support state prop", () => { - assertType( - - test - - ); - assertType( - - test - - ); - assertType( - - test - - ); - assertType( - - test - - ); + it("should throw error when `to` and `href` props are used in same time", () => { + // @ts-expect-error + + Hello + ; + }); + + it("should inherit props from `HTMLAnchorElements`", () => { + + Hello + ; + + + Hello + ; + + + Hello + ; + + + Hello + ; + }); + + it("should support other navigation params", () => { + + test + ; + + + test + ; + + + test + ; }); }); -describe("Link's ref", () => { +describe(" with ref", () => { it("should work", () => { const ref = React.useRef(null); - HELLO + Hello ; }); @@ -47,7 +63,7 @@ describe("Link's ref", () => { // @ts-expect-error - HELLO + Hello ; }); @@ -56,19 +72,61 @@ describe("Link's ref", () => { // @ts-expect-error - HELLO + Hello ; }); +}); - it("should work with composed components", () => { - const ref = React.useRef>(null); +describe(" in asChild mode", () => { + it("should work", () => { + + Hello + ; + }); - - + it("should throw error when `to` and `href` props are used in same time", () => { + // @ts-expect-error + + Hello + ; + }); + + it("should throw error when unsupported props are provided", () => { + // @ts-expect-error + + Hello + ; + + // @ts-expect-error + + Hello + ; + + // @ts-expect-error + + Hello + ; + + // @ts-expect-error + + Hello ; }); -}); -const Component = React.forwardRef<{ hello: "world" }>((props, ref) => { - return <>test; + it("should throw error when to and href props are used in same time", () => { + // @ts-expect-error + + Hello + ; + }); + + it("should support other navigation params", () => { + + Hello + ; + + + Hello + ; + }); }); diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index 0bba93c0..a0187ad6 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, PropsWithChildren, MouseEventHandler } from "react"; +import { type MouseEventHandler } from "react"; import { it, expect, afterEach, vi, describe } from "vitest"; import { render, cleanup, fireEvent } from "@testing-library/react"; @@ -6,7 +6,7 @@ import { Router, Link } from "wouter"; afterEach(cleanup); -describe("base link", () => { +describe("", () => { it("renders a link with proper attributes", () => { const { getByText } = render( @@ -165,3 +165,35 @@ describe("base link", () => { expect(history.state).toBe(testState); }); }); + +describe(" in asChild mode", () => { + it("works for any other elements as well", () => { + const { getByText } = render( + +
Click Me
+ + ); + + const link = getByText("Click Me"); + + expect(link.tagName).toBe("DIV"); + expect(link).not.toHaveAttribute("href"); + expect(link).toHaveClass("link--wannabe"); + expect(link).toHaveTextContent("Click Me"); + }); + + it("injects href prop when rendered with `asChild`", () => { + const { getByText } = render( + +
Click Me
+ + ); + + const link = getByText("Click Me"); + + expect(link.tagName).toBe("DIV"); + expect(link).toHaveClass("link--wannabe"); + expect(link).toHaveAttribute("href", "/about"); + expect(link).toHaveTextContent("Click Me"); + }); +}); From 4fbcb2794e598d02223410f8f43cb3520c9c1d1c Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Sat, 11 Nov 2023 16:35:32 +0300 Subject: [PATCH 07/18] increase size limit for preact --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d3f83dfa..354a1ef6 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ }, { "path": "packages/wouter-preact/esm/index.js", - "limit": "2000 B", + "limit": "2500 B", "ignore": [ "preact", "preact/hooks" From 32da14dab3ba8a1f483b9481a2fc86fdca0636b8 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Sat, 11 Nov 2023 16:45:52 +0300 Subject: [PATCH 08/18] add preact types --- packages/wouter-preact/test/preact.test.tsx | 1 - packages/wouter-preact/types/index.d.ts | 11 ++++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/wouter-preact/test/preact.test.tsx b/packages/wouter-preact/test/preact.test.tsx index 2f37745a..204766e4 100644 --- a/packages/wouter-preact/test/preact.test.tsx +++ b/packages/wouter-preact/test/preact.test.tsx @@ -24,7 +24,6 @@ describe("Preact support", () => { The Best Albums Ever - {/* @ts-ignore */} Featured Now: London Calling, Clash diff --git a/packages/wouter-preact/types/index.d.ts b/packages/wouter-preact/types/index.d.ts index 7085cc64..4c7a308b 100644 --- a/packages/wouter-preact/types/index.d.ts +++ b/packages/wouter-preact/types/index.d.ts @@ -81,11 +81,12 @@ export type NavigationalProps< > = ({ to: Path; href?: never } | { href: Path; to?: never }) & HookNavigationOptions; -export type LinkProps = Omit< - JSX.HTMLAttributes, - "href" -> & - NavigationalProps; +type AsChildProps = + | ({ asChild?: false } & RequiredProps & DefaultElementProps) + | ({ asChild: true; children: ComponentChildren } & RequiredProps); + +export type LinkProps = + AsChildProps, JSX.HTMLAttributes>; export type RedirectProps = NavigationalProps & { From fab38b3a4cd8a0f6036128d29d653b68dbf072c8 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Sat, 11 Nov 2023 16:46:04 +0300 Subject: [PATCH 09/18] bump typescript --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 354a1ef6..ec37b7c0 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "rimraf": "^3.0.2", "rollup": "^3.7.4", "size-limit": "^10.0.1", - "typescript": "5.0.4", + "typescript": "5.2.2", "vitest": "^0.34.6" } } From 2dc83bfc1efd85c2736588c7fa47352ba4b7c4bd Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Sat, 11 Nov 2023 17:02:19 +0300 Subject: [PATCH 10/18] throw Error when children are not valid in link component --- packages/wouter/src/index.js | 9 ++++++--- packages/wouter/test/link.test.tsx | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index f5260543..c8bb2313 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -204,12 +204,15 @@ export const Link = forwardRef((props, ref) => { } }); + // handle nested routers and absolute paths const link = href[0] === "~" ? href.slice(1) : router.base + href; if (asChild) { - const jsx = isValidElement(children) ? children : null; - return cloneElement(jsx, { - // handle nested routers and absolute paths + if (!isValidElement(children)) { + throw Error("Only one child allowed"); + } + + return cloneElement(children, { href: link, onClick: handleClick, }); diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index a0187ad6..9d1b6e56 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -182,6 +182,28 @@ describe(" in asChild mode", () => { expect(link).toHaveTextContent("Click Me"); }); + it("throws error when invalid element is provided", () => { + expect(() => + render( + + Click Me + + ) + ).throw(); + }); + + it("throws error when more than one element is provided", () => { + expect(() => + render( + + 1 + 2 + 3 + + ) + ).throw(); + }); + it("injects href prop when rendered with `asChild`", () => { const { getByText } = render( From 48fbf0dc5a63bd4ddee7b7c48a9c9b2c30c0a7ce Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Tue, 14 Nov 2023 21:50:03 +0300 Subject: [PATCH 11/18] Update packages/wouter/test/link.test-d.tsx Co-authored-by: Alexey Taktarov --- packages/wouter/test/link.test-d.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wouter/test/link.test-d.tsx b/packages/wouter/test/link.test-d.tsx index 0c012039..47e32d2a 100644 --- a/packages/wouter/test/link.test-d.tsx +++ b/packages/wouter/test/link.test-d.tsx @@ -9,7 +9,7 @@ describe(" types", () => { test; }); - it("should throw error when `to` and `href` props are used in same time", () => { + it("does not allow `to` and `href` props to be used at the same time", () => { // @ts-expect-error Hello From cdc673df58e0226445c484da06fbe9a9d9eb2fb6 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Tue, 14 Nov 2023 21:50:15 +0300 Subject: [PATCH 12/18] Update packages/wouter/test/link.test-d.tsx Co-authored-by: Alexey Taktarov --- packages/wouter/test/link.test-d.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wouter/test/link.test-d.tsx b/packages/wouter/test/link.test-d.tsx index 47e32d2a..cae7a744 100644 --- a/packages/wouter/test/link.test-d.tsx +++ b/packages/wouter/test/link.test-d.tsx @@ -16,7 +16,7 @@ describe(" types", () => { ; }); - it("should inherit props from `HTMLAnchorElements`", () => { + it("should inherit props from `HTMLAnchorElement`", () => { Hello ; From 891e0c8237ad59ec928fc8fae9897f4b3ab4c71e Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Tue, 14 Nov 2023 22:00:17 +0300 Subject: [PATCH 13/18] tweak tests --- packages/wouter/test/link.test-d.tsx | 21 ++++++++++++--------- packages/wouter/test/link.test.tsx | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/wouter/test/link.test-d.tsx b/packages/wouter/test/link.test-d.tsx index cae7a744..93bfad2a 100644 --- a/packages/wouter/test/link.test-d.tsx +++ b/packages/wouter/test/link.test-d.tsx @@ -43,6 +43,11 @@ describe(" types", () => { test ; + // @ts-expect-error + + Hello + ; + test ; @@ -77,21 +82,21 @@ describe(" with ref", () => { }); }); -describe(" in asChild mode", () => { +describe(" with `asChild` prop", () => { it("should work", () => { Hello ; }); - it("should throw error when `to` and `href` props are used in same time", () => { + it("does not allow `to` and `href` props to be used at the same time", () => { // @ts-expect-error Hello ; }); - it("should throw error when unsupported props are provided", () => { + it("does not allow other props", () => { // @ts-expect-error Hello @@ -113,15 +118,13 @@ describe(" in asChild mode", () => { ; }); - it("should throw error when to and href props are used in same time", () => { - // @ts-expect-error - + it("should support other navigation params", () => { + Hello ; - }); - it("should support other navigation params", () => { - + // @ts-expect-error + Hello ; diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index 9d1b6e56..07d826ed 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -166,7 +166,7 @@ describe("", () => { }); }); -describe(" in asChild mode", () => { +describe(" with `asChild` prop", () => { it("works for any other elements as well", () => { const { getByText } = render( From edcfec2d6e4cf9bd1b4f81fc27729b7742643454 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Tue, 14 Nov 2023 22:01:27 +0300 Subject: [PATCH 14/18] Update packages/wouter/test/link.test.tsx Co-authored-by: Alexey Taktarov --- packages/wouter/test/link.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index 07d826ed..c5c9dbb0 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -167,7 +167,7 @@ describe("", () => { }); describe(" with `asChild` prop", () => { - it("works for any other elements as well", () => { + it("when `asChild` is not specified, wraps the children in an ", () => { const { getByText } = render(
Click Me
From 9bfba7713069deaae443f1308327b9c1698adaa6 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Tue, 14 Nov 2023 22:33:18 +0300 Subject: [PATCH 15/18] apply code golfed code from @molefrog --- packages/wouter/src/index.js | 44 +++++++++++-------------- packages/wouter/test/link.test.tsx | 52 ++++++++++++++++++------------ packages/wouter/test/ssr.test.tsx | 2 +- 3 files changed, 51 insertions(+), 47 deletions(-) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index c8bb2313..0b7e6400 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -183,9 +183,18 @@ export const Link = forwardRef((props, ref) => { const router = useRouter(); const [, navigate] = useLocationFromRouter(router); - const { to, href = to, children, asChild, onClick } = props; - - const handleClick = useEvent((event) => { + const { + to, + href: _href = to, + onClick: _onClick, + asChild, + children, + replace /* ignore nav props */, + state /* ignore nav props */, + ...restProps + } = props; + + const onClick = useEvent((event) => { // ignores the navigation when clicked using right mouse button or // by holding a special modifier key: ctrl, command, win, alt, shift if ( @@ -197,36 +206,19 @@ export const Link = forwardRef((props, ref) => { ) return; - onClick && onClick(event); + _onClick && _onClick(event); // TODO: is it safe to use _onClick?.(event) if (!event.defaultPrevented) { event.preventDefault(); - navigate(to || href, props); + navigate(_href, props); } }); // handle nested routers and absolute paths - const link = href[0] === "~" ? href.slice(1) : router.base + href; - - if (asChild) { - if (!isValidElement(children)) { - throw Error("Only one child allowed"); - } + const href = _href[0] === "~" ? _href.slice(1) : router.base + _href; - return cloneElement(children, { - href: link, - onClick: handleClick, - }); - } - - return h("a", { - ...props, - href: link, - onClick: handleClick, - to: null, - replace: null, - state: null, - ref, - }); + return asChild && isValidElement(children) + ? cloneElement(children, { href, onClick }) + : h("a", { ...restProps, href, onClick, children, ref }); }); const flattenChildren = (children) => { diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index c5c9dbb0..aa8eb57a 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -180,28 +180,40 @@ describe(" with `asChild` prop", () => { expect(link).not.toHaveAttribute("href"); expect(link).toHaveClass("link--wannabe"); expect(link).toHaveTextContent("Click Me"); + + expect(link.parentElement?.tagName).toBe("A"); + expect(link.parentElement).toHaveAttribute("href", "/about"); + }); + + it("when invalid element is provided, wraps the children in an
", () => { + const { getByText } = render( + + Click Me + + ); + + const link = getByText("Click Me"); + + expect(link.tagName).toBe("A"); + expect(link).toHaveAttribute("href", "/about"); + expect(link).toHaveTextContent("Click Me"); }); - it("throws error when invalid element is provided", () => { - expect(() => - render( - - Click Me - - ) - ).throw(); - }); - - it("throws error when more than one element is provided", () => { - expect(() => - render( - - 1 - 2 - 3 - - ) - ).throw(); + it("when more than one element is provided, wraps the children in an ", async () => { + const { getByText } = render( + + 1 + 2 + 3 + + ); + + const span = getByText("1"); + + expect(span.parentElement?.tagName).toBe("A"); + + expect(span.parentElement).toHaveAttribute("href", "/about"); + expect(span.parentElement).toHaveTextContent("123"); }); it("injects href prop when rendered with `asChild`", () => { diff --git a/packages/wouter/test/ssr.test.tsx b/packages/wouter/test/ssr.test.tsx index f82ffbaf..add22739 100644 --- a/packages/wouter/test/ssr.test.tsx +++ b/packages/wouter/test/ssr.test.tsx @@ -55,7 +55,7 @@ describe("server-side rendering", () => { ); const rendered = renderToStaticMarkup(); - expect(rendered).toBe(`Mark`); + expect(rendered).toBe(`Mark`); }); it("renders redirects however they have effect only on a client-side", () => { From 3538922990f298178dab4b39fc0901ddeb7a79a8 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Tue, 14 Nov 2023 22:34:50 +0300 Subject: [PATCH 16/18] fix lint errors --- packages/wouter/src/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 0b7e6400..d56e21ac 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -189,8 +189,10 @@ export const Link = forwardRef((props, ref) => { onClick: _onClick, asChild, children, + /* eslint-disable no-unused-vars */ replace /* ignore nav props */, state /* ignore nav props */, + /* eslint-enable no-unused-vars */ ...restProps } = props; From 15b6471c892bee85e6bd02ca661ce05b11888357 Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Wed, 15 Nov 2023 13:06:41 +0300 Subject: [PATCH 17/18] add test case for generics --- packages/wouter/test/link.test-d.tsx | 35 +++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/wouter/test/link.test-d.tsx b/packages/wouter/test/link.test-d.tsx index 93bfad2a..eed01c6a 100644 --- a/packages/wouter/test/link.test-d.tsx +++ b/packages/wouter/test/link.test-d.tsx @@ -1,7 +1,12 @@ import { describe, it } from "vitest"; -import { Link } from "wouter"; +import { Link, type Path } from "wouter"; import * as React from "react"; +type NetworkLocationHook = () => [ + Path, + (path: string, options: { host: string; retries?: number }) => void +]; + describe(" types", () => { it("should have required prop href", () => { // @ts-expect-error @@ -52,6 +57,19 @@ describe(" types", () => { test ; }); + + it("should work with generic type", () => { + href="/" host="wouter.com"> + test + ; + + // @ts-expect-error + href="/">test; + + href="/" host="wouter.com" retries={4}> + test + ; + }); }); describe(" with ref", () => { @@ -132,4 +150,19 @@ describe(" with `asChild` prop", () => { Hello ; }); + + it("should work with generic type", () => { + asChild to="/" host="wouter.com"> +
test
+ ; + + // @ts-expect-error + asChild to="/"> +
test
+ ; + + asChild to="/" host="wouter.com" retries={4}> +
test
+ ; + }); }); From e5e9e04d266d799ac4c795c2067702946cba86dc Mon Sep 17 00:00:00 2001 From: Dmitry Ivakhnenko Date: Wed, 15 Nov 2023 13:11:03 +0300 Subject: [PATCH 18/18] rename `RequiredProps` to `ComponentProps` --- packages/wouter-preact/types/index.d.ts | 6 +++--- packages/wouter/types/index.d.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/wouter-preact/types/index.d.ts b/packages/wouter-preact/types/index.d.ts index 4c7a308b..f0b8688a 100644 --- a/packages/wouter-preact/types/index.d.ts +++ b/packages/wouter-preact/types/index.d.ts @@ -81,9 +81,9 @@ export type NavigationalProps< > = ({ to: Path; href?: never } | { href: Path; to?: never }) & HookNavigationOptions; -type AsChildProps = - | ({ asChild?: false } & RequiredProps & DefaultElementProps) - | ({ asChild: true; children: ComponentChildren } & RequiredProps); +type AsChildProps = + | ({ asChild?: false } & ComponentProps & DefaultElementProps) + | ({ asChild: true; children: ComponentChildren } & ComponentProps); export type LinkProps = AsChildProps, JSX.HTMLAttributes>; diff --git a/packages/wouter/types/index.d.ts b/packages/wouter/types/index.d.ts index 594c8f0d..0d750da7 100644 --- a/packages/wouter/types/index.d.ts +++ b/packages/wouter/types/index.d.ts @@ -107,9 +107,9 @@ export function Redirect( context?: any ): ReactElement | null; -type AsChildProps = - | ({ asChild?: false } & RequiredProps & DefaultElementProps) - | ({ asChild: true; children: React.ReactNode } & RequiredProps); +type AsChildProps = + | ({ asChild?: false } & ComponentProps & DefaultElementProps) + | ({ asChild: true; children: React.ReactNode } & ComponentProps); export type LinkProps = AsChildProps<