diff --git a/.changeset/polite-fans-rhyme.md b/.changeset/polite-fans-rhyme.md new file mode 100644 index 0000000000..ad919c2d74 --- /dev/null +++ b/.changeset/polite-fans-rhyme.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Support unencoded UTF-8 routes in prerender config with `ssr` set to `false` diff --git a/contributors.yml b/contributors.yml index cac4dc861d..3101fd0268 100644 --- a/contributors.yml +++ b/contributors.yml @@ -297,6 +297,7 @@ - rtzll - rubeonline - ruidi-huang +- rururux - ryanflorence - ryanhiebert - saengmotmi diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index 73d6bad381..fcd92cfdcb 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -2370,6 +2370,86 @@ test.describe("Prerendering", () => { expect(requests).toEqual(["/page.data"]); }); + test("Navigates prerendered multibyte path routes", async ({ page }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, // turn off fog of war since we're serving with a static server + prerender: ["/", "/page", "/ページ"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` + import * as React from "react"; + import { Link, Outlet, Scripts } from "react-router"; + + export function Layout({ children }) { + return ( + + + + + {children} + + + + ); + } + + export default function Root({ loaderData }) { + return + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + "app/routes/page.tsx": js` + export function loader() { + return "PAGE DATA" + } + export default function Page({ loaderData }) { + return

{loaderData}

; + } + `, + "app/routes/ページ.tsx": js` + export function loader() { + return "ページ データ"; + } + export default function Page({ loaderData }) { + return

{loaderData}

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let encodedMultibytePath = encodeURIComponent("ページ"); + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-index]"); + + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA" + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); + + await app.clickLink("/ページ"); + await page.waitForSelector("[data-multibyte-page]"); + expect(await (await page.$("[data-multibyte-page]"))?.innerText()).toBe( + "ページ データ" + ); + expect(requests).toEqual([`/${encodedMultibytePath}.data`]); + }) + test("Returns a 404 if navigating to a non-prerendered param value", async ({ page, }) => { diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 5bbe24e801..89b7df110c 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -172,14 +172,17 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( // When runtime SSR is disabled, make our dev server behave like the deployed // pre-rendered site would if (!_build.ssr) { + // Decode the URL path before checking against the prerender config + let decodedPath = decodeURI(normalizedPath); + // When SSR is disabled this, file can only ever run during dev because we // delete the server build at the end of the build if (_build.prerender.length === 0) { // ssr:false and no prerender config indicates "SPA Mode" isSpaMode = true; } else if ( - !_build.prerender.includes(normalizedPath) && - !_build.prerender.includes(normalizedPath + "/") + !_build.prerender.includes(decodedPath) && + !_build.prerender.includes(decodedPath + "/") ) { if (url.pathname.endsWith(".data")) { // 404 on non-pre-rendered `.data` requests @@ -187,7 +190,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( new ErrorResponseImpl( 404, "Not Found", - `Refusing to SSR the path \`${normalizedPath}\` because \`ssr:false\` is set and the path is not included in the \`prerender\` config, so in production the path will be a 404.` + `Refusing to SSR the path \`${decodedPath}\` because \`ssr:false\` is set and the path is not included in the \`prerender\` config, so in production the path will be a 404.` ), { context: loadContext,