Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion e2e/docs/.vuepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -78,6 +78,21 @@ 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)',
'[Linked page whose exact path collides with a redirect](./redirect-collision.md)',
'[Linked page with extracted CSS](../styles/css-container.md)',
].join('\n\n'),
}),
)
},

plugins: [fooPlugin],

// The alias entries are intentionally ordered by key length to ensure
Expand Down
5 changes: 5 additions & 0 deletions e2e/docs/resource-hints/linked-permalink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
permalink: /resource-hints/linked-permalink/
---

# Resource Hints Linked Permalink Page
1 change: 1 addition & 0 deletions e2e/docs/resource-hints/linked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Resource Hints Linked Page
5 changes: 5 additions & 0 deletions e2e/docs/resource-hints/redirect-collision-exact.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
permalink: /resource-hints/redirect-collision.html
---

# Exact Resource Hints Page
5 changes: 5 additions & 0 deletions e2e/docs/resource-hints/redirect-collision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
permalink: /resource-hints/redirect-collision-permalink/
---

# Resource Hints Redirect Collision
9 changes: 9 additions & 0 deletions e2e/docs/resource-hints/source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 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)

[Linked page with extracted CSS](../styles/css-container.md)
1 change: 1 addition & 0 deletions e2e/docs/resource-hints/unlinked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Resource Hints Unlinked Page
216 changes: 216 additions & 0 deletions e2e/tests/resource-hints.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'

import { BUNDLER, IS_PROD } from '../utils/env'

interface PageResourceInfo {
prefetch: string[]
preload: string[]
resources: string[]
}

const collectPageResourceInfo = async (
page: Page,
path: string,
): Promise<PageResourceInfo> => {
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))

const EXTRACTED_CSS_PAGE_PATH = 'styles/css-container.html'

const LINKED_PAGE_CASES = [
{
name: 'without a permalink',
path: 'resource-hints/linked.html',
},
{
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',
},
{
name: 'with extracted CSS',
path: EXTRACTED_CSS_PAGE_PATH,
},
] as const

if (IS_PROD) {
test.describe('default resource hints', () => {
let allLinkedPageFiles: string[]
let linkedPageFiles: Map<string, string[]>
let sourceInfo: PageResourceInfo
let sourcePageFiles: string[]
let unlinkedPageFiles: string[]
let virtualSourceInfo: PageResourceInfo
let virtualSourcePageFiles: string[]

test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()

const linkedInfos = new Map<string, PageResourceInfo>()
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',
)
sourceInfo = await collectPageResourceInfo(
page,
'resource-hints/source.html',
)
virtualSourceInfo = await collectPageResourceInfo(
page,
'resource-hints/virtual-source.html',
)

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,
allLinkedPageResources,
)
sourcePageFiles = sourceInfo.resources.filter(
(file) =>
!allLinkedPageResources.includes(file) &&
!unlinkedInfo.resources.includes(file),
)
virtualSourcePageFiles = virtualSourceInfo.resources.filter(
(file) =>
!allLinkedPageResources.includes(file) &&
!unlinkedInfo.resources.includes(file),
)

await page.close()
})

LINKED_PAGE_CASES.forEach((linkedPageCase) => {
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).not.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)
})
})
})

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)

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)

sourcePageFiles.forEach((file) => {
expect(sourceInfo.prefetch).not.toContain(file)
})
unlinkedPageFiles.forEach((file) => {
expect(sourceInfo.prefetch).not.toContain(file)
})
sourceInfo.prefetch.forEach((file) => {
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)
})
})
})
}
15 changes: 14 additions & 1 deletion packages/bundler-vite/src/build/build.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
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'
Expand All @@ -7,6 +11,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')

Expand Down Expand Up @@ -65,6 +70,13 @@ export const build = async (
)
const ssrTemplate = await getSsrTemplate(app)

// build page path -> chunk files map for preload & prefetch
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) {
if (spinner) spinner.text = `Rendering pages ${colors.magenta(page.path)}`
Expand All @@ -77,6 +89,7 @@ export const build = async (
output: clientOutput.output,
outputEntryChunk: clientEntryChunk,
outputCssAsset: clientCssAsset,
pageChunkFilesMap,
})
}
})
Expand Down
5 changes: 5 additions & 0 deletions packages/bundler-vite/src/build/renderPage.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -20,6 +21,7 @@ export const renderPage = async ({
output,
outputEntryChunk,
outputCssAsset,
pageChunkFilesMap,
}: {
app: App
page: Page
Expand All @@ -29,6 +31,7 @@ export const renderPage = async ({
output: RolldownOutput['output']
outputEntryChunk: OutputChunk
outputCssAsset: OutputAsset | undefined
pageChunkFilesMap: PageChunkFilesMap
}): Promise<void> => {
// render current page to string
const { ssrContext, ssrString } = await renderPageToString({
Expand All @@ -48,7 +51,9 @@ export const renderPage = async ({
prefetch: renderPagePrefetchLinks({
app,
outputEntryChunk,
page,
pageChunkFiles,
pageChunkFilesMap,
}),
preload: renderPagePreloadLinks({
app,
Expand Down
Loading
Loading