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: build.modulePreload options #9938

Merged
merged 9 commits into from Sep 24, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
54 changes: 49 additions & 5 deletions docs/config/build-options.md
Expand Up @@ -17,21 +17,65 @@ The transform is performed with esbuild and the value should be a valid [esbuild

Note the build will fail if the code contains features that cannot be safely transpiled by esbuild. See [esbuild docs](https://esbuild.github.io/content-types/#javascript) for more details.

## build.polyfillModulePreload
## build.modulePreload

- **Type:** `boolean`
- **Type:** `boolean | { polyfill?: boolean, resolveDependencies?: ResolveModulePreloadDependenciesFn }`
- **Default:** `true`

Whether to automatically inject [module preload polyfill](https://guybedford.com/es-module-preloading-integrity#modulepreload-polyfill).

If set to `true`, the polyfill is auto injected into the proxy module of each `index.html` entry. If the build is configured to use a non-html custom entry via `build.rollupOptions.input`, then it is necessary to manually import the polyfill in your custom entry:
By default, a [module preload polyfill](https://guybedford.com/es-module-preloading-integrity#modulepreload-polyfill) is automatically injected. The polyfill is auto injected into the proxy module of each `index.html` entry. If the build is configured to use a non-html custom entry via `build.rollupOptions.input`, then it is necessary to manually import the polyfill in your custom entry:
patak-dev marked this conversation as resolved.
Show resolved Hide resolved

```js
import 'vite/modulepreload-polyfill'
```

Note: the polyfill does **not** apply to [Library Mode](/guide/build#library-mode). If you need to support browsers without native dynamic import, you should probably avoid using it in your library.

The polyfill can be disabled using `{ polyfill: false }`.

The list of chunks to preload for each dynamic import is computed by Vite. By default, an absolute path including the `base` will be used when loading these dependencies. If the `base` is relative (`''` or `'./'`), `import.meta.url` is used at runtime to avoid absolute paths that depends on final the deployed base.
patak-dev marked this conversation as resolved.
Show resolved Hide resolved

There is experimental support for fine grained control over the dependencies list and their paths using the `resolveDependencies` function. It expects a function of type `ResolveModulePreloadDependenciesFn`
patak-dev marked this conversation as resolved.
Show resolved Hide resolved

```ts
type ResolveModulePreloadDependenciesFn = (
url: string,
deps: string[],
context: {
importer: string
}
) => (string | { runtime?: string })[]
```

The `resolveDependencies` function will be called for each dynamic import with a list of the chunks it depends on. A new dependencies array can be returned with these filtered or more dependencies injected, and their paths modified. The `deps` paths are relative to the `build.outDir`.

```js
modulePreload: {
patak-dev marked this conversation as resolved.
Show resolved Hide resolved
resolveDependencies: (url, deps, { importer }) => {
return deps.map((dep) => {
if (...) {
// a relative path to the importer makes the dependency independent of the base
// this is the default
return { relative: dep } // ~ `path.relative(path.dirname(importer), dep)`
}
else if (...) {
// a runtime expression can be returned
return { runtime: `globalThis.__preloadPath(${dep}, import.meta.url)` }
}
// returning the dep as is defaults to the regular resolution
return dep
})
}
}
```

## build.polyfillModulePreload

- **Type:** `boolean`
- **Default:** `true`
- **Deprecated** use `build.modulePreload.polyfill` instead

Whether to automatically inject [module preload polyfill](https://guybedford.com/es-module-preloading-integrity#modulepreload-polyfill).
patak-dev marked this conversation as resolved.
Show resolved Hide resolved

## build.outDir

- **Type:** `string`
Expand Down
63 changes: 60 additions & 3 deletions packages/vite/src/node/build.ts
Expand Up @@ -73,8 +73,15 @@ export interface BuildOptions {
* whether to inject module preload polyfill.
* Note: does not apply to library mode.
* @default true
* @deprecated use `modulePreload.polyfill` instead
*/
polyfillModulePreload?: boolean
/**
* Configure module preload
* Note: does not apply to library mode.
* @default true
*/
modulePreload?: boolean | ModulePreloadOptions
/**
* Directory relative from `root` where build output will be placed. If the
* directory exists, it will be removed before the build.
Expand Down Expand Up @@ -229,16 +236,59 @@ export interface LibraryOptions {

export type LibraryFormats = 'es' | 'cjs' | 'umd' | 'iife'

export type ResolvedBuildOptions = Required<BuildOptions>
export interface ModulePreloadOptions {
/**
* whether to inject module preload polyfill.
patak-dev marked this conversation as resolved.
Show resolved Hide resolved
* Note: does not apply to library mode.
* @default true
*/
polyfill?: boolean
/**
* Resolve the list of dependencies to preload for
* a given dynamic import
patak-dev marked this conversation as resolved.
Show resolved Hide resolved
* @experimental
*/
resolveDependencies?: ResolveModulePreloadDependenciesFn
}

export type ResolveModulePreloadDependenciesFn = (
url: string,
deps: string[],
context: {
importer: string
}
) => (string | { runtime?: string; relative?: string })[]

export type ResolvedBuildOptions = Required<
Omit<BuildOptions, 'polyfillModulePreload'>
>

export function resolveBuildOptions(
raw: BuildOptions | undefined,
isBuild: boolean,
logger: Logger
): ResolvedBuildOptions {
const deprecatedPolyfillModulePreload = raw?.polyfillModulePreload
if (raw) {
const { polyfillModulePreload, ...rest } = raw
raw = rest
if (deprecatedPolyfillModulePreload !== undefined) {
logger.warn(
'polyfillModulePreload is deprecated. Use modulePreload.polyfill instead.'
)
}
if (
deprecatedPolyfillModulePreload === false &&
raw.modulePreload === undefined
) {
raw.modulePreload = { polyfill: false }
}
}

const modulePreload = raw?.modulePreload

const resolved: ResolvedBuildOptions = {
target: 'modules',
polyfillModulePreload: true,
outDir: 'dist',
assetsDir: 'assets',
assetsInlineLimit: 4096,
Expand Down Expand Up @@ -267,7 +317,14 @@ export function resolveBuildOptions(
warnOnError: true,
exclude: [/node_modules/],
...raw?.dynamicImportVarsOptions
}
},
modulePreload:
typeof modulePreload === 'object'
? {
polyfill: true,
...modulePreload
}
: modulePreload ?? true
}

// handle special build targets
Expand Down
6 changes: 5 additions & 1 deletion packages/vite/src/node/config.ts
Expand Up @@ -60,7 +60,11 @@ import { resolveSSROptions } from './ssr'

const debug = createDebugger('vite:config')

export type { RenderBuiltAssetUrl } from './build'
export type {
RenderBuiltAssetUrl,
ModulePreloadOptions,
ResolveModulePreloadDependenciesFn
} from './build'

// NOTE: every export in this file is re-exported from ./index.ts so it will
// be part of the public API.
Expand Down
4 changes: 3 additions & 1 deletion packages/vite/src/node/plugins/html.ts
Expand Up @@ -581,8 +581,10 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
processedHtml.set(id, s.toString())

// inject module preload polyfill only when configured and needed
const { modulePreload } = config.build
if (
config.build.polyfillModulePreload &&
(modulePreload === true ||
(typeof modulePreload === 'object' && modulePreload.polyfill)) &&
(someScriptsAreAsync || someScriptsAreDefer)
) {
js = `import "${modulePreloadPolyfillId}";\n${js}`
Expand Down
97 changes: 73 additions & 24 deletions packages/vite/src/node/plugins/importAnalysisBuild.ts
Expand Up @@ -108,13 +108,26 @@ function preload(
export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin {
const ssr = !!config.build.ssr
const isWorker = config.isWorker
const insertPreload = !(ssr || !!config.build.lib || isWorker)

const relativePreloadUrls = config.base === './' || config.base === ''

const scriptRel = config.build.polyfillModulePreload
? `'modulepreload'`
: `(${detectScriptRel.toString()})()`
const insertPreload = !(
ssr ||
!!config.build.lib ||
isWorker ||
config.build.modulePreload === false
)

const customResolveModulePreloadDependencies =
typeof config.build.modulePreload === 'object' &&
config.build.modulePreload.resolveDependencies
const isRelativeBase = config.base === './' || config.base === ''
const relativePreloadUrls =
isRelativeBase || customResolveModulePreloadDependencies

const { modulePreload } = config.build
const scriptRel =
modulePreload === true ||
(typeof modulePreload === 'object' && modulePreload.polyfill)
? `'modulepreload'`
: `(${detectScriptRel.toString()})()`
const assetsURL = relativePreloadUrls
? `function(dep,importerUrl) { return new URL(dep, importerUrl).href }`
: `function(dep) { return ${JSON.stringify(config.base)}+dep }`
Expand Down Expand Up @@ -367,7 +380,12 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin {
},

generateBundle({ format }, bundle) {
if (format !== 'es' || ssr || isWorker) {
if (
format !== 'es' ||
ssr ||
isWorker ||
config.build.modulePreload === false
) {
return
}

Expand Down Expand Up @@ -454,25 +472,56 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin {
}

if (markerStartPos > 0) {
s.overwrite(
markerStartPos,
markerStartPos + preloadMarkerWithQuote.length,
// the dep list includes the main chunk, so only need to reload when there are
// actual other deps. Don't include the assets dir if the default asset file names
// are used, the path will be reconstructed by the import preload helper
const { modulePreload } = config.build
// the dep list includes the main chunk, so only need to reload when there are actual other deps.
const depsArray =
deps.size > 1 ||
// main chunk is removed
(hasRemovedPureCssChunk && deps.size > 0)
? `[${[...deps]
.map((d) =>
JSON.stringify(
relativePreloadUrls
? path.relative(path.dirname(file), d)
: d
// main chunk is removed
(hasRemovedPureCssChunk && deps.size > 0)
? [...deps]
: []
const toRelative = (dep: string) =>
path.relative(path.dirname(file), dep)
const resolvedDependencies =
patak-dev marked this conversation as resolved.
Show resolved Hide resolved
url &&
typeof modulePreload === 'object' &&
modulePreload.resolveDependencies
? modulePreload
.resolveDependencies(url, depsArray, { importer: file })
.map((dep) => {
if (typeof dep === 'object') {
if (dep.runtime) {
return dep.runtime
}
if (dep.relative) {
return JSON.stringify(toRelative(dep.relative))
}
throw new Error(
`Invalid dependency object, no runtime or relative option specified`
)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be a very advanced feature, especially the runtime part. I'm curious how Nuxt will be using this part of the API. Do they have their own runtime preload list?

IIUC this PR would also disable the optimization in #9491, or perhaps doesn't work only if resolveDependencies is used. But I don't think it's a big issue since most won't be using this feature directly.

The rest looks great though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nuxt is using runtime to use its own function to resolve paths, like globalThis.__resolvePath(...).

I commented in #9491, that we should wait a bit to avoid breaking the ecosystem but I think we can still do the optimization after this PR.

The PR changed quite a bit after 9f1eaa5 (#9938)

return JSON.stringify(
depsArray.includes(dep)
? isRelativeBase
? toRelative(dep)
: config.base + dep
: dep
)
})
: depsArray.map((d) =>
// Don't include the assets dir if the default asset file names
// are used, the path will be reconstructed by the import preload helper
JSON.stringify(
relativePreloadUrls
? path.relative(path.dirname(file), d)
: d
)
.join(',')}]`
: `[]`,
)

s.overwrite(
markerStartPos,
markerStartPos + preloadMarkerWithQuote.length,
`[${resolvedDependencies.join(',')}]`,
{ contentOnly: true }
)
rewroteMarkerStartPos.add(markerStartPos)
Expand Down
4 changes: 3 additions & 1 deletion packages/vite/src/node/plugins/index.ts
Expand Up @@ -36,14 +36,16 @@ export async function resolvePlugins(
const buildPlugins = isBuild
? (await import('../build')).resolveBuildPlugins(config)
: { pre: [], post: [] }
const { modulePreload } = config.build

return [
isWatch ? ensureWatchPlugin() : null,
isBuild ? metadataPlugin() : null,
preAliasPlugin(config),
aliasPlugin({ entries: config.resolve.alias }),
...prePlugins,
config.build.polyfillModulePreload
modulePreload === true ||
(typeof modulePreload === 'object' && modulePreload.polyfill)
? modulePreloadPolyfillPlugin(config)
: null,
...(isDepsOptimizerEnabled(config, false) ||
Expand Down
@@ -0,0 +1,22 @@
import { describe, expect, test } from 'vitest'
import { browserLogs, isBuild, page, viteTestUrl } from '~utils'

test('should have no 404s', () => {
browserLogs.forEach((msg) => {
expect(msg).not.toMatch('404')
})
})

describe.runIf(isBuild)('build', () => {
test('dynamic import', async () => {
const appHtml = await page.content()
expect(appHtml).toMatch('This is <b>home</b> page.')
})

test('dynamic import with comments', async () => {
await page.goto(viteTestUrl + '/#/hello')
const html = await page.content()
expect(html).not.toMatch(/link rel="modulepreload"/)
expect(html).not.toMatch(/link rel="stylesheet"/)
})
})
@@ -0,0 +1 @@
module.exports = require('../../vite.config-preload-disabled')
@@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest'
import { browserLogs, isBuild, page, viteTestUrl } from '~utils'

test('should have no 404s', () => {
browserLogs.forEach((msg) => {
expect(msg).not.toMatch('404')
})
})

describe.runIf(isBuild)('build', () => {
test('dynamic import', async () => {
const appHtml = await page.content()
expect(appHtml).toMatch('This is <b>home</b> page.')
})

test('dynamic import with comments', async () => {
await page.goto(viteTestUrl + '/#/hello')
const html = await page.content()
expect(html).toMatch(
/link rel="modulepreload".*?href="http.*?\/Hello\.\w{8}\.js"/
)
expect(html).toMatch(
/link rel="stylesheet".*?href="http.*?\/Hello\.\w{8}\.css"/
)
})
})
1 change: 1 addition & 0 deletions playground/preload/__tests__/resolve-deps/vite.config.js
@@ -0,0 +1 @@
module.exports = require('../../vite.config-resolve-deps')
10 changes: 9 additions & 1 deletion playground/preload/package.json
Expand Up @@ -6,7 +6,15 @@
"dev": "vite",
"build": "vite build",
"debug": "node --inspect-brk ../../packages/vite/bin/vite",
"preview": "vite preview"
"preview": "vite preview",
"dev:resolve-deps": "vite --config vite.config-resolve-deps.ts",
"build:resolve-deps": "vite build --config vite.config-resolve-deps.ts",
"debug:resolve-deps": "node --inspect-brk ../../packages/vite/bin/vite --config vite.config-resolve-deps.ts",
"preview:resolve-deps": "vite preview --config vite.config-resolve-deps.ts",
"dev:preload-disabled": "vite --config vite.config-preload-disabled.ts",
"build:preload-disabled": "vite build --config vite.config-preload-disabled.ts",
"debug:preload-disabled": "node --inspect-brk ../../packages/vite/bin/vite --config vite.config-preload-disabled.ts",
"preview:preload-disabled": "vite preview --config vite.config-preload-disabled.ts"
},
"dependencies": {
"vue": "^3.2.37",
Expand Down