Skip to content
Open
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/fresh-pages-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rspress/core': patch
---

Refresh page data and search indexes when documentation pages or their imported Markdown dependencies change during development.
4 changes: 4 additions & 0 deletions packages/core/src/node/route/extractPageData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ describe('getPageIndexInfoByRoute', async () => {
);
expect(pageIndexInfo).toMatchInlineSnapshot(`
{
"_deps": [
"<ROOT>/packages/core/src/node/route/fixtures/recursive/Comp-in-comp.mdx",
"<ROOT>/packages/core/src/node/route/fixtures/recursive/Comp.mdx",
],
"_filepath": "<ROOT>/packages/core/src/node/route/fixtures/recursive/index.mdx",
"_flattenContent": "# Recursive comp test

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/node/route/extractPageData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ async function getPageIndexInfoByRoute(
// 1. Replace rules for frontmatter & content
applyReplaceRulesToNestedObject(frontmatter, replaceRules);

const { flattenContent } = await flattenMdxContent(
const { flattenContent, deps } = await flattenMdxContent(
applyReplaceRules(contentWithoutFrontMatter, replaceRules),
route.absolutePath,
alias,
Expand Down Expand Up @@ -380,6 +380,7 @@ async function getPageIndexInfoByRoute(
toc: rawToc.map(item => ({ ...item, charIndex: -1 })),
content: '',
description: frontmatter.description || extractedDescription || undefined,
...(deps.length ? { _deps: deps } : {}),
_flattenContent: flattenContent,
frontmatter: {
...frontmatter,
Expand All @@ -402,6 +403,7 @@ async function getPageIndexInfoByRoute(
// processed markdown content for search index
content: processedContent,
description: frontmatter.description || extractedDescription || undefined,
...(deps.length ? { _deps: deps } : {}),
_flattenContent: flattenContent,
frontmatter: {
...frontmatter,
Expand Down
17 changes: 13 additions & 4 deletions packages/core/src/node/runtimeModule/pageData/createPageData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,27 @@ export async function createPageData(context: FactoryContext): Promise<{
pages.map(async pageData => pluginDriver.extendPageData(pageData)),
);

const filepaths: string[] = [];
const filepaths = new Set<string>();
const pageData: PageData = {
pages: pages.map(page => {
// omit some fields for runtime size
const { content: _content, _filepath, _flattenContent, ...rest } = page;
filepaths.push(_filepath);
const {
content: _content,
_deps,
_filepath,
_flattenContent,
...rest
} = page;
filepaths.add(_filepath);
for (const dep of _deps ?? []) {
filepaths.add(dep);
}
return rest;
}),
};

return {
filepaths,
filepaths: [...filepaths],
pageData,
searchIndex,
indexHashByGroup,
Expand Down
154 changes: 154 additions & 0 deletions packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {
afterEach,
beforeEach,
describe,
expect,
rs,
test,
} from '@rstest/core';
import { pluginVirtualModule } from 'rsbuild-plugin-virtual-module';
import { RuntimeModuleID } from '../types';
import { createPageData } from './createPageData';
import { rsbuildPluginDocVM } from './rsbuildPlugin';

rs.mock('rsbuild-plugin-virtual-module', () => ({
pluginVirtualModule: rs.fn(() => ({ name: 'virtual-page-data' })),
}));

rs.mock('./createPageData', () => ({
createPageData: rs.fn(),
}));

type ModifyBundlerChain = (
chain: {
resolve: { alias: { entries: () => Record<string, string> } };
},
context: { environment: { name: string } },
) => void | Promise<void>;

const pageDataResult = {
filepaths: ['/docs/index.md'],
pageData: { pages: [] },
searchIndex: {},
indexHashByGroup: {},
};

async function setupPlugin() {
const plugins = await rsbuildPluginDocVM({
config: {},
userDocRoot: '/docs',
routeService: {},
pluginDriver: {},
} as never);
let modifyBundlerChainCallback: ModifyBundlerChain | undefined;
const processAssets = rs.fn();
await plugins[0].setup?.({
modifyBundlerChain(callback: ModifyBundlerChain) {
modifyBundlerChainCallback = callback;
},
processAssets,
} as never);

const virtualModuleOptions = rs
.mocked(pluginVirtualModule)
.mock.calls.at(-1)?.[0];
const renderPageData =
virtualModuleOptions?.virtualModules?.[RuntimeModuleID.PageData];
if (!modifyBundlerChainCallback || typeof renderPageData !== 'function') {
throw new Error('Failed to initialize page data plugins');
}

return {
async configureEnvironment(name: string, alias: Record<string, string>) {
await modifyBundlerChainCallback(
{ resolve: { alias: { entries: () => alias } } },
{ environment: { name } },
);
if (!processAssets.mock.calls.length) {
throw new Error('Environment configuration callback did not run');
}
},
renderPageData: () =>
renderPageData({ addDependency: rs.fn() } as never, {} as never),
};
}

describe('page data rsbuild plugin', () => {
const originalNodeEnv = process.env.NODE_ENV;

beforeEach(() => {
process.env.NODE_ENV = 'development';
rs.mocked(createPageData).mockResolvedValue(pageDataResult);
});

afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
rs.clearAllMocks();
});

test('uses the first environment alias until the web alias is available', async () => {
const plugin = await setupPlugin();
await plugin.configureEnvironment('node', { fallback: '/fallback' });

await expect(plugin.renderPageData()).resolves.toContain('"pages": []');
expect(createPageData).toHaveBeenLastCalledWith(
expect.objectContaining({ alias: { fallback: '/fallback' } }),
);

await plugin.configureEnvironment('web', { web: '/web' });
await plugin.renderPageData();
expect(createPageData).toHaveBeenLastCalledWith(
expect.objectContaining({ alias: { web: '/web' } }),
);
});

test('caches production generation across compiler targets', async () => {
process.env.NODE_ENV = 'production';
const plugin = await setupPlugin();
await plugin.configureEnvironment('web', { web: '/web' });

await plugin.renderPageData();
await plugin.renderPageData();

expect(createPageData).toHaveBeenCalledTimes(1);
});

test('rebuilds production data when the authoritative web alias arrives', async () => {
process.env.NODE_ENV = 'production';
const plugin = await setupPlugin();
await plugin.configureEnvironment('node', { fallback: '/fallback' });
await plugin.renderPageData();

await plugin.configureEnvironment('web', { web: '/web' });

expect(createPageData).toHaveBeenCalledTimes(2);
expect(createPageData).toHaveBeenLastCalledWith(
expect.objectContaining({ alias: { web: '/web' } }),
);
});

test('regenerates in development and deduplicates concurrent requests', async () => {
const plugin = await setupPlugin();
await plugin.configureEnvironment('web', { web: '/web' });

await Promise.all([plugin.renderPageData(), plugin.renderPageData()]);
expect(createPageData).toHaveBeenCalledTimes(1);

await plugin.renderPageData();
expect(createPageData).toHaveBeenCalledTimes(2);
});

test('retries a failed production generation', async () => {
process.env.NODE_ENV = 'production';
rs.mocked(createPageData)
.mockRejectedValueOnce(new Error('generation failed'))
.mockResolvedValueOnce(pageDataResult);
const plugin = await setupPlugin();

await expect(
plugin.configureEnvironment('web', { web: '/web' }),
).rejects.toThrow('generation failed');
await expect(plugin.renderPageData()).resolves.toContain('"pages": []');
expect(createPageData).toHaveBeenCalledTimes(2);
});
});
117 changes: 83 additions & 34 deletions packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { RsbuildPlugin } from '@rsbuild/core';
import type { PageData } from '@rspress/shared';
import { isProduction } from '@rspress/shared';
import { logger } from '@rspress/shared/logger';
import { pluginVirtualModule } from 'rsbuild-plugin-virtual-module';
import { type FactoryContext, RuntimeModuleID } from '../types';
Expand All @@ -11,48 +11,98 @@ export const rsbuildPluginDocVM = async ({
routeService,
pluginDriver,
}: Omit<FactoryContext, 'alias'>): Promise<RsbuildPlugin[]> => {
const ref: {
pageData: PageData | null;
searchIndex: Record<string, string> | null;
indexHashByGroup: Record<string, string> | null;
filepaths: string[];
} = {
pageData: null,
searchIndex: null,
indexHashByGroup: null,
filepaths: [],
type PageDataResult = Awaited<ReturnType<typeof createPageData>>;
type RefreshMode = 'compiler' | 'virtual-module';
const pageDataState: {
alias?: Record<string, string>;
generation?: { revision: number; promise: Promise<PageDataResult> };
result?: PageDataResult;
revision: number;
} = { revision: 0 };

const setPageDataAlias = (
alias: Record<string, string>,
source: 'fallback' | 'web',
) => {
if (
(pageDataState.alias && source === 'fallback') ||
pageDataState.alias === alias
) {
return;
}
pageDataState.alias = alias;
pageDataState.generation = undefined;
pageDataState.revision += 1;
};

const refreshPageData = async (mode: RefreshMode) => {
const alias = pageDataState.alias;
if (!alias) {
return;
}
const revision = pageDataState.revision;
if (pageDataState.generation?.revision !== revision) {
const now = performance.now();
pageDataState.generation = {
revision,
promise: createPageData({
config,
alias,
userDocRoot,
routeService,
pluginDriver,
}).then(result => {
logger.debug(`createPageData cost: ${performance.now() - now}ms`);
return result;
}),
};
}
const generation = pageDataState.generation;
try {
const result = await generation.promise;
if (pageDataState.revision === revision) {
pageDataState.result = result;
}
} catch (error) {
if (pageDataState.generation === generation) {
pageDataState.generation = undefined;
}
throw error;
} finally {
if (
mode === 'virtual-module' &&
!isProduction() &&
pageDataState.generation === generation
) {
pageDataState.generation = undefined;
}
}
};

const searchIndexRsbuildPlugin: RsbuildPlugin = {
name: 'rsbuild-plugin-searchIndex',
async setup(api) {
api.modifyBundlerChain(async (bundlerChain, { environment }) => {
const alias = bundlerChain.resolve.alias.entries();
const alias = bundlerChain.resolve.alias.entries() as Record<
string,
string
>;
setPageDataAlias(
alias,
environment.name === 'web' ? 'web' : 'fallback',
);
Comment on lines +90 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recreate the MD resolver after switching to web aliases

When the virtual module is rendered by a non-web environment before the web chain is configured, this stores the fallback aliases and initializes flattenMdxContent's module-level resolver with them. Although the subsequent web callback regenerates page data with the authoritative aliases, packages/core/src/node/utils/flattenMdxContent.ts never recreates its resolver once startFlatten is set, so Markdown imports that depend on a web-specific alias can remain unresolved or resolve to the fallback target, producing incorrect search content and omitting the dependency from HMR tracking. Reset or key that resolver when the alias source changes.

Useful? React with 👍 / 👎.

if (environment.name === 'web') {
const now = performance.now();
const { pageData, indexHashByGroup, searchIndex, filepaths } =
await createPageData({
config,
alias: alias as Record<string, string>,
userDocRoot,
routeService,
pluginDriver,
});
logger.debug(`createPageData cost: ${performance.now() - now}ms`);

ref.pageData = pageData;
ref.searchIndex = searchIndex;
ref.indexHashByGroup = indexHashByGroup;
ref.filepaths = filepaths;
await refreshPageData('compiler');
}

api.processAssets(
{ stage: 'report', environments: ['web'] },
({ compilation, compiler }) => {
if (!ref.searchIndex) {
if (!pageDataState.result) {
return;
}
for (const [filename, stringifiedIndex] of Object.entries(
ref.searchIndex,
pageDataState.result.searchIndex,
)) {
compilation.emitAsset(
`static/${filename}`,
Expand All @@ -71,14 +121,13 @@ export const rsbuildPluginDocVM = async ({
tempDir: '.rspress',
virtualModules: {
[RuntimeModuleID.PageData]: async ({ addDependency }) => {
// TODO: support hmr
// This place needs to obtain the specific file that has been modified and update the file information.
for (const file of ref.filepaths) {
await refreshPageData('virtual-module');
for (const file of pageDataState.result?.filepaths ?? []) {
addDependency(file);
}

return `export const pageData = ${JSON.stringify(ref.pageData, null, 2)};
export const searchIndexHash = ${JSON.stringify(ref.indexHashByGroup, null, 2)};`;
return `export const pageData = ${JSON.stringify(pageDataState.result?.pageData ?? null, null, 2)};
export const searchIndexHash = ${JSON.stringify(pageDataState.result?.indexHashByGroup ?? null, null, 2)};`;
},
},
}),
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ export interface PageIndexInfo {
toc: Header[];
content: string;
description?: string;
_deps?: string[];
_flattenContent?: string;
frontmatter: FrontMatterMeta;
lang: string;
Expand Down
Loading