Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-brooms-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

Fix prerendering when a `basename` is set with `ssr:false`
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,4 @@
- zeromask1337
- zheng-chuang
- zxTomw
- skrhlm
32 changes: 19 additions & 13 deletions integration/helpers/create-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,16 +158,15 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
prerender: init.prerender,
requestDocument(href: string) {
let file = new URL(href, "test://test").pathname + "/index.html";
let mainPath = path.join(projectDir, "build", "client", file);
let fallbackPath = path.join(
projectDir,
"build",
"client",
"__spa-fallback.html",
);
let clientDir = path.join(projectDir, "build", "client");
let mainPath = path.join(clientDir, file);
let fallbackPath = path.join(clientDir, "__spa-fallback.html");
let fallbackPath2 = path.join(clientDir, "index.html");
let html = existsSync(mainPath)
? readFileSync(mainPath)
: readFileSync(fallbackPath);
: existsSync(fallbackPath)
? readFileSync(fallbackPath)
: readFileSync(fallbackPath2);
return new Response(html, {
headers: {
"Content-Type": "text/html",
Expand Down Expand Up @@ -344,11 +343,18 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) {
);
app.get("*", (req, res, next) => {
let dir = path.join(fixture.projectDir, "build", "client");
let file = req.path.endsWith(".data")
? req.path
: req.path + "/index.html";
if (file.endsWith(".html") && !existsSync(path.join(dir, file))) {
file = "__spa-fallback.html";
let file;
if (req.path.endsWith(".data")) {
file = req.path;
} else {
let mainPath = req.path + "/index.html";
let fallbackPath = "__spa-fallback.html";
let fallbackPath2 = "index.html";
file = existsSync(mainPath)
? mainPath
: existsSync(fallbackPath)
? fallbackPath
: fallbackPath2;
}
let filePath = path.join(dir, file);
if (existsSync(filePath)) {
Expand Down
285 changes: 285 additions & 0 deletions integration/vite-prerender-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2673,5 +2673,290 @@ test.describe("Prerendering", () => {
await page.waitForSelector("#target");
expect(requests).toEqual(["/redirect.data"]);
});

test("Navigates across SPA/prerender pages when starting from a SPA page (w/basename)", 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"],
basename: "/base",
}),
"vite.config.ts": files["vite.config.ts"],
"app/root.tsx": js`
import * as React from "react";
import { Outlet, Scripts } from "react-router";

export function Layout({ children }) {
return (
<html lang="en">
<head />
<body>
{children}
<Scripts />
</body>
</html>
);
}

export default function Root({ loaderData }) {
return <Outlet />
}
`,
"app/routes/_index.tsx": js`
import { Link } from 'react-router';
export default function Index() {
return <Link to="/page">Go to page</Link>
}
`,
"app/routes/page.tsx": js`
import { Link, Form } from 'react-router';
export async function loader() {
return "PAGE DATA"
}
let count = 0;
export function clientAction() {
return "PAGE ACTION " + (++count)
}
export default function Page({ loaderData, actionData }) {
return (
<>
<p data-page>{loaderData}</p>
{actionData ? <p data-page-action>{actionData}</p> : null}
<Link to="/page2">Go to page2</Link>
<Form method="post" action="/page">
<button type="submit">Submit</button>
</Form>
<Form method="post" action="/page2">
<button type="submit">Submit /page2</button>
</Form>
</>
);
}
`,
"app/routes/page2.tsx": js`
import { Form } from 'react-router';
export function clientLoader() {
return "PAGE2 DATA"
}
let count = 0;
export function clientAction() {
return "PAGE2 ACTION " + (++count)
}
export default function Page({ loaderData, actionData }) {
return (
<>
<p data-page2>{loaderData}</p>
{actionData ? <p data-page2-action>{actionData}</p> : null}
<Form method="post" action="/page">
<button type="submit">Submit</button>
</Form>
<Form method="post" action="/page2">
<button type="submit">Submit /page2</button>
</Form>
</>
);
}
`,
},
});
appFixture = await createAppFixture(fixture);

let requests = captureRequests(page);
let app = new PlaywrightFixture(appFixture, page);

await app.goto("/base", true);
await page.waitForSelector('a[href="/base/page"]');

await app.clickLink("/base/page");
await page.waitForSelector("[data-page]");
expect(await (await page.$("[data-page]"))?.innerText()).toBe(
"PAGE DATA",
);
expect(requests).toEqual(["/base/page.data"]);
clearRequests(requests);

await app.clickSubmitButton("/base/page");
await page.waitForSelector("[data-page-action]");
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
"PAGE ACTION 1",
);
// No revalidation after submission to self
expect(requests).toEqual([]);

await app.clickLink("/base/page2");
await page.waitForSelector("[data-page2]");
expect(await (await page.$("[data-page2]"))?.innerText()).toBe(
"PAGE2 DATA",
);
expect(requests).toEqual([]);

await app.clickSubmitButton("/base/page2");
await page.waitForSelector("[data-page2-action]");
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
"PAGE2 ACTION 1",
);
expect(requests).toEqual([]);

await app.clickSubmitButton("/base/page");
await page.waitForSelector("[data-page-action]");
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
"PAGE ACTION 2",
);
expect(requests).toEqual(["/base/page.data"]);
clearRequests(requests);

