Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/prerender-root-data-rr8.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 27 additions & 7 deletions src/prerender-build.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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';
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,
Expand All @@ -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'
Expand Down Expand Up @@ -150,6 +154,7 @@ const prerenderDataEffect = ({
clientBuildDir,
basename,
trailingSlashAwareDataRequests,
legacyRootDataRequest,
api,
requestInit,
}: {
Expand All @@ -159,17 +164,21 @@ const prerenderDataEffect = ({
clientBuildDir: string;
basename: string;
trailingSlashAwareDataRequests: boolean;
legacyRootDataRequest: boolean;
api: PrerenderBuildApi;
requestInit?: RequestInit;
}) => {
const dataOutputPath = createDataRequestPath(
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, '/');
Expand Down Expand Up @@ -442,13 +451,15 @@ const createPrerenderPathEffect = ({
buildRoutes,
requestHandler,
clientBuildDir,
legacyRootDataRequest,
options,
}: {
path: string;
build: ServerBuildDescription;
buildRoutes: ReturnType<typeof createPrerenderRoutes>;
requestHandler: (request: Request) => Promise<Response>;
clientBuildDir: string;
legacyRootDataRequest: boolean;
options: RunReactRouterPrerenderBuildOptions;
}): Effect.Effect<void, Error, never> =>
Effect.gen(function* () {
Expand All @@ -474,6 +485,7 @@ const createPrerenderPathEffect = ({
basename,
trailingSlashAwareDataRequests:
future.unstable_trailingSlashAwareDataRequests,
legacyRootDataRequest,
api,
});
yield* prerenderResourceRouteEffect({
Expand Down Expand Up @@ -509,6 +521,7 @@ const createPrerenderPathEffect = ({
basename,
trailingSlashAwareDataRequests:
future.unstable_trailingSlashAwareDataRequests,
legacyRootDataRequest,
api,
})
: undefined;
Expand Down Expand Up @@ -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,
Expand All @@ -642,6 +661,7 @@ export const runReactRouterPrerenderBuild = async (
buildRoutes,
requestHandler,
clientBuildDir,
legacyRootDataRequest,
options,
})
)
Expand Down
75 changes: 75 additions & 0 deletions tests/prerender-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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' : '<html>', {
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 });
}
}
);
Loading