From bf63729561365b50705a24fd576e293424df23ef Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 11 Jun 2026 11:15:33 -0400 Subject: [PATCH 1/4] Consolidate url normalization logic (#15176) --- .../.changes/patch.url-consolidation.md | 1 + .../__tests__/resolvePath-test.tsx | 16 +++++++++ .../__tests__/router/redirects-test.ts | 34 +++++++++++++++++++ .../__tests__/useNavigate-test.tsx | 27 +++++++++++++++ packages/react-router/lib/dom/lib.tsx | 3 +- packages/react-router/lib/dom/server.tsx | 3 +- packages/react-router/lib/router/history.ts | 4 ++- packages/react-router/lib/router/router.ts | 10 ++++-- packages/react-router/lib/router/url.ts | 6 ++++ packages/react-router/lib/router/utils.ts | 12 ++++--- packages/react-router/lib/rsc/browser.tsx | 30 +++++++++++----- 11 files changed, 126 insertions(+), 20 deletions(-) create mode 100644 packages/react-router/.changes/patch.url-consolidation.md create mode 100644 packages/react-router/lib/router/url.ts diff --git a/packages/react-router/.changes/patch.url-consolidation.md b/packages/react-router/.changes/patch.url-consolidation.md new file mode 100644 index 0000000000..2d4d3a81f6 --- /dev/null +++ b/packages/react-router/.changes/patch.url-consolidation.md @@ -0,0 +1 @@ +Consolidate url normalization logic and better handle mixed slashes diff --git a/packages/react-router/__tests__/resolvePath-test.tsx b/packages/react-router/__tests__/resolvePath-test.tsx index 6a28d83e41..268cd974a8 100644 --- a/packages/react-router/__tests__/resolvePath-test.tsx +++ b/packages/react-router/__tests__/resolvePath-test.tsx @@ -60,6 +60,22 @@ describe("resolvePath", () => { pathname: "/foo", }); + expect(resolvePath("//foo")).toMatchObject({ + pathname: "/foo", + }); + + expect(resolvePath("\\\\foo")).toMatchObject({ + pathname: "/foo", + }); + + expect(resolvePath("/\\foo")).toMatchObject({ + pathname: "/foo", + }); + + expect(resolvePath("\\/foo")).toMatchObject({ + pathname: "/foo", + }); + spy.mockRestore(); }); diff --git a/packages/react-router/__tests__/router/redirects-test.ts b/packages/react-router/__tests__/router/redirects-test.ts index bf14871076..86d5f99d10 100644 --- a/packages/react-router/__tests__/router/redirects-test.ts +++ b/packages/react-router/__tests__/router/redirects-test.ts @@ -448,6 +448,40 @@ describe("redirects", () => { }); }); + it("normalizes mixed leading separators in redirects", async () => { + let locations = [ + "\\\\localhost/parent", + "/\\localhost/parent", + "\\/localhost/parent", + ]; + + for (let location of locations) { + let t = setup({ routes: REDIRECT_ROUTES }); + + let A = await t.navigate("/parent/child", { + formMethod: "post", + formData: createFormData({}), + }); + + let B = await A.actions.child.redirectReturn( + location, + undefined, + undefined, + ["parent"], + ); + await B.loaders.parent.resolve("PARENT"); + expect(t.router.state.location).toMatchObject({ + hash: "", + pathname: "/parent", + search: "", + state: { + _isRedirect: true, + }, + }); + expect(t.window.location.assign).not.toHaveBeenCalled(); + } + }); + it("properly handles same-origin absolute URLs when using a basename", async () => { let t = setup({ routes: REDIRECT_ROUTES, basename: "/base" }); diff --git a/packages/react-router/__tests__/useNavigate-test.tsx b/packages/react-router/__tests__/useNavigate-test.tsx index 743aed2709..1e0abc2e5e 100644 --- a/packages/react-router/__tests__/useNavigate-test.tsx +++ b/packages/react-router/__tests__/useNavigate-test.tsx @@ -761,6 +761,33 @@ describe("useNavigate", () => { `); }); + + it("normalizes mixed leading separators", async () => { + for (let to of ["//foo", "\\\\foo", "/\\foo", "\\/foo"]) { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + } /> + foo

} /> +
+
, + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + await TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ foo +

