diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts
index 6eec19ca100..30ef86e8269 100644
--- a/integration/single-fetch-test.ts
+++ b/integration/single-fetch-test.ts
@@ -90,7 +90,6 @@ test.describe("single-fetch", () => {
test.beforeEach(() => {
oldConsoleError = console.error;
- console.error = () => {};
});
test.afterEach(() => {
@@ -138,6 +137,8 @@ test.describe("single-fetch", () => {
});
test("loads proper errors on single fetch loader requests", async () => {
+ console.error = () => {};
+
let fixture = await createFixture(
{
config: {
@@ -584,6 +585,251 @@ test.describe("single-fetch", () => {
expect(res.headers.get("x-c-headers")).toEqual("true");
});
+ test("processes loader redirects", async ({ page }) => {
+ let fixture = await createFixture({
+ config: {
+ future: {
+ unstable_singleFetch: true,
+ },
+ },
+ files: {
+ ...files,
+ "app/routes/data.tsx": js`
+ import { redirect } from '@remix-run/node';
+ export function loader() {
+ return redirect('/target');
+ }
+ export default function Component() {
+ return null
+ }
+ `,
+ "app/routes/target.tsx": js`
+ export default function Component() {
+ return
Target
+ }
+ `,
+ },
+ });
+ let appFixture = await createAppFixture(fixture);
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickLink("/data");
+ await page.waitForSelector("#target");
+ expect(await app.getHtml("#target")).toContain("Target");
+ });
+
+ test("processes action redirects", async ({ page }) => {
+ let fixture = await createFixture(
+ {
+ config: {
+ future: {
+ unstable_singleFetch: true,
+ },
+ },
+ files: {
+ ...files,
+ "app/routes/data.tsx": js`
+ import { redirect } from '@remix-run/node';
+ export function action() {
+ return redirect('/target');
+ }
+ export default function Component() {
+ return null
+ }
+ `,
+ "app/routes/target.tsx": js`
+ export default function Component() {
+ return Target
+ }
+ `,
+ },
+ },
+ ServerMode.Development
+ );
+ let appFixture = await createAppFixture(fixture, ServerMode.Development);
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickSubmitButton("/data");
+ await page.waitForSelector("#target");
+ expect(await app.getHtml("#target")).toContain("Target");
+ });
+
+ test("processes redirects from handleDataRequest (after loaders)", async ({
+ page,
+ }) => {
+ let fixture = await createFixture({
+ config: {
+ future: {
+ unstable_singleFetch: true,
+ },
+ },
+ files: {
+ ...files,
+ "app/entry.server.tsx": js`
+ import { PassThrough } from "node:stream";
+
+ import type { EntryContext } from "@remix-run/node";
+ import { createReadableStreamFromReadable } from "@remix-run/node";
+ import { RemixServer } from "@remix-run/react";
+ import { renderToPipeableStream } from "react-dom/server";
+
+ export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+ ) {
+ return new Promise((resolve, reject) => {
+ const { pipe } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+ const stream = createReadableStreamFromReadable(body);
+ responseHeaders.set("Content-Type", "text/html");
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ },
+ }
+ );
+ });
+ }
+
+ export function handleDataRequest(response, { request }) {
+ if (request.url.endsWith("/data.data")) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: "/target",
+ },
+ });
+ }
+ return response;
+ }
+ `,
+ "app/routes/data.tsx": js`
+ import { redirect } from '@remix-run/node';
+ export function loader() {
+ return redirect('/target');
+ }
+ export default function Component() {
+ return null
+ }
+ `,
+ "app/routes/target.tsx": js`
+ export default function Component() {
+ return Target
+ }
+ `,
+ },
+ });
+ let appFixture = await createAppFixture(fixture);
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickLink("/data");
+ await page.waitForSelector("#target");
+ expect(await app.getHtml("#target")).toContain("Target");
+ });
+
+ test("processes redirects from handleDataRequest (after actions)", async ({
+ page,
+ }) => {
+ let fixture = await createFixture({
+ config: {
+ future: {
+ unstable_singleFetch: true,
+ },
+ },
+ files: {
+ ...files,
+ "app/entry.server.tsx": js`
+ import { PassThrough } from "node:stream";
+
+ import type { EntryContext } from "@remix-run/node";
+ import { createReadableStreamFromReadable } from "@remix-run/node";
+ import { RemixServer } from "@remix-run/react";
+ import { renderToPipeableStream } from "react-dom/server";
+
+ export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+ ) {
+ return new Promise((resolve, reject) => {
+ const { pipe } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+ const stream = createReadableStreamFromReadable(body);
+ responseHeaders.set("Content-Type", "text/html");
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ },
+ }
+ );
+ });
+ }
+
+ export function handleDataRequest(response, { request }) {
+ if (request.url.endsWith("/data.data")) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: "/target",
+ },
+ });
+ }
+ return response;
+ }
+ `,
+ "app/routes/data.tsx": js`
+ import { redirect } from '@remix-run/node';
+ export function action() {
+ return redirect('/target');
+ }
+ export default function Component() {
+ return null
+ }
+ `,
+ "app/routes/target.tsx": js`
+ export default function Component() {
+ return Target
+ }
+ `,
+ },
+ });
+ let appFixture = await createAppFixture(fixture);
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickSubmitButton("/data");
+ await page.waitForSelector("#target");
+ expect(await app.getHtml("#target")).toContain("Target");
+ });
+
test.describe("client loaders", () => {
test("when no routes have client loaders", async ({ page }) => {
let fixture = await createFixture(
diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts
index ccf4c3e6ce4..65da52b4e39 100644
--- a/packages/remix-server-runtime/server.ts
+++ b/packages/remix-server-runtime/server.ts
@@ -80,6 +80,8 @@ function derive(build: ServerBuild, mode?: string) {
};
}
+export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect");
+
export const createRequestHandler: CreateRequestHandlerFunction = (
build,
mode
@@ -190,7 +192,29 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
});
if (isRedirectResponse(response)) {
- response = createRemixRedirectResponse(response, _build.basename);
+ let result: SingleFetchResult | SingleFetchResults =
+ getSingleFetchRedirect(response);
+
+ if (request.method === "GET") {
+ result = {
+ [SingleFetchRedirectSymbol]: result,
+ };
+ }
+ let headers = new Headers(response.headers);
+ headers.set("Content-Type", "text/x-turbo");
+
+ return new Response(
+ encodeViaTurboStream(
+ result,
+ request.signal,
+ _build.entry.module.streamTimeout,
+ serverMode
+ ),
+ {
+ status: 200,
+ headers,
+ }
+ );
}
}
} else if (
@@ -300,8 +324,6 @@ async function handleDataRequest(
}
}
-export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect");
-
type SingleFetchRedirectResult = {
redirect: string;
status: number;