[turbopack] Lazily compile dynamic imports in development - #97203
[turbopack] Lazily compile dynamic imports in development#97203jimmyhmiller wants to merge 2 commits into
Conversation
Behind `experimental.turbopackLazyDynamicImports`, a dynamic import in the client graph is no longer compiled until the browser actually reaches it. `EsmAsyncAssetReference` wraps each resolved target in a `LazyCompilationProxyModule`. While the proxy is inactive it reports no references, so the target is never traversed and never compiled; the import resolves to a rejected promise that names the module, which is only reachable through a stale cache. Activation travels over the request the runtime already makes. Dev builds route async imports through a manifest chunk, and the proxy's manifest chunk carries the key that activates it in its file name, so the dev server can recover the key from the requested path alone: no index has to be maintained on the side, and the mapping survives a restart against a warm cache. The dev server intercepts the request, flips one activation bit, rebuilds the owning entrypoints, and only then serves the freshly written chunk. Because the manifest chunk's name does not depend on the activation bit, the URL the browser asked for is the file it receives.
ec19c87 to
16d88cb
Compare
Failing test suitesCommit: 45d1b20 | About building and testing Next.js
Expand output● use-cache-size-zero › serves the stale cached value on a warm reload, then converges to a fresh one |
Stats from current PR🔴 3 regressions
📊 All Metrics📖 Metrics GlossaryDev Server Metrics:
Build Metrics:
Change Thresholds:
⚡ Dev Server
📦 Dev Server (Webpack) (Legacy)📦 Dev Server (Webpack)
⚡ Production Builds
📦 Production Builds (Webpack) (Legacy)📦 Production Builds (Webpack)
📦 Bundle SizesBundle Sizes⚡ TurbopackClient Main Bundles
Server Middleware
Build DetailsBuild Manifests
Build Cache
📦 WebpackClient Main Bundles
Polyfills
Pages
Server Edge SSR
Middleware
Build DetailsBuild Manifests
Build Cache
🔄 Shared (bundler-independent)Runtimes
📎 Tarball URLCommit: 45d1b20 |
| /// members of the graph they were synthesized from. | ||
| #[turbo_tasks::function] | ||
| pub fn isolated_async_entry(entry: ResolvedVc<Box<dyn Module>>) -> Vc<Self> { | ||
| Self::from_graphs( |
There was a problem hiding this comment.
This will end up with multiple module graphs though?
- I think server actions/client references are broken. Because these async graphs aren't scanned
- This will lead to duplication in module graph size because the async graph doesn't know about modules that are already loaded in the parent and can be skipped
There was a problem hiding this comment.
I think server actions/client references are broken.
No I already had a test for server actions behind the dynamic import. It passes and I tested it out manually as well. Just pushed a commit that tests client references. No breakage that I can see.
This will lead to duplication in module graph size because the async graph doesn't know about modules that are already loaded in the parent and can be skipped
That doesn't appear to be the case at all. I added a test for exactly that now. I did find a problem. Because we already do know things in the parent, we were trying to load a module that was already loaded and it caused issues, so pushed the fix for that one.
If I've missed something you meant, or if these tests don't cover your intended scenarios let me know.
There was a problem hiding this comment.
Yeah, I wasn't entirely correct in the earlier comment. So the real problem is:
In this PR, you have implemented only lazy async chunking. Not lazy module graph discovery (i.e. parsing, transforming, analyzing), etc.
In your old version, you did have both.
So server actions/client references do indeed work fine.
But you leave performance on the table because the unrequested modules are still analyzed.
There was a problem hiding this comment.
@mischnic I don't think that's true either. When I very first started doing this I accidentally had that. So i made a test for it to make sure I wasn't making that mistake.
But you are partially write as well and I didn't quite catch that distinction. So thanks.
If you do import("myfile.js") right now with this change, myfile.js will be parsed and analyzed and all that. But none of its dependencies will. I confirmed this two ways. 1) a very large set of deps that takes a long time 2) a poison pill dep.
If you put the poison pill in the target of the dynamic import, it errors. If you put it in the deps, it does not.
Working now to move it to not parse/analyze the target either. But this is still a pretty big win if your target itself is not a single massive file.
Summary
Defer compiling client-side dynamic import targets in Turbopack development until the browser requests their manifest chunk. This avoids compiling untouched dynamic imports while preserving server-side imports, CSS loading, Server Actions, source maps, and Fast Refresh behavior.
Lazy chunk activation is limited to emitted assets owned by known entrypoints so arbitrary chunk-shaped requests cannot create permanent activation state.
Verification
pnpm --filter=next typespnpm test-dev-turbo test/development/app-dir/lazy-dynamic-imports/lazy-dynamic-imports.test.ts(Jest stopped during haste-map construction because of unrelated duplicate fixture package names)