diff --git a/packages/react-router-dom-v5-compat/package.json b/packages/react-router-dom-v5-compat/package.json index 8d6224f0c7..f4f010eb0a 100644 --- a/packages/react-router-dom-v5-compat/package.json +++ b/packages/react-router-dom-v5-compat/package.json @@ -24,8 +24,10 @@ "types": "./dist/index.d.ts", "dependencies": { "@remix-run/router": "workspace:*", + "@remix-run/server-runtime": "workspace:*", "history": "^5.3.0", - "react-router": "workspace:*" + "react-router": "workspace:*", + "turbo-stream": "^2.0.0" }, "peerDependencies": { "react": ">=16.8", diff --git a/packages/react-router-dom-v5-compat/rollup.config.js b/packages/react-router-dom-v5-compat/rollup.config.js index 37ef230b6a..efe827bc81 100644 --- a/packages/react-router-dom-v5-compat/rollup.config.js +++ b/packages/react-router-dom-v5-compat/rollup.config.js @@ -17,13 +17,7 @@ const { name, version } = require("./package.json"); module.exports = function rollup() { const { ROOT_DIR, SOURCE_DIR, OUTPUT_DIR } = getBuildDirectories(name); - const ROUTER_DOM_SOURCE = path.join( - ROOT_DIR, - "packages", - "react-router-dom", - "(index|dom).ts*" - ); - const ROUTER_DOM_COPY_DEST = path.join(SOURCE_DIR, "react-router-dom"); + const RR_DOM_DIR = path.join(ROOT_DIR, "packages", "react-router-dom"); // JS modules for bundlers let modules = [ @@ -45,7 +39,19 @@ module.exports = function rollup() { ], plugins: [ copy({ - targets: [{ src: ROUTER_DOM_SOURCE, dest: ROUTER_DOM_COPY_DEST }], + targets: [ + { + src: path.join(RR_DOM_DIR, "(index|dom).ts*"), + dest: path.join(SOURCE_DIR, "react-router-dom"), + }, + { + src: [ + path.join(RR_DOM_DIR, "ssr", "*.ts*"), + "!" + path.join(RR_DOM_DIR, "ssr", "server.tsx"), + ], + dest: path.join(SOURCE_DIR, "react-router-dom", "ssr"), + }, + ], // buildStart is not soon enough to run before the typescript plugin :/ hook: "options", verbose: true, diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 1d4675396e..fedb5ccd00 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -6,14 +6,14 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import type { DataRouteObject, - FutureConfig, + FutureConfig as RenderFutureConfig, Location, NavigateOptions, NavigationType, Navigator, RelativeRoutingType, RouteObject, - RouterProviderProps, + RouterProviderProps as MemoryRouterProviderProps, To, } from "react-router"; import { @@ -65,6 +65,7 @@ import { UNSAFE_warning as warning, matchPath, IDLE_FETCHER, + matchRoutes, } from "@remix-run/router"; import type { @@ -81,6 +82,23 @@ import { shouldProcessLinkClick, } from "./dom"; +import { RemixContext } from "./ssr/components"; +import type { + AssetsManifest, + FutureConfig as RemixFutureConfig, +} from "./ssr/entry"; +import { RemixErrorBoundary } from "./ssr/errorBoundaries"; +import type { RouteModules } from "./ssr/routeModules"; +import { + createClientRoutes, + createClientRoutesWithHMRRevalidationOptOut, + shouldHydrateRouteLoader, +} from "./ssr/routes"; +import { + decodeViaTurboStream, + getSingleFetchDataStrategy, +} from "./ssr/single-fetch"; + //////////////////////////////////////////////////////////////////////////////// //#region Re-exports //////////////////////////////////////////////////////////////////////////////// @@ -142,7 +160,6 @@ export type { RouteObject, RouteProps, RouterProps, - RouterProviderProps, RoutesProps, Search, ShouldRevalidateFunction, @@ -199,6 +216,25 @@ export { useRoutes, } from "react-router"; +export { + Meta, + Links, + Scripts, + PrefetchPageLinks, + LiveReload, +} from "./ssr/components"; + +export type { HtmlLinkDescriptor } from "./ssr/links"; +export type { + ClientActionFunction, + ClientActionFunctionArgs, + ClientLoaderFunction, + ClientLoaderFunctionArgs, + MetaArgs, + MetaDescriptor, + MetaFunction, +} from "./ssr/routeModules"; + /////////////////////////////////////////////////////////////////////////////// // DANGER! PLEASE READ ME! // We provide these exports as an escape hatch in the event that you need any @@ -221,14 +257,63 @@ export { UNSAFE_RouteContext, UNSAFE_useRouteId, } from "react-router"; +export { RemixContext as UNSAFE_RemixContext } from "./ssr/components"; +export type { RouteModules as UNSAFE_RouteModules } from "./ssr/routeModules"; +export type { + FutureConfig as UNSAFE_FutureConfig, + AssetsManifest as UNSAFE_AssetsManifest, + RemixContextObject as UNSAFE_RemixContextObject, +} from "./ssr/entry"; +export type { + EntryRoute as UNSAFE_EntryRoute, + RouteManifest as UNSAFE_RouteManifest, +} from "./ssr/routes"; //#endregion +//////////////////////////////////////////////////////////////////////////////// +//#region Global Stuff +//////////////////////////////////////////////////////////////////////////////// + +type WindowRemixContext = { + url: string; + basename?: string; + state: HydrationState; + criticalCss?: string; + future: RemixFutureConfig; + isSpaMode: boolean; + stream: ReadableStream | undefined; + streamController: ReadableStreamDefaultController; + // The number of active deferred keys rendered on the server + a?: number; + dev?: { + port?: number; + hmrRuntime?: string; + }; +}; + declare global { - var __staticRouterHydrationData: HydrationState | undefined; - var __reactRouterVersion: string; interface Document { startViewTransition(cb: () => Promise | void): ViewTransition; } + + // v6 SPA info + var __reactRouterVersion: string; + // TODO: v7 - Can this go away in favor of "just use remix"? + var __staticRouterHydrationData: HydrationState | undefined; + + // v7 SSR Info + // TODO: v7 - Once this is all working, rename these global variables to __reactRouter* + var __remixContext: WindowRemixContext | undefined; + var __remixRouter: RemixRouter | undefined; + var __remixRouteModules: RouteModules | undefined; + var __remixManifest: AssetsManifest | undefined; + var __remixRevalidation: number | undefined; + var __remixClearCriticalCss: (() => void) | undefined; + var $RefreshRuntime$: + | { + performReactRefresh: () => void; + } + | undefined; } // HEY YOU! DON'T TOUCH THIS VARIABLE! @@ -247,6 +332,170 @@ try { // no-op } +type SSRInfo = { + context: WindowRemixContext; + routeModules: RouteModules; + manifest: AssetsManifest; + stateDecodingPromise: + | (Promise & { + value?: unknown; + error?: unknown; + }) + | undefined; + router: RemixRouter | undefined; + routerInitialized: boolean; + hmrAbortController: AbortController | undefined; + hmrRouterReadyResolve: ((router: RemixRouter) => void) | undefined; + hmrRouterReadyPromise: Promise; +}; + +let ssrInfo: SSRInfo | null = + typeof window !== "undefined" && + window.__remixContext && + window.__remixManifest && + window.__remixRouteModules + ? { + context: window.__remixContext, + routeModules: window.__remixRouteModules, + manifest: window.__remixManifest, + stateDecodingPromise: undefined, + router: undefined, + routerInitialized: false, + hmrAbortController: undefined, + hmrRouterReadyResolve: undefined, + // There's a race condition with HMR where the remix:manifest is signaled before + // the router is assigned in the RemixBrowser component. This promise gates the + // HMR handler until the router is ready + hmrRouterReadyPromise: new Promise((resolve) => { + // body of a promise is executed immediately, so this can be resolved outside + // of the promise body + ssrInfo!.hmrRouterReadyResolve = resolve; + }).catch(() => { + // This is a noop catch handler to avoid unhandled promise rejection warnings + // in the console. The promise is never rejected. + return undefined; + }) as Promise, + } + : null; + +if ( + import.meta && + // @ts-expect-error + import.meta.hot && + ssrInfo +) { + let localSsrInfo = ssrInfo; + + // @ts-expect-error + import.meta.hot.accept( + "remix:manifest", + async ({ + assetsManifest, + needsRevalidation, + }: { + assetsManifest: AssetsManifest; + needsRevalidation: Set; + }) => { + let router = await localSsrInfo.hmrRouterReadyPromise; + // This should never happen, but just in case... + if (!router) { + console.error( + "Failed to accept HMR update because the router was not ready." + ); + return; + } + + let routeIds = [ + ...new Set( + router.state.matches + .map((m) => m.route.id) + .concat(Object.keys(localSsrInfo.routeModules)) + ), + ]; + + if (localSsrInfo.hmrAbortController) { + localSsrInfo.hmrAbortController.abort(); + } + localSsrInfo.hmrAbortController = new AbortController(); + let signal = localSsrInfo.hmrAbortController.signal; + + // Load new route modules that we've seen. + let newRouteModules = Object.assign( + {}, + localSsrInfo.routeModules, + Object.fromEntries( + ( + await Promise.all( + routeIds.map(async (id) => { + if (!assetsManifest.routes[id]) { + return null; + } + let imported = await import( + assetsManifest.routes[id].module + + `?t=${assetsManifest.hmr?.timestamp}` + ); + return [ + id, + { + ...imported, + // react-refresh takes care of updating these in-place, + // if we don't preserve existing values we'll loose state. + default: imported.default + ? localSsrInfo.routeModules[id]?.default || + imported.default + : imported.default, + ErrorBoundary: imported.ErrorBoundary + ? localSsrInfo.routeModules[id]?.ErrorBoundary || + imported.ErrorBoundary + : imported.ErrorBoundary, + HydrateFallback: imported.HydrateFallback + ? localSsrInfo.routeModules[id]?.HydrateFallback || + imported.HydrateFallback + : imported.HydrateFallback, + }, + ]; + }) + ) + ).filter(Boolean) as [string, RouteModules[string]][] + ) + ); + + Object.assign(localSsrInfo.routeModules, newRouteModules); + // Create new routes + let routes = createClientRoutesWithHMRRevalidationOptOut( + needsRevalidation, + assetsManifest.routes, + localSsrInfo.routeModules, + localSsrInfo.context.state, + localSsrInfo.context.future, + localSsrInfo.context.isSpaMode + ); + + // This is temporary API and will be more granular before release + router._internalSetRoutes(routes); + + // Wait for router to be idle before updating the manifest and route modules + // and triggering a react-refresh + let unsub = router.subscribe((state) => { + if (state.revalidation === "idle") { + unsub(); + // Abort if a new update comes in while we're waiting for the + // router to be idle. + if (signal.aborted) return; + // Ensure RouterProvider setState has flushed before re-rendering + setTimeout(() => { + Object.assign(localSsrInfo.manifest, assetsManifest); + window.$RefreshRuntime$!.performReactRefresh(); + }, 1); + } + }); + window.__remixRevalidation = (window.__remixRevalidation || 0) + 1; + router.revalidate(); + } + ); +} +//#endregion + //////////////////////////////////////////////////////////////////////////////// //#region Routers //////////////////////////////////////////////////////////////////////////////// @@ -474,14 +723,200 @@ class Deferred { } } +// When using a DOM RouterProvider with SSR you don't have to specify a router +// and it can be constructed via `__remixManifest`/`__remixContext` etc. +export type RouterProviderProps = MemoryRouterProviderProps & { + router?: RemixRouter; +}; + /** * Given a Remix Router instance, render the appropriate UI */ export function RouterProvider({ fallbackElement, - router, + router: propRouter, future, }: RouterProviderProps): React.ReactElement { + let router = propRouter || ssrInfo?.router; + + if (!router) { + if (!ssrInfo) { + throw new Error( + "You must be using the SSR features of React Router in order to skip " + + "passing a `router` prop to ``" + ); + } + + // TODO: Do some testing to confirm it's OK to skip the hard reload check + // now that all route.lazy stuff is wired up + + // When single fetch is enabled, we need to suspend until the initial state + // snapshot is decoded into window.__remixContext.state + if (ssrInfo.context.future.unstable_singleFetch) { + let localSsrInfo = ssrInfo; + // Note: `stateDecodingPromise` is not coupled to `router` - we'll reach this + // code potentially many times waiting for our state to arrive, but we'll + // then only get past here and create the `router` one time + if (!ssrInfo.stateDecodingPromise) { + let stream = ssrInfo.context.stream; + invariant(stream, "No stream found for single fetch decoding"); + ssrInfo.context.stream = undefined; + ssrInfo.stateDecodingPromise = decodeViaTurboStream(stream, window) + .then((value) => { + window.__remixContext!.state = + value.value as typeof localSsrInfo.context.state; + localSsrInfo.stateDecodingPromise!.value = true; + }) + .catch((e) => { + localSsrInfo.stateDecodingPromise!.error = e; + }); + } + if (ssrInfo.stateDecodingPromise.error) { + throw ssrInfo.stateDecodingPromise.error; + } + if (!ssrInfo.stateDecodingPromise.value) { + throw ssrInfo.stateDecodingPromise; + } + } + + let routes = createClientRoutes( + ssrInfo.manifest.routes, + ssrInfo.routeModules, + ssrInfo.context.state, + ssrInfo.context.future, + ssrInfo.context.isSpaMode + ); + + let hydrationData = undefined; + if (!ssrInfo.context.isSpaMode) { + // 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: { ...ssrInfo.context.state.loaderData }, + }; + let initialMatches = matchRoutes(routes, window.location); + 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 && + shouldHydrateRouteLoader( + manifestRoute, + route, + ssrInfo.context.isSpaMode + ) && + (route.HydrateFallback || !manifestRoute.hasLoader) + ) { + hydrationData.loaderData[routeId] = undefined; + } 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) { + hydrationData.errors = deserializeErrors(hydrationData.errors); + } + } + + // We don't use createBrowserRouter here because we need fine-grained control + // over initialization to support synchronous `clientLoader` flows. + ssrInfo.router = router = createRouter({ + routes, + history: createBrowserHistory(), + basename: ssrInfo.context.basename, + future: { + v7_normalizeFormMethod: true, + v7_fetcherPersist: ssrInfo.context.future.v3_fetcherPersist, + v7_partialHydration: true, + v7_prependBasename: true, + v7_relativeSplatPath: ssrInfo.context.future.v3_relativeSplatPath, + // Single fetch enables this underlying behavior + unstable_skipActionErrorRevalidation: + ssrInfo.context.future.unstable_singleFetch === true, + }, + hydrationData, + mapRouteProperties, + unstable_dataStrategy: ssrInfo.context.future.unstable_singleFetch + ? getSingleFetchDataStrategy(ssrInfo.manifest, ssrInfo.routeModules) + : undefined, + }); + + // We can call initialize() immediately if the router doesn't have any + // loaders to run on hydration + if (router.state.initialized) { + ssrInfo.routerInitialized = true; + router.initialize(); + } + + // @ts-ignore + router.createRoutesForHMR = createClientRoutesWithHMRRevalidationOptOut; + window.__remixRouter = router; + + // Notify that the router is ready for HMR + if (ssrInfo.hmrRouterReadyResolve) { + ssrInfo.hmrRouterReadyResolve(router); + } + } + + // SSR State + + // Critical CSS can become stale after code changes, e.g. styles might be + // removed from a component, but the styles will still be present in the + // server HTML. This allows our HMR logic to clear the critical CSS state. + // eslint-disable-next-line react-hooks/rules-of-hooks + let [criticalCss, setCriticalCss] = React.useState( + process.env.NODE_ENV === "development" + ? window.__remixContext?.criticalCss + : undefined + ); + if (process.env.NODE_ENV === "development") { + if (ssrInfo) { + window.__remixClearCriticalCss = () => setCriticalCss(undefined); + } + } + + // This is due to the short circuit return above when the pathname doesn't + // match and we force a hard reload. This is an exceptional scenario in which + // we can't hydrate anyway. + let [location, setLocation] = React.useState(router.state.location); + + React.useLayoutEffect(() => { + // If we had to run clientLoaders on hydration, we delay initialization until + // after we've hydrated to avoid hydration issues from synchronous client loaders + if (ssrInfo && ssrInfo.router && !ssrInfo.routerInitialized) { + ssrInfo.routerInitialized = true; + ssrInfo.router.initialize(); + } + }, []); + + React.useLayoutEffect(() => { + if (ssrInfo && ssrInfo.router) { + return ssrInfo.router.subscribe((newState) => { + if (newState.location !== location) { + setLocation(newState.location); + } + }); + } + }, [location]); + + // SPA State let [state, setStateImpl] = React.useState(router.state); let [pendingState, setPendingState] = React.useState(); let [vtContext, setVtContext] = React.useState({ @@ -707,7 +1142,7 @@ export function RouterProvider({ // useId relies on the component tree structure to generate deterministic id's // so we need to ensure it remains the same on the client even though // we don't need the