Skip to content

Commit dd6120d

Browse files
committed
[devtools] Port cache invalidation logic from turbopack to webpack
1 parent 90a4e3a commit dd6120d

File tree

9 files changed

+173
-17
lines changed

9 files changed

+173
-17
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,5 +706,6 @@
706706
"705": "Route is configured with dynamic = error that cannot be statically generated.",
707707
"706": "Invariant: static responses cannot be streamed %s",
708708
"707": "Invariant app-page handler received invalid cache entry %s",
709-
"708": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload."
709+
"708": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload.",
710+
"709": "Unable to remove an invalidated webpack cache. If this issue persists you can work around it by deleting %s"
710711
}

packages/next/src/build/define-env.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,21 @@ export function getDefineEnv({
289289
: {}),
290290
'process.env.__NEXT_DEVTOOL_SEGMENT_EXPLORER':
291291
config.experimental.devtoolSegmentExplorer ?? false,
292-
'process.env.__NEXT_TURBOPACK_PERSISTENT_CACHE':
293-
config.experimental.turbopackPersistentCaching ?? false,
292+
293+
// The devtools need to know whether or not to show an option to clear the
294+
// bundler cache. This option may be removed later once Turbopack's
295+
// persistent cache feature is more stable.
296+
//
297+
// This environment value is currently best-effort:
298+
// - It's possible to disable the webpack filesystem cache, but it's
299+
// unlikely for a user to do that.
300+
// - Rspack's persistent cache is unstable and requires a different
301+
// configuration than webpack to enable (which we don't do).
302+
//
303+
// In the worst case we'll show an option to clear the cache, but it'll be a
304+
// no-op that just restarts the development server.
305+
'process.env.__NEXT_BUNDLER_HAS_PERSISTENT_CACHE':
306+
!isTurbopack || (config.experimental.turbopackPersistentCaching ?? false),
294307
}
295308

296309
const userDefines = config.compiler?.define ?? {}

