diff --git a/.changeset/fresh-pages-refresh.md b/.changeset/fresh-pages-refresh.md new file mode 100644 index 000000000..47fe5dc1b --- /dev/null +++ b/.changeset/fresh-pages-refresh.md @@ -0,0 +1,5 @@ +--- +'@rspress/core': patch +--- + +Refresh page data and search indexes when documentation pages or their imported Markdown dependencies change during development. diff --git a/packages/core/src/node/route/extractPageData.test.ts b/packages/core/src/node/route/extractPageData.test.ts index f1d376a19..f8e1425f0 100644 --- a/packages/core/src/node/route/extractPageData.test.ts +++ b/packages/core/src/node/route/extractPageData.test.ts @@ -162,6 +162,10 @@ describe('getPageIndexInfoByRoute', async () => { ); expect(pageIndexInfo).toMatchInlineSnapshot(` { + "_deps": [ + "/packages/core/src/node/route/fixtures/recursive/Comp-in-comp.mdx", + "/packages/core/src/node/route/fixtures/recursive/Comp.mdx", + ], "_filepath": "/packages/core/src/node/route/fixtures/recursive/index.mdx", "_flattenContent": "# Recursive comp test diff --git a/packages/core/src/node/route/extractPageData.ts b/packages/core/src/node/route/extractPageData.ts index d64e21dac..cc62bbef5 100644 --- a/packages/core/src/node/route/extractPageData.ts +++ b/packages/core/src/node/route/extractPageData.ts @@ -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, @@ -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, @@ -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, diff --git a/packages/core/src/node/runtimeModule/pageData/createPageData.ts b/packages/core/src/node/runtimeModule/pageData/createPageData.ts index b3d415c71..a53a6c1a5 100644 --- a/packages/core/src/node/runtimeModule/pageData/createPageData.ts +++ b/packages/core/src/node/runtimeModule/pageData/createPageData.ts @@ -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(); 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, diff --git a/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.test.ts b/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.test.ts new file mode 100644 index 000000000..600ed750c --- /dev/null +++ b/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.test.ts @@ -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 } }; + }, + context: { environment: { name: string } }, +) => void | Promise; + +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) { + 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); + }); +}); diff --git a/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.ts b/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.ts index 08d6d70d1..17859ee8e 100644 --- a/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.ts +++ b/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.ts @@ -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'; @@ -11,48 +11,98 @@ export const rsbuildPluginDocVM = async ({ routeService, pluginDriver, }: Omit): Promise => { - const ref: { - pageData: PageData | null; - searchIndex: Record | null; - indexHashByGroup: Record | null; - filepaths: string[]; - } = { - pageData: null, - searchIndex: null, - indexHashByGroup: null, - filepaths: [], + type PageDataResult = Awaited>; + type RefreshMode = 'compiler' | 'virtual-module'; + const pageDataState: { + alias?: Record; + generation?: { revision: number; promise: Promise }; + result?: PageDataResult; + revision: number; + } = { revision: 0 }; + + const setPageDataAlias = ( + alias: Record, + 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', + ); if (environment.name === 'web') { - const now = performance.now(); - const { pageData, indexHashByGroup, searchIndex, filepaths } = - await createPageData({ - config, - alias: alias as Record, - 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}`, @@ -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)};`; }, }, }), diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7609efd98..1c6e8f125 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -480,6 +480,7 @@ export interface PageIndexInfo { toc: Header[]; content: string; description?: string; + _deps?: string[]; _flattenContent?: string; frontmatter: FrontMatterMeta; lang: string;