diff --git a/.changeset/prerender-root-data-rr8.md b/.changeset/prerender-root-data-rr8.md new file mode 100644 index 00000000..5ce786fe --- /dev/null +++ b/.changeset/prerender-root-data-rr8.md @@ -0,0 +1,5 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Fix prerendering a root route with a loader on React Router 8 by requesting `/_.data` instead of the legacy `/_root.data` path, which React Router 8 handlers no longer recognize. diff --git a/src/prerender-build.ts b/src/prerender-build.ts index 8348a367..885935d4 100644 --- a/src/prerender-build.ts +++ b/src/prerender-build.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import fsExtra from 'fs-extra'; import * as Effect from 'effect/Effect'; import type { RsbuildPluginAPI } from '@rsbuild/core'; @@ -7,7 +8,7 @@ import { matchRoutes } from 'react-router'; import { dirname, relative, resolve } from 'pathe'; import { PLUGIN_NAME, SPA_FALLBACK_HTML_FILE } from './constants.js'; import { getBuildManifest } from './build-manifest.js'; -import { escapeHtml } from './plugin-utils.js'; +import { escapeHtml, getPackageVersion } from './plugin-utils.js'; import { createReactRouterManifestOptions, generateReactRouterManifestForDev, @@ -23,15 +24,18 @@ import { getSsrFalsePrerenderExportErrors, normalizePrerenderMatchPath, } from './prerender.js'; -import type { - Config, - ResolvedReactRouterConfig, +import { + getDefaultTrailingSlashAwareDataRequests, + type Config, + type ResolvedReactRouterConfig, } from './react-router-config.js'; import { startServerBuildWorker } from './server-build-worker-client.js'; import type { ServerBuildDescription } from './server-build-worker-protocol.js'; import type { PluginOptions, Route } from './types.js'; import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; +const requireFromPlugin = createRequire(import.meta.url); + type PrerenderBuildApi = Pick< RsbuildPluginAPI, 'logger' | 'getNormalizedConfig' @@ -150,6 +154,7 @@ const prerenderDataEffect = ({ clientBuildDir, basename, trailingSlashAwareDataRequests, + legacyRootDataRequest, api, requestInit, }: { @@ -159,6 +164,7 @@ const prerenderDataEffect = ({ clientBuildDir: string; basename: string; trailingSlashAwareDataRequests: boolean; + legacyRootDataRequest: boolean; api: PrerenderBuildApi; requestInit?: RequestInit; }) => { @@ -166,10 +172,13 @@ const prerenderDataEffect = ({ prerenderPath, trailingSlashAwareDataRequests ); - // The handler always serves the root route's data at /_root.data, even when - // trailing-slash-aware naming writes the root output to /_.data. + // React Router 7 handlers serve root data only at /_root.data, even when + // trailing-slash-aware naming writes the output to /_.data. React Router 8 + // handlers only understand /_.data. const dataRequestPath = - trailingSlashAwareDataRequests && prerenderPath === '/' + legacyRootDataRequest && + trailingSlashAwareDataRequests && + prerenderPath === '/' ? '/_root.data' : dataOutputPath; const normalizedPath = `${basename}${dataRequestPath}`.replace(/\/\/+/g, '/'); @@ -442,6 +451,7 @@ const createPrerenderPathEffect = ({ buildRoutes, requestHandler, clientBuildDir, + legacyRootDataRequest, options, }: { path: string; @@ -449,6 +459,7 @@ const createPrerenderPathEffect = ({ buildRoutes: ReturnType; requestHandler: (request: Request) => Promise; clientBuildDir: string; + legacyRootDataRequest: boolean; options: RunReactRouterPrerenderBuildOptions; }): Effect.Effect => Effect.gen(function* () { @@ -474,6 +485,7 @@ const createPrerenderPathEffect = ({ basename, trailingSlashAwareDataRequests: future.unstable_trailingSlashAwareDataRequests, + legacyRootDataRequest, api, }); yield* prerenderResourceRouteEffect({ @@ -509,6 +521,7 @@ const createPrerenderPathEffect = ({ basename, trailingSlashAwareDataRequests: future.unstable_trailingSlashAwareDataRequests, + legacyRootDataRequest, api, }) : undefined; @@ -631,6 +644,12 @@ export const runReactRouterPrerenderBuild = async ( } const buildRoutes = createPrerenderRoutes(build.routes); + // The worker's request handler uses the react-router this plugin resolves. + const legacyRootDataRequest = !getDefaultTrailingSlashAwareDataRequests( + getPackageVersion('react-router', specifier => + requireFromPlugin.resolve(specifier) + ) + ); await runPluginEffect( createBoundedPrerenderTasksEffect( prerenderPaths, @@ -642,6 +661,7 @@ export const runReactRouterPrerenderBuild = async ( buildRoutes, requestHandler, clientBuildDir, + legacyRootDataRequest, options, }) ) diff --git a/tests/prerender-build.test.ts b/tests/prerender-build.test.ts index ea42fcb9..b87d0292 100644 --- a/tests/prerender-build.test.ts +++ b/tests/prerender-build.test.ts @@ -3,12 +3,18 @@ import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { createLogger } from '@rsbuild/core'; import { expect, it, rstest } from '@rstest/core'; +import { getPackageVersion } from '../src/plugin-utils'; +import * as actualPluginUtils from '../src/plugin-utils' with { rstest: 'importActual' }; import { runReactRouterPrerenderBuild } from '../src/prerender-build'; import { startServerBuildWorker } from '../src/server-build-worker-client'; rstest.mock('../src/server-build-worker-client', () => ({ startServerBuildWorker: rstest.fn(), })); +rstest.mock('../src/plugin-utils', () => ({ + ...actualPluginUtils, + getPackageVersion: rstest.fn(), +})); it('writes escaped classic redirects without consuming their body and releases the request', async () => { const buildDirectory = await mkdtemp( @@ -85,3 +91,72 @@ it('writes escaped classic redirects without consuming their body and releases t await rm(buildDirectory, { recursive: true, force: true }); } }); + +it.each([ + ['8.4.0', '/_.data'], + ['7.18.0', '/_root.data'], +])( + 'requests root data the way React Router %s handlers expect', + async (version, expectedRequestPath) => { + const buildDirectory = await mkdtemp( + resolve(tmpdir(), 'rsbuild-prerender-root-data-') + ); + rstest.mocked(getPackageVersion).mockReturnValueOnce(version); + const routes = { + root: { id: 'root', path: '', file: 'root.tsx' }, + }; + const requestPaths: string[] = []; + rstest.mocked(startServerBuildWorker).mockResolvedValueOnce({ + description: { + routes: { + root: { + ...routes.root, + module: { default: true, ErrorBoundary: false, loader: true }, + }, + }, + assets: { routes: { root: { hasLoader: true } } }, + }, + handler: async request => { + const { pathname } = new URL(request.url); + requestPaths.push(pathname); + return new Response(pathname.endsWith('.data') ? 'data' : '', { + status: 200, + }); + }, + close: async () => {}, + }); + try { + await runReactRouterPrerenderBuild({ + api: { + logger: createLogger({ level: 'silent' }), + getNormalizedConfig: () => ({}) as never, + }, + hasWebEnvironment: true, + buildDirectory, + ssr: true, + isPrerenderEnabled: true, + prerenderConfig: ['/'], + prerenderPaths: ['/'], + basename: '/', + future: { unstable_trailingSlashAwareDataRequests: true } as never, + routes, + latestBrowserManifest: null, + latestBrowserManifestModuleExports: {}, + clientStats: undefined, + pluginOptions: {}, + appDirectory: buildDirectory, + assetPrefix: '/', + routeChunkOptions: undefined, + buildManifest: { routes }, + buildEndReactRouterConfig: {} as never, + buildEnd: undefined, + }); + expect(requestPaths).toContain(expectedRequestPath); + expect( + await readFile(resolve(buildDirectory, 'client/_.data'), 'utf8') + ).toBe('data'); + } finally { + await rm(buildDirectory, { recursive: true, force: true }); + } + } +);