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

perf(hmr): implement soft invalidation #14654

Merged
merged 12 commits into from
Oct 25, 2023
11 changes: 8 additions & 3 deletions packages/vite/src/node/plugins/importAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
injectQuery,
isBuiltin,
isDataUrl,
isDefined,
isExternalUrl,
isInNodeModules,
isJSRequest,
Expand Down Expand Up @@ -677,9 +678,12 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
}),
)

const importedUrls = new Set(
orderedImportedUrls.filter(Boolean) as string[],
)
const _orderedImportedUrls = orderedImportedUrls.filter(isDefined)
const importedUrls = new Set(_orderedImportedUrls)
// `importedUrls` will be mixed with watched files for the module graph,
// `staticImportedUrls` will only contain the static top-level imports and
// dynamic imports
const staticImportedUrls = new Set(_orderedImportedUrls)
const acceptedUrls = mergeAcceptedUrls(orderedAcceptedUrls)
const acceptedExports = mergeAcceptedUrls(orderedAcceptedExports)

Expand Down Expand Up @@ -767,6 +771,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
isPartiallySelfAccepting ? acceptedExports : null,
isSelfAccepting,
ssr,
staticImportedUrls,
)
if (hasHMR && prunedImports) {
handlePrunedModules(prunedImports, server)
Expand Down
69 changes: 67 additions & 2 deletions packages/vite/src/node/server/moduleGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ export class ModuleNode {
ssrError: Error | null = null
lastHMRTimestamp = 0
lastInvalidationTimestamp = 0
/**
* If the module only needs to update its imports timestamp (e.g. within an HMR chain),
* it is considered soft-invalidated. In this state, its `transformResult` should exist,
* and the next `transformRequest` for this module will replace the timestamps.
*
* By default the value is `undefined` if it's not soft/hard-invalidated. If it gets
* soft-invalidated, this will contain the previous `transformResult` value. If it gets
* hard-invalidated, this will be set to `'HARD_INVALIDATED'`.
* @internal
*/
invalidationState: TransformResult | 'HARD_INVALIDATED' | undefined
/**
* @internal
*/
ssrInvalidationState: TransformResult | 'HARD_INVALIDATED' | undefined
/**
* The module urls that are statically imported in the code. This information is separated
* out from `importedModules` as only importers that statically import the module can be
* soft invalidated. Other imports (e.g. watched files) needs the importer to be hard invalidated.
* @internal
*/
staticImportedUrls?: Set<string>

/**
* @param setIsSelfAccepting - set `false` to set `isSelfAccepting` later. e.g. #7870
Expand Down Expand Up @@ -131,18 +153,43 @@ export class ModuleGraph {
timestamp: number = Date.now(),
isHmr: boolean = false,
hmrBoundaries: ModuleNode[] = [],
softInvalidate = false,
): void {
if (seen.has(mod)) {
const prevInvalidationState = mod.invalidationState
const prevSsrInvalidationState = mod.ssrInvalidationState

// Handle soft invalidation before the `seen` check, as consecutive soft/hard invalidations can
// cause the final soft invalidation state to be different.
// If soft invalidated, save the previous `transformResult` so that we can reuse and transform the
// import timestamps only in `transformRequest`. If there's no previous `transformResult`, hard invalidate it.
if (softInvalidate) {
mod.invalidationState ??= mod.transformResult ?? 'HARD_INVALIDATED'
mod.ssrInvalidationState ??= mod.ssrTransformResult ?? 'HARD_INVALIDATED'
}
// If hard invalidated, further soft invalidations have no effect until it's reset to `undefined`
else {
mod.invalidationState = 'HARD_INVALIDATED'
mod.ssrInvalidationState = 'HARD_INVALIDATED'
}

// Skip updating the module if it was already invalidated before and the invalidation state has not changed
if (
seen.has(mod) &&
prevInvalidationState === mod.invalidationState &&
prevSsrInvalidationState === mod.ssrInvalidationState
) {
return
}
seen.add(mod)

if (isHmr) {
mod.lastHMRTimestamp = timestamp
} else {
// Save the timestamp for this invalidation, so we can avoid caching the result of possible already started
// processing being done for this module
mod.lastInvalidationTimestamp = timestamp
}

// Don't invalidate mod.info and mod.meta, as they are part of the processing pipeline
// Invalidating the transform result is enough to ensure this module is re-processed next time it is requested
mod.transformResult = null
Expand All @@ -160,7 +207,20 @@ export class ModuleGraph {
}
mod.importers.forEach((importer) => {
if (!importer.acceptedHmrDeps.has(mod)) {
this.invalidateModule(importer, seen, timestamp, isHmr)
// If the importer statically imports the current module, we can soft-invalidate the importer
// to only update the import timestamps. If it's not statically imported, e.g. watched/glob file,
// we can only soft invalidate if the current module was also soft-invalidated. A soft-invalidation
// doesn't need to trigger a re-load and re-transform of the importer.
const shouldSoftInvalidateImporter =
importer.staticImportedUrls?.has(mod.url) || softInvalidate
this.invalidateModule(
importer,
seen,
timestamp,
isHmr,
undefined,
shouldSoftInvalidateImporter,
)
}
})
}
Expand All @@ -177,6 +237,9 @@ export class ModuleGraph {
* Update the module graph based on a module's updated imports information
* If there are dependencies that no longer have any importers, they are
* returned as a Set.
*
* @param staticImportedUrls Subset of `importedModules` where they're statically imported in code.
* This is only used for soft invalidations so `undefined` is fine but may cause more runtime processing.
*/
async updateModuleInfo(
mod: ModuleNode,
Expand All @@ -186,6 +249,7 @@ export class ModuleGraph {
acceptedExports: Set<string> | null,
isSelfAccepting: boolean,
ssr?: boolean,
staticImportedUrls?: Set<string>,
): Promise<Set<ModuleNode> | undefined> {
mod.isSelfAccepting = isSelfAccepting
const prevImports = ssr ? mod.ssrImportedModules : mod.clientImportedModules
Expand Down Expand Up @@ -257,6 +321,7 @@ export class ModuleGraph {
}

mod.acceptedHmrDeps = new Set(resolveResults)
mod.staticImportedUrls = staticImportedUrls

// update accepted hmr exports
mod.acceptedHmrExports = acceptedExports
Expand Down
116 changes: 110 additions & 6 deletions packages/vite/src/node/server/transformRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@ import path from 'node:path'
import { performance } from 'node:perf_hooks'
import getEtag from 'etag'
import convertSourceMap from 'convert-source-map'
import MagicString from 'magic-string'
import { init, parse as parseImports } from 'es-module-lexer'
import type { PartialResolvedId, SourceDescription, SourceMap } from 'rollup'
import colors from 'picocolors'
import type { ModuleNode, ViteDevServer } from '..'
import {
blankReplacer,
cleanUrl,
createDebugger,
injectQuery,
isObject,
prettifyUrl,
removeImportQuery,
removeTimestampQuery,
stripBase,
timeFrom,
unwrapId,
} from '../utils'
import { checkPublicFile } from '../plugins/asset'
import { getDepsOptimizer } from '../optimizer'
Expand Down Expand Up @@ -128,16 +134,25 @@ async function doTransform(

const module = await server.moduleGraph.getModuleByUrl(url, ssr)

// tries to handle soft invalidation of the module if available,
// returns a boolean true is successful, or false if no handling is needed
const softInvalidatedTransformResult =
module &&
(await handleModuleSoftInvalidation(
module,
ssr,
timestamp,
server.config.base,
))
if (softInvalidatedTransformResult) {
debugCache?.(`[memory-hmr] ${prettyUrl}`)
return softInvalidatedTransformResult
}

// check if we have a fresh cache
const cached =
module && (ssr ? module.ssrTransformResult : module.transformResult)
if (cached) {
// TODO: check if the module is "partially invalidated" - i.e. an import
// down the chain has been fully invalidated, but this current module's
// content has not changed.
// in this case, we can reuse its previous cached result and only update
// its import timestamps.

debugCache?.(`[memory] ${prettyUrl}`)
return cached
}
Expand Down Expand Up @@ -357,3 +372,92 @@ function createConvertSourceMapReadMap(originalFileName: string) {
)
}
}

/**
* When a module is soft-invalidated, we can preserve its previous `transformResult` and
* return similar code to before:
*
* - Client: We need to transform the import specifiers with new timestamps
* - SSR: We don't need to change anything as `ssrLoadModule` controls it
*/
async function handleModuleSoftInvalidation(
mod: ModuleNode,
ssr: boolean,
timestamp: number,
base: string,
) {
const transformResult = ssr ? mod.ssrInvalidationState : mod.invalidationState

// Reset invalidation state
if (ssr) mod.ssrInvalidationState = undefined
else mod.invalidationState = undefined

// Skip if not soft-invalidated
if (!transformResult || transformResult === 'HARD_INVALIDATED') return

if (ssr ? mod.ssrTransformResult : mod.transformResult) {
throw new Error(
`Internal server error: Soft-invalidated module "${mod.url}" should not have existing tranform result`,
)
}

let result: TransformResult
// For SSR soft-invalidation, no transformation is needed
if (ssr) {
result = transformResult
}
// For client soft-invalidation, we need to transform each imports with new timestamps if available
else {
await init
const source = transformResult.code
const s = new MagicString(source)
const [imports] = parseImports(source)

for (const imp of imports) {
let rawUrl = source.slice(imp.s, imp.e)
if (rawUrl === 'import.meta') continue

const hasQuotes = rawUrl[0] === '"' || rawUrl[0] === "'"
if (hasQuotes) {
rawUrl = rawUrl.slice(1, -1)
}

const urlWithoutTimestamp = removeTimestampQuery(rawUrl)
// hmrUrl must be derived the same way as importAnalysis
const hmrUrl = unwrapId(
stripBase(removeImportQuery(urlWithoutTimestamp), base),
)
for (const importedMod of mod.clientImportedModules) {
if (importedMod.url !== hmrUrl) continue
if (importedMod.lastHMRTimestamp > 0) {
const replacedUrl = injectQuery(
urlWithoutTimestamp,
`t=${importedMod.lastHMRTimestamp}`,
)
const start = hasQuotes ? imp.s + 1 : imp.s
const end = hasQuotes ? imp.e - 1 : imp.e
s.overwrite(start, end, replacedUrl)
}
break
}
}

// Update `transformResult` with new code. We don't have to update the sourcemap
// as the timestamp changes doesn't affect the code lines (stable).
const code = s.toString()
result = {
...transformResult,
code,
etag: getEtag(code, { weak: true }),
}
}

// Only cache the result if the module wasn't invalidated while it was
// being processed, so it is re-processed next time if it is stale
if (timestamp > mod.lastInvalidationTimestamp) {
if (ssr) mod.ssrTransformResult = result
else mod.transformResult = result
}

return result
}
14 changes: 14 additions & 0 deletions playground/hmr/__tests__/hmr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,20 @@ if (!isBuild) {
await untilUpdated(() => el.textContent(), 'child updated')
})

test('soft invalidate', async () => {
const el = await page.$('.soft-invalidation')
expect(await el.textContent()).toBe(
'soft-invalidation/index.js is transformed 1 times. child is bar',
)
editFile('soft-invalidation/child.js', (code) =>
code.replace('bar', 'updated'),
)
await untilUpdated(
() => el.textContent(),
'soft-invalidation/index.js is transformed 1 times. child is updated',
)
})

test('plugin hmr handler + custom event', async () => {
const el = await page.$('.custom')
editFile('customFile.js', (code) => code.replace('custom', 'edited'))
Expand Down
2 changes: 2 additions & 0 deletions playground/hmr/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import './file-delete-restore'
import './optional-chaining/parent'
import './intermediate-file-delete'
import logo from './logo.svg'
import { msg as softInvalidationMsg } from './soft-invalidation'

export const foo = 1
text('.app', foo)
text('.dep', depFoo)
text('.nested', nestedFoo)
text('.virtual', virtual)
text('.soft-invalidation', softInvalidationMsg)
setLogo(logo)

const btn = document.querySelector('.virtual-update') as HTMLButtonElement
Expand Down
1 change: 1 addition & 0 deletions playground/hmr/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<div class="custom"></div>
<div class="toRemove"></div>
<div class="virtual"></div>
<div class="soft-invalidation"></div>
<div class="invalidation"></div>
<div class="custom-communication"></div>
<div class="css-prev"></div>
Expand Down
1 change: 1 addition & 0 deletions playground/hmr/soft-invalidation/child.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'bar'
4 changes: 4 additions & 0 deletions playground/hmr/soft-invalidation/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { foo } from './child'

// @ts-expect-error global
export const msg = `soft-invalidation/index.js is transformed ${__TRANSFORM_COUNT__} times. child is ${foo}`
13 changes: 13 additions & 0 deletions playground/hmr/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default defineConfig({
},
},
virtualPlugin(),
transformCountPlugin(),
],
})

Expand Down Expand Up @@ -53,3 +54,15 @@ export const virtual = _virtual + '${num}';`
},
}
}

function transformCountPlugin(): Plugin {
let num = 0
return {
name: 'transform-count',
transform(code) {
if (code.includes('__TRANSFORM_COUNT__')) {
return code.replace('__TRANSFORM_COUNT__', String(++num))
}
},
}
}