Skip to content

Commit 41ea498

Browse files
authored
Fix: prendering with basename and ssr:false renders 404s for dynamic routes (#13791)
1 parent ea9738c commit 41ea498

File tree

5 files changed

+334
-13
lines changed

5 files changed

+334
-13
lines changed

.changeset/fresh-brooms-trade.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Fix prerendering when a `basename` is set with `ssr:false`

contributors.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,3 +433,4 @@
433433
- zeromask1337
434434
- zheng-chuang
435435
- zxTomw
436+
- skrhlm

integration/helpers/create-fixture.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -158,16 +158,15 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
158158
prerender: init.prerender,
159159
requestDocument(href: string) {
160160
let file = new URL(href, "test://test").pathname + "/index.html";
161-
let mainPath = path.join(projectDir, "build", "client", file);
162-
let fallbackPath = path.join(
163-
projectDir,
164-
"build",
165-
"client",
166-
"__spa-fallback.html",
167-
);
161+
let clientDir = path.join(projectDir, "build", "client");
162+
let mainPath = path.join(clientDir, file);
163+
let fallbackPath = path.join(clientDir, "__spa-fallback.html");
164+
let fallbackPath2 = path.join(clientDir, "index.html");
168165
let html = existsSync(mainPath)
169166
? readFileSync(mainPath)
170-
: readFileSync(fallbackPath);
167+
: existsSync(fallbackPath)
168+
? readFileSync(fallbackPath)
169+
: readFileSync(fallbackPath2);
171170
return new Response(html, {
172171
headers: {
173172
"Content-Type": "text/html",
@@ -344,11 +343,18 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) {
344343
);
345344
app.get("*", (req, res, next) => {
346345
let dir = path.join(fixture.projectDir, "build", "client");
347-
let file = req.path.endsWith(".data")
348-
? req.path
349-
: req.path + "/index.html";
350-
if (file.endsWith(".html") && !existsSync(path.join(dir, file))) {
351-
file = "__spa-fallback.html";
346+
let file;
347+
if (req.path.endsWith(".data")) {
348+
file = req.path;
349+
} else {
350+
let mainPath = req.path + "/index.html";
351+
let fallbackPath = "__spa-fallback.html";
352+
let fallbackPath2 = "index.html";
353+
file = existsSync(mainPath)
354+
? mainPath
355+
: existsSync(fallbackPath)
356+
? fallbackPath
357+
: fallbackPath2;
352358
}
353359
let filePath = path.join(dir, file);
354360
if (existsSync(filePath)) {

integration/vite-prerender-test.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2673,5 +2673,290 @@ test.describe("Prerendering", () => {
26732673
await page.waitForSelector("#target");
26742674
expect(requests).toEqual(["/redirect.data"]);
26752675
});
2676+
2677+
test("Navigates across SPA/prerender pages when starting from a SPA page (w/basename)", async ({
2678+
page,
2679+
}) => {
2680+
fixture = await createFixture({
2681+
prerender: true,
2682+
files: {
2683+
"react-router.config.ts": reactRouterConfig({
2684+
ssr: false, // turn off fog of war since we're serving with a static server
2685+
prerender: ["/page"],
2686+
basename: "/base",
2687+
}),
2688+
"vite.config.ts": files["vite.config.ts"],
2689+
"app/root.tsx": js`
2690+
import * as React from "react";
2691+
import { Outlet, Scripts } from "react-router";
2692+
2693+
export function Layout({ children }) {
2694+
return (
2695+
<html lang="en">
2696+
<head />
2697+
<body>
2698+
{children}
2699+
<Scripts />
2700+
</body>
2701+
</html>
2702+
);
2703+
}
2704+
2705+
export default function Root({ loaderData }) {
2706+
return <Outlet />
2707+
}
2708+
`,
2709+
"app/routes/_index.tsx": js`
2710+
import { Link } from 'react-router';
2711+
export default function Index() {
2712+
return <Link to="/page">Go to page</Link>
2713+
}
2714+
`,
2715+
"app/routes/page.tsx": js`
2716+
import { Link, Form } from 'react-router';
2717+
export async function loader() {
2718+
return "PAGE DATA"
2719+
}
2720+
let count = 0;
2721+
export function clientAction() {
2722+
return "PAGE ACTION " + (++count)
2723+
}
2724+
export default function Page({ loaderData, actionData }) {
2725+
return (
2726+
<>
2727+
<p data-page>{loaderData}</p>
2728+
{actionData ? <p data-page-action>{actionData}</p> : null}
2729+
<Link to="/page2">Go to page2</Link>
2730+
<Form method="post" action="/page">
2731+
<button type="submit">Submit</button>
2732+
</Form>
2733+
<Form method="post" action="/page2">
2734+
<button type="submit">Submit /page2</button>
2735+
</Form>
2736+
</>
2737+
);
2738+
}
2739+
`,
2740+
"app/routes/page2.tsx": js`
2741+
import { Form } from 'react-router';
2742+
export function clientLoader() {
2743+
return "PAGE2 DATA"
2744+
}
2745+
let count = 0;
2746+
export function clientAction() {
2747+
return "PAGE2 ACTION " + (++count)
2748+
}
2749+
export default function Page({ loaderData, actionData }) {
2750+
return (
2751+
<>
2752+
<p data-page2>{loaderData}</p>
2753+
{actionData ? <p data-page2-action>{actionData}</p> : null}
2754+
<Form method="post" action="/page">
2755+
<button type="submit">Submit</button>
2756+
</Form>
2757+
<Form method="post" action="/page2">
2758+
<button type="submit">Submit /page2</button>
2759+
</Form>
2760+
</>
2761+
);
2762+
}
2763+
`,
2764+
},
2765+
});
2766+
appFixture = await createAppFixture(fixture);
2767+
2768+
let requests = captureRequests(page);
2769+
let app = new PlaywrightFixture(appFixture, page);
2770+
2771+
await app.goto("/base", true);
2772+
await page.waitForSelector('a[href="/base/page"]');
2773+
2774+
await app.clickLink("/base/page");
2775+
await page.waitForSelector("[data-page]");
2776+
expect(await (await page.$("[data-page]"))?.innerText()).toBe(
2777+
"PAGE DATA",
2778+
);
2779+
expect(requests).toEqual(["/base/page.data"]);
2780+
clearRequests(requests);
2781+
2782+
await app.clickSubmitButton("/base/page");
2783+
await page.waitForSelector("[data-page-action]");
2784+
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
2785+
"PAGE ACTION 1",
2786+
);
2787+
// No revalidation after submission to self
2788+
expect(requests).toEqual([]);
2789+
2790+
await app.clickLink("/base/page2");
2791+
await page.waitForSelector("[data-page2]");
2792+
expect(await (await page.$("[data-page2]"))?.innerText()).toBe(
2793+
"PAGE2 DATA",
2794+
);
2795+
expect(requests).toEqual([]);
2796+
2797+
await app.clickSubmitButton("/base/page2");
2798+
await page.waitForSelector("[data-page2-action]");
2799+
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
2800+
"PAGE2 ACTION 1",
2801+
);
2802+
expect(requests).toEqual([]);
2803+
2804+
await app.clickSubmitButton("/base/page");
2805+
await page.waitForSelector("[data-page-action]");
2806+
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
2807+
"PAGE ACTION 2",
2808+
);
2809+
expect(requests).toEqual(["/base/page.data"]);
2810+
clearRequests(requests);
2811+
2812+
await app.clickSubmitButton("/base/page2");
2813+
await page.waitForSelector("[data-page2-action]");
2814+
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
2815+
"PAGE2 ACTION 2",
2816+
);
2817+
expect(requests).toEqual([]);
2818+
});
2819+
2820+
test("Navigates across SPA/prerender pages when starting from a prerendered page (w/basename)", async ({
2821+
page,
2822+
}) => {
2823+
fixture = await createFixture({
2824+
prerender: true,
2825+
files: {
2826+
"react-router.config.ts": reactRouterConfig({
2827+
ssr: false, // turn off fog of war since we're serving with a static server
2828+
prerender: ["/", "/page"],
2829+
basename: "/base",
2830+
}),
2831+
"vite.config.ts": files["vite.config.ts"],
2832+
"app/root.tsx": js`
2833+
import * as React from "react";
2834+
import { Outlet, Scripts } from "react-router";
2835+
2836+
export function Layout({ children }) {
2837+
return (
2838+
<html lang="en">
2839+
<head />
2840+
<body>
2841+
{children}
2842+
<Scripts />
2843+
</body>
2844+
</html>
2845+
);
2846+
}
2847+
2848+
export default function Root({ loaderData }) {
2849+
return <Outlet />;
2850+
}
2851+
`,
2852+
"app/routes/_index.tsx": js`
2853+
import { Link } from 'react-router';
2854+
export default function Index() {
2855+
return <Link to="/page">Go to page</Link>
2856+
}
2857+
`,
2858+
"app/routes/page.tsx": js`
2859+
import { Link, Form } from 'react-router';
2860+
export async function loader() {
2861+
return "PAGE DATA"
2862+
}
2863+
let count = 0;
2864+
export function clientAction() {
2865+
return "PAGE ACTION " + (++count)
2866+
}
2867+
export default function Page({ loaderData, actionData }) {
2868+
return (
2869+
<>
2870+
<p data-page>{loaderData}</p>
2871+
{actionData ? <p data-page-action>{actionData}</p> : null}
2872+
<Link to="/page2">Go to page2</Link>
2873+
<Form method="post" action="/page">
2874+
<button type="submit">Submit</button>
2875+
</Form>
2876+
<Form method="post" action="/page2">
2877+
<button type="submit">Submit /page2</button>
2878+
</Form>
2879+
</>
2880+
);
2881+
}
2882+
`,
2883+
"app/routes/page2.tsx": js`
2884+
import { Form } from 'react-router';
2885+
export function clientLoader() {
2886+
return "PAGE2 DATA"
2887+
}
2888+
let count = 0;
2889+
export function clientAction() {
2890+
return "PAGE2 ACTION " + (++count)
2891+
}
2892+
export default function Page({ loaderData, actionData }) {
2893+
return (
2894+
<>
2895+
<p data-page2>{loaderData}</p>
2896+
{actionData ? <p data-page2-action>{actionData}</p> : null}
2897+
<Form method="post" action="/page">
2898+
<button type="submit">Submit</button>
2899+
</Form>
2900+
<Form method="post" action="/page2">
2901+
<button type="submit">Submit /page2</button>
2902+
</Form>
2903+
</>
2904+
);
2905+
}
2906+
`,
2907+
},
2908+
});
2909+
appFixture = await createAppFixture(fixture);
2910+
2911+
let requests = captureRequests(page);
2912+
let app = new PlaywrightFixture(appFixture, page);
2913+
await app.goto("/base", true);
2914+
await page.waitForSelector('a[href="/base/page"]');
2915+
2916+
await app.clickLink("/base/page");
2917+
await page.waitForSelector("[data-page]");
2918+
expect(await (await page.$("[data-page]"))?.innerText()).toBe(
2919+
"PAGE DATA",
2920+
);
2921+
expect(requests).toEqual(["/base/page.data"]);
2922+
clearRequests(requests);
2923+
2924+
await app.clickSubmitButton("/base/page");
2925+
await page.waitForSelector("[data-page-action]");
2926+
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
2927+
"PAGE ACTION 1",
2928+
);
2929+
// No revalidation after submission to self
2930+
expect(requests).toEqual([]);
2931+
2932+
await app.clickLink("/base/page2");
2933+
await page.waitForSelector("[data-page2]");
2934+
expect(await (await page.$("[data-page2]"))?.innerText()).toBe(
2935+
"PAGE2 DATA",
2936+
);
2937+
expect(requests).toEqual([]);
2938+
2939+
await app.clickSubmitButton("/base/page2");
2940+
await page.waitForSelector("[data-page2-action]");
2941+
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
2942+
"PAGE2 ACTION 1",
2943+
);
2944+
expect(requests).toEqual([]);
2945+
2946+
await app.clickSubmitButton("/base/page");
2947+
await page.waitForSelector("[data-page-action]");
2948+
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
2949+
"PAGE ACTION 2",
2950+
);
2951+
expect(requests).toEqual(["/base/page.data"]);
2952+
clearRequests(requests);
2953+
2954+
await app.clickSubmitButton("/base/page2");
2955+
await page.waitForSelector("[data-page2-action]");
2956+
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
2957+
"PAGE2 ACTION 2",
2958+
);
2959+
expect(requests).toEqual([]);
2960+
});
26762961
});
26772962
});

packages/react-router/lib/server-runtime/server.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,30 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
170170
// Decode the URL path before checking against the prerender config
171171
let decodedPath = decodeURI(normalizedPath);
172172

173+
if (normalizedBasename !== "/") {
174+
let strippedPath = stripBasename(decodedPath, normalizedBasename);
175+
if (strippedPath == null) {
176+
errorHandler(
177+
new ErrorResponseImpl(
178+
404,
179+
"Not Found",
180+
`Refusing to prerender the \`${decodedPath}\` path because it does ` +
181+
`not start with the basename \`${normalizedBasename}\``,
182+
),
183+
{
184+
context: loadContext,
185+
params,
186+
request,
187+
},
188+
);
189+
return new Response("Not Found", {
190+
status: 404,
191+
statusText: "Not Found",
192+
});
193+
}
194+
decodedPath = strippedPath;
195+
}
196+
173197
// When SSR is disabled this, file can only ever run during dev because we
174198
// delete the server build at the end of the build
175199
if (_build.prerender.length === 0) {

0 commit comments

Comments
 (0)