await app.clickSubmitButton("/base/page2");
await page.waitForSelector("[data-page2-action]");
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
"PAGE2 ACTION 2",
);
expect(requests).toEqual([]);
});

test("Navigates across SPA/prerender pages when starting from a prerendered page (w/basename)", 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"],
basename: "/base",
}),
"vite.config.ts": files["vite.config.ts"],
"app/root.tsx": js`
import * as React from "react";
import { Outlet, Scripts } from "react-router";

export function Layout({ children }) {
return (
<html lang="en">
<head />
<body>
{children}
<Scripts />
</body>
</html>
);
}

export default function Root({ loaderData }) {
return <Outlet />;
}
`,
"app/routes/_index.tsx": js`
import { Link } from 'react-router';
export default function Index() {
return <Link to="/page">Go to page</Link>
}
`,
"app/routes/page.tsx": js`
import { Link, Form } from 'react-router';
export async function loader() {
return "PAGE DATA"
}
let count = 0;
export function clientAction() {
return "PAGE ACTION " + (++count)
}
export default function Page({ loaderData, actionData }) {
return (
<>
<p data-page>{loaderData}</p>
{actionData ? <p data-page-action>{actionData}</p> : null}
<Link to="/page2">Go to page2</Link>
<Form method="post" action="/page">
<button type="submit">Submit</button>
</Form>
<Form method="post" action="/page2">
<button type="submit">Submit /page2</button>
</Form>
</>
);
}
`,
"app/routes/page2.tsx": js`
import { Form } from 'react-router';
export function clientLoader() {
return "PAGE2 DATA"
}
let count = 0;
export function clientAction() {
return "PAGE2 ACTION " + (++count)
}
export default function Page({ loaderData, actionData }) {
return (
<>
<p data-page2>{loaderData}</p>
{actionData ? <p data-page2-action>{actionData}</p> : null}
<Form method="post" action="/page">
<button type="submit">Submit</button>
</Form>
<Form method="post" action="/page2">
<button type="submit">Submit /page2</button>
</Form>
</>
);
}
`,
},
});
appFixture = await createAppFixture(fixture);

let requests = captureRequests(page);
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/base", true);
await page.waitForSelector('a[href="/base/page"]');

await app.clickLink("/base/page");
await page.waitForSelector("[data-page]");
expect(await (await page.$("[data-page]"))?.innerText()).toBe(
"PAGE DATA",
);
expect(requests).toEqual(["/base/page.data"]);
clearRequests(requests);

await app.clickSubmitButton("/base/page");
await page.waitForSelector("[data-page-action]");
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
"PAGE ACTION 1",
);
// No revalidation after submission to self
expect(requests).toEqual([]);

await app.clickLink("/base/page2");
await page.waitForSelector("[data-page2]");
expect(await (await page.$("[data-page2]"))?.innerText()).toBe(
"PAGE2 DATA",
);
expect(requests).toEqual([]);

await app.clickSubmitButton("/base/page2");
await page.waitForSelector("[data-page2-action]");
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
"PAGE2 ACTION 1",
);
expect(requests).toEqual([]);

await app.clickSubmitButton("/base/page");
await page.waitForSelector("[data-page-action]");
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
"PAGE ACTION 2",
);
expect(requests).toEqual(["/base/page.data"]);
clearRequests(requests);

await app.clickSubmitButton("/base/page2");
await page.waitForSelector("[data-page2-action]");
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
"PAGE2 ACTION 2",
);
expect(requests).toEqual([]);
});
});
});
25 changes: 25 additions & 0 deletions packages/react-router/lib/server-runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,31 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
// Decode the URL path before checking against the prerender config
let decodedPath = decodeURI(normalizedPath);

if (normalizedBasename !== "/") {
let strippedPath = stripBasename(decodedPath, normalizedBasename);
if (strippedPath == null) {
// 404 on non-pre-rendered `.data` requests
errorHandler(
new ErrorResponseImpl(
404,
"Not Found",
`Refusing to prerender the \`${decodedPath}\` path because it does ` +
`not start with the basename \`${normalizedBasename}\``,
),
{
context: loadContext,
params,
request,
},
);
return new Response("Not Found", {
status: 404,
statusText: "Not Found",
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a request comes into the dev server without the basename, we can 404 to match what will happen in production

decodedPath = strippedPath;
}

// 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) {
Expand Down
Loading