Skip to content

Commit f09905d

Browse files
committed
fix(plugin-client-redirects): redirect before hydration
1 parent 0519db8 commit f09905d

9 files changed

Lines changed: 255 additions & 80 deletions

File tree

e2e/fixtures/client-redirects/index.test.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import { expect, test } from '@playwright/test';
2-
import { getPort, killProcess, runDevCommand } from '../../utils/runCommands';
2+
import {
3+
getPort,
4+
killProcess,
5+
runBuildCommand,
6+
runDevCommand,
7+
runPreviewCommand,
8+
} from '../../utils/runCommands';
39

4-
test.describe('client redirects test', async () => {
10+
test.describe('client redirects production test', async () => {
511
let appPort: number;
6-
let app: Awaited<ReturnType<typeof runDevCommand>>;
12+
let app: Awaited<ReturnType<typeof runPreviewCommand>>;
713
test.beforeAll(async () => {
814
const appDir = import.meta.dirname;
915
appPort = await getPort();
10-
app = await runDevCommand(appDir, appPort);
16+
await runBuildCommand(appDir);
17+
app = await runPreviewCommand(appDir, appPort);
1118
});
1219

1320
test.afterAll(async () => {
@@ -23,6 +30,21 @@ test.describe('client redirects test', async () => {
2330
await expect(page).toHaveURL(`http://localhost:${appPort}/docs/new1`);
2431
});
2532

33+
test('Should redirect before hydration', async ({ page }) => {
34+
await page.route('**/*', async route => {
35+
if (route.request().resourceType() === 'script') {
36+
await route.abort();
37+
} else {
38+
await route.continue();
39+
}
40+
});
41+
42+
await page.goto(`http://localhost:${appPort}/docs/old1`, {
43+
waitUntil: 'domcontentloaded',
44+
});
45+
await expect(page).toHaveURL(`http://localhost:${appPort}/docs/new1`);
46+
});
47+
2648
test('Should redirect correctly - array', async ({ page }) => {
2749
await page.goto(`http://localhost:${appPort}/docs/2022`, {
2850
waitUntil: 'networkidle',
@@ -77,3 +99,29 @@ test.describe('client redirects test', async () => {
7799
await expect(page).toHaveURL(externalUrl);
78100
});
79101
});
102+
103+
test.describe('client redirects development test', async () => {
104+
let appPort: number;
105+
let app: Awaited<ReturnType<typeof runDevCommand>>;
106+
107+
test.beforeAll(async () => {
108+
const appDir = import.meta.dirname;
109+
appPort = await getPort();
110+
app = await runDevCommand(appDir, appPort);
111+
});
112+
113+
test.afterAll(async () => {
114+
if (app) {
115+
await killProcess(app);
116+
}
117+
});
118+
119+
test('Should be inactive in development', async ({ page }) => {
120+
const sourceUrl = `http://localhost:${appPort}/docs/old1`;
121+
122+
await page.goto(sourceUrl, {
123+
waitUntil: 'networkidle',
124+
});
125+
await expect(page).toHaveURL(sourceUrl);
126+
});
127+
});

packages/plugin-client-redirects/package.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@
2525
"module": "./dist/index.js",
2626
"types": "./dist/index.d.ts",
2727
"files": [
28-
"dist",
29-
"static"
28+
"dist"
3029
],
3130
"scripts": {
3231
"build": "rslib build",
@@ -38,9 +37,6 @@
3837
"@rslib/core": "1.0.0-beta.1",
3938
"@rspress/config": "workspace:*",
4039
"@types/node": "^22.8.1",
41-
"@types/react": "^19.2.17",
42-
"@types/react-dom": "^19.2.3",
43-
"react": "^19.2.8",
4440
"rsbuild-plugin-publint": "^1.0.0",
4541
"typescript": "^6.0.3"
4642
},
Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import path from 'node:path';
21
import type { RspressPlugin } from '@rspress/core';
2+
import { getInlineRedirectScript } from './inlineRedirect';
33
import type { RedirectsOptions } from './types';
44

55
/**
@@ -8,10 +8,29 @@ import type { RedirectsOptions } from './types';
88
export function pluginClientRedirects(
99
options: RedirectsOptions = {},
1010
): RspressPlugin {
11-
return {
11+
const inlineRedirectScript = getInlineRedirectScript(options);
12+
13+
const plugin: RspressPlugin = {
1214
name: '@rspress/plugin-client-redirects',
13-
globalUIComponents: [
14-
[path.join(__dirname, '../static/Redirect.tsx'), options],
15-
],
15+
config(config, _utils, isProd) {
16+
plugin.builderConfig =
17+
isProd && inlineRedirectScript
18+
? {
19+
html: {
20+
tags: [
21+
{
22+
tag: 'script',
23+
children: inlineRedirectScript,
24+
append: false,
25+
},
26+
],
27+
},
28+
}
29+
: undefined;
30+
31+
return config;
32+
},
1633
};
34+
35+
return plugin;
1736
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { RedirectsOptions } from './types';
2+
3+
const serializeInlineScriptData = (value: unknown) =>
4+
JSON.stringify(value).replace(/</g, '\\u003c');
5+
6+
// Resolve redirects before the first render to avoid showing the source page
7+
// while waiting for React to hydrate.
8+
export function getInlineRedirectScript(
9+
options: RedirectsOptions = {},
10+
): string {
11+
const { redirects } = options;
12+
13+
if (!redirects?.length) {
14+
return '';
15+
}
16+
17+
return `{
18+
var redirects = ${serializeInlineScriptData(redirects)}, pathname = window.location.pathname, hash = window.location.hash;
19+
redirectLoop: for (var i = 0; i < redirects.length; i++) {
20+
var redirect = redirects[i], patterns = Array.isArray(redirect.from) ? redirect.from : [redirect.from], to = redirect.to;
21+
for (var j = 0; j < patterns.length; j++) {
22+
var pattern = patterns[j];
23+
try {
24+
var regex = new RegExp(pattern);
25+
if (regex.test(pathname)) {
26+
var isExternal = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(to) && !/^(?:javascript|data|file):/i.test(to);
27+
window.location.replace(isExternal ? to : pathname.replace(regex, to) + hash);
28+
break redirectLoop;
29+
}
30+
} catch (error) {
31+
console.warn('Invalid redirect pattern: ' + pattern, error);
32+
}
33+
}
34+
}
35+
}`.replace(/\n\s*/g, '');
36+
}

packages/plugin-client-redirects/static/Redirect.tsx

Lines changed: 0 additions & 55 deletions
This file was deleted.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, expect, test } from '@rstest/core';
2+
import { pluginClientRedirects } from '../src';
3+
import { getInlineRedirectScript } from '../src/inlineRedirect';
4+
import type { RedirectsOptions } from '../src/types';
5+
6+
const runInlineRedirectScript = (
7+
options: RedirectsOptions,
8+
{
9+
pathname,
10+
hash = '',
11+
}: {
12+
pathname: string;
13+
hash?: string;
14+
},
15+
) => {
16+
let redirectedTo: string | undefined;
17+
const warnings: unknown[][] = [];
18+
const script = getInlineRedirectScript(options);
19+
20+
Function(
21+
'window',
22+
'console',
23+
script,
24+
)(
25+
{
26+
location: {
27+
pathname,
28+
hash,
29+
replace: (url: string) => {
30+
redirectedTo = url;
31+
},
32+
},
33+
},
34+
{
35+
warn: (...args: unknown[]) => {
36+
warnings.push(args);
37+
},
38+
},
39+
);
40+
41+
return { redirectedTo, script, warnings };
42+
};
43+
44+
describe('getInlineRedirectScript', () => {
45+
test('redirects internal routes and preserves the hash', () => {
46+
expect(
47+
runInlineRedirectScript(
48+
{
49+
redirects: [
50+
{
51+
from: ['/docs/2022', '/docs/2023'],
52+
to: '/docs/2024',
53+
},
54+
],
55+
},
56+
{
57+
pathname: '/docs/2023/guide',
58+
hash: '#install',
59+
},
60+
).redirectedTo,
61+
).toBe('/docs/2024/guide#install');
62+
});
63+
64+
test('redirects to external URLs', () => {
65+
expect(
66+
runInlineRedirectScript(
67+
{
68+
redirects: [
69+
{
70+
from: '/docs/old',
71+
to: 'https://example.com/new',
72+
},
73+
],
74+
},
75+
{ pathname: '/docs/old' },
76+
).redirectedTo,
77+
).toBe('https://example.com/new');
78+
});
79+
80+
test('warns for invalid patterns and continues matching', () => {
81+
const result = runInlineRedirectScript(
82+
{
83+
redirects: [
84+
{
85+
from: ['[', '/docs/old'],
86+
to: '/docs/new',
87+
},
88+
],
89+
},
90+
{ pathname: '/docs/old' },
91+
);
92+
93+
expect(result.redirectedTo).toBe('/docs/new');
94+
expect(result.warnings).toHaveLength(1);
95+
expect(result.warnings[0][0]).toBe('Invalid redirect pattern: [');
96+
});
97+
98+
test('does not inject a script without redirect rules', () => {
99+
expect(getInlineRedirectScript()).toBe('');
100+
expect(getInlineRedirectScript({ redirects: [] })).toBe('');
101+
});
102+
103+
test('escapes data that could close the inline script element', () => {
104+
const script = getInlineRedirectScript({
105+
redirects: [{ from: '</script>', to: '/docs/new' }],
106+
});
107+
108+
expect(script).not.toContain('</script>');
109+
expect(script).toContain('\\u003c/script>');
110+
});
111+
});
112+
113+
describe('pluginClientRedirects', () => {
114+
test('only injects the redirect script in production', async () => {
115+
const plugin = pluginClientRedirects({
116+
redirects: [{ from: '/docs/old', to: '/docs/new' }],
117+
});
118+
const utils = {
119+
addPlugin: () => {},
120+
removePlugin: () => {},
121+
};
122+
123+
await plugin.config?.({}, utils, false);
124+
expect(plugin.builderConfig).toBeUndefined();
125+
126+
await plugin.config?.({}, utils, true);
127+
expect(plugin.builderConfig?.html?.tags).toHaveLength(1);
128+
129+
await plugin.config?.({}, utils, false);
130+
expect(plugin.builderConfig).toBeUndefined();
131+
});
132+
});

pnpm-lock.yaml

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

website/docs/en/plugin/official-plugins/client-redirects.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import { SourceCode, PackageManagerTabs } from '@rspress/core/theme';
44

55
Used for client redirects.
66

7+
:::warning Production only
8+
This plugin is always inactive in development and only active in production because it works on the build output.
9+
:::
10+
711
:::warning
812
Before using this plugin, make sure your deployment environment has the fallback page correctly configured. See the [SSG refresh-404 guide](/guide/basic/ssg#refresh-404) for details.
913

10-
Note: Client-side redirects differ from server-side redirects — the page will load first and then redirect (causing a brief flash), and SSR is not supported. If your deployment platform supports server-side redirects (301/302), prefer using that approach for better SEO and user experience.
14+
If your deployment platform supports server-side redirects (301/302), prefer using that approach for better SEO and user experience.
1115
:::
1216

1317
## Installation

0 commit comments

Comments
 (0)