+ `); + } + }); }); describe("with a relative href (relative=route)", () => { diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index d3d4d39601..298c809824 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -42,6 +42,7 @@ import { resolveTo, stripBasename, } from "../router/utils"; +import { ABSOLUTE_URL_REGEX } from "../router/url"; // eslint-disable-next-line @typescript-eslint/no-unused-vars import type * as _ from "./global"; @@ -1283,8 +1284,6 @@ export interface LinkProps mask?: To; } -const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; - /** * A progressively enhanced [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) * wrapper to enable navigation with client-side routing. diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx index 84bdbf5a93..d75cb236af 100644 --- a/packages/react-router/lib/dom/server.tsx +++ b/packages/react-router/lib/dom/server.tsx @@ -30,6 +30,7 @@ import { convertRoutesToDataRoutes, isRouteErrorResponse, } from "../router/utils"; +import { ABSOLUTE_URL_REGEX } from "../router/url"; import { DataRoutes, Router, mapRouteProperties } from "../components"; import { DataRouterContext, @@ -523,5 +524,3 @@ function encodeLocation(to: To): Path { hash: encoded.hash, }; } - -const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; diff --git a/packages/react-router/lib/router/history.ts b/packages/react-router/lib/router/history.ts index 46254b02c8..8a302cf6a8 100644 --- a/packages/react-router/lib/router/history.ts +++ b/packages/react-router/lib/router/history.ts @@ -1,3 +1,5 @@ +import { PROTOCOL_RELATIVE_URL_REGEX } from "./url"; + //////////////////////////////////////////////////////////////////////////////// //#region Types and Constants //////////////////////////////////////////////////////////////////////////////// @@ -803,7 +805,7 @@ export function createBrowserURLImpl( // then we need to avoid the URL constructor treating a leading double slash // as a protocol-less URL. By prepending the base, it forces the double slash // to be parsed correctly as part of the pathname. - if (!isAbsolute && href.startsWith("//")) { + if (!isAbsolute && PROTOCOL_RELATIVE_URL_REGEX.test(href)) { // new URL('//', 'https://localhost') -> error! // new URL('https://localhost//', 'https://localhost') -> no error! href = base + href; diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 713e3dba62..e585aa4140 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -71,6 +71,10 @@ import { removeDoubleSlashes, flattenAndRankRoutes, } from "./utils"; +import { + normalizeProtocolRelativeUrl, + PROTOCOL_RELATIVE_URL_REGEX, +} from "./url"; //////////////////////////////////////////////////////////////////////////////// //#region Types and Constants @@ -6840,8 +6844,10 @@ function normalizeRedirectLocation( if (isAbsoluteUrl(location)) { // Strip off the protocol+origin for same-origin + same-basename absolute redirects let normalizedLocation = location; - let url = normalizedLocation.startsWith("//") - ? new URL(currentUrl.protocol + normalizedLocation) + let url = PROTOCOL_RELATIVE_URL_REGEX.test(normalizedLocation) + ? new URL( + normalizeProtocolRelativeUrl(normalizedLocation, currentUrl.protocol), + ) : new URL(normalizedLocation); if (invalidProtocols.includes(url.protocol)) { throw new Error("Invalid redirect location"); diff --git a/packages/react-router/lib/router/url.ts b/packages/react-router/lib/router/url.ts new file mode 100644 index 0000000000..078b92a292 --- /dev/null +++ b/packages/react-router/lib/router/url.ts @@ -0,0 +1,6 @@ +export const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i; +export const PROTOCOL_RELATIVE_URL_REGEX = /^[\\/]{2}/; + +export function normalizeProtocolRelativeUrl(url: string, protocol: string) { + return protocol + url.replace(/\\/g, "/"); +} diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index c1ec6c55db..0596fffc45 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -3,6 +3,11 @@ import type { MiddlewareEnabled } from "../types/future"; import type { Equal, Expect } from "../types/utils"; import type { Location, Path, To } from "./history"; import { invariant, parsePath, warning } from "./history"; +import { + ABSOLUTE_URL_REGEX, + normalizeProtocolRelativeUrl, + PROTOCOL_RELATIVE_URL_REGEX, +} from "./url"; export type MaybePromise = T | Promise; @@ -1706,7 +1711,6 @@ export function prependBasename({ return pathname === "/" ? basename : joinPaths([basename, pathname]); } -const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; export const isAbsoluteUrl = (url: string) => ABSOLUTE_URL_REGEX.test(url); /** @@ -1901,7 +1905,7 @@ export function resolveTo( } export const removeDoubleSlashes = (path: string): string => - path.replace(/\/\/+/g, "/"); + path.replace(/[\\/]{2,}/g, "/"); export const joinPaths = (paths: string[]): string => removeDoubleSlashes(paths.join("/")); @@ -2223,8 +2227,8 @@ export function parseToInfo( if (isBrowser) { try { let currentUrl = new URL(window.location.href); - let targetUrl = to.startsWith("//") - ? new URL(currentUrl.protocol + to) + let targetUrl = PROTOCOL_RELATIVE_URL_REGEX.test(to) + ? new URL(normalizeProtocolRelativeUrl(to, currentUrl.protocol)) : new URL(to); let path = stripBasename(targetUrl.pathname, basename); diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index b3f841088d..645ca26dc3 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -24,7 +24,8 @@ import type { DataStrategyFunctionArgs, RouterContextProvider, } from "../router/utils"; -import { ErrorResponseImpl, createContext } from "../router/utils"; +import { ErrorResponseImpl, createContext, resolvePath } from "../router/utils"; +import { PROTOCOL_RELATIVE_URL_REGEX } from "../router/url"; import type { DecodedSingleFetchResults, FetchAndDecodeFunction, @@ -146,16 +147,17 @@ export function createCallServer({ Promise.resolve(payloadPromise) .then(async (payload) => { if (payload.type === "redirect") { - if (payload.reload || isExternalLocation(payload.location)) { - if (hasInvalidProtocol(payload.location)) { + let location = normalizeRedirectLocation(payload.location); + if (payload.reload || isExternalLocation(location)) { + if (hasInvalidProtocol(location)) { throw new Error("Invalid redirect location"); } - window.location.href = payload.location; + window.location.href = location; return; } React.startTransition(() => { - globalVar.__reactRouterDataRouter.navigate(payload.location, { + globalVar.__reactRouterDataRouter.navigate(location, { replace: payload.replace, }); }); @@ -173,15 +175,16 @@ export function createCallServer({ globalVar.__routerActionID <= actionId ) { if (rerender.type === "redirect") { - if (rerender.reload || isExternalLocation(rerender.location)) { - if (hasInvalidProtocol(rerender.location)) { + let location = normalizeRedirectLocation(rerender.location); + if (rerender.reload || isExternalLocation(location)) { + if (hasInvalidProtocol(location)) { throw new Error("Invalid redirect location"); } - window.location.href = rerender.location; + window.location.href = location; return; } React.startTransition(() => { - globalVar.__reactRouterDataRouter.navigate(rerender.location, { + globalVar.__reactRouterDataRouter.navigate(location, { replace: rerender.replace, }); }); @@ -1114,6 +1117,15 @@ function hasInvalidProtocol(location: string): boolean { } } +function normalizeRedirectLocation(location: string): string { + if (PROTOCOL_RELATIVE_URL_REGEX.test(location)) { + let path = resolvePath(location); + return path.pathname + path.search + path.hash; + } + + return location; +} + function cloneRoutes(routes: DataRouteObject[] | undefined): DataRouteObject[] { if (!routes) return undefined as any; return routes.map((route) => ({ From 9d22943fd46c8ae4b08236425fa3549e10e9ad1a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 11 Jun 2026 11:16:11 -0400 Subject: [PATCH 2/4] Use turbo stream for framework hydration errors (#15175) * Use turbo stream for framework hydration errors * Avoid serializing internal error response flag --- integration/error-sanitization-test.ts | 28 +++-------- .../patch.framework-errors-turbo-stream.md | 1 + ...tch.restrict-data-error-deserialization.md | 1 + .../dom/data-browser-router-test.tsx | 50 +++++++++++++++++++ .../__tests__/server-runtime/data-test.ts | 30 +++++++++++ packages/react-router/index.ts | 3 -- .../lib/dom-export/hydrated-router.tsx | 6 --- packages/react-router/lib/dom/lib.tsx | 6 ++- packages/react-router/lib/dom/ssr/errors.ts | 49 ------------------ .../react-router/lib/dom/ssr/single-fetch.tsx | 6 +-- packages/react-router/lib/router/utils.ts | 9 ++++ .../react-router/lib/server-runtime/server.ts | 6 +-- .../vendor/turbo-stream-v2/turbo-stream.ts | 2 +- .../vendor/turbo-stream-v2/unflatten.ts | 2 +- .../vendor/turbo-stream-v2/utils.ts | 9 ---- 15 files changed, 111 insertions(+), 97 deletions(-) create mode 100644 packages/react-router/.changes/patch.framework-errors-turbo-stream.md create mode 100644 packages/react-router/.changes/patch.restrict-data-error-deserialization.md delete mode 100644 packages/react-router/lib/dom/ssr/errors.ts diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index 56f6accf30..03134de624 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -179,11 +179,8 @@ test.describe("Error Sanitization", () => { expect(html).toMatch("Index Error"); expect(html).not.toMatch("LOADER"); expect(html).toMatch("MESSAGE:Unexpected Server Error"); - // This is the turbo-stream encoding - the fact that stack goes right - // into __type means it has no value - expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"', - ); + expect(html).toMatch('\\"SanitizedError\\"'); + expect(html).toMatch('\\"Error\\",\\"Unexpected Server Error\\"'); expect(html).not.toMatch(/ at /i); expect(errorLogs.length).toBe(1); expect(errorLogs[0][0].message).toMatch("Loader Error"); @@ -195,11 +192,8 @@ test.describe("Error Sanitization", () => { let html = await response.text(); expect(html).toMatch("Index Error"); expect(html).toMatch("MESSAGE:Unexpected Server Error"); - // This is the turbo-stream encoding - the fact that stack goes right - // into __type means it has no value - expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"', - ); + expect(html).toMatch('\\"SanitizedError\\"'); + expect(html).toMatch('\\"Error\\",\\"Unexpected Server Error\\"'); expect(html).not.toMatch(/ at /i); expect(errorLogs.length).toBe(1); expect(errorLogs[0][0].message).toMatch("Render Error"); @@ -571,11 +565,8 @@ test.describe("Error Sanitization", () => { expect(html).toMatch("Index Error"); expect(html).not.toMatch("LOADER"); expect(html).toMatch("MESSAGE:Unexpected Server Error"); - // This is the turbo-stream encoding - the fact that stack goes right - // into __type means it has no value - expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"', - ); + expect(html).toMatch('\\"SanitizedError\\"'); + expect(html).toMatch('\\"Error\\",\\"Unexpected Server Error\\"'); expect(html).not.toMatch(/ at /i); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?loader"); @@ -589,11 +580,8 @@ test.describe("Error Sanitization", () => { let html = await response.text(); expect(html).toMatch("Index Error"); expect(html).toMatch("MESSAGE:Unexpected Server Error"); - // This is the turbo-stream encoding - the fact that stack goes right - // into __type means it has no value - expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"', - ); + expect(html).toMatch('\\"SanitizedError\\"'); + expect(html).toMatch('\\"Error\\",\\"Unexpected Server Error\\"'); expect(html).not.toMatch(/ at /i); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?render"); diff --git a/packages/react-router/.changes/patch.framework-errors-turbo-stream.md b/packages/react-router/.changes/patch.framework-errors-turbo-stream.md new file mode 100644 index 0000000000..90a935a87a --- /dev/null +++ b/packages/react-router/.changes/patch.framework-errors-turbo-stream.md @@ -0,0 +1 @@ +Use `turbo-stream` to serialize and deserialize Framework Mode hydration errors diff --git a/packages/react-router/.changes/patch.restrict-data-error-deserialization.md b/packages/react-router/.changes/patch.restrict-data-error-deserialization.md new file mode 100644 index 0000000000..6879c3fb1c --- /dev/null +++ b/packages/react-router/.changes/patch.restrict-data-error-deserialization.md @@ -0,0 +1 @@ +Remove the un-documented custom error serialization logic from Data Mode SSR built-in hydration flows diff --git a/packages/react-router/__tests__/dom/data-browser-router-test.tsx b/packages/react-router/__tests__/dom/data-browser-router-test.tsx index fa7ebbd07e..f8c33542c2 100644 --- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx @@ -8103,6 +8103,56 @@ function testDomRouter( `); }); + it("does not deserialize custom Error subclass instances from the window", () => { + try { + (window as any).CustomError = class CustomError extends Error {}; + window.__staticRouterHydrationData = { + loaderData: {}, + actionData: null, + errors: { + "0": { + message: "custom error message", + __type: "Error", + __subType: "CustomError", + }, + }, + }; + let router = createTestRouter([ + { + path: "/", + Component: () =>

Nope

, + ErrorBoundary: () => , + }, + ]); + let { container } = render(); + + function Boundary() { + let error = useRouteError() as Error; + return error instanceof Error ? ( + <> +
{error.constructor.name}
+
{error.toString()}
+ + ) : ( +

No :(

+ ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+
+                Error
+              
+
+                Error: custom error message
+              
+
" + `); + } finally { + delete (window as any).CustomError; + } + }); + it("renders hydration errors on leaf elements", async () => { let router = createTestRouter( [ diff --git a/packages/react-router/__tests__/server-runtime/data-test.ts b/packages/react-router/__tests__/server-runtime/data-test.ts index bf196b458f..2eba5adefe 100644 --- a/packages/react-router/__tests__/server-runtime/data-test.ts +++ b/packages/react-router/__tests__/server-runtime/data-test.ts @@ -1,5 +1,11 @@ import { decodeViaTurboStream } from "../../lib/dom/ssr/single-fetch"; +import { + ErrorResponseImpl, + isRouteErrorResponse, +} from "../../lib/router/utils"; import { createRequestHandler } from "../../lib/server-runtime/server"; +import { encodeViaTurboStream } from "../../lib/server-runtime/single-fetch"; +import { ServerMode } from "../../lib/server-runtime/mode"; import { mockServerBuild } from "./utils"; describe("loaders", () => { @@ -33,3 +39,27 @@ describe("loaders", () => { expect((decoded.value as any)[routeId].data).toEqual("/random"); }); }); + +describe("turbo-stream error decoding", () => { + it("decodes ErrorResponse instances", async () => { + let body = encodeViaTurboStream( + { + errors: { + root: new ErrorResponseImpl(404, "Not Found", "Missing", true), + }, + }, + new AbortController().signal, + undefined, + ServerMode.Development, + ); + + let decoded = await decodeViaTurboStream(body, global); + let error = (decoded.value as any).errors.root; + + expect(isRouteErrorResponse(error)).toBe(true); + expect(error.status).toBe(404); + expect(error.statusText).toBe("Not Found"); + expect(error.data).toBe("Missing"); + expect(error.internal).toBe(false); + }); +}); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 681ecf7ec4..b49c3e8d32 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -395,9 +395,6 @@ export { FrameworkContext as UNSAFE_FrameworkContext } from "./lib/dom/ssr/compo /** @internal */ export type { AssetsManifest as UNSAFE_AssetsManifest } from "./lib/dom/ssr/entry"; -/** @internal */ -export { deserializeErrors as UNSAFE_deserializeErrors } from "./lib/dom/ssr/errors"; - /** @internal */ export { RemixErrorBoundary as UNSAFE_RemixErrorBoundary } from "./lib/dom/ssr/errorBoundaries"; diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 9183feb344..3d4b8b9649 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -17,7 +17,6 @@ import { UNSAFE_createBrowserHistory as createBrowserHistory, UNSAFE_createClientRoutes as createClientRoutes, UNSAFE_createRouter as createRouter, - UNSAFE_deserializeErrors as deserializeErrors, UNSAFE_getTurboStreamSingleFetchDataStrategy as getTurboStreamSingleFetchDataStrategy, UNSAFE_getPatchRoutesOnNavigationFunction as getPatchRoutesOnNavigationFunction, UNSAFE_useFogOFWarDiscovery as useFogOFWarDiscovery, @@ -159,11 +158,6 @@ function createHydratedRouter({ isSpaMode: ssrInfo.context.isSpaMode, }); - if (hydrationData && hydrationData.errors) { - // TODO: De-dup this or remove entirely in v7 where single fetch is the - // only approach and we have already serialized or deserialized on the server - hydrationData.errors = deserializeErrors(hydrationData.errors); - } } // We cannot support history-state-driven masking with SSR, so if a hard diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 298c809824..58937241b7 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -36,6 +36,7 @@ import type { } from "../router/utils"; import { ErrorResponseImpl, + SUPPORTED_ERROR_TYPES, joinPaths, matchPath, parseToInfo, @@ -740,7 +741,10 @@ function deserializeErrors( ); } else if (val && val.__type === "Error") { // Attempt to reconstruct the right type of Error (i.e., ReferenceError) - if (val.__subType) { + if ( + typeof val.__subType === "string" && + SUPPORTED_ERROR_TYPES.includes(val.__subType) + ) { let ErrorConstructor = window[val.__subType]; if (typeof ErrorConstructor === "function") { try { diff --git a/packages/react-router/lib/dom/ssr/errors.ts b/packages/react-router/lib/dom/ssr/errors.ts deleted file mode 100644 index 3bdd82c414..0000000000 --- a/packages/react-router/lib/dom/ssr/errors.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { RouterState } from "../../router/router"; -import { ErrorResponseImpl } from "../../router/utils"; - -export function deserializeErrors( - errors: RouterState["errors"], -): RouterState["errors"] { - if (!errors) return null; - let entries = Object.entries(errors); - let serialized: RouterState["errors"] = {}; - for (let [key, val] of entries) { - // Hey you! If you change this, please change the corresponding logic in - // serializeErrors in react-router/lib/server-runtime/errors.ts :) - if (val && val.__type === "RouteErrorResponse") { - serialized[key] = new ErrorResponseImpl( - val.status, - val.statusText, - val.data, - val.internal === true, - ); - } else if (val && val.__type === "Error") { - // Attempt to reconstruct the right type of Error (i.e., ReferenceError) - if (val.__subType) { - let ErrorConstructor = window[val.__subType]; - if (typeof ErrorConstructor === "function") { - try { - // @ts-expect-error - let error = new ErrorConstructor(val.message); - error.stack = val.stack; - serialized[key] = error; - } catch ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - e - ) { - // no-op - fall through and create a normal Error - } - } - } - - if (serialized[key] == null) { - let error = new Error(val.message); - error.stack = val.stack; - serialized[key] = error; - } - } else { - serialized[key] = val; - } - } - return serialized; -} diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 2860c51267..42fd58b0e1 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -1,9 +1,6 @@ import * as React from "react"; -import { - SUPPORTED_ERROR_TYPES, - decode, -} from "../../../vendor/turbo-stream-v2/turbo-stream"; +import { decode } from "../../../vendor/turbo-stream-v2/turbo-stream"; import type { Router as DataRouter } from "../../router/router"; import { isDataWithResponseInit, isResponse } from "../../router/router"; import type { @@ -14,6 +11,7 @@ import type { } from "../../router/utils"; import { ErrorResponseImpl, + SUPPORTED_ERROR_TYPES, isRouteErrorResponse, redirect, data, diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 0596fffc45..f1eeb0c292 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -2109,6 +2109,15 @@ export type ErrorResponse = { data: any; }; +export const SUPPORTED_ERROR_TYPES = [ + "EvalError", + "RangeError", + "ReferenceError", + "SyntaxError", + "TypeError", + "URIError", +]; + /* * Utility class we use to hold auto-unwrapped 4xx/5xx Response bodies * diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index dcc15f5869..8cb9d16c5a 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -21,7 +21,7 @@ import type { AppLoadContext } from "./data"; import type { HandleErrorFunction, ServerBuild } from "./build"; import type { CriticalCss, EntryContext } from "../dom/ssr/entry"; import { createEntryRouteModules } from "./entry"; -import { sanitizeErrors, serializeError, serializeErrors } from "./errors"; +import { sanitizeErrors, serializeError } from "./errors"; import { ServerMode, isServerMode } from "./mode"; import type { RouteMatch } from "./routeMatching"; import { matchServerRoutes } from "./routeMatching"; @@ -550,7 +550,7 @@ async function handleDocumentRequest( let state = { loaderData: context.loaderData, actionData: context.actionData, - errors: serializeErrors(context.errors, serverMode), + errors: context.errors, }; let baseServerHandoff: ServerHandoff = { basename: build.basename, @@ -636,7 +636,7 @@ async function handleDocumentRequest( let state = { loaderData: context.loaderData, actionData: context.actionData, - errors: serializeErrors(context.errors, serverMode), + errors: context.errors, }; entryContext = { ...entryContext, diff --git a/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts index 3128162d86..791a75836d 100644 --- a/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts +++ b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts @@ -1,8 +1,8 @@ +import { SUPPORTED_ERROR_TYPES } from "../../lib/router/utils"; import { flatten } from "./flatten"; import { unflatten } from "./unflatten"; import { Deferred, - SUPPORTED_ERROR_TYPES, TYPE_ERROR, TYPE_PREVIOUS_RESOLVED, TYPE_PROMISE, diff --git a/packages/react-router/vendor/turbo-stream-v2/unflatten.ts b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts index 86e45358ad..0c146a930f 100644 --- a/packages/react-router/vendor/turbo-stream-v2/unflatten.ts +++ b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts @@ -1,3 +1,4 @@ +import { SUPPORTED_ERROR_TYPES } from "../../lib/router/utils"; import { Deferred, HOLE, @@ -19,7 +20,6 @@ import { TYPE_SYMBOL, TYPE_URL, type ThisDecode, - SUPPORTED_ERROR_TYPES, } from "./utils"; const globalObj = ( diff --git a/packages/react-router/vendor/turbo-stream-v2/utils.ts b/packages/react-router/vendor/turbo-stream-v2/utils.ts index 51ccc7765f..e091c55318 100644 --- a/packages/react-router/vendor/turbo-stream-v2/utils.ts +++ b/packages/react-router/vendor/turbo-stream-v2/utils.ts @@ -44,15 +44,6 @@ export interface ThisEncode { signal?: AbortSignal; } -export const SUPPORTED_ERROR_TYPES = [ - "EvalError", - "RangeError", - "ReferenceError", - "SyntaxError", - "TypeError", - "URIError", -]; - export class Deferred { promise: Promise; resolve!: (value: T) => void; From 1cebd2a823bb232ad74dcb2d970f750070b2bebe Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Thu, 11 Jun 2026 15:20:48 +0000 Subject: [PATCH 3/4] chore: format --- packages/react-router/lib/dom-export/hydrated-router.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 3d4b8b9649..da380b2fbb 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -157,7 +157,6 @@ function createHydratedRouter({ basename: window.__reactRouterContext?.basename, isSpaMode: ssrInfo.context.isSpaMode, }); - } // We cannot support history-state-driven masking with SSR, so if a hard From ce596e823f0d7b883a433af1d5a839a8b9fe0242 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 11 Jun 2026 12:37:38 -0400 Subject: [PATCH 4/4] Validate RSC redirect protocols (#15177) --- integration/rsc/rsc-nojs-test.ts | 31 +++++++++++++++++ integration/rsc/rsc-test.ts | 34 +++++++++++++++++++ .../.changes/patch.rsc-redirect-protocols.md | 1 + packages/react-router/lib/hooks.tsx | 16 ++++----- packages/react-router/lib/router/router.ts | 12 +++++-- packages/react-router/lib/rsc/browser.tsx | 10 +----- packages/react-router/lib/rsc/server.ssr.tsx | 29 ++++++++++++++++ 7 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 packages/react-router/.changes/patch.rsc-redirect-protocols.md diff --git a/integration/rsc/rsc-nojs-test.ts b/integration/rsc/rsc-nojs-test.ts index 9e8274ce2c..8f6d8babed 100644 --- a/integration/rsc/rsc-nojs-test.ts +++ b/integration/rsc/rsc-nojs-test.ts @@ -115,11 +115,16 @@ implementations.forEach((implementation) => { throw redirect("https://example.com/"); } + if (id === "unsupported-protocol") { + throw redirect("about:blank"); + } + return ( <>

{id || "home"}

Redirect External + Unsupported ) } @@ -147,11 +152,16 @@ implementations.forEach((implementation) => { throw redirect("https://example.com/"); } + if (id === "unsupported-protocol") { + throw redirect("about:blank"); + } + return ( <>

{id || "home"}

Redirect External + Unsupported ); } @@ -234,6 +244,16 @@ implementations.forEach((implementation) => { await expect(page.getByText("Example Domain")).toBeAttached(); }); + test("Handles unsupported protocol redirect Responses from render", async ({ + page, + }) => { + let response = await page.request.get( + `http://localhost:${port}/render-redirect/unsupported-protocol`, + { maxRedirects: 0 }, + ); + expect(response.headers()["location"]).not.toBe("about:blank"); + }); + test("Suppport throwing redirect Response from suspended render", async ({ page, }) => { @@ -256,5 +276,16 @@ implementations.forEach((implementation) => { await page.waitForURL(`https://example.com/`); await expect(page.getByText("Example Domain")).toBeAttached(); }); + + test("Handles unsupported protocol redirect Responses from suspended render", async ({ + page, + }) => { + let response = await page.request.get( + `http://localhost:${port}/render-redirect/lazy/unsupported-protocol`, + ); + expect(await response.text()).not.toContain( + '

{id || "home"}

Redirect External + Unsupported ) } @@ -1522,11 +1527,16 @@ implementations.forEach((implementation) => { throw redirect("https://example.com/") } + if (id === "unsupported-protocol") { + throw redirect("about:blank") + } + return ( <>

{id || "home"}

Redirect External + Unsupported ); } @@ -1867,6 +1877,18 @@ implementations.forEach((implementation) => { await expect(page.getByText("Example Domain")).toBeAttached(); }); + test("Handles unsupported protocol redirect Responses from render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("Unsupported").click(); + await page.waitForTimeout(500); + await expect(page).toHaveURL( + `http://localhost:${port}/render-redirect/unsupported-protocol`, + ); + }); + test("Suppport throwing redirect Response from suspended render", async ({ page, }) => { @@ -1894,6 +1916,18 @@ implementations.forEach((implementation) => { await expect(page.getByText("Example Domain")).toBeAttached(); }); + test("Handles unsupported protocol redirect Responses from suspended render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect/lazy`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("Unsupported").click(); + await page.waitForTimeout(500); + await expect(page).toHaveURL( + `http://localhost:${port}/render-redirect/lazy/unsupported-protocol`, + ); + }); + test("Support throwing Responses", async ({ page }) => { await page.goto( `http://localhost:${port}/render-route-error-response`, diff --git a/packages/react-router/.changes/patch.rsc-redirect-protocols.md b/packages/react-router/.changes/patch.rsc-redirect-protocols.md new file mode 100644 index 0000000000..624bb35e87 --- /dev/null +++ b/packages/react-router/.changes/patch.rsc-redirect-protocols.md @@ -0,0 +1 @@ +Validate protocols in RSC render redirects diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index fb01bff674..5a40a17b30 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -26,7 +26,7 @@ import type { RevalidationState, NavigationStates, } from "./router/router"; -import { IDLE_BLOCKER } from "./router/router"; +import { hasInvalidProtocol, IDLE_BLOCKER } from "./router/router"; import type { DataRouteMatch, ParamParseKey, @@ -1118,6 +1118,7 @@ export class RenderErrorBoundary extends React.Component< } const errorRedirectHandledMap = new WeakMap>(); + function RSCErrorHandler({ children, error, @@ -1139,10 +1140,14 @@ function RSCErrorHandler({ if (existingRedirect) throw existingRedirect; let parsed = parseToInfo(redirect.location, basename); + let target = parsed.absoluteURL || parsed.to; + if (hasInvalidProtocol(target)) { + throw new Error("Invalid redirect location"); + } if (isBrowser && !errorRedirectHandledMap.get(error)) { if (parsed.isExternal || redirect.reloadDocument) { - window.location.href = parsed.absoluteURL || parsed.to; + window.location.href = target; } else { const redirectPromise: Promise = Promise.resolve().then(() => window.__reactRouterDataRouter!.navigate(parsed.to, { @@ -1154,12 +1159,7 @@ function RSCErrorHandler({ } } - return ( - - ); + return ; } } return children; diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index e585aa4140..f90f4fb1ed 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -6835,6 +6835,14 @@ export const invalidProtocols = [ "javascript:", ]; +export function hasInvalidProtocol(location: string): boolean { + try { + return invalidProtocols.includes(new URL(location).protocol); + } catch { + return false; + } +} + function normalizeRedirectLocation( location: string, currentUrl: URL, @@ -6849,7 +6857,7 @@ function normalizeRedirectLocation( normalizeProtocolRelativeUrl(normalizedLocation, currentUrl.protocol), ) : new URL(normalizedLocation); - if (invalidProtocols.includes(url.protocol)) { + if (hasInvalidProtocol(url.toString())) { throw new Error("Invalid redirect location"); } let isSameBasename = stripBasename(url.pathname, basename) != null; @@ -6860,7 +6868,7 @@ function normalizeRedirectLocation( try { let url = historyInstance.createURL(location); - if (invalidProtocols.includes(url.protocol)) { + if (hasInvalidProtocol(url.toString())) { throw new Error("Invalid redirect location"); } } catch ( diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 645ca26dc3..9db9982f0a 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -9,7 +9,7 @@ import { createBrowserHistory, invariant } from "../router/history"; import type { Router as DataRouter, RouterInit } from "../router/router"; import { createRouter, - invalidProtocols, + hasInvalidProtocol, isMutationMethod, } from "../router/router"; import type { @@ -1109,14 +1109,6 @@ function isExternalLocation(location: string) { return newLocation.origin !== window.location.origin; } -function hasInvalidProtocol(location: string): boolean { - try { - return invalidProtocols.includes(new URL(location).protocol); - } catch { - return false; - } -} - function normalizeRedirectLocation(location: string): string { if (PROTOCOL_RELATIVE_URL_REGEX.test(location)) { let path = resolvePath(location); diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index a082abf9f3..317c714365 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -10,6 +10,7 @@ import { shouldHydrateRouteLoader } from "../dom/ssr/routes"; import type { RSCPayload } from "./server.rsc"; import { createRSCRouteModules } from "./route-modules"; import { isRouteErrorResponse, type DataRouteObject } from "../router/utils"; +import { hasInvalidProtocol } from "../router/router"; import { decodeRedirectErrorDigest, decodeRouteErrorResponseDigest, @@ -202,6 +203,10 @@ export async function routeRSCServerRequest({ serverResponse.status === SINGLE_FETCH_REDIRECT_STATUS && payload.type === "redirect" ) { + if (hasInvalidProtocol(payload.location)) { + throw new Error("Invalid redirect location"); + } + const headers = new Headers(serverResponse.headers); headers.delete("Content-Encoding"); headers.delete("Content-Length"); @@ -255,6 +260,10 @@ export async function routeRSCServerRequest({ headers.set("Content-Type", "text/html; charset=utf-8"); if (renderRedirect) { + if (hasInvalidProtocol(renderRedirect.location)) { + throw new Error("Invalid redirect location"); + } + headers.set("Location", renderRedirect.location); return new Response(html, { status: renderRedirect.status, @@ -265,6 +274,10 @@ export async function routeRSCServerRequest({ const redirectTransform = new TransformStream({ flush(controller) { if (renderRedirect) { + if (hasInvalidProtocol(renderRedirect.location)) { + return; + } + controller.enqueue( new TextEncoder().encode( ``, @@ -300,6 +313,10 @@ export async function routeRSCServerRequest({ } if (renderRedirect) { + if (hasInvalidProtocol(renderRedirect.location)) { + throw new Error("Invalid redirect location"); + } + return new Response(`Redirect: ${renderRedirect.location}`, { status: renderRedirect.status, headers: { @@ -388,6 +405,10 @@ export async function routeRSCServerRequest({ headers.set("Content-Type", "text/html; charset=utf-8"); if (retryRedirect) { + if (hasInvalidProtocol(retryRedirect.location)) { + throw new Error("Invalid redirect location"); + } + headers.set("Location", retryRedirect.location); return new Response(html, { status: retryRedirect.status, @@ -398,6 +419,10 @@ export async function routeRSCServerRequest({ const retryRedirectTransform = new TransformStream({ flush(controller) { if (retryRedirect) { + if (hasInvalidProtocol(retryRedirect.location)) { + return; + } + controller.enqueue( new TextEncoder().encode( ``, @@ -495,6 +520,10 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { const payload = useSafe(decoded); if (payload.type === "redirect") { + if (hasInvalidProtocol(payload.location)) { + throw new Error("Invalid redirect location"); + } + throw new Response(null, { status: payload.status, headers: {