diff --git a/.changeset/lovely-cheetahs-sip.md b/.changeset/lovely-cheetahs-sip.md new file mode 100644 index 0000000000..28500c29ed --- /dev/null +++ b/.changeset/lovely-cheetahs-sip.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": patch +--- + +Fix SSR of absolute Link urls diff --git a/contributors.yml b/contributors.yml index 93c88b4e00..ac2ef23d42 100644 --- a/contributors.yml +++ b/contributors.yml @@ -163,6 +163,7 @@ - tanayv - theostavrides - thisiskartik +- thomasverleye - ThornWu - timdorr - TkDodo diff --git a/packages/react-router-dom/__tests__/data-static-router-test.tsx b/packages/react-router-dom/__tests__/data-static-router-test.tsx index 839d5d24c0..9c6856a62b 100644 --- a/packages/react-router-dom/__tests__/data-static-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-static-router-test.tsx @@ -1,3 +1,7 @@ +/** + * @jest-environment node + */ + import * as React from "react"; import * as ReactDOMServer from "react-dom/server"; import type { StaticHandlerContext } from "@remix-run/router"; @@ -642,6 +646,44 @@ describe("A ", () => { `); }); + it("renders absolute links correctly", async () => { + let routes = [ + { + path: "/", + element: ( + <> + relative path + absolute same-origin url + absolute different-origin url + absolute mailto: url + + ), + }, + ]; + let { query } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + expect(html).toMatch( + 'relative path' + + 'absolute same-origin url' + + 'absolute different-origin url' + + 'absolute mailto: url' + ); + }); + describe("boundary tracking", () => { it("tracks the deepest boundary during render", async () => { let routes = [ diff --git a/packages/react-router-dom/__tests__/polyfills/SubmitEvent.submitter.ts b/packages/react-router-dom/__tests__/polyfills/SubmitEvent.submitter.ts index 84f73710ae..323c8964f2 100644 --- a/packages/react-router-dom/__tests__/polyfills/SubmitEvent.submitter.ts +++ b/packages/react-router-dom/__tests__/polyfills/SubmitEvent.submitter.ts @@ -1,7 +1,8 @@ // Polyfill jsdom SubmitEvent.submitter, until https://github.com/jsdom/jsdom/pull/3481 is merged if ( - typeof SubmitEvent === "undefined" || - !SubmitEvent.prototype.hasOwnProperty("submitter") + typeof window !== "undefined" && + (typeof SubmitEvent === "undefined" || + !SubmitEvent.prototype.hasOwnProperty("submitter")) ) { const setImmediate = (fn, ...args) => global.setTimeout(fn, 0, ...args); diff --git a/packages/react-router-dom/__tests__/setup.ts b/packages/react-router-dom/__tests__/setup.ts index 846e483ef1..576b8289af 100644 --- a/packages/react-router-dom/__tests__/setup.ts +++ b/packages/react-router-dom/__tests__/setup.ts @@ -2,7 +2,7 @@ import { TextEncoder as NodeTextEncoder, TextDecoder as NodeTextDecoder, } from "util"; -import { fetch, Request, Response } from "@remix-run/web-fetch"; +import { fetch, Request, Response, Headers } from "@remix-run/web-fetch"; import { AbortController as NodeAbortController } from "abort-controller"; import "./polyfills/SubmitEvent.submitter"; @@ -22,6 +22,7 @@ if (!globalThis.fetch) { // web-std/fetch Response does not currently implement Response.error() // @ts-expect-error globalThis.Response = Response; + globalThis.Headers = Headers; } if (!globalThis.AbortController) { diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 560c85a483..1c24998adc 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -400,6 +400,8 @@ const isBrowser = typeof window.document !== "undefined" && typeof window.document.createElement !== "undefined"; +const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; + /** * The public API for rendering a history-aware . */ @@ -422,21 +424,22 @@ export const Link = React.forwardRef( let absoluteHref; let isExternal = false; - if ( - isBrowser && - typeof to === "string" && - /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(to) - ) { + if (typeof to === "string" && ABSOLUTE_URL_REGEX.test(to)) { + // Render the absolute href server- and client-side absoluteHref = to; - let currentUrl = new URL(window.location.href); - let targetUrl = to.startsWith("//") - ? new URL(currentUrl.protocol + to) - : new URL(to); - if (targetUrl.origin === currentUrl.origin) { - // Strip the protocol/origin for same-origin absolute URLs - to = targetUrl.pathname + targetUrl.search + targetUrl.hash; - } else { - isExternal = true; + + // Only check for external origins client-side + if (isBrowser) { + let currentUrl = new URL(window.location.href); + let targetUrl = to.startsWith("//") + ? new URL(currentUrl.protocol + to) + : new URL(to); + if (targetUrl.origin === currentUrl.origin) { + // Strip the protocol/origin for same-origin absolute URLs + to = targetUrl.pathname + targetUrl.search + targetUrl.hash; + } else { + isExternal = true; + } } }