Skip to content

Commit ce73a9b

Browse files
committed
feat: add 'unstable_api-mode' value to config to preserve server builds in SPA mode
1 parent 744ff85 commit ce73a9b

18 files changed

Lines changed: 565 additions & 35 deletions

File tree

integration/helpers/create-fixture.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export async function spawnTestServer({
5050
let started = false;
5151
let stdout = "";
5252
let rejectTimeout = setTimeout(() => {
53+
serverProcess.kill();
5354
reject(new Error(`Timed out waiting for server to start (${timeout}ms)`));
5455
}, timeout);
5556

@@ -81,6 +82,17 @@ export async function spawnTestServer({
8182
clearTimeout(rejectTimeout);
8283
reject(error);
8384
});
85+
86+
serverProcess.on("exit", (code, signal) => {
87+
if (!started) {
88+
clearTimeout(rejectTimeout);
89+
reject(
90+
new Error(
91+
`Server exited before starting (code ${code}, signal ${signal})`,
92+
),
93+
);
94+
}
95+
});
8496
});
8597
}
8698

@@ -395,6 +407,18 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) {
395407
let app = express();
396408
app.use(express.static(path.join(fixture.projectDir, "build/client")));
397409

410+
if (build.unstable_apiOnly) {
411+
app.get("*", (req, res, next) => {
412+
if (req.path.endsWith(".data")) {
413+
next();
414+
} else {
415+
res.sendFile(
416+
path.join(fixture.projectDir, "build/client/index.html"),
417+
);
418+
}
419+
});
420+
}
421+
398422
app.all(
399423
"*",
400424
createExpressHandler({

integration/vite-api-only-test.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { test, expect } from "@playwright/test";
2+
import getPort from "get-port";
3+
import {
4+
createRequestHandler,
5+
UNSAFE_ServerMode as ServerMode,
6+
} from "react-router";
7+
8+
import {
9+
createAppFixture,
10+
createFixture,
11+
js,
12+
} from "./helpers/create-fixture.js";
13+
import type { AppFixture, Fixture } from "./helpers/create-fixture.js";
14+
import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
15+
import { dev, reactRouterConfig } from "./helpers/vite.js";
16+
import { spawnTestServer } from "./helpers/create-fixture.js";
17+
18+
test.describe("API-only Mode", () => {
19+
test.describe.configure({ mode: "serial" });
20+
21+
let fixture: Fixture;
22+
let appFixture: AppFixture | undefined;
23+
let stopDev: (() => unknown) | undefined;
24+
let stopServe: (() => unknown) | undefined;
25+
let devPort: number;
26+
let servePort: number;
27+
28+
test.beforeAll(async () => {
29+
devPort = await getPort();
30+
fixture = await createFixture({
31+
port: devPort,
32+
files: {
33+
"react-router.config.ts": reactRouterConfig({
34+
ssr: "unstable_api-only",
35+
}),
36+
"app/routes/_index.tsx": js`
37+
import { Link } from "react-router";
38+
39+
export function loader() {
40+
return { server: "root-loader" };
41+
}
42+
43+
export default function Index({ loaderData }) {
44+
return (
45+
<>
46+
<p data-root-loader>{loaderData.server}</p>
47+
<Link to="/dashboard">Dashboard</Link>
48+
<Link to="/loader-only">Loader only</Link>
49+
</>
50+
);
51+
}
52+
`,
53+
"app/routes/loader-only.tsx": js`
54+
export async function loader() {
55+
return { server: "loader-only" };
56+
}
57+
58+
export default function LoaderOnly({ loaderData }) {
59+
return <p data-loader-only>{loaderData.server}</p>;
60+
}
61+
`,
62+
"app/routes/dashboard.tsx": js`
63+
import { Form, Link } from "react-router";
64+
65+
export async function loader() {
66+
return { server: "server-loader" };
67+
}
68+
69+
export async function action() {
70+
return { action: "server-action" };
71+
}
72+
73+
export async function clientLoader({ serverLoader }) {
74+
let data = await serverLoader();
75+
return { ...data, client: "client-loader" };
76+
}
77+
78+
export async function clientAction({ serverAction }) {
79+
let data = await serverAction();
80+
return { ...data, client: "client-action" };
81+
}
82+
83+
export default function Dashboard({ loaderData, actionData }) {
84+
return (
85+
<>
86+
<p data-server>{loaderData.server}</p>
87+
<p data-client>{loaderData.client}</p>
88+
<p data-action>
89+
{actionData?.action ?? ""}:{actionData?.client ?? ""}
90+
</p>
91+
<Form method="post">
92+
<button type="submit">Submit</button>
93+
</Form>
94+
<Link to="/">Home</Link>
95+
</>
96+
);
97+
}
98+
`,
99+
},
100+
});
101+
});
102+
103+
async function startServe() {
104+
servePort = await getPort();
105+
let serveServer = await spawnTestServer({
106+
cwd: fixture.projectDir,
107+
command: [
108+
process.argv[0],
109+
"node_modules/@react-router/serve/dist/cli.js",
110+
"build/server/index.js",
111+
],
112+
env: {
113+
NODE_ENV: "production",
114+
PORT: String(servePort),
115+
},
116+
regex: new RegExp(`react-router-serve.*localhost:${servePort}\\s`),
117+
});
118+
stopServe = serveServer.stop;
119+
}
120+
121+
async function startDev() {
122+
stopDev = await dev({
123+
cwd: fixture.projectDir,
124+
port: devPort,
125+
});
126+
}
127+
128+
async function startApp() {
129+
appFixture = await createAppFixture(fixture);
130+
}
131+
132+
async function stopServers() {
133+
stopServe?.();
134+
stopServe = undefined;
135+
stopDev?.();
136+
stopDev = undefined;
137+
await appFixture?.close();
138+
appFixture = undefined;
139+
}
140+
141+
test.afterEach(stopServers);
142+
test.afterAll(stopServers);
143+
144+
test("builds an API-only server with server APIs but no route UI exports", async () => {
145+
expect(fixture.build?.unstable_apiOnly).toBe(true);
146+
147+
let route = fixture.build?.routes["routes/dashboard"];
148+
expect(route?.module.loader).toEqual(expect.any(Function));
149+
expect(route?.module.action).toEqual(expect.any(Function));
150+
expect(route?.module.default).toBeUndefined();
151+
expect(route?.module.clientLoader).toBeUndefined();
152+
expect(route?.module.clientAction).toBeUndefined();
153+
});
154+
155+
test("handles server data requests and rejects document requests", async () => {
156+
await startServe();
157+
158+
let loaderData = await fixture.requestSingleFetchData("/dashboard.data");
159+
expect(loaderData.status).toBe(200);
160+
expect(loaderData.data).toMatchObject({
161+
"routes/dashboard": { data: { server: "server-loader" } },
162+
});
163+
164+
let actionData = await fixture.requestSingleFetchData("/dashboard.data", {
165+
method: "POST",
166+
body: new URLSearchParams(),
167+
});
168+
expect(actionData.status).toBe(200);
169+
expect(actionData.data).toMatchObject({
170+
data: { action: "server-action" },
171+
});
172+
173+
let documentResponse = await fixture.requestDocument("/dashboard");
174+
expect(documentResponse.status).toBe(404);
175+
176+
let serveDocumentResponse = await fetch(`http://localhost:${servePort}/`);
177+
expect(serveDocumentResponse.status).toBe(200);
178+
expect(await serveDocumentResponse.text()).toContain(
179+
"window.__reactRouterContext",
180+
);
181+
182+
let serveAssetResponse = await fetch(
183+
`http://localhost:${servePort}${fixture.build!.assets.entry.module}`,
184+
);
185+
expect(serveAssetResponse.status).toBe(200);
186+
});
187+
188+
test("handles document requests in development mode", async () => {
189+
let build = fixture.build;
190+
expect(build).not.toBeNull();
191+
192+
let response = await createRequestHandler(
193+
build!,
194+
ServerMode.Development,
195+
)(new Request("http://localhost/"));
196+
expect(response.status).toBe(200);
197+
});
198+
199+
test("does not produce a hydration error in development mode", async ({
200+
page,
201+
}) => {
202+
await startDev();
203+
204+
let errors: string[] = [];
205+
page.on("console", (message) => {
206+
if (message.type() === "error") {
207+
errors.push(message.text());
208+
}
209+
});
210+
page.on("pageerror", (error) => errors.push(error.message));
211+
212+
await page.goto(`http://localhost:${devPort}/`);
213+
await expect(page.locator("[data-root-loader]")).toHaveText("root-loader");
214+
await page.waitForTimeout(1000);
215+
expect(errors.filter((error) => error.includes("Hydration"))).toEqual([]);
216+
});
217+
218+
test("navigates and runs clientLoader through serverLoader", async ({
219+
page,
220+
}) => {
221+
await startApp();
222+
223+
let app = new PlaywrightFixture(appFixture!, page);
224+
await app.goto("/");
225+
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
226+
await app.clickLink("/dashboard");
227+
228+
await expect(page.locator("[data-server]")).toHaveText("server-loader");
229+
await expect(page.locator("[data-client]")).toHaveText("client-loader");
230+
await app.clickLink("/");
231+
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
232+
await app.clickLink("/loader-only");
233+
await expect(page.locator("[data-loader-only]")).toHaveText("loader-only");
234+
});
235+
236+
test("submits a Form through clientAction to the server action", async ({
237+
page,
238+
}) => {
239+
await startApp();
240+
241+
let app = new PlaywrightFixture(appFixture!, page);
242+
await app.goto("/");
243+
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
244+
await app.clickLink("/dashboard");
245+
await page.getByRole("button", { name: "Submit" }).click();
246+
247+
await expect(page.locator("[data-action]")).toHaveText(
248+
"server-action:client-action",
249+
);
250+
});
251+
});

integration/vite-server-bundles-test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,3 +457,53 @@ test.describe("Server bundles", () => {
457457
});
458458
});
459459
});
460+
461+
test.describe("API-only server bundles", () => {
462+
test("preserves server APIs and serves data requests", async () => {
463+
let cwd = await createProject(
464+
{
465+
"react-router.config.ts": dedent(js`
466+
export default {
467+
ssr: "unstable_api-only",
468+
serverBundles({ branch }) {
469+
return branch.some((route) => route.id === "routes/api")
470+
? "api"
471+
: "root";
472+
}
473+
}
474+
`),
475+
"app/routes/api.tsx": js`
476+
export function loader() {
477+
return { loader: true };
478+
}
479+
480+
export function action() {
481+
return { action: true };
482+
}
483+
484+
export default function ApiRoute() {
485+
return null;
486+
}
487+
`,
488+
},
489+
"vite-7-template",
490+
);
491+
492+
let result = build({ cwd });
493+
expect(result.status).toBe(0);
494+
495+
let apiBuild = await import(
496+
path.join(cwd, "build/server/api/index.js")
497+
);
498+
let apiRoute = apiBuild.routes["routes/api"].module;
499+
expect(apiRoute.loader).toEqual(expect.any(Function));
500+
expect(apiRoute.action).toEqual(expect.any(Function));
501+
expect(apiRoute.default).toBeUndefined();
502+
503+
await withBundleServer(cwd, "api", async (port) => {
504+
let response = await fetch(`http://localhost:${port}/api.data`);
505+
expect(response.status).toBe(200);
506+
expect(await response.text()).toContain('"loader",true');
507+
});
508+
});
509+
});

0 commit comments

Comments
 (0)