Skip to content

perf(turbopack): defer dynamic imports during lazy compilation - #181

Draft
fireairforce wants to merge 10 commits into
utoofrom
zoomdong/perf-lazy-dynamic-imports-core
Draft

perf(turbopack): defer dynamic imports during lazy compilation#181
fireairforce wants to merge 10 commits into
utoofrom
zoomdong/perf-lazy-dynamic-imports-core

Conversation

@fireairforce

Copy link
Copy Markdown
Member

What?

Add an opt-in, framework-agnostic lazy-compilation boundary for client dynamic imports:

  • wrap eligible dynamic-import targets in a lazy proxy module
  • defer target resolution, processing, and chunk generation until its manifest chunk is requested
  • encode a stable activation key in the manifest chunk path
  • give activated imports their own dynamic HMR chunk list
  • keep only the small per-import activation bit across Turbo Tasks eviction

This is adapted from vercel#97203, but intentionally excludes the Next.js configuration, server, NAPI, and framework-specific E2E integration. Utoo performs request activation through its own generic development asset server in utooland/utoo#3298.

Why?

Request-driven output serving alone avoids eagerly materializing every output asset, but Turbopack still discovers and analyzes dynamic-import targets while constructing the initial client graph. Large documentation applications such as Ant Design therefore continue to spend most of startup in module resolution, transforms, and analysis for routes that the browser has not opened.

With this change, those dynamic targets remain unresolved until their runtime manifest is requested.

Ant Design uncached local measurements on the same machine:

  • dev-server ready: approximately 49.5s to 19.6s
  • cold process start to rendered homepage content: approximately 26.7s
  • a subsequently opened Button documentation route: approximately 2.18s

The first page intentionally pays for the dynamic modules it actually uses, but the full start-to-usable path remains substantially faster.

How?

LazyCompilationProxyModule starts without a target reference. Requesting its manifest chunk recovers the activation key, flips the independently memoized LazyCompilationState, and invalidates only that proxy. The activated proxy then resolves the original import and exposes the real target to chunking. Dynamic HMR chunk lists keep subsequent updates scoped to the loaded import.

The outer proxy/module/chunk graph remains evictable. Only the tiny activation state uses evict = "never", matching the lifecycle boundary used by the upstream proposal.

Validation

  • cargo check -p pack-napi
  • Utoo lazy-compilation integration: dynamic loader is not invoked at ready, then runs on first request
  • dynamic asset GET, source map, HEAD, Range and copied-asset coverage
  • WebSocket partial HMR update without full reload
  • fresh-process restart and forced-eviction coverage
  • Ant Design cold startup and real browser rendering measurements

Consumer PR: utooland/utoo#3298
Upstream proposal: vercel#97203

fireairforce and others added 10 commits July 31, 2026 23:23
Squash the 16 utoo-specific commits into one integration commit while preserving the existing tree.
* fix(turbopack): preserve lazy asset graph state

* fix(turbopack): narrow lazy asset state retention
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants