mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
Currently, importing a new module causes a complete eviction (`clear()`) and re-evaluation of server chunks, both in the Turbopack runtime's module cache and Node's `require.cache`. Here's what happens today: - A new module is imported into a chunk's graph - This changes its `availability_info`, which in dev for non-entry chunks is encoded into the chunk's filepath - `VersionedContentMap` works entirely on chunk paths. When we construct instructions to transition a chunk into its new state, we fail to find the prior state and fall back to `clear()` as described above. An ideal version of this is a refactor that implements `VersionedContentMap` on a per-module basis, not a per-chunk one. This PR doesn't do that, but achieves consistent module-level updates when a chunk's availability info changes. It does this by implementing chunk lists for server entry chunks the same way the client hmr implementation does: an entry chunk's version aggregates the versions of its dependent chunks (including dynamically imported ones), keyed by merger rather than path. Entry chunks do not encode availability info in their paths, so they are insulated from missing versions. Updates are applied in Node.js through the same shared merged-update machinery in the unified hmr runtime that the client already uses, plus a new `ChunkListUpdate` branch in the server hmr client to unwrap the merged updates. Details: - The merged-update wire format (`EcmascriptMergedUpdate` etc.) moves from turbopack-browser into shared `turbopack_ecmascript::chunk_list`, along with runtime-agnostic `ChunkListVersion` and `update_chunk_list` implementations. - `turbopack-nodejs` gains a chunk content merger mirroring the browser one. `EcmascriptBuildNodeEntryChunk`'s versioned content is now a chunk-list content over its sync and async chunks. It is version-only and never emitted; the entry chunk still inlines its loader calls. - The aggregate server hmr subscription now tracks only entry chunks. Shared chunks under `server/chunks/` ride the entry's `ChunkListUpdate` as module deltas, so their content-hash paths no longer matter. - The Node hmr client applies `ChunkListUpdate` by feeding each nested merged update through the shared apply path. Modules that appear in an "added" chunk but already exist in the module cache were moved by a chunk rename and are treated as modified. - On a successful partial apply, the hot reloader clears the manifest cache for updated chunks and notifies browsers to refetch RSC, without clearing `require.cache`. ### Test Plan Adds a series of e2e tests to catch the described manual test plan in both entry and dynamic chunks.
153 lines
5.7 KiB
TypeScript
153 lines
5.7 KiB
TypeScript
import { nextTestSetup, isNextDev } from 'e2e-utils'
|
|
import { waitFor } from 'next-test-utils'
|
|
import fs from 'fs/promises'
|
|
import path from 'path'
|
|
|
|
// Regression guard for warm-cache work: after one cold cycle, run a warm
|
|
// cycle and snapshot the set of turbo-tasks functions that had any cache
|
|
// miss. If a task previously cached (e.g. via a persistable entries map)
|
|
// regresses to recomputing on warm restart, this snapshot will change and
|
|
// fail the test.
|
|
//
|
|
// When the snapshot moves:
|
|
// - FEWER entries → that's a win. Run with `-u` and commit the new snapshot.
|
|
// - MORE entries → something is recomputing on warm start. Investigate
|
|
// before updating.
|
|
//
|
|
// Dev and start (build) snapshots are kept separate because the warm graphs
|
|
// differ: dev brings up HMR/Fast Refresh infra that build doesn't.
|
|
|
|
interface TaskFunctionStatistics {
|
|
cache_hit: number
|
|
cache_miss: number
|
|
}
|
|
type TaskStats = Record<string, TaskFunctionStatistics>
|
|
|
|
const STATS_RELATIVE_PATH = '.next/warm-restart-task-stats.json'
|
|
|
|
;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)(
|
|
'warm-restart task statistics',
|
|
() => {
|
|
const env = [
|
|
'ENABLE_CACHING=1',
|
|
'TURBO_ENGINE_IGNORE_DIRTY=1',
|
|
'TURBO_ENGINE_SNAPSHOT_IDLE_TIMEOUT_MILLIS=1000',
|
|
// Persist even tiny snapshots so the test doesn't depend on the
|
|
// minimum-compilation-time threshold.
|
|
'TURBO_ENGINE_SNAPSHOT_MIN_ACTIVE_TIME_MILLIS=0',
|
|
`NEXT_TURBOPACK_TASK_STATISTICS=${STATS_RELATIVE_PATH}`,
|
|
// The task-statistics file is written by an `on_exit` handler in the
|
|
// napi binding. In dev that handler only runs if the child process
|
|
// gets a chance to clean up (i.e. SIGTERM, not SIGKILL). The parent
|
|
// `next dev` process gives the child 100ms by default before
|
|
// escalating to SIGKILL — bump that so the on-exit handler can flush.
|
|
'NEXT_EXIT_TIMEOUT_MS=30000',
|
|
].join(' ')
|
|
|
|
const { skipped, next } = nextTestSetup({
|
|
files: __dirname,
|
|
skipDeployment: true,
|
|
packageJson: {
|
|
packageManager: '[email protected]',
|
|
scripts: {
|
|
build: `${env} next build`,
|
|
dev: `${env} next dev`,
|
|
start: 'next start',
|
|
},
|
|
},
|
|
installCommand: 'npm i',
|
|
buildCommand: 'npm run build',
|
|
startCommand: isNextDev ? 'npm run dev' : 'npm run start',
|
|
})
|
|
|
|
if (skipped) {
|
|
return
|
|
}
|
|
|
|
beforeAll(() => {
|
|
// No file edits in this test — skip HMR debounce.
|
|
;(next as any).handleDevWatchDelayBeforeChange = () => {}
|
|
;(next as any).handleDevWatchDelayAfterChange = () => {}
|
|
})
|
|
|
|
async function stop() {
|
|
if (isNextDev) {
|
|
// Persistent cache snapshot is on a 1s idle timer; give it room.
|
|
await waitFor(3000)
|
|
// SIGTERM (not the harness default SIGKILL) so the dev server gets
|
|
// to run its cleanup, which is what flushes the task-stats file.
|
|
await next.stop('SIGTERM')
|
|
} else {
|
|
await next.stop()
|
|
}
|
|
}
|
|
|
|
async function readMissedTaskNames(): Promise<string[]> {
|
|
const absPath = path.join(next.testDir, STATS_RELATIVE_PATH)
|
|
const raw = await fs.readFile(absPath, 'utf8')
|
|
const stats = JSON.parse(raw) as TaskStats
|
|
const misses: string[] = []
|
|
for (const [name, s] of Object.entries(stats)) {
|
|
if (s.cache_miss > 0) misses.push(name)
|
|
}
|
|
// Turbopack sorts on the Rust side, but sort again here to be robust
|
|
// against the JSON parser's object iteration order.
|
|
misses.sort()
|
|
return misses
|
|
}
|
|
|
|
if (isNextDev) {
|
|
it('snapshot of tasks with cache misses on warm dev restart', async () => {
|
|
// Cycle 1: the harness already auto-started; perform a request so the
|
|
// SSR/Node chunk work runs and gets persisted on shutdown.
|
|
async function runDevCycle() {
|
|
const browser = await next.browser('/')
|
|
// Wait for actual rendered content before declaring "ready".
|
|
await browser.elementByCss('p')
|
|
// Settle: the dev server keeps doing background work after first
|
|
// paint (HMR socket handshake, tail compilation). A fixed sleep
|
|
// is the least-bad option here since next.js doesn't expose
|
|
// turbopack's internal idle signal. If this proves flaky in CI,
|
|
// raise the value.
|
|
await waitFor(2000)
|
|
await browser.close()
|
|
}
|
|
await runDevCycle()
|
|
await stop()
|
|
|
|
// Cycle 2: warm.
|
|
await next.start()
|
|
await runDevCycle()
|
|
await stop()
|
|
|
|
const missed = await readMissedTaskNames()
|
|
expect(missed).toMatchInlineSnapshot(`
|
|
[
|
|
"<turbopack_browser::ecmascript::list::content::EcmascriptDevChunkListContent as dyn turbopack_core::version::VersionedContent>::update",
|
|
"next_api::project::Project::hmr_update",
|
|
"next_api::project::Project::hmr_version_state",
|
|
"next_napi_bindings::next_api::project::hmr_update_with_issues_operation",
|
|
"next_napi_bindings::next_api::project::project_hmr_update_operation",
|
|
"turbopack_core::version::VersionState::get",
|
|
]
|
|
`)
|
|
}, 180_000)
|
|
} else {
|
|
it('snapshot of tasks with cache misses on warm build', async () => {
|
|
// In start mode the harness auto-runs `next build` then `next start`.
|
|
// The build is cycle 1. We stop the server (we don't care about it)
|
|
// and run a second build manually for the warm measurement.
|
|
await stop()
|
|
|
|
const result = await (next as any).build()
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(`next build exited with ${result.exitCode}`)
|
|
}
|
|
|
|
const missed = await readMissedTaskNames()
|
|
expect(missed).toMatchInlineSnapshot(`[]`)
|
|
}, 240_000)
|
|
}
|
|
}
|
|
)
|