Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 🧪 chunkMap #15373

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
81 changes: 79 additions & 2 deletions packages/plugin-legacy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
import type {
NormalizedOutputOptions,
OutputBundle,
OutputChunk,
OutputOptions,
PreRenderedChunk,
RenderedChunk,
Expand Down Expand Up @@ -122,6 +123,51 @@ const _require = createRequire(import.meta.url)
const nonLeadingHashInFileNameRE = /[^/]+\[hash(?::\d+)?\]/
const prefixedHashInFileNameRE = /\W?\[hash(:\d+)?\]/

const hashPlaceholderLeft = '!~{'
const hashPlaceholderRight = '}~'
const hashPlaceholderOverhead =
hashPlaceholderLeft.length + hashPlaceholderRight.length
const maxHashSize = 22
// from https://github.com/rollup/rollup/blob/fbc25afcc2e494b562358479524a88ab8fe0f1bf/src/utils/hashPlaceholders.ts#L41-L46
const REPLACER_REGEX = new RegExp(
// eslint-disable-next-line regexp/strict, regexp/prefer-w
`${hashPlaceholderLeft}[0-9a-zA-Z_$]{1,${
maxHashSize - hashPlaceholderOverhead
}}${hashPlaceholderRight}`,
'g',
)

const hashPlaceholderToFacadeModuleIdHashMap: Map<string, string> = new Map()

function augmentFacadeModuleIdHash(name: string): string {
return name.replace(
REPLACER_REGEX,
(match) => hashPlaceholderToFacadeModuleIdHashMap.get(match) ?? match,
)
}

function createChunkMap(
bundle: OutputBundle,
base: string,
): Record<string, string> {
return Object.fromEntries(
Object.values(bundle)
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
.map((output) => {
return [
base + augmentFacadeModuleIdHash(output.preliminaryFileName),
base + output.fileName,
]
}),
)
}

export function getHash(text: Buffer | string, length = 8): string {
const h = createHash('sha256').update(text).digest('hex').substring(0, length)
if (length <= 64) return h
return h.padEnd(length, '_')
}

function viteLegacyPlugin(options: Options = {}): Plugin[] {
let config: ResolvedConfig
let targets: Options['targets']
Expand Down Expand Up @@ -153,6 +199,7 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] {
const isDebug =
debugFlags.includes('vite:*') || debugFlags.includes('vite:legacy')

const chunkMap = new Map()
const facadeToLegacyChunkMap = new Map()
const facadeToLegacyPolyfillMap = new Map()
const facadeToModernPolyfillMap = new Map()
Expand Down Expand Up @@ -405,7 +452,7 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] {
}
},

async renderChunk(raw, chunk, opts) {
async renderChunk(raw, chunk, opts, meta) {
if (config.build.ssr) {
return null
}
Expand Down Expand Up @@ -450,6 +497,17 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] {
}
}

Object.values(meta.chunks).forEach((chunk) => {
const hashPlaceholder = chunk.fileName.match(REPLACER_REGEX)?.[0]
if (!hashPlaceholder) return
if (hashPlaceholderToFacadeModuleIdHashMap.get(hashPlaceholder)) return

hashPlaceholderToFacadeModuleIdHashMap.set(
hashPlaceholder,
getHash(chunk.facadeModuleId ?? chunk.fileName),
)
})

if (!genLegacy) {
return null
}
Expand Down Expand Up @@ -505,13 +563,21 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] {
return null
},

transformIndexHtml(html, { chunk }) {
transformIndexHtml(html, { chunk, filename, bundle }) {
if (config.build.ssr) return
if (!chunk) return
if (chunk.fileName.includes('-legacy')) {
// The legacy bundle is built first, and its index.html isn't actually emitted if
// modern bundle will be generated. Here we simply record its corresponding legacy chunk.
facadeToLegacyChunkMap.set(chunk.facadeModuleId, chunk.fileName)

const relativeUrlPath = path.posix.relative(
config.root,
normalizePath(filename),
)
const assetsBase = getBaseInHTML(relativeUrlPath, config)
chunkMap.set(chunk.facadeModuleId, createChunkMap(bundle!, assetsBase))

if (genModern) {
return
}
Expand Down Expand Up @@ -634,6 +700,17 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] {
})
}

