Skip to content

Minor refactors to support RSC #13423

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ export {
} from "./lib/dom/ssr/routes";

/** @internal */
export { getSingleFetchDataStrategy as UNSAFE_getSingleFetchDataStrategy } from "./lib/dom/ssr/single-fetch";
export { getTurboStreamSingleFetchDataStrategy as UNSAFE_getTurboStreamSingleFetchDataStrategy } from "./lib/dom/ssr/single-fetch";

/** @internal */
export {
Expand Down
75 changes: 16 additions & 59 deletions packages/react-router/lib/dom-export/hydrated-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
UNSAFE_createClientRoutes as createClientRoutes,
UNSAFE_createRouter as createRouter,
UNSAFE_deserializeErrors as deserializeErrors,
UNSAFE_getSingleFetchDataStrategy as getSingleFetchDataStrategy,
UNSAFE_getTurboStreamSingleFetchDataStrategy as getTurboStreamSingleFetchDataStrategy,
UNSAFE_getPatchRoutesOnNavigationFunction as getPatchRoutesOnNavigationFunction,
UNSAFE_shouldHydrateRouteLoader as shouldHydrateRouteLoader,
UNSAFE_useFogOFWarDiscovery as useFogOFWarDiscovery,
Expand All @@ -25,6 +25,7 @@ import {
UNSAFE_createClientRoutesWithHMRRevalidationOptOut as createClientRoutesWithHMRRevalidationOptOut,
matchRoutes,
} from "react-router";
import { getHydrationData } from "../dom/ssr/hydration";
import { RouterProvider } from "./dom-router-provider";

type SSRInfo = {
Expand Down Expand Up @@ -126,9 +127,9 @@ function createHydratedRouter({
);

let hydrationData: HydrationState | undefined = undefined;
let loaderData = ssrInfo.context.state.loaderData;
// In SPA mode we only hydrate build-time root loader data
if (ssrInfo.context.isSpaMode) {
let { loaderData } = ssrInfo.context.state;
if (
ssrInfo.manifest.routes.root?.hasLoader &&
loaderData &&
Expand All @@ -141,51 +142,19 @@ function createHydratedRouter({
};
}
} else {
// Create a shallow clone of `loaderData` we can mutate for partial hydration.
// When a route exports a `clientLoader` and a `HydrateFallback`, the SSR will
// render the fallback so we need the client to do the same for hydration.
// The server loader data has already been exposed to these route `clientLoader`'s
// in `createClientRoutes` above, so we need to clear out the version we pass to
// `createBrowserRouter` so it initializes and runs the client loaders.
hydrationData = {
...ssrInfo.context.state,
loaderData: { ...loaderData },
};
let initialMatches = matchRoutes(
hydrationData = getHydrationData(
ssrInfo.context.state,
routes,
(routeId) => ({
clientLoader: ssrInfo!.routeModules[routeId]?.clientLoader,
hasLoader: ssrInfo!.manifest.routes[routeId]?.hasLoader === true,
hasHydrateFallback:
ssrInfo!.routeModules[routeId]?.HydrateFallback != null,
}),
window.location,
window.__reactRouterContext?.basename
window.__reactRouterContext?.basename,
ssrInfo.context.isSpaMode
);
if (initialMatches) {
for (let match of initialMatches) {
let routeId = match.route.id;
let route = ssrInfo.routeModules[routeId];
let manifestRoute = ssrInfo.manifest.routes[routeId];
// Clear out the loaderData to avoid rendering the route component when the
// route opted into clientLoader hydration and either:
// * gave us a HydrateFallback
// * or doesn't have a server loader and we have no data to render
if (
route &&
manifestRoute &&
shouldHydrateRouteLoader(
manifestRoute,
route,
ssrInfo.context.isSpaMode
) &&
(route.HydrateFallback || !manifestRoute.hasLoader)
) {
delete hydrationData.loaderData![routeId];
} else if (manifestRoute && !manifestRoute.hasLoader) {
// Since every Remix route gets a `loader` on the client side to load
// the route JS module, we need to add a `null` value to `loaderData`
// for any routes that don't have server loaders so our partial
// hydration logic doesn't kick off the route module loaders during
// hydration
hydrationData.loaderData![routeId] = null;
}
}
}

if (hydrationData && hydrationData.errors) {
// TODO: De-dup this or remove entirely in v7 where single fetch is the
Expand All @@ -207,22 +176,10 @@ function createHydratedRouter({
future: {
unstable_middleware: ssrInfo.context.future.unstable_middleware,
},
dataStrategy: getSingleFetchDataStrategy(
dataStrategy: getTurboStreamSingleFetchDataStrategy(
() => router,
(routeId: string) => {
let manifestRoute = ssrInfo!.manifest.routes[routeId];
invariant(manifestRoute, "Route not found in manifest/routeModules");
let routeModule = ssrInfo!.routeModules[routeId];
return {
hasLoader: manifestRoute.hasLoader,
hasClientLoader: manifestRoute.hasClientLoader,
// In some cases the module may not be loaded yet and we don't care
// if it's got shouldRevalidate or not
hasShouldRevalidate: routeModule
? routeModule.shouldRevalidate != null
: undefined,
};
},
ssrInfo.manifest,
ssrInfo.routeModules,
ssrInfo.context.ssr,
ssrInfo.context.basename
),
Expand Down
65 changes: 65 additions & 0 deletions packages/react-router/lib/dom/ssr/hydration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { DataRouteObject } from "../../context";
import type { Path } from "../../router/history";
import type { Router as DataRouter, HydrationState } from "../../router/router";
import { matchRoutes } from "../../router/utils";
import type { ClientLoaderFunction } from "./routeModules";
import { shouldHydrateRouteLoader } from "./routes";

export function getHydrationData(
state: {
loaderData?: DataRouter["state"]["loaderData"];
actionData?: DataRouter["state"]["actionData"];
errors?: DataRouter["state"]["errors"];
},
routes: DataRouteObject[],
getRouteInfo: (routeId: string) => {
clientLoader: ClientLoaderFunction | undefined;
hasLoader: boolean;
hasHydrateFallback: boolean;
},
location: Path,
basename: string | undefined,
isSpaMode: boolean
): HydrationState {
// Create a shallow clone of `loaderData` we can mutate for partial hydration.
// When a route exports a `clientLoader` and a `HydrateFallback`, the SSR will
// render the fallback so we need the client to do the same for hydration.
// The server loader data has already been exposed to these route `clientLoader`'s
// in `createClientRoutes` above, so we need to clear out the version we pass to
// `createBrowserRouter` so it initializes and runs the client loaders.
let hydrationData = {
...state,
loaderData: { ...state.loaderData },
};
let initialMatches = matchRoutes(routes, location, basename);
if (initialMatches) {
for (let match of initialMatches) {
let routeId = match.route.id;
let routeInfo = getRouteInfo(routeId);
// Clear out the loaderData to avoid rendering the route component when the
// route opted into clientLoader hydration and either:
// * gave us a HydrateFallback
// * or doesn't have a server loader and we have no data to render
if (
shouldHydrateRouteLoader(
routeId,
routeInfo.clientLoader,
routeInfo.hasLoader,
isSpaMode
) &&
(routeInfo.hasHydrateFallback || !routeInfo.hasLoader)
) {
delete hydrationData.loaderData![routeId];
} else if (!routeInfo.hasLoader) {
// Since every Remix route gets a `loader` on the client side to load
// the route JS module, we need to add a `null` value to `loaderData`
// for any routes that don't have server loaders so our partial
// hydration logic doesn't kick off the route module loaders during
// hydration
hydrationData.loaderData![routeId] = null;
}
}
}

return hydrationData;
}
22 changes: 14 additions & 8 deletions packages/react-router/lib/dom/ssr/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import type {
ShouldRevalidateFunctionArgs,
} from "../../router/utils";
import { ErrorResponseImpl, compilePath } from "../../router/utils";
import type { RouteModule, RouteModules } from "./routeModules";
import type {
ClientLoaderFunction,
RouteModule,
RouteModules,
} from "./routeModules";
import { loadRouteModule } from "./routeModules";
import type { FutureConfig } from "./entry";
import { prefetchRouteCss, prefetchStyleLinks } from "./links";
Expand Down Expand Up @@ -382,8 +386,9 @@ export function createClientRoutes(

// Let React Router know whether to run this on hydration
dataRoute.loader.hydrate = shouldHydrateRouteLoader(
route,
routeModule,
route.id,
routeModule.clientLoader,
route.hasLoader,
isSpaMode
);

Expand Down Expand Up @@ -676,13 +681,14 @@ function getRouteModuleComponent(routeModule: RouteModule) {
}

export function shouldHydrateRouteLoader(
route: EntryRoute,
routeModule: RouteModule,
routeId: string,
clientLoader: ClientLoaderFunction | undefined,
hasLoader: boolean,
isSpaMode: boolean
) {
return (
(isSpaMode && route.id !== "root") ||
(routeModule.clientLoader != null &&
(routeModule.clientLoader.hydrate === true || route.hasLoader !== true))
(isSpaMode && routeId !== "root") ||
(clientLoader != null &&
(clientLoader.hydrate === true || hasLoader !== true))
);
}
7 changes: 6 additions & 1 deletion packages/react-router/lib/dom/ssr/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ export function ServerRouter({
if (
route &&
manifestRoute &&
shouldHydrateRouteLoader(manifestRoute, route, context.isSpaMode) &&
shouldHydrateRouteLoader(
routeId,
route.clientLoader,
manifestRoute.hasLoader,
context.isSpaMode
) &&
(route.HydrateFallback || !manifestRoute.hasLoader)
) {
delete context.staticHandlerContext.loaderData[routeId];
Expand Down
42 changes: 27 additions & 15 deletions packages/react-router/lib/dom/ssr/single-fetch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import {
stripBasename,
} from "../../router/utils";
import { createRequestInit } from "./data";
import type { EntryContext } from "./entry";
import type { AssetsManifest, EntryContext } from "./entry";
import { escapeHtml } from "./markup";
import invariant from "./invariant";
import type { RouteModules } from "./routeModules";
import type { DataRouteMatch } from "../../context";

export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect");

Expand Down Expand Up @@ -147,10 +149,10 @@ export function StreamTransfer({
}
}

type GetRouteInfoFunction = (routeId: string) => {
type GetRouteInfoFunction = (match: DataRouteMatch) => {
hasLoader: boolean;
hasClientLoader: boolean; // TODO: Can this be read from match.route?
hasShouldRevalidate: boolean | undefined; // TODO: Can this be read from match.route?
hasClientLoader: boolean;
hasShouldRevalidate: boolean;
};

type FetchAndDecodeFunction = (
Expand All @@ -159,23 +161,33 @@ type FetchAndDecodeFunction = (
targetRoutes?: string[]
) => Promise<{ status: number; data: DecodedSingleFetchResults }>;

export function getSingleFetchDataStrategy(
export function getTurboStreamSingleFetchDataStrategy(
getRouter: () => DataRouter,
getRouteInfo: GetRouteInfoFunction,
manifest: AssetsManifest,
routeModules: RouteModules,
ssr: boolean,
basename: string | undefined
): DataStrategyFunction {
let dataStrategy = getSingleFetchDataStrategyImpl(
let dataStrategy = getTurboStreamSingleFetchDataStrategyImpl(
getRouter,
getRouteInfo,
(match: DataRouteMatch) => {
let manifestRoute = manifest.routes[match.route.id];
invariant(manifestRoute, "Route not found in manifest");
let routeModule = routeModules[match.route.id];
return {
hasLoader: manifestRoute.hasLoader,
hasClientLoader: manifestRoute.hasClientLoader,
hasShouldRevalidate: Boolean(routeModule?.shouldRevalidate),
};
},
fetchAndDecodeViaTurboStream,
ssr,
basename
);
return async (args) => args.unstable_runClientMiddleware(dataStrategy);
}

export function getSingleFetchDataStrategyImpl(
export function getTurboStreamSingleFetchDataStrategyImpl(
getRouter: () => DataRouter,
getRouteInfo: GetRouteInfoFunction,
fetchAndDecode: FetchAndDecodeFunction,
Expand All @@ -192,7 +204,7 @@ export function getSingleFetchDataStrategyImpl(
}

let foundRevalidatingServerLoader = matches.some((m) => {
let { hasLoader, hasClientLoader } = getRouteInfo(m.route.id);
let { hasLoader, hasClientLoader } = getRouteInfo(m);
return m.unstable_shouldCallHandler() && hasLoader && !hasClientLoader;
});
if (!ssr && !foundRevalidatingServerLoader) {
Expand Down Expand Up @@ -298,7 +310,7 @@ async function nonSsrStrategy(
matchesToLoad.map((m) =>
m.resolve(async (handler) => {
try {
let { hasClientLoader } = getRouteInfo(m.route.id);
let { hasClientLoader } = getRouteInfo(m);
// Need to pass through a `singleFetch` override handler so
// clientLoader's can still call server loaders through `.data`
// requests
Expand Down Expand Up @@ -350,7 +362,7 @@ async function singleFetchLoaderNavigationStrategy(
routeDfds[i].resolve();
let routeId = m.route.id;
let { hasLoader, hasClientLoader, hasShouldRevalidate } =
getRouteInfo(routeId);
getRouteInfo(m);

let defaultShouldRevalidate =
!m.unstable_shouldRevalidateArgs ||
Expand Down Expand Up @@ -419,7 +431,7 @@ async function singleFetchLoaderNavigationStrategy(
(!router.state.initialized || routesParams.size === 0) &&
!window.__reactRouterHdrActive
) {
singleFetchDfd.resolve({});
singleFetchDfd.resolve({ routes: {} });
} else {
// When routes have opted out, add a `_routes` param to filter server loaders
// Skipped in `ssr:false` because we expect to be loading static `.data` files
Expand Down Expand Up @@ -659,8 +671,8 @@ function unwrapSingleFetchResult(
}

function createDeferred<T = unknown>() {
let resolve: (val?: any) => Promise<void>;
let reject: (error?: unknown) => Promise<void>;
let resolve: (val: T) => Promise<void>;
let reject: (error: unknown) => Promise<void>;
let promise = new Promise<T>((res, rej) => {
resolve = async (val: T) => {
res(val);
Expand Down