Skip to content

Commit

Permalink
perf(hmr): implement soft invalidation (#14654)
Browse files Browse the repository at this point in the history
  • Loading branch information
bluwy authored Oct 25, 2023
1 parent 43cc3b9 commit 4150bcb
Show file tree
Hide file tree
Showing 9 changed files with 220 additions and 11 deletions.
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))
}
},
}
}

0 comments on commit 4150bcb

Please sign in to comment.