From 83b87ba1cba2d19732816860c4401485928edea0 Mon Sep 17 00:00:00 2001 From: "Mr.Hope" Date: Tue, 19 May 2026 18:49:13 +0800 Subject: [PATCH 01/13] feat: implement 'as-needed' prefetch and preload strategy with chunk file mapping --- packages/bundler-vite/src/build/build.ts | 12 +++++ packages/bundler-vite/src/build/renderPage.ts | 7 +++ .../src/build/renderPagePrefetchLinks.ts | 49 ++++++++++++++++--- .../src/build/renderPagePreloadLinks.ts | 31 +++++++++++- packages/bundler-webpack/src/build/build.ts | 12 +++++ .../src/build/createClientPlugin.ts | 11 +++++ .../bundler-webpack/src/build/renderPage.ts | 8 ++- .../src/build/renderPagePrefetchLinks.ts | 47 +++++++++++++++--- .../src/build/renderPagePreloadLinks.ts | 36 +++++++++++++- packages/bundler-webpack/src/build/types.ts | 4 ++ packages/bundlerutils/src/build/index.ts | 2 + .../src/build/resolveLinkRoutePath.ts | 21 ++++++++ packages/bundlerutils/src/build/types.ts | 5 ++ packages/core/src/types/app/options.ts | 10 +++- 14 files changed, 235 insertions(+), 20 deletions(-) create mode 100644 packages/bundlerutils/src/build/resolveLinkRoutePath.ts create mode 100644 packages/bundlerutils/src/build/types.ts diff --git a/packages/bundler-vite/src/build/build.ts b/packages/bundler-vite/src/build/build.ts index 3cc7e9c922..4a7200dabf 100644 --- a/packages/bundler-vite/src/build/build.ts +++ b/packages/bundler-vite/src/build/build.ts @@ -1,3 +1,4 @@ +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' import { createVueServerApp, getSsrTemplate } from '@vuepress/bundlerutils' import type { App, Bundler } from '@vuepress/core' import { colors, debug, fs, withSpinner } from '@vuepress/utils' @@ -7,6 +8,7 @@ import { build as viteBuild } from 'vite' import { resolveViteConfig } from '../resolveViteConfig.js' import type { ViteBundlerOptions } from '../types.js' import { renderPage } from './renderPage.js' +import { resolvePageChunkFiles } from './resolvePageChunkFiles.js' const log = debug('vuepress:bundler-vite/build') @@ -65,6 +67,15 @@ export const build = async ( ) const ssrTemplate = await getSsrTemplate(app) + // build page path -> chunk files map for 'as-needed' strategy + const pageChunkFilesMap: PageChunkFilesMap = new Map() + for (const page of app.pages) { + pageChunkFilesMap.set( + page.path, + resolvePageChunkFiles({ page, output: clientOutput.output }), + ) + } + // pre-render pages to html files for (const page of app.pages) { if (spinner) spinner.text = `Rendering pages ${colors.magenta(page.path)}` @@ -77,6 +88,7 @@ export const build = async ( output: clientOutput.output, outputEntryChunk: clientEntryChunk, outputCssAsset: clientCssAsset, + pageChunkFilesMap, }) } }) diff --git a/packages/bundler-vite/src/build/renderPage.ts b/packages/bundler-vite/src/build/renderPage.ts index 34a6860887..f186caa917 100644 --- a/packages/bundler-vite/src/build/renderPage.ts +++ b/packages/bundler-vite/src/build/renderPage.ts @@ -1,3 +1,4 @@ +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' import { renderPageToString } from '@vuepress/bundlerutils' import type { App, Page } from '@vuepress/core' import { fs, renderHead } from '@vuepress/utils' @@ -20,6 +21,7 @@ export const renderPage = async ({ output, outputEntryChunk, outputCssAsset, + pageChunkFilesMap, }: { app: App page: Page @@ -29,6 +31,7 @@ export const renderPage = async ({ output: RolldownOutput['output'] outputEntryChunk: OutputChunk outputCssAsset: OutputAsset | undefined + pageChunkFilesMap: PageChunkFilesMap }): Promise => { // render current page to string const { ssrContext, ssrString } = await renderPageToString({ @@ -49,11 +52,15 @@ export const renderPage = async ({ app, outputEntryChunk, pageChunkFiles, + page, + pageChunkFilesMap, }), preload: renderPagePreloadLinks({ app, outputEntryChunk, pageChunkFiles, + page, + pageChunkFilesMap, }), scripts: renderPageScripts({ app, outputEntryChunk }), styles: renderPageStyles({ app, outputCssAsset }), diff --git a/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts b/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts index ca7299fc58..07b0288b9c 100644 --- a/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts +++ b/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts @@ -1,4 +1,6 @@ -import type { App } from '@vuepress/core' +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' +import { resolveLinkRoutePath } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' import type { OutputChunk } from 'rolldown' /** @@ -8,10 +10,14 @@ export const renderPagePrefetchLinks = ({ app, outputEntryChunk, pageChunkFiles, + page, + pageChunkFilesMap, }: { app: App outputEntryChunk: OutputChunk pageChunkFiles: string[] + page: Page + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPrefetch option const { shouldPrefetch } = app.options @@ -21,12 +27,37 @@ export const renderPagePrefetchLinks = ({ return '' } - // dynamic imports excluding current page chunks - const prefetchFiles = outputEntryChunk.dynamicImports.filter( - (item) => !pageChunkFiles.some((file) => file === item), - ) + let candidateFiles: string[] - return prefetchFiles + if (shouldPrefetch === 'as-needed') { + // collect linked page chunk file names + const linkedFileNames = new Set() + for (const link of page.links) { + const routePath = resolveLinkRoutePath(link.absolute, app.options.base) + if (routePath) { + const targetChunks = pageChunkFilesMap.get(routePath) + if (targetChunks) { + for (const file of targetChunks) { + linkedFileNames.add(file) + } + } + } + } + // dynamic imports excluding current page chunks + // filtered to only linked pages' chunk files + candidateFiles = outputEntryChunk.dynamicImports.filter( + (item) => + linkedFileNames.has(item) && + !pageChunkFiles.some((file) => file === item), + ) + } else { + // dynamic imports excluding current page chunks + candidateFiles = outputEntryChunk.dynamicImports.filter( + (item) => !pageChunkFiles.some((file) => file === item), + ) + } + + return candidateFiles .map((item) => { // resolve file type const type = item.endsWith('.js') @@ -36,7 +67,11 @@ export const renderPagePrefetchLinks = ({ : '' // user wants to explicitly control what to prefetch - if (shouldPrefetch !== true && !shouldPrefetch(item, type)) { + if ( + shouldPrefetch !== true && + shouldPrefetch !== 'as-needed' && + !shouldPrefetch(item, type) + ) { return '' } return `` diff --git a/packages/bundler-vite/src/build/renderPagePreloadLinks.ts b/packages/bundler-vite/src/build/renderPagePreloadLinks.ts index 06b481c7fd..6a9740b1d1 100644 --- a/packages/bundler-vite/src/build/renderPagePreloadLinks.ts +++ b/packages/bundler-vite/src/build/renderPagePreloadLinks.ts @@ -1,4 +1,6 @@ -import type { App } from '@vuepress/core' +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' +import { resolveLinkRoutePath } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' import type { OutputChunk } from 'rolldown' /** @@ -8,10 +10,14 @@ export const renderPagePreloadLinks = ({ app, outputEntryChunk, pageChunkFiles, + page, + pageChunkFilesMap, }: { app: App outputEntryChunk: OutputChunk pageChunkFiles: string[] + page: Page + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPreload option const { shouldPreload } = app.options @@ -30,6 +36,23 @@ export const renderPagePreloadLinks = ({ ]), ) + // when 'as-needed', also add linked pages' chunk files + if (shouldPreload === 'as-needed') { + for (const link of page.links) { + const routePath = resolveLinkRoutePath(link.absolute, app.options.base) + if (routePath) { + const targetChunks = pageChunkFilesMap.get(routePath) + if (targetChunks) { + for (const file of targetChunks) { + if (!preloadFiles.includes(file)) { + preloadFiles.push(file) + } + } + } + } + } + } + return preloadFiles .map((item) => { // resolve file type @@ -45,7 +68,11 @@ export const renderPagePreloadLinks = ({ } // user wants to explicitly control what to preload - if (shouldPreload !== true && !shouldPreload(item, type)) { + if ( + shouldPreload !== true && + shouldPreload !== 'as-needed' && + !shouldPreload(item, type) + ) { return '' } diff --git a/packages/bundler-webpack/src/build/build.ts b/packages/bundler-webpack/src/build/build.ts index 9cb82786e4..73f4919782 100644 --- a/packages/bundler-webpack/src/build/build.ts +++ b/packages/bundler-webpack/src/build/build.ts @@ -1,3 +1,4 @@ +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' import { createVueServerApp, getSsrTemplate } from '@vuepress/bundlerutils' import type { App, Bundler } from '@vuepress/core' import { colors, debug, fs, logger, withSpinner } from '@vuepress/utils' @@ -78,6 +79,16 @@ export const build = async ( const { initialFilesMeta, asyncFilesMeta, moduleFilesMetaMap } = resolveClientManifestMeta(clientManifest) + // build page path -> chunk files map for 'as-needed' strategy + const pageChunkFilesMap: PageChunkFilesMap = new Map() + for (const page of app.pages) { + const pageFileNames = clientManifest.chunks[page.chunkName] ?? [] + + if (pageFileNames.length) { + pageChunkFilesMap.set(page.path, pageFileNames) + } + } + // create vue ssr app and get ssr template const { vueApp, vueRouter } = await createVueServerApp( app.dir.temp('.server/app.cjs'), @@ -98,6 +109,7 @@ export const build = async ( initialFilesMeta, asyncFilesMeta, moduleFilesMetaMap, + pageChunkFilesMap, }) } }) diff --git a/packages/bundler-webpack/src/build/createClientPlugin.ts b/packages/bundler-webpack/src/build/createClientPlugin.ts index 8e39036dd0..ef878c273c 100644 --- a/packages/bundler-webpack/src/build/createClientPlugin.ts +++ b/packages/bundler-webpack/src/build/createClientPlugin.ts @@ -90,12 +90,23 @@ export const createClientPlugin = ( if (request) manifestModules[request] = files }) + // build chunk name -> file names mapping + const chunkFiles: Record = {} + chunks.forEach((chunk) => { + if (chunk.names.length && chunk.files.length) { + chunk.names.forEach((name) => { + chunkFiles[name] = [...(chunkFiles[name] ?? []), ...chunk.files] + }) + } + }) + // generate client manifest json file const clientManifest: ClientManifest = { all: allFiles, initial: initialFiles, async: asyncFiles, modules: manifestModules, + chunks: chunkFiles, } const clientManifestJson = JSON.stringify(clientManifest, null, 2) diff --git a/packages/bundler-webpack/src/build/renderPage.ts b/packages/bundler-webpack/src/build/renderPage.ts index 2608faff9e..135029142b 100644 --- a/packages/bundler-webpack/src/build/renderPage.ts +++ b/packages/bundler-webpack/src/build/renderPage.ts @@ -1,4 +1,4 @@ -import type { PageSSRContext } from '@vuepress/bundlerutils' +import type { PageChunkFilesMap, PageSSRContext } from '@vuepress/bundlerutils' import { renderPageToString } from '@vuepress/bundlerutils' import type { App, Page } from '@vuepress/core' import { fs, renderHead } from '@vuepress/utils' @@ -33,6 +33,7 @@ export const renderPage = async ({ initialFilesMeta, asyncFilesMeta, moduleFilesMetaMap, + pageChunkFilesMap, }: { app: App page: Page @@ -42,6 +43,7 @@ export const renderPage = async ({ initialFilesMeta: FileMeta[] asyncFilesMeta: FileMeta[] moduleFilesMetaMap: ModuleFilesMetaMap + pageChunkFilesMap: PageChunkFilesMap }): Promise => { // render current page to string const { ssrContext, ssrString } = @@ -67,11 +69,15 @@ export const renderPage = async ({ app, asyncFilesMeta, pageClientFilesMeta, + page, + pageChunkFilesMap, }), preload: renderPagePreloadLinks({ app, initialFilesMeta, pageClientFilesMeta, + page, + pageChunkFilesMap, }), scripts: renderPageScripts({ app, initialFilesMeta, pageClientFilesMeta }), styles: renderPageStyles({ app, initialFilesMeta, pageClientFilesMeta }), diff --git a/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts b/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts index 810fd115d0..2cf3754bf0 100644 --- a/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts +++ b/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts @@ -1,4 +1,6 @@ -import type { App } from '@vuepress/core' +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' +import { resolveLinkRoutePath } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' import type { FileMeta } from './types.js' @@ -9,10 +11,14 @@ export const renderPagePrefetchLinks = ({ app, asyncFilesMeta, pageClientFilesMeta, + page, + pageChunkFilesMap, }: { app: App asyncFilesMeta: FileMeta[] pageClientFilesMeta: FileMeta[] + page: Page + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPrefetch option const { shouldPrefetch } = app.options @@ -22,15 +28,44 @@ export const renderPagePrefetchLinks = ({ return '' } - // async files excluding files used by current page should be prefetch - const prefetchFilesMeta = asyncFilesMeta.filter( - ({ file }) => !pageClientFilesMeta.some((f) => f.file === file), - ) + let prefetchFilesMeta: FileMeta[] + + if (shouldPrefetch === 'as-needed') { + // collect linked page chunk file names + const linkedFileNames = new Set() + for (const link of page.links) { + const routePath = resolveLinkRoutePath(link.absolute, app.options.base) + if (routePath) { + const targetChunks = pageChunkFilesMap.get(routePath) + if (targetChunks) { + for (const file of targetChunks) { + linkedFileNames.add(file) + } + } + } + } + // async files excluding files used by current page + // filtered to only linked pages' chunk files + prefetchFilesMeta = asyncFilesMeta.filter( + ({ file }) => + linkedFileNames.has(file) && + !pageClientFilesMeta.some((f) => f.file === file), + ) + } else { + // async files excluding files used by current page should be prefetch + prefetchFilesMeta = asyncFilesMeta.filter( + ({ file }) => !pageClientFilesMeta.some((f) => f.file === file), + ) + } return prefetchFilesMeta .map(({ file, type }) => { // user wants to explicitly control what to prefetch - if (shouldPrefetch !== true && !shouldPrefetch(file, type)) { + if ( + shouldPrefetch !== true && + shouldPrefetch !== 'as-needed' && + !shouldPrefetch(file, type) + ) { return '' } return `` diff --git a/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts b/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts index e3dda92dee..d817209116 100644 --- a/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts +++ b/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts @@ -1,5 +1,8 @@ -import type { App } from '@vuepress/core' +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' +import { resolveLinkRoutePath } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' +import { resolveFileMeta } from './resolveFileMeta.js' import type { FileMeta } from './types.js' /** @@ -9,10 +12,14 @@ export const renderPagePreloadLinks = ({ app, initialFilesMeta, pageClientFilesMeta, + page, + pageChunkFilesMap, }: { app: App initialFilesMeta: FileMeta[] pageClientFilesMeta: FileMeta[] + page: Page + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPreload option const { shouldPreload } = app.options @@ -25,6 +32,27 @@ export const renderPagePreloadLinks = ({ // initial files and files used by current page should be preload const preloadFilesMeta = [...initialFilesMeta, ...pageClientFilesMeta] + // when 'as-needed', also add linked pages' chunk files + if (shouldPreload === 'as-needed') { + const linkedFileNames = new Set() + for (const link of page.links) { + const routePath = resolveLinkRoutePath(link.absolute, app.options.base) + if (routePath) { + const targetChunks = pageChunkFilesMap.get(routePath) + if (targetChunks) { + for (const file of targetChunks) { + linkedFileNames.add(file) + } + } + } + } + for (const fileName of linkedFileNames) { + if (!preloadFilesMeta.some((f) => f.file === fileName)) { + preloadFilesMeta.push(resolveFileMeta(fileName)) + } + } + } + return preloadFilesMeta .map(({ file, extension, type }) => { // by default, we only preload scripts or css @@ -33,7 +61,11 @@ export const renderPagePreloadLinks = ({ } // user wants to explicitly control what to preload - if (shouldPreload !== true && !shouldPreload(file, type)) { + if ( + shouldPreload !== true && + shouldPreload !== 'as-needed' && + !shouldPreload(file, type) + ) { return '' } diff --git a/packages/bundler-webpack/src/build/types.ts b/packages/bundler-webpack/src/build/types.ts index 5eaf515096..e81feaf5d6 100644 --- a/packages/bundler-webpack/src/build/types.ts +++ b/packages/bundler-webpack/src/build/types.ts @@ -36,4 +36,8 @@ export interface ClientManifest { initial: string[] async: string[] modules: Record + /** + * Chunk name to output file names mapping + */ + chunks: Record } diff --git a/packages/bundlerutils/src/build/index.ts b/packages/bundlerutils/src/build/index.ts index a3e53e9f79..aeb819eb73 100644 --- a/packages/bundlerutils/src/build/index.ts +++ b/packages/bundlerutils/src/build/index.ts @@ -1,3 +1,5 @@ export * from './createVueServerApp' export * from './getSsrTemplate' export * from './renderPageToString' +export * from './resolveLinkRoutePath' +export type * from './types' diff --git a/packages/bundlerutils/src/build/resolveLinkRoutePath.ts b/packages/bundlerutils/src/build/resolveLinkRoutePath.ts new file mode 100644 index 0000000000..446e2f45e1 --- /dev/null +++ b/packages/bundlerutils/src/build/resolveLinkRoutePath.ts @@ -0,0 +1,21 @@ +import { inferRoutePath } from '@vuepress/shared' + +/** + * Normalize a MarkdownLink's absolute path to a route path + * that can be matched against Page.path. + * + * Returns null if the link is unresolvable. + */ +export const resolveLinkRoutePath = ( + absolute: string | null, + base: string, +): string | null => { + if (!absolute) return null + + // Strip base prefix, ensuring leading slash + const pathWithoutBase = absolute.startsWith(base) + ? `/${absolute.slice(base.length)}` + : absolute + + return inferRoutePath(pathWithoutBase) +} diff --git a/packages/bundlerutils/src/build/types.ts b/packages/bundlerutils/src/build/types.ts new file mode 100644 index 0000000000..44abc170fd --- /dev/null +++ b/packages/bundlerutils/src/build/types.ts @@ -0,0 +1,5 @@ +/** + * Map from page route path to its chunk output file names. + * Used by prefetch/preload strategy. + */ +export type PageChunkFilesMap = Map diff --git a/packages/core/src/types/app/options.ts b/packages/core/src/types/app/options.ts index 7b47c6bae0..288b1995e9 100644 --- a/packages/core/src/types/app/options.ts +++ b/packages/core/src/types/app/options.ts @@ -153,7 +153,10 @@ export interface AppConfigBuild { * * @default true */ - shouldPreload?: boolean | ((file: string, type: string) => boolean) + shouldPreload?: + | boolean + | 'as-needed' + | ((file: string, type: string) => boolean) /** * Determine what resource files should be prefetched. Use boolean value to @@ -161,7 +164,10 @@ export interface AppConfigBuild { * * @default true */ - shouldPrefetch?: boolean | ((file: string, type: string) => boolean) + shouldPrefetch?: + | boolean + | 'as-needed' + | ((file: string, type: string) => boolean) /** * Specify the path of the HTML template to be used for build From 33e82927c5aa1f1a357b44b618d0958c66fbd688 Mon Sep 17 00:00:00 2001 From: "Mr.Hope" Date: Tue, 19 May 2026 18:50:19 +0800 Subject: [PATCH 02/13] feat!: change default options --- packages/core/src/app/resolveAppOptions.ts | 4 ++-- packages/core/tests/app/resolveAppOptions.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/app/resolveAppOptions.ts b/packages/core/src/app/resolveAppOptions.ts index 12f9ee4bb5..3f0f477ccf 100644 --- a/packages/core/src/app/resolveAppOptions.ts +++ b/packages/core/src/app/resolveAppOptions.ts @@ -33,8 +33,8 @@ export const resolveAppOptions = ({ require.resolve('@vuepress/client/templates/dev.html'), ), // build config - shouldPreload = true, - shouldPrefetch = true, + shouldPreload = 'as-needed', + shouldPrefetch = 'as-needed', templateBuild = path.normalize( require.resolve('@vuepress/client/templates/build.html'), ), diff --git a/packages/core/tests/app/resolveAppOptions.spec.ts b/packages/core/tests/app/resolveAppOptions.spec.ts index ab3033c435..de1e0c25e4 100644 --- a/packages/core/tests/app/resolveAppOptions.spec.ts +++ b/packages/core/tests/app/resolveAppOptions.spec.ts @@ -43,8 +43,8 @@ it('should create app options with default values', () => { require.resolve('@vuepress/client/templates/build.html'), ), templateBuildRenderer: templateRenderer, - shouldPreload: true, - shouldPrefetch: true, + shouldPreload: 'as-needed', + shouldPrefetch: 'as-needed', markdown: {}, plugins: [], }) From eb05f106bb377d53c4307bf37d23d68012e4189f Mon Sep 17 00:00:00 2001 From: Mister-Hope Date: Tue, 19 May 2026 23:02:51 +0800 Subject: [PATCH 03/13] chore: update jsdocs --- packages/core/src/types/app/options.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/types/app/options.ts b/packages/core/src/types/app/options.ts index 288b1995e9..382d4423d4 100644 --- a/packages/core/src/types/app/options.ts +++ b/packages/core/src/types/app/options.ts @@ -151,7 +151,7 @@ export interface AppConfigBuild { * Determine what resource files should be preloaded. Use boolean value to * totally enable / disable. * - * @default true + * @default 'as-needed' */ shouldPreload?: | boolean @@ -162,7 +162,7 @@ export interface AppConfigBuild { * Determine what resource files should be prefetched. Use boolean value to * totally enable / disable. * - * @default true + * @default 'as-needed' */ shouldPrefetch?: | boolean From 2b96681600af4488a260e50eec0ff7b5d2d75aa2 Mon Sep 17 00:00:00 2001 From: Mister-Hope Date: Wed, 24 Jun 2026 22:08:06 +0800 Subject: [PATCH 04/13] Update packages/bundler-vite/src/build/build.ts Co-authored-by: Xinyu Liu --- packages/bundler-vite/src/build/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bundler-vite/src/build/build.ts b/packages/bundler-vite/src/build/build.ts index 4a7200dabf..a569950309 100644 --- a/packages/bundler-vite/src/build/build.ts +++ b/packages/bundler-vite/src/build/build.ts @@ -67,7 +67,7 @@ export const build = async ( ) const ssrTemplate = await getSsrTemplate(app) - // build page path -> chunk files map for 'as-needed' strategy + // build page path -> chunk files map for preload & prefetch const pageChunkFilesMap: PageChunkFilesMap = new Map() for (const page of app.pages) { pageChunkFilesMap.set( From 8fa27716b06de535bdb40eec116b3c362d7fd6e6 Mon Sep 17 00:00:00 2001 From: Mister-Hope Date: Wed, 24 Jun 2026 22:08:13 +0800 Subject: [PATCH 05/13] Update packages/bundler-webpack/src/build/build.ts Co-authored-by: Xinyu Liu --- packages/bundler-webpack/src/build/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bundler-webpack/src/build/build.ts b/packages/bundler-webpack/src/build/build.ts index 73f4919782..254a3dabcc 100644 --- a/packages/bundler-webpack/src/build/build.ts +++ b/packages/bundler-webpack/src/build/build.ts @@ -79,7 +79,7 @@ export const build = async ( const { initialFilesMeta, asyncFilesMeta, moduleFilesMetaMap } = resolveClientManifestMeta(clientManifest) - // build page path -> chunk files map for 'as-needed' strategy + // build page path -> chunk files map for preload & prefetch const pageChunkFilesMap: PageChunkFilesMap = new Map() for (const page of app.pages) { const pageFileNames = clientManifest.chunks[page.chunkName] ?? [] From fb47de9552c7e08f80da12dcd46c22892a24bbb0 Mon Sep 17 00:00:00 2001 From: Xinyu Liu Date: Fri, 24 Jul 2026 14:48:21 +0800 Subject: [PATCH 06/13] refactor: updates --- packages/bundler-vite/src/build/renderPage.ts | 16 +++++--- .../src/build/renderPagePrefetchLinks.ts | 25 ++---------- .../src/build/renderPagePreloadLinks.ts | 38 ++++++------------- .../bundler-webpack/src/build/renderPage.ts | 16 +++++--- .../src/build/renderPagePrefetchLinks.ts | 25 ++---------- .../src/build/renderPagePreloadLinks.ts | 28 ++++---------- packages/bundlerutils/src/build/index.ts | 1 + .../src/build/resolveLinkedPageChunkFiles.ts | 33 ++++++++++++++++ 8 files changed, 82 insertions(+), 100 deletions(-) create mode 100644 packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts diff --git a/packages/bundler-vite/src/build/renderPage.ts b/packages/bundler-vite/src/build/renderPage.ts index f186caa917..5f23bffc81 100644 --- a/packages/bundler-vite/src/build/renderPage.ts +++ b/packages/bundler-vite/src/build/renderPage.ts @@ -1,5 +1,8 @@ import type { PageChunkFilesMap } from '@vuepress/bundlerutils' -import { renderPageToString } from '@vuepress/bundlerutils' +import { + renderPageToString, + resolveLinkedPageChunkFiles, +} from '@vuepress/bundlerutils' import type { App, Page } from '@vuepress/core' import { fs, renderHead } from '@vuepress/utils' import type { OutputAsset, OutputChunk, RolldownOutput } from 'rolldown' @@ -42,6 +45,11 @@ export const renderPage = async ({ // resolve page chunks const pageChunkFiles = resolvePageChunkFiles({ page, output }) + const linkedPageChunkFiles = resolveLinkedPageChunkFiles({ + base: app.options.base, + page, + pageChunkFilesMap, + }) // generate html string const html = await app.options.templateBuildRenderer(ssrTemplate, { @@ -50,17 +58,15 @@ export const renderPage = async ({ lang: ssrContext.lang, prefetch: renderPagePrefetchLinks({ app, + linkedPageChunkFiles, outputEntryChunk, pageChunkFiles, - page, - pageChunkFilesMap, }), preload: renderPagePreloadLinks({ app, + linkedPageChunkFiles, outputEntryChunk, pageChunkFiles, - page, - pageChunkFilesMap, }), scripts: renderPageScripts({ app, outputEntryChunk }), styles: renderPageStyles({ app, outputCssAsset }), diff --git a/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts b/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts index 07b0288b9c..5e211e2cd0 100644 --- a/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts +++ b/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts @@ -1,6 +1,4 @@ -import type { PageChunkFilesMap } from '@vuepress/bundlerutils' -import { resolveLinkRoutePath } from '@vuepress/bundlerutils' -import type { App, Page } from '@vuepress/core' +import type { App } from '@vuepress/core' import type { OutputChunk } from 'rolldown' /** @@ -8,16 +6,14 @@ import type { OutputChunk } from 'rolldown' */ export const renderPagePrefetchLinks = ({ app, + linkedPageChunkFiles, outputEntryChunk, pageChunkFiles, - page, - pageChunkFilesMap, }: { app: App + linkedPageChunkFiles: Set outputEntryChunk: OutputChunk pageChunkFiles: string[] - page: Page - pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPrefetch option const { shouldPrefetch } = app.options @@ -30,24 +26,11 @@ export const renderPagePrefetchLinks = ({ let candidateFiles: string[] if (shouldPrefetch === 'as-needed') { - // collect linked page chunk file names - const linkedFileNames = new Set() - for (const link of page.links) { - const routePath = resolveLinkRoutePath(link.absolute, app.options.base) - if (routePath) { - const targetChunks = pageChunkFilesMap.get(routePath) - if (targetChunks) { - for (const file of targetChunks) { - linkedFileNames.add(file) - } - } - } - } // dynamic imports excluding current page chunks // filtered to only linked pages' chunk files candidateFiles = outputEntryChunk.dynamicImports.filter( (item) => - linkedFileNames.has(item) && + linkedPageChunkFiles.has(item) && !pageChunkFiles.some((file) => file === item), ) } else { diff --git a/packages/bundler-vite/src/build/renderPagePreloadLinks.ts b/packages/bundler-vite/src/build/renderPagePreloadLinks.ts index 6a9740b1d1..0fea60b582 100644 --- a/packages/bundler-vite/src/build/renderPagePreloadLinks.ts +++ b/packages/bundler-vite/src/build/renderPagePreloadLinks.ts @@ -1,6 +1,4 @@ -import type { PageChunkFilesMap } from '@vuepress/bundlerutils' -import { resolveLinkRoutePath } from '@vuepress/bundlerutils' -import type { App, Page } from '@vuepress/core' +import type { App } from '@vuepress/core' import type { OutputChunk } from 'rolldown' /** @@ -8,16 +6,14 @@ import type { OutputChunk } from 'rolldown' */ export const renderPagePreloadLinks = ({ app, + linkedPageChunkFiles, outputEntryChunk, pageChunkFiles, - page, - pageChunkFilesMap, }: { app: App + linkedPageChunkFiles: Set outputEntryChunk: OutputChunk pageChunkFiles: string[] - page: Page - pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPreload option const { shouldPreload } = app.options @@ -28,32 +24,20 @@ export const renderPagePreloadLinks = ({ } // dedupe entry chunks and page chunks - const preloadFiles = Array.from( - new Set([ - outputEntryChunk.fileName, - ...outputEntryChunk.imports, - ...pageChunkFiles, - ]), - ) + const preloadFiles = new Set([ + outputEntryChunk.fileName, + ...outputEntryChunk.imports, + ...pageChunkFiles, + ]) // when 'as-needed', also add linked pages' chunk files if (shouldPreload === 'as-needed') { - for (const link of page.links) { - const routePath = resolveLinkRoutePath(link.absolute, app.options.base) - if (routePath) { - const targetChunks = pageChunkFilesMap.get(routePath) - if (targetChunks) { - for (const file of targetChunks) { - if (!preloadFiles.includes(file)) { - preloadFiles.push(file) - } - } - } - } + for (const file of linkedPageChunkFiles) { + preloadFiles.add(file) } } - return preloadFiles + return Array.from(preloadFiles) .map((item) => { // resolve file type const type = item.endsWith('.js') diff --git a/packages/bundler-webpack/src/build/renderPage.ts b/packages/bundler-webpack/src/build/renderPage.ts index 135029142b..bbf7dce262 100644 --- a/packages/bundler-webpack/src/build/renderPage.ts +++ b/packages/bundler-webpack/src/build/renderPage.ts @@ -1,5 +1,8 @@ import type { PageChunkFilesMap, PageSSRContext } from '@vuepress/bundlerutils' -import { renderPageToString } from '@vuepress/bundlerutils' +import { + renderPageToString, + resolveLinkedPageChunkFiles, +} from '@vuepress/bundlerutils' import type { App, Page } from '@vuepress/core' import { fs, renderHead } from '@vuepress/utils' import type { App as VueApp } from 'vue' @@ -59,6 +62,11 @@ export const renderPage = async ({ moduleRequests: Array.from(ssrContext._registeredComponents), moduleFilesMetaMap, }) + const linkedPageChunkFiles = resolveLinkedPageChunkFiles({ + base: app.options.base, + page, + pageChunkFilesMap, + }) // generate html string const html = await app.options.templateBuildRenderer(ssrTemplate, { @@ -68,16 +76,14 @@ export const renderPage = async ({ prefetch: renderPagePrefetchLinks({ app, asyncFilesMeta, + linkedPageChunkFiles, pageClientFilesMeta, - page, - pageChunkFilesMap, }), preload: renderPagePreloadLinks({ app, initialFilesMeta, + linkedPageChunkFiles, pageClientFilesMeta, - page, - pageChunkFilesMap, }), scripts: renderPageScripts({ app, initialFilesMeta, pageClientFilesMeta }), styles: renderPageStyles({ app, initialFilesMeta, pageClientFilesMeta }), diff --git a/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts b/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts index 2cf3754bf0..07f376ab1d 100644 --- a/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts +++ b/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts @@ -1,6 +1,4 @@ -import type { PageChunkFilesMap } from '@vuepress/bundlerutils' -import { resolveLinkRoutePath } from '@vuepress/bundlerutils' -import type { App, Page } from '@vuepress/core' +import type { App } from '@vuepress/core' import type { FileMeta } from './types.js' @@ -10,15 +8,13 @@ import type { FileMeta } from './types.js' export const renderPagePrefetchLinks = ({ app, asyncFilesMeta, + linkedPageChunkFiles, pageClientFilesMeta, - page, - pageChunkFilesMap, }: { app: App asyncFilesMeta: FileMeta[] + linkedPageChunkFiles: Set pageClientFilesMeta: FileMeta[] - page: Page - pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPrefetch option const { shouldPrefetch } = app.options @@ -31,24 +27,11 @@ export const renderPagePrefetchLinks = ({ let prefetchFilesMeta: FileMeta[] if (shouldPrefetch === 'as-needed') { - // collect linked page chunk file names - const linkedFileNames = new Set() - for (const link of page.links) { - const routePath = resolveLinkRoutePath(link.absolute, app.options.base) - if (routePath) { - const targetChunks = pageChunkFilesMap.get(routePath) - if (targetChunks) { - for (const file of targetChunks) { - linkedFileNames.add(file) - } - } - } - } // async files excluding files used by current page // filtered to only linked pages' chunk files prefetchFilesMeta = asyncFilesMeta.filter( ({ file }) => - linkedFileNames.has(file) && + linkedPageChunkFiles.has(file) && !pageClientFilesMeta.some((f) => f.file === file), ) } else { diff --git a/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts b/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts index d817209116..2c26ceee94 100644 --- a/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts +++ b/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts @@ -1,6 +1,4 @@ -import type { PageChunkFilesMap } from '@vuepress/bundlerutils' -import { resolveLinkRoutePath } from '@vuepress/bundlerutils' -import type { App, Page } from '@vuepress/core' +import type { App } from '@vuepress/core' import { resolveFileMeta } from './resolveFileMeta.js' import type { FileMeta } from './types.js' @@ -11,15 +9,13 @@ import type { FileMeta } from './types.js' export const renderPagePreloadLinks = ({ app, initialFilesMeta, + linkedPageChunkFiles, pageClientFilesMeta, - page, - pageChunkFilesMap, }: { app: App initialFilesMeta: FileMeta[] + linkedPageChunkFiles: Set pageClientFilesMeta: FileMeta[] - page: Page - pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPreload option const { shouldPreload } = app.options @@ -34,20 +30,10 @@ export const renderPagePreloadLinks = ({ // when 'as-needed', also add linked pages' chunk files if (shouldPreload === 'as-needed') { - const linkedFileNames = new Set() - for (const link of page.links) { - const routePath = resolveLinkRoutePath(link.absolute, app.options.base) - if (routePath) { - const targetChunks = pageChunkFilesMap.get(routePath) - if (targetChunks) { - for (const file of targetChunks) { - linkedFileNames.add(file) - } - } - } - } - for (const fileName of linkedFileNames) { - if (!preloadFilesMeta.some((f) => f.file === fileName)) { + const preloadFileNames = new Set(preloadFilesMeta.map(({ file }) => file)) + for (const fileName of linkedPageChunkFiles) { + if (!preloadFileNames.has(fileName)) { + preloadFileNames.add(fileName) preloadFilesMeta.push(resolveFileMeta(fileName)) } } diff --git a/packages/bundlerutils/src/build/index.ts b/packages/bundlerutils/src/build/index.ts index aeb819eb73..8687538eb0 100644 --- a/packages/bundlerutils/src/build/index.ts +++ b/packages/bundlerutils/src/build/index.ts @@ -2,4 +2,5 @@ export * from './createVueServerApp' export * from './getSsrTemplate' export * from './renderPageToString' export * from './resolveLinkRoutePath' +export * from './resolveLinkedPageChunkFiles' export type * from './types' diff --git a/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts b/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts new file mode 100644 index 0000000000..6e87e157d6 --- /dev/null +++ b/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts @@ -0,0 +1,33 @@ +import type { Page } from '@vuepress/core' + +import { resolveLinkRoutePath } from './resolveLinkRoutePath.js' +import type { PageChunkFilesMap } from './types.js' + +/** + * Resolve the chunk output file names of pages linked from the current page. + */ +export const resolveLinkedPageChunkFiles = ({ + base, + page, + pageChunkFilesMap, +}: { + base: string + page: Page + pageChunkFilesMap: PageChunkFilesMap +}): Set => { + const linkedPageChunkFiles = new Set() + + for (const link of page.links) { + const routePath = resolveLinkRoutePath(link.absolute, base) + if (routePath) { + const targetChunks = pageChunkFilesMap.get(routePath) + if (targetChunks) { + for (const file of targetChunks) { + linkedPageChunkFiles.add(file) + } + } + } + } + + return linkedPageChunkFiles +} From 513d1b546ba47677431789d9c0757cf33c4fd8b7 Mon Sep 17 00:00:00 2001 From: Xinyu Liu Date: Fri, 24 Jul 2026 15:26:50 +0800 Subject: [PATCH 07/13] test: add test cases --- e2e/docs/resource-hints/linked.md | 1 + e2e/docs/resource-hints/source.md | 3 + e2e/docs/resource-hints/unlinked.md | 1 + e2e/tests/resource-hints.spec.ts | 114 ++++++++++++++++++ .../tests/build/resolveLinkRoutePath.spec.ts | 20 +++ .../build/resolveLinkedPageChunkFiles.spec.ts | 62 ++++++++++ 6 files changed, 201 insertions(+) create mode 100644 e2e/docs/resource-hints/linked.md create mode 100644 e2e/docs/resource-hints/source.md create mode 100644 e2e/docs/resource-hints/unlinked.md create mode 100644 e2e/tests/resource-hints.spec.ts create mode 100644 packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts create mode 100644 packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts diff --git a/e2e/docs/resource-hints/linked.md b/e2e/docs/resource-hints/linked.md new file mode 100644 index 0000000000..3f50f6b0d2 --- /dev/null +++ b/e2e/docs/resource-hints/linked.md @@ -0,0 +1 @@ +# Resource Hints Linked Page diff --git a/e2e/docs/resource-hints/source.md b/e2e/docs/resource-hints/source.md new file mode 100644 index 0000000000..11724b9549 --- /dev/null +++ b/e2e/docs/resource-hints/source.md @@ -0,0 +1,3 @@ +# Resource Hints Source + +[Linked page](./linked.md) diff --git a/e2e/docs/resource-hints/unlinked.md b/e2e/docs/resource-hints/unlinked.md new file mode 100644 index 0000000000..1636a558dd --- /dev/null +++ b/e2e/docs/resource-hints/unlinked.md @@ -0,0 +1 @@ +# Resource Hints Unlinked Page diff --git a/e2e/tests/resource-hints.spec.ts b/e2e/tests/resource-hints.spec.ts new file mode 100644 index 0000000000..40c21798df --- /dev/null +++ b/e2e/tests/resource-hints.spec.ts @@ -0,0 +1,114 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' + +import { IS_PROD } from '../utils/env' + +interface PageResourceInfo { + prefetch: string[] + preload: string[] + resources: string[] +} + +const collectPageResourceInfo = async ( + page: Page, + path: string, +): Promise => { + await page.goto(path) + await page.waitForLoadState('networkidle') + + const links = await page + .locator('head link[rel][href]') + .evaluateAll((elements) => + elements.map((element) => ({ + href: element.getAttribute('href') ?? '', + rel: element.getAttribute('rel') ?? '', + })), + ) + const resources = await page.evaluate(() => + performance + .getEntriesByType('resource') + .map(({ name }) => new URL(name).pathname), + ) + + return { + prefetch: links + .filter(({ rel }) => rel === 'prefetch') + .map(({ href }) => href), + preload: links + .filter(({ rel }) => rel === 'preload' || rel === 'modulepreload') + .map(({ href }) => href), + resources: resources.filter((file) => /\.(?:css|js)$/.test(file)), + } +} + +const difference = (values: string[], otherValues: string[]): string[] => + values.filter((value) => !otherValues.includes(value)) + +if (IS_PROD) { + test.describe('as-needed resource hints', () => { + let sourceInfo: PageResourceInfo + let linkedPageFiles: string[] + let sourcePageFiles: string[] + let unlinkedPageFiles: string[] + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + + const linkedInfo = await collectPageResourceInfo( + page, + 'resource-hints/linked.html', + ) + const unlinkedInfo = await collectPageResourceInfo( + page, + 'resource-hints/unlinked.html', + ) + sourceInfo = await collectPageResourceInfo( + page, + 'resource-hints/source.html', + ) + + linkedPageFiles = difference(linkedInfo.resources, unlinkedInfo.resources) + unlinkedPageFiles = difference( + unlinkedInfo.resources, + linkedInfo.resources, + ) + sourcePageFiles = sourceInfo.resources.filter( + (file) => + !linkedInfo.resources.includes(file) && + !unlinkedInfo.resources.includes(file), + ) + + await page.close() + }) + + test('should only preload linked page files', () => { + expect(linkedPageFiles.length).toBeGreaterThan(0) + expect(unlinkedPageFiles.length).toBeGreaterThan(0) + + linkedPageFiles.forEach((file) => { + expect(sourceInfo.preload).toContain(file) + }) + unlinkedPageFiles.forEach((file) => { + expect(sourceInfo.preload).not.toContain(file) + }) + }) + + test('should only prefetch linked page files', () => { + expect(sourcePageFiles.length).toBeGreaterThan(0) + expect(sourceInfo.prefetch.length).toBeGreaterThan(0) + + linkedPageFiles.forEach((file) => { + expect(sourceInfo.prefetch).toContain(file) + }) + sourcePageFiles.forEach((file) => { + expect(sourceInfo.prefetch).not.toContain(file) + }) + unlinkedPageFiles.forEach((file) => { + expect(sourceInfo.prefetch).not.toContain(file) + }) + sourceInfo.prefetch.forEach((file) => { + expect(linkedPageFiles).toContain(file) + }) + }) + }) +} diff --git a/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts b/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts new file mode 100644 index 0000000000..76342b9dcc --- /dev/null +++ b/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from 'vitest' + +import { resolveLinkRoutePath } from '../../src/index.js' + +const TEST_CASES = [ + [null, '/', null], + ['/foo.md', '/', '/foo.html'], + ['/foo/README.md', '/', '/foo/'], + ['/base/foo.md', '/base/', '/foo.html'], + ['/base/foo/index.html', '/base/', '/foo/'], + ['/base/base/foo.md', '/base/', '/base/foo.html'], + ['/foo.md', '/base/', '/foo.html'], +] as const + +test.for(TEST_CASES)( + 'should resolve %s with base %s to %s', + ([absolute, base, expected]) => { + expect(resolveLinkRoutePath(absolute, base)).toBe(expected) + }, +) diff --git a/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts b/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts new file mode 100644 index 0000000000..bd4a4860ea --- /dev/null +++ b/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts @@ -0,0 +1,62 @@ +import type { Page } from '@vuepress/core' +import { expect, test } from 'vitest' + +import type { PageChunkFilesMap } from '../../src/index.js' +import { resolveLinkedPageChunkFiles } from '../../src/index.js' + +const createPage = (links: Page['links']): Page => ({ links }) as Page + +test('should resolve and dedupe linked page chunk files', () => { + const page = createPage([ + { + raw: './foo.md', + relative: 'guide/foo.md', + absolute: '/base/guide/foo.md', + }, + { + raw: './bar/README.md', + relative: 'guide/bar/README.md', + absolute: '/base/guide/bar/README.md', + }, + { + raw: './foo.md', + relative: 'guide/foo.md', + absolute: '/base/guide/foo.md', + }, + ]) + const pageChunkFilesMap: PageChunkFilesMap = new Map([ + ['/guide/foo.html', ['foo.js', 'shared.js']], + ['/guide/bar/', ['bar.js', 'shared.js']], + ]) + + expect( + resolveLinkedPageChunkFiles({ + base: '/base/', + page, + pageChunkFilesMap, + }), + ).toEqual(new Set(['foo.js', 'shared.js', 'bar.js'])) +}) + +test('should ignore unresolvable links and links without mapped chunks', () => { + const page = createPage([ + { + raw: './virtual.md', + relative: 'virtual.md', + absolute: null, + }, + { + raw: './missing.md', + relative: 'missing.md', + absolute: '/missing.md', + }, + ]) + + expect( + resolveLinkedPageChunkFiles({ + base: '/', + page, + pageChunkFilesMap: new Map(), + }), + ).toEqual(new Set()) +}) From 9dae32cf001a73fd181cb2893ea04bbb586129a2 Mon Sep 17 00:00:00 2001 From: Xinyu Liu Date: Fri, 24 Jul 2026 15:35:41 +0800 Subject: [PATCH 08/13] feat: resolve redirects and permalink --- e2e/docs/resource-hints/linked-permalink.md | 5 ++ e2e/docs/resource-hints/source.md | 4 +- e2e/tests/resource-hints.spec.ts | 76 ++++++++++++++----- packages/bundler-vite/src/build/build.ts | 17 +++-- packages/bundler-webpack/src/build/build.ts | 20 ++--- .../src/build/createPageChunkFilesMap.ts | 28 +++++++ packages/bundlerutils/src/build/index.ts | 1 + .../build/createPageChunkFilesMap.spec.ts | 51 +++++++++++++ .../core/src/app/prepare/prepareRoutes.ts | 5 +- 9 files changed, 170 insertions(+), 37 deletions(-) create mode 100644 e2e/docs/resource-hints/linked-permalink.md create mode 100644 packages/bundlerutils/src/build/createPageChunkFilesMap.ts create mode 100644 packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts diff --git a/e2e/docs/resource-hints/linked-permalink.md b/e2e/docs/resource-hints/linked-permalink.md new file mode 100644 index 0000000000..2ab4081f65 --- /dev/null +++ b/e2e/docs/resource-hints/linked-permalink.md @@ -0,0 +1,5 @@ +--- +permalink: /resource-hints/linked-permalink/ +--- + +# Resource Hints Linked Permalink Page diff --git a/e2e/docs/resource-hints/source.md b/e2e/docs/resource-hints/source.md index 11724b9549..78635673d1 100644 --- a/e2e/docs/resource-hints/source.md +++ b/e2e/docs/resource-hints/source.md @@ -1,3 +1,5 @@ # Resource Hints Source -[Linked page](./linked.md) +[Linked page without a permalink](./linked.md) + +[Linked page with a permalink](./linked-permalink.md) diff --git a/e2e/tests/resource-hints.spec.ts b/e2e/tests/resource-hints.spec.ts index 40c21798df..405049204a 100644 --- a/e2e/tests/resource-hints.spec.ts +++ b/e2e/tests/resource-hints.spec.ts @@ -44,20 +44,35 @@ const collectPageResourceInfo = async ( const difference = (values: string[], otherValues: string[]): string[] => values.filter((value) => !otherValues.includes(value)) +const LINKED_PAGE_CASES = [ + { + name: 'without a permalink', + path: 'resource-hints/linked.html', + }, + { + name: 'with a permalink', + path: 'resource-hints/linked-permalink/', + }, +] as const + if (IS_PROD) { test.describe('as-needed resource hints', () => { + let allLinkedPageFiles: string[] + let linkedPageFiles: Map let sourceInfo: PageResourceInfo - let linkedPageFiles: string[] let sourcePageFiles: string[] let unlinkedPageFiles: string[] test.beforeAll(async ({ browser }) => { const page = await browser.newPage() - const linkedInfo = await collectPageResourceInfo( - page, - 'resource-hints/linked.html', - ) + const linkedInfos = new Map() + for (const linkedPageCase of LINKED_PAGE_CASES) { + linkedInfos.set( + linkedPageCase.path, + await collectPageResourceInfo(page, linkedPageCase.path), + ) + } const unlinkedInfo = await collectPageResourceInfo( page, 'resource-hints/unlinked.html', @@ -67,27 +82,57 @@ if (IS_PROD) { 'resource-hints/source.html', ) - linkedPageFiles = difference(linkedInfo.resources, unlinkedInfo.resources) + const allLinkedPageResources = [ + ...new Set( + [...linkedInfos.values()].flatMap(({ resources }) => resources), + ), + ] + linkedPageFiles = new Map( + [...linkedInfos].map(([path, { resources }]) => [ + path, + difference(resources, unlinkedInfo.resources), + ]), + ) + allLinkedPageFiles = difference( + allLinkedPageResources, + unlinkedInfo.resources, + ) unlinkedPageFiles = difference( unlinkedInfo.resources, - linkedInfo.resources, + allLinkedPageResources, ) sourcePageFiles = sourceInfo.resources.filter( (file) => - !linkedInfo.resources.includes(file) && + !allLinkedPageResources.includes(file) && !unlinkedInfo.resources.includes(file), ) await page.close() }) - test('should only preload linked page files', () => { - expect(linkedPageFiles.length).toBeGreaterThan(0) - expect(unlinkedPageFiles.length).toBeGreaterThan(0) + LINKED_PAGE_CASES.forEach((linkedPageCase) => { + test(`should preload linked page files ${linkedPageCase.name}`, () => { + const pageFiles = linkedPageFiles.get(linkedPageCase.path) ?? [] - linkedPageFiles.forEach((file) => { - expect(sourceInfo.preload).toContain(file) + expect(pageFiles.length).toBeGreaterThan(0) + pageFiles.forEach((file) => { + expect(sourceInfo.preload).toContain(file) + }) }) + + test(`should prefetch linked page files ${linkedPageCase.name}`, () => { + const pageFiles = linkedPageFiles.get(linkedPageCase.path) ?? [] + + expect(pageFiles.length).toBeGreaterThan(0) + pageFiles.forEach((file) => { + expect(sourceInfo.prefetch).toContain(file) + }) + }) + }) + + test('should not preload unlinked page files', () => { + expect(unlinkedPageFiles.length).toBeGreaterThan(0) + unlinkedPageFiles.forEach((file) => { expect(sourceInfo.preload).not.toContain(file) }) @@ -97,9 +142,6 @@ if (IS_PROD) { expect(sourcePageFiles.length).toBeGreaterThan(0) expect(sourceInfo.prefetch.length).toBeGreaterThan(0) - linkedPageFiles.forEach((file) => { - expect(sourceInfo.prefetch).toContain(file) - }) sourcePageFiles.forEach((file) => { expect(sourceInfo.prefetch).not.toContain(file) }) @@ -107,7 +149,7 @@ if (IS_PROD) { expect(sourceInfo.prefetch).not.toContain(file) }) sourceInfo.prefetch.forEach((file) => { - expect(linkedPageFiles).toContain(file) + expect(allLinkedPageFiles).toContain(file) }) }) }) diff --git a/packages/bundler-vite/src/build/build.ts b/packages/bundler-vite/src/build/build.ts index a569950309..aa9a40a82d 100644 --- a/packages/bundler-vite/src/build/build.ts +++ b/packages/bundler-vite/src/build/build.ts @@ -1,5 +1,8 @@ -import type { PageChunkFilesMap } from '@vuepress/bundlerutils' -import { createVueServerApp, getSsrTemplate } from '@vuepress/bundlerutils' +import { + createPageChunkFilesMap, + createVueServerApp, + getSsrTemplate, +} from '@vuepress/bundlerutils' import type { App, Bundler } from '@vuepress/core' import { colors, debug, fs, withSpinner } from '@vuepress/utils' import type { OutputAsset, OutputChunk, RolldownOutput } from 'rolldown' @@ -68,13 +71,11 @@ export const build = async ( const ssrTemplate = await getSsrTemplate(app) // build page path -> chunk files map for preload & prefetch - const pageChunkFilesMap: PageChunkFilesMap = new Map() - for (const page of app.pages) { - pageChunkFilesMap.set( - page.path, + const pageChunkFilesMap = createPageChunkFilesMap({ + pages: app.pages, + resolvePageChunkFiles: (page) => resolvePageChunkFiles({ page, output: clientOutput.output }), - ) - } + }) // pre-render pages to html files for (const page of app.pages) { diff --git a/packages/bundler-webpack/src/build/build.ts b/packages/bundler-webpack/src/build/build.ts index 254a3dabcc..081403ee0d 100644 --- a/packages/bundler-webpack/src/build/build.ts +++ b/packages/bundler-webpack/src/build/build.ts @@ -1,5 +1,8 @@ -import type { PageChunkFilesMap } from '@vuepress/bundlerutils' -import { createVueServerApp, getSsrTemplate } from '@vuepress/bundlerutils' +import { + createPageChunkFilesMap, + createVueServerApp, + getSsrTemplate, +} from '@vuepress/bundlerutils' import type { App, Bundler } from '@vuepress/core' import { colors, debug, fs, logger, withSpinner } from '@vuepress/utils' import type { MultiConfiguration } from 'webpack' @@ -80,14 +83,11 @@ export const build = async ( resolveClientManifestMeta(clientManifest) // build page path -> chunk files map for preload & prefetch - const pageChunkFilesMap: PageChunkFilesMap = new Map() - for (const page of app.pages) { - const pageFileNames = clientManifest.chunks[page.chunkName] ?? [] - - if (pageFileNames.length) { - pageChunkFilesMap.set(page.path, pageFileNames) - } - } + const pageChunkFilesMap = createPageChunkFilesMap({ + pages: app.pages, + resolvePageChunkFiles: (page) => + clientManifest.chunks[page.chunkName] ?? [], + }) // create vue ssr app and get ssr template const { vueApp, vueRouter } = await createVueServerApp( diff --git a/packages/bundlerutils/src/build/createPageChunkFilesMap.ts b/packages/bundlerutils/src/build/createPageChunkFilesMap.ts new file mode 100644 index 0000000000..b0b67f0e1e --- /dev/null +++ b/packages/bundlerutils/src/build/createPageChunkFilesMap.ts @@ -0,0 +1,28 @@ +import type { Page } from '@vuepress/core' +import { resolvePageRedirects } from '@vuepress/core' + +import type { PageChunkFilesMap } from './types.js' + +/** + * Create a map from page route paths to their chunk output file names. + */ +export const createPageChunkFilesMap = ({ + pages, + resolvePageChunkFiles, +}: { + pages: Page[] + resolvePageChunkFiles: (page: Page) => string[] +}): PageChunkFilesMap => { + const pageChunkFilesMap: PageChunkFilesMap = new Map() + + for (const page of pages) { + const pageChunkFiles = resolvePageChunkFiles(page) + + pageChunkFilesMap.set(page.path, pageChunkFiles) + for (const redirect of resolvePageRedirects(page)) { + pageChunkFilesMap.set(redirect, pageChunkFiles) + } + } + + return pageChunkFilesMap +} diff --git a/packages/bundlerutils/src/build/index.ts b/packages/bundlerutils/src/build/index.ts index 8687538eb0..18abd8db83 100644 --- a/packages/bundlerutils/src/build/index.ts +++ b/packages/bundlerutils/src/build/index.ts @@ -1,3 +1,4 @@ +export * from './createPageChunkFilesMap' export * from './createVueServerApp' export * from './getSsrTemplate' export * from './renderPageToString' diff --git a/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts b/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts new file mode 100644 index 0000000000..3cbd847976 --- /dev/null +++ b/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts @@ -0,0 +1,51 @@ +import type { Page } from '@vuepress/core' +import { describe, expect, it } from 'vitest' + +import { createPageChunkFilesMap } from '../../src/index.js' + +const createPage = ( + page: Pick, +): Page => page as Page + +describe('createPageChunkFilesMap', () => { + it('should map final paths and inferred route aliases to page chunks', () => { + const pages = [ + createPage({ + chunkName: 'permalink', + path: '/permalink/', + pathInferred: '/posts/foo.md', + }), + createPage({ + chunkName: 'custom-path', + path: '/custom/', + pathInferred: '/中文/README.md', + }), + createPage({ + chunkName: 'regular', + path: '/regular.html', + pathInferred: '/regular.md', + }), + createPage({ + chunkName: 'virtual', + path: '/virtual.html', + pathInferred: null, + }), + ] + + expect( + createPageChunkFilesMap({ + pages, + resolvePageChunkFiles: (page) => [`${page.chunkName}.js`], + }), + ).toEqual( + new Map([ + ['/permalink/', ['permalink.js']], + ['/posts/foo.html', ['permalink.js']], + ['/custom/', ['custom-path.js']], + ['/%E4%B8%AD%E6%96%87/', ['custom-path.js']], + ['/regular.html', ['regular.js']], + ['/virtual.html', ['virtual.js']], + ]), + ) + }) +}) diff --git a/packages/core/src/app/prepare/prepareRoutes.ts b/packages/core/src/app/prepare/prepareRoutes.ts index 7f40f68faf..dced59609a 100644 --- a/packages/core/src/app/prepare/prepareRoutes.ts +++ b/packages/core/src/app/prepare/prepareRoutes.ts @@ -25,7 +25,10 @@ if (import.meta.hot) { * * @internal */ -const resolvePageRedirects = ({ path, pathInferred }: Page): string[] => { +export const resolvePageRedirects = ({ + path, + pathInferred, +}: Page): string[] => { // paths that should redirect to this page, use set to dedupe const redirectsSet = new Set() From b8a1e6ec9bdd1b85b476b24bce283269ee69deb4 Mon Sep 17 00:00:00 2001 From: Xinyu Liu Date: Fri, 24 Jul 2026 16:02:35 +0800 Subject: [PATCH 09/13] refactor: remove as-needed from preload --- e2e/tests/resource-hints.spec.ts | 6 ++-- packages/bundler-vite/src/build/renderPage.ts | 14 ++------- .../src/build/renderPagePrefetchLinks.ts | 16 ++++++++-- .../src/build/renderPagePreloadLinks.ts | 31 ++++++------------- .../bundler-webpack/src/build/renderPage.ts | 14 ++------- .../src/build/renderPagePrefetchLinks.ts | 16 ++++++++-- .../src/build/renderPagePreloadLinks.ts | 20 +----------- packages/core/src/app/resolveAppOptions.ts | 2 +- packages/core/src/types/app/options.ts | 7 ++--- .../core/tests/app/resolveAppOptions.spec.ts | 2 +- 10 files changed, 50 insertions(+), 78 deletions(-) diff --git a/e2e/tests/resource-hints.spec.ts b/e2e/tests/resource-hints.spec.ts index 405049204a..f85393a1cd 100644 --- a/e2e/tests/resource-hints.spec.ts +++ b/e2e/tests/resource-hints.spec.ts @@ -56,7 +56,7 @@ const LINKED_PAGE_CASES = [ ] as const if (IS_PROD) { - test.describe('as-needed resource hints', () => { + test.describe('default resource hints', () => { let allLinkedPageFiles: string[] let linkedPageFiles: Map let sourceInfo: PageResourceInfo @@ -111,12 +111,12 @@ if (IS_PROD) { }) LINKED_PAGE_CASES.forEach((linkedPageCase) => { - test(`should preload linked page files ${linkedPageCase.name}`, () => { + test(`should not preload linked page files ${linkedPageCase.name}`, () => { const pageFiles = linkedPageFiles.get(linkedPageCase.path) ?? [] expect(pageFiles.length).toBeGreaterThan(0) pageFiles.forEach((file) => { - expect(sourceInfo.preload).toContain(file) + expect(sourceInfo.preload).not.toContain(file) }) }) diff --git a/packages/bundler-vite/src/build/renderPage.ts b/packages/bundler-vite/src/build/renderPage.ts index 5f23bffc81..599b7292fa 100644 --- a/packages/bundler-vite/src/build/renderPage.ts +++ b/packages/bundler-vite/src/build/renderPage.ts @@ -1,8 +1,5 @@ import type { PageChunkFilesMap } from '@vuepress/bundlerutils' -import { - renderPageToString, - resolveLinkedPageChunkFiles, -} from '@vuepress/bundlerutils' +import { renderPageToString } from '@vuepress/bundlerutils' import type { App, Page } from '@vuepress/core' import { fs, renderHead } from '@vuepress/utils' import type { OutputAsset, OutputChunk, RolldownOutput } from 'rolldown' @@ -45,11 +42,6 @@ export const renderPage = async ({ // resolve page chunks const pageChunkFiles = resolvePageChunkFiles({ page, output }) - const linkedPageChunkFiles = resolveLinkedPageChunkFiles({ - base: app.options.base, - page, - pageChunkFilesMap, - }) // generate html string const html = await app.options.templateBuildRenderer(ssrTemplate, { @@ -58,13 +50,13 @@ export const renderPage = async ({ lang: ssrContext.lang, prefetch: renderPagePrefetchLinks({ app, - linkedPageChunkFiles, outputEntryChunk, + page, pageChunkFiles, + pageChunkFilesMap, }), preload: renderPagePreloadLinks({ app, - linkedPageChunkFiles, outputEntryChunk, pageChunkFiles, }), diff --git a/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts b/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts index 5e211e2cd0..b339f0a60c 100644 --- a/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts +++ b/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts @@ -1,4 +1,6 @@ -import type { App } from '@vuepress/core' +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' +import { resolveLinkedPageChunkFiles } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' import type { OutputChunk } from 'rolldown' /** @@ -6,14 +8,16 @@ import type { OutputChunk } from 'rolldown' */ export const renderPagePrefetchLinks = ({ app, - linkedPageChunkFiles, outputEntryChunk, + page, pageChunkFiles, + pageChunkFilesMap, }: { app: App - linkedPageChunkFiles: Set outputEntryChunk: OutputChunk + page: Page pageChunkFiles: string[] + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPrefetch option const { shouldPrefetch } = app.options @@ -26,6 +30,12 @@ export const renderPagePrefetchLinks = ({ let candidateFiles: string[] if (shouldPrefetch === 'as-needed') { + const linkedPageChunkFiles = resolveLinkedPageChunkFiles({ + base: app.options.base, + page, + pageChunkFilesMap, + }) + // dynamic imports excluding current page chunks // filtered to only linked pages' chunk files candidateFiles = outputEntryChunk.dynamicImports.filter( diff --git a/packages/bundler-vite/src/build/renderPagePreloadLinks.ts b/packages/bundler-vite/src/build/renderPagePreloadLinks.ts index 0fea60b582..06b481c7fd 100644 --- a/packages/bundler-vite/src/build/renderPagePreloadLinks.ts +++ b/packages/bundler-vite/src/build/renderPagePreloadLinks.ts @@ -6,12 +6,10 @@ import type { OutputChunk } from 'rolldown' */ export const renderPagePreloadLinks = ({ app, - linkedPageChunkFiles, outputEntryChunk, pageChunkFiles, }: { app: App - linkedPageChunkFiles: Set outputEntryChunk: OutputChunk pageChunkFiles: string[] }): string => { @@ -24,20 +22,15 @@ export const renderPagePreloadLinks = ({ } // dedupe entry chunks and page chunks - const preloadFiles = new Set([ - outputEntryChunk.fileName, - ...outputEntryChunk.imports, - ...pageChunkFiles, - ]) - - // when 'as-needed', also add linked pages' chunk files - if (shouldPreload === 'as-needed') { - for (const file of linkedPageChunkFiles) { - preloadFiles.add(file) - } - } - - return Array.from(preloadFiles) + const preloadFiles = Array.from( + new Set([ + outputEntryChunk.fileName, + ...outputEntryChunk.imports, + ...pageChunkFiles, + ]), + ) + + return preloadFiles .map((item) => { // resolve file type const type = item.endsWith('.js') @@ -52,11 +45,7 @@ export const renderPagePreloadLinks = ({ } // user wants to explicitly control what to preload - if ( - shouldPreload !== true && - shouldPreload !== 'as-needed' && - !shouldPreload(item, type) - ) { + if (shouldPreload !== true && !shouldPreload(item, type)) { return '' } diff --git a/packages/bundler-webpack/src/build/renderPage.ts b/packages/bundler-webpack/src/build/renderPage.ts index bbf7dce262..f5413d031e 100644 --- a/packages/bundler-webpack/src/build/renderPage.ts +++ b/packages/bundler-webpack/src/build/renderPage.ts @@ -1,8 +1,5 @@ import type { PageChunkFilesMap, PageSSRContext } from '@vuepress/bundlerutils' -import { - renderPageToString, - resolveLinkedPageChunkFiles, -} from '@vuepress/bundlerutils' +import { renderPageToString } from '@vuepress/bundlerutils' import type { App, Page } from '@vuepress/core' import { fs, renderHead } from '@vuepress/utils' import type { App as VueApp } from 'vue' @@ -62,11 +59,6 @@ export const renderPage = async ({ moduleRequests: Array.from(ssrContext._registeredComponents), moduleFilesMetaMap, }) - const linkedPageChunkFiles = resolveLinkedPageChunkFiles({ - base: app.options.base, - page, - pageChunkFilesMap, - }) // generate html string const html = await app.options.templateBuildRenderer(ssrTemplate, { @@ -76,13 +68,13 @@ export const renderPage = async ({ prefetch: renderPagePrefetchLinks({ app, asyncFilesMeta, - linkedPageChunkFiles, + page, pageClientFilesMeta, + pageChunkFilesMap, }), preload: renderPagePreloadLinks({ app, initialFilesMeta, - linkedPageChunkFiles, pageClientFilesMeta, }), scripts: renderPageScripts({ app, initialFilesMeta, pageClientFilesMeta }), diff --git a/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts b/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts index 07f376ab1d..6172d9197c 100644 --- a/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts +++ b/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts @@ -1,4 +1,6 @@ -import type { App } from '@vuepress/core' +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' +import { resolveLinkedPageChunkFiles } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' import type { FileMeta } from './types.js' @@ -8,13 +10,15 @@ import type { FileMeta } from './types.js' export const renderPagePrefetchLinks = ({ app, asyncFilesMeta, - linkedPageChunkFiles, + page, pageClientFilesMeta, + pageChunkFilesMap, }: { app: App asyncFilesMeta: FileMeta[] - linkedPageChunkFiles: Set + page: Page pageClientFilesMeta: FileMeta[] + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPrefetch option const { shouldPrefetch } = app.options @@ -27,6 +31,12 @@ export const renderPagePrefetchLinks = ({ let prefetchFilesMeta: FileMeta[] if (shouldPrefetch === 'as-needed') { + const linkedPageChunkFiles = resolveLinkedPageChunkFiles({ + base: app.options.base, + page, + pageChunkFilesMap, + }) + // async files excluding files used by current page // filtered to only linked pages' chunk files prefetchFilesMeta = asyncFilesMeta.filter( diff --git a/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts b/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts index 2c26ceee94..e3dda92dee 100644 --- a/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts +++ b/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts @@ -1,6 +1,5 @@ import type { App } from '@vuepress/core' -import { resolveFileMeta } from './resolveFileMeta.js' import type { FileMeta } from './types.js' /** @@ -9,12 +8,10 @@ import type { FileMeta } from './types.js' export const renderPagePreloadLinks = ({ app, initialFilesMeta, - linkedPageChunkFiles, pageClientFilesMeta, }: { app: App initialFilesMeta: FileMeta[] - linkedPageChunkFiles: Set pageClientFilesMeta: FileMeta[] }): string => { // shouldPreload option @@ -28,17 +25,6 @@ export const renderPagePreloadLinks = ({ // initial files and files used by current page should be preload const preloadFilesMeta = [...initialFilesMeta, ...pageClientFilesMeta] - // when 'as-needed', also add linked pages' chunk files - if (shouldPreload === 'as-needed') { - const preloadFileNames = new Set(preloadFilesMeta.map(({ file }) => file)) - for (const fileName of linkedPageChunkFiles) { - if (!preloadFileNames.has(fileName)) { - preloadFileNames.add(fileName) - preloadFilesMeta.push(resolveFileMeta(fileName)) - } - } - } - return preloadFilesMeta .map(({ file, extension, type }) => { // by default, we only preload scripts or css @@ -47,11 +33,7 @@ export const renderPagePreloadLinks = ({ } // user wants to explicitly control what to preload - if ( - shouldPreload !== true && - shouldPreload !== 'as-needed' && - !shouldPreload(file, type) - ) { + if (shouldPreload !== true && !shouldPreload(file, type)) { return '' } diff --git a/packages/core/src/app/resolveAppOptions.ts b/packages/core/src/app/resolveAppOptions.ts index 3f0f477ccf..7b3cba3234 100644 --- a/packages/core/src/app/resolveAppOptions.ts +++ b/packages/core/src/app/resolveAppOptions.ts @@ -33,7 +33,7 @@ export const resolveAppOptions = ({ require.resolve('@vuepress/client/templates/dev.html'), ), // build config - shouldPreload = 'as-needed', + shouldPreload = true, shouldPrefetch = 'as-needed', templateBuild = path.normalize( require.resolve('@vuepress/client/templates/build.html'), diff --git a/packages/core/src/types/app/options.ts b/packages/core/src/types/app/options.ts index 382d4423d4..c2b0e24f13 100644 --- a/packages/core/src/types/app/options.ts +++ b/packages/core/src/types/app/options.ts @@ -151,12 +151,9 @@ export interface AppConfigBuild { * Determine what resource files should be preloaded. Use boolean value to * totally enable / disable. * - * @default 'as-needed' + * @default true */ - shouldPreload?: - | boolean - | 'as-needed' - | ((file: string, type: string) => boolean) + shouldPreload?: boolean | ((file: string, type: string) => boolean) /** * Determine what resource files should be prefetched. Use boolean value to diff --git a/packages/core/tests/app/resolveAppOptions.spec.ts b/packages/core/tests/app/resolveAppOptions.spec.ts index 4b2fcba582..b5633a23e7 100644 --- a/packages/core/tests/app/resolveAppOptions.spec.ts +++ b/packages/core/tests/app/resolveAppOptions.spec.ts @@ -43,7 +43,7 @@ it('should create app options with default values', () => { require.resolve('@vuepress/client/templates/build.html'), ), templateBuildRenderer: templateRenderer, - shouldPreload: 'as-needed', + shouldPreload: true, shouldPrefetch: 'as-needed', markdown: {}, plugins: [], From e5b586e5f4cfbc10a18d4767b01f2825dd05b828 Mon Sep 17 00:00:00 2001 From: Xinyu Liu Date: Fri, 24 Jul 2026 16:22:04 +0800 Subject: [PATCH 10/13] feat: resolve virtual pages --- e2e/docs/.vuepress/config.ts | 15 ++++- e2e/tests/resource-hints.spec.ts | 37 +++++++++++ packages/bundlerutils/package.json | 1 + .../src/build/resolveLinkRoutePath.ts | 28 ++++++--- .../src/build/resolveLinkedPageChunkFiles.ts | 6 +- .../tests/build/resolveLinkRoutePath.spec.ts | 61 +++++++++++++++---- .../build/resolveLinkedPageChunkFiles.spec.ts | 49 +++++++++++++-- pnpm-lock.yaml | 3 + 8 files changed, 173 insertions(+), 27 deletions(-) diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts index 4e9fada490..5536c9350d 100644 --- a/e2e/docs/.vuepress/config.ts +++ b/e2e/docs/.vuepress/config.ts @@ -2,7 +2,7 @@ import process from 'node:process' import { viteBundler } from '@vuepress/bundler-vite' import { webpackBundler } from '@vuepress/bundler-webpack' -import { defineUserConfig } from 'vuepress' +import { createPage, defineUserConfig } from 'vuepress' import { path } from 'vuepress/utils' import { fooPlugin } from './plugins/foo/fooPlugin.js' @@ -78,6 +78,19 @@ export default defineUserConfig({ } }, + onInitialized: async (app) => { + app.pages.push( + await createPage(app, { + path: '/resource-hints/virtual-source.html', + content: [ + '# Virtual Resource Hints Source', + '[Linked page without a permalink](./linked.md)', + '[Linked page with a permalink](./linked-permalink.md)', + ].join('\n\n'), + }), + ) + }, + plugins: [fooPlugin], // The alias entries are intentionally ordered by key length to ensure diff --git a/e2e/tests/resource-hints.spec.ts b/e2e/tests/resource-hints.spec.ts index f85393a1cd..136d477518 100644 --- a/e2e/tests/resource-hints.spec.ts +++ b/e2e/tests/resource-hints.spec.ts @@ -62,6 +62,8 @@ if (IS_PROD) { let sourceInfo: PageResourceInfo let sourcePageFiles: string[] let unlinkedPageFiles: string[] + let virtualSourceInfo: PageResourceInfo + let virtualSourcePageFiles: string[] test.beforeAll(async ({ browser }) => { const page = await browser.newPage() @@ -81,6 +83,10 @@ if (IS_PROD) { page, 'resource-hints/source.html', ) + virtualSourceInfo = await collectPageResourceInfo( + page, + 'resource-hints/virtual-source.html', + ) const allLinkedPageResources = [ ...new Set( @@ -106,6 +112,11 @@ if (IS_PROD) { !allLinkedPageResources.includes(file) && !unlinkedInfo.resources.includes(file), ) + virtualSourcePageFiles = virtualSourceInfo.resources.filter( + (file) => + !allLinkedPageResources.includes(file) && + !unlinkedInfo.resources.includes(file), + ) await page.close() }) @@ -152,5 +163,31 @@ if (IS_PROD) { expect(allLinkedPageFiles).toContain(file) }) }) + + test('should not preload linked page files from a virtual page', () => { + expect(allLinkedPageFiles.length).toBeGreaterThan(0) + + allLinkedPageFiles.forEach((file) => { + expect(virtualSourceInfo.preload).not.toContain(file) + }) + }) + + test('should only prefetch linked page files from a virtual page', () => { + expect(virtualSourcePageFiles.length).toBeGreaterThan(0) + expect(virtualSourceInfo.prefetch.length).toBeGreaterThan(0) + + allLinkedPageFiles.forEach((file) => { + expect(virtualSourceInfo.prefetch).toContain(file) + }) + virtualSourcePageFiles.forEach((file) => { + expect(virtualSourceInfo.prefetch).not.toContain(file) + }) + unlinkedPageFiles.forEach((file) => { + expect(virtualSourceInfo.prefetch).not.toContain(file) + }) + virtualSourceInfo.prefetch.forEach((file) => { + expect(allLinkedPageFiles).toContain(file) + }) + }) }) } diff --git a/packages/bundlerutils/package.json b/packages/bundlerutils/package.json index 6f9459015f..26f168244e 100644 --- a/packages/bundlerutils/package.json +++ b/packages/bundlerutils/package.json @@ -38,6 +38,7 @@ "dependencies": { "@vuepress/client": "workspace:*", "@vuepress/core": "workspace:*", + "@vuepress/markdown": "workspace:*", "@vuepress/shared": "workspace:*", "@vuepress/utils": "workspace:*", "vue": "catalog:vue", diff --git a/packages/bundlerutils/src/build/resolveLinkRoutePath.ts b/packages/bundlerutils/src/build/resolveLinkRoutePath.ts index 446e2f45e1..42cbe1a9e8 100644 --- a/packages/bundlerutils/src/build/resolveLinkRoutePath.ts +++ b/packages/bundlerutils/src/build/resolveLinkRoutePath.ts @@ -1,21 +1,29 @@ -import { inferRoutePath } from '@vuepress/shared' +import type { MarkdownLink } from '@vuepress/markdown' +import { inferRoutePath, normalizeRoutePath } from '@vuepress/shared' /** - * Normalize a MarkdownLink's absolute path to a route path + * Normalize a MarkdownLink to a route path * that can be matched against Page.path. * * Returns null if the link is unresolvable. */ -export const resolveLinkRoutePath = ( - absolute: string | null, - base: string, -): string | null => { - if (!absolute) return null +export const resolveLinkRoutePath = ({ + base, + current, + link, +}: { + base: string + current: string + link: MarkdownLink +}): string | null => { + if (!link.absolute) { + return link.relative ? normalizeRoutePath(link.relative, current) : null + } // Strip base prefix, ensuring leading slash - const pathWithoutBase = absolute.startsWith(base) - ? `/${absolute.slice(base.length)}` - : absolute + const pathWithoutBase = link.absolute.startsWith(base) + ? `/${link.absolute.slice(base.length)}` + : link.absolute return inferRoutePath(pathWithoutBase) } diff --git a/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts b/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts index 6e87e157d6..1d209c1b4d 100644 --- a/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts +++ b/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts @@ -18,7 +18,11 @@ export const resolveLinkedPageChunkFiles = ({ const linkedPageChunkFiles = new Set() for (const link of page.links) { - const routePath = resolveLinkRoutePath(link.absolute, base) + const routePath = resolveLinkRoutePath({ + base, + current: page.path, + link, + }) if (routePath) { const targetChunks = pageChunkFilesMap.get(routePath) if (targetChunks) { diff --git a/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts b/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts index 76342b9dcc..4e1970f8c7 100644 --- a/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts +++ b/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts @@ -1,20 +1,59 @@ +import type { MarkdownLink } from '@vuepress/markdown' import { expect, test } from 'vitest' import { resolveLinkRoutePath } from '../../src/index.js' const TEST_CASES = [ - [null, '/', null], - ['/foo.md', '/', '/foo.html'], - ['/foo/README.md', '/', '/foo/'], - ['/base/foo.md', '/base/', '/foo.html'], - ['/base/foo/index.html', '/base/', '/foo/'], - ['/base/base/foo.md', '/base/', '/base/foo.html'], - ['/foo.md', '/base/', '/foo.html'], -] as const + [{ absolute: null, relative: '' }, '/', '/', null], + [{ absolute: '/foo.md', relative: 'foo.md' }, '/', '/', '/foo.html'], + [ + { absolute: '/foo/README.md', relative: 'foo/README.md' }, + '/', + '/', + '/foo/', + ], + [ + { absolute: '/base/foo.md', relative: 'foo.md' }, + '/base/', + '/', + '/foo.html', + ], + [ + { absolute: '/base/foo/index.html', relative: 'foo/index.html' }, + '/base/', + '/', + '/foo/', + ], + [ + { absolute: '/base/base/foo.md', relative: 'base/foo.md' }, + '/base/', + '/', + '/base/foo.html', + ], + [{ absolute: '/foo.md', relative: 'foo.md' }, '/base/', '/', '/foo.html'], + [ + { absolute: null, relative: 'foo.md' }, + '/base/', + '/virtual/page.html', + '/virtual/foo.html', + ], + [ + { absolute: null, relative: '../foo.md' }, + '/', + '/virtual/page.html', + '/foo.html', + ], + [ + { absolute: null, relative: 'foo/README.md' }, + '/', + '/virtual/', + '/virtual/foo/', + ], +] as [MarkdownLink, string, string, string | null][] test.for(TEST_CASES)( - 'should resolve %s with base %s to %s', - ([absolute, base, expected]) => { - expect(resolveLinkRoutePath(absolute, base)).toBe(expected) + 'should resolve $0 with base $1 and current route $2 to $3', + ([link, base, current, expected]) => { + expect(resolveLinkRoutePath({ base, current, link })).toBe(expected) }, ) diff --git a/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts b/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts index bd4a4860ea..dfee3b1f6b 100644 --- a/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts +++ b/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts @@ -1,10 +1,13 @@ -import type { Page } from '@vuepress/core' +import type { Bundler, Page } from '@vuepress/core' +import { createBaseApp, createPage as createVuePressPage } from '@vuepress/core' +import { path } from '@vuepress/utils' import { expect, test } from 'vitest' import type { PageChunkFilesMap } from '../../src/index.js' import { resolveLinkedPageChunkFiles } from '../../src/index.js' -const createPage = (links: Page['links']): Page => ({ links }) as Page +const createPage = (links: Page['links'], pagePath = '/current.html'): Page => + ({ links, path: pagePath }) as Page test('should resolve and dedupe linked page chunk files', () => { const page = createPage([ @@ -38,11 +41,49 @@ test('should resolve and dedupe linked page chunk files', () => { ).toEqual(new Set(['foo.js', 'shared.js', 'bar.js'])) }) +test('should resolve relative links from virtual pages against the current route', async () => { + const app = createBaseApp({ + source: path.resolve(__dirname, 'fake-source'), + theme: { name: 'test' }, + bundler: {} as Bundler, + }) + await app.init() + + const page = await createVuePressPage(app, { + path: '/guide/virtual.html', + content: ['[foo](./foo.md)', '[bar](../bar/README.md)'].join('\n\n'), + }) + const pageChunkFilesMap: PageChunkFilesMap = new Map([ + ['/guide/foo.html', ['foo.js', 'shared.js']], + ['/bar/', ['bar.js', 'shared.js']], + ]) + + expect(page.links).toEqual([ + { + raw: './foo.md', + relative: 'foo.md', + absolute: null, + }, + { + raw: '../bar/README.md', + relative: '../bar/README.md', + absolute: null, + }, + ]) + expect( + resolveLinkedPageChunkFiles({ + base: '/', + page, + pageChunkFilesMap, + }), + ).toEqual(new Set(['foo.js', 'shared.js', 'bar.js'])) +}) + test('should ignore unresolvable links and links without mapped chunks', () => { const page = createPage([ { - raw: './virtual.md', - relative: 'virtual.md', + raw: '', + relative: '', absolute: null, }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67fb8722d9..b987601e86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,6 +287,9 @@ importers: '@vuepress/core': specifier: workspace:* version: link:../core + '@vuepress/markdown': + specifier: workspace:* + version: link:../markdown '@vuepress/shared': specifier: workspace:* version: link:../shared From 9cd6f75a200d2174ce53fd8e12871f4e81c41682 Mon Sep 17 00:00:00 2001 From: Xinyu Liu Date: Fri, 24 Jul 2026 16:39:17 +0800 Subject: [PATCH 11/13] chore: update jsdocs --- packages/core/src/types/app/options.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/core/src/types/app/options.ts b/packages/core/src/types/app/options.ts index c2b0e24f13..f8e6254c86 100644 --- a/packages/core/src/types/app/options.ts +++ b/packages/core/src/types/app/options.ts @@ -148,16 +148,26 @@ export interface AppConfigDev { */ export interface AppConfigBuild { /** - * Determine what resource files should be preloaded. Use boolean value to - * totally enable / disable. + * Determine what resource files should be preloaded. + * + * - `false` disables preloading. + * - `true` preloads script and style resource files used by the current page. + * - A function preloads a candidate file when it returns `true`. It receives + * the output file name and inferred resource type. * * @default true */ shouldPreload?: boolean | ((file: string, type: string) => boolean) /** - * Determine what resource files should be prefetched. Use boolean value to - * totally enable / disable. + * Determine what resource files should be prefetched. + * + * - `false` disables prefetching. + * - `true` prefetches all async resource files not used by the current page. + * - `'as-needed'` prefetches async resource files only for pages linked from + * the current page's Markdown content. + * - A function prefetches a candidate file when it returns `true`. It receives + * the output file name and inferred resource type. * * @default 'as-needed' */ From 90527452d992689f733fc376c67d0ef6dda297c4 Mon Sep 17 00:00:00 2001 From: Xinyu Liu Date: Fri, 24 Jul 2026 17:00:20 +0800 Subject: [PATCH 12/13] feat: updates --- e2e/docs/.vuepress/config.ts | 1 + .../redirect-collision-exact.md | 5 + e2e/docs/resource-hints/redirect-collision.md | 5 + e2e/docs/resource-hints/source.md | 2 + e2e/tests/resource-hints.spec.ts | 4 + .../src/build/createPageChunkFilesMap.ts | 6 +- .../build/createPageChunkFilesMap.spec.ts | 107 +++++++++++------- 7 files changed, 90 insertions(+), 40 deletions(-) create mode 100644 e2e/docs/resource-hints/redirect-collision-exact.md create mode 100644 e2e/docs/resource-hints/redirect-collision.md diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts index 5536c9350d..445a0a4f58 100644 --- a/e2e/docs/.vuepress/config.ts +++ b/e2e/docs/.vuepress/config.ts @@ -86,6 +86,7 @@ export default defineUserConfig({ '# Virtual Resource Hints Source', '[Linked page without a permalink](./linked.md)', '[Linked page with a permalink](./linked-permalink.md)', + '[Linked page whose exact path collides with a redirect](./redirect-collision.md)', ].join('\n\n'), }), ) diff --git a/e2e/docs/resource-hints/redirect-collision-exact.md b/e2e/docs/resource-hints/redirect-collision-exact.md new file mode 100644 index 0000000000..aa485dfc86 --- /dev/null +++ b/e2e/docs/resource-hints/redirect-collision-exact.md @@ -0,0 +1,5 @@ +--- +permalink: /resource-hints/redirect-collision.html +--- + +# Exact Resource Hints Page diff --git a/e2e/docs/resource-hints/redirect-collision.md b/e2e/docs/resource-hints/redirect-collision.md new file mode 100644 index 0000000000..3331c7baef --- /dev/null +++ b/e2e/docs/resource-hints/redirect-collision.md @@ -0,0 +1,5 @@ +--- +permalink: /resource-hints/redirect-collision-permalink/ +--- + +# Resource Hints Redirect Collision diff --git a/e2e/docs/resource-hints/source.md b/e2e/docs/resource-hints/source.md index 78635673d1..be4bdbcef9 100644 --- a/e2e/docs/resource-hints/source.md +++ b/e2e/docs/resource-hints/source.md @@ -3,3 +3,5 @@ [Linked page without a permalink](./linked.md) [Linked page with a permalink](./linked-permalink.md) + +[Linked page whose exact path collides with a redirect](./redirect-collision.md) diff --git a/e2e/tests/resource-hints.spec.ts b/e2e/tests/resource-hints.spec.ts index 136d477518..8cda8e48a6 100644 --- a/e2e/tests/resource-hints.spec.ts +++ b/e2e/tests/resource-hints.spec.ts @@ -53,6 +53,10 @@ const LINKED_PAGE_CASES = [ name: 'with a permalink', path: 'resource-hints/linked-permalink/', }, + { + name: 'with an exact path that collides with a redirect', + path: 'resource-hints/redirect-collision.html', + }, ] as const if (IS_PROD) { diff --git a/packages/bundlerutils/src/build/createPageChunkFilesMap.ts b/packages/bundlerutils/src/build/createPageChunkFilesMap.ts index b0b67f0e1e..299076a263 100644 --- a/packages/bundlerutils/src/build/createPageChunkFilesMap.ts +++ b/packages/bundlerutils/src/build/createPageChunkFilesMap.ts @@ -14,13 +14,17 @@ export const createPageChunkFilesMap = ({ resolvePageChunkFiles: (page: Page) => string[] }): PageChunkFilesMap => { const pageChunkFilesMap: PageChunkFilesMap = new Map() + const pagePaths = new Set(pages.map((page) => page.path)) for (const page of pages) { const pageChunkFiles = resolvePageChunkFiles(page) pageChunkFilesMap.set(page.path, pageChunkFiles) for (const redirect of resolvePageRedirects(page)) { - pageChunkFilesMap.set(redirect, pageChunkFiles) + // Exact page paths take precedence over redirects in the client router. + if (!pagePaths.has(redirect)) { + pageChunkFilesMap.set(redirect, pageChunkFiles) + } } } diff --git a/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts b/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts index 3cbd847976..9d561838b9 100644 --- a/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts +++ b/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts @@ -1,5 +1,5 @@ import type { Page } from '@vuepress/core' -import { describe, expect, it } from 'vitest' +import { expect, test, vi } from 'vitest' import { createPageChunkFilesMap } from '../../src/index.js' @@ -7,45 +7,74 @@ const createPage = ( page: Pick, ): Page => page as Page -describe('createPageChunkFilesMap', () => { - it('should map final paths and inferred route aliases to page chunks', () => { - const pages = [ - createPage({ - chunkName: 'permalink', - path: '/permalink/', - pathInferred: '/posts/foo.md', - }), - createPage({ - chunkName: 'custom-path', - path: '/custom/', - pathInferred: '/中文/README.md', - }), - createPage({ - chunkName: 'regular', - path: '/regular.html', - pathInferred: '/regular.md', - }), - createPage({ - chunkName: 'virtual', - path: '/virtual.html', - pathInferred: null, - }), - ] +test('should map final paths and inferred route aliases to page chunks', () => { + const pages = [ + createPage({ + chunkName: 'permalink', + path: '/permalink/', + pathInferred: '/posts/foo.md', + }), + createPage({ + chunkName: 'custom-path', + path: '/custom/', + pathInferred: '/中文/README.md', + }), + createPage({ + chunkName: 'regular', + path: '/regular.html', + pathInferred: '/regular.md', + }), + createPage({ + chunkName: 'virtual', + path: '/virtual.html', + pathInferred: null, + }), + ] + + expect( + createPageChunkFilesMap({ + pages, + resolvePageChunkFiles: (page) => [`${page.chunkName}.js`], + }), + ).toEqual( + new Map([ + ['/permalink/', ['permalink.js']], + ['/posts/foo.html', ['permalink.js']], + ['/custom/', ['custom-path.js']], + ['/%E4%B8%AD%E6%96%87/', ['custom-path.js']], + ['/regular.html', ['regular.js']], + ['/virtual.html', ['virtual.js']], + ]), + ) +}) + +const exactPage = createPage({ + chunkName: 'exact', + path: '/legacy.html', + pathInferred: null, +}) +const redirectingPage = createPage({ + chunkName: 'permalink', + path: '/permalink/', + pathInferred: '/legacy.md', +}) + +test.for([ + { name: 'before', pages: [exactPage, redirectingPage] }, + { name: 'after', pages: [redirectingPage, exactPage] }, +])( + 'should prioritize an exact page path when it appears $name its redirect alias', + ({ pages }) => { + const resolvePageChunkFiles = vi.fn((page: Page) => [ + `${page.chunkName}.js`, + ]) expect( createPageChunkFilesMap({ pages, - resolvePageChunkFiles: (page) => [`${page.chunkName}.js`], - }), - ).toEqual( - new Map([ - ['/permalink/', ['permalink.js']], - ['/posts/foo.html', ['permalink.js']], - ['/custom/', ['custom-path.js']], - ['/%E4%B8%AD%E6%96%87/', ['custom-path.js']], - ['/regular.html', ['regular.js']], - ['/virtual.html', ['virtual.js']], - ]), - ) - }) -}) + resolvePageChunkFiles, + }).get('/legacy.html'), + ).toEqual(['exact.js']) + expect(resolvePageChunkFiles).toHaveBeenCalledTimes(pages.length) + }, +) From a0756dda6d6c49d9c81db452e87da2b229ce0eeb Mon Sep 17 00:00:00 2001 From: Xinyu Liu Date: Fri, 24 Jul 2026 17:07:43 +0800 Subject: [PATCH 13/13] feat: resolve chunkGroup files in webpack --- e2e/docs/.vuepress/config.ts | 1 + e2e/docs/resource-hints/source.md | 2 ++ e2e/tests/resource-hints.spec.ts | 21 ++++++++++++++++++- .../src/build/createClientPlugin.ts | 16 +++++++------- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts index 445a0a4f58..d670adfb49 100644 --- a/e2e/docs/.vuepress/config.ts +++ b/e2e/docs/.vuepress/config.ts @@ -87,6 +87,7 @@ export default defineUserConfig({ '[Linked page without a permalink](./linked.md)', '[Linked page with a permalink](./linked-permalink.md)', '[Linked page whose exact path collides with a redirect](./redirect-collision.md)', + '[Linked page with extracted CSS](../styles/css-container.md)', ].join('\n\n'), }), ) diff --git a/e2e/docs/resource-hints/source.md b/e2e/docs/resource-hints/source.md index be4bdbcef9..843faae915 100644 --- a/e2e/docs/resource-hints/source.md +++ b/e2e/docs/resource-hints/source.md @@ -5,3 +5,5 @@ [Linked page with a permalink](./linked-permalink.md) [Linked page whose exact path collides with a redirect](./redirect-collision.md) + +[Linked page with extracted CSS](../styles/css-container.md) diff --git a/e2e/tests/resource-hints.spec.ts b/e2e/tests/resource-hints.spec.ts index 8cda8e48a6..7ba0d1f5cb 100644 --- a/e2e/tests/resource-hints.spec.ts +++ b/e2e/tests/resource-hints.spec.ts @@ -1,7 +1,7 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' -import { IS_PROD } from '../utils/env' +import { BUNDLER, IS_PROD } from '../utils/env' interface PageResourceInfo { prefetch: string[] @@ -44,6 +44,8 @@ const collectPageResourceInfo = async ( const difference = (values: string[], otherValues: string[]): string[] => values.filter((value) => !otherValues.includes(value)) +const EXTRACTED_CSS_PAGE_PATH = 'styles/css-container.html' + const LINKED_PAGE_CASES = [ { name: 'without a permalink', @@ -57,6 +59,10 @@ const LINKED_PAGE_CASES = [ name: 'with an exact path that collides with a redirect', path: 'resource-hints/redirect-collision.html', }, + { + name: 'with extracted CSS', + path: EXTRACTED_CSS_PAGE_PATH, + }, ] as const if (IS_PROD) { @@ -145,6 +151,19 @@ if (IS_PROD) { }) }) + if (BUNDLER === 'webpack') { + test('should prefetch extracted CSS required by a linked page', () => { + const cssFiles = ( + linkedPageFiles.get(EXTRACTED_CSS_PAGE_PATH) ?? [] + ).filter((file) => file.endsWith('.css')) + + expect(cssFiles.length).toBeGreaterThan(0) + cssFiles.forEach((file) => { + expect(sourceInfo.prefetch).toContain(file) + }) + }) + } + test('should not preload unlinked page files', () => { expect(unlinkedPageFiles.length).toBeGreaterThan(0) diff --git a/packages/bundler-webpack/src/build/createClientPlugin.ts b/packages/bundler-webpack/src/build/createClientPlugin.ts index ef878c273c..a5d5cb41f9 100644 --- a/packages/bundler-webpack/src/build/createClientPlugin.ts +++ b/packages/bundler-webpack/src/build/createClientPlugin.ts @@ -90,15 +90,13 @@ export const createClientPlugin = ( if (request) manifestModules[request] = files }) - // build chunk name -> file names mapping - const chunkFiles: Record = {} - chunks.forEach((chunk) => { - if (chunk.names.length && chunk.files.length) { - chunk.names.forEach((name) => { - chunkFiles[name] = [...(chunkFiles[name] ?? []), ...chunk.files] - }) - } - }) + // build chunk group name -> file names mapping + const chunkFiles = Object.fromEntries( + Array.from(compilation.namedChunkGroups, ([name, chunkGroup]) => [ + name, + chunkGroup.getFiles(), + ]), + ) // generate client manifest json file const clientManifest: ClientManifest = {