-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
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: chunk importmap #16552
base: main
Are you sure you want to change the base?
feat: chunk importmap #16552
Changes from 1 commit
9bed85b
a0e94cc
bb63e50
eaca13c
5f898b3
553e503
47ce953
a4740c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,7 @@ import type { | |
import type { | ||
NormalizedOutputOptions, | ||
OutputBundle, | ||
OutputChunk, | ||
OutputOptions, | ||
PreRenderedChunk, | ||
RenderedChunk, | ||
|
@@ -134,6 +135,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/91352494fc722bcd5e8e922cd1497b34aec57a67/src/utils/hashPlaceholders.ts#L41-L46 | ||
const hashPlaceholderRE = new RegExp( | ||
// eslint-disable-next-line regexp/strict, regexp/prefer-w | ||
`${hashPlaceholderLeft}[0-9a-zA-Z_$]{1,${ | ||
maxHashSize - hashPlaceholderOverhead | ||
}}${hashPlaceholderRight}`, | ||
'g', | ||
) | ||
|
||
const hashPlaceholderToFacadeModuleIdHashMap = new Map<string, string>() | ||
|
||
function augmentFacadeModuleIdHash(name: string): string { | ||
return name.replace( | ||
hashPlaceholderRE, | ||
(match) => hashPlaceholderToFacadeModuleIdHashMap.get(match) ?? match, | ||
) | ||
} | ||
|
||
function createChunkImportMap( | ||
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'] | ||
|
@@ -165,6 +211,7 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] { | |
const isDebug = | ||
debugFlags.includes('vite:*') || debugFlags.includes('vite:legacy') | ||
|
||
const chunkImportMap = new Map() | ||
const facadeToLegacyChunkMap = new Map() | ||
const facadeToLegacyPolyfillMap = new Map() | ||
const facadeToModernPolyfillMap = new Map() | ||
|
@@ -519,6 +566,16 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] { | |
} | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried simply adding an if statement at the end of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The current That said, I noticed that the script that has the |
||
if (config.build.chunkImportMap) { | ||
const hashPlaceholder = chunk.fileName.match(hashPlaceholderRE)?.[0] | ||
if (hashPlaceholder) { | ||
hashPlaceholderToFacadeModuleIdHashMap.set( | ||
hashPlaceholder, | ||
getHash(chunk.facadeModuleId ?? chunk.fileName), | ||
) | ||
} | ||
} | ||
|
||
if (!genLegacy) { | ||
return null | ||
} | ||
|
@@ -574,13 +631,26 @@ 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) | ||
|
||
if (config.build.chunkImportMap) { | ||
const relativeUrlPath = path.posix.relative( | ||
config.root, | ||
normalizePath(filename), | ||
) | ||
const assetsBase = getBaseInHTML(relativeUrlPath, config) | ||
chunkImportMap.set( | ||
chunk.facadeModuleId, | ||
createChunkImportMap(bundle!, assetsBase), | ||
) | ||
} | ||
|
||
if (genModern) { | ||
return | ||
} | ||
|
@@ -703,6 +773,19 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] { | |
}) | ||
} | ||
|
||
if (config.build.chunkImportMap) { | ||
const imports = chunkImportMap.get(chunk.facadeModuleId) | ||
|
||
if (imports) { | ||
tags.push({ | ||
tag: 'script', | ||
attrs: { type: 'systemjs-importmap' }, | ||
children: JSON.stringify({ imports }), | ||
injectTo: 'head-prepend', | ||
}) | ||
} | ||
} | ||
|
||
return { | ||
html, | ||
tags, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
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/91352494fc722bcd5e8e922cd1497b34aec57a67/src/utils/hashPlaceholders.ts#L41-L46 | ||
const hashPlaceholderRE = new RegExp( | ||
// eslint-disable-next-line regexp/strict, regexp/prefer-w | ||
`${hashPlaceholderLeft}[0-9a-zA-Z_$]{1,${ | ||
maxHashSize - hashPlaceholderOverhead | ||
}}${hashPlaceholderRight}`, | ||
'g', | ||
) | ||
|
||
const hashPlaceholderToFacadeModuleIdHashMap = new Map() | ||
|
||
function augmentFacadeModuleIdHash(name: string): string { | ||
return name.replace( | ||
hashPlaceholderRE, | ||
(match) => hashPlaceholderToFacadeModuleIdHashMap.get(match) ?? match, | ||
) | ||
} | ||
|
||
export function createChunkImportMap( | ||
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, | ||
] | ||
Comment on lines
+40
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Note to reviewers: the mapping by the browsers is operated after both the module specifier and the key of import maps are resolved to absolute URLs. |
||
}), | ||
) | ||
} | ||
|
||
export function chunkImportMapPlugin(): Plugin { | ||
return { | ||
name: 'vite:chunk-importmap', | ||
|
||
// If the hash part is simply removed, 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, a hash is generated from the facadeModuleId to avoid this issue. | ||
renderChunk(code, _chunk, _options, meta) { | ||
Object.values(meta.chunks).forEach((chunk) => { | ||
const hashPlaceholder = chunk.fileName.match(hashPlaceholderRE)?.[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', | ||
}), | ||
sapphi-red marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 For reviewers: This works as rollup calculates the chunk hash after the Reference: https://www.youtube.com/watch?v=cFwO9UvDzfI |
||
} | ||
}, | ||
} | ||
} | ||
|
||
export function postChunkImportMapHook( | ||
config: ResolvedConfig, | ||
): IndexHtmlTransformHook { | ||
return (html, ctx) => { | ||
if (!config.build.chunkImportMap) return | ||
|
||
const { filename, bundle } = ctx | ||
|
||
const relativeUrlPath = path.posix.relative( | ||
config.root, | ||
normalizePath(filename), | ||
) | ||
const assetsBase = getBaseInHTML(relativeUrlPath, config) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess this does not take There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven’t come up with a good way to use it together with the renderBuiltUrl runtime yet. I’ll need some time to investigate and think it over. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess there aren't any. |
||
|
||
return { | ||
html, | ||
tags: [ | ||
{ | ||
tag: 'script', | ||
attrs: { type: 'importmap' }, | ||
children: JSON.stringify({ | ||
imports: createChunkImportMap(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 | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems
build.chunkImportMap
is used instead ofhtml.chunkImportMap
in the code.I think
build.chunkImportMap
is better as I think we should have an option to generate the import map in a separate file so that frameworks and users using backend integration can use it.Let's move this description to
docs/config/build-options.md
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might even be worth being an experimental feature.
build.experimentalChunkImportMap
Im really looking forward to this change as itll be a massive win for users.
I expect though at this time there may be gotchas we need to learn and patch, as people report behaviour issues with there individual setups.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can set experimental without changing the name. 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, I need to use this feature as backend integration 👍 . It would be great if we could export
ImportMap
contents into a file likeimport-maps.json
, and the consumers can name as the build.manifest feature (ref: https://vite.dev/config/build-options.html#build-manifest)