From 712d0fd912b17c462c7e34ed4e631bda5e6ea64e Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 20 Mar 2024 18:07:09 -0400 Subject: [PATCH 01/12] TEMP WORK --- integration/single-fetch-test.ts | 18 +++++++++++------- packages/remix-server-runtime/headers.ts | 11 ++++++++++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index 30ef86e8269..68dc36289e6 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -151,6 +151,8 @@ test.describe("single-fetch", () => { ServerMode.Development ); + console.error = () => {}; + let res = await fixture.requestSingleFetchData("/data.data?error=true"); expect(res.data).toEqual({ root: { @@ -417,7 +419,7 @@ test.describe("single-fetch", () => { expect(urls).toEqual([]); }); - test("handles headers correctly for loader and action calls", async () => { + test("merges headers correctly for loader and action calls", async () => { let fixture = await createFixture({ config: { future: { @@ -465,6 +467,8 @@ test.describe("single-fetch", () => { expect(res.headers.get("x-action")).toEqual("true"); expect(res.headers.get("x-headers-function")).toEqual(null); + console.error = () => {}; + res = await fixture.requestSingleFetchData("/headers.data?error"); expect(res.headers.get("x-loader-error")).toEqual("true"); expect(res.headers.get("x-headers-function")).toEqual("true"); @@ -477,7 +481,7 @@ test.describe("single-fetch", () => { expect(res.headers.get("x-headers-function")).toEqual(null); }); - test("scopes loader headers to the _routes param if present", async () => { + test("scopes headers function to the _routes param if more than one route exists", async () => { let fixture = await createFixture({ config: { future: { @@ -568,9 +572,9 @@ test.describe("single-fetch", () => { "/a/b/c.data?_routes=routes%2Fa.b.c" ); expect(res.headers.get("x-a-loader")).toBeNull(); - expect(res.headers.get("x-a-headers")).toBeNull(); + expect(res.headers.get("x-a-headers")).toEqual("true"); expect(res.headers.get("x-b-loader")).toBeNull(); - expect(res.headers.get("x-b-headers")).toBeNull(); + expect(res.headers.get("x-b-headers")).toEqual("true"); expect(res.headers.get("x-c-loader")).toEqual("true"); expect(res.headers.get("x-c-headers")).toEqual("true"); @@ -578,9 +582,9 @@ test.describe("single-fetch", () => { "/a/b/c.data?_routes=routes%2Fa,routes%2Fa.b.c" ); expect(res.headers.get("x-a-loader")).toEqual("true"); - expect(res.headers.get("x-a-loader")).toEqual("true"); - expect(res.headers.get("x-b-headers")).toBeNull(); - expect(res.headers.get("x-b-headers")).toBeNull(); + expect(res.headers.get("x-a-headers")).toEqual("true"); + expect(res.headers.get("x-b-loader")).toBeNull(); + expect(res.headers.get("x-b-headers")).toEqual("true"); expect(res.headers.get("x-c-loader")).toEqual("true"); expect(res.headers.get("x-c-headers")).toEqual("true"); }); diff --git a/packages/remix-server-runtime/headers.ts b/packages/remix-server-runtime/headers.ts index 698b6af1542..e7e344f2e16 100644 --- a/packages/remix-server-runtime/headers.ts +++ b/packages/remix-server-runtime/headers.ts @@ -17,7 +17,16 @@ export function getDocumentHeaders( : context.matches; if (loadRouteIds) { - matches = matches.filter((m) => loadRouteIds.includes(m.route.id)); + let deepestRouteId = loadRouteIds[loadRouteIds.length - 1]; + let deepestRouteIdx = matches.findLastIndex( + (m) => m.route.id === deepestRouteId + ); + let deepestHeadersIdx = matches.findLastIndex((m) => m.route.headers); + let idx = + deepestHeadersIdx >= 0 + ? Math.min(deepestRouteIdx, deepestHeadersIdx) + : deepestRouteIdx; + matches = matches.slice(0, idx + 1); } let errorHeaders: Headers | undefined; From 045731dc8498d6cf2114828b613a0b5658e7d9de Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 26 Mar 2024 13:57:27 -0400 Subject: [PATCH 02/12] Wire up responseStub interface --- integration/single-fetch-test.ts | 185 ++++----------- packages/remix-react/single-fetch.tsx | 15 +- packages/remix-server-runtime/data.ts | 11 +- packages/remix-server-runtime/headers.ts | 16 +- packages/remix-server-runtime/routeModules.ts | 16 ++ packages/remix-server-runtime/routes.ts | 20 +- packages/remix-server-runtime/server.ts | 210 +++++++++++++----- 7 files changed, 256 insertions(+), 217 deletions(-) diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index 68dc36289e6..1fd817c0654 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -334,19 +334,23 @@ test.describe("single-fetch", () => { "app/routes/action.tsx": js` import { Form, Link, useActionData, useLoaderData, useNavigation } from '@remix-run/react'; - export async function action({ request }) { + export async function action({ request, response }) { let fd = await request.formData(); if (fd.get('throw') === "5xx") { - throw new Response("Thrown 500", { status: 500 }); + response.status = 500; + throw new Error("Thrown 500"); } if (fd.get('throw') === "4xx") { - throw new Response("Thrown 400", { status: 400 }); + response.status = 400; + throw new Error("Thrown 400"); } if (fd.get('return') === "5xx") { - return new Response("Returned 500", { status: 500 }); + response.status = 500; + return "Returned 500"; } if (fd.get('return') === "4xx") { - return new Response("Returned 400", { status: 400 }); + response.status = 400; + return "Returned 400"; } return null; } @@ -361,7 +365,7 @@ test.describe("single-fetch", () => { let data = useLoaderData(); return (
- + @@ -390,6 +394,8 @@ test.describe("single-fetch", () => { } }); + console.error = () => {}; + let appFixture = await createAppFixture(fixture); let app = new PlaywrightFixture(appFixture, page); await app.goto("/action"); @@ -409,6 +415,7 @@ test.describe("single-fetch", () => { await page.click('button[name="throw"][value="5xx"]'); await page.waitForSelector("#error"); expect(urls).toEqual([]); + await app.clickLink("/action"); await page.waitForSelector("#data"); expect(await app.getHtml("#data")).toContain("2"); @@ -429,24 +436,22 @@ test.describe("single-fetch", () => { files: { ...files, "app/routes/headers.tsx": js` - export function headers({ loaderHeaders }) { - let headers = new Headers(loaderHeaders); - headers.set('x-headers-function', 'true') - return headers; - } - - export function action({ request }) { + export function action({ request, response }) { if (new URL(request.url).searchParams.has("error")) { - throw new Response(null, { headers: { "x-action-error": "true" } }); + response.headers.set("x-action-error", "true"); + throw response; } - return new Response(null, { headers: { "x-action": "true" } }); + response.headers.set("x-action", "true"); + return null; } - export function loader({ request }) { + export function loader({ request, response }) { if (new URL(request.url).searchParams.has("error")) { - throw new Response(null, { headers: { "x-loader-error": "true" } }); + response.headers.set("x-loader-error", "true"); + throw response; } - return new Response(null, { headers: { "x-loader": "true" } }); + response.headers.set("x-loader", "true"); + return null; } export default function Comp() { @@ -456,137 +461,43 @@ test.describe("single-fetch", () => { }, }); - let res = await fixture.requestSingleFetchData("/headers.data"); - expect(res.headers.get("x-loader")).toEqual("true"); - expect(res.headers.get("x-headers-function")).toEqual("true"); + // Loader + let docResponse = await fixture.requestDocument("/headers"); + let dataResponse = await fixture.requestSingleFetchData("/headers.data"); + expect(docResponse.headers.get("x-loader")).toEqual("true"); + expect(dataResponse.headers.get("x-loader")).toEqual("true"); - res = await fixture.requestSingleFetchData("/headers.data", { + // Action + docResponse = await fixture.requestDocument("/headers", { + method: "post", + body: null, + }); + dataResponse = await fixture.requestSingleFetchData("/headers.data", { method: "post", body: null, }); - expect(res.headers.get("x-action")).toEqual("true"); - expect(res.headers.get("x-headers-function")).toEqual(null); + expect(docResponse.headers.get("x-action")).toEqual("true"); + expect(dataResponse.headers.get("x-action")).toEqual("true"); console.error = () => {}; - res = await fixture.requestSingleFetchData("/headers.data?error"); - expect(res.headers.get("x-loader-error")).toEqual("true"); - expect(res.headers.get("x-headers-function")).toEqual("true"); + // Loader Error + docResponse = await fixture.requestDocument("/headers?error"); + dataResponse = await fixture.requestSingleFetchData("/headers.data?error"); + expect(docResponse.headers.get("x-loader-error")).toEqual("true"); + expect(dataResponse.headers.get("x-loader-error")).toEqual("true"); - res = await fixture.requestSingleFetchData("/headers.data?error", { + // Action Error + docResponse = await fixture.requestDocument("/headers?error", { method: "post", body: null, }); - expect(res.headers.get("x-action-error")).toEqual("true"); - expect(res.headers.get("x-headers-function")).toEqual(null); - }); - - test("scopes headers function to the _routes param if more than one route exists", async () => { - let fixture = await createFixture({ - config: { - future: { - unstable_singleFetch: true, - }, - }, - files: { - ...files, - "app/routes/a.tsx": js` - export function headers({ loaderHeaders }) { - let headers = new Headers(loaderHeaders); - headers.set('x-a-headers', 'true') - return headers; - } - - export function loader({ request }) { - return new Response(null, { headers: { "x-a-loader": "true" } }); - } - - export default function Comp() { - return null; - } - `, - "app/routes/a.b.tsx": js` - export function headers({ loaderHeaders, parentHeaders }) { - let headers = new Headers(parentHeaders); - loaderHeaders.forEach((value, name) => headers.set(name, value)); - headers.set('x-b-headers', 'true') - return headers; - } - - export function loader({ request }) { - return new Response(null, { headers: { "x-b-loader": "true" } }); - } - - export default function Comp() { - return null; - } - `, - "app/routes/a.b.c.tsx": js` - export function headers({ loaderHeaders, parentHeaders }) { - let headers = new Headers(parentHeaders); - loaderHeaders.forEach((value, name) => headers.set(name, value)); - headers.set('x-c-headers', 'true') - return headers; - } - - export function loader({ request }) { - return new Response(null, { headers: { "x-c-loader": "true" } }); - } - - export default function Comp() { - return null; - } - `, - }, + dataResponse = await fixture.requestSingleFetchData("/headers.data?error", { + method: "post", + body: null, }); - - let res = await fixture.requestSingleFetchData("/a/b/c.data"); - expect(res.headers.get("x-a-loader")).toEqual("true"); - expect(res.headers.get("x-a-headers")).toEqual("true"); - expect(res.headers.get("x-b-loader")).toEqual("true"); - expect(res.headers.get("x-b-headers")).toEqual("true"); - expect(res.headers.get("x-c-loader")).toEqual("true"); - expect(res.headers.get("x-c-headers")).toEqual("true"); - - res = await fixture.requestSingleFetchData( - "/a/b/c.data?_routes=routes%2Fa,routes%2Fa.b" - ); - expect(res.headers.get("x-a-loader")).toEqual("true"); - expect(res.headers.get("x-a-headers")).toEqual("true"); - expect(res.headers.get("x-b-loader")).toEqual("true"); - expect(res.headers.get("x-b-headers")).toEqual("true"); - expect(res.headers.get("x-c-loader")).toBeNull(); - expect(res.headers.get("x-c-headers")).toBeNull(); - - res = await fixture.requestSingleFetchData( - "/a/b/c.data?_routes=routes%2Fa" - ); - expect(res.headers.get("x-a-loader")).toEqual("true"); - expect(res.headers.get("x-a-headers")).toEqual("true"); - expect(res.headers.get("x-b-loader")).toBeNull(); - expect(res.headers.get("x-b-headers")).toBeNull(); - expect(res.headers.get("x-c-loader")).toBeNull(); - expect(res.headers.get("x-c-headers")).toBeNull(); - - res = await fixture.requestSingleFetchData( - "/a/b/c.data?_routes=routes%2Fa.b.c" - ); - expect(res.headers.get("x-a-loader")).toBeNull(); - expect(res.headers.get("x-a-headers")).toEqual("true"); - expect(res.headers.get("x-b-loader")).toBeNull(); - expect(res.headers.get("x-b-headers")).toEqual("true"); - expect(res.headers.get("x-c-loader")).toEqual("true"); - expect(res.headers.get("x-c-headers")).toEqual("true"); - - res = await fixture.requestSingleFetchData( - "/a/b/c.data?_routes=routes%2Fa,routes%2Fa.b.c" - ); - expect(res.headers.get("x-a-loader")).toEqual("true"); - expect(res.headers.get("x-a-headers")).toEqual("true"); - expect(res.headers.get("x-b-loader")).toBeNull(); - expect(res.headers.get("x-b-headers")).toEqual("true"); - expect(res.headers.get("x-c-loader")).toEqual("true"); - expect(res.headers.get("x-c-headers")).toEqual("true"); + expect(docResponse.headers.get("x-action-error")).toEqual("true"); + expect(dataResponse.headers.get("x-action-error")).toEqual("true"); }); test("processes loader redirects", async ({ page }) => { diff --git a/packages/remix-react/single-fetch.tsx b/packages/remix-react/single-fetch.tsx index 2df962276c5..5824af92ba7 100644 --- a/packages/remix-react/single-fetch.tsx +++ b/packages/remix-react/single-fetch.tsx @@ -126,9 +126,9 @@ function singleFetchActionStrategy( matches: DataStrategyFunctionArgs["matches"] ) { return Promise.all( - matches.map(async (m) => - m.resolve(async (handler): Promise => { - let actionStatus: number | undefined; + matches.map(async (m) => { + let actionStatus: number | undefined; + let result = await m.resolve(async (handler): Promise => { let result = await handler(async () => { let url = singleFetchUrl(request.url); let init = await createRequestInit(request); @@ -141,8 +141,13 @@ function singleFetchActionStrategy( result, status: actionStatus, }; - }) - ) + }); + return { + ...result, + // Proxy along the action HTTP response status for thrown errors + status: actionStatus, + }; + }) ); } diff --git a/packages/remix-server-runtime/data.ts b/packages/remix-server-runtime/data.ts index 47c2e6dbd54..59d70500d34 100644 --- a/packages/remix-server-runtime/data.ts +++ b/packages/remix-server-runtime/data.ts @@ -10,6 +10,7 @@ import type { ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs, + ResponseStub, } from "./routeModules"; /** @@ -27,13 +28,14 @@ export interface AppLoadContext { */ export type AppData = unknown; -export async function callRouteActionRR({ +export async function callRouteAction({ loadContext, action, params, request, routeId, singleFetch, + response, }: { request: Request; action: ActionFunction; @@ -41,11 +43,13 @@ export async function callRouteActionRR({ loadContext: AppLoadContext; routeId: string; singleFetch: boolean; + response?: ResponseStub; }) { let result = await action({ request: stripDataParam(stripIndexParam(request)), context: loadContext, params, + response, }); if (result === undefined) { @@ -63,13 +67,14 @@ export async function callRouteActionRR({ return isResponse(result) ? result : json(result); } -export async function callRouteLoaderRR({ +export async function callRouteLoader({ loadContext, loader, params, request, routeId, singleFetch, + response, }: { request: Request; loader: LoaderFunction; @@ -77,11 +82,13 @@ export async function callRouteLoaderRR({ loadContext: AppLoadContext; routeId: string; singleFetch: boolean; + response?: ResponseStub; }) { let result = await loader({ request: stripDataParam(stripIndexParam(request)), context: loadContext, params, + response, }); if (result === undefined) { diff --git a/packages/remix-server-runtime/headers.ts b/packages/remix-server-runtime/headers.ts index e7e344f2e16..c17e1cea7d4 100644 --- a/packages/remix-server-runtime/headers.ts +++ b/packages/remix-server-runtime/headers.ts @@ -5,8 +5,7 @@ import type { ServerBuild } from "./build"; export function getDocumentHeaders( build: ServerBuild, - context: StaticHandlerContext, - loadRouteIds?: string[] + context: StaticHandlerContext ): Headers { let boundaryIdx = context.errors ? context.matches.findIndex((m) => context.errors![m.route.id]) @@ -16,19 +15,6 @@ export function getDocumentHeaders( ? context.matches.slice(0, boundaryIdx + 1) : context.matches; - if (loadRouteIds) { - let deepestRouteId = loadRouteIds[loadRouteIds.length - 1]; - let deepestRouteIdx = matches.findLastIndex( - (m) => m.route.id === deepestRouteId - ); - let deepestHeadersIdx = matches.findLastIndex((m) => m.route.headers); - let idx = - deepestHeadersIdx >= 0 - ? Math.min(deepestRouteIdx, deepestHeadersIdx) - : deepestRouteIdx; - matches = matches.slice(0, idx + 1); - } - let errorHeaders: Headers | undefined; if (boundaryIdx >= 0) { diff --git a/packages/remix-server-runtime/routeModules.ts b/packages/remix-server-runtime/routeModules.ts index 58e0ce2f7d0..1a22ae3c807 100644 --- a/packages/remix-server-runtime/routeModules.ts +++ b/packages/remix-server-runtime/routeModules.ts @@ -27,6 +27,18 @@ export type DataFunctionArgs = RRActionFunctionArgs & context: AppLoadContext; }; +export const ResponseStubSymbol = Symbol("ResponseStub"); + +/** + * A stubbed response to let you set the status/headers of your response from + * loader/action functions + */ +export type ResponseStub = { + status: number; + headers: Headers; + [ResponseStubSymbol]: boolean; +}; + /** * A function that handles data mutations for a route on the server */ @@ -40,6 +52,8 @@ export type ActionFunction = ( export type ActionFunctionArgs = RRActionFunctionArgs & { // Context is always provided in Remix, and typed for module augmentation support. context: AppLoadContext; + // TODO: (v7) Make this non-optional once single-fetch is the default + response?: ResponseStub; }; /** @@ -71,6 +85,8 @@ export type LoaderFunction = ( export type LoaderFunctionArgs = RRLoaderFunctionArgs & { // Context is always provided in Remix, and typed for module augmentation support. context: AppLoadContext; + // TODO: (v7) Make this non-optional once single-fetch is the default + response?: ResponseStub; }; /** diff --git a/packages/remix-server-runtime/routes.ts b/packages/remix-server-runtime/routes.ts index a96b6dbd71c..bf9e4400334 100644 --- a/packages/remix-server-runtime/routes.ts +++ b/packages/remix-server-runtime/routes.ts @@ -4,9 +4,9 @@ import type { ActionFunctionArgs as RRActionFunctionArgs, } from "@remix-run/router"; -import { callRouteActionRR, callRouteLoaderRR } from "./data"; +import { callRouteAction, callRouteLoader } from "./data"; import type { FutureConfig } from "./entry"; -import type { ServerRouteModule } from "./routeModules"; +import type { ResponseStub, ServerRouteModule } from "./routeModules"; export interface RouteManifest { [routeId: string]: Route; @@ -92,25 +92,33 @@ export function createStaticHandlerDataRoutes( loader: route.module.loader ? // Need to use RR's version here to permit the optional context even // though we know it'll always be provided in remix - (args: RRLoaderFunctionArgs) => - callRouteLoaderRR({ + ( + args: RRLoaderFunctionArgs, + dataStrategyCtx?: { response: ResponseStub } + ) => + callRouteLoader({ request: args.request, params: args.params, loadContext: args.context, loader: route.module.loader!, routeId: route.id, singleFetch: future.unstable_singleFetch === true, + response: dataStrategyCtx?.response, }) : undefined, action: route.module.action - ? (args: RRActionFunctionArgs) => - callRouteActionRR({ + ? ( + args: RRActionFunctionArgs, + dataStrategyCtx?: { response: ResponseStub } + ) => + callRouteAction({ request: args.request, params: args.params, loadContext: args.context, action: route.module.action!, routeId: route.id, singleFetch: future.unstable_singleFetch === true, + response: dataStrategyCtx?.response, }) : undefined, handle: route.module.handle, diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 65da52b4e39..c1d5329fb31 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -2,6 +2,7 @@ import type { UNSAFE_DeferredData as DeferredData, ErrorResponse, StaticHandler, + unstable_DataStrategyFunctionArgs as DataStrategyFunctionArgs, } from "@remix-run/router"; import { UNSAFE_DEFERRED_SYMBOL as DEFERRED_SYMBOL, @@ -27,8 +28,8 @@ import { import { getDocumentHeaders } from "./headers"; import invariant from "./invariant"; import { ServerMode, isServerMode } from "./mode"; -import type { RouteMatch } from "./routeMatching"; import { matchServerRoutes } from "./routeMatching"; +import { ResponseStubSymbol, type ResponseStub } from "./routeModules"; import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { @@ -59,6 +60,9 @@ function derive(build: ServerBuild, mode?: string) { v7_relativeSplatPath: build.future?.v3_relativeSplatPath === true, v7_throwAbortReason: build.future?.v3_throwAbortReason === true, }, + unstable_dataStrategy: build.future.unstable_singleFetch + ? singleFetchDataStrategy + : undefined, }); let errorHandler = @@ -81,6 +85,7 @@ function derive(build: ServerBuild, mode?: string) { } export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); +export const ContextResponseStubSymbol = Symbol("ContextResponseStub"); export const createRequestHandler: CreateRequestHandlerFunction = ( build, @@ -167,7 +172,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( .replace(/\.data$/, "") .replace(/^\/_root$/, "/"); - let matches = matchServerRoutes( + let singleFetchMatches = matchServerRoutes( routes, handlerUrl.pathname, _build.basename @@ -177,7 +182,6 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( serverMode, _build, staticHandler, - matches, request, handlerUrl, loadContext, @@ -187,7 +191,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( if (_build.entry.module.handleDataRequest) { response = await _build.entry.module.handleDataRequest(response, { context: loadContext, - params, + params: singleFetchMatches ? singleFetchMatches[0].params : {}, request, }); @@ -344,7 +348,6 @@ async function handleSingleFetchRequest( serverMode: ServerMode, build: ServerBuild, staticHandler: StaticHandler, - matches: RouteMatch[] | null, request: Request, handlerUrl: URL, loadContext: AppLoadContext, @@ -353,20 +356,20 @@ async function handleSingleFetchRequest( let { result, headers, status } = request.method !== "GET" ? await singleFetchAction( + serverMode, + staticHandler, request, handlerUrl, - staticHandler, loadContext, handleError ) : await singleFetchLoaders( + serverMode, + staticHandler, request, handlerUrl, - staticHandler, loadContext, - handleError, - serverMode, - build + handleError ); // Mark all successful responses with a header so we can identify in-flight @@ -392,9 +395,10 @@ async function handleSingleFetchRequest( } async function singleFetchAction( + serverMode: ServerMode, + staticHandler: StaticHandler, request: Request, handlerUrl: URL, - staticHandler: StaticHandler, loadContext: AppLoadContext, handleError: (err: unknown) => void ): Promise<{ result: SingleFetchResult; headers: Headers; status: number }> { @@ -406,60 +410,83 @@ async function singleFetchAction( signal: request.signal, ...(request.body ? { duplex: "half" } : undefined), }); - let result = await staticHandler.queryRoute(handlerRequest, { + + let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, + skipLoaderErrorBubbling: true, + skipLoaders: true, }); // Unlike `handleDataRequest`, when singleFetch is enabled, queryRoute does // let non-Response return values through - if (!isResponse(result)) { - return { result: { data: result }, headers: new Headers(), status: 200 }; - } - - if (isRedirectResponse(result)) { + if (isResponse(result)) { return { result: getSingleFetchRedirect(result), headers: result.headers, - status: 200, // Don't trigger a redirect on the `fetch` + status: 200, }; } + + let context = result; + + // Sanitize errors outside of development environments + if (context.errors) { + Object.values(context.errors).forEach((err) => { + // @ts-expect-error This is "private" from users but intended for internal use + if (!isRouteErrorResponse(err) || err.error) { + handleError(err); + } + }); + context.errors = sanitizeErrors(context.errors, serverMode); + } + + let singleFetchResult: SingleFetchResult; + if (context.errors) { + let error = Object.values(context.errors)[0]; + if (isResponseStub(error)) { + return { + result: { error: null }, + headers: error.headers, + status: error.status, + }; + } + singleFetchResult = { error: Object.values(context.errors)[0] }; + } else { + singleFetchResult = { data: Object.values(context.actionData || {})[0] }; + } + + let responseStub: ResponseStub = + loadContext && ContextResponseStubSymbol in loadContext + ? (loadContext[ContextResponseStubSymbol] as ResponseStub) + : getResponseStub(); + return { - result: { data: await unwrapResponse(result) }, - headers: result.headers, - status: result.status, + result: singleFetchResult, + headers: responseStub.headers, + status: responseStub.status, }; } catch (error) { - if (isResponse(error)) { - return { - result: { - error: new ErrorResponseImpl( - error.status, - error.statusText, - await unwrapResponse(error) - ), - }, - headers: error.headers, - status: error.status, - }; - } else { - handleError(error); - return { - result: { error }, - headers: new Headers(), - status: isRouteErrorResponse(error) ? error.status : 500, - }; - } + let responseStub: ResponseStub = + loadContext && ContextResponseStubSymbol in loadContext + ? (loadContext[ContextResponseStubSymbol] as ResponseStub) + : getResponseStub(500); + + handleError(error); + return { + result: { error }, + headers: responseStub.headers, + status: responseStub.status, + }; } } async function singleFetchLoaders( + serverMode: ServerMode, + staticHandler: StaticHandler, request: Request, handlerUrl: URL, - staticHandler: StaticHandler, loadContext: AppLoadContext, - handleError: (err: unknown) => void, - serverMode: ServerMode, - build: ServerBuild + handleError: (err: unknown) => void ): Promise<{ result: SingleFetchResults; headers: Headers; status: number }> { try { let handlerRequest = new Request(handlerUrl, { @@ -473,6 +500,7 @@ async function singleFetchLoaders( requestContext: loadContext, loadRouteIds, skipLoaderErrorBubbling: true, + // dataStrategy: createDataStrategy(responseStubs), }); if (isResponse(result)) { return { @@ -509,23 +537,37 @@ async function singleFetchLoaders( let data = context.loaderData?.[m.route.id]; let error = context.errors?.[m.route.id]; if (error !== undefined) { - results[m.route.id] = { error }; + if (isResponseStub(error)) { + results[m.route.id] = { error: null }; + } else { + results[m.route.id] = { error }; + } } else if (data !== undefined) { results[m.route.id] = { data }; } }); + let responseStub: ResponseStub = + loadContext && ContextResponseStubSymbol in loadContext + ? (loadContext[ContextResponseStubSymbol] as ResponseStub) + : getResponseStub(); + return { result: results, - headers: getDocumentHeaders(build, context, loadRouteIds), - status: context.statusCode, + headers: responseStub.headers, + status: responseStub.status, }; } catch (error: unknown) { handleError(error); + let responseStub: ResponseStub = + loadContext && ContextResponseStubSymbol in loadContext + ? (loadContext[ContextResponseStubSymbol] as ResponseStub) + : getResponseStub(500); + return { result: { root: { error } }, - headers: new Headers(), - status: 500, + headers: responseStub.headers, + status: responseStub.status, }; } } @@ -564,7 +606,19 @@ async function handleDocumentRequest( context.errors = sanitizeErrors(context.errors, serverMode); } - let headers = getDocumentHeaders(build, context); + let statusCode: number; + let headers: Headers; + if (build.future.unstable_singleFetch) { + let responseStub = + ContextResponseStubSymbol in loadContext + ? (loadContext[ContextResponseStubSymbol] as ResponseStub) + : getResponseStub(); + statusCode = responseStub.status; + headers = responseStub.headers; + } else { + statusCode = context.statusCode; + headers = getDocumentHeaders(build, context); + } // Server UI state to send to the client. // - When single fetch is enabled, this is streamed down via `serverHandoffStream` @@ -607,7 +661,7 @@ async function handleDocumentRequest( try { return await handleDocumentRequestFunction( request, - context.statusCode, + statusCode, headers, entryContext, loadContext @@ -788,6 +842,58 @@ function unwrapResponse(response: Response) { : response.text(); } +function getResponseStub(status = 200) { + return { + status, + headers: new Headers(), + [ResponseStubSymbol]: true, + }; +} + +async function singleFetchDataStrategy({ + matches, + context, +}: DataStrategyFunctionArgs) { + // We will have an existing responseStub on document POST requests when we run + // this the second time for the loader pass + let hasResponseStub = ContextResponseStubSymbol in context; + let responseStub = hasResponseStub + ? (context[ContextResponseStubSymbol] as ResponseStub) + : getResponseStub(); + + if (!hasResponseStub) { + Object.defineProperty(context, ContextResponseStubSymbol, { + get: () => responseStub, + }); + } + + let results = await Promise.all( + matches.map(async (match) => { + let result = await match.resolve(async (handler) => { + // create responseStub specific to this loader/acton implementation + let data = await handler({ response: responseStub }); + return { type: "data", result: data }; + }); + return result; + }) + ); + return results.map((result) => ({ + type: result.type, + result: result.result, + status: responseStub.status, + headers: responseStub.headers, + })); +} + +function isResponseStub(value: any): value is ResponseStub { + return ( + value && + typeof value === "object" && + ResponseStubSymbol in value && + value[ResponseStubSymbol] === true + ); +} + function getSingleFetchRedirect(response: Response): SingleFetchRedirectResult { return { redirect: response.headers.get("Location")!, From 392ab0f7d908ae0a19e7ec8e2192f015f6282680 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 26 Mar 2024 15:44:56 -0400 Subject: [PATCH 03/12] Switch to proxied headers approach --- integration/single-fetch-test.ts | 113 +++++++++++- packages/remix-server-runtime/routeModules.ts | 12 +- packages/remix-server-runtime/server.ts | 172 +++++++++--------- 3 files changed, 207 insertions(+), 90 deletions(-) diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index 1fd817c0654..f4e3c79bcf1 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -426,7 +426,7 @@ test.describe("single-fetch", () => { expect(urls).toEqual([]); }); - test("merges headers correctly for loader and action calls", async () => { + test("returns headers correctly for singular loader and action calls", async () => { let fixture = await createFixture({ config: { future: { @@ -500,6 +500,117 @@ test.describe("single-fetch", () => { expect(dataResponse.headers.get("x-action-error")).toEqual("true"); }); + test("merges headers from nested route loaders", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + export function loader({ request, response }) { + response.headers.set('x-one', 'a set'); + response.headers.append('x-one', 'a append'); + response.headers.set('x-two', 'a set'); + response.headers.append('x-three', 'a append'); + response.headers.set('x-four', 'a set'); + return null; + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.tsx": js` + export function loader({ request, response }) { + response.headers.set('x-one', 'b set'); + response.headers.append('x-one', 'b append'); + response.headers.set('x-two', 'b set'); + response.headers.append('x-three', 'b append'); + response.headers.delete('x-four'); + return null; + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.c.tsx": js` + export function loader({ request, response }) { + response.headers.set('x-one', 'c set'); + response.headers.append('x-one', 'c append'); + response.headers.set('x-two', 'c set'); + response.headers.append('x-three', 'c append'); + return null; + } + + export default function Comp() { + return null; + } + `, + }, + }); + + // x-one used both set and append + // x-two only uses set + // x-three only uses append + // x-four deletes + async function assertHeaders( + method: "requestDocument" | "requestSingleFetchData" + ) { + let res = await fixture[method]("/a.data"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture[method]("/a/b.data"); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("a append, b append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture[method]("/a/b/c.data"); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual( + "a append, b append, c append" + ); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture[method]("/a/b/c.data?_routes=routes%2Fa"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture[method]("/a/b.data?_routes=routes%2Fa,routes%2Fa.b"); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("a append, b append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture[method]("/a/b/c.data?_routes=routes%2Fa.b.c"); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual("c append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture[method]( + "/a/b/c.data?_routes=routes%2Fa,routes%2Fa.b.c" + ); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual("a append, c append"); + expect(res.headers.get("x-four")).toEqual("a set"); + } + + assertHeaders("requestDocument"); + assertHeaders("requestSingleFetchData"); + }); + test("processes loader redirects", async ({ page }) => { let fixture = await createFixture({ config: { diff --git a/packages/remix-server-runtime/routeModules.ts b/packages/remix-server-runtime/routeModules.ts index 1a22ae3c807..74832b6b384 100644 --- a/packages/remix-server-runtime/routeModules.ts +++ b/packages/remix-server-runtime/routeModules.ts @@ -27,16 +27,20 @@ export type DataFunctionArgs = RRActionFunctionArgs & context: AppLoadContext; }; -export const ResponseStubSymbol = Symbol("ResponseStub"); - +export const ResponseStubOperationsSymbol = Symbol("ResponseStubOperations"); +export type ResponseStubOperation = [ + "set" | "append" | "delete", + string, + string? +]; /** * A stubbed response to let you set the status/headers of your response from * loader/action functions */ export type ResponseStub = { - status: number; + status: number | undefined; headers: Headers; - [ResponseStubSymbol]: boolean; + [ResponseStubOperationsSymbol]: ResponseStubOperation[]; }; /** diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index c1d5329fb31..143a4312d4e 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -3,6 +3,7 @@ import type { ErrorResponse, StaticHandler, unstable_DataStrategyFunctionArgs as DataStrategyFunctionArgs, + StaticHandlerContext, } from "@remix-run/router"; import { UNSAFE_DEFERRED_SYMBOL as DEFERRED_SYMBOL, @@ -29,7 +30,8 @@ import { getDocumentHeaders } from "./headers"; import invariant from "./invariant"; import { ServerMode, isServerMode } from "./mode"; import { matchServerRoutes } from "./routeMatching"; -import { ResponseStubSymbol, type ResponseStub } from "./routeModules"; +import type { ResponseStub, ResponseStubOperation } from "./routeModules"; +import { ResponseStubOperationsSymbol } from "./routeModules"; import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { @@ -60,9 +62,6 @@ function derive(build: ServerBuild, mode?: string) { v7_relativeSplatPath: build.future?.v3_relativeSplatPath === true, v7_throwAbortReason: build.future?.v3_throwAbortReason === true, }, - unstable_dataStrategy: build.future.unstable_singleFetch - ? singleFetchDataStrategy - : undefined, }); let errorHandler = @@ -411,10 +410,12 @@ async function singleFetchAction( ...(request.body ? { duplex: "half" } : undefined), }); + let responseStubs: Record = {}; let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, skipLoaderErrorBubbling: true, skipLoaders: true, + unstable_dataStrategy: getSingleFetchDataStrategy(responseStubs), }); // Unlike `handleDataRequest`, when singleFetch is enabled, queryRoute does @@ -441,41 +442,26 @@ async function singleFetchAction( } let singleFetchResult: SingleFetchResult; + let { statusCode, headers } = mergeResponseStubs(context, responseStubs); if (context.errors) { let error = Object.values(context.errors)[0]; - if (isResponseStub(error)) { - return { - result: { error: null }, - headers: error.headers, - status: error.status, - }; - } - singleFetchResult = { error: Object.values(context.errors)[0] }; + singleFetchResult = { error: isResponseStub(error) ? null : error }; } else { singleFetchResult = { data: Object.values(context.actionData || {})[0] }; } - let responseStub: ResponseStub = - loadContext && ContextResponseStubSymbol in loadContext - ? (loadContext[ContextResponseStubSymbol] as ResponseStub) - : getResponseStub(); - return { result: singleFetchResult, - headers: responseStub.headers, - status: responseStub.status, + headers, + status: statusCode, }; } catch (error) { - let responseStub: ResponseStub = - loadContext && ContextResponseStubSymbol in loadContext - ? (loadContext[ContextResponseStubSymbol] as ResponseStub) - : getResponseStub(500); - handleError(error); + // These should only be internal remix errors, no need to deal with responseStubs return { result: { error }, - headers: responseStub.headers, - status: responseStub.status, + headers: new Headers(), + status: 500, }; } } @@ -496,12 +482,14 @@ async function singleFetchLoaders( let loadRouteIds = new URL(request.url).searchParams.get("_routes")?.split(",") || undefined; + let responseStubs: Record = {}; let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, loadRouteIds, skipLoaderErrorBubbling: true, - // dataStrategy: createDataStrategy(responseStubs), + unstable_dataStrategy: getSingleFetchDataStrategy(responseStubs), }); + if (isResponse(result)) { return { result: { @@ -547,27 +535,20 @@ async function singleFetchLoaders( } }); - let responseStub: ResponseStub = - loadContext && ContextResponseStubSymbol in loadContext - ? (loadContext[ContextResponseStubSymbol] as ResponseStub) - : getResponseStub(); + let { statusCode, headers } = mergeResponseStubs(context, responseStubs); return { result: results, - headers: responseStub.headers, - status: responseStub.status, + headers, + status: statusCode, }; } catch (error: unknown) { handleError(error); - let responseStub: ResponseStub = - loadContext && ContextResponseStubSymbol in loadContext - ? (loadContext[ContextResponseStubSymbol] as ResponseStub) - : getResponseStub(500); - + // These should only be internal remix errors, no need to deal with responseStubs return { result: { root: { error } }, - headers: responseStub.headers, - status: responseStub.status, + headers: new Headers(), + status: 500, }; } } @@ -582,9 +563,13 @@ async function handleDocumentRequest( criticalCss?: string ) { let context; + let responseStubs: Record = {}; try { context = await staticHandler.query(request, { requestContext: loadContext, + unstable_dataStrategy: build.future.unstable_singleFetch + ? getSingleFetchDataStrategy(responseStubs) + : undefined, }); } catch (error: unknown) { handleError(error); @@ -609,12 +594,9 @@ async function handleDocumentRequest( let statusCode: number; let headers: Headers; if (build.future.unstable_singleFetch) { - let responseStub = - ContextResponseStubSymbol in loadContext - ? (loadContext[ContextResponseStubSymbol] as ResponseStub) - : getResponseStub(); - statusCode = responseStub.status; - headers = responseStub.headers; + let merged = mergeResponseStubs(context, responseStubs); + statusCode = merged.statusCode; + headers = merged.headers; } else { statusCode = context.statusCode; headers = getDocumentHeaders(build, context); @@ -843,57 +825,77 @@ function unwrapResponse(response: Response) { } function getResponseStub(status = 200) { + let headers = new Headers(); + let operations: ResponseStubOperation[] = []; + let headersProxy = new Proxy(headers, { + get(target, prop, receiver) { + if (prop === "set" || prop === "append" || prop === "delete") { + return (name: string, value: string) => { + operations.push([prop, name, value]); + Reflect.apply(target[prop], target, [name, value]); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); return { status, - headers: new Headers(), - [ResponseStubSymbol]: true, + headers: headersProxy, + [ResponseStubOperationsSymbol]: operations, }; } -async function singleFetchDataStrategy({ - matches, - context, -}: DataStrategyFunctionArgs) { - // We will have an existing responseStub on document POST requests when we run - // this the second time for the loader pass - let hasResponseStub = ContextResponseStubSymbol in context; - let responseStub = hasResponseStub - ? (context[ContextResponseStubSymbol] as ResponseStub) - : getResponseStub(); - - if (!hasResponseStub) { - Object.defineProperty(context, ContextResponseStubSymbol, { - get: () => responseStub, - }); - } - - let results = await Promise.all( - matches.map(async (match) => { - let result = await match.resolve(async (handler) => { - // create responseStub specific to this loader/acton implementation - let data = await handler({ response: responseStub }); - return { type: "data", result: data }; - }); - return result; - }) - ); - return results.map((result) => ({ - type: result.type, - result: result.result, - status: responseStub.status, - headers: responseStub.headers, - })); +function getSingleFetchDataStrategy( + responseStubs: Record +) { + return async ({ matches }: DataStrategyFunctionArgs) => { + let results = await Promise.all( + matches.map(async (match) => { + let result = await match.resolve(async (handler) => { + let responseStub = responseStubs[match.route.id]; + if (!responseStub) { + responseStub = getResponseStub(); + responseStubs[match.route.id] = responseStub; + } + let data = await handler({ response: responseStub }); + return { type: "data", result: data }; + }); + return result; + }) + ); + return results; + }; } function isResponseStub(value: any): value is ResponseStub { return ( - value && - typeof value === "object" && - ResponseStubSymbol in value && - value[ResponseStubSymbol] === true + value && typeof value === "object" && ResponseStubOperationsSymbol in value ); } +function mergeResponseStubs( + context: StaticHandlerContext, + responseStubs: Record +) { + let statusCode = 200; + let headers = new Headers(); + context.matches.forEach((m) => { + let stub = responseStubs[m.route.id]; + if (stub) { + // Take the highest error/redirect, or the lowest success value + if (statusCode < 300 && stub.status) { + statusCode = stub.status; + } + + // Replay headers operations in order + let ops = stub[ResponseStubOperationsSymbol]; + // @ts-expect-error + ops.forEach(([op, ...args]) => headers[op](...args)); + } + }); + return { statusCode, headers }; +} + function getSingleFetchRedirect(response: Response): SingleFetchRedirectResult { return { redirect: response.headers.get("Location")!, From dae36b998d741f6630eb0e210ee0177719fdddd8 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 26 Mar 2024 17:15:02 -0400 Subject: [PATCH 04/12] Handle redirects --- integration/single-fetch-test.ts | 734 ++++++++++++++++++++++-- packages/remix-server-runtime/server.ts | 131 ++++- 2 files changed, 781 insertions(+), 84 deletions(-) diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index f4e3c79bcf1..11bb668ca82 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -500,7 +500,7 @@ test.describe("single-fetch", () => { expect(dataResponse.headers.get("x-action-error")).toEqual("true"); }); - test("merges headers from nested route loaders", async () => { + test("merges headers from nested routes", async () => { let fixture = await createFixture({ config: { future: { @@ -538,6 +538,15 @@ test.describe("single-fetch", () => { } `, "app/routes/a.b.c.tsx": js` + export function action({ request, response }) { + response.headers.set('x-one', 'c action set'); + response.headers.append('x-one', 'c action append'); + response.headers.set('x-two', 'c action set'); + response.headers.append('x-three', 'c action append'); + response.headers.set('x-four', 'c action set'); + return null; + } + export function loader({ request, response }) { response.headers.set('x-one', 'c set'); response.headers.append('x-one', 'c append'); @@ -553,65 +562,519 @@ test.describe("single-fetch", () => { }, }); - // x-one used both set and append + // x-one uses both set and append // x-two only uses set // x-three only uses append // x-four deletes - async function assertHeaders( - method: "requestDocument" | "requestSingleFetchData" - ) { - let res = await fixture[method]("/a.data"); - expect(res.headers.get("x-one")).toEqual("a set, a append"); - expect(res.headers.get("x-two")).toEqual("a set"); - expect(res.headers.get("x-three")).toEqual("a append"); - expect(res.headers.get("x-four")).toEqual("a set"); - - res = await fixture[method]("/a/b.data"); - expect(res.headers.get("x-one")).toEqual("b set, b append"); - expect(res.headers.get("x-two")).toEqual("b set"); - expect(res.headers.get("x-three")).toEqual("a append, b append"); - expect(res.headers.get("x-four")).toEqual(null); - - res = await fixture[method]("/a/b/c.data"); - expect(res.headers.get("x-one")).toEqual("c set, c append"); - expect(res.headers.get("x-two")).toEqual("c set"); - expect(res.headers.get("x-three")).toEqual( - "a append, b append, c append" - ); - expect(res.headers.get("x-four")).toEqual(null); - - res = await fixture[method]("/a/b/c.data?_routes=routes%2Fa"); - expect(res.headers.get("x-one")).toEqual("a set, a append"); - expect(res.headers.get("x-two")).toEqual("a set"); - expect(res.headers.get("x-three")).toEqual("a append"); - expect(res.headers.get("x-four")).toEqual("a set"); - - res = await fixture[method]("/a/b.data?_routes=routes%2Fa,routes%2Fa.b"); - expect(res.headers.get("x-one")).toEqual("b set, b append"); - expect(res.headers.get("x-two")).toEqual("b set"); - expect(res.headers.get("x-three")).toEqual("a append, b append"); - expect(res.headers.get("x-four")).toEqual(null); - - res = await fixture[method]("/a/b/c.data?_routes=routes%2Fa.b.c"); - expect(res.headers.get("x-one")).toEqual("c set, c append"); - expect(res.headers.get("x-two")).toEqual("c set"); - expect(res.headers.get("x-three")).toEqual("c append"); - expect(res.headers.get("x-four")).toEqual(null); - - res = await fixture[method]( - "/a/b/c.data?_routes=routes%2Fa,routes%2Fa.b.c" - ); - expect(res.headers.get("x-one")).toEqual("c set, c append"); - expect(res.headers.get("x-two")).toEqual("c set"); - expect(res.headers.get("x-three")).toEqual("a append, c append"); - expect(res.headers.get("x-four")).toEqual("a set"); - } + let res = await fixture.requestDocument("/a"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestSingleFetchData("/a.data"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestDocument("/a/b"); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("a append, b append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture.requestSingleFetchData("/a/b.data"); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("a append, b append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture.requestDocument("/a/b/c"); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual("a append, b append, c append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture.requestSingleFetchData("/a/b/c.data"); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual("a append, b append, c append"); + expect(res.headers.get("x-four")).toEqual(null); + + // Fine-grained revalidation + res = await fixture.requestDocument("/a/b/c.data?_routes=routes%2Fa"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestDocument( + "/a/b.data?_routes=routes%2Fa,routes%2Fa.b" + ); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("a append, b append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture.requestDocument("/a/b/c.data?_routes=routes%2Fa.b.c"); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual("c append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture.requestDocument( + "/a/b/c.data?_routes=routes%2Fa,routes%2Fa.b.c" + ); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual("a append, c append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + // Action only - single fetch request + res = await fixture.requestSingleFetchData("/a/b/c.data", { + method: "post", + body: null, + }); + expect(res.headers.get("x-one")).toEqual("c action set, c action append"); + expect(res.headers.get("x-two")).toEqual("c action set"); + expect(res.headers.get("x-three")).toEqual("c action append"); + expect(res.headers.get("x-four")).toEqual("c action set"); + + // Actions and Loaders - Document request + res = await fixture.requestDocument("/a/b/c", { + method: "post", + body: null, + }); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual( + "c action append, a append, b append, c append" + ); + expect(res.headers.get("x-four")).toEqual(null); + }); + + test("merges status codes from nested routes", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + export function loader({ request, response }) { + if (new URL(request.url).searchParams.has("error")) { + response.status = 401 + } else { + response.status = 201 + } + return null; + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.tsx": js` + export function loader({ request, response }) { + response.status = 202 + return null; + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.c.tsx": js` + export function action({ request, response }) { + response.status = 206 + return null; + } + + export function loader({ request, response }) { + response.status = 203 + return null; + } + + export default function Comp() { + return null; + } + `, + }, + }); + + // Loaders + let res = await fixture.requestDocument("/a"); + expect(res.status).toEqual(201); + + res = await fixture.requestSingleFetchData("/a.data"); + expect(res.status).toEqual(201); + + res = await fixture.requestDocument("/a/b"); + expect(res.status).toEqual(202); + + res = await fixture.requestSingleFetchData("/a/b.data"); + expect(res.status).toEqual(202); + + res = await fixture.requestDocument("/a/b/c"); + expect(res.status).toEqual(203); + + res = await fixture.requestSingleFetchData("/a/b/c.data"); + expect(res.status).toEqual(203); + + // Errors + res = await fixture.requestDocument("/a?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestSingleFetchData("/a.data?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestDocument("/a/b?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestSingleFetchData("/a/b.data?error"); + expect(res.status).toEqual(401); + + // Actions + res = await fixture.requestDocument("/a/b/c", { + method: "post", + body: null, + }); + expect(res.status).toEqual(206); + + res = await fixture.requestSingleFetchData("/a/b/c.data", { + method: "post", + body: null, + }); + expect(res.status).toEqual(206); + }); + + test("merges headers from nested routes when raw Responses are returned", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + export function loader({ request}) { + let headers = new Headers(); + headers.set('x-one', 'a set'); + headers.append('x-one', 'a append'); + headers.set('x-two', 'a set'); + headers.append('x-three', 'a append'); + headers.set('x-four', 'a set'); + return new Response(null, { headers }); + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.tsx": js` + export function action({ request, response }) { + let headers = new Headers(); + headers.set('x-one', 'b action set'); + headers.append('x-one', 'b action append'); + headers.set('x-two', 'b action set'); + headers.append('x-three', 'b action append'); + headers.set('x-four', 'b action set'); + return new Response(null, { headers }); + } + + export function loader({ request, response }) { + let headers = new Headers(); + headers.set('x-one', 'b set'); + headers.append('x-one', 'b append'); + headers.set('x-two', 'b set'); + headers.append('x-three', 'b append'); + headers.delete('x-four'); + return new Response(null, { headers }); + } + + export default function Comp() { + return null; + } + `, + }, + }); + + // x-one uses both set and append + // x-two only uses set + // x-three only uses append + // x-four deletes + let res = await fixture.requestDocument("/a"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestSingleFetchData("/a.data"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestDocument("/a/b"); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("b append"); // Blows away "a append" + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestSingleFetchData("/a/b.data"); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("b append"); // Blows away "a append" + expect(res.headers.get("x-four")).toEqual("a set"); + + // Action only - single fetch request + res = await fixture.requestSingleFetchData("/a/b.data", { + method: "post", + body: null, + }); + expect(res.headers.get("x-one")).toEqual("b action set, b action append"); + expect(res.headers.get("x-two")).toEqual("b action set"); + expect(res.headers.get("x-three")).toEqual("b action append"); + expect(res.headers.get("x-four")).toEqual("b action set"); - assertHeaders("requestDocument"); - assertHeaders("requestSingleFetchData"); + // Actions and Loaders - Document request + res = await fixture.requestDocument("/a/b", { + method: "post", + body: null, + }); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("b append"); // Blows away prior appends + expect(res.headers.get("x-four")).toEqual("a set"); // Can't delete via Response }); - test("processes loader redirects", async ({ page }) => { + test("merges status codes from nested routes when raw Responses are used", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + export function loader({ request, response }) { + if (new URL(request.url).searchParams.has("error")) { + return new Response(null, { status: 401 }); + } else { + return new Response(null, { status: 201 }); + } + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.tsx": js` + export function loader({ request, response }) { + return new Response(null, { status: 202 }); + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.c.tsx": js` + export function action({ request, response }) { + return new Response(null, { status: 206 }); + } + + export function loader({ request, response }) { + return new Response(null, { status: 203 }); + } + + export default function Comp() { + return null; + } + `, + }, + }); + + // Loaders + let res = await fixture.requestDocument("/a"); + expect(res.status).toEqual(201); + + res = await fixture.requestSingleFetchData("/a.data"); + expect(res.status).toEqual(201); + + res = await fixture.requestDocument("/a/b"); + expect(res.status).toEqual(202); + + res = await fixture.requestSingleFetchData("/a/b.data"); + expect(res.status).toEqual(202); + + res = await fixture.requestDocument("/a/b/c"); + expect(res.status).toEqual(203); + + res = await fixture.requestSingleFetchData("/a/b/c.data"); + expect(res.status).toEqual(203); + + // Errors + res = await fixture.requestDocument("/a?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestSingleFetchData("/a.data?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestDocument("/a/b?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestSingleFetchData("/a/b.data?error"); + expect(res.status).toEqual(401); + + // Actions + res = await fixture.requestDocument("/a/b/c", { + method: "post", + body: null, + }); + expect(res.status).toEqual(206); + + res = await fixture.requestSingleFetchData("/a/b/c.data", { + method: "post", + body: null, + }); + expect(res.status).toEqual(206); + }); + + test("processes thrown loader redirects via responseStub", 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({ request, response }) { + response.status = 302; + response.headers.set('Location', '/target'); + throw response; + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }); + + console.error = () => {}; + + let res = await fixture.requestDocument("/data"); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + 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 returned loader redirects via responseStub", 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({ request, response }) { + response.status = 302; + response.headers.set('Location', '/target'); + return null + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }); + + let res = await fixture.requestDocument("/data"); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + 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 thrown loader redirects via Response", 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() { + throw redirect('/target'); + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }); + + console.error = () => {}; + + let res = await fixture.requestDocument("/data"); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + 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 returned loader redirects via Response", async ({ page }) => { let fixture = await createFixture({ config: { future: { @@ -636,6 +1099,11 @@ test.describe("single-fetch", () => { `, }, }); + let res = await fixture.requestDocument("/data"); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + let appFixture = await createAppFixture(fixture); let app = new PlaywrightFixture(appFixture, page); await app.goto("/"); @@ -644,7 +1112,154 @@ test.describe("single-fetch", () => { expect(await app.getHtml("#target")).toContain("Target"); }); - test("processes action redirects", async ({ page }) => { + test("processes thrown action redirects via responseStub", 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({ response }) { + response.status = 302; + response.headers.set('Location', '/target'); + throw response; + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }, + ServerMode.Development + ); + + console.error = () => {}; + + let res = await fixture.requestDocument("/data", { + method: "post", + body: null, + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + 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 returned action redirects via responseStub", 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({ response }) { + response.status = 302; + response.headers.set('Location', '/target'); + return null + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }, + ServerMode.Development + ); + + let res = await fixture.requestDocument("/data", { + method: "post", + body: null, + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + 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 thrown action redirects via Response", 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() { + throw redirect('/target'); + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }, + ServerMode.Development + ); + + console.error = () => {}; + + let res = await fixture.requestDocument("/data", { + method: "post", + body: null, + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + 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 returned action redirects via Response", async ({ page }) => { let fixture = await createFixture( { config: { @@ -672,6 +1287,15 @@ test.describe("single-fetch", () => { }, ServerMode.Development ); + + let res = await fixture.requestDocument("/data", { + method: "post", + body: null, + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + let appFixture = await createAppFixture(fixture, ServerMode.Development); let app = new PlaywrightFixture(appFixture, page); await app.goto("/"); diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 143a4312d4e..c90295dce47 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -37,6 +37,7 @@ import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { createDeferredReadableStream, isRedirectResponse, + isRedirectStatusCode, isResponse, } from "./responses"; import { createServerHandoffString } from "./serverHandoff"; @@ -84,7 +85,7 @@ function derive(build: ServerBuild, mode?: string) { } export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); -export const ContextResponseStubSymbol = Symbol("ContextResponseStub"); +export const ResponseStubActionSymbol = Symbol("ResponseStubAction"); export const createRequestHandler: CreateRequestHandlerFunction = ( build, @@ -196,7 +197,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( if (isRedirectResponse(response)) { let result: SingleFetchResult | SingleFetchResults = - getSingleFetchRedirect(response); + getSingleFetchRedirect(response.status, response.headers); if (request.method === "GET") { result = { @@ -410,7 +411,7 @@ async function singleFetchAction( ...(request.body ? { duplex: "half" } : undefined), }); - let responseStubs: Record = {}; + let responseStubs = getResponseStubs(); let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, skipLoaderErrorBubbling: true, @@ -422,7 +423,7 @@ async function singleFetchAction( // let non-Response return values through if (isResponse(result)) { return { - result: getSingleFetchRedirect(result), + result: getSingleFetchRedirect(result.status, result.headers), headers: result.headers, status: 200, }; @@ -430,6 +431,17 @@ async function singleFetchAction( let context = result; + let singleFetchResult: SingleFetchResult; + let { statusCode, headers } = mergeResponseStubs(context, responseStubs); + + if (isRedirectStatusCode(statusCode) && headers.has("Location")) { + return { + result: getSingleFetchRedirect(statusCode, headers), + headers, + status: 200, // Don't want the `fetch` call to follow the redirect + }; + } + // Sanitize errors outside of development environments if (context.errors) { Object.values(context.errors).forEach((err) => { @@ -441,8 +453,6 @@ async function singleFetchAction( context.errors = sanitizeErrors(context.errors, serverMode); } - let singleFetchResult: SingleFetchResult; - let { statusCode, headers } = mergeResponseStubs(context, responseStubs); if (context.errors) { let error = Object.values(context.errors)[0]; singleFetchResult = { error: isResponseStub(error) ? null : error }; @@ -482,7 +492,7 @@ async function singleFetchLoaders( let loadRouteIds = new URL(request.url).searchParams.get("_routes")?.split(",") || undefined; - let responseStubs: Record = {}; + let responseStubs = getResponseStubs(); let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, loadRouteIds, @@ -493,7 +503,10 @@ async function singleFetchLoaders( if (isResponse(result)) { return { result: { - [SingleFetchRedirectSymbol]: getSingleFetchRedirect(result), + [SingleFetchRedirectSymbol]: getSingleFetchRedirect( + result.status, + result.headers + ), }, headers: result.headers, status: 200, // Don't want the `fetch` call to follow the redirect @@ -502,6 +515,21 @@ async function singleFetchLoaders( let context = result; + let { statusCode, headers } = mergeResponseStubs(context, responseStubs); + + if (isRedirectStatusCode(statusCode) && headers.has("Location")) { + return { + result: { + [SingleFetchRedirectSymbol]: getSingleFetchRedirect( + statusCode, + headers + ), + }, + headers, + status: 200, // Don't want the `fetch` call to follow the redirect + }; + } + // Sanitize errors outside of development environments if (context.errors) { Object.values(context.errors).forEach((err) => { @@ -521,6 +549,7 @@ async function singleFetchLoaders( (m) => m.route.loader && loadRouteIds!.includes(m.route.id) ) : context.matches; + loadedMatches.forEach((m) => { let data = context.loaderData?.[m.route.id]; let error = context.errors?.[m.route.id]; @@ -535,8 +564,6 @@ async function singleFetchLoaders( } }); - let { statusCode, headers } = mergeResponseStubs(context, responseStubs); - return { result: results, headers, @@ -563,7 +590,7 @@ async function handleDocumentRequest( criticalCss?: string ) { let context; - let responseStubs: Record = {}; + let responseStubs = getResponseStubs(); try { context = await staticHandler.query(request, { requestContext: loadContext, @@ -597,6 +624,13 @@ async function handleDocumentRequest( let merged = mergeResponseStubs(context, responseStubs); statusCode = merged.statusCode; headers = merged.headers; + + if (isRedirectStatusCode(statusCode) && headers.has("Location")) { + return new Response(null, { + status: statusCode, + headers, + }); + } } else { statusCode = context.statusCode; headers = getDocumentHeaders(build, context); @@ -824,7 +858,7 @@ function unwrapResponse(response: Response) { : response.text(); } -function getResponseStub(status = 200) { +function getResponseStub(status?: number) { let headers = new Headers(); let operations: ResponseStubOperation[] = []; let headersProxy = new Proxy(headers, { @@ -846,20 +880,46 @@ function getResponseStub(status = 200) { } function getSingleFetchDataStrategy( - responseStubs: Record + responseStubs: ReturnType ) { - return async ({ matches }: DataStrategyFunctionArgs) => { + return async ({ request, matches }: DataStrategyFunctionArgs) => { let results = await Promise.all( matches.map(async (match) => { - let result = await match.resolve(async (handler) => { - let responseStub = responseStubs[match.route.id]; + let responseStub: ResponseStub | undefined; + if (request.method !== "GET") { + responseStub = responseStubs[ResponseStubActionSymbol]; + if (!responseStub) { + responseStub = getResponseStub(); + responseStubs[ResponseStubActionSymbol] = responseStub; + } + } else { + responseStub = responseStubs[match.route.id]; if (!responseStub) { responseStub = getResponseStub(); responseStubs[match.route.id] = responseStub; } + } + + let result = await match.resolve(async (handler) => { let data = await handler({ response: responseStub }); return { type: "data", result: data }; }); + + // Transfer raw Response status/headers to responseStubs + if (isResponse(result.result)) { + if (responseStub.status == null) { + responseStub.status = result.result.status; + } + for (let [k, v] of result.result.headers) { + if (k !== "Set-Cookie") { + responseStub.headers.set(k, v); + } + } + for (let v of result.result.headers.getSetCookie()) { + responseStub.headers.append("Set-Cookie", v); + } + } + return result; }) ); @@ -873,17 +933,29 @@ function isResponseStub(value: any): value is ResponseStub { ); } +function getResponseStubs(): { + [k: string]: ResponseStub; + [ResponseStubActionSymbol]?: ResponseStub; +} { + return {}; +} + function mergeResponseStubs( context: StaticHandlerContext, - responseStubs: Record + responseStubs: ReturnType ) { let statusCode = 200; let headers = new Headers(); - context.matches.forEach((m) => { - let stub = responseStubs[m.route.id]; - if (stub) { - // Take the highest error/redirect, or the lowest success value - if (statusCode < 300 && stub.status) { + // Action followed by top-down loaders + let actionStub = responseStubs[ResponseStubActionSymbol]; + let stubs = [ + actionStub, + ...context.matches.map((m) => responseStubs[m.route.id]), + ].filter((stub) => stub) as ResponseStub[]; + stubs.forEach((stub: ResponseStub) => { + // Take the highest error/redirect, or the lowest success value - preferring + // action 200's over loader 200s + if (statusCode < 300 && stub.status && statusCode !== actionStub?.status) { statusCode = stub.status; } @@ -891,15 +963,17 @@ function mergeResponseStubs( let ops = stub[ResponseStubOperationsSymbol]; // @ts-expect-error ops.forEach(([op, ...args]) => headers[op](...args)); - } }); return { statusCode, headers }; } -function getSingleFetchRedirect(response: Response): SingleFetchRedirectResult { +function getSingleFetchRedirect( + status: number, + headers: Headers +): SingleFetchRedirectResult { return { - redirect: response.headers.get("Location")!, - status: response.status, + redirect: headers.get("Location")!, + status, revalidate: // Technically X-Remix-Revalidate isn't needed here - that was an implementation // detail of ?_data requests as our way to tell the front end to revalidate when @@ -908,9 +982,8 @@ function getSingleFetchRedirect(response: Response): SingleFetchRedirectResult { // However, we're respecting it for now because it may be something folks have // used in their own responses // TODO(v3): Consider removing or making this official public API - response.headers.has("X-Remix-Revalidate") || - response.headers.has("Set-Cookie"), - reload: response.headers.has("X-Remix-Reload-Document"), + headers.has("X-Remix-Revalidate") || headers.has("Set-Cookie"), + reload: headers.has("X-Remix-Reload-Document"), }; } From b838164dad943f9f299bd0ea5e7120eff2ef8f17 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 26 Mar 2024 17:48:34 -0400 Subject: [PATCH 05/12] Bump router and fix bump router script --- integration/package.json | 2 +- packages/remix-dev/package.json | 2 +- packages/remix-react/package.json | 6 +-- packages/remix-server-runtime/package.json | 2 +- packages/remix-testing/package.json | 4 +- pnpm-lock.yaml | 50 +++++++++++----------- scripts/bump-router-versions.sh | 2 +- 7 files changed, 34 insertions(+), 34 deletions(-) diff --git a/integration/package.json b/integration/package.json index a7d0b877a37..619a96c8707 100644 --- a/integration/package.json +++ b/integration/package.json @@ -14,7 +14,7 @@ "@remix-run/dev": "workspace:*", "@remix-run/express": "workspace:*", "@remix-run/node": "workspace:*", - "@remix-run/router": "0.0.0-experimental-c7dd3d3a", + "@remix-run/router": "0.0.0-experimental-0f302655", "@remix-run/server-runtime": "workspace:*", "@types/express": "^4.17.9", "@vanilla-extract/css": "^1.10.0", diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index d6c59042c93..1789ff2b438 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -32,7 +32,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "workspace:*", - "@remix-run/router": "0.0.0-experimental-c7dd3d3a", + "@remix-run/router": "0.0.0-experimental-0f302655", "@remix-run/server-runtime": "workspace:*", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index be37161acaa..79eb341f290 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -19,10 +19,10 @@ "tsc": "tsc" }, "dependencies": { - "@remix-run/router": "0.0.0-experimental-c7dd3d3a", + "@remix-run/router": "0.0.0-experimental-0f302655", "@remix-run/server-runtime": "workspace:*", - "react-router": "0.0.0-experimental-c7dd3d3a", - "react-router-dom": "0.0.0-experimental-c7dd3d3a", + "react-router": "0.0.0-experimental-0f302655", + "react-router-dom": "0.0.0-experimental-0f302655", "turbo-stream": "^2.0.0" }, "devDependencies": { diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index e47dbe1dbcf..1d0548a4467 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -19,7 +19,7 @@ "tsc": "tsc" }, "dependencies": { - "@remix-run/router": "0.0.0-experimental-c7dd3d3a", + "@remix-run/router": "0.0.0-experimental-0f302655", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index 9079854fe9d..81636d20bc2 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -21,8 +21,8 @@ "dependencies": { "@remix-run/node": "workspace:*", "@remix-run/react": "workspace:*", - "@remix-run/router": "0.0.0-experimental-c7dd3d3a", - "react-router-dom": "0.0.0-experimental-c7dd3d3a" + "@remix-run/router": "0.0.0-experimental-0f302655", + "react-router-dom": "0.0.0-experimental-0f302655" }, "devDependencies": { "@remix-run/server-runtime": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86f20902365..a56de7cf336 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,8 +320,8 @@ importers: specifier: workspace:* version: link:../packages/remix-node '@remix-run/router': - specifier: 0.0.0-experimental-c7dd3d3a - version: 0.0.0-experimental-c7dd3d3a + specifier: 0.0.0-experimental-0f302655 + version: 0.0.0-experimental-0f302655 '@remix-run/server-runtime': specifier: workspace:* version: link:../packages/remix-server-runtime @@ -868,8 +868,8 @@ importers: specifier: ^2.8.1 version: link:../remix-react '@remix-run/router': - specifier: 0.0.0-experimental-c7dd3d3a - version: 0.0.0-experimental-c7dd3d3a + specifier: 0.0.0-experimental-0f302655 + version: 0.0.0-experimental-0f302655 '@remix-run/server-runtime': specifier: workspace:* version: link:../remix-server-runtime @@ -1211,17 +1211,17 @@ importers: packages/remix-react: dependencies: '@remix-run/router': - specifier: 0.0.0-experimental-c7dd3d3a - version: 0.0.0-experimental-c7dd3d3a + specifier: 0.0.0-experimental-0f302655 + version: 0.0.0-experimental-0f302655 '@remix-run/server-runtime': specifier: workspace:* version: link:../remix-server-runtime react-router: - specifier: 0.0.0-experimental-c7dd3d3a - version: 0.0.0-experimental-c7dd3d3a(react@18.2.0) + specifier: 0.0.0-experimental-0f302655 + version: 0.0.0-experimental-0f302655(react@18.2.0) react-router-dom: - specifier: 0.0.0-experimental-c7dd3d3a - version: 0.0.0-experimental-c7dd3d3a(react-dom@18.2.0)(react@18.2.0) + specifier: 0.0.0-experimental-0f302655 + version: 0.0.0-experimental-0f302655(react-dom@18.2.0)(react@18.2.0) turbo-stream: specifier: ^2.0.0 version: 2.0.0 @@ -1297,8 +1297,8 @@ importers: packages/remix-server-runtime: dependencies: '@remix-run/router': - specifier: 0.0.0-experimental-c7dd3d3a - version: 0.0.0-experimental-c7dd3d3a + specifier: 0.0.0-experimental-0f302655 + version: 0.0.0-experimental-0f302655 '@types/cookie': specifier: ^0.6.0 version: 0.6.0 @@ -1334,11 +1334,11 @@ importers: specifier: workspace:* version: link:../remix-react '@remix-run/router': - specifier: 0.0.0-experimental-c7dd3d3a - version: 0.0.0-experimental-c7dd3d3a + specifier: 0.0.0-experimental-0f302655 + version: 0.0.0-experimental-0f302655 react-router-dom: - specifier: 0.0.0-experimental-c7dd3d3a - version: 0.0.0-experimental-c7dd3d3a(react-dom@18.2.0)(react@18.2.0) + specifier: 0.0.0-experimental-0f302655 + version: 0.0.0-experimental-0f302655(react-dom@18.2.0)(react@18.2.0) devDependencies: '@remix-run/server-runtime': specifier: workspace:* @@ -4193,8 +4193,8 @@ packages: - encoding dev: false - /@remix-run/router@0.0.0-experimental-c7dd3d3a: - resolution: {integrity: sha512-reXnySaRGlEl6cOgF7fSQvIRzyRFUjGsez0kHQk0ok6Z3Y7n1PVTCKNhooSfOI1hBOi0c9cIMurhoTgjYXyxHw==} + /@remix-run/router@0.0.0-experimental-0f302655: + resolution: {integrity: sha512-/Lb7eqBbMON5ue+lxTflS8lZ2WwXxk/iA+YRbsmuu3gn/pb8eto5JfyV3H/NPjlL+oxxXxcYGvzWor55OXIlSQ==} engines: {node: '>=14.0.0'} dev: false @@ -12784,26 +12784,26 @@ packages: engines: {node: '>=0.10.0'} dev: false - /react-router-dom@0.0.0-experimental-c7dd3d3a(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-pp1l8sk4Rc9vLucHgHU4B2LUL/4e6iwD7ipDGgfc9a08eNVowxaDJXkbtyYE10lpjaZZkXF1DFkdHfGrKeXWQw==} + /react-router-dom@0.0.0-experimental-0f302655(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-UmaABR8mrGMrcH/EHt2bQYFFX2faICJEXg+Q+jUSXQhiN8lsqDGOF7chHYzU17Ud7NtsxdoeFeVIXIpXwjE4xQ==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' react-dom: '>=16.8' dependencies: - '@remix-run/router': 0.0.0-experimental-c7dd3d3a + '@remix-run/router': 0.0.0-experimental-0f302655 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-router: 0.0.0-experimental-c7dd3d3a(react@18.2.0) + react-router: 0.0.0-experimental-0f302655(react@18.2.0) dev: false - /react-router@0.0.0-experimental-c7dd3d3a(react@18.2.0): - resolution: {integrity: sha512-biSo7Pmedzrm9DXjC3ScIwCWVt3qVAutf51hJ/POxfcRYvhgi9ENNGHLjpqabBH4oI5IwI53Vh0w7pKOdadC9A==} + /react-router@0.0.0-experimental-0f302655(react@18.2.0): + resolution: {integrity: sha512-0A1d352GLuI23uIuPJgRDTqEz90KLG7ZmZ/mZPhv+bw8pymeAAVL8WAHIjjBFePvsjUVS30iV19DPvbgDUU+PA==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' dependencies: - '@remix-run/router': 0.0.0-experimental-c7dd3d3a + '@remix-run/router': 0.0.0-experimental-0f302655 react: 18.2.0 dev: false diff --git a/scripts/bump-router-versions.sh b/scripts/bump-router-versions.sh index 9fe24fe3223..1cfe1609942 100755 --- a/scripts/bump-router-versions.sh +++ b/scripts/bump-router-versions.sh @@ -41,7 +41,7 @@ cd ../.. cd integration pnpm add -E @remix-run/router@${ROUTER_VERSION} -cd ../.. +cd .. # Because deplicates... pnpm dedupe && rm -rf ./node_modules && pnpm install From 9231eb68e5b84239414770407ed6b04af954f103 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 26 Mar 2024 18:00:00 -0400 Subject: [PATCH 06/12] Fix TS errors --- integration/single-fetch-test.ts | 28 +++++++++++++++++++++---- packages/remix-server-runtime/routes.ts | 18 +++++++--------- packages/remix-server-runtime/server.ts | 15 +++++++------ 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index 11bb668ca82..c571d63f46e 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -566,7 +566,12 @@ test.describe("single-fetch", () => { // x-two only uses set // x-three only uses append // x-four deletes - let res = await fixture.requestDocument("/a"); + let res: Awaited< + ReturnType< + typeof fixture.requestDocument | typeof fixture.requestSingleFetchData + > + >; + res = await fixture.requestDocument("/a"); expect(res.headers.get("x-one")).toEqual("a set, a append"); expect(res.headers.get("x-two")).toEqual("a set"); expect(res.headers.get("x-three")).toEqual("a append"); @@ -706,7 +711,12 @@ test.describe("single-fetch", () => { }); // Loaders - let res = await fixture.requestDocument("/a"); + let res: Awaited< + ReturnType< + typeof fixture.requestDocument | typeof fixture.requestSingleFetchData + > + >; + res = await fixture.requestDocument("/a"); expect(res.status).toEqual(201); res = await fixture.requestSingleFetchData("/a.data"); @@ -807,7 +817,12 @@ test.describe("single-fetch", () => { // x-two only uses set // x-three only uses append // x-four deletes - let res = await fixture.requestDocument("/a"); + let res: Awaited< + ReturnType< + typeof fixture.requestDocument | typeof fixture.requestSingleFetchData + > + >; + res = await fixture.requestDocument("/a"); expect(res.headers.get("x-one")).toEqual("a set, a append"); expect(res.headers.get("x-two")).toEqual("a set"); expect(res.headers.get("x-three")).toEqual("a append"); @@ -900,7 +915,12 @@ test.describe("single-fetch", () => { }); // Loaders - let res = await fixture.requestDocument("/a"); + let res: Awaited< + ReturnType< + typeof fixture.requestDocument | typeof fixture.requestSingleFetchData + > + >; + res = await fixture.requestDocument("/a"); expect(res.status).toEqual(201); res = await fixture.requestSingleFetchData("/a.data"); diff --git a/packages/remix-server-runtime/routes.ts b/packages/remix-server-runtime/routes.ts index bf9e4400334..5aade6661d5 100644 --- a/packages/remix-server-runtime/routes.ts +++ b/packages/remix-server-runtime/routes.ts @@ -92,10 +92,7 @@ export function createStaticHandlerDataRoutes( loader: route.module.loader ? // Need to use RR's version here to permit the optional context even // though we know it'll always be provided in remix - ( - args: RRLoaderFunctionArgs, - dataStrategyCtx?: { response: ResponseStub } - ) => + (args: RRLoaderFunctionArgs, dataStrategyCtx?: unknown) => callRouteLoader({ request: args.request, params: args.params, @@ -103,14 +100,13 @@ export function createStaticHandlerDataRoutes( loader: route.module.loader!, routeId: route.id, singleFetch: future.unstable_singleFetch === true, - response: dataStrategyCtx?.response, + response: ( + dataStrategyCtx as unknown as { response: ResponseStub } + )?.response, }) : undefined, action: route.module.action - ? ( - args: RRActionFunctionArgs, - dataStrategyCtx?: { response: ResponseStub } - ) => + ? (args: RRActionFunctionArgs, dataStrategyCtx?: unknown) => callRouteAction({ request: args.request, params: args.params, @@ -118,7 +114,9 @@ export function createStaticHandlerDataRoutes( action: route.module.action!, routeId: route.id, singleFetch: future.unstable_singleFetch === true, - response: dataStrategyCtx?.response, + response: ( + dataStrategyCtx as unknown as { response: ResponseStub } + )?.response, }) : undefined, handle: route.module.handle, diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index c90295dce47..6091b5ba9b4 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -915,6 +915,9 @@ function getSingleFetchDataStrategy( responseStub.headers.set(k, v); } } + + // Unsure why this is complaining? It's fine in VSCode but fails with tsc... + // @ts-expect-error for (let v of result.result.headers.getSetCookie()) { responseStub.headers.append("Set-Cookie", v); } @@ -956,13 +959,13 @@ function mergeResponseStubs( // Take the highest error/redirect, or the lowest success value - preferring // action 200's over loader 200s if (statusCode < 300 && stub.status && statusCode !== actionStub?.status) { - statusCode = stub.status; - } + statusCode = stub.status; + } - // Replay headers operations in order - let ops = stub[ResponseStubOperationsSymbol]; - // @ts-expect-error - ops.forEach(([op, ...args]) => headers[op](...args)); + // Replay headers operations in order + let ops = stub[ResponseStubOperationsSymbol]; + // @ts-expect-error + ops.forEach(([op, ...args]) => headers[op](...args)); }); return { statusCode, headers }; } From d3bba248b2d5d9aabdcc3bf0dcd0fb5a0d507061 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 26 Mar 2024 18:26:58 -0400 Subject: [PATCH 07/12] Fix a few E2E tests --- packages/remix-server-runtime/server.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 6091b5ba9b4..eec1b073f21 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -947,7 +947,7 @@ function mergeResponseStubs( context: StaticHandlerContext, responseStubs: ReturnType ) { - let statusCode = 200; + let statusCode: number | undefined = undefined; let headers = new Headers(); // Action followed by top-down loaders let actionStub = responseStubs[ResponseStubActionSymbol]; @@ -958,7 +958,11 @@ function mergeResponseStubs( stubs.forEach((stub: ResponseStub) => { // Take the highest error/redirect, or the lowest success value - preferring // action 200's over loader 200s - if (statusCode < 300 && stub.status && statusCode !== actionStub?.status) { + if ( + (statusCode === undefined || statusCode < 300) && + stub.status && + statusCode !== actionStub?.status + ) { statusCode = stub.status; } @@ -967,6 +971,14 @@ function mergeResponseStubs( // @ts-expect-error ops.forEach(([op, ...args]) => headers[op](...args)); }); + + // If no response stubs set it, use whatever we got back from the router + // context which handles internal ErrorResponse cases like 404/405's where + // we may never run a loader/action + if (statusCode === undefined) { + statusCode = context.statusCode; + } + return { statusCode, headers }; } From 309a4983209aaee49868c3edf91e7fa2e64790c6 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 26 Mar 2024 21:24:40 -0400 Subject: [PATCH 08/12] Few more E2E fixes --- integration/revalidate-test.ts | 6 +++--- packages/remix-server-runtime/server.ts | 27 +++++++++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/integration/revalidate-test.ts b/integration/revalidate-test.ts index 92019b472a0..8830e977a2c 100644 --- a/integration/revalidate-test.ts +++ b/integration/revalidate-test.ts @@ -326,9 +326,9 @@ test.describe("single fetch", () => {
  • /
  • /parent
  • /parent/child
  • -
  • /parent/child
  • -
  • /parent/child
  • -
  • /parent/child
  • +
  • /parent/child?revalidate=parent
  • +
  • /parent/child?revalidate=child
  • +
  • /parent/child?revalidate=parent,child
  • diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index eec1b073f21..1f77994a238 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -911,7 +911,7 @@ function getSingleFetchDataStrategy( responseStub.status = result.result.status; } for (let [k, v] of result.result.headers) { - if (k !== "Set-Cookie") { + if (k.toLowerCase() !== "set-cookie") { responseStub.headers.set(k, v); } } @@ -949,28 +949,36 @@ function mergeResponseStubs( ) { let statusCode: number | undefined = undefined; let headers = new Headers(); + // Action followed by top-down loaders let actionStub = responseStubs[ResponseStubActionSymbol]; let stubs = [ actionStub, ...context.matches.map((m) => responseStubs[m.route.id]), ].filter((stub) => stub) as ResponseStub[]; - stubs.forEach((stub: ResponseStub) => { + + for (let stub of stubs) { // Take the highest error/redirect, or the lowest success value - preferring // action 200's over loader 200s if ( - (statusCode === undefined || statusCode < 300) && - stub.status && - statusCode !== actionStub?.status + // first status found on the way down + (statusCode === undefined && stub.status) || + // deeper 2xx status found while not overriding the action status + (statusCode !== undefined && + statusCode < 300 && + stub.status && + statusCode !== actionStub?.status) ) { statusCode = stub.status; } // Replay headers operations in order let ops = stub[ResponseStubOperationsSymbol]; - // @ts-expect-error - ops.forEach(([op, ...args]) => headers[op](...args)); - }); + for (let [op, ...args] of ops) { + // @ts-expect-error + headers[op](...args); + } + } // If no response stubs set it, use whatever we got back from the router // context which handles internal ErrorResponse cases like 404/405's where @@ -978,6 +986,9 @@ function mergeResponseStubs( if (statusCode === undefined) { statusCode = context.statusCode; } + if (statusCode === undefined) { + statusCode = 200; + } return { statusCode, headers }; } From 9e87eb5e588ae406ee43e472349c390802b3962f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 26 Mar 2024 21:58:20 -0400 Subject: [PATCH 09/12] Fix boundary tests and handle defer headers --- integration/error-boundary-test.ts | 16 +++++++-- packages/remix-server-runtime/server.ts | 45 +++++++++++++++++-------- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/integration/error-boundary-test.ts b/integration/error-boundary-test.ts index 9d2166baa62..dadfa3d31cf 100644 --- a/integration/error-boundary-test.ts +++ b/integration/error-boundary-test.ts @@ -2229,9 +2229,19 @@ test.describe("single fetch", () => { expect(await app.getHtml("#parent-error")).toEqual( '

    Broken!

    ' ); - expect(await app.getHtml("#parent-matches-data")).toEqual( - '

    ' - ); + if (javaScriptEnabled) { + // This data remains in single fetch with JS because we don't revalidate + // due to the 500 action response + expect(await app.getHtml("#parent-matches-data")).toEqual( + '

    PARENT

    ' + ); + } else { + // But without JS document requests call all loaders up to the + // boundary route so parent's data clears out + expect(await app.getHtml("#parent-matches-data")).toEqual( + '

    ' + ); + } expect(await app.getHtml("#parent-data")).toEqual( '

    ' ); diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 1f77994a238..d4ecda81c52 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -36,6 +36,7 @@ import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { createDeferredReadableStream, + isDeferredData, isRedirectResponse, isRedirectStatusCode, isResponse, @@ -907,20 +908,17 @@ function getSingleFetchDataStrategy( // Transfer raw Response status/headers to responseStubs if (isResponse(result.result)) { - if (responseStub.status == null) { - responseStub.status = result.result.status; - } - for (let [k, v] of result.result.headers) { - if (k.toLowerCase() !== "set-cookie") { - responseStub.headers.set(k, v); - } - } - - // Unsure why this is complaining? It's fine in VSCode but fails with tsc... - // @ts-expect-error - for (let v of result.result.headers.getSetCookie()) { - responseStub.headers.append("Set-Cookie", v); - } + proxyResponseToResponseStub( + result.result.status, + result.result.headers, + responseStub + ); + } else if (isDeferredData(result.result) && result.result.init) { + proxyResponseToResponseStub( + result.result.init.status, + new Headers(result.result.init.headers), + responseStub + ); } return result; @@ -930,6 +928,25 @@ function getSingleFetchDataStrategy( }; } +function proxyResponseToResponseStub( + status: number | undefined, + headers: Headers, + responseStub: ResponseStub +) { + if (status != null && responseStub.status == null) { + responseStub.status = status; + } + for (let [k, v] of headers) { + if (k.toLowerCase() !== "set-cookie") { + responseStub.headers.set(k, v); + } + } + + for (let v of headers.getSetCookie()) { + responseStub.headers.append("Set-Cookie", v); + } +} + function isResponseStub(value: any): value is ResponseStub { return ( value && typeof value === "object" && ResponseStubOperationsSymbol in value From 366870036ec512a6b4ecdd85257636ac8d926c25 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 26 Mar 2024 22:01:38 -0400 Subject: [PATCH 10/12] shakes fist at TS --- packages/remix-server-runtime/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index d4ecda81c52..089b0ac524a 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -942,6 +942,8 @@ function proxyResponseToResponseStub( } } + // Unsure why this is complaining? It's fine in VSCode but fails with tsc... + // @ts-expect-error for (let v of headers.getSetCookie()) { responseStub.headers.append("Set-Cookie", v); } From 91275cffe6a475cfdc95234fee9d45181b53d423 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 27 Mar 2024 06:46:21 -0700 Subject: [PATCH 11/12] chore: use proxy for response stub cache to simplify call site use (#9145) --- packages/remix-server-runtime/server.ts | 27 +++++++++++-------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 089b0ac524a..f7e30332914 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -889,16 +889,8 @@ function getSingleFetchDataStrategy( let responseStub: ResponseStub | undefined; if (request.method !== "GET") { responseStub = responseStubs[ResponseStubActionSymbol]; - if (!responseStub) { - responseStub = getResponseStub(); - responseStubs[ResponseStubActionSymbol] = responseStub; - } } else { responseStub = responseStubs[match.route.id]; - if (!responseStub) { - responseStub = getResponseStub(); - responseStubs[match.route.id] = responseStub; - } } let result = await match.resolve(async (handler) => { @@ -943,7 +935,7 @@ function proxyResponseToResponseStub( } // Unsure why this is complaining? It's fine in VSCode but fails with tsc... - // @ts-expect-error + // @ts-ignore - ignoring instead of expecting because otherwise build fails locally for (let v of headers.getSetCookie()) { responseStub.headers.append("Set-Cookie", v); } @@ -955,11 +947,16 @@ function isResponseStub(value: any): value is ResponseStub { ); } -function getResponseStubs(): { - [k: string]: ResponseStub; - [ResponseStubActionSymbol]?: ResponseStub; -} { - return {}; +function getResponseStubs() { + return new Proxy({} as Record, { + get(responseStubCache, prop) { + let cached = responseStubCache[prop]; + if (!cached) { + responseStubCache[prop] = cached = getResponseStub(); + } + return cached; + }, + }); } function mergeResponseStubs( @@ -974,7 +971,7 @@ function mergeResponseStubs( let stubs = [ actionStub, ...context.matches.map((m) => responseStubs[m.route.id]), - ].filter((stub) => stub) as ResponseStub[]; + ]; for (let stub of stubs) { // Take the highest error/redirect, or the lowest success value - preferring From 6bfd9f341b0d44a63193eef8434b780693cdb2f7 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 27 Mar 2024 10:03:34 -0400 Subject: [PATCH 12/12] Add changeset --- .changeset/response-stub.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/response-stub.md diff --git a/.changeset/response-stub.md b/.changeset/response-stub.md new file mode 100644 index 00000000000..cef4c45e9a7 --- /dev/null +++ b/.changeset/response-stub.md @@ -0,0 +1,25 @@ +--- +"@remix-run/server-runtime": patch +--- + +Add `ResponseStub` header interface for single fetch and deprecate the `headers` export + +- The `headers` export is no longer used when single fetch is enabled +- `loader`/`action` functions now receive a mutable `response` parameter + - `type ResponseStub = { status: numbers | undefined, headers: Headers }` +- To alter the status of a response, set the `status` field directly + - `response.status = 201` +- To set headers on the Response, you may use: + - `response.headers.set` + - `response.headers.append` + - `response.headers.delete` +- Each `loader`/`action`receives it's own unique `response` instance so you cannot see what other `loader`/`action` functions have set (which would be subject to race conditions) +- If all status codes are unset or have values <200, the deepest status code will be used for the HTTP response +- If any status codes are set to a value >=300, the highest >=300 value will be used for the HTTP Response +- Remix tracks header operations and will replay them in order (action if present, then loaders top-down) after all handlers have completed on a fresh `Headers` instance that will be applied to the HTTP Response + - `headers.set` on any child handler will overwrite values from parent handlers + - `headers.append` can be used to set the same header from both a parent and child handler + - `headers.delete` can be used to delete a value set by a parent handler, but not a value set from a child handler +- Because single fetch supports naked object returns, and you no longer need to return a `Response` instance to set status/headers, the `json`/`redirect`/`redirectDocument`/`defer` utilities are considered deprecated when using Single Fetch +- You may still continue returning normal `Response` instances and they'll apply status codes in the same way, and will apply all headers via `headers.set` - overwriting any same-named header values from parents + - If you need to append, you will need to switch from returning a `Response` instance to using the new `response` parameter