packages/next/src/build/webpack-config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,21 @@ export function hasExternalOtelApiPackage(): boolean {
289289

290290
const UNSAFE_CACHE_REGEX = /[\\/]pages[\\/][^\\/]+(?:$|\?|#)/
291291

292+
export function getCacheDirectories(
293+
configs: webpack.Configuration[]
294+
): Set<string> {
295+
return new Set(
296+
configs
297+
.map((cfg) => {
298+
if (typeof cfg.cache === 'object' && cfg.cache.type === 'filesystem') {
299+
return cfg.cache.cacheDirectory
300+
}
301+
return null
302+
})
303+
.filter((dir) => dir != null)
304+
)
305+
}
306+
292307
export default async function getBaseWebpackConfig(
293308
dir: string,
294309
{
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
4+
const INVALIDATION_MARKER = '__nextjs_invalidated_cache'
5+
6+
/**
7+
* Atomically write an invalidation marker.
8+
*
9+
* Because attempting to delete currently open cache files could cause issues,
10+
* actual deletion of files is deferred until the next start-up (in
11+
* `checkPersistentCacheInvalidationAndCleanup`).
12+
*
13+
* In the case that no database is currently open (e.g. via a separate CLI
14+
* subcommand), you should call `cleanupPersistentCache` *after* this to eagerly
15+
* remove the cache files.
16+
*/
17+
export async function invalidatePersistentCache(cacheDirectory: string) {
18+
let file
19+
try {
20+
// We're just opening it so that `open()` creates the file.
21+
file = await fs.open(path.join(cacheDirectory, INVALIDATION_MARKER), 'w')
22+
// We don't currently write anything to the file, but we could choose to
23+
// later, e.g. a reason for the invalidation.
24+
} catch (err: any) {
25+
// it's valid for the cache to not exist at all
26+
if (err.errno === 'ENOENT') {
27+
return
28+
}
29+
} finally {
30+
file?.close()
31+
}
32+
}
33+
34+
/**
35+
* Called during startup. See if the cache is in a partially-completed
36+
* invalidation state. Finds and delete any invalidated cache files.
37+
*/
38+
export async function checkPersistentCacheInvalidationAndCleanup(
39+
cacheDirectory: string
40+
) {
41+
const invalidated = await fs
42+
.access(path.join(cacheDirectory, INVALIDATION_MARKER))
43+
.then(
44+
() => true,
45+
() => false
46+
)
47+
if (invalidated) {
48+
await cleanupPersistentCache(cacheDirectory)
49+
}
50+
}
51+
52+
/**
53+
* Helper for `checkPersistentCacheInvalidationAndCleanup`. You can call this to
54+
* explicitly clean up a database after running `invalidatePersistentCache` when
55+
* webpack is not running.
56+
*
57+
* You should not run this if the cache has not yet been invalidated, as this
58+
* operation is not atomic and could result in a partially-deleted and corrupted
59+
* database.
60+
*/
61+
async function cleanupPersistentCache(cacheDirectory: string) {
62+
try {
63+
await cleanupPersistentCacheInner(cacheDirectory)
64+
} catch (e) {
65+
// generate a user-friendly error message
66+
throw new Error(
67+
`Unable to remove an invalidated webpack cache. If this issue persists ` +
68+
`you can work around it by deleting ${cacheDirectory}`,
69+
{ cause: e }
70+
)
71+
}
72+
}
73+
74+
async function cleanupPersistentCacheInner(cacheDirectory: string) {
75+
const files = await fs.readdir(cacheDirectory)
76+
77+
// delete everything except the invalidation marker
78+
await Promise.all(
79+
files.map((name) =>
80+
name !== INVALIDATION_MARKER
81+
? fs.rm(path.join(cacheDirectory, name), {
82+
force: true, // ignore errors if path does not exist
83+
recursive: true,
84+
maxRetries: 2, // windows prevents deletion of open files
85+
})
86+
: null
87+
)
88+
)
89+
90+
// delete the invalidation marker last, once we're sure everything is cleaned
91+
// up
92+
await fs.rm(path.join(cacheDirectory, INVALIDATION_MARKER), {
93+
force: true,
94+
maxRetries: 2,
95+
})
96+
}

packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/dev-tools-info/user-preferences.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@ export function UserPreferences({
6666
setScale(value)
6767
}
6868

69-
function handleRestartDevServer() {
69+
function handleRestartDevServer(invalidatePersistentCache: boolean) {
7070
let endpoint = '/__nextjs_restart_dev'
7171

72-
if (process.env.__NEXT_TURBOPACK_PERSISTENT_CACHE) {
72+
if (invalidatePersistentCache) {
7373
endpoint = '/__nextjs_restart_dev?invalidatePersistentCache'
7474
}
7575

@@ -198,14 +198,16 @@ export function UserPreferences({
198198
name="restart-dev-server"
199199
data-restart-dev-server
200200
className="action-button"
201-
onClick={handleRestartDevServer}
201+
onClick={() =>
202+
handleRestartDevServer(/*invalidatePersistentCache*/ false)
203+
}
202204
>
203205
<span>Restart</span>
204206
</button>
205207
</div>
206208
</div>
207209
</div>
208-
{process.env.__NEXT_TURBOPACK_PERSISTENT_CACHE ? (
210+
{process.env.__NEXT_BUNDLER_HAS_PERSISTENT_CACHE ? (
209211
<div className="preferences-container">
210212
<div className="preference-section">
211213
<div className="preference-header">
@@ -222,7 +224,9 @@ export function UserPreferences({
222224
name="reset-bundler-cache"
223225
data-reset-bundler-cache
224226
className="action-button"
225-
onClick={handleRestartDevServer}
227+
onClick={() =>
228+
handleRestartDevServer(/*invalidatePersistentCache*/ true)
229+
}
226230
>
227231
<span>Reset Cache</span>
228232
</button>

packages/next/src/next-devtools/dev-overlay/components/errors/error-overlay-toolbar/restart-server-button.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function RestartServerButton({ error }: { error: Error }) {
3434
function handleClick() {
3535
let endpoint = '/__nextjs_restart_dev'
3636

37-
if (process.env.__NEXT_TURBOPACK_PERSISTENT_CACHE) {
37+
if (process.env.__NEXT_BUNDLER_HAS_PERSISTENT_CACHE) {
3838
endpoint = '/__nextjs_restart_dev?invalidatePersistentCache'
3939
}
4040

@@ -52,7 +52,7 @@ export function RestartServerButton({ error }: { error: Error }) {
5252
className="restart-dev-server-button"
5353
onClick={handleClick}
5454
title={
55-
process.env.__NEXT_TURBOPACK_PERSISTENT_CACHE
55+
process.env.__NEXT_BUNDLER_HAS_PERSISTENT_CACHE
5656
? 'Clears the bundler cache and restarts the dev server. Helpful if you are seeing stale errors or changes are not appearing.'
5757
: 'Restarts the development server without needing to leave the browser.'
5858
}

packages/next/src/next-devtools/server/restart-dev-server-middleware.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@ import type { Telemetry } from '../../telemetry/storage'
33
import { RESTART_EXIT_CODE } from '../../server/lib/utils'
44
import { middlewareResponse } from './middleware-response'
55
import type { Project } from '../../build/swc/types'
6+
import { invalidatePersistentCache as invalidateWebpackPersistentCache } from '../../build/webpack/cache-invalidation'
67

78
const EVENT_DEV_OVERLAY_RESTART_SERVER = 'DEV_OVERLAY_RESTART_SERVER'
89

910
interface RestartDevServerMiddlewareConfig {
1011
telemetry: Telemetry
1112
turbopackProject?: Project
13+
webpackCacheDirectories?: Set<string>
1214
}
1315

1416
export function getRestartDevServerMiddleware({
1517
telemetry,
1618
turbopackProject,
19+
webpackCacheDirectories,
1720
}: RestartDevServerMiddlewareConfig) {
1821
/**
1922
* Some random value between 1 and Number.MAX_SAFE_INTEGER (inclusive). The same value is returned
@@ -34,16 +37,25 @@ export function getRestartDevServerMiddleware({
3437
return middlewareResponse.methodNotAllowed(res)
3538
}
3639

37-
const invalidatePersistentCache = searchParams.has(
40+
const shouldInvalidatePersistentCache = searchParams.has(
3841
'invalidatePersistentCache'
3942
)
40-
if (invalidatePersistentCache) {
41-
await turbopackProject?.invalidatePersistentCache()
43+
if (shouldInvalidatePersistentCache) {
44+
if (webpackCacheDirectories != null) {
45+
await Promise.all(
46+
Array.from(webpackCacheDirectories).map(
47+
invalidateWebpackPersistentCache
48+
)
49+
)
50+
}
51+
if (turbopackProject != null) {
52+
await turbopackProject.invalidatePersistentCache()
53+
}
4254
}
4355

4456
telemetry.record({
4557
eventName: EVENT_DEV_OVERLAY_RESTART_SERVER,
46-
payload: { invalidatePersistentCache },
58+
payload: { invalidatePersistentCache: shouldInvalidatePersistentCache },
4759
})
4860

4961
// TODO: Use flushDetached

packages/next/src/server/dev/hot-reloader-webpack.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { watchCompilers } from '../../build/output'
2828
import * as Log from '../../build/output/log'
2929
import getBaseWebpackConfig, {
30+
getCacheDirectories,
3031
loadProjectInfo,
3132
} from '../../build/webpack-config'
3233
import { APP_DIR_ALIAS, WEBPACK_LAYERS } from '../../lib/constants'
@@ -88,6 +89,7 @@ import { getDevOverlayFontMiddleware } from '../../next-devtools/server/font/get
8889
import { getDisableDevIndicatorMiddleware } from '../../next-devtools/server/dev-indicator-middleware'
8990
import getWebpackBundler from '../../shared/lib/get-webpack-bundler'
9091
import { getRestartDevServerMiddleware } from '../../next-devtools/server/restart-dev-server-middleware'
92+
import { checkPersistentCacheInvalidationAndCleanup } from '../../build/webpack/cache-invalidation'
9193

9294
const MILLISECONDS_IN_NANOSECOND = BigInt(1_000_000)
9395

@@ -1155,6 +1157,11 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
11551157
// @ts-ignore webpack 5
11561158
this.activeWebpackConfigs.parallelism = 1
11571159

1160+
await Promise.all(
1161+
Array.from(getCacheDirectories(this.activeWebpackConfigs)).map(
1162+
checkPersistentCacheInvalidationAndCleanup
1163+
)
1164+
)
11581165
this.multiCompiler = getWebpackBundler()(
11591166
this.activeWebpackConfigs
11601167
) as unknown as webpack.MultiCompiler
@@ -1574,6 +1581,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
15741581
getDisableDevIndicatorMiddleware(),
15751582
getRestartDevServerMiddleware({
15761583
telemetry: this.telemetry,
1584+
webpackCacheDirectories:
1585+
this.activeWebpackConfigs != null
1586+
? getCacheDirectories(this.activeWebpackConfigs)
1587+
: undefined,
15771588
}),
15781589
]
15791590
}

turbopack/crates/turbo-tasks-backend/src/database/db_invalidation.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::{
22
fs::{self, read_dir},
3-
io,
3+
io::{self, ErrorKind},
44
path::Path,
55
};
66

@@ -19,8 +19,12 @@ const INVALIDATION_MARKER: &str = "__turbo_tasks_invalidated_db";
1919
/// This should be run with the base (non-versioned) path, as that likely aligns closest with user
2020
/// expectations (e.g. if they're clearing the cache for disk space reasons).
2121
pub fn invalidate_db(base_path: &Path) -> anyhow::Result<()> {
22-
fs::write(base_path.join(INVALIDATION_MARKER), [0u8; 0])?;
23-
Ok(())
22+
match fs::write(base_path.join(INVALIDATION_MARKER), [0u8; 0]) {
23+
Ok(_) => Ok(()),
24+
// just ignore if the cache directory doesn't exist at all
25+
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
26+
Err(err) => Err(err).context("Failed to invalidate database"),
27+
}
2428
}
2529

2630
/// Called during startup. See if the db is in a partially-completed invalidation state. Find and

0 commit comments

Comments
 (0)