if (chunkMap.get(chunk.facadeModuleId)) {
tags.push({
tag: 'script',
attrs: { type: 'systemjs-importmap' },
children: JSON.stringify({
imports: chunkMap.get(chunk.facadeModuleId),
}),
injectTo: 'head-prepend',
})
}

return {
html,
tags,
Expand Down
6 changes: 4 additions & 2 deletions packages/vite/src/node/__tests__/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const __dirname = resolve(fileURLToPath(import.meta.url), '..')
type FormatsToFileNames = [LibraryFormats, string][]

describe('build', () => {
test('file hash should change when css changes for dynamic entries', async () => {
// Since only the hash inside the importmap changes, there are no changes!
test.skip('file hash should change when css changes for dynamic entries', async () => {
const buildProject = async (cssColor: string) => {
return (await build({
root: resolve(__dirname, 'packages/build-project'),
Expand Down Expand Up @@ -55,7 +56,8 @@ describe('build', () => {
assertOutputHashContentChange(result[0], result[1])
})

test('file hash should change when pure css chunk changes', async () => {
// Since only the hash inside the importmap changes, there are no changes!
test.skip('file hash should change when pure css chunk changes', async () => {
const buildProject = async (cssColor: string) => {
return (await build({
root: resolve(__dirname, 'packages/build-project'),
Expand Down
8 changes: 8 additions & 0 deletions packages/vite/src/node/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
import { manifestPlugin } from './plugins/manifest'
import type { Logger } from './logger'
import { dataURIPlugin } from './plugins/dataUri'
import { chunkMapPlugin } from './plugins/chunkMap'
import { buildImportAnalysisPlugin } from './plugins/importAnalysisBuild'
import { ssrManifestPlugin } from './ssr/ssrManifestPlugin'
import { loadFallbackPlugin } from './plugins/loadFallback'
Expand Down Expand Up @@ -443,6 +444,13 @@ export async function resolveBuildPlugins(config: ResolvedConfig): Promise<{
Boolean,
) as Plugin[]),
...(config.isWorker ? [webWorkerPostPlugin()] : []),
...(!config.isWorker &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// TODO: Change to an opt-in option (temporarily disable only for VitePress)
!config.vitepress
? [chunkMapPlugin()]
: []),
],
post: [
buildImportAnalysisPlugin(config),
Expand Down
115 changes: 115 additions & 0 deletions packages/vite/src/node/plugins/chunkMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import path from 'node:path'
import type { OutputBundle, OutputChunk } from 'rollup'
import MagicString from 'magic-string'
import { getHash, normalizePath } from '../utils'
import type { Plugin } from '../plugin'
import type { ResolvedConfig } from '../config'
import type { IndexHtmlTransformHook } from './html'

const hashPlaceholderLeft = '!~{'
const hashPlaceholderRight = '}~'
const hashPlaceholderOverhead =
hashPlaceholderLeft.length + hashPlaceholderRight.length
export const maxHashSize = 22
// from https://github.com/rollup/rollup/blob/fbc25afcc2e494b562358479524a88ab8fe0f1bf/src/utils/hashPlaceholders.ts#L41-L46
const REPLACER_REGEX = new RegExp(
// eslint-disable-next-line regexp/strict, regexp/prefer-w
`${hashPlaceholderLeft}[0-9a-zA-Z_$]{1,${
maxHashSize - hashPlaceholderOverhead
}}${hashPlaceholderRight}`,
'g',
)

const hashPlaceholderToFacadeModuleIdHashMap: Map<string, string> = new Map()

function augmentFacadeModuleIdHash(name: string): string {
return name.replace(
REPLACER_REGEX,
(match) => hashPlaceholderToFacadeModuleIdHashMap.get(match) ?? match,
)
}

export function createChunkMap(
bundle: OutputBundle,
base: string = '',
): Record<string, string> {
return Object.fromEntries(
Object.values(bundle)
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
.map((output) => {
return [
base + augmentFacadeModuleIdHash(output.preliminaryFileName),
base + output.fileName,
]
}),
)
}

export function chunkMapPlugin(): Plugin {
return {
name: 'vite:chunk-map',

// If we simply remove the hash part, there is a risk of key collisions within the importmap.
// For example, both `foo/index-[hash].js` and `index-[hash].js` would become `assets/index-.js`.
// Therefore, we generate a hash from the facadeModuleId.
renderChunk(code, _chunk, _options, meta) {
Object.values(meta.chunks).forEach((chunk) => {
const hashPlaceholder = chunk.fileName.match(REPLACER_REGEX)?.[0]
if (!hashPlaceholder) return
if (hashPlaceholderToFacadeModuleIdHashMap.get(hashPlaceholder)) return

hashPlaceholderToFacadeModuleIdHashMap.set(
hashPlaceholder,
getHash(chunk.facadeModuleId ?? chunk.fileName),
)
})

const codeProcessed = augmentFacadeModuleIdHash(code)
return {
code: codeProcessed,
map: new MagicString(codeProcessed).generateMap({
hires: 'boundary',
}),
}
},
}
}

export function postChunkMapHook(
config: ResolvedConfig,
): IndexHtmlTransformHook {
return (html, ctx) => {
const { filename, bundle } = ctx

const relativeUrlPath = path.posix.relative(
config.root,
normalizePath(filename),
)
const assetsBase = getBaseInHTML(relativeUrlPath, config)

return {
html,
tags: [
{
tag: 'script',
attrs: { type: 'importmap' },
children: JSON.stringify({
imports: createChunkMap(bundle!, assetsBase),
}),
injectTo: 'head-prepend',
},
],
}
}
}

function getBaseInHTML(urlRelativePath: string, config: ResolvedConfig) {
// Prefer explicit URL if defined for linking to assets and public files from HTML,
// even when base relative is specified
return config.base === './' || config.base === ''
? path.posix.join(
path.posix.relative(urlRelativePath, '').slice(0, -2),
'./',
)
: config.base
}
18 changes: 14 additions & 4 deletions packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
} from './asset'
import type { ESBuildOptions } from './esbuild'
import { getChunkOriginalFileName } from './manifest'
import { createChunkMap } from './chunkMap'

// const debug = createDebugger('vite:css')

Expand Down Expand Up @@ -837,6 +838,11 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
return
}

const chunkMap = createChunkMap(bundle)
const reverseChunkMap = Object.fromEntries(
Object.entries(chunkMap).map(([k, v]) => [v, k]),
)

// remove empty css chunks and their imports
if (pureCssChunks.size) {
// map each pure css chunk (rendered chunk) to it's corresponding bundle
Expand All @@ -848,9 +854,10 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
.map((chunk) => [chunk.preliminaryFileName, chunk.fileName]),
)

const pureCssChunkNames = [...pureCssChunks].map(
(pureCssChunk) => prelimaryNameToChunkMap[pureCssChunk.fileName],
)
const pureCssChunkNames = [...pureCssChunks].flatMap((pureCssChunk) => {
const chunkName = prelimaryNameToChunkMap[pureCssChunk.fileName]
return [chunkName, reverseChunkMap[chunkName]]
})

const replaceEmptyChunk = getEmptyChunkReplacer(
pureCssChunkNames,
Expand Down Expand Up @@ -888,7 +895,10 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {

const removedPureCssFiles = removedPureCssFilesCache.get(config)!
pureCssChunkNames.forEach((fileName) => {
removedPureCssFiles.set(fileName, bundle[fileName] as RenderedChunk)
const chunk = bundle[fileName] as RenderedChunk
if (!chunk) return
removedPureCssFiles.set(fileName, chunk)
removedPureCssFiles.set(reverseChunkMap[fileName], chunk)
delete bundle[fileName]
delete bundle[`${fileName}.map`]
})
Expand Down